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() {
206206 return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked });
207207 }
208208
209 // macOS attributes the Keychain prompt and the resulting ACL grant to the
210 // executable that asks, so record which one that was — in a packaged build
211 // it is the signed app, in dev whatever binary hosts the main process.
212 // Only Chromium reads a key; a Firefox import touches no keychain, and
213 // logging that it did would put a false security-sensitive event in the
214 // audit trail.
215 if (definition.engine === "chromium") {
209 if (platform === "darwin" && definition.engine === "chromium") {
210 // macOS attributes the Keychain prompt and the resulting ACL grant to the
211 // executable that asks, so record which one that was — in a packaged build
212 // it is the signed app, in dev whatever binary hosts the main process.
216213 yield* Effect.logInfo("Reading browser cookie key from the keychain", {
217214 sourceId: definition.id,
218215 executablePath,
apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts+2-2
......@@ -1,5 +1,5 @@
1// @effect-diagnostics nodeBuiltinImport:off - Encrypts a fixture with the same
2// OSCrypt primitive as Chromium.
1// @effect-diagnostics nodeBuiltinImport:off - Encrypts fixtures with the same
2// OSCrypt primitives the module under test decrypts.
33import * as NodeServices from "@effect/platform-node/NodeServices";
44import { describe, expect, it } from "@effect/vitest";
55import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
apps/desktop/src/preview/BrowserImport/Sources.test.ts+221
......@@ -10,6 +10,7 @@ import {
1010import * as Effect from "effect/Effect";
1111import * as FileSystem from "effect/FileSystem";
1212import * as Path from "effect/Path";
13import * as PlatformError from "effect/PlatformError";
1314import * as Scope from "effect/Scope";
1415import * as Stream from "effect/Stream";
1516import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
......@@ -25,6 +26,7 @@ import {
2526 resolveCookieDatabase,
2627 isSourceInstalled,
2728 isSourceRunning,
29 isWindowsLockHeldError,
2830 posixLockIsHeld,
2931 listSourceProfiles,
3032 sourcePathContext,
......@@ -54,6 +56,23 @@ describe("Linux Chromium secret applications", () => {
5456 });
5557});
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
5776/** A scratch home with the source's user-data directory already created. */
5877const withSourceHome = Effect.fnUntraced(function* () {
5978 const fileSystem = yield* FileSystem.FileSystem;
......@@ -352,6 +371,20 @@ describe("isSourceInstalled", () => {
352371 ),
353372 );
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
355388 it.effect("follows cookie database symlinks when detecting profiles", () =>
356389 run(
357390 Effect.gen(function* () {
......@@ -460,6 +493,79 @@ describe("listSourceProfiles", () => {
460493 ),
461494 );
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
463569 it.effect("counts a profile's cookies without decrypting them", () =>
464570 run(
465571 Effect.gen(function* () {
......@@ -474,6 +580,24 @@ describe("listSourceProfiles", () => {
474580 }),
475581 ),
476582 );
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 );
477601});
478602
479603describe("cookieDatabaseCandidatePaths", () => {
......@@ -512,6 +636,25 @@ describe("cookieDatabaseCandidatePaths", () => {
512636 }),
513637 ),
514638 );
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 );
515658});
516659
517660const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!;
......@@ -642,6 +785,41 @@ describe("listSourceProfiles Firefox fallback", () => {
642785 );
643786 }
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
645823 it.effect("counts only importable cookies for declared and fallback profiles", () =>
646824 run(
647825 Effect.gen(function* () {
......@@ -809,6 +987,49 @@ describe("isSourceRunning for Firefox", () => {
809987 assert.isTrue(yield* firefoxSymlinkLockIsHeld("10.0.0.7:+9999", local, alive));
810988 }),
811989 );
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 );
8121033});
8131034
8141035describe("listSourceProfiles hardening", () => {
apps/desktop/src/preview/BrowserImport/Sources.ts+47-10
......@@ -109,6 +109,10 @@ const chromiumSource = (input: {
109109});
110110
111111export 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.
112116 chromiumSource({
113117 id: "chrome",
114118 name: "Chrome",
......@@ -198,6 +202,10 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
198202 * and a fresh install with only `Network/Cookies` would read as not installed.
199203 * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini`
200204 * 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.
201209 */
202210export const cookieDatabaseCandidatePaths = (
203211 definition: BrowserImportSourceDefinition,
......@@ -206,11 +214,18 @@ export const cookieDatabaseCandidatePaths = (
206214): ReadonlyArray<string> => {
207215 const root = definition.userDataDirectory(context);
208216 if (root === undefined) return [];
209 const profile = context.path.isAbsolute(profileDirectory)
217 const profilePath = context.path.isAbsolute(profileDirectory)
210218 ? profileDirectory
211219 : context.path.join(root, profileDirectory);
212 if (definition.engine === "firefox") return [context.path.join(profile, "cookies.sqlite")];
213 return [context.path.join(profile, "Network", "Cookies"), context.path.join(profile, "Cookies")];
220 if (definition.engine === "firefox") {
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 ];
214229};
215230
216231/** The first candidate that is a regular file, or undefined when none is. */
......@@ -393,16 +408,35 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* (
393408 Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)),
394409 Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
395410 );
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. macOS
399 // and Windows put them in `Profiles/`.
431 // No usable `profiles.ini`, so fall back to scanning the directory the
432 // profiles actually live in, keeping only the ones a cookie database
433 // proves were launched.
400434 const fallbackDirectory =
401435 context.platform === "linux" ? root : context.path.join(root, "Profiles");
402 const entries = yield* fileSystem
436 const scanned = yield* fileSystem
403437 .readDirectory(fallbackDirectory)
404438 .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));
405 const found = yield* Effect.forEach(entries, (entry) => {
439 const found = yield* Effect.forEach(scanned, (entry) => {
406440 const directory = context.platform === "linux" ? entry : context.path.join("Profiles", entry);
407441 return resolveCookieDatabase(definition, context, directory).pipe(
408442 Effect.map((database) => (database === undefined ? undefined : { directory, name: entry })),
......@@ -739,8 +773,11 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")
739773 const root = definition.userDataDirectory(context);
740774 if (root === undefined) return false;
741775 // 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 its
743 // user-data SingletonLock on POSIX.
776 // Chromium exposes its lock through the cookie jar on Windows and through a
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.
744781 if (definition.engine !== "firefox") {
745782 if (context.platform === "win32") {
746783 return yield* windowsChromiumCookiesAreHeld(definition, context);
apps/web/src/components/settings/IntegrationsSettings.tsx+5-4
......@@ -781,10 +781,11 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
781781 setProfilePendingRemoval(null);
782782 };
783783
784 // A browser that is not on this machine, or that this platform cannot import
785 // from at all, is left out rather than listed as a dead row: there is nothing
786 // to act on, and the menu is a list of things you can import from. Every
787 // other unavailable reason stays, since each names a step the user can take.
784 // A browser that is not on this machine is left out rather than listed as a
785 // dead row: there is nothing to act on, and the menu is a list of things you
786 // can import from. An unsupported one is left out for the same reason — the
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.
788789 const importableSources = (sources ?? []).filter(
789790 (source) =>
790791 source.unavailable !== "notInstalled" && source.unavailable !== "unsupportedPlatform",
native/browser-secret/main.c+4-1
......@@ -5,9 +5,12 @@
55 * with no newline or other framing that could change the derived cookie key. */
66enum { 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. */
811static const SecretSchema chromium_schema = {
912 .name = "chrome_libsecret_os_crypt_password_v2",
10 .flags = SECRET_SCHEMA_NONE,
13 .flags = SECRET_SCHEMA_DONT_MATCH_NAME,
1114 .attributes = {{"application", SECRET_SCHEMA_ATTRIBUTE_STRING}, {NULL, 0}},
1215};
1316
native/browser-secret/test.c+1-1
......@@ -11,7 +11,7 @@ GList *__wrap_secret_service_search_sync(SecretService *service, const SecretSch
1111 g_assert_null(service);
1212 g_assert_null(cancellable);
1313 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);
1515 g_assert_cmpint(flags, ==, SECRET_SEARCH_UNLOCK | SECRET_SEARCH_LOAD_SECRETS);
1616 g_assert_cmpuint(g_hash_table_size(attributes), ==, 1);
1717 scenario = g_intern_string(g_hash_table_lookup(attributes, "application"));
scripts/build-desktop-artifact.test.ts+80
......@@ -66,6 +66,8 @@ import {
6666 STAGE_INSTALL_ARGS,
6767 ancestorNodeModulesPaths,
6868 copyDirectoryPreservingSymlinks,
69 LinuxBrowserSecretHostError,
70 stageBrowserSecret,
6971 validateWindowsPackagedPayload,
7072 WindowsPrimaryNativeProbeError,
7173 WindowsDesktopBuildPrerequisitesMissingError,
......@@ -1207,6 +1209,84 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
12071209 );
12081210 });
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
12101290 it.effect("skips the primary native probe for cross-architecture Windows payloads", () => {
12111291 const commands: Array<{
12121292 readonly command: string;
scripts/build-desktop-artifact.ts+21-1
......@@ -447,6 +447,15 @@ const desktopIconPlatformNames = {
447447 win: "Windows",
448448} 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
450459export class DesktopIconSourceMissingError extends Schema.TaggedErrorClass<DesktopIconSourceMissingError>()(
451460 "DesktopIconSourceMissingError",
452461 {
......@@ -2220,6 +2229,17 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp
22202229 readonly verbose: boolean;
22212230}) {
22222231 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 }
22232243 const path = yield* Path.Path;
22242244 yield* runCommand(
22252245 ChildProcess.make(
......@@ -2227,7 +2247,7 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp
22272247 [
22282248 path.join(input.repoRoot, "apps/desktop/scripts/build-browser-secret.mjs"),
22292249 "--arch",
2230 input.arch,
2250 input.arch === "arm64" ? "arm64" : "x64",
22312251 "--output",
22322252 path.join(input.stageResourcesDir, "browser-secret", "t3-browser-secret"),
22332253 ],