diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 71bcf5f7aef1..e68feb3e8a95 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -14,6 +14,7 @@ import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopUserData from "./DesktopUserData.ts"; const defaultEnvironmentInput = { dirname: "/repo/apps/desktop/dist-electron", @@ -125,13 +126,14 @@ const withIdentity = ( return effect.pipe( Effect.provide( DesktopAppIdentity.layer.pipe( + Layer.provide(NodePath.layerPosix), Layer.provideMerge( FileSystem.layerNoop({ exists: (path) => input.legacyPathProbeError ? Effect.fail(input.legacyPathProbeError) : Effect.succeed( - input.legacyPathExists === true && path.includes("T3 Code (Alpha)"), + input.legacyPathExists === true && /T3 Code \((Alpha|Dev)\)/.test(path), ), readFileString: () => Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), @@ -146,20 +148,36 @@ const withIdentity = ( }; describe("DesktopAppIdentity", () => { - it.effect("keeps using the legacy userData path when it already exists", () => + it.effect("isolates the V2 profile even when the legacy V1 profile exists", () => withIdentity( Effect.gen(function* () { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; const userDataPath = yield* identity.resolveUserDataPath; - assert.equal(userDataPath, "/Users/alice/Library/Application Support/T3 Code (Alpha)"); + assert.equal(userDataPath, "/Users/alice/Library/Application Support/t3code-v2"); }), { legacyPathExists: true }, ), ); + it.effect("keeps using the legacy development profile", () => + withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + assert.equal( + yield* identity.resolveUserDataPath, + "/Users/alice/Library/Application Support/T3 Code (Dev)", + ); + }), + { + legacyPathExists: true, + environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } }, + }, + ), + ); + it.effect("preserves failures while inspecting the legacy userData path", () => { - const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)"; + const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Dev)"; const cause = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -173,15 +191,18 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; const error = yield* identity.resolveUserDataPath.pipe(Effect.flip); - assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathResolutionError); - assert.equal(error.legacyPath, legacyPath); + assert.instanceOf(error, DesktopUserData.DesktopUserDataInitializationError); + assert.equal(error.resourcePath, legacyPath); assert.strictEqual(error.cause, cause); assert.equal( error.message, - `Failed to inspect legacy desktop user-data path at "${legacyPath}".`, + `Could not initialize Electron user data during inspect at ${legacyPath} (PermissionDenied).`, ); }), - { legacyPathProbeError: cause }, + { + legacyPathProbeError: cause, + environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } }, + }, ); }); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 36b3079ad2c4..69fce083d4fb 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -3,12 +3,14 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopUserData from "./DesktopUserData.ts"; const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; @@ -18,22 +20,13 @@ const AppPackageMetadata = Schema.Struct({ }); const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); -export class DesktopUserDataPathResolutionError extends Schema.TaggedError()( - "DesktopUserDataPathResolutionError", - { - legacyPath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to inspect legacy desktop user-data path at "${this.legacyPath}".`; - } -} - export class DesktopAppIdentity extends Context.Service< DesktopAppIdentity, { - readonly resolveUserDataPath: Effect.Effect; + readonly resolveUserDataPath: Effect.Effect< + string, + DesktopUserData.DesktopUserDataInitializationError + >; readonly configure: Effect.Effect; } >()("@t3tools/desktop/app/DesktopAppIdentity") {} @@ -45,33 +38,13 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; -export const resolveUserDataPath = Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const legacyPath = environment.path.join( - environment.appDataDirectory, - environment.legacyUserDataDirName, - ); - const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( - Effect.mapError( - (cause) => - new DesktopUserDataPathResolutionError({ - legacyPath, - cause, - }), - ), - ); - return legacyPathExists - ? legacyPath - : environment.path.join(environment.appDataDirectory, environment.userDataDirName); -}).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); - /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; + const userDataContext = yield* Effect.context(); const commitHashCache = yield* Ref.make>>(Option.none()); const resolveEmbeddedCommitHash = Effect.gen(function* () { @@ -112,10 +85,8 @@ export const make = Effect.gen(function* () { return commitHash; }); - const userDataPath = resolveUserDataPath.pipe( - Effect.provide( - yield* Effect.context(), - ), + const userDataPath = DesktopUserData.resolveUserDataPath(environment).pipe( + Effect.provide(userDataContext), ); const configure = Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 1641149e9e35..b4d0d14ca62e 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -34,9 +35,6 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { stateDir: "/tmp/t3-state", isDevelopment, appDataDirectory: "/tmp/app-data", - userDataDirName: isDevelopment ? "t3code-dev" : "t3code", - legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)", - path: { join: (...parts: ReadonlyArray) => parts.join("/") }, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); const electronApp = { @@ -49,6 +47,7 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { return DesktopClerk.layer.pipe( Layer.provide( Layer.mergeAll( + NodePath.layerPosix, Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), Layer.succeed(ElectronApp.ElectronApp, electronApp), FileSystem.layerNoop({ exists: () => Effect.succeed(false) }), diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 072a1871d986..cc19f9f0e66c 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -11,7 +11,7 @@ import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/rela import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; +import * as DesktopUserData from "./DesktopUserData.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -88,13 +88,8 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const electronApp = yield* ElectronApp.ElectronApp; - // Electron scopes the single-instance lock to the userData directory and - // creates that directory when the lock is acquired. The SDK bridge takes - // the lock at creation, so userData must already point at the real - // directory here — under the default productName-derived path, acquiring - // the lock would create "T3 Code (Alpha)" and make the legacy-install - // detection in resolveUserDataPath match on fresh installs. - const userDataPath = yield* DesktopAppIdentity.resolveUserDataPath; + // The SDK bridge acquires Electron's profile-scoped single-instance lock. + const userDataPath = yield* DesktopUserData.resolveUserDataPath(environment); yield* electronApp.setPath("userData", userDataPath); const bridge = yield* Effect.acquireRelease( diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 5ded307da128..0e5fbecd0224 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -112,8 +112,6 @@ describe("DesktopEnvironment", () => { assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); - assert.equal(environment.userDataDirName, "t3code"); - assert.equal(environment.legacyUserDataDirName, "T3 Code (Alpha)"); assert.equal(environment.otlpProtocol, "http/json"); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index e7a489d5e89d..e31082979b32 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -82,8 +82,6 @@ export class DesktopEnvironment extends Context.Service< readonly linuxWmClass: string; readonly linuxApplicationsDir: string; readonly appImagePath: Option.Option; - readonly userDataDirName: string; - readonly legacyUserDataDirName: string; readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; readonly runtimeInfo: DesktopRuntimeInfo; readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; @@ -184,8 +182,6 @@ const make = Effect.fn("desktop.environment.make")(function* ( joinPath: path.join, t3Home: config.t3Home, }); - const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; - const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; const linuxApplicationsDir = path.join( Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")), "applications", @@ -239,8 +235,6 @@ const make = Effect.fn("desktop.environment.make")(function* ( linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", linuxApplicationsDir, appImagePath: config.appImagePath, - userDataDirName, - legacyUserDataDirName, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), runtimeInfo: resolveDesktopRuntimeInfo({ platform: input.platform, diff --git a/apps/desktop/src/app/DesktopUserData.test.ts b/apps/desktop/src/app/DesktopUserData.test.ts new file mode 100644 index 000000000000..374948ec921d --- /dev/null +++ b/apps/desktop/src/app/DesktopUserData.test.ts @@ -0,0 +1,75 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import { resolveUserDataPath } from "./DesktopUserData.ts"; + +it.effect("identifies a failed source read and preserves its cause", () => { + const sourceState = "/profiles/t3code/Local State"; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: sourceState, + }); + return Effect.gen(function* () { + const error = yield* resolveUserDataPath({ + appDataDirectory: "/profiles", + isDevelopment: false, + platform: "win32", + }).pipe(Effect.flip); + assert.equal(error.operation, "read"); + assert.equal(error.resourcePath, sourceState); + assert.equal(error.category, "PermissionDenied"); + assert.strictEqual(error.cause, cause); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (path) => Effect.succeed(path === sourceState), + readFileString: () => Effect.fail(cause), + }), + ), + Effect.provide(NodeServices.layer), + ); +}); + +for (const sourceName of ["t3code", "T3 Code (Alpha)"]) { + it.effect( + `preserves Windows credential keys from ${sourceName} without copying browser databases`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-profile-" }); + const source = path.join(directory, sourceName); + const destination = path.join(directory, "t3code-v2"); + const state = '{"os_crypt":{"encrypted_key":"test-encrypted-key"}}'; + yield* fs.makeDirectory(path.join(directory, "T3 Code (Alpha)"), { recursive: true }); + yield* fs.makeDirectory(path.join(source, "IndexedDB"), { recursive: true }); + yield* fs.writeFileString(path.join(source, "Local State"), state); + yield* fs.writeFileString(path.join(source, "IndexedDB", "LOCK"), "V1 owns this database"); + yield* resolveUserDataPath({ + appDataDirectory: directory, + isDevelopment: false, + platform: "win32", + }); + assert.equal(yield* fs.readFileString(path.join(destination, "Local State")), state); + assert.equal(yield* fs.readFileString(path.join(source, "Local State")), state); + assert.isFalse(yield* fs.exists(path.join(destination, "IndexedDB"))); + yield* fs.writeFileString(path.join(destination, "Local State"), "existing V2 state"); + yield* resolveUserDataPath({ + appDataDirectory: directory, + isDevelopment: false, + platform: "win32", + }); + assert.equal( + yield* fs.readFileString(path.join(destination, "Local State")), + "existing V2 state", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} diff --git a/apps/desktop/src/app/DesktopUserData.ts b/apps/desktop/src/app/DesktopUserData.ts new file mode 100644 index 000000000000..3306b244dc72 --- /dev/null +++ b/apps/desktop/src/app/DesktopUserData.ts @@ -0,0 +1,98 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +export class DesktopUserDataInitializationError extends Schema.TaggedError()( + "DesktopUserDataInitializationError", + { + operation: Schema.Literals(["inspect", "read", "create-directory", "write"]), + resourcePath: Schema.String, + category: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Could not initialize Electron user data during ${this.operation} at ${this.resourcePath} (${this.category}).`; + } + + static fromFileSystem( + cause: PlatformError.PlatformError, + operation: DesktopUserDataInitializationError["operation"], + resourcePath: string, + ) { + return new DesktopUserDataInitializationError({ + operation, + resourcePath, + category: cause.reason._tag, + cause, + }); + } +} + +/** Select Electron's profile independently of the server's T3 home. */ +export const resolveUserDataPath = Effect.fn("desktop.userData.resolveUserDataPath")( + function* (input: { + readonly appDataDirectory: string; + readonly isDevelopment: boolean; + readonly platform: NodeJS.Platform; + }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const names = input.isDevelopment + ? { current: "t3code-dev", legacy: "T3 Code (Dev)" } + : { current: "t3code-v2", legacy: "T3 Code (Alpha)" }; + const destinationPath = path.join(input.appDataDirectory, names.current); + const legacyPath = path.join(input.appDataDirectory, names.legacy); + const inspect = (resourcePath: string) => + fs + .exists(resourcePath) + .pipe( + Effect.mapError((cause) => + DesktopUserDataInitializationError.fromFileSystem(cause, "inspect", resourcePath), + ), + ); + if (input.isDevelopment) { + return (yield* inspect(legacyPath)) ? legacyPath : destinationPath; + } + // Chromium databases require their own profile for each running version. + if (input.platform !== "win32") return destinationPath; + const destinationState = path.join(destinationPath, "Local State"); + if (yield* inspect(destinationState)) return destinationPath; + const legacyState = path.join(legacyPath, "Local State"); + const sourceState = (yield* inspect(legacyState)) + ? legacyState + : path.join(input.appDataDirectory, "t3code", "Local State"); + if (!(yield* inspect(sourceState))) return destinationPath; + // Windows safeStorage keys live here. Copy only these preferences, never locked databases. + const state = yield* fs + .readFileString(sourceState) + .pipe( + Effect.mapError((cause) => + DesktopUserDataInitializationError.fromFileSystem(cause, "read", sourceState), + ), + ); + yield* fs + .makeDirectory(destinationPath, { recursive: true }) + .pipe( + Effect.mapError((cause) => + DesktopUserDataInitializationError.fromFileSystem( + cause, + "create-directory", + destinationPath, + ), + ), + ); + yield* fs.writeFileString(destinationState, state, { flag: "wx" }).pipe( + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.void, + ), + Effect.mapError((cause) => + DesktopUserDataInitializationError.fromFileSystem(cause, "write", destinationState), + ), + ); + return destinationPath; + }, +); diff --git a/docs/user/thread-migration.md b/docs/user/thread-migration.md index bbff8ad22610..030322ce67a8 100644 --- a/docs/user/thread-migration.md +++ b/docs/user/thread-migration.md @@ -4,9 +4,13 @@ On your first V2 launch, T3 Code copies the V1 database, `state.sqlite`, into `s in the same data directory and migrates the copy. Your threads appear automatically, with full transcripts imported as needed. You do not need to run an import command. -You can close V2 and return to V1, which continues using its original database. Opening V2 again -resumes your V2 history. The copy happens only once: later conversations and changes in either -version do not sync to the other. Settings, attachments, and workspace files remain shared. +V1 continues using its original database while V2 uses the copy. The database import can run while +V1 is open. Opening V2 again resumes your V2 history. The copy happens only once: later conversations +and changes in either version do not sync to the other. Settings, attachments, and workspace files +remain shared. + +The V2 desktop app uses a separate browser profile, so browser cookies and caches do not carry +over from V1. You may need to sign in again to websites opened inside the app. The migrated thread keeps its title, project, provider and model selection, permission and interaction modes, branch or worktree, archive state, settlement state, snooze and pin state, and