authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 16:57:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 16:57:10-07:00
log3653cb22ffb30bf133fecc30c955941fe267cf1c
treeb99c71cbafa2738953d1c9d6fc278f1df7afc202
parent8ea52c8f2f4361e875d72ab30d72d37cfae4d87f
signature Signed by PGP key B5690EEEBB952194

fix(desktop): address the browser import review left over from the stack (#9516)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com>

9 files changed, 385 insertions(+), 26 deletions(-)

apps/desktop/src/preview/BrowserImport/BrowserImport.ts+4-7
...@@ -206,13 +206,10 @@ export const make = Effect.gen(function* BrowserImportMake() {...@@ -206,13 +206,10 @@ export const make = Effect.gen(function* BrowserImportMake() {
206 return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked });206 return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked });
207 }207 }
208208
209 // macOS attributes the Keychain prompt and the resulting ACL grant to the209 if (platform === "darwin" && definition.engine === "chromium") {
210 // executable that asks, so record which one that was — in a packaged build210 // macOS attributes the Keychain prompt and the resulting ACL grant to the
211 // it is the signed app, in dev whatever binary hosts the main process.211 // executable that asks, so record which one that was — in a packaged build
212 // Only Chromium reads a key; a Firefox import touches no keychain, and212 // it is the signed app, in dev whatever binary hosts the main process.
213 // logging that it did would put a false security-sensitive event in the
214 // audit trail.
215 if (definition.engine === "chromium") {
216 yield* Effect.logInfo("Reading browser cookie key from the keychain", {213 yield* Effect.logInfo("Reading browser cookie key from the keychain", {
217 sourceId: definition.id,214 sourceId: definition.id,
218 executablePath,215 executablePath,
apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts+2-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1// @effect-diagnostics nodeBuiltinImport:off - Encrypts a fixture with the same1// @effect-diagnostics nodeBuiltinImport:off - Encrypts fixtures with the same
2// OSCrypt primitive as Chromium.2// OSCrypt primitives the module under test decrypts.
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
5import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";5import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
apps/desktop/src/preview/BrowserImport/Sources.test.ts+221
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";11import * as FileSystem from "effect/FileSystem";
12import * as Path from "effect/Path";12import * as Path from "effect/Path";
13import * as PlatformError from "effect/PlatformError";
13import * as Scope from "effect/Scope";14import * as Scope from "effect/Scope";
14import * as Stream from "effect/Stream";15import * as Stream from "effect/Stream";
15import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";16import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
...@@ -25,6 +26,7 @@ import {...@@ -25,6 +26,7 @@ import {
25 resolveCookieDatabase,26 resolveCookieDatabase,
26 isSourceInstalled,27 isSourceInstalled,
27 isSourceRunning,28 isSourceRunning,
29 isWindowsLockHeldError,
28 posixLockIsHeld,30 posixLockIsHeld,
29 listSourceProfiles,31 listSourceProfiles,
30 sourcePathContext,32 sourcePathContext,
...@@ -54,6 +56,23 @@ describe("Linux Chromium secret applications", () => {...@@ -54,6 +56,23 @@ describe("Linux Chromium secret applications", () => {
54 });56 });
55});57});
5658
59const platformError = (reasonTag: string): PlatformError.PlatformError =>
60 ({ _tag: "PlatformError", reason: { _tag: reasonTag } }) as never;
61
62describe("Windows browser lock errors", () => {
63 it("treats sharing and lock violations reported as Busy as held", () => {
64 assert.isTrue(isWindowsLockHeldError(platformError("Busy")));
65 });
66
67 it("does not treat access denied as proof of an active lock", () => {
68 assert.isFalse(isWindowsLockHeldError(platformError("PermissionDenied")));
69 });
70
71 it("does not treat a missing lock file as held", () => {
72 assert.isFalse(isWindowsLockHeldError(platformError("NotFound")));
73 });
74});
75
57/** A scratch home with the source's user-data directory already created. */76/** A scratch home with the source's user-data directory already created. */
58const withSourceHome = Effect.fnUntraced(function* () {77const withSourceHome = Effect.fnUntraced(function* () {
59 const fileSystem = yield* FileSystem.FileSystem;78 const fileSystem = yield* FileSystem.FileSystem;
...@@ -352,6 +371,20 @@ describe("isSourceInstalled", () => {...@@ -352,6 +371,20 @@ describe("isSourceInstalled", () => {
352 ),371 ),
353 );372 );
354373
374 it.effect("detects a Chromium 127+ install with cookies under Network/", () =>
375 run(
376 Effect.gen(function* () {
377 const fileSystem = yield* FileSystem.FileSystem;
378 const context = yield* withSourceHome();
379 const root = userDataDirectory(context);
380
381 yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true });
382 yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db");
383 assert.isTrue(yield* isSourceInstalled(helium, context));
384 }),
385 ),
386 );
387
355 it.effect("follows cookie database symlinks when detecting profiles", () =>388 it.effect("follows cookie database symlinks when detecting profiles", () =>
356 run(389 run(
357 Effect.gen(function* () {390 Effect.gen(function* () {
...@@ -460,6 +493,79 @@ describe("listSourceProfiles", () => {...@@ -460,6 +493,79 @@ describe("listSourceProfiles", () => {
460 ),493 ),
461 );494 );
462495
496 it.effect("drops Firefox profiles that hold no cookie database", () =>
497 run(
498 Effect.gen(function* () {
499 const fileSystem = yield* FileSystem.FileSystem;
500 const context = yield* withSourceHome();
501 const root = firefox.userDataDirectory(context)!;
502 yield* fileSystem.makeDirectory(root, { recursive: true });
503 yield* fileSystem.writeFileString(
504 `${root}/profiles.ini`,
505 `[Profile0]
506Name=original
507IsRelative=1
508Path=Profiles/abcd.default-release
509Default=1
510
511[Profile1]
512Name=empty
513IsRelative=1
514Path=Profiles/wxyz.empty
515`,
516 );
517 yield* fileSystem.makeDirectory(`${root}/Profiles/abcd.default-release`, {
518 recursive: true,
519 });
520 yield* fileSystem.writeFileString(
521 `${root}/Profiles/abcd.default-release/cookies.sqlite`,
522 "db",
523 );
524 yield* fileSystem.makeDirectory(`${root}/Profiles/wxyz.empty`, { recursive: true });
525
526 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
527 { directory: "Profiles/abcd.default-release", name: "original" },
528 ]);
529 }),
530 ),
531 );
532
533 it.effect("drops empty profiles when falling back to the Profiles/ scan", () =>
534 run(
535 Effect.gen(function* () {
536 const fileSystem = yield* FileSystem.FileSystem;
537 const context = yield* withSourceHome();
538 const root = firefox.userDataDirectory(context)!;
539 yield* fileSystem.makeDirectory(`${root}/Profiles/filled.default`, { recursive: true });
540 yield* fileSystem.writeFileString(`${root}/Profiles/filled.default/cookies.sqlite`, "db");
541 yield* fileSystem.makeDirectory(`${root}/Profiles/empty.default`, { recursive: true });
542
543 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
544 {
545 directory: context.path.join("Profiles", "filled.default"),
546 name: "filled.default",
547 },
548 ]);
549 }),
550 ),
551 );
552
553 it.effect("discovers profiles with cookies under Network/ (Chromium 127+)", () =>
554 run(
555 Effect.gen(function* () {
556 const fileSystem = yield* FileSystem.FileSystem;
557 const context = yield* withSourceHome();
558 const root = userDataDirectory(context);
559 yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true });
560 yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db");
561
562 assert.deepEqual(yield* listSourceProfiles(helium, context), [
563 { directory: "Default", name: "Default" },
564 ]);
565 }),
566 ),
567 );
568
463 it.effect("counts a profile's cookies without decrypting them", () =>569 it.effect("counts a profile's cookies without decrypting them", () =>
464 run(570 run(
465 Effect.gen(function* () {571 Effect.gen(function* () {
...@@ -474,6 +580,24 @@ describe("listSourceProfiles", () => {...@@ -474,6 +580,24 @@ describe("listSourceProfiles", () => {
474 }),580 }),
475 ),581 ),
476 );582 );
583
584 it.effect("falls through to the legacy database when Network/Cookies is a directory", () =>
585 run(
586 Effect.gen(function* () {
587 const fileSystem = yield* FileSystem.FileSystem;
588 const paths = yield* withSourceHome();
589 const root = helium.userDataDirectory(paths);
590 // A folder squatting on the preferred candidate path must not shadow
591 // the real legacy database behind it.
592 yield* fileSystem.makeDirectory(`${root}/Default/Network/Cookies`, { recursive: true });
593 yield* writeCookieDatabase(`${root}/Default/Cookies`, 2);
594
595 const [profile] = yield* listSourceProfiles(helium, paths);
596 assert.equal(profile?.directory, "Default");
597 assert.equal(profile?.cookieCount, 2);
598 }),
599 ),
600 );
477});601});
478602
479describe("cookieDatabaseCandidatePaths", () => {603describe("cookieDatabaseCandidatePaths", () => {
...@@ -512,6 +636,25 @@ describe("cookieDatabaseCandidatePaths", () => {...@@ -512,6 +636,25 @@ describe("cookieDatabaseCandidatePaths", () => {
512 }),636 }),
513 ),637 ),
514 );638 );
639
640 it.effect("returns only cookies.sqlite for Firefox", () =>
641 run(
642 Effect.gen(function* () {
643 const path = yield* Path.Path;
644 const context = yield* sourcePathContext.pipe(
645 Effect.provideService(HostProcessEnvironment, { HOME: "/tmp/test" }),
646 Effect.provideService(HostProcessPlatform, "darwin"),
647 );
648 const candidates = cookieDatabaseCandidatePaths(firefox, context, "Profiles/abc.default");
649 assert.deepEqual(candidates, [
650 path.join(
651 "/tmp/test",
652 "Library/Application Support/Firefox/Profiles/abc.default/cookies.sqlite",
653 ),
654 ]);
655 }),
656 ),
657 );
515});658});
516659
517const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!;660const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!;
...@@ -642,6 +785,41 @@ describe("listSourceProfiles Firefox fallback", () => {...@@ -642,6 +785,41 @@ describe("listSourceProfiles Firefox fallback", () => {
642 );785 );
643 }786 }
644787
788 it.effect("scans for profiles when profiles.ini declares only ones without cookies", () =>
789 run(
790 Effect.gen(function* () {
791 const fileSystem = yield* FileSystem.FileSystem;
792 const path = yield* Path.Path;
793 const home = yield* fileSystem.makeTempDirectoryScoped({
794 prefix: "t3code-firefox-stale-ini-",
795 });
796 const context = yield* sourcePathContext.pipe(
797 Effect.provideService(HostProcessEnvironment, { HOME: home }),
798 Effect.provideService(HostProcessPlatform, "darwin"),
799 );
800 const root = firefox.userDataDirectory(context)!;
801 // `profiles.ini` names a profile that was never launched (no cookie
802 // database), while the real cookies sit in an undeclared one.
803 yield* fileSystem.makeDirectory(path.join(root, "Profiles", "stale.default"), {
804 recursive: true,
805 });
806 const realDirectory = path.join(root, "Profiles", "real.default");
807 yield* fileSystem.makeDirectory(realDirectory, { recursive: true });
808 yield* writeFirefoxCookieDatabase(path.join(realDirectory, "cookies.sqlite"), 3, 0);
809 yield* fileSystem.writeFileString(
810 path.join(root, "profiles.ini"),
811 ["[Profile0]", "Name=Stale", "IsRelative=1", "Path=Profiles/stale.default"].join("\n"),
812 );
813
814 // Returning the empty declared list would hide the browser entirely.
815 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
816 { directory: "Profiles/real.default", name: "real.default", cookieCount: 3 },
817 ]);
818 assert.isTrue(yield* isSourceInstalled(firefox, context));
819 }),
820 ),
821 );
822
645 it.effect("counts only importable cookies for declared and fallback profiles", () =>823 it.effect("counts only importable cookies for declared and fallback profiles", () =>
646 run(824 run(
647 Effect.gen(function* () {825 Effect.gen(function* () {
...@@ -809,6 +987,49 @@ describe("isSourceRunning for Firefox", () => {...@@ -809,6 +987,49 @@ describe("isSourceRunning for Firefox", () => {
809 assert.isTrue(yield* firefoxSymlinkLockIsHeld("10.0.0.7:+9999", local, alive));987 assert.isTrue(yield* firefoxSymlinkLockIsHeld("10.0.0.7:+9999", local, alive));
810 }),988 }),
811 );989 );
990
991 it.effect("does not treat a stale parent.lock file as a running browser", () =>
992 run(
993 Effect.gen(function* () {
994 const fileSystem = yield* FileSystem.FileSystem;
995 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" });
996 const context = yield* sourcePathContext.pipe(
997 // Firefox's win32 root hangs off %APPDATA%; without it the root is
998 // undefined and the fixture would escape the sandbox into the repo.
999 Effect.provideService(HostProcessEnvironment, {
1000 HOME: home,
1001 APPDATA: `${home}/AppData/Roaming`,
1002 }),
1003 Effect.provideService(HostProcessPlatform, "win32"),
1004 );
1005 const root = firefox.userDataDirectory(context)!;
1006 const profile = `${root}/Profiles/gx7x7fqx.default-release`;
1007 yield* fileSystem.makeDirectory(profile, { recursive: true });
1008 yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db");
1009
1010 // On Windows, Firefox creates parent.lock as a regular file that
1011 // persists after the process exits. The file is only locked while
1012 // Firefox is running; the old stat-based check always found it.
1013 yield* fileSystem.writeFileString(`${profile}/parent.lock`, "");
1014 assert.isFalse(yield* isSourceRunning(firefox, context));
1015 }),
1016 ),
1017 );
1018});
1019
1020describe("Windows user-data directories", () => {
1021 it.effect("keeps app-bound Chromium forks unsupported on win32", () =>
1022 Effect.sync(() => {
1023 // Helium retains the older DPAPI-backed store. Other Chromium forks use
1024 // App-Bound Encryption, so omitting win32 makes `unavailableReason`
1025 // report `unsupportedPlatform` and keeps them out of the menu.
1026 for (const source of BROWSER_IMPORT_SOURCES) {
1027 if (source.engine === "chromium" && source.id !== "helium") {
1028 assert.notInclude(source.platforms, "win32");
1029 }
1030 }
1031 }),
1032 );
812});1033});
8131034
814describe("listSourceProfiles hardening", () => {1035describe("listSourceProfiles hardening", () => {
apps/desktop/src/preview/BrowserImport/Sources.ts+47-10
...@@ -109,6 +109,10 @@ const chromiumSource = (input: {...@@ -109,6 +109,10 @@ const chromiumSource = (input: {
109});109});
110110
111export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [111export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
112 // No Chromium fork is importable on Windows: since Chrome 127 their cookies
113 // are encrypted to the browser's own identity (App-Bound Encryption), so no
114 // other process can read them. macOS and Linux keep working, so only the
115 // Windows segments are omitted.
112 chromiumSource({116 chromiumSource({
113 id: "chrome",117 id: "chrome",
114 name: "Chrome",118 name: "Chrome",
...@@ -198,6 +202,10 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition...@@ -198,6 +202,10 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
198 * and a fresh install with only `Network/Cookies` would read as not installed.202 * and a fresh install with only `Network/Cookies` would read as not installed.
199 * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini`203 * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini`
200 * may already be absolute.204 * may already be absolute.
205 *
206 * Chrome 96+ moved network-related files (including Cookies) into a `Network`
207 * subdirectory for sandboxing. The candidate list includes both locations so
208 * callers tolerate fresh and legacy installs alike.
201 */209 */
202export const cookieDatabaseCandidatePaths = (210export const cookieDatabaseCandidatePaths = (
203 definition: BrowserImportSourceDefinition,211 definition: BrowserImportSourceDefinition,
...@@ -206,11 +214,18 @@ export const cookieDatabaseCandidatePaths = (...@@ -206,11 +214,18 @@ export const cookieDatabaseCandidatePaths = (
206): ReadonlyArray<string> => {214): ReadonlyArray<string> => {
207 const root = definition.userDataDirectory(context);215 const root = definition.userDataDirectory(context);
208 if (root === undefined) return [];216 if (root === undefined) return [];
209 const profile = context.path.isAbsolute(profileDirectory)217 const profilePath = context.path.isAbsolute(profileDirectory)
210 ? profileDirectory218 ? profileDirectory
211 : context.path.join(root, profileDirectory);219 : context.path.join(root, profileDirectory);
212 if (definition.engine === "firefox") return [context.path.join(profile, "cookies.sqlite")];220 if (definition.engine === "firefox") {
213 return [context.path.join(profile, "Network", "Cookies"), context.path.join(profile, "Cookies")];221 return [context.path.join(profilePath, "cookies.sqlite")];
222 }
223 // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade
224 // leaves the legacy file behind, so prefer the current one and fall back.
225 return [
226 context.path.join(profilePath, "Network", "Cookies"),
227 context.path.join(profilePath, "Cookies"),
228 ];
214};229};
215230
216/** The first candidate that is a regular file, or undefined when none is. */231/** The first candidate that is a regular file, or undefined when none is. */
...@@ -393,16 +408,35 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* (...@@ -393,16 +408,35 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* (
393 Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)),408 Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)),
394 Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),409 Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
395 );410 );
396 if (declared.length > 0) return yield* withCookieCounts(definition, context, declared);411 // `profiles.ini` also lists profiles the installer created but the user
412 // never launched, which hold no cookie database and nothing to import.
413 // Only keep the ones a database proves exist, like the directory scans
414 // below do. When none of the declared profiles has one, fall through to
415 // the scan rather than returning empty: `profiles.ini` can list stale or
416 // never-launched profiles while the cookies live in one it does not
417 // mention, and an empty answer here hides the browser entirely.
418 if (declared.length > 0) {
419 const found = yield* Effect.forEach(declared, (profile) =>
420 Effect.forEach(
421 cookieDatabaseCandidatePaths(definition, context, profile.directory),
422 (candidate) => databaseFileExists(candidate),
423 ).pipe(Effect.map((results) => (results.some(Boolean) ? profile : undefined))),
424 );
425 const withDatabase = found.filter((profile) => profile !== undefined);
426 if (withDatabase.length > 0) {
427 return yield* withCookieCounts(definition, context, withDatabase);
428 }
429 }
397430
398 // Linux keeps profile directories directly under the Firefox root. macOS431 // No usable `profiles.ini`, so fall back to scanning the directory the
399 // and Windows put them in `Profiles/`.432 // profiles actually live in, keeping only the ones a cookie database
433 // proves were launched.
400 const fallbackDirectory =434 const fallbackDirectory =
401 context.platform === "linux" ? root : context.path.join(root, "Profiles");435 context.platform === "linux" ? root : context.path.join(root, "Profiles");
402 const entries = yield* fileSystem436 const scanned = yield* fileSystem
403 .readDirectory(fallbackDirectory)437 .readDirectory(fallbackDirectory)
404 .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));438 .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));
405 const found = yield* Effect.forEach(entries, (entry) => {439 const found = yield* Effect.forEach(scanned, (entry) => {
406 const directory = context.platform === "linux" ? entry : context.path.join("Profiles", entry);440 const directory = context.platform === "linux" ? entry : context.path.join("Profiles", entry);
407 return resolveCookieDatabase(definition, context, directory).pipe(441 return resolveCookieDatabase(definition, context, directory).pipe(
408 Effect.map((database) => (database === undefined ? undefined : { directory, name: entry })),442 Effect.map((database) => (database === undefined ? undefined : { directory, name: entry })),
...@@ -739,8 +773,11 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")...@@ -739,8 +773,11 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")
739 const root = definition.userDataDirectory(context);773 const root = definition.userDataDirectory(context);
740 if (root === undefined) return false;774 if (root === undefined) return false;
741 // Probe the source's own lock state rather than scanning the process table.775 // Probe the source's own lock state rather than scanning the process table.
742 // Chromium exposes that through the cookie jar on Windows and through its776 // Chromium exposes its lock through the cookie jar on Windows and through a
743 // user-data SingletonLock on POSIX.777 // user-data SingletonLock on POSIX. Firefox keeps its locks inside each
778 // profile under three names across platforms (`lock` on macOS and Linux,
779 // `.parentlock` beside it, `parent.lock` on Windows). Looking for Firefox's
780 // at the root finds nothing and reports a running browser as importable.
744 if (definition.engine !== "firefox") {781 if (definition.engine !== "firefox") {
745 if (context.platform === "win32") {782 if (context.platform === "win32") {
746 return yield* windowsChromiumCookiesAreHeld(definition, context);783 return yield* windowsChromiumCookiesAreHeld(definition, context);
apps/web/src/components/settings/IntegrationsSettings.tsx+5-4
...@@ -781,10 +781,11 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -781,10 +781,11 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
781 setProfilePendingRemoval(null);781 setProfilePendingRemoval(null);
782 };782 };
783783
784 // A browser that is not on this machine, or that this platform cannot import784 // A browser that is not on this machine is left out rather than listed as a
785 // from at all, is left out rather than listed as a dead row: there is nothing785 // dead row: there is nothing to act on, and the menu is a list of things you
786 // to act on, and the menu is a list of things you can import from. Every786 // can import from. An unsupported one is left out for the same reason — the
787 // other unavailable reason stays, since each names a step the user can take.787 // blocked wizard step can't be fixed from here. Every other unavailable
788 // reason stays, since each names a step the user can take.
788 const importableSources = (sources ?? []).filter(789 const importableSources = (sources ?? []).filter(
789 (source) =>790 (source) =>
790 source.unavailable !== "notInstalled" && source.unavailable !== "unsupportedPlatform",791 source.unavailable !== "notInstalled" && source.unavailable !== "unsupportedPlatform",
native/browser-secret/main.c+4-1
...@@ -5,9 +5,12 @@...@@ -5,9 +5,12 @@
5 * with no newline or other framing that could change the derived cookie key. */5 * with no newline or other framing that could change the derived cookie key. */
6enum { KEY_MISSING = 2, KEY_LOCKED = 3, READ_FAILED = 4 };6enum { KEY_MISSING = 2, KEY_LOCKED = 3, READ_FAILED = 4 };
77
8/* Chromium looks its key up by attributes alone: items written by older
9 * libgnome-keyring builds carry no schema name, and SECRET_SCHEMA_NONE would
10 * refuse them. DONT_MATCH_NAME keeps the attribute match and drops the name. */
8static const SecretSchema chromium_schema = {11static const SecretSchema chromium_schema = {
9 .name = "chrome_libsecret_os_crypt_password_v2",12 .name = "chrome_libsecret_os_crypt_password_v2",
10 .flags = SECRET_SCHEMA_NONE,13 .flags = SECRET_SCHEMA_DONT_MATCH_NAME,
11 .attributes = {{"application", SECRET_SCHEMA_ATTRIBUTE_STRING}, {NULL, 0}},14 .attributes = {{"application", SECRET_SCHEMA_ATTRIBUTE_STRING}, {NULL, 0}},
12};15};
1316
native/browser-secret/test.c+1-1
...@@ -11,7 +11,7 @@ GList *__wrap_secret_service_search_sync(SecretService *service, const SecretSch...@@ -11,7 +11,7 @@ GList *__wrap_secret_service_search_sync(SecretService *service, const SecretSch
11 g_assert_null(service);11 g_assert_null(service);
12 g_assert_null(cancellable);12 g_assert_null(cancellable);
13 g_assert_cmpstr(schema->name, ==, "chrome_libsecret_os_crypt_password_v2");13 g_assert_cmpstr(schema->name, ==, "chrome_libsecret_os_crypt_password_v2");
14 g_assert_cmpint(schema->flags, ==, SECRET_SCHEMA_NONE);14 g_assert_cmpint(schema->flags, ==, SECRET_SCHEMA_DONT_MATCH_NAME);
15 g_assert_cmpint(flags, ==, SECRET_SEARCH_UNLOCK | SECRET_SEARCH_LOAD_SECRETS);15 g_assert_cmpint(flags, ==, SECRET_SEARCH_UNLOCK | SECRET_SEARCH_LOAD_SECRETS);
16 g_assert_cmpuint(g_hash_table_size(attributes), ==, 1);16 g_assert_cmpuint(g_hash_table_size(attributes), ==, 1);
17 scenario = g_intern_string(g_hash_table_lookup(attributes, "application"));17 scenario = g_intern_string(g_hash_table_lookup(attributes, "application"));
scripts/build-desktop-artifact.test.ts+80
...@@ -66,6 +66,8 @@ import {...@@ -66,6 +66,8 @@ import {
66 STAGE_INSTALL_ARGS,66 STAGE_INSTALL_ARGS,
67 ancestorNodeModulesPaths,67 ancestorNodeModulesPaths,
68 copyDirectoryPreservingSymlinks,68 copyDirectoryPreservingSymlinks,
69 LinuxBrowserSecretHostError,
70 stageBrowserSecret,
69 validateWindowsPackagedPayload,71 validateWindowsPackagedPayload,
70 WindowsPrimaryNativeProbeError,72 WindowsPrimaryNativeProbeError,
71 WindowsDesktopBuildPrerequisitesMissingError,73 WindowsDesktopBuildPrerequisitesMissingError,
...@@ -1207,6 +1209,84 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1207,6 +1209,84 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1207 );1209 );
1208 });1210 });
12091211
1212 it.effect("builds the Linux browser secret helper for a concrete architecture", () => {
1213 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = [];
1214 const spawnerLayer = Layer.succeed(
1215 ChildProcessSpawner.ChildProcessSpawner,
1216 ChildProcessSpawner.make((command) => {
1217 commands.push(command as unknown as (typeof commands)[number]);
1218 return Effect.succeed(mockProcess(0));
1219 }),
1220 );
1221
1222 return Effect.gen(function* () {
1223 // `universal` is a mac-only arch the option type still admits. The helper
1224 // script only knows x64 and arm64, so the request maps to x64, the same
1225 // concrete target the Linux resource monitor resolves it to.
1226 yield* stageBrowserSecret({
1227 repoRoot: "/repo",
1228 stageResourcesDir: "/stage/resources",
1229 platform: "linux",
1230 arch: "universal",
1231 verbose: false,
1232 });
1233 const helper = commands.find((command) =>
1234 command.args.some((arg) => arg.endsWith("build-browser-secret.mjs")),
1235 );
1236 assert.isDefined(helper);
1237 assert.deepStrictEqual(helper.args.slice(-4), [
1238 "--arch",
1239 "x64",
1240 "--output",
1241 "/stage/resources/browser-secret/t3-browser-secret",
1242 ]);
1243 }).pipe(
1244 Effect.provide(
1245 Layer.mergeAll(
1246 spawnerLayer,
1247 Layer.succeed(HostProcessPlatform, "linux"),
1248 Layer.succeed(HostProcessArchitecture, "x64"),
1249 ),
1250 ),
1251 );
1252 });
1253
1254 it.effect("refuses a Linux build on a host that cannot build the browser secret helper", () => {
1255 const commands: Array<{ readonly command: string }> = [];
1256 const spawnerLayer = Layer.succeed(
1257 ChildProcessSpawner.ChildProcessSpawner,
1258 ChildProcessSpawner.make((command) => {
1259 commands.push(command as unknown as (typeof commands)[number]);
1260 return Effect.succeed(mockProcess(0));
1261 }),
1262 );
1263
1264 return Effect.gen(function* () {
1265 // The helper links against the host's libsecret and its build script is
1266 // a no-op elsewhere, so a Linux artifact built on macOS would ship
1267 // without it and report the keyring as unavailable on every import.
1268 const error = yield* stageBrowserSecret({
1269 repoRoot: "/repo",
1270 stageResourcesDir: "/stage/resources",
1271 platform: "linux",
1272 arch: "x64",
1273 verbose: false,
1274 }).pipe(Effect.flip);
1275 assert.instanceOf(error, LinuxBrowserSecretHostError);
1276 assert.equal(error.hostPlatform, "darwin");
1277 assert.include(error.message, "Linux host");
1278 assert.lengthOf(commands, 0);
1279 }).pipe(
1280 Effect.provide(
1281 Layer.mergeAll(
1282 spawnerLayer,
1283 Layer.succeed(HostProcessPlatform, "darwin"),
1284 Layer.succeed(HostProcessArchitecture, "arm64"),
1285 ),
1286 ),
1287 );
1288 });
1289
1210 it.effect("skips the primary native probe for cross-architecture Windows payloads", () => {1290 it.effect("skips the primary native probe for cross-architecture Windows payloads", () => {
1211 const commands: Array<{1291 const commands: Array<{
1212 readonly command: string;1292 readonly command: string;
scripts/build-desktop-artifact.ts+21-1
...@@ -447,6 +447,15 @@ const desktopIconPlatformNames = {...@@ -447,6 +447,15 @@ const desktopIconPlatformNames = {
447 win: "Windows",447 win: "Windows",
448} satisfies Record<typeof BuildPlatform.Type, string>;448} satisfies Record<typeof BuildPlatform.Type, string>;
449449
450export class LinuxBrowserSecretHostError extends Schema.TaggedErrorClass<LinuxBrowserSecretHostError>()(
451 "LinuxBrowserSecretHostError",
452 { hostPlatform: Schema.String },
453) {
454 override get message(): string {
455 return `Linux desktop builds must run on a Linux host: the browser secret helper links against libsecret and cannot be built on '${this.hostPlatform}'.`;
456 }
457}
458
450export class DesktopIconSourceMissingError extends Schema.TaggedErrorClass<DesktopIconSourceMissingError>()(459export class DesktopIconSourceMissingError extends Schema.TaggedErrorClass<DesktopIconSourceMissingError>()(
451 "DesktopIconSourceMissingError",460 "DesktopIconSourceMissingError",
452 {461 {
...@@ -2220,6 +2229,17 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp...@@ -2220,6 +2229,17 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp
2220 readonly verbose: boolean;2229 readonly verbose: boolean;
2221}) {2230}) {
2222 if (input.platform !== "linux") return;2231 if (input.platform !== "linux") return;
2232 // The helper links against the host's libsecret, so it can only be built on
2233 // Linux; the build script is a no-op elsewhere. A Linux artifact from
2234 // another host would ship without it and every v11 cookie import would
2235 // report the keyring as unavailable, so refuse rather than package that
2236 // silently. `universal` is a mac-only arch the option type still admits;
2237 // the helper script rejects it, so it maps to the concrete x64 the Linux
2238 // resource monitor uses for the same request.
2239 const hostPlatform = yield* HostProcessPlatform;
2240 if (hostPlatform !== "linux") {
2241 return yield* new LinuxBrowserSecretHostError({ hostPlatform });
2242 }
2223 const path = yield* Path.Path;2243 const path = yield* Path.Path;
2224 yield* runCommand(2244 yield* runCommand(
2225 ChildProcess.make(2245 ChildProcess.make(
...@@ -2227,7 +2247,7 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp...@@ -2227,7 +2247,7 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp
2227 [2247 [
2228 path.join(input.repoRoot, "apps/desktop/scripts/build-browser-secret.mjs"),2248 path.join(input.repoRoot, "apps/desktop/scripts/build-browser-secret.mjs"),
2229 "--arch",2249 "--arch",
2230 input.arch,2250 input.arch === "arm64" ? "arm64" : "x64",
2231 "--output",2251 "--output",
2232 path.join(input.stageResourcesDir, "browser-secret", "t3-browser-secret"),2252 path.join(input.stageResourcesDir, "browser-secret", "t3-browser-secret"),
2233 ],2253 ],