| author | |
| committer | |
| log | 39449e53e31a56103192aa7905e89fc92c977a4a |
| tree | 38d0bb667e7f09198d90184f2e6a452a967c6d57 |
| parent | e01c153c18a94a2aa33df7115c6cfee1eb09880f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>27 files changed, 3913 insertions(+), 183 deletions(-)
apps/desktop/package.json+1| ... | @@ -15,6 +15,7 @@ | ... | @@ -15,6 +15,7 @@ |
| 15 | "@clerk/electron": "catalog:", | 15 | "@clerk/electron": "catalog:", |
| 16 | "@clerk/electron-passkeys": "catalog:", | 16 | "@clerk/electron-passkeys": "catalog:", |
| 17 | "@effect/platform-node": "catalog:", | 17 | "@effect/platform-node": "catalog:", |
| 18 | "@napi-rs/keyring": "^1.3.0", | ||
| 18 | "@t3tools/client-runtime": "workspace:*", | 19 | "@t3tools/client-runtime": "workspace:*", |
| 19 | "@t3tools/contracts": "workspace:*", | 20 | "@t3tools/contracts": "workspace:*", |
| 20 | "@t3tools/shared": "workspace:*", | 21 | "@t3tools/shared": "workspace:*", |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | @@ -103,4 +103,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" | ... | @@ -103,4 +103,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 103 | for (const previewMethod of PreviewIpc.methods) { | 103 | for (const previewMethod of PreviewIpc.methods) { |
| 104 | yield* ipc.handle(previewMethod); | 104 | yield* ipc.handle(previewMethod); |
| 105 | } | 105 | } |
| 106 | yield* ipc.handle(PreviewIpc.listBrowserImportSources); | ||
| 107 | yield* ipc.handle(PreviewIpc.importBrowserCookies); | ||
| 106 | }); | 108 | }); |
apps/desktop/src/ipc/channels.ts+2| ... | @@ -64,6 +64,8 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; | ... | @@ -64,6 +64,8 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; |
| 64 | export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; | 64 | export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; |
| 65 | export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; | 65 | export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; |
| 66 | export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; | 66 | export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; |
| 67 | export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources"; | ||
| 68 | export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies"; | ||
| 67 | export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme"; | 69 | export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme"; |
| 68 | export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; | 70 | export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; |
| 69 | export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; | 71 | export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; |
apps/desktop/src/ipc/methods/preview.test.ts+33| ... | @@ -12,6 +12,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -12,6 +12,7 @@ import * as Schema from "effect/Schema"; |
| 12 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 12 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 13 | 13 | ||
| 14 | import * as PreviewManager from "../../preview/Manager.ts"; | 14 | import * as PreviewManager from "../../preview/Manager.ts"; |
| 15 | import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; | ||
| 15 | import * as PreviewIpc from "./preview.ts"; | 16 | import * as PreviewIpc from "./preview.ts"; |
| 16 | 17 | ||
| 17 | const { fromPartition } = vi.hoisted(() => ({ | 18 | const { fromPartition } = vi.hoisted(() => ({ |
| ... | @@ -80,6 +81,38 @@ describe("preview IPC methods", () => { | ... | @@ -80,6 +81,38 @@ describe("preview IPC methods", () => { |
| 80 | }); | 81 | }); |
| 81 | }); | 82 | }); |
| 82 | 83 | ||
| 84 | effectIt.effect("targets imports at the same partition tuple as the renderer", () => { | ||
| 85 | const received: Array<Parameters<BrowserImport.BrowserImport["Service"]["importCookies"]>[0]> = | ||
| 86 | []; | ||
| 87 | const browserImport = BrowserImport.BrowserImport.of({ | ||
| 88 | listSources: Effect.succeed([]), | ||
| 89 | importCookies: (input) => | ||
| 90 | Effect.sync(() => { | ||
| 91 | received.push(input); | ||
| 92 | return { imported: 0, skipped: 0, skippedDomains: [] }; | ||
| 93 | }), | ||
| 94 | }); | ||
| 95 | const request = (environmentId: string, targetProfileId: string) => | ||
| 96 | PreviewIpc.importBrowserCookies.handler({ | ||
| 97 | environmentId, | ||
| 98 | sourceId: "helium", | ||
| 99 | sourceProfileDirectory: "Default", | ||
| 100 | targetProfileId, | ||
| 101 | }); | ||
| 102 | |||
| 103 | return Effect.gen(function* () { | ||
| 104 | yield* request("a", "b"); | ||
| 105 | yield* request("a::b", DEFAULT_BROWSER_PROFILE_ID); | ||
| 106 | |||
| 107 | expect(received[0]).toMatchObject(PreviewIpc.resolvePartitionScope("a", "b")); | ||
| 108 | expect(received[1]).toMatchObject( | ||
| 109 | PreviewIpc.resolvePartitionScope("a::b", DEFAULT_BROWSER_PROFILE_ID), | ||
| 110 | ); | ||
| 111 | expect(received[0]?.namespace).toBe("profile"); | ||
| 112 | expect(received[1]?.namespace).toBeUndefined(); | ||
| 113 | }).pipe(Effect.provideService(BrowserImport.BrowserImport, browserImport)); | ||
| 114 | }); | ||
| 115 | |||
| 83 | effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => | 116 | effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => |
| 84 | Effect.map( | 117 | Effect.map( |
| 85 | PreviewIpc.registerWebview | 118 | PreviewIpc.registerWebview |
apps/desktop/src/ipc/methods/preview.ts+43| ... | @@ -16,7 +16,10 @@ import { | ... | @@ -16,7 +16,10 @@ import { |
| 16 | DesktopPreviewScreenshotArtifactSchema, | 16 | DesktopPreviewScreenshotArtifactSchema, |
| 17 | DesktopPreviewSetAudioMutedInputSchema, | 17 | DesktopPreviewSetAudioMutedInputSchema, |
| 18 | DesktopPreviewSetColorSchemeInputSchema, | 18 | DesktopPreviewSetColorSchemeInputSchema, |
| 19 | BrowserImportResult, | ||
| 20 | BrowserImportSource, | ||
| 19 | DesktopPreviewClearDataInputSchema, | 21 | DesktopPreviewClearDataInputSchema, |
| 22 | DesktopPreviewImportCookiesInputSchema, | ||
| 20 | DesktopPreviewCreateTabInputSchema, | 23 | DesktopPreviewCreateTabInputSchema, |
| 21 | DesktopPreviewTabInputSchema, | 24 | DesktopPreviewTabInputSchema, |
| 22 | DesktopPreviewWebviewConfigSchema, | 25 | DesktopPreviewWebviewConfigSchema, |
| ... | @@ -30,6 +33,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -30,6 +33,7 @@ import * as Schema from "effect/Schema"; |
| 30 | import * as NodeURL from "node:url"; | 33 | import * as NodeURL from "node:url"; |
| 31 | 34 | ||
| 32 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | 35 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; |
| 36 | import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; | ||
| 33 | import * as PreviewManager from "../../preview/Manager.ts"; | 37 | import * as PreviewManager from "../../preview/Manager.ts"; |
| 34 | import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; | 38 | import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; |
| 35 | import * as IpcChannels from "../channels.ts"; | 39 | import * as IpcChannels from "../channels.ts"; |
| ... | @@ -284,6 +288,45 @@ export const getPreviewConfig = DesktopIpc.makeIpcMethod({ | ... | @@ -284,6 +288,45 @@ export const getPreviewConfig = DesktopIpc.makeIpcMethod({ |
| 284 | }), | 288 | }), |
| 285 | }); | 289 | }); |
| 286 | 290 | ||
| 291 | /** | ||
| 292 | * Registered separately from `methods`: these carry `BrowserImport` in their | ||
| 293 | * context and their own failure type, so they do not unify with the | ||
| 294 | * manager-backed handlers the shared loop iterates. | ||
| 295 | */ | ||
| 296 | export const listBrowserImportSources = DesktopIpc.makeIpcMethod({ | ||
| 297 | channel: IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL, | ||
| 298 | payload: Schema.Void, | ||
| 299 | result: Schema.Array(BrowserImportSource), | ||
| 300 | handler: Effect.fn("desktop.ipc.preview.listBrowserImportSources")(function* () { | ||
| 301 | const browserImport = yield* BrowserImport.BrowserImport; | ||
| 302 | return yield* browserImport.listSources; | ||
| 303 | }), | ||
| 304 | }); | ||
| 305 | |||
| 306 | export const importBrowserCookies = DesktopIpc.makeIpcMethod({ | ||
| 307 | channel: IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL, | ||
| 308 | payload: DesktopPreviewImportCookiesInputSchema, | ||
| 309 | result: BrowserImportResult, | ||
| 310 | handler: Effect.fn("desktop.ipc.preview.importBrowserCookies")(function* ({ | ||
| 311 | environmentId, | ||
| 312 | ...importInput | ||
| 313 | }) { | ||
| 314 | const browserImport = yield* BrowserImport.BrowserImport; | ||
| 315 | // Derived in main from the same helper the webview config uses, so cookies | ||
| 316 | // land in exactly the partition the profile's tabs attach to. | ||
| 317 | const { scope, persistent, namespace } = resolvePartitionScope( | ||
| 318 | environmentId, | ||
| 319 | importInput.targetProfileId, | ||
| 320 | ); | ||
| 321 | return yield* browserImport.importCookies({ | ||
| 322 | input: importInput, | ||
| 323 | scope, | ||
| 324 | persistent, | ||
| 325 | ...(namespace === undefined ? {} : { namespace }), | ||
| 326 | }); | ||
| 327 | }), | ||
| 328 | }); | ||
| 329 | |||
| 287 | export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ | 330 | export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ |
| 288 | channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, | 331 | channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, |
| 289 | payload: DesktopPreviewAnnotationThemeInputSchema, | 332 | payload: DesktopPreviewAnnotationThemeInputSchema, |
apps/desktop/src/main.ts+4| ... | @@ -58,6 +58,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; | ... | @@ -58,6 +58,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; |
| 58 | import * as DesktopState from "./app/DesktopState.ts"; | 58 | import * as DesktopState from "./app/DesktopState.ts"; |
| 59 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; | 59 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; |
| 60 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; | 60 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 61 | import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; | ||
| 61 | import * as BrowserSession from "./preview/BrowserSession.ts"; | 62 | import * as BrowserSession from "./preview/BrowserSession.ts"; |
| 62 | import * as PreviewManager from "./preview/Manager.ts"; | 63 | import * as PreviewManager from "./preview/Manager.ts"; |
| 63 | import * as DesktopWindow from "./window/DesktopWindow.ts"; | 64 | import * as DesktopWindow from "./window/DesktopWindow.ts"; |
| ... | @@ -149,6 +150,9 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( | ... | @@ -149,6 +150,9 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( |
| 149 | ); | 150 | ); |
| 150 | 151 | ||
| 151 | const desktopPreviewLayer = PreviewManager.layer.pipe( | 152 | const desktopPreviewLayer = PreviewManager.layer.pipe( |
| 153 | // Merged rather than provided so the IPC handlers can reach the import | ||
| 154 | // service alongside the manager; both sit on the same BrowserSession. | ||
| 155 | Layer.provideMerge(BrowserImport.layer), | ||
| 152 | Layer.provideMerge(BrowserSession.layer), | 156 | Layer.provideMerge(BrowserSession.layer), |
| 153 | Layer.provideMerge(desktopFoundationLayer), | 157 | Layer.provideMerge(desktopFoundationLayer), |
| 154 | ); | 158 | ); |
apps/desktop/src/preload.ts+3| ... | @@ -223,6 +223,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -223,6 +223,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 223 | ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), | 223 | ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), |
| 224 | openDevTools: (tabId) => | 224 | openDevTools: (tabId) => |
| 225 | ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), | 225 | ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), |
| 226 | listBrowserImportSources: () => ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL), | ||
| 227 | importBrowserCookies: (input) => | ||
| 228 | ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL, input), | ||
| 226 | clearCookies: (environmentId, profileId) => | 229 | clearCookies: (environmentId, profileId) => |
| 227 | ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }), | 230 | ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }), |
| 228 | clearCache: (environmentId, profileId) => | 231 | clearCache: (environmentId, profileId) => |
apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts created+211| ... | @@ -0,0 +1,211 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, describe, it } from "@effect/vitest"; | ||
| 3 | import { | ||
| 4 | HostProcessEnvironment, | ||
| 5 | HostProcessExecutablePath, | ||
| 6 | HostProcessPlatform, | ||
| 7 | } from "@t3tools/shared/hostProcess"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as FileSystem from "effect/FileSystem"; | ||
| 10 | import * as Fiber from "effect/Fiber"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Ref from "effect/Ref"; | ||
| 13 | |||
| 14 | import * as BrowserSession from "../BrowserSession.ts"; | ||
| 15 | import * as BrowserImport from "./BrowserImport.ts"; | ||
| 16 | import { BROWSER_IMPORT_SOURCES, sourcePaths } from "./Sources.ts"; | ||
| 17 | |||
| 18 | const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; | ||
| 19 | |||
| 20 | const cookie = { | ||
| 21 | url: "https://rejected.example/path", | ||
| 22 | name: "session", | ||
| 23 | value: "value", | ||
| 24 | domain: undefined, | ||
| 25 | path: "/", | ||
| 26 | secure: true, | ||
| 27 | httpOnly: true, | ||
| 28 | expirationDate: undefined, | ||
| 29 | sameSite: "lax" as const, | ||
| 30 | }; | ||
| 31 | |||
| 32 | /** | ||
| 33 | * Dies if the import reaches session work: every case here covers a request | ||
| 34 | * that must be rejected before a cookie is read or written. | ||
| 35 | */ | ||
| 36 | const rejectedBeforeSession = Layer.succeed( | ||
| 37 | BrowserSession.BrowserSession, | ||
| 38 | BrowserSession.BrowserSession.of({ | ||
| 39 | getPartition: () => Effect.die("getPartition must not be reached"), | ||
| 40 | isPartition: () => false, | ||
| 41 | getSession: () => Effect.die("getSession must not be reached"), | ||
| 42 | clearCookies: () => Effect.die("clearCookies must not be reached"), | ||
| 43 | clearCache: () => Effect.die("clearCache must not be reached"), | ||
| 44 | }), | ||
| 45 | ); | ||
| 46 | |||
| 47 | /** | ||
| 48 | * Builds the service against a scratch home containing an installed, closed | ||
| 49 | * copy of the source browser. | ||
| 50 | */ | ||
| 51 | const withImporter = Effect.fnUntraced(function* () { | ||
| 52 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 53 | const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" }); | ||
| 54 | const environment = Layer.succeed(HostProcessEnvironment, { HOME: home }); | ||
| 55 | const paths = yield* sourcePaths.pipe( | ||
| 56 | Effect.provideService(HostProcessEnvironment, { HOME: home }), | ||
| 57 | ); | ||
| 58 | yield* fileSystem.makeDirectory(`${helium.userDataDirectory(paths)}/Default`, { | ||
| 59 | recursive: true, | ||
| 60 | }); | ||
| 61 | // The cookie database is what marks a source as installed, so a fixture | ||
| 62 | // without one is reported as absent before any other check runs. | ||
| 63 | yield* fileSystem.writeFileString(`${helium.userDataDirectory(paths)}/Default/Cookies`, "db"); | ||
| 64 | |||
| 65 | const importer = yield* BrowserImport.BrowserImport.pipe( | ||
| 66 | Effect.provide( | ||
| 67 | BrowserImport.layer.pipe( | ||
| 68 | Layer.provide(rejectedBeforeSession), | ||
| 69 | Layer.provide(environment), | ||
| 70 | Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), | ||
| 71 | Layer.provide(Layer.succeed(HostProcessExecutablePath, "/Applications/T3 Code.app")), | ||
| 72 | Layer.provide(NodeServices.layer), | ||
| 73 | ), | ||
| 74 | ), | ||
| 75 | ); | ||
| 76 | return { importer, home, paths }; | ||
| 77 | }); | ||
| 78 | |||
| 79 | describe("BrowserImport.importCookies", () => { | ||
| 80 | it.effect("rejects a source profile the browser never reported", () => | ||
| 81 | Effect.gen(function* () { | ||
| 82 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 83 | const { importer, home } = yield* withImporter(); | ||
| 84 | |||
| 85 | // A cookie database reachable on disk but outside the browser's | ||
| 86 | // user-data directory — the payoff a traversal would be after. | ||
| 87 | yield* fileSystem.makeDirectory(`${home}/secrets`, { recursive: true }); | ||
| 88 | yield* fileSystem.writeFileString(`${home}/secrets/Cookies`, "not-a-db"); | ||
| 89 | |||
| 90 | const error = yield* importer | ||
| 91 | .importCookies({ | ||
| 92 | input: { | ||
| 93 | sourceId: "helium", | ||
| 94 | sourceProfileDirectory: "../../../../secrets", | ||
| 95 | targetProfileId: "default", | ||
| 96 | }, | ||
| 97 | scope: "persist:t3code-preview-test", | ||
| 98 | persistent: true, | ||
| 99 | }) | ||
| 100 | .pipe(Effect.flip); | ||
| 101 | |||
| 102 | assert.instanceOf(error, BrowserImport.BrowserImportFailedError); | ||
| 103 | assert.equal(error.reason, "unknownSourceProfile"); | ||
| 104 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 105 | ); | ||
| 106 | |||
| 107 | it.effect("refuses to import while the source browser holds its profile", () => | ||
| 108 | Effect.gen(function* () { | ||
| 109 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 110 | const { importer, paths } = yield* withImporter(); | ||
| 111 | // The lock Chromium leaves while it is running, dangling target and | ||
| 112 | // all. This must stop the import before it ever asks the keychain. | ||
| 113 | yield* fileSystem.symlink( | ||
| 114 | "host-that-does-not-exist-1234", | ||
| 115 | `${helium.userDataDirectory(paths)}/SingletonLock`, | ||
| 116 | ); | ||
| 117 | |||
| 118 | const error = yield* importer | ||
| 119 | .importCookies({ | ||
| 120 | input: { | ||
| 121 | sourceId: "helium", | ||
| 122 | sourceProfileDirectory: "Default", | ||
| 123 | targetProfileId: "default", | ||
| 124 | }, | ||
| 125 | scope: "persist:t3code-preview-test", | ||
| 126 | persistent: true, | ||
| 127 | }) | ||
| 128 | .pipe(Effect.flip); | ||
| 129 | |||
| 130 | assert.equal(error.reason, "browserRunning"); | ||
| 131 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 132 | ); | ||
| 133 | }); | ||
| 134 | |||
| 135 | describe("BrowserImport.writeCookies", () => { | ||
| 136 | it.effect("counts a rejected cookie and its domain as skipped", () => | ||
| 137 | Effect.gen(function* () { | ||
| 138 | let flushes = 0; | ||
| 139 | const result = yield* BrowserImport.writeCookies( | ||
| 140 | { | ||
| 141 | cookies: { | ||
| 142 | set: () => Promise.reject(new Error("fixture rejection")), | ||
| 143 | flushStore: () => { | ||
| 144 | flushes += 1; | ||
| 145 | return Promise.resolve(); | ||
| 146 | }, | ||
| 147 | }, | ||
| 148 | }, | ||
| 149 | { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] }, | ||
| 150 | ); | ||
| 151 | |||
| 152 | assert.deepEqual(result, { | ||
| 153 | imported: 0, | ||
| 154 | skipped: 1, | ||
| 155 | skippedDomains: ["rejected.example"], | ||
| 156 | }); | ||
| 157 | // Nothing landed, so there is nothing to persist. | ||
| 158 | assert.equal(flushes, 0); | ||
| 159 | }), | ||
| 160 | ); | ||
| 161 | |||
| 162 | it.effect("flushes the store after writing, and reports success if the flush fails", () => | ||
| 163 | Effect.gen(function* () { | ||
| 164 | const events: Array<string> = []; | ||
| 165 | const result = yield* BrowserImport.writeCookies( | ||
| 166 | { | ||
| 167 | cookies: { | ||
| 168 | set: () => { | ||
| 169 | events.push("set"); | ||
| 170 | return Promise.resolve(); | ||
| 171 | }, | ||
| 172 | flushStore: () => { | ||
| 173 | events.push("flush"); | ||
| 174 | return Promise.reject(new Error("fixture flush failure")); | ||
| 175 | }, | ||
| 176 | }, | ||
| 177 | }, | ||
| 178 | { cookies: [cookie, cookie], undecryptable: 0, undecryptableHosts: [] }, | ||
| 179 | ); | ||
| 180 | |||
| 181 | // One flush after every write, not one per cookie; the cookies are in | ||
| 182 | // the session either way, so a failed flush is not a failed import. | ||
| 183 | assert.deepEqual(events, ["set", "set", "flush"]); | ||
| 184 | assert.deepEqual(result, { imported: 2, skipped: 0, skippedDomains: [] }); | ||
| 185 | }), | ||
| 186 | ); | ||
| 187 | |||
| 188 | it.effect("propagates interruption while writing a cookie", () => | ||
| 189 | Effect.gen(function* () { | ||
| 190 | const write = BrowserImport.writeCookies( | ||
| 191 | { | ||
| 192 | cookies: { | ||
| 193 | set: () => new Promise<void>(() => {}), | ||
| 194 | flushStore: () => Promise.resolve(), | ||
| 195 | }, | ||
| 196 | }, | ||
| 197 | { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] }, | ||
| 198 | ); | ||
| 199 | |||
| 200 | const interrupted = yield* Ref.make(false); | ||
| 201 | const fiber = yield* write.pipe( | ||
| 202 | Effect.onInterrupt(() => Ref.set(interrupted, true)), | ||
| 203 | Effect.forkChild, | ||
| 204 | ); | ||
| 205 | yield* Effect.yieldNow; | ||
| 206 | yield* Fiber.interrupt(fiber); | ||
| 207 | |||
| 208 | assert.isTrue(yield* Ref.get(interrupted)); | ||
| 209 | }), | ||
| 210 | ); | ||
| 211 | }); | ||
apps/desktop/src/preview/BrowserImport/BrowserImport.ts created+270| ... | @@ -0,0 +1,270 @@ | ||
| 1 | /** | ||
| 2 | * Browser import service - lists importable sources and writes their cookies | ||
| 3 | * into a T3 Code browser profile's Electron partition. | ||
| 4 | * | ||
| 5 | * @module BrowserImport | ||
| 6 | */ | ||
| 7 | import type { | ||
| 8 | BrowserImportInput, | ||
| 9 | BrowserImportResult, | ||
| 10 | BrowserImportSource, | ||
| 11 | BrowserImportUnavailableReason, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import { BrowserImportFailureReason } from "@t3tools/contracts"; | ||
| 14 | import * as Context from "effect/Context"; | ||
| 15 | import type { Session } from "electron"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as FileSystem from "effect/FileSystem"; | ||
| 18 | import * as Layer from "effect/Layer"; | ||
| 19 | import * as Path from "effect/Path"; | ||
| 20 | import * as Schema from "effect/Schema"; | ||
| 21 | |||
| 22 | import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 23 | |||
| 24 | import * as BrowserSession from "../BrowserSession.ts"; | ||
| 25 | import { readChromiumCookies, type CookieReadResult } from "./ChromiumCookies.ts"; | ||
| 26 | import { | ||
| 27 | BROWSER_IMPORT_SOURCES, | ||
| 28 | resolveCookieDatabase, | ||
| 29 | isSourceInstalled, | ||
| 30 | isSourceRunning, | ||
| 31 | listSourceProfiles, | ||
| 32 | sourcePaths, | ||
| 33 | type BrowserImportSourceDefinition, | ||
| 34 | type SourcePaths, | ||
| 35 | } from "./Sources.ts"; | ||
| 36 | |||
| 37 | export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()( | ||
| 38 | "BrowserImportFailedError", | ||
| 39 | { | ||
| 40 | sourceId: Schema.String, | ||
| 41 | reason: BrowserImportFailureReason, | ||
| 42 | /** Kept for the log; the user only ever sees the reason's copy. */ | ||
| 43 | cause: Schema.optional(Schema.Defect()), | ||
| 44 | }, | ||
| 45 | ) { | ||
| 46 | // The reason token is part of the message on purpose: IPC flattens the error | ||
| 47 | // to its message, and the renderer maps that token back to user-facing copy. | ||
| 48 | override get message(): string { | ||
| 49 | return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`; | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | export class BrowserCookieWriteError extends Schema.TaggedErrorClass<BrowserCookieWriteError>()( | ||
| 54 | "BrowserCookieWriteError", | ||
| 55 | { | ||
| 56 | url: Schema.String, | ||
| 57 | name: Schema.String, | ||
| 58 | cause: Schema.Defect(), | ||
| 59 | }, | ||
| 60 | ) { | ||
| 61 | override get message(): string { | ||
| 62 | return `Could not write imported cookie ${this.name} for ${this.url}.`; | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | export class BrowserImport extends Context.Service< | ||
| 67 | BrowserImport, | ||
| 68 | { | ||
| 69 | readonly listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>>; | ||
| 70 | readonly importCookies: (input: { | ||
| 71 | readonly input: BrowserImportInput; | ||
| 72 | /** Partition scope of the target profile, derived by the caller in main. */ | ||
| 73 | readonly scope: string; | ||
| 74 | readonly persistent: boolean; | ||
| 75 | readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; | ||
| 76 | }) => Effect.Effect<BrowserImportResult, BrowserImportFailedError>; | ||
| 77 | } | ||
| 78 | >()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {} | ||
| 79 | |||
| 80 | const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* ( | ||
| 81 | definition: BrowserImportSourceDefinition, | ||
| 82 | platform: NodeJS.Platform, | ||
| 83 | paths: SourcePaths, | ||
| 84 | ): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> { | ||
| 85 | if (!definition.platforms.includes(platform)) return "unsupportedPlatform"; | ||
| 86 | if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled"; | ||
| 87 | if (yield* isSourceRunning(definition, paths)) return "browserRunning"; | ||
| 88 | return undefined; | ||
| 89 | }); | ||
| 90 | |||
| 91 | /** The host a constructed cookie URL points at, for naming what was skipped. */ | ||
| 92 | const cookieHost = (url: string): string => { | ||
| 93 | try { | ||
| 94 | return new URL(url).hostname; | ||
| 95 | } catch { | ||
| 96 | return url; | ||
| 97 | } | ||
| 98 | }; | ||
| 99 | |||
| 100 | export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( | ||
| 101 | session: { readonly cookies: Pick<Session["cookies"], "set" | "flushStore"> }, | ||
| 102 | read: CookieReadResult, | ||
| 103 | ) { | ||
| 104 | let imported = 0; | ||
| 105 | let skipped = read.undecryptable; | ||
| 106 | const skippedDomains = new Set(read.undecryptableHosts); | ||
| 107 | for (const cookie of read.cookies) { | ||
| 108 | const written = yield* Effect.tryPromise({ | ||
| 109 | try: () => | ||
| 110 | session.cookies.set({ | ||
| 111 | url: cookie.url, | ||
| 112 | name: cookie.name, | ||
| 113 | value: cookie.value, | ||
| 114 | // Omitted for host-only cookies: Electron reads any `domain` as a | ||
| 115 | // domain cookie and re-adds the leading dot, widening its scope. | ||
| 116 | ...(cookie.domain === undefined ? {} : { domain: cookie.domain }), | ||
| 117 | path: cookie.path, | ||
| 118 | secure: cookie.secure, | ||
| 119 | httpOnly: cookie.httpOnly, | ||
| 120 | sameSite: cookie.sameSite, | ||
| 121 | ...(cookie.expirationDate === undefined ? {} : { expirationDate: cookie.expirationDate }), | ||
| 122 | }), | ||
| 123 | catch: (cause) => new BrowserCookieWriteError({ url: cookie.url, name: cookie.name, cause }), | ||
| 124 | }).pipe( | ||
| 125 | Effect.as(true), | ||
| 126 | Effect.tapError((error) => Effect.logDebug(error.message, { cause: error.cause })), | ||
| 127 | Effect.catchTags({ BrowserCookieWriteError: () => Effect.succeed(false) }), | ||
| 128 | ); | ||
| 129 | if (written) { | ||
| 130 | imported += 1; | ||
| 131 | } else { | ||
| 132 | skipped += 1; | ||
| 133 | skippedDomains.add(cookieHost(cookie.url)); | ||
| 134 | } | ||
| 135 | } | ||
| 136 | // `set` resolves once the cookie is in memory; Chromium writes the store to | ||
| 137 | // disk on its own schedule. Flush before reporting "Done", so a crash right | ||
| 138 | // after does not lose what the user was just told was imported. A failed | ||
| 139 | // flush is logged rather than surfaced: the cookies are still in the | ||
| 140 | // session and land on disk at the next scheduled write. | ||
| 141 | if (imported > 0) { | ||
| 142 | yield* Effect.tryPromise(() => session.cookies.flushStore()).pipe( | ||
| 143 | Effect.tapError((error) => | ||
| 144 | Effect.logWarning("Imported cookies could not be flushed to disk", { cause: error.cause }), | ||
| 145 | ), | ||
| 146 | Effect.ignore, | ||
| 147 | ); | ||
| 148 | } | ||
| 149 | return { imported, skipped, skippedDomains: [...skippedDomains].slice(0, 20) }; | ||
| 150 | }); | ||
| 151 | |||
| 152 | export const make = Effect.gen(function* BrowserImportMake() { | ||
| 153 | const browserSession = yield* BrowserSession.BrowserSession; | ||
| 154 | const platform = yield* HostProcessPlatform; | ||
| 155 | const executablePath = yield* HostProcessExecutablePath; | ||
| 156 | // Captured here so the service's methods stay free of a requirements | ||
| 157 | // channel: the layer is built where NodeServices is already in scope. | ||
| 158 | const platformServices = yield* Effect.context<FileSystem.FileSystem | Path.Path>(); | ||
| 159 | const paths = yield* sourcePaths; | ||
| 160 | |||
| 161 | const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach( | ||
| 162 | BROWSER_IMPORT_SOURCES, | ||
| 163 | Effect.fnUntraced(function* (definition) { | ||
| 164 | const unavailable = yield* unavailableReason(definition, platform, paths); | ||
| 165 | return { | ||
| 166 | id: definition.id, | ||
| 167 | name: definition.name, | ||
| 168 | // Listing profiles touches the source's own files, so skip it when the | ||
| 169 | // source is unusable anyway. | ||
| 170 | profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [], | ||
| 171 | ...(unavailable === undefined ? {} : { unavailable }), | ||
| 172 | } satisfies BrowserImportSource; | ||
| 173 | }), | ||
| 174 | ).pipe(Effect.provide(platformServices)); | ||
| 175 | |||
| 176 | const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: { | ||
| 177 | readonly input: BrowserImportInput; | ||
| 178 | readonly scope: string; | ||
| 179 | readonly persistent: boolean; | ||
| 180 | readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; | ||
| 181 | }) { | ||
| 182 | const definition = BROWSER_IMPORT_SOURCES.find( | ||
| 183 | (candidate) => candidate.id === input.input.sourceId, | ||
| 184 | ); | ||
| 185 | if (!definition) { | ||
| 186 | return yield* new BrowserImportFailedError({ | ||
| 187 | sourceId: input.input.sourceId, | ||
| 188 | reason: "unknownSource", | ||
| 189 | }); | ||
| 190 | } | ||
| 191 | |||
| 192 | const blocked = yield* unavailableReason(definition, platform, paths).pipe( | ||
| 193 | Effect.provide(platformServices), | ||
| 194 | ); | ||
| 195 | if (blocked !== undefined) { | ||
| 196 | return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked }); | ||
| 197 | } | ||
| 198 | |||
| 199 | // macOS attributes the Keychain prompt and the resulting ACL grant to the | ||
| 200 | // executable that asks, so record which one that was — in a packaged build | ||
| 201 | // it is the signed app, in dev whatever binary hosts the main process. | ||
| 202 | yield* Effect.logInfo("Reading browser cookie key from the keychain", { | ||
| 203 | sourceId: definition.id, | ||
| 204 | executablePath, | ||
| 205 | }); | ||
| 206 | |||
| 207 | // The profile directory arrives over IPC, so it is only honoured when the | ||
| 208 | // source itself reported it. Forwarding it unchecked would let `..` | ||
| 209 | // segments walk out of the browser's user-data directory and read any | ||
| 210 | // cookie database reachable on disk. | ||
| 211 | const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe( | ||
| 212 | Effect.provide(platformServices), | ||
| 213 | ); | ||
| 214 | const requestedProfile = sourceProfiles.find( | ||
| 215 | (profile) => profile.directory === input.input.sourceProfileDirectory, | ||
| 216 | ); | ||
| 217 | if (requestedProfile === undefined) { | ||
| 218 | return yield* new BrowserImportFailedError({ | ||
| 219 | sourceId: definition.id, | ||
| 220 | reason: "unknownSourceProfile", | ||
| 221 | }); | ||
| 222 | } | ||
| 223 | |||
| 224 | // The profile was listed against a database moments ago; resolve it again | ||
| 225 | // rather than assume a path, since the live jar may sit under `Network/`. | ||
| 226 | const databasePath = yield* resolveCookieDatabase( | ||
| 227 | definition, | ||
| 228 | paths, | ||
| 229 | requestedProfile.directory, | ||
| 230 | ).pipe(Effect.provide(platformServices)); | ||
| 231 | if (databasePath === undefined) { | ||
| 232 | return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" }); | ||
| 233 | } | ||
| 234 | |||
| 235 | const read = yield* readChromiumCookies({ | ||
| 236 | cookieDatabasePath: databasePath, | ||
| 237 | keychainService: definition.keychainService, | ||
| 238 | keychainAccount: definition.keychainAccount, | ||
| 239 | platform, | ||
| 240 | }).pipe( | ||
| 241 | Effect.scoped, | ||
| 242 | Effect.provide(platformServices), | ||
| 243 | Effect.mapError( | ||
| 244 | (cause) => | ||
| 245 | new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), | ||
| 246 | ), | ||
| 247 | ); | ||
| 248 | |||
| 249 | const session = yield* browserSession | ||
| 250 | .getSession(input.scope, input.persistent, input.namespace) | ||
| 251 | .pipe( | ||
| 252 | Effect.mapError( | ||
| 253 | (cause) => | ||
| 254 | new BrowserImportFailedError({ | ||
| 255 | sourceId: definition.id, | ||
| 256 | reason: "sessionUnavailable", | ||
| 257 | cause, | ||
| 258 | }), | ||
| 259 | ), | ||
| 260 | ); | ||
| 261 | |||
| 262 | // Written one at a time rather than in parallel: Chromium's cookie store | ||
| 263 | // serialises writes anyway, and a rejected cookie should only cost itself. | ||
| 264 | return yield* writeCookies(session, read); | ||
| 265 | }); | ||
| 266 | |||
| 267 | return BrowserImport.of({ listSources, importCookies }); | ||
| 268 | }); | ||
| 269 | |||
| 270 | export const layer = Layer.effect(BrowserImport, make); | ||
apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts created+379| ... | @@ -0,0 +1,379 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Encrypts a fixture with the same | ||
| 2 | // OSCrypt primitive as Chromium. | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 4 | import { describe, expect, it } from "@effect/vitest"; | ||
| 5 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | ||
| 6 | import * as NodeCrypto from "node:crypto"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as FileSystem from "effect/FileSystem"; | ||
| 9 | import * as Path from "effect/Path"; | ||
| 10 | import * as Scope from "effect/Scope"; | ||
| 11 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | cookieScope, | ||
| 15 | readChromiumCookieDatabase, | ||
| 16 | snapshotCookieDatabase, | ||
| 17 | } from "./ChromiumCookies.ts"; | ||
| 18 | |||
| 19 | const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => { | ||
| 20 | const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20)); | ||
| 21 | return Buffer.concat([Buffer.from("v10"), cipher.update(value), cipher.final()]); | ||
| 22 | }; | ||
| 23 | |||
| 24 | const runNode = <A, E>( | ||
| 25 | effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>, | ||
| 26 | ) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); | ||
| 27 | |||
| 28 | describe("snapshotCookieDatabase", () => { | ||
| 29 | it.effect("includes committed WAL data in one consistent database", () => | ||
| 30 | runNode( | ||
| 31 | Effect.gen(function* () { | ||
| 32 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 33 | const path = yield* Path.Path; | ||
| 34 | const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 35 | prefix: "t3code-cookie-source-", | ||
| 36 | }); | ||
| 37 | const source = path.join(sourceDirectory, "Cookies"); | ||
| 38 | |||
| 39 | const snapshot = yield* Effect.gen(function* () { | ||
| 40 | const sql = yield* SqlClient.SqlClient; | ||
| 41 | yield* sql`PRAGMA journal_mode = WAL`; | ||
| 42 | yield* sql`PRAGMA wal_autocheckpoint = 0`; | ||
| 43 | yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`; | ||
| 44 | yield* sql`INSERT INTO cookies(name) VALUES (${"committed-in-wal"})`; | ||
| 45 | |||
| 46 | expect(yield* fileSystem.exists(`${source}-wal`)).toBe(true); | ||
| 47 | return yield* snapshotCookieDatabase(source); | ||
| 48 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source }))); | ||
| 49 | |||
| 50 | const rows = yield* Effect.gen(function* () { | ||
| 51 | const sql = yield* SqlClient.SqlClient; | ||
| 52 | return yield* sql<{ readonly name: string }>`SELECT name FROM cookies`; | ||
| 53 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshot, readonly: true }))); | ||
| 54 | |||
| 55 | expect(rows).toEqual([{ name: "committed-in-wal" }]); | ||
| 56 | }), | ||
| 57 | ), | ||
| 58 | ); | ||
| 59 | |||
| 60 | it.effect("propagates snapshot failures and removes its temporary directory", () => | ||
| 61 | runNode( | ||
| 62 | Effect.gen(function* () { | ||
| 63 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 64 | const path = yield* Path.Path; | ||
| 65 | const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 66 | prefix: "t3code-cookie-invalid-source-", | ||
| 67 | }); | ||
| 68 | const source = path.join(sourceDirectory, "Cookies"); | ||
| 69 | yield* fileSystem.writeFileString(source, "not a sqlite database"); | ||
| 70 | |||
| 71 | const prefix = `t3code-cookie-failed-${process.pid}-`; | ||
| 72 | const error = yield* snapshotCookieDatabase(source, prefix).pipe( | ||
| 73 | Effect.scoped, | ||
| 74 | Effect.flip, | ||
| 75 | ); | ||
| 76 | |||
| 77 | expect(error._tag).toBe("SqlError"); | ||
| 78 | const temporaryEntries = yield* fileSystem.readDirectory(path.dirname(sourceDirectory)); | ||
| 79 | expect(temporaryEntries.some((entry) => entry.startsWith(prefix))).toBe(false); | ||
| 80 | }), | ||
| 81 | ), | ||
| 82 | ); | ||
| 83 | |||
| 84 | it.effect("removes a successful snapshot when its scope closes", () => | ||
| 85 | runNode( | ||
| 86 | Effect.gen(function* () { | ||
| 87 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 88 | const path = yield* Path.Path; | ||
| 89 | const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 90 | prefix: "t3code-cookie-cleanup-source-", | ||
| 91 | }); | ||
| 92 | const source = path.join(sourceDirectory, "Cookies"); | ||
| 93 | |||
| 94 | yield* Effect.gen(function* () { | ||
| 95 | const sql = yield* SqlClient.SqlClient; | ||
| 96 | yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`; | ||
| 97 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source }))); | ||
| 98 | |||
| 99 | const snapshot = yield* snapshotCookieDatabase(source).pipe(Effect.scoped); | ||
| 100 | expect(yield* fileSystem.exists(snapshot)).toBe(false); | ||
| 101 | }), | ||
| 102 | ), | ||
| 103 | ); | ||
| 104 | }); | ||
| 105 | |||
| 106 | describe("cookieScope", () => { | ||
| 107 | it("keeps a host-only cookie host-only", () => { | ||
| 108 | // Chromium stores a host-only cookie without a leading dot. Passing any | ||
| 109 | // `domain` to Electron makes it a domain cookie and re-adds the dot, which | ||
| 110 | // would expose the cookie to every subdomain it was never scoped to. | ||
| 111 | expect(cookieScope("example.test", "/", true)).toEqual({ | ||
| 112 | url: "https://example.test/", | ||
| 113 | domain: undefined, | ||
| 114 | }); | ||
| 115 | }); | ||
| 116 | |||
| 117 | it("preserves a domain cookie's leading dot", () => { | ||
| 118 | expect(cookieScope(".example.test", "/app", true)).toEqual({ | ||
| 119 | url: "https://example.test/app", | ||
| 120 | domain: ".example.test", | ||
| 121 | }); | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("matches the scheme to the secure flag", () => { | ||
| 125 | expect(cookieScope("example.test", "/", false).url).toBe("http://example.test/"); | ||
| 126 | }); | ||
| 127 | |||
| 128 | it("brackets bare IPv6 hosts without duplicating existing brackets", () => { | ||
| 129 | expect(cookieScope("::1", "/", false)).toEqual({ | ||
| 130 | url: "http://[::1]/", | ||
| 131 | domain: undefined, | ||
| 132 | }); | ||
| 133 | expect(cookieScope("[::1]", "/app", true)).toEqual({ | ||
| 134 | url: "https://[::1]/app", | ||
| 135 | domain: undefined, | ||
| 136 | }); | ||
| 137 | }); | ||
| 138 | }); | ||
| 139 | |||
| 140 | describe("readChromiumCookieDatabase", () => { | ||
| 141 | it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () => | ||
| 142 | Effect.gen(function* () { | ||
| 143 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 144 | const directory = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 145 | prefix: "t3code-chromium-cookies-", | ||
| 146 | }); | ||
| 147 | const filename = `${directory}/Cookies`; | ||
| 148 | const key = Buffer.from("0123456789abcdef"); | ||
| 149 | |||
| 150 | yield* Effect.gen(function* () { | ||
| 151 | const sql = yield* SqlClient.SqlClient; | ||
| 152 | yield* sql`create table meta (key text primary key, value text not null)`; | ||
| 153 | yield* sql`insert into meta values ('version', 23)`; | ||
| 154 | yield* sql` | ||
| 155 | create table cookies ( | ||
| 156 | host_key text not null, | ||
| 157 | name text not null, | ||
| 158 | value text not null, | ||
| 159 | encrypted_value blob not null, | ||
| 160 | path text not null, | ||
| 161 | expires_utc integer not null, | ||
| 162 | is_secure integer not null, | ||
| 163 | is_httponly integer not null, | ||
| 164 | samesite integer not null, | ||
| 165 | top_frame_site_key text not null default '' | ||
| 166 | ) | ||
| 167 | `; | ||
| 168 | yield* sql` | ||
| 169 | insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values | ||
| 170 | ('plain.example', 'plain', 'stored plaintext', ${new Uint8Array()}, '/', 0, 0, 0, -1) | ||
| 171 | `; | ||
| 172 | yield* sql` | ||
| 173 | insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values | ||
| 174 | ('secure.example', 'encrypted', '', ${encryptV10("stored encrypted", key)}, '/', 0, 1, 1, 2) | ||
| 175 | `; | ||
| 176 | yield* sql` | ||
| 177 | insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values | ||
| 178 | ('empty.example', 'empty', '', ${new Uint8Array()}, '/', 0, 0, 0, 0) | ||
| 179 | `; | ||
| 180 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); | ||
| 181 | |||
| 182 | const result = yield* readChromiumCookieDatabase(filename, key, "darwin"); | ||
| 183 | |||
| 184 | expect(result.undecryptable).toBe(0); | ||
| 185 | expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ | ||
| 186 | { name: "plain", value: "stored plaintext" }, | ||
| 187 | { name: "encrypted", value: "stored encrypted" }, | ||
| 188 | { name: "empty", value: "" }, | ||
| 189 | ]); | ||
| 190 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 191 | ); | ||
| 192 | |||
| 193 | it.effect("enforces domain binding only for schema 24 and newer", () => | ||
| 194 | Effect.gen(function* () { | ||
| 195 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 196 | const directory = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 197 | prefix: "t3code-chromium-cookies-", | ||
| 198 | }); | ||
| 199 | const filename = `${directory}/Cookies`; | ||
| 200 | const key = Buffer.from("0123456789abcdef"); | ||
| 201 | const boundValue = (host: string, value: string) => | ||
| 202 | Buffer.concat([NodeCrypto.createHash("sha256").update(host).digest(), Buffer.from(value)]); | ||
| 203 | |||
| 204 | yield* Effect.gen(function* () { | ||
| 205 | const sql = yield* SqlClient.SqlClient; | ||
| 206 | yield* sql`create table meta (key text primary key, value text not null)`; | ||
| 207 | yield* sql`insert into meta values ('version', 24)`; | ||
| 208 | yield* sql` | ||
| 209 | create table cookies ( | ||
| 210 | host_key text not null, name text not null, value text not null, | ||
| 211 | encrypted_value blob not null, path text not null, expires_utc integer not null, | ||
| 212 | is_secure integer not null, is_httponly integer not null, samesite integer not null, | ||
| 213 | top_frame_site_key text not null default '' | ||
| 214 | ) | ||
| 215 | `; | ||
| 216 | yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values | ||
| 217 | ('bound.example', 'valid', '', ${encryptV10(boundValue("bound.example", "kept"), key)}, '/', 0, 1, 0, 0)`; | ||
| 218 | yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values | ||
| 219 | ('wrong.example', 'mismatch', '', ${encryptV10(boundValue("another.example", "drop"), key)}, '/', 0, 1, 0, 0)`; | ||
| 220 | yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values | ||
| 221 | ('short.example', 'short', '', ${encryptV10("short value", key)}, '/', 0, 1, 0, 0)`; | ||
| 222 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); | ||
| 223 | |||
| 224 | const result = yield* readChromiumCookieDatabase(filename, key, "darwin"); | ||
| 225 | |||
| 226 | expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ | ||
| 227 | { name: "valid", value: "kept" }, | ||
| 228 | ]); | ||
| 229 | expect(result.undecryptable).toBe(2); | ||
| 230 | expect(result.undecryptableHosts).toEqual(["wrong.example", "short.example"]); | ||
| 231 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 232 | ); | ||
| 233 | |||
| 234 | it.effect("preserves arbitrary long encrypted values from pre-24 schemas", () => | ||
| 235 | Effect.gen(function* () { | ||
| 236 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 237 | const directory = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 238 | prefix: "t3code-chromium-cookies-", | ||
| 239 | }); | ||
| 240 | const filename = `${directory}/Cookies`; | ||
| 241 | const key = Buffer.from("0123456789abcdef"); | ||
| 242 | const value = "x".repeat(32) + " legacy value"; | ||
| 243 | |||
| 244 | yield* Effect.gen(function* () { | ||
| 245 | const sql = yield* SqlClient.SqlClient; | ||
| 246 | yield* sql`create table meta (key text primary key, value text not null)`; | ||
| 247 | yield* sql`insert into meta values ('version', 23)`; | ||
| 248 | yield* sql` | ||
| 249 | create table cookies ( | ||
| 250 | host_key text not null, name text not null, value text not null, | ||
| 251 | encrypted_value blob not null, path text not null, expires_utc integer not null, | ||
| 252 | is_secure integer not null, is_httponly integer not null, samesite integer not null, | ||
| 253 | top_frame_site_key text not null default '' | ||
| 254 | ) | ||
| 255 | `; | ||
| 256 | yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values | ||
| 257 | ('legacy.example', 'legacy', '', ${encryptV10(value, key)}, '/', 0, 0, 0, 0)`; | ||
| 258 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); | ||
| 259 | |||
| 260 | const result = yield* readChromiumCookieDatabase(filename, key, "darwin"); | ||
| 261 | expect(result.cookies[0]?.value).toBe(value); | ||
| 262 | expect(result.undecryptable).toBe(0); | ||
| 263 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 264 | ); | ||
| 265 | |||
| 266 | it.effect("rejects a malformed text schema version", () => | ||
| 267 | Effect.gen(function* () { | ||
| 268 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 269 | const directory = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 270 | prefix: "t3code-chromium-cookies-", | ||
| 271 | }); | ||
| 272 | const filename = `${directory}/Cookies`; | ||
| 273 | |||
| 274 | yield* Effect.gen(function* () { | ||
| 275 | const sql = yield* SqlClient.SqlClient; | ||
| 276 | yield* sql`create table meta (key text primary key, value text not null)`; | ||
| 277 | yield* sql`insert into meta values ('version', 'not-a-version')`; | ||
| 278 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); | ||
| 279 | |||
| 280 | const error = yield* readChromiumCookieDatabase( | ||
| 281 | filename, | ||
| 282 | Buffer.from("0123456789abcdef"), | ||
| 283 | "darwin", | ||
| 284 | ).pipe(Effect.flip); | ||
| 285 | |||
| 286 | expect(error._tag).toBe("SchemaError"); | ||
| 287 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 288 | ); | ||
| 289 | |||
| 290 | it.effect("treats unversioned encrypted values as legacy plaintext only on macOS", () => | ||
| 291 | Effect.gen(function* () { | ||
| 292 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 293 | const directory = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 294 | prefix: "t3code-chromium-cookies-", | ||
| 295 | }); | ||
| 296 | const filename = `${directory}/Cookies`; | ||
| 297 | const key = Buffer.from("0123456789abcdef"); | ||
| 298 | |||
| 299 | yield* Effect.gen(function* () { | ||
| 300 | const sql = yield* SqlClient.SqlClient; | ||
| 301 | yield* sql`create table meta (key text primary key, value integer not null)`; | ||
| 302 | yield* sql`insert into meta values ('version', 23)`; | ||
| 303 | yield* sql` | ||
| 304 | create table cookies ( | ||
| 305 | host_key text not null, name text not null, value text not null, | ||
| 306 | encrypted_value blob not null, path text not null, expires_utc integer not null, | ||
| 307 | is_secure integer not null, is_httponly integer not null, samesite integer not null, | ||
| 308 | top_frame_site_key text not null default '' | ||
| 309 | ) | ||
| 310 | `; | ||
| 311 | yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values | ||
| 312 | ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`; | ||
| 313 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); | ||
| 314 | |||
| 315 | const mac = yield* readChromiumCookieDatabase(filename, key, "darwin"); | ||
| 316 | const linux = yield* readChromiumCookieDatabase(filename, key, "linux"); | ||
| 317 | |||
| 318 | expect(mac.cookies[0]?.value).toBe("legacy cleartext"); | ||
| 319 | expect(mac.undecryptable).toBe(0); | ||
| 320 | expect(linux.cookies).toEqual([]); | ||
| 321 | expect(linux.undecryptable).toBe(1); | ||
| 322 | expect(linux.undecryptableHosts).toEqual(["legacy.example"]); | ||
| 323 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 324 | ); | ||
| 325 | |||
| 326 | it.effect("skips partitioned cookies without breaking pre-CHIPS schemas", () => | ||
| 327 | Effect.gen(function* () { | ||
| 328 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 329 | const directory = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 330 | prefix: "t3code-chromium-cookies-", | ||
| 331 | }); | ||
| 332 | const legacyFilename = `${directory}/LegacyCookies`; | ||
| 333 | const chipsFilename = `${directory}/ChipsCookies`; | ||
| 334 | const key = Buffer.from("0123456789abcdef"); | ||
| 335 | |||
| 336 | yield* Effect.gen(function* () { | ||
| 337 | const sql = yield* SqlClient.SqlClient; | ||
| 338 | yield* sql`create table meta (key text primary key, value integer not null)`; | ||
| 339 | yield* sql`insert into meta values ('version', 14)`; | ||
| 340 | yield* sql` | ||
| 341 | create table cookies ( | ||
| 342 | host_key text not null, name text not null, value text not null, | ||
| 343 | encrypted_value blob not null, path text not null, expires_utc integer not null, | ||
| 344 | is_secure integer not null, is_httponly integer not null, samesite integer not null | ||
| 345 | ) | ||
| 346 | `; | ||
| 347 | yield* sql`insert into cookies values | ||
| 348 | ('legacy.example', 'legacy', 'kept', ${new Uint8Array()}, '/', 0, 0, 0, 0)`; | ||
| 349 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: legacyFilename }))); | ||
| 350 | |||
| 351 | yield* Effect.gen(function* () { | ||
| 352 | const sql = yield* SqlClient.SqlClient; | ||
| 353 | yield* sql`create table meta (key text primary key, value integer not null)`; | ||
| 354 | yield* sql`insert into meta values ('version', 23)`; | ||
| 355 | yield* sql` | ||
| 356 | create table cookies ( | ||
| 357 | host_key text not null, name text not null, value text not null, | ||
| 358 | encrypted_value blob not null, path text not null, expires_utc integer not null, | ||
| 359 | is_secure integer not null, is_httponly integer not null, samesite integer not null, | ||
| 360 | top_frame_site_key text not null | ||
| 361 | ) | ||
| 362 | `; | ||
| 363 | yield* sql`insert into cookies values | ||
| 364 | ('plain.example', 'plain', 'kept', ${new Uint8Array()}, '/', 0, 0, 0, 0, '')`; | ||
| 365 | yield* sql`insert into cookies values | ||
| 366 | ('partitioned.example', 'partitioned', 'must skip', ${new Uint8Array()}, '/', 0, 1, 0, 0, 'https://top.example')`; | ||
| 367 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: chipsFilename }))); | ||
| 368 | |||
| 369 | const legacy = yield* readChromiumCookieDatabase(legacyFilename, key, "darwin"); | ||
| 370 | const chips = yield* readChromiumCookieDatabase(chipsFilename, key, "darwin"); | ||
| 371 | |||
| 372 | expect(legacy.cookies.map(({ name }) => name)).toEqual(["legacy"]); | ||
| 373 | expect(legacy.undecryptable).toBe(0); | ||
| 374 | expect(chips.cookies.map(({ name }) => name)).toEqual(["plain"]); | ||
| 375 | expect(chips.undecryptable).toBe(1); | ||
| 376 | expect(chips.undecryptableHosts).toEqual(["partitioned.example"]); | ||
| 377 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 378 | ); | ||
| 379 | }); | ||
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts created+409| ... | @@ -0,0 +1,409 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the | ||
| 2 | // OSCrypt primitives Chromium uses; Effect has no equivalent. | ||
| 3 | /** | ||
| 4 | * Chromium cookie extraction. | ||
| 5 | * | ||
| 6 | * Reads a Chromium-family browser's cookie database and decrypts it with the | ||
| 7 | * key the OS keychain hands us, which is the mechanism the browser itself | ||
| 8 | * uses. macOS mediates that with a per-app consent prompt, so the user | ||
| 9 | * explicitly approves T3 Code reading it. | ||
| 10 | * | ||
| 11 | * Deliberately no fallback when the keychain says no: the alternative | ||
| 12 | * techniques exist to defeat that consent, and this feature is not worth | ||
| 13 | * shipping them. | ||
| 14 | * | ||
| 15 | * @module ChromiumCookies | ||
| 16 | */ | ||
| 17 | import * as Keyring from "@napi-rs/keyring"; | ||
| 18 | import * as NodeCrypto from "node:crypto"; | ||
| 19 | |||
| 20 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | ||
| 21 | import * as Effect from "effect/Effect"; | ||
| 22 | import * as FileSystem from "effect/FileSystem"; | ||
| 23 | import * as Path from "effect/Path"; | ||
| 24 | import * as Schema from "effect/Schema"; | ||
| 25 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 26 | |||
| 27 | /** macOS OSCrypt parameters. Chromium has used these since the feature landed. */ | ||
| 28 | const MAC_KEY_ITERATIONS = 1003; | ||
| 29 | const MAC_KEY_SALT = "saltysalt"; | ||
| 30 | const MAC_KEY_LENGTH = 16; | ||
| 31 | /** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */ | ||
| 32 | const AES_IV = Buffer.alloc(16, 0x20); | ||
| 33 | const V10_PREFIX = "v10"; | ||
| 34 | |||
| 35 | export interface ChromiumCookie { | ||
| 36 | readonly url: string; | ||
| 37 | readonly name: string; | ||
| 38 | readonly value: string; | ||
| 39 | /** | ||
| 40 | * Set only for domain cookies, which Chromium stores with a leading dot. | ||
| 41 | * A host-only cookie leaves this undefined: Electron treats any `domain` it | ||
| 42 | * is given as a domain cookie and re-adds the dot, which would widen the | ||
| 43 | * cookie to every subdomain of the host it was scoped to. | ||
| 44 | */ | ||
| 45 | readonly domain: string | undefined; | ||
| 46 | readonly path: string; | ||
| 47 | readonly secure: boolean; | ||
| 48 | readonly httpOnly: boolean; | ||
| 49 | /** Seconds since the UNIX epoch, or undefined for a session cookie. */ | ||
| 50 | readonly expirationDate: number | undefined; | ||
| 51 | readonly sameSite: "no_restriction" | "lax" | "strict"; | ||
| 52 | } | ||
| 53 | |||
| 54 | export const ChromiumCookieReadReason = Schema.Literals([ | ||
| 55 | "needsKeychainApproval", | ||
| 56 | "keychainItemMissing", | ||
| 57 | "browserRunning", | ||
| 58 | "unsupportedPlatform", | ||
| 59 | "readFailed", | ||
| 60 | ]); | ||
| 61 | export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type; | ||
| 62 | |||
| 63 | export class ChromiumCookieReadError extends Schema.TaggedErrorClass<ChromiumCookieReadError>()( | ||
| 64 | "ChromiumCookieReadError", | ||
| 65 | { | ||
| 66 | reason: ChromiumCookieReadReason, | ||
| 67 | /** | ||
| 68 | * Which database the read was for. Without it every `readFailed` and | ||
| 69 | * keychain failure logs identically, and a user with several browsers | ||
| 70 | * installed has no way to tell which one refused. | ||
| 71 | */ | ||
| 72 | cookieDatabasePath: Schema.String, | ||
| 73 | /** Kept for the log; never surfaced to the user. */ | ||
| 74 | cause: Schema.optional(Schema.Defect()), | ||
| 75 | }, | ||
| 76 | ) { | ||
| 77 | override get message(): string { | ||
| 78 | return `Could not read Chromium cookies at ${this.cookieDatabasePath}: ${this.reason}.`; | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | /** Row shape of the cookie table, decoded rather than cast. */ | ||
| 83 | const CookieRow = Schema.Struct({ | ||
| 84 | host_key: Schema.String, | ||
| 85 | name: Schema.String, | ||
| 86 | value: Schema.String, | ||
| 87 | encrypted_value: Schema.Uint8Array, | ||
| 88 | path: Schema.String, | ||
| 89 | expires_seconds: Schema.Number, | ||
| 90 | is_secure: Schema.Number, | ||
| 91 | is_httponly: Schema.Number, | ||
| 92 | samesite: Schema.Number, | ||
| 93 | top_frame_site_key: Schema.String, | ||
| 94 | }); | ||
| 95 | |||
| 96 | const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); | ||
| 97 | const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); | ||
| 98 | const SchemaVersion = Schema.Union([ | ||
| 99 | NonNegativeInt, | ||
| 100 | Schema.FiniteFromString.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), | ||
| 101 | ]); | ||
| 102 | const decodeSchemaVersion = Schema.decodeUnknownEffect( | ||
| 103 | Schema.Tuple([Schema.Struct({ value: SchemaVersion })]), | ||
| 104 | ); | ||
| 105 | |||
| 106 | /** | ||
| 107 | * Chromium stores `SameSite` as an int; unspecified (-1) behaves as Lax in | ||
| 108 | * modern Chromium, so it maps there rather than to `no_restriction`, which | ||
| 109 | * would widen the cookie's scope on import. | ||
| 110 | */ | ||
| 111 | const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => { | ||
| 112 | if (value === 0) return "no_restriction"; | ||
| 113 | if (value === 2) return "strict"; | ||
| 114 | return "lax"; | ||
| 115 | }; | ||
| 116 | |||
| 117 | /** | ||
| 118 | * Chromium timestamps count microseconds from 1601-01-01; Electron wants | ||
| 119 | * seconds from the UNIX epoch. | ||
| 120 | * | ||
| 121 | * The microsecond value overflows JavaScript's safe integer range, and | ||
| 122 | * `node:sqlite` refuses to narrow it, so the division happens in SQL and this | ||
| 123 | * only ever sees seconds. | ||
| 124 | */ | ||
| 125 | const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600; | ||
| 126 | const toUnixSeconds = (webkitSeconds: number): number | undefined => { | ||
| 127 | if (webkitSeconds <= 0) return undefined; | ||
| 128 | return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS; | ||
| 129 | }; | ||
| 130 | |||
| 131 | /** | ||
| 132 | * Reads the OSCrypt key from the login keychain. | ||
| 133 | * | ||
| 134 | * Uses the in-process Keychain API rather than shelling out to | ||
| 135 | * `/usr/bin/security`, because the keychain attributes both the consent prompt | ||
| 136 | * and the resulting ACL entry to the binary that asks. Via the CLI the prompt | ||
| 137 | * says "security" and "Always Allow" grants trust to a tool every process on | ||
| 138 | * the machine can invoke; in-process it names this app and the grant belongs | ||
| 139 | * to it. (In an unsigned dev build the name is the dev Electron binary rather | ||
| 140 | * than the shipped app identity.) | ||
| 141 | * | ||
| 142 | * Deliberately untimed: macOS answers this with a modal, and a timeout racing | ||
| 143 | * the user means the prompt can be approved while nothing is left listening — | ||
| 144 | * which reads as "approving did nothing". | ||
| 145 | */ | ||
| 146 | const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* ( | ||
| 147 | service: string, | ||
| 148 | account: string, | ||
| 149 | cookieDatabasePath: string, | ||
| 150 | ) { | ||
| 151 | const password = yield* Effect.try({ | ||
| 152 | try: () => new Keyring.Entry(service, account).getPassword(), | ||
| 153 | catch: (cause) => { | ||
| 154 | const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); | ||
| 155 | // Distinguish the causes rather than telling the user to approve a | ||
| 156 | // prompt when approving cannot fix the failure. | ||
| 157 | const missing = /no (matching )?entry|not found/i.test(message); | ||
| 158 | return new ChromiumCookieReadError({ | ||
| 159 | reason: missing ? "keychainItemMissing" : "needsKeychainApproval", | ||
| 160 | cookieDatabasePath, | ||
| 161 | cause, | ||
| 162 | }); | ||
| 163 | }, | ||
| 164 | }); | ||
| 165 | if (password === null || password === "") { | ||
| 166 | return yield* new ChromiumCookieReadError({ | ||
| 167 | reason: "keychainItemMissing", | ||
| 168 | cookieDatabasePath, | ||
| 169 | }); | ||
| 170 | } | ||
| 171 | return password; | ||
| 172 | }); | ||
| 173 | |||
| 174 | /** | ||
| 175 | * Chromium keeps the cookie DB open with WAL. SQLite must create the snapshot | ||
| 176 | * itself so the main database and WAL are read from one transactionally | ||
| 177 | * consistent generation. Copying those files one after another can pair a | ||
| 178 | * newer database with an older WAL (or the reverse). | ||
| 179 | * | ||
| 180 | * Scoped: the temp directory is removed when the caller's scope closes. | ||
| 181 | */ | ||
| 182 | export const snapshotCookieDatabase = Effect.fn("ChromiumCookies.snapshotCookieDatabase")( | ||
| 183 | function* (cookiePath: string, tempPrefix = "t3code-cookie-import-") { | ||
| 184 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 185 | const path = yield* Path.Path; | ||
| 186 | |||
| 187 | const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: tempPrefix }); | ||
| 188 | const target = path.join(directory, "Cookies"); | ||
| 189 | |||
| 190 | yield* Effect.gen(function* () { | ||
| 191 | const sql = yield* SqlClient.SqlClient; | ||
| 192 | yield* sql`VACUUM INTO ${target}`; | ||
| 193 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: cookiePath, readonly: true }))); | ||
| 194 | |||
| 195 | return target; | ||
| 196 | }, | ||
| 197 | ); | ||
| 198 | |||
| 199 | /** | ||
| 200 | * The URL and domain Electron should register a stored row under. | ||
| 201 | * | ||
| 202 | * Chromium marks a domain cookie with a leading dot on `host_key`. Electron | ||
| 203 | * matches on a URL, so the dot comes off for that; `domain` is passed through | ||
| 204 | * only for domain cookies, because supplying it at all makes Electron treat | ||
| 205 | * the cookie as one and re-add the dot — widening a host-only cookie to every | ||
| 206 | * subdomain of the host it was scoped to, and rejecting `__Host-` cookies, | ||
| 207 | * which require it to be absent. | ||
| 208 | */ | ||
| 209 | export const cookieScope = ( | ||
| 210 | hostKey: string, | ||
| 211 | path: string, | ||
| 212 | secure: boolean, | ||
| 213 | ): { readonly url: string; readonly domain: string | undefined } => { | ||
| 214 | const isDomainCookie = hostKey.startsWith("."); | ||
| 215 | const host = isDomainCookie ? hostKey.slice(1) : hostKey; | ||
| 216 | const urlAuthority = | ||
| 217 | host.includes(":") && !(host.startsWith("[") && host.endsWith("]")) ? `[${host}]` : host; | ||
| 218 | return { | ||
| 219 | url: `${secure ? "https" : "http"}://${urlAuthority}${path}`, | ||
| 220 | ...(isDomainCookie ? { domain: hostKey } : { domain: undefined }), | ||
| 221 | }; | ||
| 222 | }; | ||
| 223 | |||
| 224 | /** The host without Chromium's domain-cookie leading dot, for display. */ | ||
| 225 | const bareHost = (hostKey: string): string => | ||
| 226 | hostKey.startsWith(".") ? hostKey.slice(1) : hostKey; | ||
| 227 | |||
| 228 | const decryptValue = ( | ||
| 229 | encrypted: Uint8Array, | ||
| 230 | key: Buffer, | ||
| 231 | domain: string, | ||
| 232 | schemaVersion: number, | ||
| 233 | platform: NodeJS.Platform, | ||
| 234 | ): string | null => { | ||
| 235 | const buffer = Buffer.from(encrypted); | ||
| 236 | const version = buffer.subarray(0, 3).toString("latin1"); | ||
| 237 | |||
| 238 | try { | ||
| 239 | let plaintext: Buffer; | ||
| 240 | if (version === V10_PREFIX) { | ||
| 241 | const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_IV); | ||
| 242 | decipher.setAutoPadding(true); | ||
| 243 | plaintext = Buffer.concat([decipher.update(buffer.subarray(3)), decipher.final()]); | ||
| 244 | } else if (platform === "darwin") { | ||
| 245 | // macOS OSCrypt explicitly treats an unversioned encrypted_value as | ||
| 246 | // legacy cleartext. This is platform-specific: Linux uses other version | ||
| 247 | // prefixes, which must not be widened into plaintext cookies. | ||
| 248 | plaintext = buffer; | ||
| 249 | } else { | ||
| 250 | return null; | ||
| 251 | } | ||
| 252 | // Cookie schema 24 requires SHA-256(host_key) at the front of every | ||
| 253 | // encrypted value. Treat a missing or mismatched binding as undecryptable; | ||
| 254 | // older schemas stored arbitrary plaintext here, including long values | ||
| 255 | // whose first 32 bytes must not be interpreted as a hash. | ||
| 256 | if (schemaVersion >= 24) { | ||
| 257 | const domainHash = NodeCrypto.createHash("sha256").update(domain).digest(); | ||
| 258 | if (plaintext.length < domainHash.length || !plaintext.subarray(0, 32).equals(domainHash)) { | ||
| 259 | return null; | ||
| 260 | } | ||
| 261 | plaintext = plaintext.subarray(32); | ||
| 262 | } | ||
| 263 | return plaintext.toString("utf8"); | ||
| 264 | } catch { | ||
| 265 | return null; | ||
| 266 | } | ||
| 267 | }; | ||
| 268 | |||
| 269 | /** Reads and decodes one snapshotted Chromium cookie database. */ | ||
| 270 | export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiumCookieDatabase")( | ||
| 271 | function* (snapshotPath: string, key: Buffer, platform: NodeJS.Platform) { | ||
| 272 | const rows = yield* Effect.gen(function* () { | ||
| 273 | const sql = yield* SqlClient.SqlClient; | ||
| 274 | const schemaVersion = yield* sql`select value from meta where key = 'version' limit 1`.pipe( | ||
| 275 | Effect.flatMap(decodeSchemaVersion), | ||
| 276 | Effect.map(([row]) => row.value), | ||
| 277 | ); | ||
| 278 | // CHIPS added top_frame_site_key in schema 15. Keep the old query valid | ||
| 279 | // for earlier databases, which do not have the column at all. | ||
| 280 | const raw = | ||
| 281 | schemaVersion >= 15 | ||
| 282 | ? yield* sql` | ||
| 283 | select host_key, name, value, encrypted_value, path, | ||
| 284 | expires_utc / 1000000 as expires_seconds, | ||
| 285 | is_secure, is_httponly, samesite, top_frame_site_key | ||
| 286 | from cookies | ||
| 287 | ` | ||
| 288 | : yield* sql` | ||
| 289 | select host_key, name, value, encrypted_value, path, | ||
| 290 | expires_utc / 1000000 as expires_seconds, | ||
| 291 | is_secure, is_httponly, samesite, '' as top_frame_site_key | ||
| 292 | from cookies | ||
| 293 | `; | ||
| 294 | return { rows: yield* decodeCookieRows(raw), schemaVersion }; | ||
| 295 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true }))); | ||
| 296 | |||
| 297 | const cookies: ChromiumCookie[] = []; | ||
| 298 | let undecryptable = 0; | ||
| 299 | const undecryptableHosts = new Set<string>(); | ||
| 300 | for (const row of rows.rows) { | ||
| 301 | // Electron's cookies.set contract cannot represent a CHIPS partition | ||
| 302 | // key. Importing this row without one would widen it into an ordinary | ||
| 303 | // unpartitioned cookie, so count it as skipped instead. | ||
| 304 | if (row.top_frame_site_key !== "") { | ||
| 305 | undecryptable += 1; | ||
| 306 | undecryptableHosts.add(bareHost(row.host_key)); | ||
| 307 | continue; | ||
| 308 | } | ||
| 309 | // Chromium stores legacy/plaintext cookies in `value` with an empty | ||
| 310 | // encrypted blob. Preserve an actually empty cookie by falling back to | ||
| 311 | // `value`, rather than treating every empty blob as the empty string. | ||
| 312 | const value = | ||
| 313 | row.encrypted_value.length === 0 | ||
| 314 | ? row.value | ||
| 315 | : decryptValue(row.encrypted_value, key, row.host_key, rows.schemaVersion, platform); | ||
| 316 | if (value === null) { | ||
| 317 | undecryptable += 1; | ||
| 318 | undecryptableHosts.add(bareHost(row.host_key)); | ||
| 319 | continue; | ||
| 320 | } | ||
| 321 | const secure = row.is_secure === 1; | ||
| 322 | const scope = cookieScope(row.host_key, row.path, secure); | ||
| 323 | cookies.push({ | ||
| 324 | url: scope.url, | ||
| 325 | name: row.name, | ||
| 326 | value, | ||
| 327 | domain: scope.domain, | ||
| 328 | path: row.path, | ||
| 329 | secure, | ||
| 330 | httpOnly: row.is_httponly === 1, | ||
| 331 | expirationDate: toUnixSeconds(row.expires_seconds), | ||
| 332 | sameSite: sameSiteFromColumn(row.samesite), | ||
| 333 | }); | ||
| 334 | } | ||
| 335 | return { | ||
| 336 | cookies, | ||
| 337 | undecryptable, | ||
| 338 | undecryptableHosts: [...undecryptableHosts], | ||
| 339 | } satisfies CookieReadResult; | ||
| 340 | }, | ||
| 341 | ); | ||
| 342 | |||
| 343 | /** | ||
| 344 | * What a reader produces: the cookies it could recover, and how many stored | ||
| 345 | * rows it could not. The count reaches the user as part of the skipped total | ||
| 346 | * rather than disappearing. | ||
| 347 | */ | ||
| 348 | export interface CookieReadResult { | ||
| 349 | readonly cookies: ReadonlyArray<ChromiumCookie>; | ||
| 350 | readonly undecryptable: number; | ||
| 351 | /** Distinct hosts of the rows that could not be decrypted. */ | ||
| 352 | readonly undecryptableHosts: ReadonlyArray<string>; | ||
| 353 | } | ||
| 354 | |||
| 355 | export interface ChromiumCookieSource { | ||
| 356 | readonly cookieDatabasePath: string; | ||
| 357 | readonly keychainService: string; | ||
| 358 | readonly keychainAccount: string; | ||
| 359 | /** Supplied by the caller from `HostProcessPlatform` rather than read here. */ | ||
| 360 | readonly platform: NodeJS.Platform; | ||
| 361 | } | ||
| 362 | |||
| 363 | export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* ( | ||
| 364 | source: ChromiumCookieSource, | ||
| 365 | ) { | ||
| 366 | if (source.platform !== "darwin") { | ||
| 367 | // Linux (libsecret) and Windows (DPAPI, and App-Bound Encryption on | ||
| 368 | // current Chrome) each need their own key path; only macOS is implemented. | ||
| 369 | return yield* new ChromiumCookieReadError({ | ||
| 370 | reason: "unsupportedPlatform", | ||
| 371 | cookieDatabasePath: source.cookieDatabasePath, | ||
| 372 | }); | ||
| 373 | } | ||
| 374 | |||
| 375 | const password = yield* readMacKeychainPassword( | ||
| 376 | source.keychainService, | ||
| 377 | source.keychainAccount, | ||
| 378 | source.cookieDatabasePath, | ||
| 379 | ); | ||
| 380 | const key = NodeCrypto.pbkdf2Sync( | ||
| 381 | password, | ||
| 382 | MAC_KEY_SALT, | ||
| 383 | MAC_KEY_ITERATIONS, | ||
| 384 | MAC_KEY_LENGTH, | ||
| 385 | "sha1", | ||
| 386 | ); | ||
| 387 | |||
| 388 | const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe( | ||
| 389 | Effect.mapError( | ||
| 390 | (cause) => | ||
| 391 | new ChromiumCookieReadError({ | ||
| 392 | reason: "readFailed", | ||
| 393 | cookieDatabasePath: source.cookieDatabasePath, | ||
| 394 | cause, | ||
| 395 | }), | ||
| 396 | ), | ||
| 397 | ); | ||
| 398 | |||
| 399 | return yield* readChromiumCookieDatabase(snapshotPath, key, source.platform).pipe( | ||
| 400 | Effect.mapError( | ||
| 401 | (cause) => | ||
| 402 | new ChromiumCookieReadError({ | ||
| 403 | reason: "readFailed", | ||
| 404 | cookieDatabasePath: source.cookieDatabasePath, | ||
| 405 | cause, | ||
| 406 | }), | ||
| 407 | ), | ||
| 408 | ); | ||
| 409 | }); | ||
apps/desktop/src/preview/BrowserImport/Sources.test.ts created+365| ... | @@ -0,0 +1,365 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Builds a Chromium-shaped cookie | ||
| 2 | // table with the same native bindings the source reads. | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 4 | import { assert, describe, it } from "@effect/vitest"; | ||
| 5 | import { HostProcessEnvironment, HostProcessHostname } from "@t3tools/shared/hostProcess"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Path from "effect/Path"; | ||
| 9 | import * as Scope from "effect/Scope"; | ||
| 10 | import * as NodeSqlite from "node:sqlite"; | ||
| 11 | |||
| 12 | import { | ||
| 13 | BROWSER_IMPORT_SOURCES, | ||
| 14 | chromiumProcessIsAlive, | ||
| 15 | chromiumSingletonLockIsHeld, | ||
| 16 | cookieDatabaseCandidatePaths, | ||
| 17 | resolveCookieDatabase, | ||
| 18 | isSourceInstalled, | ||
| 19 | isSourceRunning, | ||
| 20 | listSourceProfiles, | ||
| 21 | sourcePaths, | ||
| 22 | } from "./Sources.ts"; | ||
| 23 | |||
| 24 | const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; | ||
| 25 | |||
| 26 | /** A scratch home with the source's user-data directory already created. */ | ||
| 27 | const withSourceHome = Effect.fnUntraced(function* () { | ||
| 28 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 29 | const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" }); | ||
| 30 | const paths = yield* sourcePaths.pipe( | ||
| 31 | Effect.provideService(HostProcessEnvironment, { HOME: home }), | ||
| 32 | ); | ||
| 33 | yield* fileSystem.makeDirectory(helium.userDataDirectory(paths), { recursive: true }); | ||
| 34 | return paths; | ||
| 35 | }); | ||
| 36 | |||
| 37 | const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>) => | ||
| 38 | effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); | ||
| 39 | |||
| 40 | /** Writes a Chromium-shaped cookie table with `count` rows. */ | ||
| 41 | const writeCookieDatabase = (file: string, count: number) => | ||
| 42 | Effect.sync(() => { | ||
| 43 | const database = new NodeSqlite.DatabaseSync(file); | ||
| 44 | database.exec("create table cookies (host_key text, name text)"); | ||
| 45 | const insert = database.prepare("insert into cookies (host_key, name) values (?, ?)"); | ||
| 46 | for (let index = 0; index < count; index += 1) insert.run("example.test", `c${index}`); | ||
| 47 | database.close(); | ||
| 48 | }); | ||
| 49 | |||
| 50 | describe("isSourceRunning", () => { | ||
| 51 | it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () => | ||
| 52 | run( | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 55 | const paths = yield* withSourceHome(); | ||
| 56 | assert.isFalse(yield* isSourceRunning(helium, paths)); | ||
| 57 | |||
| 58 | // Chromium points the lock at `<host>-<pid>`, a target that never | ||
| 59 | // exists on disk. A check that follows the link reports a running | ||
| 60 | // browser as closed, letting an import read a live, mid-write database. | ||
| 61 | yield* fileSystem.symlink( | ||
| 62 | "host-that-does-not-exist-1234", | ||
| 63 | `${helium.userDataDirectory(paths)}/SingletonLock`, | ||
| 64 | ); | ||
| 65 | |||
| 66 | assert.isTrue(yield* isSourceRunning(helium, paths)); | ||
| 67 | }), | ||
| 68 | ), | ||
| 69 | ); | ||
| 70 | |||
| 71 | it.effect("uses the provided hostname to classify Chromium locks", () => | ||
| 72 | run( | ||
| 73 | Effect.gen(function* () { | ||
| 74 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 75 | const paths = yield* withSourceHome(); | ||
| 76 | yield* fileSystem.symlink( | ||
| 77 | "lock-owner-99999999", | ||
| 78 | `${helium.userDataDirectory(paths)}/SingletonLock`, | ||
| 79 | ); | ||
| 80 | |||
| 81 | assert.isTrue( | ||
| 82 | yield* isSourceRunning(helium, paths).pipe( | ||
| 83 | Effect.provideService(HostProcessHostname, "another-host"), | ||
| 84 | ), | ||
| 85 | ); | ||
| 86 | assert.isFalse( | ||
| 87 | yield* isSourceRunning(helium, paths).pipe( | ||
| 88 | Effect.provideService(HostProcessHostname, "lock-owner"), | ||
| 89 | ), | ||
| 90 | ); | ||
| 91 | }), | ||
| 92 | ), | ||
| 93 | ); | ||
| 94 | }); | ||
| 95 | |||
| 96 | describe("chromiumSingletonLockIsHeld", () => { | ||
| 97 | it.effect("ignores a positively dead PID on the current host", () => | ||
| 98 | Effect.gen(function* () { | ||
| 99 | const checked: number[] = []; | ||
| 100 | const held = yield* chromiumSingletonLockIsHeld("current-host-4321", "current-host", (pid) => | ||
| 101 | Effect.sync(() => { | ||
| 102 | checked.push(pid); | ||
| 103 | return false; | ||
| 104 | }), | ||
| 105 | ); | ||
| 106 | assert.isFalse(held); | ||
| 107 | assert.deepEqual(checked, [4321]); | ||
| 108 | }), | ||
| 109 | ); | ||
| 110 | |||
| 111 | it.effect("keeps a live PID on the current host", () => | ||
| 112 | chromiumSingletonLockIsHeld("current-host-4321", "current-host", () => | ||
| 113 | Effect.succeed(true), | ||
| 114 | ).pipe(Effect.tap((held) => Effect.sync(() => assert.isTrue(held)))), | ||
| 115 | ); | ||
| 116 | |||
| 117 | it.effect("keeps foreign-host and malformed targets without probing a PID", () => | ||
| 118 | Effect.gen(function* () { | ||
| 119 | let probes = 0; | ||
| 120 | const probe = (_pid: number) => | ||
| 121 | Effect.sync(() => { | ||
| 122 | probes += 1; | ||
| 123 | return false; | ||
| 124 | }); | ||
| 125 | assert.isTrue(yield* chromiumSingletonLockIsHeld("another-host-4321", "current-host", probe)); | ||
| 126 | assert.isTrue( | ||
| 127 | yield* chromiumSingletonLockIsHeld("current-host-no-pid", "current-host", probe), | ||
| 128 | ); | ||
| 129 | assert.isTrue(yield* chromiumSingletonLockIsHeld("current-host-0", "current-host", probe)); | ||
| 130 | assert.strictEqual(probes, 0); | ||
| 131 | }), | ||
| 132 | ); | ||
| 133 | }); | ||
| 134 | |||
| 135 | describe("chromiumProcessIsAlive", () => { | ||
| 136 | it.effect("returns false only when signal 0 reports a missing process", () => | ||
| 137 | Effect.gen(function* () { | ||
| 138 | const missing = Object.assign(new Error("missing"), { code: "ESRCH" }); | ||
| 139 | const denied = Object.assign(new Error("denied"), { code: "EPERM" }); | ||
| 140 | assert.isFalse( | ||
| 141 | yield* chromiumProcessIsAlive(4321, () => { | ||
| 142 | throw missing; | ||
| 143 | }), | ||
| 144 | ); | ||
| 145 | assert.isTrue( | ||
| 146 | yield* chromiumProcessIsAlive(4321, () => { | ||
| 147 | throw denied; | ||
| 148 | }), | ||
| 149 | ); | ||
| 150 | assert.isTrue( | ||
| 151 | yield* chromiumProcessIsAlive(4321, () => { | ||
| 152 | throw undefined; | ||
| 153 | }), | ||
| 154 | ); | ||
| 155 | assert.isTrue( | ||
| 156 | yield* chromiumProcessIsAlive(4321, () => { | ||
| 157 | throw "unknown failure"; | ||
| 158 | }), | ||
| 159 | ); | ||
| 160 | assert.isTrue( | ||
| 161 | yield* chromiumProcessIsAlive(4321, () => { | ||
| 162 | throw null; | ||
| 163 | }), | ||
| 164 | ); | ||
| 165 | assert.isTrue(yield* chromiumProcessIsAlive(4321, () => true)); | ||
| 166 | }), | ||
| 167 | ); | ||
| 168 | }); | ||
| 169 | |||
| 170 | describe("isSourceInstalled", () => { | ||
| 171 | it.effect("ignores a user-data directory that holds no cookie database", () => | ||
| 172 | run( | ||
| 173 | Effect.gen(function* () { | ||
| 174 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 175 | const paths = yield* withSourceHome(); | ||
| 176 | const root = helium.userDataDirectory(paths); | ||
| 177 | |||
| 178 | // Installers for native messaging hosts create an empty user-data | ||
| 179 | // directory for every Chromium fork they know about, so treating the | ||
| 180 | // directory as evidence lists browsers the user does not have. | ||
| 181 | yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); | ||
| 182 | assert.isFalse(yield* isSourceInstalled(helium, paths)); | ||
| 183 | |||
| 184 | yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); | ||
| 185 | yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); | ||
| 186 | assert.isTrue(yield* isSourceInstalled(helium, paths)); | ||
| 187 | |||
| 188 | // A real install whose cookies live outside `Default` still counts: | ||
| 189 | // reporting it as absent hides the source from the menu entirely. | ||
| 190 | yield* fileSystem.remove(`${root}/Default`, { recursive: true }); | ||
| 191 | yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); | ||
| 192 | yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); | ||
| 193 | assert.isTrue(yield* isSourceInstalled(helium, paths)); | ||
| 194 | |||
| 195 | yield* fileSystem.remove(root, { recursive: true }); | ||
| 196 | assert.isFalse(yield* isSourceInstalled(helium, paths)); | ||
| 197 | }), | ||
| 198 | ), | ||
| 199 | ); | ||
| 200 | }); | ||
| 201 | |||
| 202 | describe("listSourceProfiles", () => { | ||
| 203 | it.effect("ignores a profile whose Cookies entry is not a file", () => | ||
| 204 | run( | ||
| 205 | Effect.gen(function* () { | ||
| 206 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 207 | const paths = yield* withSourceHome(); | ||
| 208 | const root = helium.userDataDirectory(paths); | ||
| 209 | // A directory named `Cookies` would list as importable and then fail | ||
| 210 | // the SQLite open, so only a regular file counts as a database. | ||
| 211 | yield* fileSystem.makeDirectory(`${root}/Broken/Cookies`, { recursive: true }); | ||
| 212 | yield* fileSystem.makeDirectory(`${root}/Real`, { recursive: true }); | ||
| 213 | yield* fileSystem.writeFileString(`${root}/Real/Cookies`, "db"); | ||
| 214 | |||
| 215 | assert.deepEqual(yield* listSourceProfiles(helium, paths), [ | ||
| 216 | { directory: "Real", name: "Real" }, | ||
| 217 | ]); | ||
| 218 | assert.isTrue(yield* isSourceInstalled(helium, paths)); | ||
| 219 | }), | ||
| 220 | ), | ||
| 221 | ); | ||
| 222 | |||
| 223 | it.effect("discovers profiles by their cookie database when Local State is absent", () => | ||
| 224 | run( | ||
| 225 | Effect.gen(function* () { | ||
| 226 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 227 | const paths = yield* withSourceHome(); | ||
| 228 | const root = helium.userDataDirectory(paths); | ||
| 229 | // Assuming `Default` would report a browser whose cookies live in | ||
| 230 | // `Profile 1` as having nothing to import, and it is then hidden. | ||
| 231 | yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); | ||
| 232 | yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); | ||
| 233 | yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); | ||
| 234 | |||
| 235 | assert.deepEqual(yield* listSourceProfiles(helium, paths), [ | ||
| 236 | { directory: "Profile 1", name: "Profile 1" }, | ||
| 237 | ]); | ||
| 238 | }), | ||
| 239 | ), | ||
| 240 | ); | ||
| 241 | |||
| 242 | it.effect("reads the profile names the browser shows", () => | ||
| 243 | run( | ||
| 244 | Effect.gen(function* () { | ||
| 245 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 246 | const paths = yield* withSourceHome(); | ||
| 247 | yield* fileSystem.writeFileString( | ||
| 248 | `${helium.userDataDirectory(paths)}/Local State`, | ||
| 249 | `{"profile":{"info_cache":{"Default":{"name":"You"},"Profile 2":{"name":" "}}}}`, | ||
| 250 | ); | ||
| 251 | |||
| 252 | assert.deepEqual(yield* listSourceProfiles(helium, paths), [ | ||
| 253 | { directory: "Default", name: "You" }, | ||
| 254 | // Blank display name falls back to the directory rather than | ||
| 255 | // rendering an empty row. | ||
| 256 | { directory: "Profile 2", name: "Profile 2" }, | ||
| 257 | ]); | ||
| 258 | }), | ||
| 259 | ), | ||
| 260 | ); | ||
| 261 | |||
| 262 | it.effect("scans for profiles when Local State is malformed", () => | ||
| 263 | run( | ||
| 264 | Effect.gen(function* () { | ||
| 265 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 266 | const paths = yield* withSourceHome(); | ||
| 267 | const root = helium.userDataDirectory(paths); | ||
| 268 | yield* fileSystem.writeFileString(`${root}/Local State`, "{not-json"); | ||
| 269 | yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); | ||
| 270 | yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); | ||
| 271 | |||
| 272 | assert.deepEqual(yield* listSourceProfiles(helium, paths), [ | ||
| 273 | { directory: "Default", name: "Default" }, | ||
| 274 | ]); | ||
| 275 | }), | ||
| 276 | ), | ||
| 277 | ); | ||
| 278 | |||
| 279 | it.effect("reports nothing when no directory holds a cookie database", () => | ||
| 280 | run( | ||
| 281 | Effect.gen(function* () { | ||
| 282 | const paths = yield* withSourceHome(); | ||
| 283 | assert.deepEqual(yield* listSourceProfiles(helium, paths), []); | ||
| 284 | }), | ||
| 285 | ), | ||
| 286 | ); | ||
| 287 | |||
| 288 | it.effect("counts a profile's cookies without decrypting them", () => | ||
| 289 | run( | ||
| 290 | Effect.gen(function* () { | ||
| 291 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 292 | const paths = yield* withSourceHome(); | ||
| 293 | const root = helium.userDataDirectory(paths); | ||
| 294 | yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); | ||
| 295 | yield* writeCookieDatabase(`${root}/Default/Cookies`, 3); | ||
| 296 | |||
| 297 | const [profile] = yield* listSourceProfiles(helium, paths); | ||
| 298 | assert.equal(profile?.cookieCount, 3); | ||
| 299 | }), | ||
| 300 | ), | ||
| 301 | ); | ||
| 302 | }); | ||
| 303 | |||
| 304 | describe("cookieDatabaseCandidatePaths", () => { | ||
| 305 | it.effect("prefers Network/Cookies and falls back to the legacy Cookies", () => | ||
| 306 | run( | ||
| 307 | Effect.gen(function* () { | ||
| 308 | const paths = yield* withSourceHome(); | ||
| 309 | const profile = `${paths.home}/Library/Application Support/net.imput.helium/Profile 1`; | ||
| 310 | assert.deepEqual(cookieDatabaseCandidatePaths(helium, paths, "Profile 1"), [ | ||
| 311 | `${profile}/Network/Cookies`, | ||
| 312 | `${profile}/Cookies`, | ||
| 313 | ]); | ||
| 314 | }), | ||
| 315 | ), | ||
| 316 | ); | ||
| 317 | |||
| 318 | it.effect("resolves the live Network/ jar over a leftover root Cookies", () => | ||
| 319 | run( | ||
| 320 | Effect.gen(function* () { | ||
| 321 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 322 | const paths = yield* withSourceHome(); | ||
| 323 | const root = helium.userDataDirectory(paths); | ||
| 324 | // Chromium 96+ keeps sessions in Network/; a root Cookies left behind | ||
| 325 | // by the move is stale and must not be the one imported. | ||
| 326 | yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); | ||
| 327 | yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "live"); | ||
| 328 | yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale"); | ||
| 329 | |||
| 330 | assert.equal( | ||
| 331 | yield* resolveCookieDatabase(helium, paths, "Default"), | ||
| 332 | `${root}/Default/Network/Cookies`, | ||
| 333 | ); | ||
| 334 | // A fresh install with only the Network/ jar is installed, not hidden. | ||
| 335 | yield* fileSystem.remove(`${root}/Default/Cookies`); | ||
| 336 | assert.isTrue(yield* isSourceInstalled(helium, paths)); | ||
| 337 | }), | ||
| 338 | ), | ||
| 339 | ); | ||
| 340 | }); | ||
| 341 | |||
| 342 | describe("listSourceProfiles hardening", () => { | ||
| 343 | it.effect("drops profile directories that are not a single plain segment", () => | ||
| 344 | run( | ||
| 345 | Effect.gen(function* () { | ||
| 346 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 347 | const paths = yield* withSourceHome(); | ||
| 348 | // `Local State` is writable by anything running as the user, so a | ||
| 349 | // crafted key must not reach `cookieDatabasePath` and read a database | ||
| 350 | // outside the browser's user-data directory. | ||
| 351 | yield* fileSystem.writeFileString( | ||
| 352 | `${helium.userDataDirectory(paths)}/Local State`, | ||
| 353 | `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`, | ||
| 354 | ); | ||
| 355 | |||
| 356 | const profiles = yield* listSourceProfiles(helium, paths); | ||
| 357 | |||
| 358 | assert.deepEqual( | ||
| 359 | profiles.map((profile) => profile.directory), | ||
| 360 | ["Default"], | ||
| 361 | ); | ||
| 362 | }), | ||
| 363 | ), | ||
| 364 | ); | ||
| 365 | }); | ||
apps/desktop/src/preview/BrowserImport/Sources.ts created+307| ... | @@ -0,0 +1,307 @@ | ||
| 1 | /** | ||
| 2 | * Importable browser sources. | ||
| 3 | * | ||
| 4 | * Each entry pins its own on-disk and keychain coordinates rather than | ||
| 5 | * deriving them: Chromium forks do not agree on the convention. Helium, for | ||
| 6 | * instance, uses the keychain service "Helium Storage Key" / account "Helium" | ||
| 7 | * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>". | ||
| 8 | * | ||
| 9 | * @module BrowserImportSources | ||
| 10 | */ | ||
| 11 | import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts"; | ||
| 12 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | ||
| 13 | import { HostProcessEnvironment, HostProcessHostname } from "@t3tools/shared/hostProcess"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as FileSystem from "effect/FileSystem"; | ||
| 16 | import * as Path from "effect/Path"; | ||
| 17 | import * as Schema from "effect/Schema"; | ||
| 18 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 19 | |||
| 20 | /** | ||
| 21 | * Where a source's files live, resolved once per call rather than read from | ||
| 22 | * the ambient process so the registry stays testable. | ||
| 23 | */ | ||
| 24 | export interface SourcePaths { | ||
| 25 | readonly path: Path.Path; | ||
| 26 | readonly home: string; | ||
| 27 | } | ||
| 28 | |||
| 29 | export const sourcePaths = Effect.gen(function* () { | ||
| 30 | const path = yield* Path.Path; | ||
| 31 | const environment = yield* HostProcessEnvironment; | ||
| 32 | return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths; | ||
| 33 | }); | ||
| 34 | |||
| 35 | export interface BrowserImportSourceDefinition { | ||
| 36 | readonly id: BrowserImportSourceId; | ||
| 37 | readonly name: string; | ||
| 38 | /** Platforms the definition's paths are valid for. */ | ||
| 39 | readonly platforms: ReadonlyArray<NodeJS.Platform>; | ||
| 40 | readonly userDataDirectory: (paths: SourcePaths) => string; | ||
| 41 | readonly keychainService: string; | ||
| 42 | readonly keychainAccount: string; | ||
| 43 | } | ||
| 44 | |||
| 45 | export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [ | ||
| 46 | { | ||
| 47 | id: "helium", | ||
| 48 | name: "Helium", | ||
| 49 | platforms: ["darwin"], | ||
| 50 | userDataDirectory: ({ path, home }) => | ||
| 51 | path.join(home, "Library", "Application Support", "net.imput.helium"), | ||
| 52 | keychainService: "Helium Storage Key", | ||
| 53 | keychainAccount: "Helium", | ||
| 54 | }, | ||
| 55 | ]; | ||
| 56 | |||
| 57 | /** | ||
| 58 | * Where a profile's cookie database may live, most current first. Chromium 96 | ||
| 59 | * moved the live jar to `Network/Cookies`; a root-level `Cookies` is either a | ||
| 60 | * pre-96 install or a leftover from before the move. Importing the leftover | ||
| 61 | * while sessions live in `Network/` would snapshot a stale or empty database, | ||
| 62 | * and a fresh install with only `Network/Cookies` would read as not installed. | ||
| 63 | */ | ||
| 64 | export const cookieDatabaseCandidatePaths = ( | ||
| 65 | definition: BrowserImportSourceDefinition, | ||
| 66 | paths: SourcePaths, | ||
| 67 | profileDirectory: string, | ||
| 68 | ): ReadonlyArray<string> => { | ||
| 69 | const profile = paths.path.join(definition.userDataDirectory(paths), profileDirectory); | ||
| 70 | return [paths.path.join(profile, "Network", "Cookies"), paths.path.join(profile, "Cookies")]; | ||
| 71 | }; | ||
| 72 | |||
| 73 | /** The first candidate that is a regular file, or undefined when none is. */ | ||
| 74 | export const resolveCookieDatabase = Effect.fnUntraced(function* ( | ||
| 75 | definition: BrowserImportSourceDefinition, | ||
| 76 | paths: SourcePaths, | ||
| 77 | profileDirectory: string, | ||
| 78 | ) { | ||
| 79 | for (const candidate of cookieDatabaseCandidatePaths(definition, paths, profileDirectory)) { | ||
| 80 | if (yield* databaseFileExists(candidate)) return candidate; | ||
| 81 | } | ||
| 82 | return undefined; | ||
| 83 | }); | ||
| 84 | |||
| 85 | /** Shape of the slice of Chromium's `Local State` that names its profiles. */ | ||
| 86 | const LocalState = Schema.Struct({ | ||
| 87 | profile: Schema.optional( | ||
| 88 | Schema.Struct({ | ||
| 89 | info_cache: Schema.optional( | ||
| 90 | Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })), | ||
| 91 | ), | ||
| 92 | }), | ||
| 93 | ), | ||
| 94 | }); | ||
| 95 | const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState)); | ||
| 96 | |||
| 97 | /** A single plain path segment: no separators, no `.`/`..`, not empty. */ | ||
| 98 | const isSafeProfileDirectory = (directory: string): boolean => | ||
| 99 | directory.length > 0 && | ||
| 100 | directory !== "." && | ||
| 101 | directory !== ".." && | ||
| 102 | !/[\\/]/.test(directory) && | ||
| 103 | !directory.includes("\u0000"); | ||
| 104 | |||
| 105 | const CookieCountRow = Schema.Struct({ count: Schema.Number }); | ||
| 106 | const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); | ||
| 107 | |||
| 108 | /** | ||
| 109 | * How many cookies a profile holds, counted without decrypting anything — a | ||
| 110 | * bare `COUNT(*)` needs no key. Best effort: a locked, missing or non-Chromium | ||
| 111 | * database (Firefox's table is named differently, Safari's is not SQL) yields | ||
| 112 | * `undefined` rather than failing the listing. | ||
| 113 | */ | ||
| 114 | const countProfileCookies = Effect.fnUntraced(function* ( | ||
| 115 | definition: BrowserImportSourceDefinition, | ||
| 116 | paths: SourcePaths, | ||
| 117 | directory: string, | ||
| 118 | ): Effect.fn.Return<number | undefined, never, FileSystem.FileSystem> { | ||
| 119 | const database = yield* resolveCookieDatabase(definition, paths, directory); | ||
| 120 | if (database === undefined) return undefined; | ||
| 121 | return yield* Effect.gen(function* () { | ||
| 122 | const sql = yield* SqlClient.SqlClient; | ||
| 123 | const rows = yield* sql`select count(*) as count from cookies`; | ||
| 124 | const [row] = yield* decodeCookieCount(rows); | ||
| 125 | return row?.count; | ||
| 126 | }).pipe( | ||
| 127 | Effect.provide(NodeSqliteClient.layer({ filename: database, readonly: true })), | ||
| 128 | Effect.orElseSucceed(() => undefined), | ||
| 129 | ); | ||
| 130 | }); | ||
| 131 | |||
| 132 | const withCookieCounts = ( | ||
| 133 | definition: BrowserImportSourceDefinition, | ||
| 134 | paths: SourcePaths, | ||
| 135 | profiles: ReadonlyArray<BrowserImportSourceProfile>, | ||
| 136 | ) => | ||
| 137 | Effect.forEach(profiles, (profile) => | ||
| 138 | countProfileCookies(definition, paths, profile.directory).pipe( | ||
| 139 | Effect.map((cookieCount) => | ||
| 140 | cookieCount === undefined ? profile : { ...profile, cookieCount }, | ||
| 141 | ), | ||
| 142 | ), | ||
| 143 | ); | ||
| 144 | |||
| 145 | /** | ||
| 146 | * Profiles the source browser knows about, read from its `Local State`. | ||
| 147 | * | ||
| 148 | * When that file is missing, unreadable or malformed, the user-data directory | ||
| 149 | * is scanned for directories that hold a cookie database. Assuming `Default` | ||
| 150 | * instead would report a browser whose cookies live in `Profile 1` as having | ||
| 151 | * nothing to import — and it is then left out of the menu entirely. | ||
| 152 | */ | ||
| 153 | export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( | ||
| 154 | definition: BrowserImportSourceDefinition, | ||
| 155 | paths: SourcePaths, | ||
| 156 | ) { | ||
| 157 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 158 | const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State"); | ||
| 159 | |||
| 160 | const root = definition.userDataDirectory(paths); | ||
| 161 | const declared = yield* fileSystem.readFileString(localStatePath).pipe( | ||
| 162 | Effect.flatMap(decodeLocalState), | ||
| 163 | Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})), | ||
| 164 | // The keys are directory names from the browser's own metadata file, which | ||
| 165 | // is writable by anything running as the user. Anything but a single plain | ||
| 166 | // segment is dropped: `..` or a path separator would otherwise be handed | ||
| 167 | // to `cookieDatabasePath` and read a database outside the user-data | ||
| 168 | // directory. | ||
| 169 | Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))), | ||
| 170 | Effect.map((entries) => | ||
| 171 | entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })), | ||
| 172 | ), | ||
| 173 | Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>), | ||
| 174 | ); | ||
| 175 | if (declared.length > 0) return yield* withCookieCounts(definition, paths, declared); | ||
| 176 | |||
| 177 | // `Local State` is missing, unreadable or malformed. Scanning for | ||
| 178 | // directories that hold a cookie database finds the profiles anyway; | ||
| 179 | // assuming `Default` would report a browser whose cookies live in | ||
| 180 | // `Profile 1` as having nothing to import, and it is then hidden entirely. | ||
| 181 | const entries = yield* fileSystem | ||
| 182 | .readDirectory(root) | ||
| 183 | .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>)); | ||
| 184 | const candidates = entries.filter(isSafeProfileDirectory); | ||
| 185 | const found = yield* Effect.forEach(candidates, (directory) => | ||
| 186 | resolveCookieDatabase(definition, paths, directory).pipe( | ||
| 187 | Effect.map((database) => | ||
| 188 | database === undefined ? undefined : { directory, name: directory }, | ||
| 189 | ), | ||
| 190 | ), | ||
| 191 | ); | ||
| 192 | return yield* withCookieCounts( | ||
| 193 | definition, | ||
| 194 | paths, | ||
| 195 | found.filter((profile) => profile !== undefined), | ||
| 196 | ); | ||
| 197 | }); | ||
| 198 | |||
| 199 | /** | ||
| 200 | * Whether a cookie database candidate is a regular file. Presence alone is | ||
| 201 | * not enough: a directory at the path would list as an importable profile and | ||
| 202 | * then fail the SQLite open, so anything but a file is treated as absent. | ||
| 203 | */ | ||
| 204 | const databaseFileExists = Effect.fnUntraced(function* (path: string) { | ||
| 205 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 206 | return yield* fileSystem.stat(path).pipe( | ||
| 207 | Effect.map((info) => info.type === "File"), | ||
| 208 | Effect.orElseSucceed(() => false), | ||
| 209 | ); | ||
| 210 | }); | ||
| 211 | |||
| 212 | type ProcessLivenessProbe = (pid: number) => Effect.Effect<boolean>; | ||
| 213 | |||
| 214 | export const chromiumProcessIsAlive = ( | ||
| 215 | pid: number, | ||
| 216 | signalProcess: (pid: number, signal: 0) => unknown = process.kill.bind(process), | ||
| 217 | ) => | ||
| 218 | Effect.sync(() => { | ||
| 219 | try { | ||
| 220 | // Signal 0 performs a read-only existence/permission check. | ||
| 221 | signalProcess(pid, 0); | ||
| 222 | return true; | ||
| 223 | } catch (cause) { | ||
| 224 | // Only ESRCH positively proves the process is gone. Permission errors | ||
| 225 | // and unknown failures stay conservative so an active browser is never | ||
| 226 | // mistaken for a stale lock. | ||
| 227 | return !( | ||
| 228 | typeof cause === "object" && | ||
| 229 | cause !== null && | ||
| 230 | "code" in cause && | ||
| 231 | cause.code === "ESRCH" | ||
| 232 | ); | ||
| 233 | } | ||
| 234 | }); | ||
| 235 | |||
| 236 | const processIsAlive: ProcessLivenessProbe = (pid) => chromiumProcessIsAlive(pid); | ||
| 237 | |||
| 238 | /** Whether a Chromium `<host>-<pid>` lock target may still name its owner. */ | ||
| 239 | export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( | ||
| 240 | target: string, | ||
| 241 | currentHost: string, | ||
| 242 | isProcessAlive: ProcessLivenessProbe, | ||
| 243 | ) { | ||
| 244 | const separator = target.lastIndexOf("-"); | ||
| 245 | if (separator <= 0) return true; | ||
| 246 | const host = target.slice(0, separator); | ||
| 247 | const pidText = target.slice(separator + 1); | ||
| 248 | if (!/^\d+$/.test(pidText)) return true; | ||
| 249 | const pid = Number(pidText); | ||
| 250 | if (!Number.isSafeInteger(pid) || pid <= 0) return true; | ||
| 251 | // A PID is meaningful only on this host. A foreign hostname can come from a | ||
| 252 | // shared home directory, and cannot safely be declared stale from here. | ||
| 253 | if (host !== currentHost) return true; | ||
| 254 | return yield* isProcessAlive(pid); | ||
| 255 | }); | ||
| 256 | |||
| 257 | /** Whether the browser is running, which leaves its cookie DB mid-write. */ | ||
| 258 | export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* ( | ||
| 259 | definition: BrowserImportSourceDefinition, | ||
| 260 | paths: SourcePaths, | ||
| 261 | ) { | ||
| 262 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 263 | const currentHost = yield* HostProcessHostname; | ||
| 264 | const lock = paths.path.join(definition.userDataDirectory(paths), "SingletonLock"); | ||
| 265 | // Chromium writes a `SingletonLock` symlink for as long as an instance holds | ||
| 266 | // the profile. Its presence is a far cheaper and more targeted signal than | ||
| 267 | // scanning the process table for a name. | ||
| 268 | // | ||
| 269 | // The link points at `<host>-<pid>`, a target that never exists, and both | ||
| 270 | // `stat` and `exists` follow links — so they report every running browser as | ||
| 271 | // closed, which would let an import read a live, mid-write database. | ||
| 272 | // `readLink` is the one probe that answers for the entry itself. Chromium | ||
| 273 | // can leave this link behind after a crash, so a positively dead local PID | ||
| 274 | // is stale. Every ambiguous target or liveness result stays conservative. | ||
| 275 | return yield* fileSystem.readLink(lock).pipe( | ||
| 276 | Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)), | ||
| 277 | Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")), | ||
| 278 | ); | ||
| 279 | }); | ||
| 280 | |||
| 281 | /** | ||
| 282 | * Whether the source has cookies to import. | ||
| 283 | * | ||
| 284 | * Keyed off the cookie database rather than the user-data directory, because | ||
| 285 | * that directory is not evidence the browser exists: installers for native | ||
| 286 | * messaging hosts create an empty one for every Chromium fork they know about, | ||
| 287 | * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as | ||
| 288 | * present. The database is the thing an import actually needs, so its absence | ||
| 289 | * is the honest answer either way. | ||
| 290 | * | ||
| 291 | * Existence is checked without opening the file, which matters for Safari: TCC | ||
| 292 | * permits `stat` on the jar inside its container but refuses a read, so this | ||
| 293 | * still sees it and the user gets the Full Disk Access prompt rather than | ||
| 294 | * having Safari disappear. | ||
| 295 | */ | ||
| 296 | export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* ( | ||
| 297 | definition: BrowserImportSourceDefinition, | ||
| 298 | paths: SourcePaths, | ||
| 299 | ) { | ||
| 300 | const profiles = yield* listSourceProfiles(definition, paths); | ||
| 301 | const found = yield* Effect.forEach(profiles, (profile) => | ||
| 302 | resolveCookieDatabase(definition, paths, profile.directory).pipe( | ||
| 303 | Effect.map((database) => database !== undefined), | ||
| 304 | ), | ||
| 305 | ); | ||
| 306 | return found.some(Boolean); | ||
| 307 | }); | ||
apps/web/src/components/settings/BrowserImportWizard.tsx created+475| ... | @@ -0,0 +1,475 @@ | ||
| 1 | import type { BrowserImportSource } from "@t3tools/contracts"; | ||
| 2 | import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts"; | ||
| 3 | import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react"; | ||
| 4 | import { useRef, useState } from "react"; | ||
| 5 | |||
| 6 | import { cn, randomUUID } from "~/lib/utils"; | ||
| 7 | |||
| 8 | import { Button } from "../ui/button"; | ||
| 9 | import { | ||
| 10 | Dialog, | ||
| 11 | DialogClose, | ||
| 12 | DialogDescription, | ||
| 13 | DialogFooter, | ||
| 14 | DialogHeader, | ||
| 15 | DialogPanel, | ||
| 16 | DialogPopup, | ||
| 17 | DialogTitle, | ||
| 18 | } from "../ui/dialog"; | ||
| 19 | import { Spinner } from "../ui/spinner"; | ||
| 20 | import { | ||
| 21 | initialWizardStep, | ||
| 22 | initialTargetSelection, | ||
| 23 | canCloseWizard, | ||
| 24 | isRetryableReason, | ||
| 25 | formatSkippedDomains, | ||
| 26 | outcomeToStep, | ||
| 27 | refreshedSourceProfileDirectory, | ||
| 28 | refreshedSourceStep, | ||
| 29 | resolveWizardTarget, | ||
| 30 | type ImportOutcome, | ||
| 31 | type WizardTarget, | ||
| 32 | type WizardTargetProfile, | ||
| 33 | type WizardTargetSelection, | ||
| 34 | type WizardStep, | ||
| 35 | } from "./browserImportWizard.logic"; | ||
| 36 | |||
| 37 | export type { WizardTarget } from "./browserImportWizard.logic"; | ||
| 38 | |||
| 39 | interface BrowserImportWizardProps { | ||
| 40 | readonly source: BrowserImportSource; | ||
| 41 | /** Captured when the wizard opens so destination copy and writes stay stable. */ | ||
| 42 | readonly destinationEnvironmentName: string; | ||
| 43 | /** Existing profiles the import can go into. Incognito is excluded upstream. */ | ||
| 44 | readonly targetProfiles: ReadonlyArray<WizardTargetProfile>; | ||
| 45 | /** Whether a new profile can still be created (profile cap). */ | ||
| 46 | readonly canCreateProfile: boolean; | ||
| 47 | /** | ||
| 48 | * Runs the import and returns how it went. For a new target the caller only | ||
| 49 | * registers the profile once the import succeeds, so a blocked attempt never | ||
| 50 | * leaves an empty profile behind. | ||
| 51 | */ | ||
| 52 | readonly onImport: (input: { | ||
| 53 | readonly sourceProfileDirectory: string; | ||
| 54 | readonly target: WizardTarget; | ||
| 55 | }) => Promise<ImportOutcome>; | ||
| 56 | /** Re-checks the source's availability after the user quits the browser. */ | ||
| 57 | readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>; | ||
| 58 | readonly onClose: () => void; | ||
| 59 | } | ||
| 60 | |||
| 61 | /** | ||
| 62 | * Guides one browser's cookies into a profile. | ||
| 63 | * | ||
| 64 | * Every state the import can be in — the browser is open, a profile has to be | ||
| 65 | * chosen, the read failed — is a screen the user can move forward from, rather | ||
| 66 | * than a disabled row that only says no. | ||
| 67 | */ | ||
| 68 | export function BrowserImportWizard({ | ||
| 69 | source: initialSource, | ||
| 70 | destinationEnvironmentName, | ||
| 71 | targetProfiles, | ||
| 72 | canCreateProfile, | ||
| 73 | onImport, | ||
| 74 | onRefreshSource, | ||
| 75 | onClose, | ||
| 76 | }: BrowserImportWizardProps) { | ||
| 77 | const [source, setSource] = useState(initialSource); | ||
| 78 | const [step, setStep] = useState<WizardStep>(() => initialWizardStep(initialSource)); | ||
| 79 | const [sourceProfileDirectory, setSourceProfileDirectory] = useState( | ||
| 80 | () => initialSource.profiles[0]?.directory ?? "", | ||
| 81 | ); | ||
| 82 | const [target, setTarget] = useState<WizardTargetSelection>(() => | ||
| 83 | initialTargetSelection(canCreateProfile, targetProfiles), | ||
| 84 | ); | ||
| 85 | const [targetError, setTargetError] = useState<string>(); | ||
| 86 | // Stable across retries so a keychain re-approval lands in one profile, not | ||
| 87 | // a new one each time. | ||
| 88 | const newProfileId = useRef(`profile-${randomUUID()}`); | ||
| 89 | // A second Import click before React has left the configure screen would | ||
| 90 | // start a second run; the parent refuses it, and applying that refusal here | ||
| 91 | // would drop the wizard out of the importing step while the first write is | ||
| 92 | // still going. The ref settles synchronously where state does not. | ||
| 93 | const importInFlight = useRef(false); | ||
| 94 | |||
| 95 | const runImport = () => { | ||
| 96 | if (importInFlight.current) return; | ||
| 97 | const chosen = resolveWizardTarget(target, newProfileId.current, targetProfiles); | ||
| 98 | if (chosen === undefined) { | ||
| 99 | setTargetError("That profile is no longer available. Choose where to import these cookies."); | ||
| 100 | setStep({ step: "configure" }); | ||
| 101 | return; | ||
| 102 | } | ||
| 103 | setTargetError(undefined); | ||
| 104 | importInFlight.current = true; | ||
| 105 | setStep({ step: "importing" }); | ||
| 106 | void onImport({ sourceProfileDirectory, target: chosen }) | ||
| 107 | .then((outcome) => setStep(outcomeToStep(outcome))) | ||
| 108 | .catch(() => setStep({ step: "blocked", reason: "readFailed" })) | ||
| 109 | .finally(() => { | ||
| 110 | importInFlight.current = false; | ||
| 111 | }); | ||
| 112 | }; | ||
| 113 | |||
| 114 | const recheckAfterQuit = () => { | ||
| 115 | setStep({ step: "checking" }); | ||
| 116 | void onRefreshSource() | ||
| 117 | .then((refreshed) => { | ||
| 118 | if (refreshed) { | ||
| 119 | setSource(refreshed); | ||
| 120 | setSourceProfileDirectory((current) => | ||
| 121 | refreshedSourceProfileDirectory(current, refreshed), | ||
| 122 | ); | ||
| 123 | } | ||
| 124 | setStep(refreshedSourceStep(refreshed)); | ||
| 125 | }) | ||
| 126 | .catch(() => setStep({ step: "blocked", reason: "readFailed" })); | ||
| 127 | }; | ||
| 128 | |||
| 129 | return ( | ||
| 130 | <Dialog open onOpenChange={(open) => (open || !canCloseWizard(step) ? undefined : onClose())}> | ||
| 131 | <DialogPopup className="max-w-lg" showCloseButton={canCloseWizard(step)}> | ||
| 132 | {step.step === "quit" ? ( | ||
| 133 | <QuitStep source={source} onCancel={onClose} onRechecked={recheckAfterQuit} /> | ||
| 134 | ) : step.step === "importing" ? ( | ||
| 135 | <ImportingStep /> | ||
| 136 | ) : step.step === "checking" ? ( | ||
| 137 | <CheckingStep sourceName={source.name} /> | ||
| 138 | ) : step.step === "done" ? ( | ||
| 139 | <DoneStep | ||
| 140 | {...step} | ||
| 141 | destinationEnvironmentName={destinationEnvironmentName} | ||
| 142 | onClose={onClose} | ||
| 143 | /> | ||
| 144 | ) : step.step === "blocked" ? ( | ||
| 145 | <BlockedStep | ||
| 146 | source={source} | ||
| 147 | reason={step.reason} | ||
| 148 | onClose={onClose} | ||
| 149 | onRetry={isRetryableReason(step.reason) ? runImport : undefined} | ||
| 150 | /> | ||
| 151 | ) : ( | ||
| 152 | <ConfigureStep | ||
| 153 | source={source} | ||
| 154 | destinationEnvironmentName={destinationEnvironmentName} | ||
| 155 | targetProfiles={targetProfiles} | ||
| 156 | canCreateProfile={canCreateProfile} | ||
| 157 | sourceProfileDirectory={sourceProfileDirectory} | ||
| 158 | onSourceProfileChange={setSourceProfileDirectory} | ||
| 159 | target={target} | ||
| 160 | onTargetChange={(selection) => { | ||
| 161 | setTarget(selection); | ||
| 162 | setTargetError(undefined); | ||
| 163 | }} | ||
| 164 | targetError={targetError} | ||
| 165 | onCancel={onClose} | ||
| 166 | onImport={runImport} | ||
| 167 | /> | ||
| 168 | )} | ||
| 169 | </DialogPopup> | ||
| 170 | </Dialog> | ||
| 171 | ); | ||
| 172 | } | ||
| 173 | |||
| 174 | function QuitStep({ | ||
| 175 | source, | ||
| 176 | onCancel, | ||
| 177 | onRechecked, | ||
| 178 | }: { | ||
| 179 | readonly source: BrowserImportSource; | ||
| 180 | readonly onCancel: () => void; | ||
| 181 | readonly onRechecked: () => void; | ||
| 182 | }) { | ||
| 183 | return ( | ||
| 184 | <> | ||
| 185 | <DialogHeader> | ||
| 186 | <DialogTitle>Quit {source.name} to import</DialogTitle> | ||
| 187 | <DialogDescription> | ||
| 188 | {source.name} is open, so its cookies can&rsquo;t be read yet. Quit it, then continue. | ||
| 189 | </DialogDescription> | ||
| 190 | </DialogHeader> | ||
| 191 | <DialogFooter> | ||
| 192 | <Button variant="outline" onClick={onCancel}> | ||
| 193 | Cancel | ||
| 194 | </Button> | ||
| 195 | <Button onClick={onRechecked}>I&rsquo;ve quit it</Button> | ||
| 196 | </DialogFooter> | ||
| 197 | </> | ||
| 198 | ); | ||
| 199 | } | ||
| 200 | |||
| 201 | /** "5,065 cookies", or "no cookies", or nothing when the store is unreadable. */ | ||
| 202 | function cookieCountLabel(count: number | undefined): string | undefined { | ||
| 203 | if (count === undefined) return undefined; | ||
| 204 | if (count === 0) return "no cookies"; | ||
| 205 | return `${count.toLocaleString()} ${count === 1 ? "cookie" : "cookies"}`; | ||
| 206 | } | ||
| 207 | |||
| 208 | function cookieResultCount(count: number): string { | ||
| 209 | return `${count.toLocaleString()} ${count === 1 ? "cookie" : "cookies"}`; | ||
| 210 | } | ||
| 211 | |||
| 212 | type ConfigureStepProps = { | ||
| 213 | readonly source: BrowserImportSource; | ||
| 214 | readonly destinationEnvironmentName: string; | ||
| 215 | readonly targetProfiles: ReadonlyArray<WizardTargetProfile>; | ||
| 216 | readonly canCreateProfile: boolean; | ||
| 217 | readonly sourceProfileDirectory: string; | ||
| 218 | readonly onSourceProfileChange: (directory: string) => void; | ||
| 219 | readonly target: WizardTargetSelection; | ||
| 220 | readonly targetError: string | undefined; | ||
| 221 | readonly onTargetChange: (target: WizardTargetSelection) => void; | ||
| 222 | readonly onCancel: () => void; | ||
| 223 | readonly onImport: () => void; | ||
| 224 | }; | ||
| 225 | |||
| 226 | function ConfigureStep({ | ||
| 227 | source, | ||
| 228 | destinationEnvironmentName, | ||
| 229 | targetProfiles, | ||
| 230 | canCreateProfile, | ||
| 231 | sourceProfileDirectory, | ||
| 232 | onSourceProfileChange, | ||
| 233 | target, | ||
| 234 | targetError, | ||
| 235 | onTargetChange, | ||
| 236 | onCancel, | ||
| 237 | onImport, | ||
| 238 | }: ConfigureStepProps) { | ||
| 239 | const targetMissing = | ||
| 240 | target.kind === "existing" && | ||
| 241 | !targetProfiles.some((profile) => profile.id === target.profileId); | ||
| 242 | // The "New profile" tile is unrendered once the cap is reached, so a target | ||
| 243 | // chosen before that leaves nothing selected in "Into" — say so, the same | ||
| 244 | // way a vanished existing target is explained. | ||
| 245 | const targetUncreatable = target.kind === "new" && !canCreateProfile; | ||
| 246 | const targetFeedback = | ||
| 247 | targetError ?? | ||
| 248 | (targetMissing | ||
| 249 | ? "That profile is no longer available. Choose where to import these cookies." | ||
| 250 | : targetUncreatable | ||
| 251 | ? "You've reached the profile limit. Choose an existing profile to import into." | ||
| 252 | : undefined); | ||
| 253 | return ( | ||
| 254 | <> | ||
| 255 | <DialogHeader> | ||
| 256 | <DialogTitle>Import from {source.name}</DialogTitle> | ||
| 257 | <DialogDescription> | ||
| 258 | Choose which cookies to import for {destinationEnvironmentName}. | ||
| 259 | </DialogDescription> | ||
| 260 | </DialogHeader> | ||
| 261 | <DialogPanel> | ||
| 262 | {/* Side by side when the dialog has room, stacked when it doesn't. */} | ||
| 263 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4"> | ||
| 264 | <section className="flex-1 space-y-2"> | ||
| 265 | <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> | ||
| 266 | From | ||
| 267 | </p> | ||
| 268 | {source.profiles.map((profile) => ( | ||
| 269 | <SelectableTile | ||
| 270 | key={profile.directory} | ||
| 271 | selected={sourceProfileDirectory === profile.directory} | ||
| 272 | title={profile.name} | ||
| 273 | subtitle={cookieCountLabel(profile.cookieCount)} | ||
| 274 | onSelect={() => onSourceProfileChange(profile.directory)} | ||
| 275 | /> | ||
| 276 | ))} | ||
| 277 | </section> | ||
| 278 | <div className="flex shrink-0 items-center justify-center text-muted-foreground"> | ||
| 279 | <ArrowDownIcon className="size-4 sm:hidden" /> | ||
| 280 | <ArrowRightIcon className="hidden size-4 sm:block" /> | ||
| 281 | </div> | ||
| 282 | <section className="flex-1 space-y-2"> | ||
| 283 | <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> | ||
| 284 | Into | ||
| 285 | </p> | ||
| 286 | {canCreateProfile ? ( | ||
| 287 | <SelectableTile | ||
| 288 | selected={target.kind === "new"} | ||
| 289 | title="New profile" | ||
| 290 | subtitle="Created for these cookies" | ||
| 291 | onSelect={() => onTargetChange({ kind: "new" })} | ||
| 292 | /> | ||
| 293 | ) : null} | ||
| 294 | {targetProfiles.map((profile) => ( | ||
| 295 | <SelectableTile | ||
| 296 | key={profile.id} | ||
| 297 | selected={target.kind === "existing" && target.profileId === profile.id} | ||
| 298 | title={profile.name} | ||
| 299 | subtitle="Existing profile" | ||
| 300 | onSelect={() => onTargetChange({ kind: "existing", profileId: profile.id })} | ||
| 301 | /> | ||
| 302 | ))} | ||
| 303 | </section> | ||
| 304 | </div> | ||
| 305 | {targetFeedback ? ( | ||
| 306 | <p role="alert" className="mt-3 text-sm text-destructive"> | ||
| 307 | {targetFeedback} | ||
| 308 | </p> | ||
| 309 | ) : null} | ||
| 310 | </DialogPanel> | ||
| 311 | <DialogFooter> | ||
| 312 | <Button variant="outline" onClick={onCancel}> | ||
| 313 | Cancel | ||
| 314 | </Button> | ||
| 315 | <Button | ||
| 316 | disabled={sourceProfileDirectory === "" || targetMissing || targetUncreatable} | ||
| 317 | onClick={onImport} | ||
| 318 | > | ||
| 319 | Import | ||
| 320 | </Button> | ||
| 321 | </DialogFooter> | ||
| 322 | </> | ||
| 323 | ); | ||
| 324 | } | ||
| 325 | |||
| 326 | /** One selectable option: a name, an optional detail line, and a check. */ | ||
| 327 | function SelectableTile({ | ||
| 328 | selected, | ||
| 329 | title, | ||
| 330 | subtitle, | ||
| 331 | onSelect, | ||
| 332 | }: { | ||
| 333 | readonly selected: boolean; | ||
| 334 | readonly title: string; | ||
| 335 | readonly subtitle?: string | undefined; | ||
| 336 | readonly onSelect: () => void; | ||
| 337 | }) { | ||
| 338 | return ( | ||
| 339 | <button | ||
| 340 | type="button" | ||
| 341 | aria-pressed={selected} | ||
| 342 | onClick={onSelect} | ||
| 343 | className={cn( | ||
| 344 | "flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | ||
| 345 | selected | ||
| 346 | ? "border-primary bg-primary/8" | ||
| 347 | : "border-border/60 hover:border-border hover:bg-muted/40", | ||
| 348 | )} | ||
| 349 | > | ||
| 350 | <span className="min-w-0"> | ||
| 351 | <span className="block truncate text-sm font-medium text-foreground">{title}</span> | ||
| 352 | {subtitle ? ( | ||
| 353 | <span className="block truncate text-xs tabular-nums text-muted-foreground"> | ||
| 354 | {subtitle} | ||
| 355 | </span> | ||
| 356 | ) : null} | ||
| 357 | </span> | ||
| 358 | <span | ||
| 359 | className={cn( | ||
| 360 | "grid size-4 shrink-0 place-items-center rounded-full border", | ||
| 361 | selected ? "border-primary bg-primary text-primary-foreground" : "border-input", | ||
| 362 | )} | ||
| 363 | > | ||
| 364 | {selected ? <CheckIcon className="size-2.5" /> : null} | ||
| 365 | </span> | ||
| 366 | </button> | ||
| 367 | ); | ||
| 368 | } | ||
| 369 | |||
| 370 | function ImportingStep() { | ||
| 371 | return ( | ||
| 372 | <> | ||
| 373 | <DialogHeader> | ||
| 374 | <DialogTitle>Importing cookies</DialogTitle> | ||
| 375 | <DialogDescription>This may take a moment.</DialogDescription> | ||
| 376 | </DialogHeader> | ||
| 377 | <DialogPanel className="flex items-center gap-3 py-6"> | ||
| 378 | <Spinner className="size-4 text-muted-foreground" /> | ||
| 379 | <span className="text-sm text-muted-foreground">Importing…</span> | ||
| 380 | </DialogPanel> | ||
| 381 | </> | ||
| 382 | ); | ||
| 383 | } | ||
| 384 | |||
| 385 | function CheckingStep({ sourceName }: { readonly sourceName: string }) { | ||
| 386 | return ( | ||
| 387 | <> | ||
| 388 | <DialogHeader> | ||
| 389 | <DialogTitle>Checking {sourceName}</DialogTitle> | ||
| 390 | <DialogDescription>Checking whether the browser has closed.</DialogDescription> | ||
| 391 | </DialogHeader> | ||
| 392 | <DialogPanel className="flex items-center gap-3 py-6"> | ||
| 393 | <Spinner className="size-4 text-muted-foreground" /> | ||
| 394 | <span className="text-sm text-muted-foreground">Checking…</span> | ||
| 395 | </DialogPanel> | ||
| 396 | </> | ||
| 397 | ); | ||
| 398 | } | ||
| 399 | |||
| 400 | function DoneStep({ | ||
| 401 | imported, | ||
| 402 | skipped, | ||
| 403 | skippedDomains, | ||
| 404 | targetName, | ||
| 405 | destinationEnvironmentName, | ||
| 406 | onClose, | ||
| 407 | }: { | ||
| 408 | readonly imported: number; | ||
| 409 | readonly skipped: number; | ||
| 410 | readonly skippedDomains: ReadonlyArray<string>; | ||
| 411 | readonly targetName: string; | ||
| 412 | readonly destinationEnvironmentName: string; | ||
| 413 | readonly onClose: () => void; | ||
| 414 | }) { | ||
| 415 | return ( | ||
| 416 | <> | ||
| 417 | <DialogHeader> | ||
| 418 | <DialogTitle> | ||
| 419 | {imported > 0 | ||
| 420 | ? `Imported ${cookieResultCount(imported)}` | ||
| 421 | : skipped > 0 | ||
| 422 | ? `Skipped ${cookieResultCount(skipped)}` | ||
| 423 | : "No cookies found"} | ||
| 424 | </DialogTitle> | ||
| 425 | <DialogDescription> | ||
| 426 | {imported > 0 | ||
| 427 | ? `Added to ${targetName} for ${destinationEnvironmentName}.${skipped > 0 ? ` ${cookieResultCount(skipped)} skipped.` : ""}` | ||
| 428 | : skipped > 0 | ||
| 429 | ? `No cookies were imported for ${destinationEnvironmentName}.` | ||
| 430 | : `There were no cookies to import for ${destinationEnvironmentName}.`} | ||
| 431 | </DialogDescription> | ||
| 432 | </DialogHeader> | ||
| 433 | {skippedDomains.length > 0 ? ( | ||
| 434 | <DialogPanel> | ||
| 435 | <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> | ||
| 436 | Skipped | ||
| 437 | </p> | ||
| 438 | <p className="mt-1 text-sm text-foreground">{formatSkippedDomains(skippedDomains)}</p> | ||
| 439 | </DialogPanel> | ||
| 440 | ) : null} | ||
| 441 | <DialogFooter> | ||
| 442 | <DialogClose render={<Button />} onClick={onClose}> | ||
| 443 | Done | ||
| 444 | </DialogClose> | ||
| 445 | </DialogFooter> | ||
| 446 | </> | ||
| 447 | ); | ||
| 448 | } | ||
| 449 | |||
| 450 | function BlockedStep({ | ||
| 451 | source, | ||
| 452 | reason, | ||
| 453 | onClose, | ||
| 454 | onRetry, | ||
| 455 | }: { | ||
| 456 | readonly source: BrowserImportSource; | ||
| 457 | readonly reason: keyof typeof BROWSER_IMPORT_FAILURE_COPY; | ||
| 458 | readonly onClose: () => void; | ||
| 459 | readonly onRetry: (() => void) | undefined; | ||
| 460 | }) { | ||
| 461 | return ( | ||
| 462 | <> | ||
| 463 | <DialogHeader> | ||
| 464 | <DialogTitle>Couldn&rsquo;t import from {source.name}</DialogTitle> | ||
| 465 | <DialogDescription>{BROWSER_IMPORT_FAILURE_COPY[reason]}</DialogDescription> | ||
| 466 | </DialogHeader> | ||
| 467 | <DialogFooter> | ||
| 468 | <Button variant="outline" onClick={onClose}> | ||
| 469 | Close | ||
| 470 | </Button> | ||
| 471 | {onRetry ? <Button onClick={onRetry}>Try again</Button> : null} | ||
| 472 | </DialogFooter> | ||
| 473 | </> | ||
| 474 | ); | ||
| 475 | } | ||
apps/web/src/components/settings/IntegrationsSettings.logic.test.ts+27-1| ... | @@ -1,7 +1,11 @@ | ... | @@ -1,7 +1,11 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | import { browserProfileRemovalAvailable, clearBrowserProfileData } from "./IntegrationsSettings"; | 4 | import { |
| 5 | browserProfileRemovalAvailable, | ||
| 6 | clearBrowserProfileData, | ||
| 7 | importFailureReason, | ||
| 8 | } from "./IntegrationsSettings"; | ||
| 5 | 9 | ||
| 6 | const environmentId = "environment-a" as EnvironmentId; | 10 | const environmentId = "environment-a" as EnvironmentId; |
| 7 | const secondEnvironmentId = "environment-b" as EnvironmentId; | 11 | const secondEnvironmentId = "environment-b" as EnvironmentId; |
| ... | @@ -72,3 +76,25 @@ describe("browserProfileRemovalAvailable", () => { | ... | @@ -72,3 +76,25 @@ describe("browserProfileRemovalAvailable", () => { |
| 72 | expect(browserProfileRemovalAvailable(false, true, 1)).toBe(false); | 76 | expect(browserProfileRemovalAvailable(false, true, 1)).toBe(false); |
| 73 | }); | 77 | }); |
| 74 | }); | 78 | }); |
| 79 | |||
| 80 | // Mirrors `BrowserImportFailedError.message`, which IPC flattens to a string | ||
| 81 | // before the renderer sees it. | ||
| 82 | const failure = (reason: string) => ({ | ||
| 83 | message: `Importing cookies from safari failed: ${reason}.`, | ||
| 84 | }); | ||
| 85 | |||
| 86 | describe("importFailureReason", () => { | ||
| 87 | it("recovers the reason token from the flattened message", () => { | ||
| 88 | expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning"); | ||
| 89 | expect(importFailureReason(failure("readFailed"))).toBe("readFailed"); | ||
| 90 | // A settings write that fails after the cookies landed is its own case, | ||
| 91 | // not a read failure over a database that was in fact read. | ||
| 92 | expect(importFailureReason(failure("profileNotSaved"))).toBe("profileNotSaved"); | ||
| 93 | expect(importFailureReason(failure("profileLimitReached"))).toBe("profileLimitReached"); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("falls back to readFailed for anything it cannot classify", () => { | ||
| 97 | expect(importFailureReason(new Error("something else entirely"))).toBe("readFailed"); | ||
| 98 | expect(importFailureReason(undefined)).toBe("readFailed"); | ||
| 99 | }); | ||
| 100 | }); |
apps/web/src/components/settings/IntegrationsSettings.tsx+432-165| ... | @@ -7,6 +7,7 @@ | ... | @@ -7,6 +7,7 @@ |
| 7 | * @module IntegrationsSettings | 7 | * @module IntegrationsSettings |
| 8 | */ | 8 | */ |
| 9 | import { | 9 | import { |
| 10 | BrowserImportFailureReason, | ||
| 10 | BROWSER_PROFILE_MAX_COUNT, | 11 | BROWSER_PROFILE_MAX_COUNT, |
| 11 | type BrowserLinkTarget, | 12 | type BrowserLinkTarget, |
| 12 | type BrowserProfile, | 13 | type BrowserProfile, |
| ... | @@ -29,21 +30,32 @@ import { | ... | @@ -29,21 +30,32 @@ import { |
| 29 | findBrowserProfile, | 30 | findBrowserProfile, |
| 30 | isBuiltInBrowserProfileId, | 31 | isBuiltInBrowserProfileId, |
| 31 | resolveBrowserProfiles, | 32 | resolveBrowserProfiles, |
| 33 | type BrowserImportSource, | ||
| 32 | type PreviewAppearancePreference, | 34 | type PreviewAppearancePreference, |
| 33 | type PreviewViewportSetting, | 35 | type PreviewViewportSetting, |
| 34 | } from "@t3tools/contracts"; | 36 | } from "@t3tools/contracts"; |
| 35 | import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; | 37 | import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; |
| 36 | import { InfoIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react"; | 38 | import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react"; |
| 37 | import { useState } from "react"; | 39 | import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; |
| 38 | import type { ReactNode } from "react"; | ||
| 39 | 40 | ||
| 40 | import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; | 41 | import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; |
| 42 | import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; | ||
| 41 | import { previewBridge } from "~/components/preview/previewBridge"; | 43 | import { previewBridge } from "~/components/preview/previewBridge"; |
| 42 | import { cn, randomUUID } from "~/lib/utils"; | 44 | import { cn, randomUUID } from "~/lib/utils"; |
| 43 | import { useEnvironments } from "~/state/environments"; | 45 | import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; |
| 44 | import { isElectron } from "../../env"; | 46 | import { isElectron } from "../../env"; |
| 45 | 47 | ||
| 46 | import { Badge } from "../ui/badge"; | 48 | import { Badge } from "../ui/badge"; |
| 49 | import { | ||
| 50 | Menu, | ||
| 51 | MenuGroup, | ||
| 52 | MenuGroupLabel, | ||
| 53 | MenuItem, | ||
| 54 | MenuPopup, | ||
| 55 | MenuSeparator, | ||
| 56 | MenuTrigger, | ||
| 57 | } from "../ui/menu"; | ||
| 58 | import { toastManager } from "../ui/toast"; | ||
| 47 | import { | 59 | import { |
| 48 | AlertDialog, | 60 | AlertDialog, |
| 49 | AlertDialogClose, | 61 | AlertDialogClose, |
| ... | @@ -69,6 +81,7 @@ import { Switch } from "../ui/switch"; | ... | @@ -69,6 +81,7 @@ import { Switch } from "../ui/switch"; |
| 69 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 81 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 70 | import { | 82 | import { |
| 71 | getClientSettings, | 83 | getClientSettings, |
| 84 | persistClientSettingsUpdate, | ||
| 72 | useClientSettings, | 85 | useClientSettings, |
| 73 | useClientSettingsHydrated, | 86 | useClientSettingsHydrated, |
| 74 | usePrimarySettings, | 87 | usePrimarySettings, |
| ... | @@ -81,8 +94,9 @@ import { | ... | @@ -81,8 +94,9 @@ import { |
| 81 | SettingsRow, | 94 | SettingsRow, |
| 82 | SettingsSection, | 95 | SettingsSection, |
| 83 | } from "./settingsLayout"; | 96 | } from "./settingsLayout"; |
| 84 | import { ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; | ||
| 85 | import { searchableSetting } from "./settingsSearch"; | 97 | import { searchableSetting } from "./settingsSearch"; |
| 98 | import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard"; | ||
| 99 | import type { ImportOutcome } from "./browserImportWizard.logic"; | ||
| 86 | 100 | ||
| 87 | const FILL_VALUE = "fill"; | 101 | const FILL_VALUE = "fill"; |
| 88 | const RESPONSIVE_VALUE = "responsive"; | 102 | const RESPONSIVE_VALUE = "responsive"; |
| ... | @@ -133,6 +147,27 @@ const APPEARANCE_LABELS: Readonly<Record<PreviewAppearancePreference, string>> = | ... | @@ -133,6 +147,27 @@ const APPEARANCE_LABELS: Readonly<Record<PreviewAppearancePreference, string>> = |
| 133 | 147 | ||
| 134 | const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`; | 148 | const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`; |
| 135 | 149 | ||
| 150 | /** | ||
| 151 | * IPC flattens the failure to its message, so the reason token travels inside | ||
| 152 | * it. Anything unrecognised reads as a plain read failure rather than leaking | ||
| 153 | * the raw message into a toast. | ||
| 154 | */ | ||
| 155 | /** Thrown from the post-import settings updater when the cap was hit meanwhile. */ | ||
| 156 | class ProfileLimitReachedError extends Error { | ||
| 157 | constructor() { | ||
| 158 | super("Browser profile limit reached."); | ||
| 159 | this.name = "ProfileLimitReachedError"; | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | export const importFailureReason = (cause: unknown): BrowserImportFailureReason => { | ||
| 164 | const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); | ||
| 165 | return ( | ||
| 166 | BrowserImportFailureReason.literals.find((reason) => message.includes(`failed: ${reason}.`)) ?? | ||
| 167 | "readFailed" | ||
| 168 | ); | ||
| 169 | }; | ||
| 170 | |||
| 136 | const viewportSelectValue = (viewport: PreviewViewportSetting): string => { | 171 | const viewportSelectValue = (viewport: PreviewViewportSetting): string => { |
| 137 | if (viewport._tag === "fill") return FILL_VALUE; | 172 | if (viewport._tag === "fill") return FILL_VALUE; |
| 138 | if ( | 173 | if ( |
| ... | @@ -614,17 +649,34 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode | ... | @@ -614,17 +649,34 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode |
| 614 | } | 649 | } |
| 615 | 650 | ||
| 616 | /** | 651 | /** |
| 617 | * Create, rename, and remove browser profiles. | 652 | * Profile list, its header menu, and the import flow. |
| 618 | * | 653 | * |
| 619 | * Built-ins render without controls: they are synthesized rather than stored, | 654 | * One menu creates profiles and imports into them, because the two are the |
| 620 | * so there is nothing to rename and removing them would strand every tab that | 655 | * same decision from the user's side: "I want a profile that has my Helium |
| 621 | * opened under them. | 656 | * logins in it". Import targets include "New profile" so that case does not |
| 657 | * require creating one first and then finding a second control. | ||
| 658 | * | ||
| 659 | * Built-ins render without a rename field: they are synthesized rather than | ||
| 660 | * stored, so there is nothing to rename and removing them would strand every | ||
| 661 | * tab that opened under them. | ||
| 662 | * | ||
| 663 | * Sources are listed lazily on open: detection touches the other browser's | ||
| 664 | * files, and the answer changes while the app is running (quitting the browser | ||
| 665 | * clears `browserRunning`), so a value cached at mount would go stale. | ||
| 622 | */ | 666 | */ |
| 623 | function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { | 667 | function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 624 | const userProfiles = useClientSettings((settings) => settings.browserProfiles); | 668 | const userProfiles = useClientSettings((settings) => settings.browserProfiles); |
| 669 | const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); | ||
| 625 | const settingsHydrated = useClientSettingsHydrated(); | 670 | const settingsHydrated = useClientSettingsHydrated(); |
| 626 | const updateSettings = useUpdatePrimarySettings(); | 671 | const updateSettings = useUpdatePrimarySettings(); |
| 627 | const { environments, isReady: environmentsReady } = useEnvironments(); | 672 | const { environments, isReady: environmentsReady } = useEnvironments(); |
| 673 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 674 | const [sources, setSources] = useState<ReadonlyArray<BrowserImportSource> | null>(null); | ||
| 675 | const [importSession, setImportSession] = useState<{ | ||
| 676 | readonly source: BrowserImportSource; | ||
| 677 | readonly environmentId: EnvironmentId; | ||
| 678 | readonly environmentName: string; | ||
| 679 | } | null>(null); | ||
| 628 | const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null); | 680 | const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null); |
| 629 | const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null); | 681 | const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null); |
| 630 | const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false); | 682 | const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false); |
| ... | @@ -633,25 +685,36 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { | ... | @@ -633,25 +685,36 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 633 | environmentsReady, | 685 | environmentsReady, |
| 634 | environments.length, | 686 | environments.length, |
| 635 | ); | 687 | ); |
| 688 | const importInFlightRef = useRef(false); | ||
| 689 | const [importInFlight, setImportInFlight] = useState(false); | ||
| 636 | const profileWritesDisabled = disabled || !settingsHydrated; | 690 | const profileWritesDisabled = disabled || !settingsHydrated; |
| 637 | 691 | ||
| 638 | const addProfile = () => { | 692 | const profiles = resolveBrowserProfiles(userProfiles); |
| 639 | if (!settingsHydrated) return; | 693 | // Incognito is deliberately not a row — it holds nothing to manage — so the |
| 694 | // default has to resolve against the list that renders. A stored | ||
| 695 | // `browserDefaultProfileId` of "incognito" would otherwise leave the section | ||
| 696 | // with no Default badge at all. | ||
| 697 | const listedProfiles = profiles.filter((profile) => profile.kind !== "incognito"); | ||
| 698 | const resolvedDefaultId = | ||
| 699 | findBrowserProfile(listedProfiles, defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID; | ||
| 700 | |||
| 701 | const createProfile = (baseName: string) => { | ||
| 702 | if (!settingsHydrated || importInFlightRef.current) return undefined; | ||
| 640 | const currentProfiles = getClientSettings().browserProfiles; | 703 | const currentProfiles = getClientSettings().browserProfiles; |
| 641 | if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return; | 704 | // Checked against the live settings, not the rendered list: two clicks |
| 642 | const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name)); | 705 | // before a re-render would otherwise both pass the disabled control. |
| 643 | let name = "New profile"; | 706 | if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return undefined; |
| 644 | for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`; | 707 | const resolvedProfiles = resolveBrowserProfiles(currentProfiles); |
| 645 | updateSettings({ | 708 | const taken = new Set(resolvedProfiles.map((profile) => profile.name)); |
| 646 | browserProfiles: [ | 709 | let name = baseName; |
| 647 | ...currentProfiles, | 710 | for (let index = 2; taken.has(name); index += 1) name = `${baseName} ${index}`; |
| 648 | { id: `profile-${randomUUID()}`, name, kind: "persistent" as const }, | 711 | const profile = { id: `profile-${randomUUID()}`, name, kind: "persistent" as const }; |
| 649 | ], | 712 | updateSettings({ browserProfiles: [...currentProfiles, profile] }); |
| 650 | }); | 713 | return profile; |
| 651 | }; | 714 | }; |
| 652 | 715 | ||
| 653 | const renameProfile = (id: string, next: string) => { | 716 | const renameProfile = (id: string, next: string) => { |
| 654 | if (!settingsHydrated) return; | 717 | if (!settingsHydrated || importInFlightRef.current) return; |
| 655 | const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH); | 718 | const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH); |
| 656 | if (name === "") return; | 719 | if (name === "") return; |
| 657 | const currentProfiles = getClientSettings().browserProfiles; | 720 | const currentProfiles = getClientSettings().browserProfiles; |
| ... | @@ -662,8 +725,31 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { | ... | @@ -662,8 +725,31 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 662 | }); | 725 | }); |
| 663 | }; | 726 | }; |
| 664 | 727 | ||
| 728 | const clearProfileData = (id: string, name: string) => { | ||
| 729 | if (!settingsHydrated || importInFlightRef.current) return; | ||
| 730 | if (!previewBridge || !environmentsReady || environments.length === 0) { | ||
| 731 | toastManager.add({ | ||
| 732 | type: "error", | ||
| 733 | title: `Could not clear ${name}'s data`, | ||
| 734 | description: "You're not connected to a server yet.", | ||
| 735 | }); | ||
| 736 | return; | ||
| 737 | } | ||
| 738 | void clearBrowserProfileData( | ||
| 739 | previewBridge, | ||
| 740 | environments.map((environment) => environment.environmentId), | ||
| 741 | id, | ||
| 742 | ) | ||
| 743 | .then(() => { | ||
| 744 | toastManager.add({ type: "success", title: `Cleared ${name}'s cookies and cache` }); | ||
| 745 | }) | ||
| 746 | .catch(() => { | ||
| 747 | toastManager.add({ type: "error", title: `Could not clear ${name}'s data` }); | ||
| 748 | }); | ||
| 749 | }; | ||
| 750 | |||
| 665 | const removeProfile = async (id: string) => { | 751 | const removeProfile = async (id: string) => { |
| 666 | if (!settingsHydrated) return; | 752 | if (!settingsHydrated || importInFlightRef.current) return; |
| 667 | if (!removalAvailable) { | 753 | if (!removalAvailable) { |
| 668 | setProfileRemovalError("Connect to an environment before removing this profile."); | 754 | setProfileRemovalError("Connect to an environment before removing this profile."); |
| 669 | return; | 755 | return; |
| ... | @@ -695,92 +781,330 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { | ... | @@ -695,92 +781,330 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 695 | setProfilePendingRemoval(null); | 781 | setProfilePendingRemoval(null); |
| 696 | }; | 782 | }; |
| 697 | 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. | ||
| 788 | const importableSources = (sources ?? []).filter( | ||
| 789 | (source) => | ||
| 790 | source.unavailable !== "notInstalled" && source.unavailable !== "unsupportedPlatform", | ||
| 791 | ); | ||
| 792 | |||
| 793 | // Refreshed without blanking the last result: the menu shows the cached list | ||
| 794 | // straight away so it doesn't reflow on open, and the source list is stable | ||
| 795 | // (names only) since choosing what to import happens in the wizard, not here. | ||
| 796 | const loadSources = useCallback(() => { | ||
| 797 | if (!previewBridge) return; | ||
| 798 | void previewBridge | ||
| 799 | .listBrowserImportSources() | ||
| 800 | .then(setSources) | ||
| 801 | .catch(() => setSources((previous) => previous ?? [])); | ||
| 802 | }, []); | ||
| 803 | |||
| 804 | // Loaded once so the first open is instant instead of flashing a spinner. | ||
| 805 | useEffect(() => { | ||
| 806 | loadSources(); | ||
| 807 | }, [loadSources]); | ||
| 808 | |||
| 809 | // Runs one import for the wizard. A new profile is registered only once the | ||
| 810 | // import succeeds — the cookies land in its partition first — so a blocked | ||
| 811 | // attempt never leaves an empty profile behind. | ||
| 812 | const runWizardImport = async ( | ||
| 813 | source: BrowserImportSource, | ||
| 814 | environmentId: EnvironmentId, | ||
| 815 | input: { readonly sourceProfileDirectory: string; readonly target: WizardTarget }, | ||
| 816 | ): Promise<ImportOutcome> => { | ||
| 817 | if (!previewBridge) return { kind: "blocked", reason: "sessionUnavailable" }; | ||
| 818 | if (!settingsHydrated) return { kind: "blocked", reason: "sessionUnavailable" }; | ||
| 819 | if ( | ||
| 820 | input.target.kind === "existing" && | ||
| 821 | !resolveBrowserProfiles(getClientSettings().browserProfiles).some( | ||
| 822 | (profile) => profile.id === input.target.profileId, | ||
| 823 | ) | ||
| 824 | ) { | ||
| 825 | return { kind: "blocked", reason: "readFailed" }; | ||
| 826 | } | ||
| 827 | if (importInFlightRef.current) return { kind: "blocked", reason: "readFailed" }; | ||
| 828 | importInFlightRef.current = true; | ||
| 829 | setImportInFlight(true); | ||
| 830 | try { | ||
| 831 | const result = await previewBridge.importBrowserCookies({ | ||
| 832 | environmentId, | ||
| 833 | sourceId: source.id, | ||
| 834 | sourceProfileDirectory: input.sourceProfileDirectory, | ||
| 835 | targetProfileId: input.target.profileId, | ||
| 836 | }); | ||
| 837 | if ( | ||
| 838 | input.target.kind === "existing" && | ||
| 839 | !resolveBrowserProfiles(getClientSettings().browserProfiles).some( | ||
| 840 | (profile) => profile.id === input.target.profileId, | ||
| 841 | ) | ||
| 842 | ) { | ||
| 843 | return { kind: "blocked", reason: "readFailed" }; | ||
| 844 | } | ||
| 845 | let targetName: string; | ||
| 846 | if (input.target.kind === "new") { | ||
| 847 | // Registered only when something actually came over: an import that | ||
| 848 | // found no cookies should not leave a new, empty profile behind. | ||
| 849 | if (result.imported > 0) { | ||
| 850 | try { | ||
| 851 | const persisted = await persistClientSettingsUpdate((current) => { | ||
| 852 | const existing = current.browserProfiles.find( | ||
| 853 | (profile) => profile.id === input.target.profileId, | ||
| 854 | ); | ||
| 855 | if (existing) return current; | ||
| 856 | // The wizard refuses a new target at the cap, but the cap can be | ||
| 857 | // reached while the import runs; the updater sees the newest | ||
| 858 | // settings, so this is the check that holds. | ||
| 859 | if (current.browserProfiles.length >= BROWSER_PROFILE_MAX_COUNT) { | ||
| 860 | throw new ProfileLimitReachedError(); | ||
| 861 | } | ||
| 862 | const taken = new Set( | ||
| 863 | resolveBrowserProfiles(current.browserProfiles).map((profile) => profile.name), | ||
| 864 | ); | ||
| 865 | let name = source.name; | ||
| 866 | for (let index = 2; taken.has(name); index += 1) name = `${source.name} ${index}`; | ||
| 867 | return { | ||
| 868 | ...current, | ||
| 869 | browserProfiles: [ | ||
| 870 | ...current.browserProfiles, | ||
| 871 | { id: input.target.profileId, name, kind: "persistent" as const }, | ||
| 872 | ], | ||
| 873 | }; | ||
| 874 | }); | ||
| 875 | targetName = | ||
| 876 | persisted.browserProfiles.find((profile) => profile.id === input.target.profileId) | ||
| 877 | ?.name ?? source.name; | ||
| 878 | } catch (cause) { | ||
| 879 | // This target id belongs only to the attempted new profile. Clear | ||
| 880 | // its partition so a failed registration cannot strand imported | ||
| 881 | // cookies behind a profile that disappears on restart. | ||
| 882 | await clearBrowserProfileData( | ||
| 883 | previewBridge, | ||
| 884 | [environmentId], | ||
| 885 | input.target.profileId, | ||
| 886 | ).catch(() => undefined); | ||
| 887 | // Not a read failure: the cookies came over and were cleared again | ||
| 888 | // because the profile could not be kept. Name that, in the same | ||
| 889 | // token form `importFailureReason` recovers from a bridge error. | ||
| 890 | const reason = | ||
| 891 | cause instanceof ProfileLimitReachedError ? "profileLimitReached" : "profileNotSaved"; | ||
| 892 | throw new Error(`Importing cookies from ${source.id} failed: ${reason}.`, { cause }); | ||
| 893 | } | ||
| 894 | } else { | ||
| 895 | targetName = source.name; | ||
| 896 | } | ||
| 897 | } else { | ||
| 898 | targetName = input.target.name; | ||
| 899 | } | ||
| 900 | return { | ||
| 901 | kind: "imported", | ||
| 902 | imported: result.imported, | ||
| 903 | skipped: result.skipped, | ||
| 904 | skippedDomains: result.skippedDomains, | ||
| 905 | targetName, | ||
| 906 | }; | ||
| 907 | } catch (cause) { | ||
| 908 | return { kind: "blocked", reason: importFailureReason(cause) }; | ||
| 909 | } finally { | ||
| 910 | importInFlightRef.current = false; | ||
| 911 | setImportInFlight(false); | ||
| 912 | } | ||
| 913 | }; | ||
| 914 | |||
| 915 | // Re-checks a source's availability after the user quits the browser, and | ||
| 916 | // keeps the cached list in step so the menu reflects it too. | ||
| 917 | const refreshImportSource = async ( | ||
| 918 | sourceId: BrowserImportSource["id"], | ||
| 919 | ): Promise<BrowserImportSource | undefined> => { | ||
| 920 | if (!previewBridge) return undefined; | ||
| 921 | try { | ||
| 922 | const latest = await previewBridge.listBrowserImportSources(); | ||
| 923 | setSources(latest); | ||
| 924 | return latest.find((source) => source.id === sourceId); | ||
| 925 | } catch { | ||
| 926 | return undefined; | ||
| 927 | } | ||
| 928 | }; | ||
| 929 | |||
| 930 | const atProfileLimit = userProfiles.length >= BROWSER_PROFILE_MAX_COUNT; | ||
| 931 | |||
| 698 | return ( | 932 | return ( |
| 699 | <SettingsRow | 933 | <SettingsRow |
| 700 | {...searchableSetting("browser-profiles")} | 934 | {...searchableSetting("browser-profiles")} |
| 701 | description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes." | 935 | description="Each profile has its own cookies and logins, so you can stay signed in to different accounts at once." |
| 702 | control={ | 936 | control={ |
| 703 | <Button | 937 | <Menu onOpenChange={(open) => open && loadSources()}> |
| 704 | size="sm" | 938 | <MenuTrigger |
| 705 | variant="outline" | 939 | render={ |
| 706 | disabled={profileWritesDisabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT} | 940 | <Button |
| 707 | onClick={addProfile} | 941 | size="sm" |
| 708 | > | 942 | variant="outline" |
| 709 | <PlusIcon /> | 943 | disabled={profileWritesDisabled || importInFlight} |
| 710 | Add profile | 944 | /> |
| 711 | </Button> | 945 | } |
| 946 | > | ||
| 947 | <PlusIcon /> | ||
| 948 | Add profile | ||
| 949 | </MenuTrigger> | ||
| 950 | <MenuPopup align="end" className="min-w-56"> | ||
| 951 | <MenuItem | ||
| 952 | disabled={!settingsHydrated || atProfileLimit} | ||
| 953 | onClick={() => createProfile("New profile")} | ||
| 954 | > | ||
| 955 | Blank profile | ||
| 956 | </MenuItem> | ||
| 957 | {atProfileLimit ? ( | ||
| 958 | <MenuItem disabled>You&rsquo;ve reached the profile limit</MenuItem> | ||
| 959 | ) : null} | ||
| 960 | <MenuSeparator /> | ||
| 961 | <MenuGroup> | ||
| 962 | <MenuGroupLabel>Import from</MenuGroupLabel> | ||
| 963 | {sources === null ? ( | ||
| 964 | <MenuItem disabled>Looking for browsers…</MenuItem> | ||
| 965 | ) : importableSources.length === 0 ? ( | ||
| 966 | <MenuItem disabled>No supported browsers found</MenuItem> | ||
| 967 | ) : ( | ||
| 968 | // Every source is a plain row — running, needs-permission and | ||
| 969 | // ready all look the same here. The wizard picks up whatever | ||
| 970 | // state the source is in and walks the user forward from there. | ||
| 971 | <> | ||
| 972 | {importableSources.map((source) => ( | ||
| 973 | <MenuItem | ||
| 974 | key={source.id} | ||
| 975 | disabled={!settingsHydrated || primaryEnvironment == null} | ||
| 976 | onClick={() => { | ||
| 977 | if (!settingsHydrated || primaryEnvironment == null) return; | ||
| 978 | setImportSession({ | ||
| 979 | source, | ||
| 980 | environmentId: primaryEnvironment.environmentId, | ||
| 981 | environmentName: resolveEnvironmentOptionLabel({ | ||
| 982 | isPrimary: true, | ||
| 983 | environmentId: primaryEnvironment.environmentId, | ||
| 984 | runtimeLabel: primaryEnvironment.label, | ||
| 985 | }), | ||
| 986 | }); | ||
| 987 | }} | ||
| 988 | > | ||
| 989 | {source.name} | ||
| 990 | </MenuItem> | ||
| 991 | ))} | ||
| 992 | {primaryEnvironment == null ? ( | ||
| 993 | <MenuItem disabled>Connect to an environment to import cookies</MenuItem> | ||
| 994 | ) : null} | ||
| 995 | </> | ||
| 996 | )} | ||
| 997 | </MenuGroup> | ||
| 998 | </MenuPopup> | ||
| 999 | </Menu> | ||
| 712 | } | 1000 | } |
| 713 | > | 1001 | > |
| 714 | {/* | 1002 | {/* |
| 715 | Each profile is its own bounded row, and the list carries the bottom | 1003 | The bordered container groups rows unambiguously at any width, and |
| 716 | spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows | 1004 | carries the bottom spacing `SettingsRow` leaves to its children |
| 717 | stack on narrow viewports with a larger gap inside a row than between | 1005 | (`pt-3 pb-1`). |
| 718 | rows, which reads as the remove button belonging to the profile below. | ||
| 719 | */} | 1006 | */} |
| 720 | <div className="mt-2 space-y-1 pb-2"> | 1007 | <div className="mt-2 mb-2 overflow-hidden rounded-lg border border-border/60"> |
| 721 | {resolveBrowserProfiles(userProfiles).map((profile) => { | 1008 | {listedProfiles.map((profile, index) => { |
| 722 | const builtIn = isBuiltInBrowserProfileId(profile.id); | 1009 | const builtIn = isBuiltInBrowserProfileId(profile.id); |
| 1010 | const isDefault = profile.id === resolvedDefaultId; | ||
| 723 | return ( | 1011 | return ( |
| 724 | <div | 1012 | <div |
| 725 | key={profile.id} | 1013 | key={profile.id} |
| 726 | className={cn( | 1014 | className={cn( |
| 727 | ITEM_ROW_INNER_CLASSNAME, | 1015 | "flex items-center gap-3 px-3 py-2", |
| 728 | "rounded-lg border border-border/60 px-3 py-2", | 1016 | index > 0 && "border-t border-border/60", |
| 729 | )} | 1017 | )} |
| 730 | > | 1018 | > |
| 731 | {builtIn ? ( | 1019 | <span className="flex min-w-0 flex-1 items-center gap-2"> |
| 732 | // Dimmed here rather than on the list, which is the only | 1020 | {builtIn ? ( |
| 733 | // content in the row without a disabled treatment of its own: | 1021 | // Dimmed here rather than on the table: a wrapper-level dim |
| 734 | // a wrapper-level dim would stack with the rename field's and | 1022 | // stacks with the rename field's and the row menu button's |
| 735 | // the remove button's, landing them near 0.41 while every | 1023 | // own, landing them near 0.41 while every other disabled |
| 736 | // other disabled control in the block sits at 0.64. | 1024 | // control in the block sits at 0.64. |
| 737 | <span | 1025 | <span |
| 738 | className={cn( | 1026 | className={cn( |
| 739 | "flex min-w-0 items-center gap-2 text-sm text-foreground", | 1027 | "truncate text-sm text-foreground", |
| 740 | profileWritesDisabled && "opacity-64", | 1028 | profileWritesDisabled && "opacity-64", |
| 741 | )} | 1029 | )} |
| 742 | > | 1030 | > |
| 743 | {profile.name} | 1031 | {profile.name} |
| 744 | <Badge variant="outline"> | 1032 | </span> |
| 745 | {profile.kind === "incognito" ? "Ephemeral" : "Built-in"} | 1033 | ) : ( |
| 746 | </Badge> | 1034 | <DraftInput |
| 747 | </span> | 1035 | nativeInput |
| 748 | ) : ( | 1036 | size="sm" |
| 749 | <DraftInput | 1037 | className="w-full max-w-56" |
| 750 | nativeInput | 1038 | aria-label={`Rename ${profile.name}`} |
| 751 | size="sm" | 1039 | disabled={profileWritesDisabled || importInFlight} |
| 752 | className="w-full sm:w-64" | 1040 | maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH} |
| 753 | aria-label={`Rename ${profile.name}`} | 1041 | value={profile.name} |
| 754 | disabled={profileWritesDisabled} | 1042 | onCommit={(next) => renameProfile(profile.id, next)} |
| 755 | maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH} | ||
| 756 | value={profile.name} | ||
| 757 | onCommit={(next) => renameProfile(profile.id, next)} | ||
| 758 | /> | ||
| 759 | )} | ||
| 760 | {builtIn ? null : ( | ||
| 761 | <Tooltip> | ||
| 762 | <TooltipTrigger | ||
| 763 | render={ | ||
| 764 | <span className="inline-flex" {...(!removalAvailable ? { tabIndex: 0 } : {})}> | ||
| 765 | <Button | ||
| 766 | size="icon-xs" | ||
| 767 | variant="ghost-muted" | ||
| 768 | disabled={profileWritesDisabled || !removalAvailable} | ||
| 769 | aria-label={`Remove ${profile.name}`} | ||
| 770 | onClick={() => setProfilePendingRemoval(profile)} | ||
| 771 | > | ||
| 772 | <Trash2Icon /> | ||
| 773 | </Button> | ||
| 774 | </span> | ||
| 775 | } | ||
| 776 | /> | 1043 | /> |
| 777 | <TooltipPopup side="top"> | 1044 | )} |
| 778 | {removalAvailable | 1045 | {/* |
| 779 | ? "Remove profile and its data" | 1046 | Dimmed with the rest of the row: a `Badge` has no disabled |
| 780 | : "Connect to an environment to remove this profile"} | 1047 | treatment of its own, so a solid `bg-primary` pill would |
| 781 | </TooltipPopup> | 1048 | otherwise sit at full strength beside a name, rename field |
| 782 | </Tooltip> | 1049 | and menu button that are all at 0.64. |
| 783 | )} | 1050 | */} |
| 1051 | {isDefault ? ( | ||
| 1052 | <Badge className={cn(profileWritesDisabled && "opacity-64")}>Default</Badge> | ||
| 1053 | ) : null} | ||
| 1054 | </span> | ||
| 1055 | <Menu> | ||
| 1056 | <MenuTrigger | ||
| 1057 | render={ | ||
| 1058 | <Button | ||
| 1059 | size="icon-xs" | ||
| 1060 | variant="ghost-muted" | ||
| 1061 | disabled={profileWritesDisabled || importInFlight} | ||
| 1062 | aria-label={`${profile.name} options`} | ||
| 1063 | /> | ||
| 1064 | } | ||
| 1065 | > | ||
| 1066 | <MoreVertical /> | ||
| 1067 | </MenuTrigger> | ||
| 1068 | <MenuPopup align="end" className="min-w-44"> | ||
| 1069 | <MenuItem | ||
| 1070 | disabled={!settingsHydrated || isDefault} | ||
| 1071 | onClick={() => { | ||
| 1072 | if (settingsHydrated) { | ||
| 1073 | updateSettings({ browserDefaultProfileId: profile.id }); | ||
| 1074 | } | ||
| 1075 | }} | ||
| 1076 | > | ||
| 1077 | Set as default | ||
| 1078 | </MenuItem> | ||
| 1079 | <MenuItem | ||
| 1080 | disabled={!settingsHydrated || !removalAvailable} | ||
| 1081 | onClick={() => clearProfileData(profile.id, profile.name)} | ||
| 1082 | > | ||
| 1083 | Clear cookies and cache | ||
| 1084 | </MenuItem> | ||
| 1085 | {builtIn ? null : ( | ||
| 1086 | <MenuItem | ||
| 1087 | variant="destructive" | ||
| 1088 | disabled={!settingsHydrated || !removalAvailable} | ||
| 1089 | onClick={() => { | ||
| 1090 | if (settingsHydrated) setProfilePendingRemoval(profile); | ||
| 1091 | }} | ||
| 1092 | > | ||
| 1093 | Remove profile and data | ||
| 1094 | </MenuItem> | ||
| 1095 | )} | ||
| 1096 | {!removalAvailable ? ( | ||
| 1097 | <> | ||
| 1098 | <MenuSeparator /> | ||
| 1099 | <MenuItem disabled> | ||
| 1100 | {environmentsReady | ||
| 1101 | ? "Connect to an environment to clear profile data" | ||
| 1102 | : "Checking environments…"} | ||
| 1103 | </MenuItem> | ||
| 1104 | </> | ||
| 1105 | ) : null} | ||
| 1106 | </MenuPopup> | ||
| 1107 | </Menu> | ||
| 784 | </div> | 1108 | </div> |
| 785 | ); | 1109 | ); |
| 786 | })} | 1110 | })} |
| ... | @@ -798,8 +1122,8 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { | ... | @@ -798,8 +1122,8 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 798 | <AlertDialogHeader> | 1122 | <AlertDialogHeader> |
| 799 | <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle> | 1123 | <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle> |
| 800 | <AlertDialogDescription> | 1124 | <AlertDialogDescription> |
| 801 | Its cookies, logins, and cache are deleted with it. Tabs already open in this profile | 1125 | Its cookies and logins are deleted. Tabs already open in this profile stay open until |
| 802 | stay open until you close them. | 1126 | you close them. |
| 803 | </AlertDialogDescription> | 1127 | </AlertDialogDescription> |
| 804 | {profileRemovalError ? ( | 1128 | {profileRemovalError ? ( |
| 805 | <p aria-live="polite" className="text-sm text-destructive"> | 1129 | <p aria-live="polite" className="text-sm text-destructive"> |
| ... | @@ -833,86 +1157,29 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { | ... | @@ -833,86 +1157,29 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 833 | </AlertDialogFooter> | 1157 | </AlertDialogFooter> |
| 834 | </AlertDialogPopup> | 1158 | </AlertDialogPopup> |
| 835 | </AlertDialog> | 1159 | </AlertDialog> |
| 1160 | {importSession ? ( | ||
| 1161 | <BrowserImportWizard | ||
| 1162 | source={importSession.source} | ||
| 1163 | destinationEnvironmentName={importSession.environmentName} | ||
| 1164 | targetProfiles={listedProfiles.map((profile) => ({ id: profile.id, name: profile.name }))} | ||
| 1165 | canCreateProfile={settingsHydrated && !atProfileLimit} | ||
| 1166 | onImport={(input) => | ||
| 1167 | runWizardImport(importSession.source, importSession.environmentId, input) | ||
| 1168 | } | ||
| 1169 | onRefreshSource={() => refreshImportSource(importSession.source.id)} | ||
| 1170 | onClose={() => setImportSession(null)} | ||
| 1171 | /> | ||
| 1172 | ) : null} | ||
| 836 | </SettingsRow> | 1173 | </SettingsRow> |
| 837 | ); | 1174 | ); |
| 838 | } | 1175 | } |
| 839 | 1176 | ||
| 840 | function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) { | ||
| 841 | const userProfiles = useClientSettings((settings) => settings.browserProfiles); | ||
| 842 | const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); | ||
| 843 | const settingsHydrated = useClientSettingsHydrated(); | ||
| 844 | const updateSettings = useUpdatePrimarySettings(); | ||
| 845 | const profileWritesDisabled = disabled || !settingsHydrated; | ||
| 846 | // Incognito is deliberately absent: as a default it would open every tab | ||
| 847 | // into storage that is discarded on close. | ||
| 848 | const profiles = resolveBrowserProfiles(userProfiles).filter( | ||
| 849 | (profile) => profile.kind !== "incognito", | ||
| 850 | ); | ||
| 851 | const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0]; | ||
| 852 | |||
| 853 | return ( | ||
| 854 | <SettingsRow | ||
| 855 | {...searchableSetting("browser-default-profile")} | ||
| 856 | description="Profile new browser tabs open under, including tabs an agent opens." | ||
| 857 | resetAction={ | ||
| 858 | !profileWritesDisabled && defaultProfileId !== DEFAULT_BROWSER_PROFILE_ID ? ( | ||
| 859 | <SettingResetButton | ||
| 860 | label="default browser profile" | ||
| 861 | onClick={() => { | ||
| 862 | if (settingsHydrated) { | ||
| 863 | updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }); | ||
| 864 | } | ||
| 865 | }} | ||
| 866 | /> | ||
| 867 | ) : null | ||
| 868 | } | ||
| 869 | control={ | ||
| 870 | <Select | ||
| 871 | disabled={profileWritesDisabled} | ||
| 872 | value={selected?.id ?? DEFAULT_BROWSER_PROFILE_ID} | ||
| 873 | onValueChange={(value) => { | ||
| 874 | if (settingsHydrated && value !== null) { | ||
| 875 | updateSettings({ browserDefaultProfileId: value }); | ||
| 876 | } | ||
| 877 | }} | ||
| 878 | > | ||
| 879 | <SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Default browser profile"> | ||
| 880 | <SelectValue>{selected?.name ?? "Default"}</SelectValue> | ||
| 881 | </SelectTrigger> | ||
| 882 | {/* | ||
| 883 | Capped and truncated like the tab menu's profile list: names are | ||
| 884 | user-supplied and run to 48 characters, which would otherwise | ||
| 885 | widen the popup to fit the longest one. The cap goes on the glass | ||
| 886 | shell (`popupClassName`), and the list fills that shell so it is | ||
| 887 | never narrower than the trigger it opens from — the same floor | ||
| 888 | every other settings select keeps. `ItemText` renders a block, so | ||
| 889 | the label must be a block too for `truncate` to apply. | ||
| 890 | */} | ||
| 891 | <SelectPopup | ||
| 892 | align="end" | ||
| 893 | alignItemWithTrigger={false} | ||
| 894 | popupClassName="max-w-64" | ||
| 895 | className="w-full" | ||
| 896 | > | ||
| 897 | {profiles.map((profile) => ( | ||
| 898 | <SelectItem hideIndicator key={profile.id} value={profile.id}> | ||
| 899 | <span className="block min-w-0 truncate">{profile.name}</span> | ||
| 900 | </SelectItem> | ||
| 901 | ))} | ||
| 902 | </SelectPopup> | ||
| 903 | </Select> | ||
| 904 | } | ||
| 905 | /> | ||
| 906 | ); | ||
| 907 | } | ||
| 908 | |||
| 909 | export function IntegrationsSettingsPanel() { | 1177 | export function IntegrationsSettingsPanel() { |
| 910 | // Client-local preview defaults are editable only where the preview exists. | 1178 | // Client-local preview defaults are editable only where the preview exists. |
| 911 | const previewDefaultsDisabled = !isElectron; | 1179 | const previewDefaultsDisabled = !isElectron; |
| 912 | const previewDefaults = ( | 1180 | const previewDefaults = ( |
| 913 | <> | 1181 | <> |
| 914 | <BrowserProfilesSetting disabled={previewDefaultsDisabled} /> | 1182 | <BrowserProfilesSetting disabled={previewDefaultsDisabled} /> |
| 915 | <BrowserDefaultProfileSetting disabled={previewDefaultsDisabled} /> | ||
| 916 | <BrowserViewportSetting disabled={previewDefaultsDisabled} /> | 1183 | <BrowserViewportSetting disabled={previewDefaultsDisabled} /> |
| 917 | <BrowserZoomSetting disabled={previewDefaultsDisabled} /> | 1184 | <BrowserZoomSetting disabled={previewDefaultsDisabled} /> |
| 918 | <BrowserAppearanceSetting disabled={previewDefaultsDisabled} /> | 1185 | <BrowserAppearanceSetting disabled={previewDefaultsDisabled} /> |
apps/web/src/components/settings/browserImportWizard.logic.test.ts created+192| ... | @@ -0,0 +1,192 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import type { BrowserImportSource } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | canCloseWizard, | ||
| 6 | initialWizardStep, | ||
| 7 | initialTargetSelection, | ||
| 8 | isRetryableReason, | ||
| 9 | formatSkippedDomains, | ||
| 10 | outcomeToStep, | ||
| 11 | refreshedSourceProfileDirectory, | ||
| 12 | refreshedSourceStep, | ||
| 13 | resolveWizardTarget, | ||
| 14 | } from "./browserImportWizard.logic"; | ||
| 15 | |||
| 16 | describe("wizard target selection", () => { | ||
| 17 | it("treats an existing profile whose id is new as an existing target", () => { | ||
| 18 | const profiles = [{ id: "new", name: "Existing new" }]; | ||
| 19 | const selection = initialTargetSelection(false, profiles); | ||
| 20 | |||
| 21 | expect(selection).toEqual({ kind: "existing", profileId: "new" }); | ||
| 22 | expect(resolveWizardTarget(selection, "generated", profiles)).toEqual({ | ||
| 23 | kind: "existing", | ||
| 24 | profileId: "new", | ||
| 25 | name: "Existing new", | ||
| 26 | }); | ||
| 27 | }); | ||
| 28 | |||
| 29 | it("rejects an existing target that is no longer listed", () => { | ||
| 30 | expect( | ||
| 31 | resolveWizardTarget({ kind: "existing", profileId: "removed" }, "generated", [ | ||
| 32 | { id: "default", name: "Default" }, | ||
| 33 | ]), | ||
| 34 | ).toBeUndefined(); | ||
| 35 | }); | ||
| 36 | }); | ||
| 37 | |||
| 38 | const source = (over: Partial<BrowserImportSource> = {}): BrowserImportSource => ({ | ||
| 39 | id: "helium", | ||
| 40 | name: "Helium", | ||
| 41 | profiles: [{ directory: "Default", name: "You" }], | ||
| 42 | ...over, | ||
| 43 | }); | ||
| 44 | |||
| 45 | describe("initialWizardStep", () => { | ||
| 46 | it("opens on the quit screen when the browser is running", () => { | ||
| 47 | expect(initialWizardStep(source({ unavailable: "browserRunning" }))).toEqual({ step: "quit" }); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("opens on configure when the source is ready", () => { | ||
| 51 | expect(initialWizardStep(source())).toEqual({ step: "configure" }); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("blocks on a reason nothing local can fix", () => { | ||
| 55 | expect(initialWizardStep(source({ unavailable: "unsupportedPlatform" }))).toEqual({ | ||
| 56 | step: "blocked", | ||
| 57 | reason: "unsupportedPlatform", | ||
| 58 | }); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it("blocks a ready source that reports no importable profiles", () => { | ||
| 62 | expect(initialWizardStep(source({ profiles: [] }))).toEqual({ | ||
| 63 | step: "blocked", | ||
| 64 | reason: "unknownSourceProfile", | ||
| 65 | }); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("handles source availability before checking its profiles", () => { | ||
| 69 | expect( | ||
| 70 | initialWizardStep(source({ profiles: [], unavailable: "needsKeychainApproval" })), | ||
| 71 | ).toEqual({ step: "blocked", reason: "needsKeychainApproval" }); | ||
| 72 | }); | ||
| 73 | }); | ||
| 74 | |||
| 75 | describe("canCloseWizard", () => { | ||
| 76 | it("keeps the wizard open while an import writes its target profile", () => { | ||
| 77 | expect(canCloseWizard({ step: "importing" })).toBe(false); | ||
| 78 | expect(canCloseWizard({ step: "configure" })).toBe(true); | ||
| 79 | expect(canCloseWizard({ step: "blocked", reason: "readFailed" })).toBe(true); | ||
| 80 | }); | ||
| 81 | }); | ||
| 82 | |||
| 83 | describe("outcomeToStep", () => { | ||
| 84 | it("lands on done after a successful import", () => { | ||
| 85 | expect( | ||
| 86 | outcomeToStep({ | ||
| 87 | kind: "imported", | ||
| 88 | imported: 12, | ||
| 89 | skipped: 3, | ||
| 90 | skippedDomains: ["example.com"], | ||
| 91 | targetName: "Work", | ||
| 92 | }), | ||
| 93 | ).toEqual({ | ||
| 94 | step: "done", | ||
| 95 | imported: 12, | ||
| 96 | skipped: 3, | ||
| 97 | skippedDomains: ["example.com"], | ||
| 98 | targetName: "Work", | ||
| 99 | }); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("routes a reopened browser back to the quit screen", () => { | ||
| 103 | expect(outcomeToStep({ kind: "blocked", reason: "browserRunning" })).toEqual({ step: "quit" }); | ||
| 104 | }); | ||
| 105 | |||
| 106 | it("surfaces every other failure on the blocked screen", () => { | ||
| 107 | expect(outcomeToStep({ kind: "blocked", reason: "readFailed" })).toEqual({ | ||
| 108 | step: "blocked", | ||
| 109 | reason: "readFailed", | ||
| 110 | }); | ||
| 111 | }); | ||
| 112 | }); | ||
| 113 | |||
| 114 | describe("refreshedSourceStep", () => { | ||
| 115 | it("moves to configure once a quit browser frees its cookies", () => { | ||
| 116 | expect(refreshedSourceStep(source())).toEqual({ step: "configure" }); | ||
| 117 | }); | ||
| 118 | |||
| 119 | it("stays on quit while the browser is still running", () => { | ||
| 120 | expect(refreshedSourceStep(source({ unavailable: "browserRunning" }))).toEqual({ | ||
| 121 | step: "quit", | ||
| 122 | }); | ||
| 123 | }); | ||
| 124 | |||
| 125 | it("blocks when the source vanished from the list", () => { | ||
| 126 | expect(refreshedSourceStep(undefined)).toEqual({ step: "blocked", reason: "unknownSource" }); | ||
| 127 | }); | ||
| 128 | |||
| 129 | it("blocks when the refreshed source has no profiles", () => { | ||
| 130 | expect(refreshedSourceStep(source({ profiles: [] }))).toEqual({ | ||
| 131 | step: "blocked", | ||
| 132 | reason: "unknownSourceProfile", | ||
| 133 | }); | ||
| 134 | }); | ||
| 135 | }); | ||
| 136 | |||
| 137 | describe("refreshedSourceProfileDirectory", () => { | ||
| 138 | const refreshed = source({ | ||
| 139 | profiles: [ | ||
| 140 | { directory: "Default", name: "Personal" }, | ||
| 141 | { directory: "Profile 2", name: "Work" }, | ||
| 142 | ], | ||
| 143 | }); | ||
| 144 | |||
| 145 | it("preserves a selected non-first profile after the browser quits", () => { | ||
| 146 | expect(refreshedSourceProfileDirectory("Profile 2", refreshed)).toBe("Profile 2"); | ||
| 147 | }); | ||
| 148 | |||
| 149 | it("falls back only when the old profile vanished and clears an empty result", () => { | ||
| 150 | expect(refreshedSourceProfileDirectory("Removed", refreshed)).toBe("Default"); | ||
| 151 | expect(refreshedSourceProfileDirectory("Removed", source({ profiles: [] }))).toBe(""); | ||
| 152 | }); | ||
| 153 | }); | ||
| 154 | |||
| 155 | describe("isRetryableReason", () => { | ||
| 156 | it("offers a retry for failures a second attempt can clear", () => { | ||
| 157 | expect(isRetryableReason("needsKeychainApproval")).toBe(true); | ||
| 158 | expect(isRetryableReason("readFailed")).toBe(true); | ||
| 159 | }); | ||
| 160 | |||
| 161 | it("does not offer a retry for a permanent failure", () => { | ||
| 162 | expect(isRetryableReason("unsupportedPlatform")).toBe(false); | ||
| 163 | expect(isRetryableReason("unknownSourceProfile")).toBe(false); | ||
| 164 | // Retrying the same new-profile import cannot lower the profile count. | ||
| 165 | expect(isRetryableReason("profileLimitReached")).toBe(false); | ||
| 166 | }); | ||
| 167 | |||
| 168 | it("offers a retry once the user has signed in to create the missing key", () => { | ||
| 169 | // The blocked copy tells the user to sign in and retry, so the screen | ||
| 170 | // has to offer the retry it asks for. | ||
| 171 | expect(isRetryableReason("keychainItemMissing")).toBe(true); | ||
| 172 | }); | ||
| 173 | |||
| 174 | it("offers a retry when the cookies landed but the new profile was not saved", () => { | ||
| 175 | expect(isRetryableReason("profileNotSaved")).toBe(true); | ||
| 176 | }); | ||
| 177 | }); | ||
| 178 | |||
| 179 | describe("formatSkippedDomains", () => { | ||
| 180 | it("joins a short list naturally", () => { | ||
| 181 | expect(formatSkippedDomains([])).toBe(""); | ||
| 182 | expect(formatSkippedDomains(["a.com"])).toBe("a.com"); | ||
| 183 | expect(formatSkippedDomains(["a.com", "b.com"])).toBe("a.com and b.com"); | ||
| 184 | expect(formatSkippedDomains(["a.com", "b.com", "c.com"])).toBe("a.com, b.com and c.com"); | ||
| 185 | }); | ||
| 186 | |||
| 187 | it("summarizes a long list", () => { | ||
| 188 | expect(formatSkippedDomains(["a.com", "b.com", "c.com", "d.com", "e.com"])).toBe( | ||
| 189 | "a.com, b.com, c.com and 2 more", | ||
| 190 | ); | ||
| 191 | }); | ||
| 192 | }); | ||
apps/web/src/components/settings/browserImportWizard.logic.ts created+154| ... | @@ -0,0 +1,154 @@ | ||
| 1 | import type { BrowserImportFailureReason, BrowserImportSource } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export interface WizardTargetProfile { | ||
| 4 | readonly id: string; | ||
| 5 | readonly name: string; | ||
| 6 | } | ||
| 7 | |||
| 8 | export type WizardTarget = | ||
| 9 | | { readonly kind: "new"; readonly profileId: string } | ||
| 10 | | { readonly kind: "existing"; readonly profileId: string; readonly name: string }; | ||
| 11 | |||
| 12 | export type WizardTargetSelection = | ||
| 13 | | { readonly kind: "new" } | ||
| 14 | | { readonly kind: "existing"; readonly profileId: string }; | ||
| 15 | |||
| 16 | export function initialTargetSelection( | ||
| 17 | canCreateProfile: boolean, | ||
| 18 | targetProfiles: ReadonlyArray<WizardTargetProfile>, | ||
| 19 | ): WizardTargetSelection { | ||
| 20 | if (canCreateProfile) return { kind: "new" }; | ||
| 21 | const first = targetProfiles[0]; | ||
| 22 | return first ? { kind: "existing", profileId: first.id } : { kind: "new" }; | ||
| 23 | } | ||
| 24 | |||
| 25 | export function resolveWizardTarget( | ||
| 26 | selection: WizardTargetSelection, | ||
| 27 | newProfileId: string, | ||
| 28 | targetProfiles: ReadonlyArray<WizardTargetProfile>, | ||
| 29 | ): WizardTarget | undefined { | ||
| 30 | if (selection.kind === "new") return { kind: "new", profileId: newProfileId }; | ||
| 31 | const profile = targetProfiles.find((candidate) => candidate.id === selection.profileId); | ||
| 32 | if (profile === undefined) return undefined; | ||
| 33 | return { | ||
| 34 | kind: "existing", | ||
| 35 | profileId: selection.profileId, | ||
| 36 | name: profile.name, | ||
| 37 | }; | ||
| 38 | } | ||
| 39 | |||
| 40 | /** | ||
| 41 | * What the import wizard produces once it has actually tried to import. The | ||
| 42 | * parent runs the import and classifies the result; the wizard only reacts to | ||
| 43 | * it, which keeps the step transitions pure and testable. | ||
| 44 | */ | ||
| 45 | export type ImportOutcome = | ||
| 46 | | { | ||
| 47 | readonly kind: "imported"; | ||
| 48 | readonly imported: number; | ||
| 49 | readonly skipped: number; | ||
| 50 | readonly skippedDomains: ReadonlyArray<string>; | ||
| 51 | readonly targetName: string; | ||
| 52 | } | ||
| 53 | | { readonly kind: "blocked"; readonly reason: BrowserImportFailureReason }; | ||
| 54 | |||
| 55 | /** | ||
| 56 | * The wizard's screens. Every one is a place the user can act from — there are | ||
| 57 | * no dead ends. `blocked` covers the reasons no local step recovers. | ||
| 58 | */ | ||
| 59 | export type WizardStep = | ||
| 60 | | { readonly step: "quit" } | ||
| 61 | | { readonly step: "configure" } | ||
| 62 | | { readonly step: "checking" } | ||
| 63 | | { readonly step: "importing" } | ||
| 64 | | { | ||
| 65 | readonly step: "done"; | ||
| 66 | readonly imported: number; | ||
| 67 | readonly skipped: number; | ||
| 68 | readonly skippedDomains: ReadonlyArray<string>; | ||
| 69 | readonly targetName: string; | ||
| 70 | } | ||
| 71 | | { readonly step: "blocked"; readonly reason: BrowserImportFailureReason }; | ||
| 72 | |||
| 73 | /** The import owns its target partition until the write finishes. */ | ||
| 74 | export function canCloseWizard(step: WizardStep): boolean { | ||
| 75 | return step.step !== "importing"; | ||
| 76 | } | ||
| 77 | |||
| 78 | /** | ||
| 79 | * Where the wizard opens for a source. A running browser is the one thing we | ||
| 80 | * know up front, from the source listing; everything else is discovered by | ||
| 81 | * trying, so the wizard starts by letting the user choose what to import. | ||
| 82 | */ | ||
| 83 | export function initialWizardStep(source: BrowserImportSource): WizardStep { | ||
| 84 | if (source.unavailable === "browserRunning") return { step: "quit" }; | ||
| 85 | if (source.unavailable !== undefined) return { step: "blocked", reason: source.unavailable }; | ||
| 86 | if (source.profiles.length === 0) return { step: "blocked", reason: "unknownSourceProfile" }; | ||
| 87 | return { step: "configure" }; | ||
| 88 | } | ||
| 89 | |||
| 90 | /** Where an attempted import lands the wizard, by how it turned out. */ | ||
| 91 | export function outcomeToStep(outcome: ImportOutcome): WizardStep { | ||
| 92 | if (outcome.kind === "imported") { | ||
| 93 | return { | ||
| 94 | step: "done", | ||
| 95 | imported: outcome.imported, | ||
| 96 | skipped: outcome.skipped, | ||
| 97 | skippedDomains: outcome.skippedDomains, | ||
| 98 | targetName: outcome.targetName, | ||
| 99 | }; | ||
| 100 | } | ||
| 101 | // A browser that reopened mid-import routes back to the quit screen; every | ||
| 102 | // other failure surfaces on the blocked screen, which offers a retry when | ||
| 103 | // one could help. | ||
| 104 | if (outcome.reason === "browserRunning") return { step: "quit" }; | ||
| 105 | return { step: "blocked", reason: outcome.reason }; | ||
| 106 | } | ||
| 107 | |||
| 108 | /** Where a fresh availability check lands the wizard after the user quits. */ | ||
| 109 | export function refreshedSourceStep(source: BrowserImportSource | undefined): WizardStep { | ||
| 110 | if (source === undefined) return { step: "blocked", reason: "unknownSource" }; | ||
| 111 | return initialWizardStep(source); | ||
| 112 | } | ||
| 113 | |||
| 114 | /** Preserve the chosen source profile when a post-quit refresh still lists it. */ | ||
| 115 | export function refreshedSourceProfileDirectory( | ||
| 116 | currentDirectory: string, | ||
| 117 | source: BrowserImportSource, | ||
| 118 | ): string { | ||
| 119 | if (source.profiles.some((profile) => profile.directory === currentDirectory)) { | ||
| 120 | return currentDirectory; | ||
| 121 | } | ||
| 122 | return source.profiles[0]?.directory ?? ""; | ||
| 123 | } | ||
| 124 | |||
| 125 | /** | ||
| 126 | * Whether retrying could clear a failure. The keychain prompt can be approved | ||
| 127 | * on a second try, a missing key appears once the user signs in to the | ||
| 128 | * browser (which is what its copy asks for), and a read or session error may | ||
| 129 | * be transient; an unsupported browser will not change, so it gets no retry | ||
| 130 | * button. | ||
| 131 | */ | ||
| 132 | export function isRetryableReason(reason: BrowserImportFailureReason): boolean { | ||
| 133 | switch (reason) { | ||
| 134 | case "needsKeychainApproval": | ||
| 135 | case "keychainItemMissing": | ||
| 136 | case "readFailed": | ||
| 137 | case "sessionUnavailable": | ||
| 138 | case "profileNotSaved": | ||
| 139 | return true; | ||
| 140 | default: | ||
| 141 | return false; | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | /** | ||
| 146 | * Names the sites whose cookies were skipped: "example.com and google.com", | ||
| 147 | * or "a, b, c and 4 more" past a few, so the line stays short. | ||
| 148 | */ | ||
| 149 | export function formatSkippedDomains(domains: ReadonlyArray<string>): string { | ||
| 150 | if (domains.length === 0) return ""; | ||
| 151 | if (domains.length === 1) return domains[0]!; | ||
| 152 | if (domains.length <= 3) return `${domains.slice(0, -1).join(", ")} and ${domains.at(-1)}`; | ||
| 153 | return `${domains.slice(0, 3).join(", ")} and ${domains.length - 3} more`; | ||
| 154 | } | ||
apps/web/src/components/settings/settingsSearch.test.ts+8| ... | @@ -217,4 +217,12 @@ describe("searchSettings", () => { | ... | @@ -217,4 +217,12 @@ describe("searchSettings", () => { |
| 217 | }); | 217 | }); |
| 218 | expect(searchSettings("external links")[0]).toMatchObject({ id: "browser-link-target" }); | 218 | expect(searchSettings("external links")[0]).toMatchObject({ id: "browser-link-target" }); |
| 219 | }); | 219 | }); |
| 220 | |||
| 221 | it("finds the default browser profile action in the profiles list", () => { | ||
| 222 | expect(searchSettings("default profile")[0]).toMatchObject({ | ||
| 223 | id: "browser-default-profile", | ||
| 224 | to: "/settings/integrations", | ||
| 225 | targetId: "browser-profiles", | ||
| 226 | }); | ||
| 227 | }); | ||
| 220 | }); | 228 | }); |
apps/web/src/components/settings/settingsSearch.ts+1-1| ... | @@ -347,7 +347,7 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -347,7 +347,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 347 | id: "browser-default-profile", | 347 | id: "browser-default-profile", |
| 348 | title: "Default browser profile", | 348 | title: "Default browser profile", |
| 349 | to: "/settings/integrations", | 349 | to: "/settings/integrations", |
| 350 | targetId: "browser", | 350 | targetId: "browser-profiles", |
| 351 | }, | 351 | }, |
| 352 | { | 352 | { |
| 353 | id: "browser-default-viewport", | 353 | id: "browser-default-viewport", |
apps/web/src/hooks/useSettings.test.ts+152-2| ... | @@ -4,9 +4,159 @@ import { | ... | @@ -4,9 +4,159 @@ import { |
| 4 | ProviderInstanceId, | 4 | ProviderInstanceId, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | 6 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; |
| 7 | import { describe, expect, it } from "vite-plus/test"; | 7 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 8 | 8 | ||
| 9 | import { mergeEnvironmentSettings, resolveEnvironmentIdentificationMode } from "./useSettings"; | 9 | import { |
| 10 | __resetClientSettingsPersistenceForTests, | ||
| 11 | __setClientSettingsForTests, | ||
| 12 | getClientSettings, | ||
| 13 | mergeEnvironmentSettings, | ||
| 14 | persistClientSettingsPatch, | ||
| 15 | persistClientSettingsUpdate, | ||
| 16 | resolveEnvironmentIdentificationMode, | ||
| 17 | } from "./useSettings"; | ||
| 18 | |||
| 19 | beforeEach(() => { | ||
| 20 | __resetClientSettingsPersistenceForTests(); | ||
| 21 | }); | ||
| 22 | |||
| 23 | describe("persistClientSettingsUpdate", () => { | ||
| 24 | it("publishes the update only after persistence succeeds", async () => { | ||
| 25 | let finishPersistence!: () => void; | ||
| 26 | const persistence = new Promise<void>((resolve) => { | ||
| 27 | finishPersistence = resolve; | ||
| 28 | }); | ||
| 29 | const setClientSettings = vi.fn(() => persistence); | ||
| 30 | __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); | ||
| 31 | |||
| 32 | const pending = persistClientSettingsUpdate( | ||
| 33 | (current) => ({ | ||
| 34 | ...current, | ||
| 35 | timestampFormat: "12-hour", | ||
| 36 | }), | ||
| 37 | setClientSettings, | ||
| 38 | ); | ||
| 39 | |||
| 40 | expect(getClientSettings().timestampFormat).toBe(DEFAULT_CLIENT_SETTINGS.timestampFormat); | ||
| 41 | finishPersistence(); | ||
| 42 | await expect(pending).resolves.toMatchObject({ timestampFormat: "12-hour" }); | ||
| 43 | expect(getClientSettings().timestampFormat).toBe("12-hour"); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("keeps the current snapshot and propagates persistence failure", async () => { | ||
| 47 | const failure = new Error("disk full"); | ||
| 48 | const setClientSettings = vi.fn().mockRejectedValue(failure); | ||
| 49 | __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); | ||
| 50 | |||
| 51 | await expect( | ||
| 52 | persistClientSettingsUpdate( | ||
| 53 | (current) => ({ ...current, timestampFormat: "12-hour" }), | ||
| 54 | setClientSettings, | ||
| 55 | ), | ||
| 56 | ).rejects.toBe(failure); | ||
| 57 | expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("preserves an optimistic write made while an awaited update persists", async () => { | ||
| 61 | let finishFirstPersistence!: () => void; | ||
| 62 | let durableSettings = DEFAULT_CLIENT_SETTINGS; | ||
| 63 | const firstPersistence = new Promise<void>((resolve) => { | ||
| 64 | finishFirstPersistence = resolve; | ||
| 65 | }); | ||
| 66 | const persist = vi | ||
| 67 | .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>() | ||
| 68 | .mockImplementationOnce((settings) => | ||
| 69 | firstPersistence.then(() => { | ||
| 70 | durableSettings = settings; | ||
| 71 | }), | ||
| 72 | ) | ||
| 73 | .mockImplementation(async (settings) => { | ||
| 74 | durableSettings = settings; | ||
| 75 | }); | ||
| 76 | __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); | ||
| 77 | const importedProfile = { id: "profile-import", name: "Imported", kind: "persistent" as const }; | ||
| 78 | |||
| 79 | const pending = persistClientSettingsUpdate( | ||
| 80 | (current) => ({ | ||
| 81 | ...current, | ||
| 82 | browserProfiles: [...current.browserProfiles, importedProfile], | ||
| 83 | }), | ||
| 84 | persist, | ||
| 85 | ); | ||
| 86 | await Promise.resolve(); | ||
| 87 | persistClientSettingsPatch({ wordWrap: false }, persist); | ||
| 88 | finishFirstPersistence(); | ||
| 89 | await pending; | ||
| 90 | await Promise.resolve(); | ||
| 91 | |||
| 92 | expect(persist).toHaveBeenCalledTimes(3); | ||
| 93 | expect(persist.mock.calls[1]?.[0]).toMatchObject({ | ||
| 94 | wordWrap: false, | ||
| 95 | }); | ||
| 96 | expect(persist.mock.calls[1]?.[0].browserProfiles).toContainEqual(importedProfile); | ||
| 97 | expect(durableSettings.wordWrap).toBe(false); | ||
| 98 | expect(durableSettings.browserProfiles).toContainEqual(importedProfile); | ||
| 99 | expect(getClientSettings().wordWrap).toBe(false); | ||
| 100 | expect(getClientSettings().browserProfiles).toContainEqual(importedProfile); | ||
| 101 | }); | ||
| 102 | |||
| 103 | it("orders an awaited update after an older optimistic write", async () => { | ||
| 104 | let finishOldWrite!: () => void; | ||
| 105 | let durableSettings = DEFAULT_CLIENT_SETTINGS; | ||
| 106 | const oldWrite = new Promise<void>((resolve) => { | ||
| 107 | finishOldWrite = resolve; | ||
| 108 | }); | ||
| 109 | const persist = vi | ||
| 110 | .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>() | ||
| 111 | .mockImplementationOnce((settings) => | ||
| 112 | oldWrite.then(() => { | ||
| 113 | durableSettings = settings; | ||
| 114 | }), | ||
| 115 | ) | ||
| 116 | .mockImplementation(async (settings) => { | ||
| 117 | durableSettings = settings; | ||
| 118 | }); | ||
| 119 | __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); | ||
| 120 | |||
| 121 | persistClientSettingsPatch({ wordWrap: false }, persist); | ||
| 122 | const importedProfile = { id: "profile-import", name: "Imported", kind: "persistent" as const }; | ||
| 123 | const registration = persistClientSettingsUpdate( | ||
| 124 | (current) => ({ | ||
| 125 | ...current, | ||
| 126 | browserProfiles: [...current.browserProfiles, importedProfile], | ||
| 127 | }), | ||
| 128 | persist, | ||
| 129 | ); | ||
| 130 | await Promise.resolve(); | ||
| 131 | expect(persist).toHaveBeenCalledTimes(1); | ||
| 132 | |||
| 133 | finishOldWrite(); | ||
| 134 | await registration; | ||
| 135 | |||
| 136 | expect(persist).toHaveBeenCalledTimes(2); | ||
| 137 | expect(durableSettings.wordWrap).toBe(false); | ||
| 138 | expect(durableSettings.browserProfiles).toContainEqual(importedProfile); | ||
| 139 | }); | ||
| 140 | |||
| 141 | it("continues the queue after a rejected write", async () => { | ||
| 142 | const failure = new Error("disk full"); | ||
| 143 | const persist = vi | ||
| 144 | .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>() | ||
| 145 | .mockRejectedValueOnce(failure) | ||
| 146 | .mockResolvedValue(undefined); | ||
| 147 | __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); | ||
| 148 | |||
| 149 | await expect( | ||
| 150 | persistClientSettingsUpdate( | ||
| 151 | (current) => ({ ...current, timestampFormat: "12-hour" }), | ||
| 152 | persist, | ||
| 153 | ), | ||
| 154 | ).rejects.toBe(failure); | ||
| 155 | await expect( | ||
| 156 | persistClientSettingsUpdate((current) => ({ ...current, wordWrap: false }), persist), | ||
| 157 | ).resolves.toMatchObject({ wordWrap: false }); | ||
| 158 | }); | ||
| 159 | }); | ||
| 10 | 160 | ||
| 11 | describe("resolveEnvironmentIdentificationMode", () => { | 161 | describe("resolveEnvironmentIdentificationMode", () => { |
| 12 | it("keeps identification hidden until client settings hydrate", () => { | 162 | it("keeps identification hidden until client settings hydrate", () => { |
apps/web/src/hooks/useSettings.ts+64-14| ... | @@ -57,6 +57,7 @@ let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; | ... | @@ -57,6 +57,7 @@ let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; |
| 57 | let clientSettingsHydrated = false; | 57 | let clientSettingsHydrated = false; |
| 58 | let clientSettingsHydrationPromise: Promise<void> | null = null; | 58 | let clientSettingsHydrationPromise: Promise<void> | null = null; |
| 59 | let clientSettingsHydrationGeneration = 0; | 59 | let clientSettingsHydrationGeneration = 0; |
| 60 | let clientSettingsPersistenceQueue: Promise<void> = Promise.resolve(); | ||
| 60 | 61 | ||
| 61 | function emitClientSettingsChange() { | 62 | function emitClientSettingsChange() { |
| 62 | for (const listener of clientSettingsListeners) { | 63 | for (const listener of clientSettingsListeners) { |
| ... | @@ -147,16 +148,54 @@ async function hydrateClientSettings(): Promise<void> { | ... | @@ -147,16 +148,54 @@ async function hydrateClientSettings(): Promise<void> { |
| 147 | return clientSettingsHydrationPromise; | 148 | return clientSettingsHydrationPromise; |
| 148 | } | 149 | } |
| 149 | 150 | ||
| 150 | function persistClientSettings(settings: ClientSettings): void { | 151 | const defaultClientSettingsPersistence = (settings: ClientSettings): Promise<void> => |
| 151 | replaceClientSettingsSnapshot(settings); | 152 | ensureLocalApi().persistence.setClientSettings(settings); |
| 152 | void ensureLocalApi() | 153 | |
| 153 | .persistence.setClientSettings(settings) | 154 | function enqueueClientSettingsPersistence<A>(work: () => Promise<A>): Promise<A> { |
| 154 | .catch((error) => { | 155 | const result = clientSettingsPersistenceQueue.then(work); |
| 156 | clientSettingsPersistenceQueue = result.then( | ||
| 157 | () => undefined, | ||
| 158 | () => undefined, | ||
| 159 | ); | ||
| 160 | return result; | ||
| 161 | } | ||
| 162 | |||
| 163 | export function persistClientSettingsPatch( | ||
| 164 | patch: ClientSettingsPatch, | ||
| 165 | persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence, | ||
| 166 | ): void { | ||
| 167 | replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); | ||
| 168 | void enqueueClientSettingsPersistence(() => persist(getClientSettingsSnapshot())).catch( | ||
| 169 | (error) => { | ||
| 155 | console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { | 170 | console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { |
| 156 | operation: "persist", | 171 | operation: "persist", |
| 157 | ...safeErrorLogAttributes(error), | 172 | ...safeErrorLogAttributes(error), |
| 158 | }); | 173 | }); |
| 159 | }); | 174 | }, |
| 175 | ); | ||
| 176 | } | ||
| 177 | |||
| 178 | /** | ||
| 179 | * Persists a client-settings update before publishing it to the in-memory | ||
| 180 | * snapshot. If another settings write lands while persistence is pending, the | ||
| 181 | * updater is reapplied to that newer snapshot and persisted again so neither | ||
| 182 | * change is lost. | ||
| 183 | */ | ||
| 184 | export async function persistClientSettingsUpdate( | ||
| 185 | update: (current: ClientSettings) => ClientSettings, | ||
| 186 | persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence, | ||
| 187 | ): Promise<ClientSettings> { | ||
| 188 | return enqueueClientSettingsPersistence(async () => { | ||
| 189 | for (;;) { | ||
| 190 | const current = getClientSettingsSnapshot(); | ||
| 191 | const next = update(current); | ||
| 192 | await persist(next); | ||
| 193 | if (getClientSettingsSnapshot() === current) { | ||
| 194 | replaceClientSettingsSnapshot(next); | ||
| 195 | return next; | ||
| 196 | } | ||
| 197 | } | ||
| 198 | }); | ||
| 160 | } | 199 | } |
| 161 | 200 | ||
| 162 | // ── Key sets for routing patches ───────────────────────────────────── | 201 | // ── Key sets for routing patches ───────────────────────────────────── |
| ... | @@ -396,10 +435,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | ... | @@ -396,10 +435,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 396 | } | 435 | } |
| 397 | } | 436 | } |
| 398 | if (Object.keys(clientPatch).length > 0) { | 437 | if (Object.keys(clientPatch).length > 0) { |
| 399 | persistClientSettings({ | 438 | persistClientSettingsPatch(clientPatch); |
| 400 | ...getClientSettingsSnapshot(), | ||
| 401 | ...clientPatch, | ||
| 402 | }); | ||
| 403 | } | 439 | } |
| 404 | }, | 440 | }, |
| 405 | [environmentId, persistServerSettings, sharedSettingsSyncTargetIds], | 441 | [environmentId, persistServerSettings, sharedSettingsSyncTargetIds], |
| ... | @@ -472,9 +508,23 @@ export function useUpdatePrimarySettings() { | ... | @@ -472,9 +508,23 @@ export function useUpdatePrimarySettings() { |
| 472 | 508 | ||
| 473 | export function useUpdateClientSettings() { | 509 | export function useUpdateClientSettings() { |
| 474 | return useCallback((patch: ClientSettingsPatch) => { | 510 | return useCallback((patch: ClientSettingsPatch) => { |
| 475 | persistClientSettings({ | 511 | persistClientSettingsPatch(patch); |
| 476 | ...getClientSettingsSnapshot(), | ||
| 477 | ...patch, | ||
| 478 | }); | ||
| 479 | }, []); | 512 | }, []); |
| 480 | } | 513 | } |
| 514 | |||
| 515 | export function __resetClientSettingsPersistenceForTests(): void { | ||
| 516 | clientSettingsHydrationGeneration += 1; | ||
| 517 | clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; | ||
| 518 | clientSettingsHydrated = false; | ||
| 519 | clientSettingsHydrationPromise = null; | ||
| 520 | clientSettingsPersistenceQueue = Promise.resolve(); | ||
| 521 | clientSettingsListeners.clear(); | ||
| 522 | clientSettingsHydrationListeners.clear(); | ||
| 523 | } | ||
| 524 | |||
| 525 | export function __setClientSettingsForTests(settings: ClientSettings): void { | ||
| 526 | clientSettingsHydrationGeneration += 1; | ||
| 527 | clientSettingsSnapshot = settings; | ||
| 528 | clientSettingsHydrated = true; | ||
| 529 | clientSettingsHydrationPromise = null; | ||
| 530 | } |
packages/contracts/src/browserImport.ts created+150| ... | @@ -0,0 +1,150 @@ | ||
| 1 | /** | ||
| 2 | * Browser import - pulling cookies from a browser already installed on the | ||
| 3 | * machine into a T3 Code browser profile. | ||
| 4 | * | ||
| 5 | * Only cookies are imported. They carry the logged-in sessions, which is what | ||
| 6 | * makes an imported profile useful; saved passwords are deliberately out of | ||
| 7 | * scope because Electron exposes no password store to put them in. | ||
| 8 | * | ||
| 9 | * Availability is per source and per platform, and the reasons are modelled | ||
| 10 | * explicitly: some are a permission the user can grant, one is a limitation | ||
| 11 | * no amount of consent works around. The UI needs to tell those apart. | ||
| 12 | * | ||
| 13 | * @module BrowserImport | ||
| 14 | */ | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 17 | import { BrowserProfileId } from "./browserProfile.ts"; | ||
| 18 | |||
| 19 | export const BROWSER_IMPORT_SOURCE_IDS = ["helium"] as const; | ||
| 20 | |||
| 21 | export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS); | ||
| 22 | export type BrowserImportSourceId = typeof BrowserImportSourceId.Type; | ||
| 23 | |||
| 24 | /** | ||
| 25 | * Why a detected source cannot be imported right now. | ||
| 26 | * | ||
| 27 | * `needsKeychainApproval` and `browserRunning` are recoverable — the user | ||
| 28 | * grants access or quits the browser. `unsupportedPlatform` is not: it covers | ||
| 29 | * cases like Chrome on Windows, whose App-Bound Encryption is designed to stop | ||
| 30 | * exactly this, and which we will not work around. | ||
| 31 | */ | ||
| 32 | export const BrowserImportUnavailableReason = Schema.Literals([ | ||
| 33 | "notInstalled", | ||
| 34 | "needsKeychainApproval", | ||
| 35 | "keychainItemMissing", | ||
| 36 | "browserRunning", | ||
| 37 | "unsupportedPlatform", | ||
| 38 | ]); | ||
| 39 | export type BrowserImportUnavailableReason = typeof BrowserImportUnavailableReason.Type; | ||
| 40 | |||
| 41 | /** | ||
| 42 | * Why an import that was actually attempted failed. | ||
| 43 | * | ||
| 44 | * A superset of the unavailable reasons: a source can pass the pre-flight | ||
| 45 | * check and still fail, most often because the user declined the keychain | ||
| 46 | * prompt the read triggers. | ||
| 47 | */ | ||
| 48 | export const BrowserImportFailureReason = Schema.Literals([ | ||
| 49 | ...BrowserImportUnavailableReason.literals, | ||
| 50 | /** No source registered under the requested id. */ | ||
| 51 | "unknownSource", | ||
| 52 | /** The requested profile directory is not one the source reported. */ | ||
| 53 | "unknownSourceProfile", | ||
| 54 | /** The target profile's Electron session could not be opened. */ | ||
| 55 | "sessionUnavailable", | ||
| 56 | /** | ||
| 57 | * The cookies were written, but the new profile could not be saved to | ||
| 58 | * settings, so its partition was cleared again rather than left orphaned. | ||
| 59 | */ | ||
| 60 | "profileNotSaved", | ||
| 61 | /** | ||
| 62 | * The cookies were written, but the profile count reached its cap while the | ||
| 63 | * import ran, so the new profile was not saved and its partition was cleared. | ||
| 64 | */ | ||
| 65 | "profileLimitReached", | ||
| 66 | /** Anything else: a corrupt database, a failed decrypt, a vanished file. */ | ||
| 67 | "readFailed", | ||
| 68 | ]); | ||
| 69 | export type BrowserImportFailureReason = typeof BrowserImportFailureReason.Type; | ||
| 70 | |||
| 71 | /** A profile inside the source browser, e.g. Chromium's "Default" directory. */ | ||
| 72 | export const BrowserImportSourceProfile = Schema.Struct({ | ||
| 73 | /** Directory name under the source's user-data dir. */ | ||
| 74 | directory: TrimmedNonEmptyString, | ||
| 75 | /** The name the source browser shows for it. */ | ||
| 76 | name: TrimmedNonEmptyString, | ||
| 77 | /** | ||
| 78 | * How many cookies the profile holds. Counted without decrypting, so it is | ||
| 79 | * cheap; absent when the store could not be read yet (Safari before Full | ||
| 80 | * Disk Access is granted). | ||
| 81 | */ | ||
| 82 | cookieCount: Schema.optional(Schema.Int), | ||
| 83 | }); | ||
| 84 | export type BrowserImportSourceProfile = typeof BrowserImportSourceProfile.Type; | ||
| 85 | |||
| 86 | export const BrowserImportSource = Schema.Struct({ | ||
| 87 | id: BrowserImportSourceId, | ||
| 88 | name: TrimmedNonEmptyString, | ||
| 89 | profiles: Schema.Array(BrowserImportSourceProfile), | ||
| 90 | /** Absent when the source is importable. */ | ||
| 91 | unavailable: Schema.optional(BrowserImportUnavailableReason), | ||
| 92 | }); | ||
| 93 | export type BrowserImportSource = typeof BrowserImportSource.Type; | ||
| 94 | |||
| 95 | export const BrowserImportInput = Schema.Struct({ | ||
| 96 | sourceId: BrowserImportSourceId, | ||
| 97 | sourceProfileDirectory: TrimmedNonEmptyString, | ||
| 98 | /** T3 Code profile the cookies are written into. */ | ||
| 99 | targetProfileId: BrowserProfileId, | ||
| 100 | }); | ||
| 101 | export type BrowserImportInput = typeof BrowserImportInput.Type; | ||
| 102 | |||
| 103 | /** IPC payload: the import input plus the environment the partition belongs to. */ | ||
| 104 | export const DesktopPreviewImportCookiesInputSchema = Schema.Struct({ | ||
| 105 | environmentId: TrimmedNonEmptyString, | ||
| 106 | sourceId: BrowserImportSourceId, | ||
| 107 | sourceProfileDirectory: TrimmedNonEmptyString, | ||
| 108 | targetProfileId: BrowserProfileId, | ||
| 109 | }); | ||
| 110 | |||
| 111 | export const BrowserImportResult = Schema.Struct({ | ||
| 112 | /** Cookies successfully written into the target partition. */ | ||
| 113 | imported: Schema.Int, | ||
| 114 | /** | ||
| 115 | * Cookies read but not written — expired, rejected as malformed, or held | ||
| 116 | * under a key we could not use. Surfaced rather than hidden so a | ||
| 117 | * mostly-failed import doesn't look like a success. | ||
| 118 | */ | ||
| 119 | skipped: Schema.Int, | ||
| 120 | /** | ||
| 121 | * The distinct hosts those skipped cookies belonged to, so the user can be | ||
| 122 | * told what didn't come over rather than just how many. Capped, since a | ||
| 123 | * broken key can skip thousands across many sites. | ||
| 124 | */ | ||
| 125 | skippedDomains: Schema.Array(Schema.String), | ||
| 126 | }); | ||
| 127 | export type BrowserImportResult = typeof BrowserImportResult.Type; | ||
| 128 | |||
| 129 | export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly< | ||
| 130 | Record<BrowserImportUnavailableReason, string> | ||
| 131 | > = { | ||
| 132 | notInstalled: "Not installed on this machine.", | ||
| 133 | needsKeychainApproval: "Needs Keychain access to read its cookies.", | ||
| 134 | keychainItemMissing: | ||
| 135 | "No encryption key in your Keychain — sign in to that browser once, then retry.", | ||
| 136 | browserRunning: "Quit the browser first so its cookie database can be read.", | ||
| 137 | unsupportedPlatform: "Importing from this browser isn't possible on this platform.", | ||
| 138 | }; | ||
| 139 | |||
| 140 | /** What to tell the user when an attempted import fails. */ | ||
| 141 | export const BROWSER_IMPORT_FAILURE_COPY: Readonly<Record<BrowserImportFailureReason, string>> = { | ||
| 142 | ...BROWSER_IMPORT_UNAVAILABLE_COPY, | ||
| 143 | unknownSource: "That browser is no longer available to import from.", | ||
| 144 | unknownSourceProfile: "That browser profile no longer exists.", | ||
| 145 | sessionUnavailable: "The target profile could not be opened.", | ||
| 146 | profileNotSaved: "The cookies were imported, but the new profile couldn't be saved. Try again.", | ||
| 147 | profileLimitReached: | ||
| 148 | "You've reached the profile limit. Delete a profile or import into an existing one.", | ||
| 149 | readFailed: "The browser's cookie database could not be read.", | ||
| 150 | }; | ||
packages/contracts/src/index.ts+1| ... | @@ -29,6 +29,7 @@ export * from "./project.ts"; | ... | @@ -29,6 +29,7 @@ export * from "./project.ts"; |
| 29 | export * from "./filesystem.ts"; | 29 | export * from "./filesystem.ts"; |
| 30 | export * from "./assets.ts"; | 30 | export * from "./assets.ts"; |
| 31 | export * from "./review.ts"; | 31 | export * from "./review.ts"; |
| 32 | export * from "./browserImport.ts"; | ||
| 32 | export * from "./browserProfile.ts"; | 33 | export * from "./browserProfile.ts"; |
| 33 | export * from "./preview.ts"; | 34 | export * from "./preview.ts"; |
| 34 | export * from "./previewAutomation.ts"; | 35 | export * from "./previewAutomation.ts"; |
packages/contracts/src/ipc.ts+13| ... | @@ -89,6 +89,11 @@ import type { | ... | @@ -89,6 +89,11 @@ import type { |
| 89 | } from "./orchestration.ts"; | 89 | } from "./orchestration.ts"; |
| 90 | import { EnvironmentId } from "./baseSchemas.ts"; | 90 | import { EnvironmentId } from "./baseSchemas.ts"; |
| 91 | import { BrowserProfileId } from "./browserProfile.ts"; | 91 | import { BrowserProfileId } from "./browserProfile.ts"; |
| 92 | import type { | ||
| 93 | BrowserImportResult, | ||
| 94 | BrowserImportSource, | ||
| 95 | BrowserImportSourceId, | ||
| 96 | } from "./browserImport.ts"; | ||
| 92 | import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; | 97 | import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; |
| 93 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; | 98 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; |
| 94 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | 99 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| ... | @@ -1190,6 +1195,14 @@ export interface DesktopPreviewBridge { | ... | @@ -1190,6 +1195,14 @@ export interface DesktopPreviewBridge { |
| 1190 | environmentId: EnvironmentId, | 1195 | environmentId: EnvironmentId, |
| 1191 | profileId?: string, | 1196 | profileId?: string, |
| 1192 | ) => Promise<DesktopPreviewWebviewConfig>; | 1197 | ) => Promise<DesktopPreviewWebviewConfig>; |
| 1198 | /** Browsers on this machine whose cookies can be imported. */ | ||
| 1199 | listBrowserImportSources: () => Promise<ReadonlyArray<BrowserImportSource>>; | ||
| 1200 | importBrowserCookies: (input: { | ||
| 1201 | readonly environmentId: EnvironmentId; | ||
| 1202 | readonly sourceId: BrowserImportSourceId; | ||
| 1203 | readonly sourceProfileDirectory: string; | ||
| 1204 | readonly targetProfileId: string; | ||
| 1205 | }) => Promise<BrowserImportResult>; | ||
| 1193 | setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>; | 1206 | setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>; |
| 1194 | /** | 1207 | /** |
| 1195 | * Activate the in-page element picker for the given tab. Resolves with | 1208 | * Activate the in-page element picker for the given tab. Resolves with |
pnpm-lock.yaml+135| ... | @@ -139,6 +139,9 @@ importers: | ... | @@ -139,6 +139,9 @@ importers: |
| 139 | '@effect/platform-node': | 139 | '@effect/platform-node': |
| 140 | specifier: 4.0.0-beta.103 | 140 | specifier: 4.0.0-beta.103 |
| 141 | version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) | 141 | version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 142 | '@napi-rs/keyring': | ||
| 143 | specifier: ^1.3.0 | ||
| 144 | version: 1.3.0 | ||
| 142 | '@t3tools/client-runtime': | 145 | '@t3tools/client-runtime': |
| 143 | specifier: workspace:* | 146 | specifier: workspace:* |
| 144 | version: link:../../packages/client-runtime | 147 | version: link:../../packages/client-runtime |
| ... | @@ -3177,6 +3180,87 @@ packages: | ... | @@ -3177,6 +3180,87 @@ packages: |
| 3177 | resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} | 3180 | resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} |
| 3178 | engines: {node: '>=18'} | 3181 | engines: {node: '>=18'} |
| 3179 | 3182 | ||
| 3183 | '@napi-rs/keyring-darwin-arm64@1.3.0': | ||
| 3184 | resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} | ||
| 3185 | engines: {node: '>= 10'} | ||
| 3186 | cpu: [arm64] | ||
| 3187 | os: [darwin] | ||
| 3188 | |||
| 3189 | '@napi-rs/keyring-darwin-x64@1.3.0': | ||
| 3190 | resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} | ||
| 3191 | engines: {node: '>= 10'} | ||
| 3192 | cpu: [x64] | ||
| 3193 | os: [darwin] | ||
| 3194 | |||
| 3195 | '@napi-rs/keyring-freebsd-x64@1.3.0': | ||
| 3196 | resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} | ||
| 3197 | engines: {node: '>= 10'} | ||
| 3198 | cpu: [x64] | ||
| 3199 | os: [freebsd] | ||
| 3200 | |||
| 3201 | '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': | ||
| 3202 | resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} | ||
| 3203 | engines: {node: '>= 10'} | ||
| 3204 | cpu: [arm] | ||
| 3205 | os: [linux] | ||
| 3206 | |||
| 3207 | '@napi-rs/keyring-linux-arm64-gnu@1.3.0': | ||
| 3208 | resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} | ||
| 3209 | engines: {node: '>= 10'} | ||
| 3210 | cpu: [arm64] | ||
| 3211 | os: [linux] | ||
| 3212 | libc: [glibc] | ||
| 3213 | |||
| 3214 | '@napi-rs/keyring-linux-arm64-musl@1.3.0': | ||
| 3215 | resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} | ||
| 3216 | engines: {node: '>= 10'} | ||
| 3217 | cpu: [arm64] | ||
| 3218 | os: [linux] | ||
| 3219 | libc: [musl] | ||
| 3220 | |||
| 3221 | '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': | ||
| 3222 | resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} | ||
| 3223 | engines: {node: '>= 10'} | ||
| 3224 | cpu: [riscv64] | ||
| 3225 | os: [linux] | ||
| 3226 | libc: [glibc] | ||
| 3227 | |||
| 3228 | '@napi-rs/keyring-linux-x64-gnu@1.3.0': | ||
| 3229 | resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} | ||
| 3230 | engines: {node: '>= 10'} | ||
| 3231 | cpu: [x64] | ||
| 3232 | os: [linux] | ||
| 3233 | libc: [glibc] | ||
| 3234 | |||
| 3235 | '@napi-rs/keyring-linux-x64-musl@1.3.0': | ||
| 3236 | resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} | ||
| 3237 | engines: {node: '>= 10'} | ||
| 3238 | cpu: [x64] | ||
| 3239 | os: [linux] | ||
| 3240 | libc: [musl] | ||
| 3241 | |||
| 3242 | '@napi-rs/keyring-win32-arm64-msvc@1.3.0': | ||
| 3243 | resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} | ||
| 3244 | engines: {node: '>= 10'} | ||
| 3245 | cpu: [arm64] | ||
| 3246 | os: [win32] | ||
| 3247 | |||
| 3248 | '@napi-rs/keyring-win32-ia32-msvc@1.3.0': | ||
| 3249 | resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} | ||
| 3250 | engines: {node: '>= 10'} | ||
| 3251 | cpu: [ia32] | ||
| 3252 | os: [win32] | ||
| 3253 | |||
| 3254 | '@napi-rs/keyring-win32-x64-msvc@1.3.0': | ||
| 3255 | resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} | ||
| 3256 | engines: {node: '>= 10'} | ||
| 3257 | cpu: [x64] | ||
| 3258 | os: [win32] | ||
| 3259 | |||
| 3260 | '@napi-rs/keyring@1.3.0': | ||
| 3261 | resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} | ||
| 3262 | engines: {node: '>= 10'} | ||
| 3263 | |||
| 3180 | '@napi-rs/wasm-runtime@1.1.6': | 3264 | '@napi-rs/wasm-runtime@1.1.6': |
| 3181 | resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} | 3265 | resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} |
| 3182 | peerDependencies: | 3266 | peerDependencies: |
| ... | @@ -13169,6 +13253,57 @@ snapshots: | ... | @@ -13169,6 +13253,57 @@ snapshots: |
| 13169 | strict-event-emitter: 0.5.1 | 13253 | strict-event-emitter: 0.5.1 |
| 13170 | optional: true | 13254 | optional: true |
| 13171 | 13255 | ||
| 13256 | '@napi-rs/keyring-darwin-arm64@1.3.0': | ||
| 13257 | optional: true | ||
| 13258 | |||
| 13259 | '@napi-rs/keyring-darwin-x64@1.3.0': | ||
| 13260 | optional: true | ||
| 13261 | |||
| 13262 | '@napi-rs/keyring-freebsd-x64@1.3.0': | ||
| 13263 | optional: true | ||
| 13264 | |||
| 13265 | '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': | ||
| 13266 | optional: true | ||
| 13267 | |||
| 13268 | '@napi-rs/keyring-linux-arm64-gnu@1.3.0': | ||
| 13269 | optional: true | ||
| 13270 | |||
| 13271 | '@napi-rs/keyring-linux-arm64-musl@1.3.0': | ||
| 13272 | optional: true | ||
| 13273 | |||
| 13274 | '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': | ||
| 13275 | optional: true | ||
| 13276 | |||
| 13277 | '@napi-rs/keyring-linux-x64-gnu@1.3.0': | ||
| 13278 | optional: true | ||
| 13279 | |||
| 13280 | '@napi-rs/keyring-linux-x64-musl@1.3.0': | ||
| 13281 | optional: true | ||
| 13282 | |||
| 13283 | '@napi-rs/keyring-win32-arm64-msvc@1.3.0': | ||
| 13284 | optional: true | ||
| 13285 | |||
| 13286 | '@napi-rs/keyring-win32-ia32-msvc@1.3.0': | ||
| 13287 | optional: true | ||
| 13288 | |||
| 13289 | '@napi-rs/keyring-win32-x64-msvc@1.3.0': | ||
| 13290 | optional: true | ||
| 13291 | |||
| 13292 | '@napi-rs/keyring@1.3.0': | ||
| 13293 | optionalDependencies: | ||
| 13294 | '@napi-rs/keyring-darwin-arm64': 1.3.0 | ||
| 13295 | '@napi-rs/keyring-darwin-x64': 1.3.0 | ||
| 13296 | '@napi-rs/keyring-freebsd-x64': 1.3.0 | ||
| 13297 | '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 | ||
| 13298 | '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 | ||
| 13299 | '@napi-rs/keyring-linux-arm64-musl': 1.3.0 | ||
| 13300 | '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 | ||
| 13301 | '@napi-rs/keyring-linux-x64-gnu': 1.3.0 | ||
| 13302 | '@napi-rs/keyring-linux-x64-musl': 1.3.0 | ||
| 13303 | '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 | ||
| 13304 | '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 | ||
| 13305 | '@napi-rs/keyring-win32-x64-msvc': 1.3.0 | ||
| 13306 | |||
| 13172 | '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': | 13307 | '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': |
| 13173 | dependencies: | 13308 | dependencies: |
| 13174 | '@emnapi/core': 1.10.0 | 13309 | '@emnapi/core': 1.10.0 |
scripts/build-desktop-artifact.ts+80| ... | @@ -200,6 +200,22 @@ export class MacPasskeySigningConfigurationResolutionError extends Schema.Tagged | ... | @@ -200,6 +200,22 @@ export class MacPasskeySigningConfigurationResolutionError extends Schema.Tagged |
| 200 | } | 200 | } |
| 201 | } | 201 | } |
| 202 | 202 | ||
| 203 | export class KeyringNativePackageMissingError extends Schema.TaggedErrorClass<KeyringNativePackageMissingError>()( | ||
| 204 | "KeyringNativePackageMissingError", | ||
| 205 | { | ||
| 206 | packageName: Schema.String, | ||
| 207 | binaryFileName: Schema.String, | ||
| 208 | packageEntryPath: Schema.String, | ||
| 209 | platform: BuildPlatform, | ||
| 210 | arch: BuildArch, | ||
| 211 | cause: Schema.Defect(), | ||
| 212 | }, | ||
| 213 | ) { | ||
| 214 | override get message(): string { | ||
| 215 | return `Keyring native package is missing: ${this.packageName}`; | ||
| 216 | } | ||
| 217 | } | ||
| 218 | |||
| 203 | export class ClerkPasskeyNativePackageMissingError extends Schema.TaggedErrorClass<ClerkPasskeyNativePackageMissingError>()( | 219 | export class ClerkPasskeyNativePackageMissingError extends Schema.TaggedErrorClass<ClerkPasskeyNativePackageMissingError>()( |
| 204 | "ClerkPasskeyNativePackageMissingError", | 220 | "ClerkPasskeyNativePackageMissingError", |
| 205 | { | 221 | { |
| ... | @@ -1343,6 +1359,69 @@ export function resolveClerkPasskeyNativeArtifacts( | ... | @@ -1343,6 +1359,69 @@ export function resolveClerkPasskeyNativeArtifacts( |
| 1343 | return []; | 1359 | return []; |
| 1344 | } | 1360 | } |
| 1345 | 1361 | ||
| 1362 | export function resolveKeyringNativeArtifacts( | ||
| 1363 | platform: typeof BuildPlatform.Type, | ||
| 1364 | arch: typeof BuildArch.Type, | ||
| 1365 | ): readonly ClerkPasskeyNativeArtifact[] { | ||
| 1366 | const architectures = arch === "universal" ? (["arm64", "x64"] as const) : [arch]; | ||
| 1367 | |||
| 1368 | if (platform === "mac") { | ||
| 1369 | return architectures.map((architecture) => ({ | ||
| 1370 | packageName: `@napi-rs/keyring-darwin-${architecture}`, | ||
| 1371 | binaryFileName: `keyring.darwin-${architecture}.node`, | ||
| 1372 | })); | ||
| 1373 | } | ||
| 1374 | |||
| 1375 | if (platform === "win") { | ||
| 1376 | return architectures.map((architecture) => ({ | ||
| 1377 | packageName: `@napi-rs/keyring-win32-${architecture}-msvc`, | ||
| 1378 | binaryFileName: `keyring.win32-${architecture}-msvc.node`, | ||
| 1379 | })); | ||
| 1380 | } | ||
| 1381 | |||
| 1382 | return architectures.map((architecture) => ({ | ||
| 1383 | packageName: `@napi-rs/keyring-linux-${architecture}-gnu`, | ||
| 1384 | binaryFileName: `keyring.linux-${architecture}-gnu.node`, | ||
| 1385 | })); | ||
| 1386 | } | ||
| 1387 | |||
| 1388 | /** | ||
| 1389 | * Same nesting problem as the Clerk passkey binaries: pnpm keeps the platform | ||
| 1390 | * package under `@napi-rs/keyring`, electron-builder only retains collected | ||
| 1391 | * top-level dependencies, and the generated loader checks for a sibling | ||
| 1392 | * `keyring.<platform>.node` before falling back to the package. Staging the | ||
| 1393 | * binary beside `index.js` lets that first branch win. | ||
| 1394 | */ | ||
| 1395 | const stageKeyringNativeBinaries = Effect.fn("stageKeyringNativeBinaries")(function* ( | ||
| 1396 | stageAppDir: string, | ||
| 1397 | platform: typeof BuildPlatform.Type, | ||
| 1398 | arch: typeof BuildArch.Type, | ||
| 1399 | ) { | ||
| 1400 | const fs = yield* FileSystem.FileSystem; | ||
| 1401 | const path = yield* Path.Path; | ||
| 1402 | const packageEntryPath = yield* fs.realPath( | ||
| 1403 | path.join(stageAppDir, "node_modules", "@napi-rs", "keyring", "index.js"), | ||
| 1404 | ); | ||
| 1405 | const packageDir = path.dirname(packageEntryPath); | ||
| 1406 | const packageRequire = NodeModule.createRequire(packageEntryPath); | ||
| 1407 | |||
| 1408 | for (const artifact of resolveKeyringNativeArtifacts(platform, arch)) { | ||
| 1409 | const sourcePath = yield* Effect.try({ | ||
| 1410 | try: () => packageRequire.resolve(`${artifact.packageName}/${artifact.binaryFileName}`), | ||
| 1411 | catch: (cause) => | ||
| 1412 | new KeyringNativePackageMissingError({ | ||
| 1413 | packageName: artifact.packageName, | ||
| 1414 | binaryFileName: artifact.binaryFileName, | ||
| 1415 | packageEntryPath, | ||
| 1416 | platform, | ||
| 1417 | arch, | ||
| 1418 | cause, | ||
| 1419 | }), | ||
| 1420 | }); | ||
| 1421 | yield* fs.copyFile(sourcePath, path.join(packageDir, artifact.binaryFileName)); | ||
| 1422 | } | ||
| 1423 | }); | ||
| 1424 | |||
| 1346 | // pnpm nests the architecture package under @clerk/electron-passkeys, while electron-builder only | 1425 | // pnpm nests the architecture package under @clerk/electron-passkeys, while electron-builder only |
| 1347 | // retains collected top-level dependencies. The SDK loader checks beside index.js first, so stage | 1426 | // retains collected top-level dependencies. The SDK loader checks beside index.js first, so stage |
| 1348 | // the binary there and let electron-builder's native-addon handling unpack it from the ASAR. | 1427 | // the binary there and let electron-builder's native-addon handling unpack it from the ASAR. |
| ... | @@ -3554,6 +3633,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -3554,6 +3633,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3554 | { label: "vp install --prod", verbose: options.verbose }, | 3633 | { label: "vp install --prod", verbose: options.verbose }, |
| 3555 | ); | 3634 | ); |
| 3556 | yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); | 3635 | yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); |
| 3636 | yield* stageKeyringNativeBinaries(stageAppDir, options.platform, options.arch); | ||
| 3557 | 3637 | ||
| 3558 | // WSL is Windows-only, so only the Windows artifact carries the server | 3638 | // WSL is Windows-only, so only the Windows artifact carries the server |
| 3559 | // sidecar (which embeds the Linux node-pty prebuild); other platforms | 3639 | // sidecar (which embeds the Linux node-pty prebuild); other platforms |