authorgravatar for aabdelwaly7@gmail.comAbdel Abdelwaly <aabdelwaly7@gmail.com> 2026-09-08 16:52:19-06:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 22:52:19+00:00
log20e2e899ecded6b30e12fa23c8f18847c2bf12e1
tree14a407564ab4be3363c8f7a3acbdbc7088eba914
parent7fbc545ae8c7866ac2b39648120cb2a17250d8b4
signature Signed by PGP key B5690EEEBB952194

fix(desktop): defer keyring loading until macOS cookie import (#10667)


3 files changed, 95 insertions(+), 1 deletions(-)

apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts created+35
...@@ -0,0 +1,35 @@
1import { expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { ChildProcessSpawner } from "effect/unstable/process";
4import { vi } from "vite-plus/test";
5
6vi.mock("@napi-rs/keyring", () => {
7 throw new Error("Cannot find native binding");
8});
9
10it("loads browser import code without a keyring native binding", async () => {
11 await expect(import("./ChromiumKeys.ts")).resolves.toBeDefined();
12});
13
14it.effect("reports an unavailable keychain when the macOS binding cannot load", () =>
15 Effect.gen(function* () {
16 const { ChromiumKeyError, resolveChromiumKeys } = yield* Effect.promise(
17 () => import("./ChromiumKeys.ts"),
18 );
19 const error = yield* resolveChromiumKeys({
20 platform: "darwin",
21 keychainService: "Chrome Safe Storage",
22 keychainAccount: "Chrome",
23 linuxSecretApplication: undefined,
24 }).pipe(Effect.flip);
25
26 expect(error).toBeInstanceOf(ChromiumKeyError);
27 expect(error.reason).toBe("keychainUnavailable");
28 expect(error.cause).toBeInstanceOf(Error);
29 }).pipe(
30 Effect.provideService(
31 ChildProcessSpawner.ChildProcessSpawner,
32 ChildProcessSpawner.make(() => Effect.die("must not spawn")),
33 ),
34 ),
35);
apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts+54
...@@ -7,6 +7,7 @@ import * as Sink from "effect/Sink";...@@ -7,6 +7,7 @@ import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";7import * as Stream from "effect/Stream";
8import * as PlatformError from "effect/PlatformError";8import * as PlatformError from "effect/PlatformError";
9import { ChildProcessSpawner } from "effect/unstable/process";9import { ChildProcessSpawner } from "effect/unstable/process";
10import { beforeEach, vi } from "vite-plus/test";
1011
11import {12import {
12 ChromiumKeyError,13 ChromiumKeyError,
...@@ -17,6 +18,18 @@ import {...@@ -17,6 +18,18 @@ import {
17} from "./ChromiumKeys.ts";18} from "./ChromiumKeys.ts";
18import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";19import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
1920
21const { getPassword } = vi.hoisted(() => ({ getPassword: vi.fn<() => string | null>() }));
22
23vi.mock("@napi-rs/keyring", () => ({
24 Entry: class {
25 getPassword = getPassword;
26 },
27}));
28
29beforeEach(() => {
30 getPassword.mockReset();
31});
32
20type CapturedCommand = {33type CapturedCommand = {
21 readonly command: string;34 readonly command: string;
22 readonly args: ReadonlyArray<string>;35 readonly args: ReadonlyArray<string>;
...@@ -63,6 +76,47 @@ const helperLayer = (input: {...@@ -63,6 +76,47 @@ const helperLayer = (input: {
63 ),76 ),
64 );77 );
6578
79describe("macOS Chromium secrets", () => {
80 const request = {
81 platform: "darwin",
82 keychainService: "Chrome Safe Storage",
83 keychainAccount: "Chrome",
84 linuxSecretApplication: undefined,
85 } as const;
86 const noProcesses = Layer.succeed(
87 ChildProcessSpawner.ChildProcessSpawner,
88 ChildProcessSpawner.make(() => Effect.die("must not spawn")),
89 );
90
91 it.effect("derives the cookie key from the keychain secret", () =>
92 Effect.gen(function* () {
93 getPassword.mockReturnValue("macos-secret");
94 const keys = yield* resolveChromiumKeys(request);
95 expect(keys.cbcV10?.toString("hex")).toBe("3df7306fb1eac353289565a2f6b64f74");
96 }).pipe(Effect.provide(noProcesses)),
97 );
98
99 it.effect("reports a missing keychain entry", () =>
100 Effect.gen(function* () {
101 getPassword.mockReturnValue(null);
102 const error = yield* resolveChromiumKeys(request).pipe(Effect.flip);
103 expect(error.reason).toBe("keychainItemMissing");
104 }).pipe(Effect.provide(noProcesses)),
105 );
106
107 it.effect("preserves a denied keychain approval", () =>
108 Effect.gen(function* () {
109 const denied = new Error("User denied access");
110 getPassword.mockImplementation(() => {
111 throw denied;
112 });
113 const error = yield* resolveChromiumKeys(request).pipe(Effect.flip);
114 expect(error.reason).toBe("needsKeychainApproval");
115 expect(error.cause).toBe(denied);
116 }).pipe(Effect.provide(noProcesses)),
117 );
118});
119
66describe("Linux Chromium secrets", () => {120describe("Linux Chromium secrets", () => {
67 it.effect("retains a missing helper failure alongside the keyring-free fallback", () =>121 it.effect("retains a missing helper failure alongside the keyring-free fallback", () =>
68 Effect.gen(function* () {122 Effect.gen(function* () {
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts+6-1
...@@ -17,7 +17,6 @@...@@ -17,7 +17,6 @@
17 *17 *
18 * @module ChromiumKeys18 * @module ChromiumKeys
19 */19 */
20import * as Keyring from "@napi-rs/keyring";
21import * as NodeCrypto from "node:crypto";20import * as NodeCrypto from "node:crypto";
2221
23import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";22import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
...@@ -102,6 +101,12 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function...@@ -102,6 +101,12 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function
102 service: string,101 service: string,
103 account: string,102 account: string,
104) {103) {
104 // Only macOS cookie imports need this binding; loading it at startup can
105 // prevent the desktop from opening on platforms that never use it.
106 const Keyring = yield* Effect.tryPromise({
107 try: () => import("@napi-rs/keyring"),
108 catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }),
109 });
105 const secret = yield* Effect.try({110 const secret = yield* Effect.try({
106 try: () => new Keyring.Entry(service, account).getPassword(),111 try: () => new Keyring.Entry(service, account).getPassword(),
107 catch: (cause) => {112 catch: (cause) => {