| author | |
| committer | |
| log | 3653cb22ffb30bf133fecc30c955941fe267cf1c |
| tree | b99c71cbafa2738953d1c9d6fc278f1df7afc202 |
| parent | 8ea52c8f2f4361e875d72ab30d72d37cfae4d87f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 206 | return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked }); |
| 207 | 207 | } |
| 208 | 208 | |
| 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. | |
| 216 | 213 | yield* Effect.logInfo("Reading browser cookie key from the keychain", { |
| 217 | 214 | sourceId: definition.id, |
| 218 | 215 | 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. | |
| 3 | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | 5 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; |
apps/desktop/src/preview/BrowserImport/Sources.test.ts+221| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | import * as Effect from "effect/Effect"; |
| 11 | 11 | import * as FileSystem from "effect/FileSystem"; |
| 12 | 12 | import * as Path from "effect/Path"; |
| 13 | import * as PlatformError from "effect/PlatformError"; | |
| 13 | 14 | import * as Scope from "effect/Scope"; |
| 14 | 15 | import * as Stream from "effect/Stream"; |
| 15 | 16 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| ... | ... | @@ -25,6 +26,7 @@ import { |
| 25 | 26 | resolveCookieDatabase, |
| 26 | 27 | isSourceInstalled, |
| 27 | 28 | isSourceRunning, |
| 29 | isWindowsLockHeldError, | |
| 28 | 30 | posixLockIsHeld, |
| 29 | 31 | listSourceProfiles, |
| 30 | 32 | sourcePathContext, |
| ... | ... | @@ -54,6 +56,23 @@ describe("Linux Chromium secret applications", () => { |
| 54 | 56 | }); |
| 55 | 57 | }); |
| 56 | 58 | |
| 59 | const platformError = (reasonTag: string): PlatformError.PlatformError => | |
| 60 | ({ _tag: "PlatformError", reason: { _tag: reasonTag } }) as never; | |
| 61 | ||
| 62 | describe("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 | 76 | /** A scratch home with the source's user-data directory already created. */ |
| 58 | 77 | const withSourceHome = Effect.fnUntraced(function* () { |
| 59 | 78 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -352,6 +371,20 @@ describe("isSourceInstalled", () => { |
| 352 | 371 | ), |
| 353 | 372 | ); |
| 354 | 373 | |
| 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 | 388 | it.effect("follows cookie database symlinks when detecting profiles", () => |
| 356 | 389 | run( |
| 357 | 390 | Effect.gen(function* () { |
| ... | ... | @@ -460,6 +493,79 @@ describe("listSourceProfiles", () => { |
| 460 | 493 | ), |
| 461 | 494 | ); |
| 462 | 495 | |
| 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] | |
| 506 | Name=original | |
| 507 | IsRelative=1 | |
| 508 | Path=Profiles/abcd.default-release | |
| 509 | Default=1 | |
| 510 | ||
| 511 | [Profile1] | |
| 512 | Name=empty | |
| 513 | IsRelative=1 | |
| 514 | Path=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 | 569 | it.effect("counts a profile's cookies without decrypting them", () => |
| 464 | 570 | run( |
| 465 | 571 | Effect.gen(function* () { |
| ... | ... | @@ -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 | }); |
| 478 | 602 | |
| 479 | 603 | 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 | }); |
| 516 | 659 | |
| 517 | 660 | const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; |
| ... | ... | @@ -642,6 +785,41 @@ describe("listSourceProfiles Firefox fallback", () => { |
| 642 | 785 | ); |
| 643 | 786 | } |
| 644 | 787 | |
| 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 | 823 | it.effect("counts only importable cookies for declared and fallback profiles", () => |
| 646 | 824 | run( |
| 647 | 825 | Effect.gen(function* () { |
| ... | ... | @@ -809,6 +987,49 @@ describe("isSourceRunning for Firefox", () => { |
| 809 | 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 | ||
| 1020 | describe("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 | }); |
| 813 | 1034 | |
| 814 | 1035 | describe("listSourceProfiles hardening", () => { |
apps/desktop/src/preview/BrowserImport/Sources.ts+47-10| ... | ... | @@ -109,6 +109,10 @@ const chromiumSource = (input: { |
| 109 | 109 | }); |
| 110 | 110 | |
| 111 | 111 | export 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 | 116 | chromiumSource({ |
| 113 | 117 | id: "chrome", |
| 114 | 118 | name: "Chrome", |
| ... | ... | @@ -198,6 +202,10 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition |
| 198 | 202 | * and a fresh install with only `Network/Cookies` would read as not installed. |
| 199 | 203 | * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini` |
| 200 | 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 | */ |
| 202 | 210 | export const cookieDatabaseCandidatePaths = ( |
| 203 | 211 | definition: BrowserImportSourceDefinition, |
| ... | ... | @@ -206,11 +214,18 @@ export const cookieDatabaseCandidatePaths = ( |
| 206 | 214 | ): ReadonlyArray<string> => { |
| 207 | 215 | const root = definition.userDataDirectory(context); |
| 208 | 216 | if (root === undefined) return []; |
| 209 | const profile = context.path.isAbsolute(profileDirectory) | |
| 217 | const profilePath = context.path.isAbsolute(profileDirectory) | |
| 210 | 218 | ? profileDirectory |
| 211 | 219 | : 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 | ]; | |
| 214 | 229 | }; |
| 215 | 230 | |
| 216 | 231 | /** The first candidate that is a regular file, or undefined when none is. */ |
| ... | ... | @@ -393,16 +408,35 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( |
| 393 | 408 | Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), |
| 394 | 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 | } | |
| 397 | 430 | |
| 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. | |
| 400 | 434 | const fallbackDirectory = |
| 401 | 435 | context.platform === "linux" ? root : context.path.join(root, "Profiles"); |
| 402 | const entries = yield* fileSystem | |
| 436 | const scanned = yield* fileSystem | |
| 403 | 437 | .readDirectory(fallbackDirectory) |
| 404 | 438 | .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>)); |
| 405 | const found = yield* Effect.forEach(entries, (entry) => { | |
| 439 | const found = yield* Effect.forEach(scanned, (entry) => { | |
| 406 | 440 | const directory = context.platform === "linux" ? entry : context.path.join("Profiles", entry); |
| 407 | 441 | return resolveCookieDatabase(definition, context, directory).pipe( |
| 408 | 442 | Effect.map((database) => (database === undefined ? undefined : { directory, name: entry })), |
| ... | ... | @@ -739,8 +773,11 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") |
| 739 | 773 | const root = definition.userDataDirectory(context); |
| 740 | 774 | if (root === undefined) return false; |
| 741 | 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 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. | |
| 744 | 781 | if (definition.engine !== "firefox") { |
| 745 | 782 | if (context.platform === "win32") { |
| 746 | 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 | 781 | setProfilePendingRemoval(null); |
| 782 | 782 | }; |
| 783 | 783 | |
| 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. | |
| 788 | 789 | const importableSources = (sources ?? []).filter( |
| 789 | 790 | (source) => |
| 790 | 791 | source.unavailable !== "notInstalled" && source.unavailable !== "unsupportedPlatform", |
native/browser-secret/main.c+4-1| ... | ... | @@ -5,9 +5,12 @@ |
| 5 | 5 | * with no newline or other framing that could change the derived cookie key. */ |
| 6 | 6 | enum { KEY_MISSING = 2, KEY_LOCKED = 3, READ_FAILED = 4 }; |
| 7 | 7 | |
| 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. */ | |
| 8 | 11 | static const SecretSchema chromium_schema = { |
| 9 | 12 | .name = "chrome_libsecret_os_crypt_password_v2", |
| 10 | .flags = SECRET_SCHEMA_NONE, | |
| 13 | .flags = SECRET_SCHEMA_DONT_MATCH_NAME, | |
| 11 | 14 | .attributes = {{"application", SECRET_SCHEMA_ATTRIBUTE_STRING}, {NULL, 0}}, |
| 12 | 15 | }; |
| 13 | 16 |
native/browser-secret/test.c+1-1| ... | ... | @@ -11,7 +11,7 @@ GList *__wrap_secret_service_search_sync(SecretService *service, const SecretSch |
| 11 | 11 | g_assert_null(service); |
| 12 | 12 | g_assert_null(cancellable); |
| 13 | 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 | 15 | g_assert_cmpint(flags, ==, SECRET_SEARCH_UNLOCK | SECRET_SEARCH_LOAD_SECRETS); |
| 16 | 16 | g_assert_cmpuint(g_hash_table_size(attributes), ==, 1); |
| 17 | 17 | scenario = g_intern_string(g_hash_table_lookup(attributes, "application")); |
scripts/build-desktop-artifact.test.ts+80| ... | ... | @@ -66,6 +66,8 @@ import { |
| 66 | 66 | STAGE_INSTALL_ARGS, |
| 67 | 67 | ancestorNodeModulesPaths, |
| 68 | 68 | copyDirectoryPreservingSymlinks, |
| 69 | LinuxBrowserSecretHostError, | |
| 70 | stageBrowserSecret, | |
| 69 | 71 | validateWindowsPackagedPayload, |
| 70 | 72 | WindowsPrimaryNativeProbeError, |
| 71 | 73 | WindowsDesktopBuildPrerequisitesMissingError, |
| ... | ... | @@ -1207,6 +1209,84 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1207 | 1209 | ); |
| 1208 | 1210 | }); |
| 1209 | 1211 | |
| 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 | 1290 | it.effect("skips the primary native probe for cross-architecture Windows payloads", () => { |
| 1211 | 1291 | const commands: Array<{ |
| 1212 | 1292 | readonly command: string; |
scripts/build-desktop-artifact.ts+21-1| ... | ... | @@ -447,6 +447,15 @@ const desktopIconPlatformNames = { |
| 447 | 447 | win: "Windows", |
| 448 | 448 | } satisfies Record<typeof BuildPlatform.Type, string>; |
| 449 | 449 | |
| 450 | export 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 | ||
| 450 | 459 | export class DesktopIconSourceMissingError extends Schema.TaggedErrorClass<DesktopIconSourceMissingError>()( |
| 451 | 460 | "DesktopIconSourceMissingError", |
| 452 | 461 | { |
| ... | ... | @@ -2220,6 +2229,17 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp |
| 2220 | 2229 | readonly verbose: boolean; |
| 2221 | 2230 | }) { |
| 2222 | 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 | 2243 | const path = yield* Path.Path; |
| 2224 | 2244 | yield* runCommand( |
| 2225 | 2245 | ChildProcess.make( |
| ... | ... | @@ -2227,7 +2247,7 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp |
| 2227 | 2247 | [ |
| 2228 | 2248 | path.join(input.repoRoot, "apps/desktop/scripts/build-browser-secret.mjs"), |
| 2229 | 2249 | "--arch", |
| 2230 | input.arch, | |
| 2250 | input.arch === "arm64" ? "arm64" : "x64", | |
| 2231 | 2251 | "--output", |
| 2232 | 2252 | path.join(input.stageResourcesDir, "browser-secret", "t3-browser-secret"), |
| 2233 | 2253 | ], |