diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index d0444d42e5021..898d94221b8d4 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -318,6 +318,237 @@ describe("cookieDatabaseCandidatePaths", () => { const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; +const zen = BROWSER_IMPORT_SOURCES.find((source) => source.id === "zen")!; + +describe("Zen", () => { + it.effect("resolves Zen's native user-data roots on every supported platform", () => + run( + Effect.gen(function* () { + const darwinContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: "/Users/zen-user" }), + Effect.provideService(HostProcess.Platform, "darwin"), + ); + const windowsContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { + HOME: "C:\\Users\\zen-user", + APPDATA: "C:\\Users\\zen-user\\AppData\\Roaming", + }), + Effect.provideService(HostProcess.Platform, "win32"), + ); + const linuxContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: "/home/zen-user" }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + + assert.equal(zen.name, "Zen"); + assert.equal(zen.engine, "firefox"); + assert.deepEqual([...zen.platforms], ["darwin", "win32", "linux"]); + assert.equal( + zen.userDataDirectory(darwinContext), + darwinContext.path.join("/Users/zen-user", "Library", "Application Support", "zen"), + ); + assert.equal( + zen.userDataDirectory(windowsContext), + windowsContext.path.join("C:\\Users\\zen-user\\AppData\\Roaming", "zen"), + ); + assert.equal( + zen.userDataDirectory(linuxContext), + linuxContext.path.join("/home/zen-user", ".config", "zen"), + ); + assert.deepEqual(zen.alternateLinuxRoots?.(linuxContext), [ + linuxContext.path.join("/home/zen-user", ".zen"), + linuxContext.path.join("/home/zen-user", ".var", "app", "app.zen_browser.zen", "zen"), + ]); + }), + ), + ); + + it.effect("discovers Zen profiles through its Firefox-compatible metadata", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "darwin"), + ); + const root = zen.userDataDirectory(context)!; + yield* fileSystem.makeDirectory(root, { recursive: true }); + const profile = context.path.join(root, "Profiles", "zen.default"); + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 2, 1); + yield* fileSystem.makeDirectory(context.path.join(root, "Profiles", "empty.default"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + "Name=Zen", + "IsRelative=1", + "Path=Profiles/zen.default", + "ZenAvatarPath=chrome://browser/content/zen-avatars/avatar-55.svg", + "Default=1", + "", + "[Profile1]", + "Name=Empty", + "IsRelative=1", + "Path=Profiles/empty.default", + "", + ].join("\n"), + ); + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { + directory: context.path.join("Profiles", "zen.default"), + name: "Zen", + cookieCount: 2, + }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.isFalse(yield* isSourceRunning(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, context.path.join("Profiles", "zen.default")), + context.path.join(profile, "cookies.sqlite"), + ); + }), + ), + ); + + it.effect("discovers Zen profiles in both current and legacy Linux roots", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-linux-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const currentRoot = context.path.join(home, ".config", "zen"); + const legacyRoot = context.path.join(home, ".zen"); + const currentProfile = context.path.join(currentRoot, "current.default"); + const legacyProfile = context.path.join(legacyRoot, "legacy.default"); + + for (const [root, profile, name] of [ + [currentRoot, currentProfile, "Current"], + [legacyRoot, legacyProfile, "Legacy"], + ] as const) { + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 1, 0); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + `Name=${name}`, + "IsRelative=1", + `Path=${context.path.basename(profile)}`, + ].join("\n"), + ); + } + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: "current.default", name: "Current", cookieCount: 1 }, + { directory: legacyProfile, name: "Legacy", cookieCount: 1 }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, legacyProfile), + context.path.join(legacyProfile, "cookies.sqlite"), + ); + + yield* fileSystem.writeFileString( + context.path.join(currentRoot, "profiles.ini"), + `[Profile0]\nName=Shared\nIsRelative=0\nPath=${legacyProfile}\n`, + ); + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: legacyProfile, name: "Shared", cookieCount: 1 }, + ]); + + yield* fileSystem.remove(currentRoot, { recursive: true }); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: legacyProfile, name: "Legacy", cookieCount: 1 }, + ]); + }), + ), + ); + + it.effect("discovers a Flatpak-only Zen installation and resolves its cookies", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-flatpak-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const root = context.path.join(home, ".var", "app", "app.zen_browser.zen", "zen"); + const profile = context.path.join(root, "flatpak.default"); + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 2, 1); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + "[Profile0]\nName=Flatpak\nIsRelative=1\nPath=flatpak.default\n", + ); + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: profile, name: "Flatpak", cookieCount: 2 }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, profile), + context.path.join(profile, "cookies.sqlite"), + ); + }), + ), + ); + + it.effect("does not mistake Firefox Snap installs for Zen profiles", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-snap-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const zenRoot = context.path.join(home, ".config", "zen"); + const snapRoot = context.path.join( + home, + "snap", + "firefox", + "common", + ".mozilla", + "firefox", + ); + const zenProfile = context.path.join(zenRoot, "zen.default"); + const snapProfile = context.path.join(snapRoot, "snap.default"); + for (const root of [zenRoot, snapRoot]) { + const profile = root === zenRoot ? zenProfile : snapProfile; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 1, 0); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + "Name=Personal", + "IsRelative=1", + `Path=${profile === zenProfile ? "zen.default" : "snap.default"}`, + ].join("\n"), + ); + } + + const profiles = yield* listSourceProfiles(zen, context); + assert.deepEqual( + profiles.map((profile) => profile.directory), + ["zen.default"], + ); + assert.isTrue(yield* isSourceInstalled(zen, context)); + }), + ), + ); +}); + describe("Firefox Snap profiles", () => { it.effect.skipIf(!symlinksSupported)( "finds Snap profiles with or without profiles.ini and checks their locks", diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 0bf3df235f946..14c4bca77b7e6 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -26,6 +26,7 @@ export interface BrowserImportSourceDefinition { readonly engine: BrowserImportEngine; readonly platforms: ReadonlyArray; readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; + readonly alternateLinuxRoots?: (context: BrowserImportPathContext) => ReadonlyArray; readonly keychainService?: string; readonly keychainAccount?: string; readonly linuxSecretApplication?: string; @@ -169,6 +170,26 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray [ + context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), + ], + }, + { + id: "zen", + name: "Zen", + engine: "firefox", + platforms: ["darwin", "win32", "linux"], + userDataDirectory: (context) => { + if (context.platform === "darwin") return macApplicationSupport(context, "zen"); + if (context.platform === "win32") { + return context.appData ? context.path.join(context.appData, "zen") : undefined; + } + return context.path.join(context.home, ".config", "zen"); + }, + alternateLinuxRoots: (context) => [ + context.path.join(context.home, ".zen"), + context.path.join(context.home, ".var", "app", "app.zen_browser.zen", "zen"), + ], }, ]; @@ -470,7 +491,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( ); }); -/** Keep Snap profile paths absolute so reads and locks distinguish native and Snap installations. */ +/** Keep alternate-root profile paths absolute so cookie reads and locks use the correct installation. */ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -481,10 +502,7 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf const root = definition.userDataDirectory(context); if (root === undefined) return []; - const roots = [ - root, - context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), - ]; + const roots = [root, ...(definition.alternateLinuxRoots?.(context) ?? [])]; const profiles = new Map(); for (const directory of roots) { const found = yield* listSourceProfilesInDirectory( diff --git a/docs/user/browser-import.md b/docs/user/browser-import.md index 36581c2c7ed27..ab1465e00bfda 100644 --- a/docs/user/browser-import.md +++ b/docs/user/browser-import.md @@ -15,6 +15,6 @@ System Settings permission list, and turn access on. **Continue** becomes availa is detected. macOS may require you to quit and reopen T3 Code before the grant applies; reopen the import wizard afterward. You can revoke Full Disk Access once the import is done. -On Windows, import supports Firefox and Helium profiles that use standard profile encryption. +On Windows, import supports Firefox, Zen, and Helium profiles that use standard profile encryption. Other Chromium-based browsers use app-bound encryption and cannot be imported. Partitioned cookies are skipped on all platforms. diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index 773d38cf6466b..f7f0f65e00e13 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -11,6 +11,7 @@ const BROWSER_IMPORT_SOURCE_IDS = [ "arc", "helium", "firefox", + "zen", "safari", ] as const;