diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 98cbc681b75d..988e223d219f 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -12,22 +12,28 @@ github:0x4bs3nt github:Adamulek123 github:adityavardhansharma +github:aoright github:arhxam github:bil0000 github:binbandit github:Brechard +github:btsouth github:chrisdeeming github:chuks-qua github:cursoragent github:D3OXY +github:dbalders github:eggfriedrice24 github:extoci +github:flamboh +github:FllipEis github:gbarros-dev github:gfsaaser24 github:github-actions[bot] github:gsimone github:GuilhermeVieiraDev github:hwanseoc +github:inayayousfi github:ipanasenko github:jakeleventhal github:jamesx0416 @@ -36,13 +42,18 @@ github:jasonLaster github:JoeEverest github:justsomelegs github:kridaydave +github:lgwacker github:lnieuwenhuis github:Lucenx9 github:mackinleysmith github:maria-rcks +github:MatthewFeroz github:maxwellyoung github:mwolson +github:myacoub91 +github:naMqe-h github:nateEc +github:naveed949 github:nmggithub github:Noojuno github:notkainoa @@ -64,6 +75,7 @@ github:tarik02 github:tris203 github:tsouth89 github:UtkarshUsername +github:vitalyiegorov github:Yash-Singh1 github:yashranaway github:Ymit24 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 570abd7d0505..7fee5f57a83c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: - name: Typecheck run: vpr typecheck + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Build desktop pipeline run: vp run build:desktop @@ -85,6 +88,9 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test diff --git a/.github/workflows/cursor-hygiene-webhook.yml b/.github/workflows/cursor-hygiene-webhook.yml new file mode 100644 index 000000000000..ea0f579b4ac6 --- /dev/null +++ b/.github/workflows/cursor-hygiene-webhook.yml @@ -0,0 +1,36 @@ +name: Forward to Cursor hygiene + +on: + push: + branches: [main] + pull_request: + types: [opened, reopened, ready_for_review] + issues: + types: [opened, closed, reopened] + discussion: + types: [created, closed, reopened] + +permissions: + contents: read + +jobs: + forward: + name: POST to Cursor + runs-on: ubuntu-24.04 + steps: + - name: POST to Cursor + env: + URL: ${{ secrets.CURSOR_T3CODE_WEBHOOK_URL }} + AUTH: ${{ secrets.CURSOR_T3CODE_WEBHOOK_AUTH }} + run: | + set -euo pipefail + if [ -z "${URL:-}" ] || [ -z "${AUTH:-}" ]; then + echo "Missing CURSOR_T3CODE_WEBHOOK_URL or CURSOR_T3CODE_WEBHOOK_AUTH — skipping." + exit 0 + fi + curl -fsS --max-time 60 -X POST "$URL" \ + -H "Authorization: $AUTH" \ + -H "Content-Type: application/json" \ + -H "X-GitHub-Event: ${{ github.event_name }}" \ + -H "X-GitHub-Delivery: ${{ github.run_id }}-${{ github.run_attempt }}" \ + --data-binary @"${{ github.event_path }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a5c691a3c7e..404e4e8075cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,6 +111,8 @@ jobs: node-version-file: package.json cache: true run-install: true + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - id: release_meta name: Resolve release version @@ -174,6 +176,14 @@ jobs: --current-tag "${{ steps.release_meta.outputs.tag }}" \ --github-output + # Share only the verification results, not the large registry metadata cache. + - name: Upload dependency verification + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: release-dependency-verification + path: ${{ runner.temp }}/pnpm-metadata/lockfile-verified.jsonl + quality: name: Release quality checks needs: [preflight] @@ -206,6 +216,9 @@ jobs: - name: Typecheck run: vp run typecheck + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Test run: vp run test @@ -446,7 +459,18 @@ jobs: path: ${{ steps.package_cache_path.outputs.path }} key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + # pnpm checks the lockfile and policy before reusing this result. A missing + # artifact leaves the cache empty, so installation runs the checks again. + - name: Download dependency verification + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: release-dependency-verification + path: ${{ runner.temp }}/pnpm-metadata + - name: Install desktop dependencies + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - name: Cache resource monitor @@ -503,12 +527,13 @@ jobs: exit $code } - - name: Install ImageMagick + - name: Install Linux desktop build libraries if: matrix.platform == 'linux' shell: bash run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev pkg-config if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then - sudo apt-get update sudo apt-get install -y imagemagick fi @@ -583,6 +608,7 @@ jobs: - name: Build desktop artifact shell: bash env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml new file mode 100644 index 000000000000..3a70ad5a26a0 --- /dev/null +++ b/.github/workflows/windows-tests.yml @@ -0,0 +1,81 @@ +# On-demand Windows test lane. Manual only: nothing in the suite passes on +# Windows yet, so this exists to give contributors (and agents) a cloud Windows +# box to iterate against. Once the suite is green here, fold it into ci.yml. +# +# gh workflow run windows-tests.yml --ref -f package=packages/shared +# gh workflow run windows-tests.yml --ref -f package=apps/server \ +# -f files="src/process/externalLauncher.test.ts src/cli/theme.test.ts" +# gh run watch && gh run view --log-failed +name: Windows Tests + +on: + workflow_dispatch: + inputs: + package: + description: "Workspace directory to test, e.g. apps/server or packages/shared. Empty runs every package except apps/server." + type: string + default: "" + files: + description: "Space-separated test files relative to the package directory. Empty runs the package's whole suite. Requires package." + type: string + default: "" + +permissions: + contents: read + +jobs: + test: + name: Test (${{ inputs.package || 'all non-server' }}) + runs-on: blacksmith-8vcpu-windows-2025 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + # setup-vp's own cache restores a Linux-shaped store on Windows, which is + # slower than no cache (see #7975). Cache pnpm's Windows store directly. + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: false + run-install: false + + - name: Resolve package cache path + id: package_cache_path + shell: pwsh + run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' + + - name: Cache packages + uses: actions/cache@v6 + with: + path: ${{ steps.package_cache_path.outputs.path }} + key: windows-tests-packages-v1-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install + run: vp install + + - name: Ensure Electron runtime is installed + if: inputs.package == '' || inputs.package == 'apps/desktop' + run: vp run --filter "@t3tools/desktop" ensure:electron + + # `vp run ... test -- ` does not forward positional args to vitest, + # so file-scoped runs call `vp test run` inside the package instead. + - name: Test + shell: pwsh + run: | + $package = '${{ inputs.package }}' + $files = '${{ inputs.files }}' + if ($package -eq '') { + vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test + } elseif ($files -eq '') { + vp run --filter "./$package" test + } else { + Set-Location $package + vp test run $files.Split(' ') + } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 83a07cccb660..cb587e152aaa 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,6 +15,7 @@ "@clerk/electron": "catalog:", "@clerk/electron-passkeys": "catalog:", "@effect/platform-node": "catalog:", + "@napi-rs/keyring": "^1.3.0", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/desktop/scripts/browser-secret-native.test.mjs b/apps/desktop/scripts/browser-secret-native.test.mjs new file mode 100644 index 000000000000..754a91f1342c --- /dev/null +++ b/apps/desktop/scripts/browser-secret-native.test.mjs @@ -0,0 +1,103 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- The native compiler targets the actual host; this script has no Effect runtime. +const hostArch = process.arch; +// oxlint-disable-next-line t3code/no-global-process-runtime -- Native compilation only runs on the actual Linux host. +const hostPlatform = process.platform; + +describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => { + let directory; + let executable; + beforeAll(() => { + directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-browser-secret-test-")); + executable = NodePath.join(directory, "t3-browser-secret"); + const root = NodeURL.fileURLToPath(new URL("../../../native/browser-secret/", import.meta.url)); + const flags = NodeChildProcess.execFileSync( + "pkg-config", + ["--cflags", "--libs", "libsecret-1"], + { + encoding: "utf8", + }, + ) + .trim() + .split(/\s+/); + NodeChildProcess.execFileSync( + process.env.CC || "cc", + [ + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + NodePath.join(root, "main.c"), + NodePath.join(root, "test.c"), + "-Wl,--wrap=secret_service_search_sync", + "-Wl,--wrap=secret_item_get_locked", + "-Wl,--wrap=secret_item_get_secret", + "-o", + executable, + ...flags, + ], + { stdio: "pipe" }, + ); + }); + afterAll(() => { + if (directory) NodeFS.rmSync(directory, { recursive: true, force: true }); + }); + + const run = (args) => + NodeChildProcess.spawnSync(executable, args, { + env: { ...process.env, DBUS_SESSION_BUS_ADDRESS: "unix:path=/unused-test-bus" }, + }); + + it("builds an executable for the requested architecture into a staged resource directory", () => { + const output = NodePath.join(directory, "resources", "browser-secret", "t3-browser-secret"); + NodeChildProcess.execFileSync(process.execPath, [ + NodeURL.fileURLToPath(new URL("./build-browser-secret.mjs", import.meta.url)), + "--arch", + hostArch, + "--output", + output, + ]); + const header = NodeFS.readFileSync(output).subarray(0, 20); + expect(header.toString("hex", 0, 6)).toBe("7f454c460201"); + expect(header.readUInt16LE(18)).toBe({ x64: 62, arm64: 183 }[hostArch]); + expect(NodeFS.statSync(output).mode & 0o111).not.toBe(0); + // Invalid arguments exit before the real executable could contact a keyring. + expect(NodeChildProcess.spawnSync(output, []).status).toBe(64); + }); + + it("preserves the exact secret bytes with no added or removed delimiter", () => { + const result = run(["success"]); + expect(result.status).toBe(0); + expect(result.stdout).toEqual(Buffer.from("secret\0with whitespace \t\r\n")); + expect(result.stderr.length).toBe(0); + }); + + for (const [scenario, code] of [ + ["missing", 2], + ["empty", 2], + ["locked", 3], + ["cancelled", 3], + ["denied", 3], + ["unavailable", 4], + ["unloaded", 4], + ]) { + it(`reports ${scenario} without emitting a secret`, () => { + const result = run([scenario]); + expect(result.status).toBe(code); + expect(result.stdout.length).toBe(0); + }); + } + it("rejects invalid arguments before accessing the keyring", () => { + for (const args of [[], [""], ["chrome", "extra"]]) { + const result = run(args); + expect(result.status).toBe(64); + expect(result.stdout.length).toBe(0); + } + }); +}); diff --git a/apps/desktop/scripts/build-browser-secret.mjs b/apps/desktop/scripts/build-browser-secret.mjs new file mode 100644 index 000000000000..c65d16a87e8b --- /dev/null +++ b/apps/desktop/scripts/build-browser-secret.mjs @@ -0,0 +1,66 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeUtil from "node:util"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- The native compiler targets the actual host; this script has no Effect runtime. +const hostArch = process.arch; +// oxlint-disable-next-line t3code/no-global-process-runtime -- Native compilation only runs on the actual Linux host. +const hostPlatform = process.platform; + +const { values } = NodeUtil.parseArgs({ + options: { output: { type: "string" }, arch: { type: "string", default: hostArch } }, +}); + +if (hostPlatform === "linux") { + const machine = { x64: 62, arm64: 183 }[values.arch]; + if (machine === undefined) throw new Error(`Unsupported Linux architecture: ${values.arch}`); + const root = NodeURL.fileURLToPath(new URL("../../../native/browser-secret/", import.meta.url)); + const source = NodePath.resolve(root, "main.c"); + const output = values.output ?? NodePath.resolve(root, "build", values.arch, "t3-browser-secret"); + const matchesArchitecture = (file) => { + const header = NodeFS.readFileSync(file).subarray(0, 20); + return header.toString("hex", 0, 6) === "7f454c460201" && header.readUInt16LE(18) === machine; + }; + let current = false; + try { + current = + NodeFS.statSync(output).mtimeMs >= + Math.max( + NodeFS.statSync(source).mtimeMs, + NodeFS.statSync(NodeURL.fileURLToPath(import.meta.url)).mtimeMs, + ) && matchesArchitecture(output); + } catch { + /* The first build has no output yet. */ + } + if (!current) { + let flags; + try { + flags = NodeChildProcess.execFileSync("pkg-config", ["--cflags", "--libs", "libsecret-1"], { + encoding: "utf8", + }) + .trim() + .split(/\s+/); + } catch (cause) { + throw new Error( + "Building the Linux browser import helper requires pkg-config and libsecret development headers (Ubuntu/Debian: libsecret-1-dev).", + { cause }, + ); + } + NodeFS.mkdirSync(NodePath.dirname(output), { recursive: true }); + const temporary = `${output}.${process.pid}.tmp`; + try { + NodeChildProcess.execFileSync( + process.env.CC || "cc", + ["-std=c11", "-O2", "-Wall", "-Wextra", "-Werror", source, "-o", temporary, ...flags], + { stdio: "inherit" }, + ); + if (!matchesArchitecture(temporary)) + throw new Error(`C compiler did not produce a Linux ${values.arch} executable.`); + NodeFS.renameSync(temporary, output); + } finally { + NodeFS.rmSync(temporary, { force: true }); + } + } +} diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index c28d5ec358b6..b5bcc4d06e36 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -37,6 +37,12 @@ const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.tr // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. const hostPlatform = NodeOS.platform(); +NodeChildProcess.execFileSync( + process.execPath, + [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")], + { stdio: "inherit" }, +); + await waitForResources({ baseDir: desktopDir, files: requiredFiles, diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index c37838ab1836..b8b8254c9b3c 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -2,6 +2,7 @@ import * as NodeFS from "node:fs"; import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import * as NodeChildProcess from "node:child_process"; const require = NodeModule.createRequire(import.meta.url); @@ -176,7 +177,8 @@ export function ensureElectronRuntime() { return electronPath; } -if (import.meta.url === `file://${process.argv[1]}`) { +// `file://${argv[1]}` never matches on Windows (drive letters need `file:///C:/`). +if (process.argv[1] && NodeURL.pathToFileURL(process.argv[1]).href === import.meta.url) { const electronPath = ensureElectronRuntime(); process.stdout.write(`${electronPath}\n`); } diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index ecabd81fb407..5dde034121b8 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,7 +1,14 @@ import * as NodeChildProcess from "node:child_process"; +import * as NodePath from "node:path"; import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; +NodeChildProcess.execFileSync( + process.execPath, + [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")], + { stdio: "inherit" }, +); + const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 124ee5095a61..2cdffbefb7ad 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -103,4 +103,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" for (const previewMethod of PreviewIpc.methods) { yield* ipc.handle(previewMethod); } + yield* ipc.handle(PreviewIpc.listBrowserImportSources); + yield* ipc.handle(PreviewIpc.importBrowserCookies); }); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0e966431b06d..81b50d165d24 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -64,6 +64,8 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; +export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources"; +export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies"; export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme"; export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index 68ff5dbfef9b..18b0b8040e3d 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -12,6 +12,7 @@ import * as Schema from "effect/Schema"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as PreviewManager from "../../preview/Manager.ts"; +import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; import * as PreviewIpc from "./preview.ts"; const { fromPartition } = vi.hoisted(() => ({ @@ -80,6 +81,38 @@ describe("preview IPC methods", () => { }); }); + effectIt.effect("targets imports at the same partition tuple as the renderer", () => { + const received: Array[0]> = + []; + const browserImport = BrowserImport.BrowserImport.of({ + listSources: Effect.succeed([]), + importCookies: (input) => + Effect.sync(() => { + received.push(input); + return { imported: 0, skipped: 0, skippedDomains: [] }; + }), + }); + const request = (environmentId: string, targetProfileId: string) => + PreviewIpc.importBrowserCookies.handler({ + environmentId, + sourceId: "helium", + sourceProfileDirectory: "Default", + targetProfileId, + }); + + return Effect.gen(function* () { + yield* request("a", "b"); + yield* request("a::b", DEFAULT_BROWSER_PROFILE_ID); + + expect(received[0]).toMatchObject(PreviewIpc.resolvePartitionScope("a", "b")); + expect(received[1]).toMatchObject( + PreviewIpc.resolvePartitionScope("a::b", DEFAULT_BROWSER_PROFILE_ID), + ); + expect(received[0]?.namespace).toBe("profile"); + expect(received[1]?.namespace).toBeUndefined(); + }).pipe(Effect.provideService(BrowserImport.BrowserImport, browserImport)); + }); + effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => Effect.map( PreviewIpc.registerWebview diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 8a77770deb1e..5fb7eff99fc6 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -16,7 +16,10 @@ import { DesktopPreviewScreenshotArtifactSchema, DesktopPreviewSetAudioMutedInputSchema, DesktopPreviewSetColorSchemeInputSchema, + BrowserImportResult, + BrowserImportSource, DesktopPreviewClearDataInputSchema, + DesktopPreviewImportCookiesInputSchema, DesktopPreviewCreateTabInputSchema, DesktopPreviewTabInputSchema, DesktopPreviewWebviewConfigSchema, @@ -30,6 +33,7 @@ import * as Schema from "effect/Schema"; import * as NodeURL from "node:url"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; import * as PreviewManager from "../../preview/Manager.ts"; import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; @@ -284,6 +288,45 @@ export const getPreviewConfig = DesktopIpc.makeIpcMethod({ }), }); +/** + * Registered separately from `methods`: these carry `BrowserImport` in their + * context and their own failure type, so they do not unify with the + * manager-backed handlers the shared loop iterates. + */ +export const listBrowserImportSources = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL, + payload: Schema.Void, + result: Schema.Array(BrowserImportSource), + handler: Effect.fn("desktop.ipc.preview.listBrowserImportSources")(function* () { + const browserImport = yield* BrowserImport.BrowserImport; + return yield* browserImport.listSources; + }), +}); + +export const importBrowserCookies = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL, + payload: DesktopPreviewImportCookiesInputSchema, + result: BrowserImportResult, + handler: Effect.fn("desktop.ipc.preview.importBrowserCookies")(function* ({ + environmentId, + ...importInput + }) { + const browserImport = yield* BrowserImport.BrowserImport; + // Derived in main from the same helper the webview config uses, so cookies + // land in exactly the partition the profile's tabs attach to. + const { scope, persistent, namespace } = resolvePartitionScope( + environmentId, + importInput.targetProfileId, + ); + return yield* browserImport.importCookies({ + input: importInput, + scope, + persistent, + ...(namespace === undefined ? {} : { namespace }), + }); + }), +}); + export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, payload: DesktopPreviewAnnotationThemeInputSchema, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c826c56e1a70..3337228aa962 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -58,6 +58,8 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; +import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; +import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; import * as BrowserSession from "./preview/BrowserSession.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; @@ -149,6 +151,9 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( ); const desktopPreviewLayer = PreviewManager.layer.pipe( + // Merged rather than provided so the IPC handlers can reach the import + // service alongside the manager; both sit on the same BrowserSession. + Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))), Layer.provideMerge(BrowserSession.layer), Layer.provideMerge(desktopFoundationLayer), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 452f4b851bc3..685a9b1204db 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -223,6 +223,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), + listBrowserImportSources: () => ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL), + importBrowserCookies: (input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL, input), clearCookies: (environmentId, profileId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }), clearCache: (environmentId, profileId) => diff --git a/apps/desktop/src/preview/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts index 5b6b73c8ba78..aba581ab5338 100644 --- a/apps/desktop/src/preview/AnnotationStyles.generated.ts +++ b/apps/desktop/src/preview/AnnotationStyles.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. export const previewAnnotationStyles = - '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; + '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts new file mode 100644 index 000000000000..9b0a652f09f1 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -0,0 +1,209 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import * as BrowserSession from "../BrowserSession.ts"; +import * as BrowserImport from "./BrowserImport.ts"; +import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts"; + +const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; + +const cookie = { + url: "https://rejected.example/path", + name: "session", + value: "value", + domain: undefined, + path: "/", + secure: true, + httpOnly: true, + expirationDate: undefined, + sameSite: "lax" as const, +}; + +/** + * Dies if the import reaches session work: every case here covers a request + * that must be rejected before a cookie is read or written. + */ +const rejectedBeforeSession = Layer.succeed( + BrowserSession.BrowserSession, + BrowserSession.BrowserSession.of({ + getPartition: () => Effect.die("getPartition must not be reached"), + isPartition: () => false, + getSession: () => Effect.die("getSession must not be reached"), + clearCookies: () => Effect.die("clearCookies must not be reached"), + clearCache: () => Effect.die("clearCache must not be reached"), + }), +); + +/** + * Builds the service against a scratch home containing an installed, closed + * copy of the source browser. + */ +const withImporter = Effect.fnUntraced(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" }); + const environment = Layer.succeed(HostProcessEnvironment, { HOME: home }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = helium.userDataDirectory(context); + if (root === undefined) throw new Error("Helium has no macOS user-data directory"); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + // The cookie database is what marks a source as installed, so a fixture + // without one is reported as absent before any other check runs. + yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); + + const importer = yield* BrowserImport.BrowserImport.pipe( + Effect.provide( + BrowserImport.layer.pipe( + Layer.provide(rejectedBeforeSession), + Layer.provide(environment), + Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), + Layer.provide(Layer.succeed(HostProcessExecutablePath, "/Applications/T3 Code.app")), + Layer.provide(NodeServices.layer), + ), + ), + ); + return { importer, home, root }; +}); + +describe("BrowserImport.importCookies", () => { + it.effect("rejects a source profile the browser never reported", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { importer, home } = yield* withImporter(); + + // A cookie database reachable on disk but outside the browser's + // user-data directory — the payoff a traversal would be after. + yield* fileSystem.makeDirectory(`${home}/secrets`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/secrets/Cookies`, "not-a-db"); + + const error = yield* importer + .importCookies({ + input: { + sourceId: "helium", + sourceProfileDirectory: "../../../../secrets", + targetProfileId: "default", + }, + scope: "persist:t3code-preview-test", + persistent: true, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, BrowserImport.BrowserImportFailedError); + assert.equal(error.reason, "unknownSourceProfile"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("refuses to import while the source browser holds its profile", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { importer, root } = yield* withImporter(); + // The lock Chromium leaves while it is running, dangling target and + // all. This must stop the import before it ever asks the keychain. + yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`); + + const error = yield* importer + .importCookies({ + input: { + sourceId: "helium", + sourceProfileDirectory: "Default", + targetProfileId: "default", + }, + scope: "persist:t3code-preview-test", + persistent: true, + }) + .pipe(Effect.flip); + + assert.equal(error.reason, "browserRunning"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); + +describe("BrowserImport.writeCookies", () => { + it.effect("counts a rejected cookie and its domain as skipped", () => + Effect.gen(function* () { + let flushes = 0; + const result = yield* BrowserImport.writeCookies( + { + cookies: { + set: () => Promise.reject(new Error("fixture rejection")), + flushStore: () => { + flushes += 1; + return Promise.resolve(); + }, + }, + }, + { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] }, + ); + + assert.deepEqual(result, { + imported: 0, + skipped: 1, + skippedDomains: ["rejected.example"], + }); + // Nothing landed, so there is nothing to persist. + assert.equal(flushes, 0); + }), + ); + + it.effect("flushes the store after writing, and reports success if the flush fails", () => + Effect.gen(function* () { + const events: Array = []; + const result = yield* BrowserImport.writeCookies( + { + cookies: { + set: () => { + events.push("set"); + return Promise.resolve(); + }, + flushStore: () => { + events.push("flush"); + return Promise.reject(new Error("fixture flush failure")); + }, + }, + }, + { cookies: [cookie, cookie], undecryptable: 0, undecryptableHosts: [] }, + ); + + // One flush after every write, not one per cookie; the cookies are in + // the session either way, so a failed flush is not a failed import. + assert.deepEqual(events, ["set", "set", "flush"]); + assert.deepEqual(result, { imported: 2, skipped: 0, skippedDomains: [] }); + }), + ); + + it.effect("propagates interruption while writing a cookie", () => + Effect.gen(function* () { + const write = BrowserImport.writeCookies( + { + cookies: { + set: () => new Promise(() => {}), + flushStore: () => Promise.resolve(), + }, + }, + { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] }, + ); + + const interrupted = yield* Ref.make(false); + const fiber = yield* write.pipe( + Effect.onInterrupt(() => Ref.set(interrupted, true)), + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* Fiber.interrupt(fiber); + + assert.isTrue(yield* Ref.get(interrupted)); + }), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts new file mode 100644 index 000000000000..e92f2f05e05c --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -0,0 +1,316 @@ +/** + * Browser import service - lists importable sources and writes their cookies + * into a T3 Code browser profile's Electron partition. + * + * @module BrowserImport + */ +import type { + BrowserImportInput, + BrowserImportResult, + BrowserImportSource, + BrowserImportUnavailableReason, +} from "@t3tools/contracts"; +import { BrowserImportFailureReason } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type { Session } from "electron"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as BrowserSession from "../BrowserSession.ts"; +import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts"; +import type { CookieReadResult } from "./CookieDatabase.ts"; +import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts"; +import { + BROWSER_IMPORT_SOURCES, + resolveCookieDatabase, + isSourceInstalled, + isSourceRunning, + listSourceProfiles, + sourcePathContext, + type BrowserImportPathContext, + type BrowserImportSourceDefinition, +} from "./Sources.ts"; + +export class BrowserImportFailedError extends Schema.TaggedErrorClass()( + "BrowserImportFailedError", + { + sourceId: Schema.String, + reason: BrowserImportFailureReason, + /** Kept for the log; the user only ever sees the reason's copy. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + // The reason token is part of the message on purpose: IPC flattens the error + // to its message, and the renderer maps that token back to user-facing copy. + override get message(): string { + return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`; + } +} + +export class BrowserCookieWriteError extends Schema.TaggedErrorClass()( + "BrowserCookieWriteError", + { + url: Schema.String, + name: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not write imported cookie ${this.name} for ${this.url}.`; + } +} + +export class BrowserImport extends Context.Service< + BrowserImport, + { + readonly listSources: Effect.Effect>; + readonly importCookies: (input: { + readonly input: BrowserImportInput; + /** Partition scope of the target profile, derived by the caller in main. */ + readonly scope: string; + readonly persistent: boolean; + readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; + }) => Effect.Effect; + } +>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {} + +const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, +): Effect.fn.Return< + BrowserImportUnavailableReason | undefined, + never, + FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner +> { + if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; + if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; + if (yield* isSourceRunning(definition, context)) return "browserRunning"; + return undefined; +}); + +/** The host a constructed cookie URL points at, for naming what was skipped. */ +const cookieHost = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return url; + } +}; + +export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( + session: { readonly cookies: Pick }, + read: CookieReadResult, +) { + let imported = 0; + let skipped = read.undecryptable; + const skippedDomains = new Set(read.undecryptableHosts); + for (const cookie of read.cookies) { + const written = yield* Effect.tryPromise({ + try: () => + session.cookies.set({ + url: cookie.url, + name: cookie.name, + value: cookie.value, + // Omitted for host-only cookies: Electron reads any `domain` as a + // domain cookie and re-adds the leading dot, widening its scope. + ...(cookie.domain === undefined ? {} : { domain: cookie.domain }), + path: cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + ...(cookie.expirationDate === undefined ? {} : { expirationDate: cookie.expirationDate }), + }), + catch: (cause) => new BrowserCookieWriteError({ url: cookie.url, name: cookie.name, cause }), + }).pipe( + Effect.as(true), + Effect.tapError((error) => Effect.logDebug(error.message, { cause: error.cause })), + Effect.catchTags({ BrowserCookieWriteError: () => Effect.succeed(false) }), + ); + if (written) { + imported += 1; + } else { + skipped += 1; + skippedDomains.add(cookieHost(cookie.url)); + } + } + // `set` resolves once the cookie is in memory; Chromium writes the store to + // disk on its own schedule. Flush before reporting "Done", so a crash right + // after does not lose what the user was just told was imported. A failed + // flush is logged rather than surfaced: the cookies are still in the + // session and land on disk at the next scheduled write. + if (imported > 0) { + yield* Effect.tryPromise(() => session.cookies.flushStore()).pipe( + Effect.tapError((error) => + Effect.logWarning("Imported cookies could not be flushed to disk", { cause: error.cause }), + ), + Effect.ignore, + ); + } + return { imported, skipped, skippedDomains: [...skippedDomains].slice(0, 20) }; +}); + +export const make = Effect.gen(function* BrowserImportMake() { + const browserSession = yield* BrowserSession.BrowserSession; + const platform = yield* HostProcessPlatform; + const executablePath = yield* HostProcessExecutablePath; + // Captured here so the service's methods stay free of a requirements + // channel: the layer is built where NodeServices is already in scope. + const platformServices = yield* Effect.context< + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >(); + const pathContext = yield* sourcePathContext; + + const listSources: Effect.Effect> = Effect.forEach( + BROWSER_IMPORT_SOURCES, + Effect.fnUntraced(function* (definition) { + const unavailable = yield* unavailableReason(definition, pathContext); + return { + id: definition.id, + name: definition.name, + // Listing profiles touches the source's own files, so skip it when the + // source is unusable anyway. + profiles: + unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [], + ...(unavailable === undefined ? {} : { unavailable }), + } satisfies BrowserImportSource; + }), + ).pipe(Effect.provide(platformServices)); + + const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: { + readonly input: BrowserImportInput; + readonly scope: string; + readonly persistent: boolean; + readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; + }) { + const definition = BROWSER_IMPORT_SOURCES.find( + (candidate) => candidate.id === input.input.sourceId, + ); + if (!definition) { + return yield* new BrowserImportFailedError({ + sourceId: input.input.sourceId, + reason: "unknownSource", + }); + } + + const blocked = yield* unavailableReason(definition, pathContext).pipe( + Effect.provide(platformServices), + ); + if (blocked !== undefined) { + return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked }); + } + + if (platform === "darwin" && definition.engine === "chromium") { + // macOS attributes the Keychain prompt and the resulting ACL grant to the + // executable that asks, so record which one that was — in a packaged build + // it is the signed app, in dev whatever binary hosts the main process. + yield* Effect.logInfo("Reading browser cookie key from the keychain", { + sourceId: definition.id, + executablePath, + }); + } + + // The profile directory arrives over IPC, so it is only honoured when the + // source itself reported it. Forwarding it unchecked would let `..` + // segments walk out of the browser's user-data directory and read any + // cookie database reachable on disk. + const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe( + Effect.provide(platformServices), + ); + const requestedProfile = sourceProfiles.find( + (profile) => profile.directory === input.input.sourceProfileDirectory, + ); + if (requestedProfile === undefined) { + return yield* new BrowserImportFailedError({ + sourceId: definition.id, + reason: "unknownSourceProfile", + }); + } + + // The profile was listed against a database moments ago; resolve it again + // rather than assume a path, since a Chromium jar may sit under `Network/`. + const databasePath = yield* resolveCookieDatabase( + definition, + pathContext, + requestedProfile.directory, + ).pipe(Effect.provide(platformServices)); + if (databasePath === undefined) { + // A profile we listed moments ago can lose its database before the + // import runs (browser data cleanup, a profile reset). That is a read + // failure, not a platform problem. + return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" }); + } + + // Both branches fail with a tagged error, so the union stays structurally + // identifiable and each tag is handled on its own below. The success side + // is normalized to one shape too, so the skipped tally survives either + // engine — Firefox stores plaintext, so nothing there is ever unreadable. + const userDataDirectory = definition.userDataDirectory(pathContext); + const read: Effect.Effect< + CookieReadResult, + ChromiumCookieReadError | FirefoxCookieReadError, + FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner + > = + definition.engine === "firefox" + ? readFirefoxCookies(databasePath).pipe( + Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), + ) + : readChromiumCookies({ + cookieDatabasePath: databasePath, + keychainService: definition.keychainService, + keychainAccount: definition.keychainAccount, + linuxSecretApplication: definition.linuxSecretApplication, + ...(platform === "win32" && userDataDirectory !== undefined + ? { + windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"), + } + : {}), + platform, + }); + + const result = yield* read.pipe( + Effect.scoped, + Effect.provide(platformServices), + Effect.catchTags({ + ChromiumCookieReadError: (cause) => + Effect.fail( + new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), + ), + // Firefox has one failure mode — its plaintext database would not open + // — so its error carries no reason of its own and the user-facing one + // is supplied here. + FirefoxCookieReadError: (cause) => + Effect.fail( + new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }), + ), + }), + ); + + const session = yield* browserSession + .getSession(input.scope, input.persistent, input.namespace) + .pipe( + Effect.mapError( + (cause) => + new BrowserImportFailedError({ + sourceId: definition.id, + reason: "sessionUnavailable", + cause, + }), + ), + ); + + // Written one at a time rather than in parallel: Chromium's cookie store + // serialises writes anyway, and a rejected cookie should only cost itself. + return yield* writeCookies(session, result); + }); + + return BrowserImport.of({ listSources, importCookies }); +}); + +export const layer = Layer.effect(BrowserImport, make); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts new file mode 100644 index 000000000000..fc60c658b1d1 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -0,0 +1,493 @@ +// @effect-diagnostics nodeBuiltinImport:off - Encrypts fixtures with the same +// OSCrypt primitives the module under test decrypts. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { + decryptChromiumValue, + readChromiumCookieDatabase, + readChromiumCookies, +} from "./ChromiumCookies.ts"; +import { ChromiumKeyError } from "./ChromiumKeys.ts"; +import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; +import { cookieScope } from "./CookieDatabase.ts"; + +const encryptChromium = ( + prefix: "v10" | "v11", + value: string | Buffer, + key: Buffer, +): Uint8Array => { + const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20)); + return Buffer.concat([Buffer.from(prefix), cipher.update(value), cipher.final()]); +}; + +const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => + encryptChromium("v10", value, key); + +const encryptWindowsV10 = (value: string | Buffer, key: Buffer): Uint8Array => { + const nonce = Buffer.from("0123456789ab"); + const cipher = NodeCrypto.createCipheriv("aes-256-gcm", key, nonce); + const encrypted = Buffer.concat([cipher.update(value), cipher.final()]); + return Buffer.concat([Buffer.from("v10"), nonce, encrypted, cipher.getAuthTag()]); +}; + +describe("cookieScope", () => { + it("keeps a host-only cookie host-only", () => { + // Chromium stores a host-only cookie without a leading dot. Passing any + // `domain` to Electron makes it a domain cookie and re-adds the dot, which + // would expose the cookie to every subdomain it was never scoped to. + expect(cookieScope("example.test", "/", true)).toEqual({ + url: "https://example.test/", + domain: undefined, + }); + }); + + it("preserves a domain cookie's leading dot", () => { + expect(cookieScope(".example.test", "/app", true)).toEqual({ + url: "https://example.test/app", + domain: ".example.test", + }); + }); + + it("matches the scheme to the secure flag", () => { + expect(cookieScope("example.test", "/", false).url).toBe("http://example.test/"); + }); + + it("brackets bare IPv6 hosts without duplicating existing brackets", () => { + expect(cookieScope("::1", "/", false)).toEqual({ + url: "http://[::1]/", + domain: undefined, + }); + expect(cookieScope("[::1]", "/app", true)).toEqual({ + url: "https://[::1]/app", + domain: undefined, + }); + }); +}); + +describe("readChromiumCookieDatabase", () => { + it("decrypts Windows v10 AES-GCM records and rejects app-bound v20 records", () => { + const key = Buffer.from("0123456789abcdef0123456789abcdef"); + const host = ".example.test"; + const bound = Buffer.concat([ + NodeCrypto.createHash("sha256").update(host).digest(), + Buffer.from("windows value"), + ]); + + expect( + decryptChromiumValue(encryptWindowsV10(bound, key), { gcmV10: key }, host, 24, "win32"), + ).toBe("windows value"); + expect( + decryptChromiumValue(Buffer.from("v20app-bound"), { gcmV10: key }, host, 24, "win32"), + ).toBeNull(); + expect( + decryptChromiumValue( + encryptWindowsV10(bound, Buffer.alloc(32, 1)), + { gcmV10: key }, + host, + 24, + "win32", + ), + ).toBeNull(); + }); + + it.effect( + "reports the missing key when no cookies can be read, while preserving partial imports", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-missing-key-", + }); + const filename = `${directory}/Cookies`; + const key = Buffer.from("0123456789abcdef"); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', 23)`; + yield* sql`create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null default '' + )`; + yield* sql`insert into cookies values ('v11.example', 'session', '', ${encryptChromium("v11", "secret", key)}, '/', 0, 1, 1, 1, '')`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + const error = yield* readChromiumCookies({ + cookieDatabasePath: filename, + platform: "linux", + linuxSecretApplication: "chromium", + keychainService: undefined, + keychainAccount: undefined, + }).pipe(Effect.provideService(LinuxBrowserSecretPath, undefined), Effect.flip); + expect(error.reason).toBe("keychainUnavailable"); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`insert into cookies values ('v10.example', 'readable', '', ${encryptV10("kept", key)}, '/', 0, 1, 1, 1, '')`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + const keys = { + cbcV10: key, + cbcV11Error: new ChromiumKeyError({ reason: "keychainUnavailable" }), + }; + const partial = yield* readChromiumCookieDatabase(filename, keys, "linux"); + expect(partial.cookies.map((cookie) => cookie.value)).toEqual(["kept"]); + expect(partial.undecryptable).toBe(1); + + // A partitioned-only jar does not need its key: it is skipped separately. + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`delete from cookies where name = 'readable'`; + yield* sql`update cookies set top_frame_site_key = 'https://top.example'`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + const partitioned = yield* readChromiumCookieDatabase(filename, keys, "linux"); + expect(partitioned.cookies).toEqual([]); + expect(partitioned.undecryptable).toBe(1); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const key = Buffer.from("0123456789abcdef"); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', 23)`; + yield* sql` + create table cookies ( + host_key text not null, + name text not null, + value text not null, + encrypted_value blob not null, + path text not null, + expires_utc integer not null, + is_secure integer not null, + is_httponly integer not null, + samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql` + insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('plain.example', 'plain', 'stored plaintext', ${new Uint8Array()}, '/', 0, 0, 0, -1) + `; + yield* sql` + insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('secure.example', 'encrypted', '', ${encryptV10("stored encrypted", key)}, '/', 0, 1, 1, 2) + `; + yield* sql` + insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('empty.example', 'empty', '', ${new Uint8Array()}, '/', 0, 0, 0, 0) + `; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); + + expect(result.undecryptable).toBe(0); + expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "plain", value: "stored plaintext" }, + { name: "encrypted", value: "stored encrypted" }, + { name: "empty", value: "" }, + ]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("enforces domain binding only for schema 24 and newer", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const key = Buffer.from("0123456789abcdef"); + const boundValue = (host: string, value: string) => + Buffer.concat([NodeCrypto.createHash("sha256").update(host).digest(), Buffer.from(value)]); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', 24)`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('bound.example', 'valid', '', ${encryptV10(boundValue("bound.example", "kept"), key)}, '/', 0, 1, 0, 0)`; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('wrong.example', 'mismatch', '', ${encryptV10(boundValue("another.example", "drop"), key)}, '/', 0, 1, 0, 0)`; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('short.example', 'short', '', ${encryptV10("short value", key)}, '/', 0, 1, 0, 0)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); + + expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "valid", value: "kept" }, + ]); + expect(result.undecryptable).toBe(2); + expect(result.undecryptableHosts).toEqual(["wrong.example", "short.example"]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("decrypts mixed v10 and v11 cookies with their respective keys", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const cbcV10 = Buffer.from("0123456789abcdef"); + const cbcV11 = Buffer.from("fedcba9876543210"); + const boundValue = (host: string, value: string) => + Buffer.concat([NodeCrypto.createHash("sha256").update(host).digest(), Buffer.from(value)]); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', '24')`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('v10.example', 'v10-cookie', '', ${encryptChromium("v10", boundValue("v10.example", "v10 value"), cbcV10)}, '/', 0, 1, 0, 0)`; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('v11.example', 'v11-cookie', '', ${encryptChromium("v11", boundValue("v11.example", "v11 value"), cbcV11)}, '/', 0, 1, 0, 0)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + const complete = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcV11 }, "linux"); + expect(complete.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "v10-cookie", value: "v10 value" }, + { name: "v11-cookie", value: "v11 value" }, + ]); + expect(complete.undecryptable).toBe(0); + + const v10Only = yield* readChromiumCookieDatabase(filename, { cbcV10 }, "linux"); + expect(v10Only.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "v10-cookie", value: "v10 value" }, + ]); + expect(v10Only.undecryptable).toBe(1); + expect(v10Only.undecryptableHosts).toEqual(["v11.example"]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("recovers records written with the empty-passphrase key", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const cbcV10 = Buffer.from("0123456789abcdef"); + const cbcV11 = Buffer.from("fedcba9876543210"); + // The key some Linux clients actually encrypted with (crbug.com/1195256): + // OSCrypt's derivation over an empty passphrase. + const cbcEmpty = NodeCrypto.pbkdf2Sync("", "saltysalt", 1, 16, "sha1"); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', '23')`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('ev10.example', 'empty-v10', '', ${encryptChromium("v10", "empty v10 value", cbcEmpty)}, '/', 0, 1, 0, 0)`; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('ev11.example', 'empty-v11', '', ${encryptChromium("v11", "empty v11 value", cbcEmpty)}, '/', 0, 1, 0, 0)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + // The records' own keys fail, and the empty key recovers both — the + // retry Chromium itself performs. + const recovered = yield* readChromiumCookieDatabase( + filename, + { cbcV10, cbcV11, cbcEmpty }, + "linux", + ); + expect(recovered.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "empty-v10", value: "empty v10 value" }, + { name: "empty-v11", value: "empty v11 value" }, + ]); + expect(recovered.undecryptable).toBe(0); + + // Matching Chromium: a record whose own key is missing entirely is not + // retried with the empty key. + const noV11 = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcEmpty }, "linux"); + expect(noV11.cookies.map(({ name }) => name)).toEqual(["empty-v10"]); + expect(noV11.undecryptableHosts).toEqual(["ev11.example"]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("preserves arbitrary long encrypted values from pre-24 schemas", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const key = Buffer.from("0123456789abcdef"); + const value = "x".repeat(32) + " legacy value"; + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', 23)`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('legacy.example', 'legacy', '', ${encryptV10(value, key)}, '/', 0, 0, 0, 0)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); + expect(result.cookies[0]?.value).toBe(value); + expect(result.undecryptable).toBe(0); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("rejects a malformed text schema version", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', 'not-a-version')`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + const error = yield* readChromiumCookieDatabase( + filename, + { cbcV10: Buffer.from("0123456789abcdef") }, + "darwin", + ).pipe(Effect.flip); + + expect(error._tag).toBe("SchemaError"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("treats unversioned encrypted values as legacy plaintext on macOS and Linux", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const key = Buffer.from("0123456789abcdef"); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value integer not null)`; + yield* sql`insert into meta values ('version', 23)`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + // Chromium's OSCrypt returns unprefixed data as-is on both platforms + // (os_crypt_mac.mm and os_crypt_linux.cc: "old data saved as clear + // text"), so neither counts it as undecryptable. + const mac = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); + const linux = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "linux"); + + expect(mac.cookies[0]?.value).toBe("legacy cleartext"); + expect(mac.undecryptable).toBe(0); + expect(linux.cookies[0]?.value).toBe("legacy cleartext"); + expect(linux.undecryptable).toBe(0); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("skips partitioned cookies without breaking pre-CHIPS schemas", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const legacyFilename = `${directory}/LegacyCookies`; + const chipsFilename = `${directory}/ChipsCookies`; + const key = Buffer.from("0123456789abcdef"); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value integer not null)`; + yield* sql`insert into meta values ('version', 14)`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null + ) + `; + yield* sql`insert into cookies values + ('legacy.example', 'legacy', 'kept', ${new Uint8Array()}, '/', 0, 0, 0, 0)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: legacyFilename }))); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value integer not null)`; + yield* sql`insert into meta values ('version', 23)`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null + ) + `; + yield* sql`insert into cookies values + ('plain.example', 'plain', 'kept', ${new Uint8Array()}, '/', 0, 0, 0, 0, '')`; + yield* sql`insert into cookies values + ('partitioned.example', 'partitioned', 'must skip', ${new Uint8Array()}, '/', 0, 1, 0, 0, 'https://top.example')`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: chipsFilename }))); + + const legacy = yield* readChromiumCookieDatabase(legacyFilename, { cbcV10: key }, "darwin"); + const chips = yield* readChromiumCookieDatabase(chipsFilename, { cbcV10: key }, "darwin"); + + expect(legacy.cookies.map(({ name }) => name)).toEqual(["legacy"]); + expect(legacy.undecryptable).toBe(0); + expect(chips.cookies.map(({ name }) => name)).toEqual(["plain"]); + expect(chips.undecryptable).toBe(1); + expect(chips.undecryptableHosts).toEqual(["partitioned.example"]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts new file mode 100644 index 000000000000..4b8d9d43a47b --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -0,0 +1,370 @@ +// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the +// OSCrypt primitives Chromium uses; Effect has no equivalent. +/** + * Chromium cookie extraction. + * + * Reads a Chromium-family browser's cookie database and decrypts each record + * with the key its prefix calls for. Key acquisition — and the consent it + * needs — lives in `ChromiumKeys`. + * + * Records whose scheme we hold no key for are skipped rather than failing the + * whole import: a Linux database can mix `v10` and `v11`. A partial result + * reported honestly is more useful than an all-or-nothing error. + * + * @module ChromiumCookies + */ +import * as NodeCrypto from "node:crypto"; + +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + ChromiumKeyError, + ChromiumKeyFailure, + readWindowsKey, + resolveChromiumKeys, + type ChromiumKeyMaterial, +} from "./ChromiumKeys.ts"; +import { + bareHost, + cookieScope, + snapshotCookieDatabase, + type CookieReadResult, + type ImportedCookie, +} from "./CookieDatabase.ts"; + +/** OSCrypt's CBC mode uses a fixed IV of 16 spaces rather than a per-record one. */ +const AES_CBC_IV = Buffer.alloc(16, 0x20); +const AES_GCM_NONCE_LENGTH = 12; +const AES_GCM_TAG_LENGTH = 16; +const isChromiumKeyError = Schema.is(ChromiumKeyError); + +/** + * Every way the read can fail: the key failures, plus the ones this module + * raises itself. + */ +export const ChromiumCookieReadReason = Schema.Literals([ + // `readFailed` already comes from the key failures, so it is not repeated. + ...ChromiumKeyFailure.literals, + "browserRunning", +]); +export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type; + +export class ChromiumCookieReadError extends Schema.TaggedErrorClass()( + "ChromiumCookieReadError", + { + reason: ChromiumCookieReadReason, + /** + * Which database the read was for. Without it every `readFailed` and + * keychain failure logs identically, and a user with several browsers + * installed has no way to tell which one refused. + */ + cookieDatabasePath: Schema.String, + /** Kept for the log; never surfaced to the user. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Could not read Chromium cookies at ${this.cookieDatabasePath}: ${this.reason}.`; + } +} + +/** Row shape of the cookie table, decoded rather than cast. */ +const CookieRow = Schema.Struct({ + host_key: Schema.String, + name: Schema.String, + value: Schema.String, + encrypted_value: Schema.Uint8Array, + path: Schema.String, + expires_seconds: Schema.Number, + is_secure: Schema.Number, + is_httponly: Schema.Number, + samesite: Schema.Number, + top_frame_site_key: Schema.String, +}); +const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); +const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); +const SchemaVersion = Schema.Union([ + NonNegativeInt, + Schema.FiniteFromString.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), +]); +const decodeSchemaVersion = Schema.decodeUnknownEffect( + Schema.Tuple([Schema.Struct({ value: SchemaVersion })]), +); + +/** + * Chromium stores `SameSite` as an int: -1 = unspecified, 0 = none, 1 = lax, + * 2 = strict. Unspecified is imported as Electron's own `unspecified` rather + * than pinned to Lax, so the target browser applies its default just as the + * source did; anything unrecognised lands there too, since guessing "none" + * would widen a cookie's scope on import. + */ +const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { + if (value === 0) return "no_restriction"; + if (value === 1) return "lax"; + if (value === 2) return "strict"; + return "unspecified"; +}; + +/** + * Chromium timestamps count microseconds from 1601-01-01; Electron wants + * seconds from the UNIX epoch. The microsecond value overflows JavaScript's + * safe integer range and `node:sqlite` refuses to narrow it, so the division + * happens in SQL and this only ever sees seconds. + */ +const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600; +const toUnixSeconds = (webkitSeconds: number): number | undefined => { + if (webkitSeconds <= 0) return undefined; + return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS; +}; + +/** + * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding + * a cookie to its domain. Strip it when present. + */ +const stripDomainBinding = ( + plaintext: Buffer, + domain: string, + schemaVersion: number, +): Buffer | null => { + if (schemaVersion < 24) return plaintext; + const domainHash = NodeCrypto.createHash("sha256").update(domain).digest(); + return plaintext.length >= 32 && plaintext.subarray(0, 32).equals(domainHash) + ? plaintext.subarray(32) + : null; +}; + +const decryptCbc = ( + payload: Buffer, + key: Buffer, + domain: string, + schemaVersion: number, +): string | null => { + try { + const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_CBC_IV); + decipher.setAutoPadding(true); + const plaintext = Buffer.concat([decipher.update(payload), decipher.final()]); + return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null; + } catch { + return null; + } +}; + +const decryptGcm = ( + payload: Buffer, + key: Buffer, + domain: string, + schemaVersion: number, +): string | null => { + if (payload.length < AES_GCM_NONCE_LENGTH + AES_GCM_TAG_LENGTH) return null; + try { + const nonce = payload.subarray(0, AES_GCM_NONCE_LENGTH); + const ciphertext = payload.subarray(AES_GCM_NONCE_LENGTH, -AES_GCM_TAG_LENGTH); + const tag = payload.subarray(-AES_GCM_TAG_LENGTH); + const decipher = NodeCrypto.createDecipheriv("aes-256-gcm", key, nonce); + decipher.setAuthTag(tag); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null; + } catch { + return null; + } +}; + +/** + * Decrypts one stored value, choosing the scheme from its prefix. Returns null + * when no key covers that scheme — including Windows' app-bound `v20`, which + * this build has no key for at all. + */ +export function decryptChromiumValue( + encrypted: Uint8Array, + keys: ChromiumKeyMaterial, + domain: string, + schemaVersion = 23, + platform: NodeJS.Platform = "linux", +): string | null { + const buffer = Buffer.from(encrypted); + if (buffer.length === 0) return ""; + const prefix = buffer.subarray(0, 3).toString("latin1"); + const payload = buffer.subarray(3); + + // Windows' legacy v10 format is AES-256-GCM. App-bound records use v20 and + // intentionally have no key here, so they fall through as undecryptable. + if (platform === "win32") { + return prefix === "v10" && keys.gcmV10 + ? decryptGcm(payload, keys.gcmV10, domain, schemaVersion) + : null; + } + + // Chromium retries a failed record with a key derived from an empty + // passphrase, because some Linux clients wrote data that way + // (crbug.com/1195256). A record whose own key is missing entirely stays + // skipped, matching Chromium. + if (prefix === "v10") { + if (!keys.cbcV10) return null; + return ( + decryptCbc(payload, keys.cbcV10, domain, schemaVersion) ?? + (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null) + ); + } + if (prefix === "v11") { + if (!keys.cbcV11) return null; + return ( + decryptCbc(payload, keys.cbcV11, domain, schemaVersion) ?? + (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null) + ); + } + // No recognised prefix: Chromium on macOS and Linux both treat this as + // legacy data stored in the clear and return it as-is, so it is a readable + // cookie rather than an undecryptable one. Windows is the exception — its + // app-bound `v20` blobs also lack these prefixes and must not be read as + // plaintext — but Windows Chromium is not importable here at all. + if (platform === "darwin" || platform === "linux") { + return stripDomainBinding(buffer, domain, schemaVersion)?.toString("utf8") ?? null; + } + return null; +} + +/** Reads and decodes one snapshotted Chromium cookie database. */ +export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiumCookieDatabase")( + function* (snapshotPath: string, keys: ChromiumKeyMaterial, platform: NodeJS.Platform) { + const result = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const schemaVersion = yield* sql`select value from meta where key = 'version' limit 1`.pipe( + Effect.flatMap(decodeSchemaVersion), + Effect.map(([row]) => row.value), + ); + const raw = + schemaVersion >= 15 + ? yield* sql`select host_key, name, value, encrypted_value, path, + expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, + samesite, top_frame_site_key from cookies` + : yield* sql`select host_key, name, value, encrypted_value, path, + expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, + samesite, '' as top_frame_site_key from cookies`; + return { rows: yield* decodeCookieRows(raw), schemaVersion }; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true }))); + + const cookies: ImportedCookie[] = []; + let undecryptable = 0; + const undecryptableHosts = new Set(); + for (const row of result.rows) { + if (row.top_frame_site_key !== "") { + undecryptable += 1; + undecryptableHosts.add(bareHost(row.host_key)); + continue; + } + const value = + row.encrypted_value.length === 0 + ? row.value + : decryptChromiumValue( + row.encrypted_value, + keys, + row.host_key, + result.schemaVersion, + platform, + ); + if (value === null) { + undecryptable += 1; + undecryptableHosts.add(bareHost(row.host_key)); + continue; + } + const secure = row.is_secure === 1; + const scope = cookieScope(row.host_key, row.path, secure); + cookies.push({ + url: scope.url, + name: row.name, + value, + domain: scope.domain, + path: row.path, + secure, + httpOnly: row.is_httponly === 1, + expirationDate: toUnixSeconds(row.expires_seconds), + sameSite: sameSiteFromColumn(row.samesite), + }); + } + // Keep partial imports, but do not call a missing key a successful import + // when it prevented every otherwise importable cookie from being read. + if ( + cookies.length === 0 && + keys.cbcV11Error !== undefined && + result.rows.some( + (row) => + row.top_frame_site_key === "" && + Buffer.from(row.encrypted_value.subarray(0, 3)).toString("latin1") === "v11", + ) + ) { + return yield* keys.cbcV11Error; + } + return { + cookies, + undecryptable, + undecryptableHosts: [...undecryptableHosts], + } satisfies CookieReadResult; + }, +); + +export interface ChromiumCookieSource { + readonly cookieDatabasePath: string; + readonly keychainService: string | undefined; + readonly keychainAccount: string | undefined; + readonly linuxSecretApplication: string | undefined; + readonly windowsLocalStatePath?: string; + /** Supplied by the caller from `HostProcessPlatform` rather than read here. */ + readonly platform: NodeJS.Platform; +} + +export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* ( + source: ChromiumCookieSource, +): Effect.fn.Return< + CookieReadResult, + ChromiumCookieReadError, + FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner +> { + const keys = yield* ( + source.platform === "win32" && source.windowsLocalStatePath + ? readWindowsKey(source.windowsLocalStatePath).pipe(Effect.map((gcmV10) => ({ gcmV10 }))) + : resolveChromiumKeys({ + platform: source.platform, + keychainService: source.keychainService, + keychainAccount: source.keychainAccount, + linuxSecretApplication: source.linuxSecretApplication, + }) + ).pipe( + Effect.mapError( + (cause: ChromiumKeyError) => + new ChromiumCookieReadError({ + reason: cause.reason, + cookieDatabasePath: source.cookieDatabasePath, + cause, + }), + ), + ); + + const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe( + Effect.mapError( + (cause) => + new ChromiumCookieReadError({ + reason: "readFailed", + cookieDatabasePath: source.cookieDatabasePath, + cause, + }), + ), + ); + + return yield* readChromiumCookieDatabase(snapshotPath, keys, source.platform).pipe( + Effect.mapError( + (cause) => + new ChromiumCookieReadError({ + reason: isChromiumKeyError(cause) ? cause.reason : "readFailed", + cookieDatabasePath: source.cookieDatabasePath, + cause, + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts new file mode 100644 index 000000000000..c6d26e7a435b --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "@effect/vitest"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + ChromiumKeyError, + decodeWindowsWrappedKey, + readLinuxSecret, + resolveChromiumKeys, + unwrapWindowsDpapiKey, +} from "./ChromiumKeys.ts"; +import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; + +type CapturedCommand = { + readonly command: string; + readonly args: ReadonlyArray; + readonly options: { + readonly stdin?: string; + readonly env?: Readonly>; + }; +}; + +const helperLayer = (input: { + readonly stdout?: string; + readonly stderr?: string; + readonly stdoutStream?: Stream.Stream; + readonly stderrStream?: Stream.Stream; + readonly exitCode?: number; + readonly spawnError?: PlatformError.PlatformError; + readonly capture?: (command: CapturedCommand) => void; +}) => + Layer.merge( + Layer.succeed(LinuxBrowserSecretPath, "/bundled/browser-secret/t3-browser-secret"), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + input.spawnError + ? Effect.fail(input.spawnError) + : Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: input.stdoutStream ?? Stream.encodeText(Stream.make(input.stdout ?? "")), + stderr: input.stderrStream ?? Stream.encodeText(Stream.make(input.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ).pipe( + Effect.tap(() => Effect.sync(() => input.capture?.(command as CapturedCommand))), + ), + ), + ), + ); + +describe("Linux Chromium secrets", () => { + it.effect("retains a missing helper failure alongside the keyring-free fallback", () => + Effect.gen(function* () { + const keys = yield* resolveChromiumKeys({ + platform: "linux", + keychainService: undefined, + keychainAccount: undefined, + linuxSecretApplication: "chromium", + }); + expect(keys.cbcV10).toHaveLength(16); + expect(keys.cbcV11).toBeUndefined(); + expect(keys.cbcV11Error?.reason).toBe("keychainUnavailable"); + }).pipe( + Effect.provide( + helperLayer({ + spawnError: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ), + ), + ); + + it.effect("reports an unconfigured helper without searching PATH", () => + readLinuxSecret("chromium").pipe( + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("keychainUnavailable"))), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("must not spawn")), + ), + Effect.provideService(LinuxBrowserSecretPath, undefined), + ), + ); + + it.effect("looks up the browser's libsecret application attribute", () => { + let captured: CapturedCommand | undefined; + return Effect.gen(function* () { + const keys = yield* resolveChromiumKeys({ + platform: "linux", + keychainService: "ignored macOS service", + keychainAccount: "ignored macOS account", + linuxSecretApplication: "msedge", + }); + + expect(captured?.command).toBe("/bundled/browser-secret/t3-browser-secret"); + expect(captured?.args).toEqual(["msedge"]); + expect(captured?.options.stdin).toBe("ignore"); + expect(keys.cbcV10).toHaveLength(16); + expect(keys.cbcV11).toHaveLength(16); + }).pipe( + Effect.provide( + helperLayer({ stdout: "linux-secret", capture: (value) => (captured = value) }), + ), + ); + }); + + it.effect("reports an unavailable Secret Service backend as a read failure", () => + Effect.gen(function* () { + const error = yield* readLinuxSecret("chrome").pipe(Effect.flip); + expect(error).toBeInstanceOf(ChromiumKeyError); + expect(error.reason).toBe("keychainUnavailable"); + }).pipe( + Effect.provide( + helperLayer({ stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY", exitCode: 1 }), + ), + ), + ); + + it.effect("preserves trailing whitespace in the stored secret", () => + Effect.gen(function* () { + const secret = yield* readLinuxSecret("chrome"); + expect(secret).toBe("linux-secret \t\n"); + }).pipe(Effect.provide(helperLayer({ stdout: "linux-secret \t\n" }))), + ); + + it.effect("drains stdout and stderr concurrently", () => + Effect.gen(function* () { + const stderrDrainStarted = yield* Deferred.make(); + const stdout = Stream.fromEffect(Deferred.await(stderrDrainStarted)).pipe( + Stream.flatMap(() => Stream.encodeText(Stream.make("linux-secret"))), + ); + const stderr = Stream.fromEffect(Deferred.succeed(stderrDrainStarted, undefined)).pipe( + Stream.drain, + ); + + const secret = yield* readLinuxSecret("chrome").pipe( + Effect.provide(helperLayer({ stdoutStream: stdout, stderrStream: stderr })), + ); + + expect(secret).toBe("linux-secret"); + }), + ); + + it.effect( + "preserves the desktop environment and identifies denial without parsing stderr", + () => { + let captured: CapturedCommand | undefined; + return Effect.gen(function* () { + const error = yield* readLinuxSecret("brave").pipe(Effect.flip); + expect(error).toBeInstanceOf(ChromiumKeyError); + expect(error.reason).toBe("needsKeychainApproval"); + expect(captured?.options.env?.LC_ALL).toBe("localized"); + expect(captured?.options.env?.PATH).toBe("/synthetic/bin"); + expect(captured?.options.env?.SESSION_MARKER).toBe("kept"); + }).pipe( + Effect.provide( + helperLayer({ + stderr: "Zugriff verweigert", + exitCode: 3, + capture: (value) => (captured = value), + }), + ), + Effect.provideService(HostProcessEnvironment, { + PATH: "/synthetic/bin", + SESSION_MARKER: "kept", + LC_ALL: "localized", + }), + ); + }, + ); + + it.effect("does not discard a denied unlock prompt while resolving keys", () => + Effect.gen(function* () { + const error = yield* resolveChromiumKeys({ + platform: "linux", + keychainService: undefined, + keychainAccount: undefined, + linuxSecretApplication: "brave", + }).pipe(Effect.flip); + expect(error.reason).toBe("needsKeychainApproval"); + }).pipe(Effect.provide(helperLayer({ stderr: "Keyring is locked", exitCode: 3 }))), + ); + + it.effect("keeps the v10 fallback when the Secret Service backend is unavailable", () => + Effect.gen(function* () { + const keys = yield* resolveChromiumKeys({ + platform: "linux", + keychainService: undefined, + keychainAccount: undefined, + linuxSecretApplication: "chrome", + }); + expect(keys.cbcV10).toHaveLength(16); + expect(keys.cbcV11).toBeUndefined(); + }).pipe( + Effect.provide( + helperLayer({ + stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY", + exitCode: 1, + }), + ), + ), + ); + + it.effect("keeps the v10 fallback when no matching v11 secret exists", () => + Effect.gen(function* () { + const keys = yield* resolveChromiumKeys({ + platform: "linux", + keychainService: undefined, + keychainAccount: undefined, + linuxSecretApplication: "vivaldi", + }); + expect(keys.cbcV10).toHaveLength(16); + expect(keys.cbcV11).toBeUndefined(); + }).pipe(Effect.provide(helperLayer({ exitCode: 2 }))), + ); +}); + +describe("Windows Chromium secrets", () => { + it.effect("accepts only DPAPI-wrapped non-app-bound keys", () => + Effect.gen(function* () { + const wrapped = Buffer.from("wrapped-key"); + const encoded = Buffer.concat([Buffer.from("DPAPI"), wrapped]).toString("base64"); + const localState = `{"os_crypt":{"encrypted_key":"${encoded}"}}`; + expect(yield* decodeWindowsWrappedKey(localState)).toEqual(wrapped); + + const appBound = yield* decodeWindowsWrappedKey( + `{"os_crypt":{"encrypted_key":"${encoded}","app_bound_encrypted_key":"present"}}`, + ).pipe(Effect.flip); + expect(appBound.reason).toBe("unsupportedPlatform"); + + const malformed = yield* decodeWindowsWrappedKey( + `{"os_crypt":{"encrypted_key":"${wrapped.toString("base64")}"}}`, + ).pipe(Effect.flip); + expect(malformed.reason).toBe("readFailed"); + }), + ); + + it.effect("unwraps the binary key through PowerShell without placing it in argv", () => { + let captured: CapturedCommand | undefined; + const wrapped = Buffer.from("wrapped-key"); + const key = Buffer.from("0123456789abcdef0123456789abcdef"); + return Effect.gen(function* () { + expect(yield* unwrapWindowsDpapiKey(wrapped)).toEqual(key); + expect(captured?.command).toBe( + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ); + expect(captured?.args).toContain("-NonInteractive"); + expect(captured?.args.join(" ")).not.toContain(wrapped.toString("base64")); + }).pipe( + Effect.provide( + helperLayer({ stdout: key.toString("base64"), capture: (value) => (captured = value) }), + ), + Effect.provideService(HostProcessEnvironment, { SystemRoot: "C:\\Windows" }), + ); + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts new file mode 100644 index 000000000000..d32462662a36 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -0,0 +1,333 @@ +// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the +// OSCrypt key derivation Chromium uses; Effect has no equivalent. +/** + * Chromium cookie-encryption keys, per platform. + * + * Chromium calls this OSCrypt, and it works differently on each OS: + * + * - **macOS** keeps one key in the login keychain. Reading it prompts the + * user, which is the consent this feature is built around. + * - **Linux** may keep a key in libsecret/kwallet (`v11` records), or use a + * hardcoded `peanuts` passphrase when no keyring is available (`v10`). Both + * can appear in the same database, so both are derived up front and chosen + * per record. + * + * - **Windows** legacy Chromium stores protect a random AES key with DPAPI. + * App-Bound Encryption remains deliberately unsupported. + * + * @module ChromiumKeys + */ +import * as Keyring from "@napi-rs/keyring"; +import * as NodeCrypto from "node:crypto"; + +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; + +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; + +const KEY_SALT = "saltysalt"; +const KEY_LENGTH = 16; +/** macOS stretches the keychain secret; Linux uses a single iteration. */ +const MAC_KEY_ITERATIONS = 1003; +const LINUX_KEY_ITERATIONS = 1; +/** Chromium's documented fallback passphrase when no Linux keyring is present. */ +const LINUX_FALLBACK_PASSPHRASE = "peanuts"; + +export const ChromiumKeyFailure = Schema.Literals([ + "needsKeychainApproval", + "keychainItemMissing", + "keychainUnavailable", + "unsupportedPlatform", + /** The key store itself could not be read, as opposed to holding no key. */ + "readFailed", +]); +export type ChromiumKeyFailure = typeof ChromiumKeyFailure.Type; + +export class ChromiumKeyError extends Schema.TaggedErrorClass()( + "ChromiumKeyError", + { + reason: ChromiumKeyFailure, + /** Kept for the log; never surfaced to the user. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Could not obtain the Chromium cookie key: ${this.reason}.`; + } +} + +/** + * Keys to try, indexed by the record prefix they decrypt. A database can hold + * records written under more than one scheme, so a missing entry means those + * records are skipped rather than the whole import failing. + */ +export interface ChromiumKeyMaterial { + /** AES-128-CBC on macOS, and the keyring-free Linux fallback. */ + readonly cbcV10?: Buffer; + /** AES-128-CBC, Linux keyring-derived. */ + readonly cbcV11?: Buffer; + /** Retained so an import that needs this key can report why it is missing. */ + readonly cbcV11Error?: ChromiumKeyError; + /** + * AES-128-CBC from an empty passphrase. Some Linux clients wrote records + * with it (crbug.com/1195256), so Chromium — and this import — retry with it + * after a record's own key fails. + */ + readonly cbcEmpty?: Buffer; + /** AES-256-GCM key used by pre-App-Bound Chromium on Windows. */ + readonly gcmV10?: Buffer; +} + +const derive = (passphrase: string, iterations: number) => + NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1"); + +/** + * Reads the macOS OSCrypt secret from the login keychain. + * + * Uses the in-process Keychain API rather than shelling out to + * `/usr/bin/security`, because macOS attributes both the consent prompt and the + * resulting ACL entry to the binary that asks. Via the CLI the prompt says + * "security" and "Always Allow" grants trust to a tool every process on the + * machine can invoke; in-process it names this app and the grant belongs to it. + * (In an unsigned dev build the name is the dev binary, not the shipped app + * identity.) + * + * Deliberately untimed: macOS answers this with a modal, and a timeout racing + * the user means the prompt can be approved while nothing is left listening, + * which reads as "approving did nothing". + */ +const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function* ( + service: string, + account: string, +) { + const secret = yield* Effect.try({ + try: () => new Keyring.Entry(service, account).getPassword(), + catch: (cause) => { + const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); + // Distinguish the causes rather than reporting "approve the prompt" for + // a failure approving cannot fix. + const missing = /no (matching )?entry|not found/i.test(message); + return new ChromiumKeyError({ + reason: missing ? "keychainItemMissing" : "needsKeychainApproval", + cause, + }); + }, + }); + if (secret === null || secret === "") { + return yield* new ChromiumKeyError({ reason: "keychainItemMissing" }); + } + return secret; +}); + +/** + * The bundled helper searches Chromium's libsecret schema and application + * attribute, retaining the desktop's normal unlock prompt. Its exit codes + * distinguish a missing key, denied access, and an unavailable keyring without + * parsing localized error messages. Stdout is the unmodified secret. + */ +export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(function* ( + application: string, +) { + return yield* Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = yield* HostProcessEnvironment; + const helper = yield* LinuxBrowserSecretPath; + if (helper === undefined) { + return yield* new ChromiumKeyError({ reason: "keychainUnavailable" }); + } + const handle = yield* spawner + .spawn(ChildProcess.make(helper, [application], { stdin: "ignore", env: environment })) + .pipe( + Effect.mapError( + (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), + ), + ); + const [secret, , exitCode] = yield* Effect.all( + [ + handle.stdout.pipe(Stream.decodeText(), Stream.mkString), + handle.stderr.pipe(Stream.runDrain), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause })), + ); + if (Number(exitCode) !== 0) { + return yield* new ChromiumKeyError({ + reason: + Number(exitCode) === 2 + ? "keychainItemMissing" + : Number(exitCode) === 3 + ? "needsKeychainApproval" + : "keychainUnavailable", + }); + } + if (secret === "") { + return yield* new ChromiumKeyError({ reason: "keychainItemMissing" }); + } + return secret; + }), + ); +}); + +const WindowsLocalState = Schema.Struct({ + os_crypt: Schema.Struct({ + encrypted_key: Schema.String, + app_bound_encrypted_key: Schema.optional(Schema.String), + }), +}); +const decodeWindowsLocalState = Schema.decodeUnknownEffect( + Schema.fromJsonString(WindowsLocalState), +); +const DPAPI_PREFIX = Buffer.from("DPAPI"); +const WINDOWS_KEY_LENGTH = 32; +const WINDOWS_DPAPI_SCRIPT = + "Add-Type -AssemblyName System.Security;" + + "$value=[Console]::In.ReadToEnd();" + + "$encrypted=[Convert]::FromBase64String($value);" + + "$plain=[Security.Cryptography.ProtectedData]::Unprotect($encrypted,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);" + + "[Console]::Out.Write([Convert]::ToBase64String($plain))"; + +export const decodeWindowsWrappedKey = Effect.fn("ChromiumKeys.decodeWindowsWrappedKey")(function* ( + contents: string, +) { + const state = yield* decodeWindowsLocalState(contents).pipe( + Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })), + ); + if (state.os_crypt.app_bound_encrypted_key !== undefined) { + return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" }); + } + const wrapped = yield* Effect.fromResult( + Encoding.decodeBase64(state.os_crypt.encrypted_key), + ).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause }))); + const wrappedBuffer = Buffer.from(wrapped); + if (!wrappedBuffer.subarray(0, DPAPI_PREFIX.length).equals(DPAPI_PREFIX)) { + return yield* new ChromiumKeyError({ reason: "readFailed" }); + } + return wrappedBuffer.subarray(DPAPI_PREFIX.length); +}); + +/** Unwraps a key with the current Windows user's DPAPI identity. */ +export const unwrapWindowsDpapiKey = Effect.fn("ChromiumKeys.unwrapWindowsDpapiKey")(function* ( + wrapped: Buffer, +) { + const environment = yield* HostProcessEnvironment; + return yield* Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const windowsRoot = environment.SystemRoot ?? environment.WINDIR; + const powershell = windowsRoot + ? `${windowsRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` + : "powershell.exe"; + const handle = yield* spawner + .spawn( + ChildProcess.make( + powershell, + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + WINDOWS_DPAPI_SCRIPT, + ], + { + env: environment, + stdin: Stream.encodeText(Stream.make(wrapped.toString("base64"))), + }, + ), + ) + .pipe( + Effect.mapError( + (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), + ), + ); + const [plainEncoded, , exitCode] = yield* Effect.all( + [ + handle.stdout.pipe(Stream.decodeText(), Stream.mkString), + handle.stderr.pipe(Stream.runDrain), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause }))); + if (Number(exitCode) !== 0) { + return yield* new ChromiumKeyError({ reason: "readFailed" }); + } + const plain = yield* Effect.fromResult(Encoding.decodeBase64(plainEncoded)).pipe( + Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })), + ); + if (plain.length !== WINDOWS_KEY_LENGTH) { + return yield* new ChromiumKeyError({ reason: "readFailed" }); + } + return Buffer.from(plain); + }), + ); +}); + +/** Reads and unwraps a legacy Windows Chromium key without exposing it in argv. */ +export const readWindowsKey = Effect.fn("ChromiumKeys.readWindowsKey")(function* ( + localStatePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const contents = yield* fileSystem + .readFileString(localStatePath) + .pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause }))); + return yield* unwrapWindowsDpapiKey(yield* decodeWindowsWrappedKey(contents)); +}); + +export interface ChromiumKeyRequest { + readonly platform: NodeJS.Platform; + readonly keychainService: string | undefined; + readonly keychainAccount: string | undefined; + readonly linuxSecretApplication: string | undefined; +} + +export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys")(function* ( + request: ChromiumKeyRequest, +): Effect.fn.Return< + ChromiumKeyMaterial, + ChromiumKeyError, + ChildProcessSpawner.ChildProcessSpawner +> { + if (request.platform === "darwin") { + if (!request.keychainService || !request.keychainAccount) { + return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" }); + } + const secret = yield* readKeychainSecret(request.keychainService, request.keychainAccount); + return { cbcV10: derive(secret, MAC_KEY_ITERATIONS) }; + } + + if (request.platform === "linux") { + // The fallback passphrase always applies to `v10` records; a keyring + // secret, when one is reachable, additionally unlocks `v11`. Preserve its + // failure until the reader knows whether any cookies needed that key. + const keyringSecret = request.linuxSecretApplication + ? yield* readLinuxSecret(request.linuxSecretApplication).pipe( + // v10 remains importable when Secret Service is absent or does not + // contain a key. An explicit denial/lock/cancel remains a consent + // failure rather than being silently downgraded. + Effect.catch((error) => + error.reason === "needsKeychainApproval" ? Effect.fail(error) : Effect.succeed(error), + ), + ) + : undefined; + return { + cbcV10: derive(LINUX_FALLBACK_PASSPHRASE, LINUX_KEY_ITERATIONS), + ...(typeof keyringSecret === "string" + ? { cbcV11: derive(keyringSecret, LINUX_KEY_ITERATIONS) } + : keyringSecret + ? { cbcV11Error: keyringSecret } + : {}), + cbcEmpty: derive("", LINUX_KEY_ITERATIONS), + }; + } + + return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts new file mode 100644 index 000000000000..8ae178e17eff --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts @@ -0,0 +1,84 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { snapshotCookieDatabase } from "./CookieDatabase.ts"; + +const runNode = ( + effect: Effect.Effect, +) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("snapshotCookieDatabase", () => { + it.effect("includes committed WAL data in one consistent database", () => + runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-cookie-source-", + }); + const source = path.join(sourceDirectory, "Cookies"); + const snapshot = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`PRAGMA journal_mode = WAL`; + yield* sql`PRAGMA wal_autocheckpoint = 0`; + yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`; + yield* sql`INSERT INTO cookies(name) VALUES (${"committed-in-wal"})`; + expect(yield* fileSystem.exists(`${source}-wal`)).toBe(true); + return yield* snapshotCookieDatabase(source); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source }))); + const rows = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ readonly name: string }>`SELECT name FROM cookies`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshot, readonly: true }))); + expect(rows).toEqual([{ name: "committed-in-wal" }]); + }), + ), + ); + + it.effect("propagates snapshot failures and removes its temporary directory", () => + runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-cookie-invalid-source-", + }); + const source = path.join(sourceDirectory, "Cookies"); + yield* fileSystem.writeFileString(source, "not a sqlite database"); + const prefix = `t3code-cookie-failed-${process.pid}-`; + const error = yield* snapshotCookieDatabase(source, prefix).pipe( + Effect.scoped, + Effect.flip, + ); + expect(error._tag).toBe("SqlError"); + const temporaryEntries = yield* fileSystem.readDirectory(path.dirname(sourceDirectory)); + expect(temporaryEntries.some((entry) => entry.startsWith(prefix))).toBe(false); + }), + ), + ); + + it.effect("removes a successful snapshot when its scope closes", () => + runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-cookie-cleanup-source-", + }); + const source = path.join(sourceDirectory, "Cookies"); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source }))); + const snapshot = yield* snapshotCookieDatabase(source).pipe(Effect.scoped); + expect(yield* fileSystem.exists(snapshot)).toBe(false); + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts new file mode 100644 index 000000000000..a9e6be495c05 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts @@ -0,0 +1,100 @@ +/** + * Shared pieces of cookie extraction: the shape both engines produce, and the + * snapshot every reader takes before touching a live database. + * + * @module CookieDatabase + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +/** A cookie in the shape Electron's `session.cookies.set` accepts. */ +export interface ImportedCookie { + readonly url: string; + readonly name: string; + readonly value: string; + /** + * Set only for domain cookies, which the sources mark with a leading dot. + * A host-only cookie leaves this undefined: Electron treats any `domain` it + * is given as marking a domain cookie and re-adds the dot, which would widen + * the cookie to every subdomain of the host it was scoped to, and rejects + * `__Host-` cookies, which require it to be absent. + */ + readonly domain: string | undefined; + readonly path: string; + readonly secure: boolean; + readonly httpOnly: boolean; + /** Seconds since the UNIX epoch, or undefined for a session cookie. */ + readonly expirationDate: number | undefined; + readonly sameSite: "unspecified" | "no_restriction" | "lax" | "strict"; +} + +/** + * Cookies recovered from one database and rows that could not be decrypted. + * The skipped count reaches the user instead of disappearing from a partial + * import result. + */ +export interface CookieReadResult { + readonly cookies: ReadonlyArray; + readonly undecryptable: number; + /** Distinct hosts of the rows that could not be decrypted. */ + readonly undecryptableHosts: ReadonlyArray; +} + +/** + * The URL and domain Electron should register a stored row under. + * + * Both engines mark a domain cookie with a leading dot on the host. Electron + * matches on a URL, so the dot comes off for that; `domain` is passed through + * only for domain cookies, because supplying it at all makes Electron treat + * the cookie as one and re-add the dot — widening a host-only cookie to every + * subdomain of the host it was scoped to, and rejecting `__Host-` cookies, + * which require it to be absent. + */ +export const cookieScope = ( + host: string, + path: string, + secure: boolean, +): { readonly url: string; readonly domain: string | undefined } => { + const isDomainCookie = host.startsWith("."); + const unwrappedHost = bareHost(host); + const authority = + unwrappedHost.includes(":") && !(unwrappedHost.startsWith("[") && unwrappedHost.endsWith("]")) + ? `[${unwrappedHost}]` + : unwrappedHost; + return { + url: `${secure ? "https" : "http"}://${authority}${path}`, + domain: isDomainCookie ? host : undefined, + }; +}; + +/** A host without the leading dot both engines put on a domain cookie, for display. */ +export const bareHost = (host: string): string => (host.startsWith(".") ? host.slice(1) : host); + +/** + * Creates a transactionally consistent snapshot of a cookie database in a + * temporary directory and returns the snapshot's path. + * + * Both engines keep the file open with WAL while the browser runs, so reading + * in place can observe a torn write. Copying also guarantees we never open the + * browser's own file for writing. + * + * Scoped: the temporary directory goes away when the caller's scope closes. + */ +export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(function* ( + cookiePath: string, + tempPrefix = "t3code-cookie-import-", +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: tempPrefix }); + const target = path.join(directory, path.basename(cookiePath)); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`VACUUM INTO ${target}`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: cookiePath, readonly: true }))); + return target; +}); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts new file mode 100644 index 000000000000..84e7678cce4a --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -0,0 +1,459 @@ +// @effect-diagnostics nodeBuiltinImport:off - Builds a Firefox-shaped +// `cookies.sqlite` fixture with the same native bindings Firefox itself uses. +import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as NodeSqlite from "node:sqlite"; + +import { readFirefoxCookies } from "./FirefoxCookies.ts"; +import { parseFirefoxProfiles } from "./Sources.ts"; + +const parsePosixFirefoxProfiles = (ini: string, root = "/home/user/.mozilla/firefox") => + Effect.gen(function* () { + const path = yield* Path.Path; + return parseFirefoxProfiles(ini, path, root); + }).pipe(Effect.provide(NodePath.layerPosix)); + +const parseWindowsFirefoxProfiles = (ini: string, root = "C:\\Users\\user\\Firefox") => + Effect.gen(function* () { + const path = yield* Path.Path; + return parseFirefoxProfiles(ini, path, root); + }).pipe(Effect.provide(NodePath.layerWin32)); + +/** Builds a `cookies.sqlite` with Firefox's real `moz_cookies` shape. */ +const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( + rows: ReadonlyArray<{ + host: string; + name: string; + value: string; + path: string; + expiry: number; + isSecure: number; + isHttpOnly: number; + sameSite: number | null; + rawSameSite?: number; + originAttributes?: string; + }>, + // Firefox stamps `PRAGMA user_version`; schema 16+ stores `expiry` in + // milliseconds, earlier ones in seconds. + schemaVersion = 15, +) { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-test-" }); + const file = `${directory}/cookies.sqlite`; + const database = new NodeSqlite.DatabaseSync(file); + database.exec(`pragma user_version = ${schemaVersion}`); + // Only schemas 10–14 have `rawSameSite`; the schema-15 migration dropped it. + const hasRawSameSite = schemaVersion >= 10 && schemaVersion <= 14; + database.exec( + `create table moz_cookies ( + id integer primary key, host text, name text, value text, path text, + expiry integer, isSecure integer, isHttpOnly integer, sameSite integer, + ${hasRawSameSite ? "rawSameSite integer," : ""} + originAttributes text not null default '' + )`, + ); + const insert = database.prepare( + `insert into moz_cookies + (host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, + ${hasRawSameSite ? "rawSameSite," : ""} originAttributes) + values (?, ?, ?, ?, ?, ?, ?, ?, ${hasRawSameSite ? "?," : ""} ?)`, + ); + for (const row of rows) { + insert.run( + row.host, + row.name, + row.value, + row.path, + row.expiry, + row.isSecure, + row.isHttpOnly, + row.sameSite, + ...(hasRawSameSite ? [row.rawSameSite ?? row.sameSite] : []), + row.originAttributes ?? "", + ); + } + database.close(); + return file; +}); + +const run = (effect: Effect.Effect) => + effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("readFirefoxCookies", () => { + it.effect("converts millisecond expiries from schema 16 and newer", () => + run( + Effect.gen(function* () { + // Firefox 129 (schema 16) migrated `expiry` to milliseconds; older + // profiles still hold seconds. Both must land as seconds for Electron. + const row = { + host: "example.test", + name: "c", + value: "v", + path: "/", + expiry: 1_800_000_000_000, + isSecure: 0, + isHttpOnly: 0, + sameSite: 0, + }; + const modern = yield* readFirefoxCookies(yield* writeFirefoxCookieDatabase([row], 16)); + expect(modern[0]?.expirationDate).toBe(1_800_000_000); + + const legacy = yield* readFirefoxCookies( + yield* writeFirefoxCookieDatabase([{ ...row, expiry: 1_800_000_000 }], 15), + ); + expect(legacy[0]?.expirationDate).toBe(1_800_000_000); + }), + ), + ); + + it.effect("maps moz_cookies onto the shape Electron accepts", () => + run( + Effect.gen(function* () { + const file = yield* writeFirefoxCookieDatabase([ + { + host: ".github.com", + name: "session", + value: "abc", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 1, + sameSite: 1, + }, + { + host: "example.test", + name: "plain", + value: "v", + path: "/app", + // Firefox writes 0 for a session cookie. + expiry: 0, + isSecure: 0, + isHttpOnly: 0, + sameSite: 0, + }, + ]); + + const cookies = yield* readFirefoxCookies(file); + + expect(cookies).toEqual([ + { + // The leading dot stays on the domain but not in the URL, which is + // what Electron matches against. + url: "https://github.com/", + name: "session", + value: "abc", + domain: ".github.com", + path: "/", + secure: true, + httpOnly: true, + expirationDate: 1_800_000_000, + sameSite: "lax", + }, + { + url: "http://example.test/app", + name: "plain", + value: "v", + // Host-only in Firefox, so no `domain`: supplying one would make + // Electron widen it to every subdomain of example.test. + domain: undefined, + path: "/app", + secure: false, + httpOnly: false, + // Session cookies carry no expiry rather than one at the epoch. + expirationDate: undefined, + sameSite: "no_restriction", + }, + ]); + }), + ), + ); + + it.effect("keeps an unset SameSite unspecified instead of widening it to none", () => + run( + Effect.gen(function* () { + // nsICookie::SAMESITE_UNSET is 256, a cookie that carried no SameSite + // attribute. It is not SAMESITE_NONE (0), which is an explicit opt-in + // to cross-site use; importing it as "none" would widen its scope. + const row = { + host: "example.test", + name: "c", + value: "v", + path: "/", + expiry: 0, + isSecure: 0, + isHttpOnly: 0, + }; + const cookies = yield* readFirefoxCookies( + yield* writeFirefoxCookieDatabase([ + { ...row, name: "unset", sameSite: 256 }, + { ...row, name: "none", sameSite: 0 }, + ]), + ); + expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([ + { name: "unset", sameSite: "unspecified" }, + { name: "none", sameSite: "no_restriction" }, + ]); + }), + ), + ); + + it.effect("imports rows whose SameSite was never written", () => + run( + Effect.gen(function* () { + // Schema 9 added `sameSite` without a default, so rows from before the + // upgrade hold NULL. One such row must not fail the whole import. + const row = { + host: "example.test", + name: "c", + value: "v", + path: "/", + expiry: 0, + isSecure: 0, + isHttpOnly: 0, + }; + const cookies = yield* readFirefoxCookies( + yield* writeFirefoxCookieDatabase( + [ + { ...row, name: "legacy", sameSite: null }, + { ...row, name: "strict", sameSite: 2 }, + ], + 9, + ), + ); + expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([ + { name: "legacy", sameSite: "unspecified" }, + { name: "strict", sameSite: "strict" }, + ]); + }), + ), + ); + + it.effect("applies the schema-15 rawSameSite rule to older databases", () => + run( + Effect.gen(function* () { + // Schemas 10–14 defaulted `sameSite` to Lax and kept the declared value + // in `rawSameSite`. Firefox's own migration to 15 turns "Lax by + // default, None declared" into Unset; an unmigrated database has to be + // read the same way or an undeclared cookie becomes an explicit Lax. + const row = { + host: "example.test", + name: "c", + value: "v", + path: "/", + expiry: 0, + isSecure: 0, + isHttpOnly: 0, + }; + const cookies = yield* readFirefoxCookies( + yield* writeFirefoxCookieDatabase( + [ + { ...row, name: "defaulted", sameSite: 1, rawSameSite: 0 }, + { ...row, name: "declared", sameSite: 1, rawSameSite: 1 }, + { ...row, name: "none", sameSite: 0, rawSameSite: 0 }, + ], + 14, + ), + ); + expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([ + { name: "defaulted", sameSite: "unspecified" }, + { name: "declared", sameSite: "lax" }, + { name: "none", sameSite: "no_restriction" }, + ]); + }), + ), + ); + + it.effect("imports only the default container", () => + run( + Effect.gen(function* () { + const file = yield* writeFirefoxCookieDatabase([ + { + host: "mail.test", + name: "session", + value: "default-container", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 1, + }, + { + // Same host, name and path as above: Firefox keeps these apart by + // container, Electron cannot, so importing both would hand the + // profile whichever one happened to be written last. + host: "mail.test", + name: "session", + value: "work-container", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 1, + originAttributes: "^userContextId=2", + }, + { + host: "mail.test", + name: "private", + value: "private-window", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 1, + originAttributes: "^privateBrowsingId=1", + }, + ]); + + const cookies = yield* readFirefoxCookies(file); + + expect(cookies.map((cookie) => cookie.value)).toEqual(["default-container"]); + }), + ), + ); + + it.effect("reads without mutating the source database", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const file = yield* writeFirefoxCookieDatabase([ + { + host: "a.test", + name: "n", + value: "v", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 2, + }, + ]); + const before = yield* fileSystem.stat(file); + + yield* readFirefoxCookies(file); + + // The browser's own file is snapshotted, never opened for writing. + const after = yield* fileSystem.stat(file); + expect(after.mtime).toEqual(before.mtime); + expect(after.size).toBe(before.size); + }), + ), + ); +}); + +describe("parseFirefoxProfiles", () => { + it.effect("reads named profiles and ignores Install sections", () => + Effect.gen(function* () { + // `Install*` sections name a default profile but do not describe one, so + // counting them would invent a profile whose directory does not exist. + const parsed = yield* parsePosixFirefoxProfiles( + [ + "[Install4F96D1932A9F858E]", + "Default=Profiles/abcd1234.default-release", + "Locked=1", + "", + "[Profile0]", + "Name=default-release", + "IsRelative=1", + "Path=Profiles/abcd1234.default-release", + "", + "[Profile1]", + "Name=Work", + "IsRelative=0", + "Path=/Volumes/External/firefox-work", + "", + "[General]", + "StartWithLastProfile=1", + ].join("\n"), + ); + + expect(parsed).toEqual([ + { directory: "Profiles/abcd1234.default-release", name: "default-release" }, + { directory: "/Volumes/External/firefox-work", name: "Work" }, + ]); + }), + ); + + it.effect("falls back to the path when a profile has no name", () => + Effect.gen(function* () { + expect( + yield* parsePosixFirefoxProfiles(["[Profile0]", "Path=Profiles/x.default"].join("\n")), + ).toEqual([{ directory: "Profiles/x.default", name: "Profiles/x.default" }]); + }), + ); + + for (const [platform, root] of [ + ["Linux", "/home/user/.mozilla/firefox"], + ["macOS", "/Users/user/Library/Application Support/Firefox"], + ] as const) { + it.effect(`validates relative and absolute ${platform} profile paths`, () => + Effect.gen(function* () { + const parsed = yield* parsePosixFirefoxProfiles( + [ + "[Profile0]", + "Name=Relative", + "IsRelative=1", + "Path=Profiles/relative.default", + "[Profile1]", + "Name=Custom", + "IsRelative=0", + "Path=/mnt/custom/firefox-profile", + "[Profile2]", + "IsRelative=1", + "Path=../../escape", + "[Profile3]", + "IsRelative=1", + "Path=/absolute-marked-relative", + "[Profile4]", + "IsRelative=0", + "Path=relative-marked-absolute", + "[Profile5]", + "IsRelative=1", + "Path=Profiles/nul\u0000escape", + ].join("\n"), + root, + ); + + expect(parsed).toEqual([ + { directory: "Profiles/relative.default", name: "Relative" }, + { directory: "/mnt/custom/firefox-profile", name: "Custom" }, + ]); + }), + ); + } + + it.effect("uses Windows path rules for relative and absolute profiles", () => + Effect.gen(function* () { + const parsed = yield* parseWindowsFirefoxProfiles( + [ + "[Profile0]", + "Name=Relative", + "IsRelative=1", + "Path=Profiles\\relative.default", + "[Profile1]", + "Name=Custom", + "IsRelative=0", + "Path=D:\\Firefox Profiles\\Work", + "[Profile2]", + "IsRelative=1", + "Path=..\\..\\escape", + "[Profile3]", + "IsRelative=1", + "Path=D:\\absolute-marked-relative", + "[Profile4]", + "IsRelative=0", + "Path=relative-marked-absolute", + ].join("\n"), + ); + + expect(parsed).toEqual([ + { directory: "Profiles\\relative.default", name: "Relative" }, + { directory: "D:\\Firefox Profiles\\Work", name: "Custom" }, + ]); + }), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts new file mode 100644 index 000000000000..f757f1ce01f5 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -0,0 +1,169 @@ +/** + * Firefox cookie extraction. + * + * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key + * to fetch and no consent prompt — the file is readable by anything running as + * the user. That is Mozilla's design choice, not a control being circumvented, + * which is why this path works identically on macOS, Windows, and Linux while + * the Chromium one needs a per-platform credential store. + * + * @module FirefoxCookies + */ +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts"; + +/** + * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error + * the service can tell apart, rather than one of them widening the channel to + * an anonymous shape. + * + * No `reason` field: unlike Chromium there is only one way this fails — the + * plaintext database would not open — and the tag already says which engine it + * was. `BrowserImport` supplies the user-facing reason when it maps the union. + */ +export class FirefoxCookieReadError extends Schema.TaggedErrorClass()( + "FirefoxCookieReadError", + { + /** + * Which database the read was for. Firefox keeps one per profile, so + * without it a failure cannot be traced back to the profile that caused + * it. + */ + cookieDatabasePath: Schema.String, + /** Always present: every construction site wraps a real failure. */ + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not read Firefox cookies at ${this.cookieDatabasePath}.`; + } +} + +/** + * `moz_cookies.sameSite` holds nsICookie's constants: 0 = None, 1 = Lax, + * 2 = Strict, and 256 = Unset for a cookie that carried no SameSite attribute + * at all. Unset is not the same thing as None — None is an explicit opt-in to + * cross-site use — so it is imported as Electron's `unspecified`, which lets + * the target browser apply its own default exactly as Firefox did. Anything + * unrecognised also lands there rather than on `no_restriction`, since + * guessing "none" would widen a cookie's scope on import. + */ +const SAMESITE_NONE = 0; +const SAMESITE_LAX = 1; +const SAMESITE_STRICT = 2; + +/** + * Schemas 10–14 carried a second column, `rawSameSite`: the value the cookie + * actually declared, beside a `sameSite` that Firefox had already defaulted to + * Lax. The schema-15 migration folded them back together with + * `sameSite = UNSET where sameSite = LAX and rawSameSite = NONE`, i.e. a row + * that "is Lax" only because nothing was declared. Reading such a database + * before Firefox has migrated it must apply the same rule, or an undeclared + * cookie is imported as an explicit Lax. + */ +const FIREFOX_RAW_SAMESITE_FIRST_SCHEMA = 10; +const FIREFOX_RAW_SAMESITE_LAST_SCHEMA = 14; + +const sameSiteFromColumn = ( + value: number | null, + rawValue: number | null, +): ImportedCookie["sameSite"] => { + // Schema 9 added the column with no default, so older rows carry NULL. + if (value === null) return "unspecified"; + if (value === SAMESITE_LAX && rawValue === SAMESITE_NONE) return "unspecified"; + if (value === SAMESITE_NONE) return "no_restriction"; + if (value === SAMESITE_LAX) return "lax"; + if (value === SAMESITE_STRICT) return "strict"; + return "unspecified"; +}; + +const CookieRow = Schema.Struct({ + host: Schema.String, + name: Schema.String, + value: Schema.String, + path: Schema.String, + // UNIX-epoch based, unlike Chromium's 1601-based microseconds — but the + // unit depends on the schema version; see `expiryToSeconds`. + expiry: Schema.Number, + isSecure: Schema.Number, + isHttpOnly: Schema.Number, + sameSite: Schema.NullOr(Schema.Number), + // Present only for schemas 10–14; selected as NULL elsewhere. + rawSameSite: Schema.NullOr(Schema.Number), +}); +const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); + +/** + * Firefox schema 16 (Firefox 129) moved `expiry` from seconds to milliseconds + * — the migration is `UPDATE moz_cookies SET expiry = expiry * 1000`. Electron + * wants seconds, so the unit is decided by `PRAGMA user_version` rather than + * assumed: importing a pre-16 profile as milliseconds would expire every cookie + * at once, and a post-16 one as seconds would keep them for ~1000× too long. + */ +const FIREFOX_EXPIRY_MILLISECONDS_SCHEMA = 16; + +const UserVersionRow = Schema.Struct({ user_version: Schema.Number }); +const decodeUserVersion = Schema.decodeUnknownEffect(Schema.Array(UserVersionRow)); + +const expiryToSeconds = (expiry: number, schemaVersion: number): number | undefined => { + if (expiry <= 0) return undefined; + return schemaVersion >= FIREFOX_EXPIRY_MILLISECONDS_SCHEMA ? Math.floor(expiry / 1000) : expiry; +}; + +export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* ( + cookieDatabasePath: string, +) { + const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath).pipe( + Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), + ); + + const { rows, schemaVersion } = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const [versionRow] = yield* decodeUserVersion(yield* sql`pragma user_version`); + const schemaVersion = versionRow?.user_version ?? 0; + const hasRawSameSite = + schemaVersion >= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && + schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; + // Only the default container. Firefox isolates cookies per container and + // per private window via `originAttributes` (`^userContextId=2`, + // `^privateBrowsingId=1`); Electron has no equivalent, so importing them + // all would collapse several identities onto one host/name/path and hand + // the profile an arbitrary container's session. + const raw = hasRawSameSite + ? yield* sql` + select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite + from moz_cookies + where originAttributes = '' + ` + : yield* sql` + select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, + null as rawSameSite + from moz_cookies + where originAttributes = '' + `; + return { rows: yield* decodeCookieRows(raw), schemaVersion }; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })), + Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), + ); + + return rows.map((row) => { + const secure = row.isSecure === 1; + const scope = cookieScope(row.host, row.path, secure); + return { + url: scope.url, + name: row.name, + value: row.value, + domain: scope.domain, + path: row.path, + secure, + httpOnly: row.isHttpOnly === 1, + expirationDate: expiryToSeconds(row.expiry, schemaVersion), + sameSite: sameSiteFromColumn(row.sameSite, row.rawSameSite), + } satisfies ImportedCookie; + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts new file mode 100644 index 000000000000..efeab3262de2 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts @@ -0,0 +1,69 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as DesktopConfig from "../../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as LinuxBrowserSecret from "./LinuxBrowserSecret.ts"; + +it.layer(NodeServices.layer)("Linux browser secret path", (it) => { + it.effect("finds development and packaged helpers without falling back outside the install", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-browser-secret-path-" }); + const resourcesPath = path.join(root, "install", "resources"); + const native = path.join( + root, + "native", + "browser-secret", + "build", + "x64", + "t3-browser-secret", + ); + const staged = path.join( + root, + "apps", + "desktop", + "prod-resources", + "browser-secret", + "t3-browser-secret", + ); + const packaged = path.join(resourcesPath, "browser-secret", "t3-browser-secret"); + for (const filename of [native, staged, packaged]) { + yield* fileSystem.makeDirectory(path.dirname(filename), { recursive: true }); + yield* fileSystem.writeFileString(filename, "helper"); + } + const resolve = (isPackaged: boolean, platform: NodeJS.Platform = "linux") => { + const environment = DesktopEnvironment.layer({ + dirname: path.join(root, "apps", "desktop", "dist-electron"), + homeDirectory: root, + platform, + processArch: "x64", + appVersion: "0.0.1", + appPath: path.join(resourcesPath, "app.asar"), + isPackaged, + resourcesPath, + runningUnderArm64Translation: false, + }).pipe(Layer.provide(DesktopConfig.layerTest({}))); + return LinuxBrowserSecret.LinuxBrowserSecretPath.pipe( + Effect.provide(LinuxBrowserSecret.layer.pipe(Layer.provide(environment))), + ); + }; + + assert.equal(yield* resolve(false), native); + assert.equal(yield* resolve(true), packaged); + yield* fileSystem.remove(native); + assert.equal(yield* resolve(false), staged); + yield* fileSystem.remove(packaged); + assert.isUndefined(yield* resolve(true)); + assert.isUndefined(yield* resolve(false, "darwin")); + assert.isUndefined(yield* resolve(false, "win32")); + yield* fileSystem.remove(staged); + assert.isUndefined(yield* resolve(false)); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts new file mode 100644 index 000000000000..1f5fe02bc454 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts @@ -0,0 +1,40 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import { DesktopEnvironment } from "../../app/DesktopEnvironment.ts"; + +/** Absolute path to the helper shipped with this desktop instance. */ +export const LinuxBrowserSecretPath = Context.Reference( + "@t3tools/desktop/preview/BrowserImport/LinuxBrowserSecretPath", + { defaultValue: () => undefined }, +); + +export const layer = Layer.effect( + LinuxBrowserSecretPath, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + if (environment.platform !== "linux") return undefined; + const fileSystem = yield* FileSystem.FileSystem; + const relative = environment.path.join("browser-secret", "t3-browser-secret"); + const candidates = environment.isPackaged + ? [environment.path.join(environment.resourcesPath, relative)] + : [ + environment.path.join( + environment.rootDir, + "native", + "browser-secret", + "build", + environment.processArch, + "t3-browser-secret", + ), + ...environment.resolveResourcePathCandidates(relative), + ]; + for (const candidate of candidates) { + if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) + return candidate; + } + return undefined; + }), +); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts new file mode 100644 index 000000000000..feaac842cbef --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -0,0 +1,1058 @@ +// @effect-diagnostics nodeBuiltinImport:off - Builds a Chromium-shaped cookie +// table with the same native bindings the source reads. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + HostProcessEnvironment, + HostProcessHostname, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as NodeSqlite from "node:sqlite"; + +import type { BrowserImportPathContext } from "./Sources.ts"; +import { + BROWSER_IMPORT_SOURCES, + chromiumProcessIsAlive, + chromiumSingletonLockIsHeld, + cookieDatabaseCandidatePaths, + firefoxSymlinkLockIsHeld, + resolveCookieDatabase, + isSourceInstalled, + isSourceRunning, + isWindowsLockHeldError, + posixLockIsHeld, + listSourceProfiles, + sourcePathContext, + windowsChromiumCookiesAreHeld, +} from "./Sources.ts"; + +const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; + +describe("Linux Chromium secret applications", () => { + it("pins the libsecret application attribute for each supported fork", () => { + assert.deepEqual( + Object.fromEntries( + BROWSER_IMPORT_SOURCES.filter((source) => source.platforms.includes("linux")).map( + (source) => [source.id, source.linuxSecretApplication], + ), + ), + { + chrome: "chrome", + edge: "msedge", + brave: "brave", + vivaldi: "vivaldi", + opera: "opera", + helium: "chromium", + firefox: undefined, + }, + ); + }); +}); + +const platformError = (reasonTag: string): PlatformError.PlatformError => + ({ _tag: "PlatformError", reason: { _tag: reasonTag } }) as never; + +describe("Windows browser lock errors", () => { + it("treats sharing and lock violations reported as Busy as held", () => { + assert.isTrue(isWindowsLockHeldError(platformError("Busy"))); + }); + + it("does not treat access denied as proof of an active lock", () => { + assert.isFalse(isWindowsLockHeldError(platformError("PermissionDenied"))); + }); + + it("does not treat a missing lock file as held", () => { + assert.isFalse(isWindowsLockHeldError(platformError("NotFound"))); + }); +}); + +/** A scratch home with the source's user-data directory already created. */ +const withSourceHome = Effect.fnUntraced(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + yield* fileSystem.makeDirectory(userDataDirectory(context), { recursive: true }); + return context; +}); + +/** Every case here runs on darwin, where Helium always resolves a directory. */ +const userDataDirectory = (context: BrowserImportPathContext) => { + const root = helium.userDataDirectory(context); + if (root === undefined) throw new Error("Helium has no macOS user-data directory"); + return root; +}; + +const run = ( + effect: Effect.Effect< + A, + E, + FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner + >, +) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +/** Writes a Chromium-shaped cookie table with `count` rows. */ +const writeCookieDatabase = (file: string, count: number) => + Effect.sync(() => { + const database = new NodeSqlite.DatabaseSync(file); + database.exec("create table cookies (host_key text, name text)"); + const insert = database.prepare("insert into cookies (host_key, name) values (?, ?)"); + for (let index = 0; index < count; index += 1) insert.run("example.test", `c${index}`); + database.close(); + }); + +const writeFirefoxCookieDatabase = ( + file: string, + defaultContainerCount: number, + containerCount: number, +) => + Effect.sync(() => { + const database = new NodeSqlite.DatabaseSync(file); + database.exec("create table moz_cookies (originAttributes text not null)"); + const insert = database.prepare("insert into moz_cookies (originAttributes) values (?)"); + for (let index = 0; index < defaultContainerCount; index += 1) insert.run(""); + for (let index = 0; index < containerCount; index += 1) insert.run("^userContextId=2"); + database.close(); + }); + +describe("Helium on Linux", () => { + it.effect("discovers its profiles and checks the user-data lock", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-helium-linux-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "linux"), + ); + const root = `${home}/.config/net.imput.helium`; + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* writeCookieDatabase(`${root}/Default/Cookies`, 3); + yield* fileSystem.writeFileString( + `${root}/Local State`, + '{"profile":{"info_cache":{"Default":{"name":"Personal"}}}}', + ); + + assert.include(helium.platforms, "linux"); + assert.isTrue(yield* isSourceInstalled(helium, context)); + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Default", name: "Personal", cookieCount: 3 }, + ]); + assert.isFalse(yield* isSourceRunning(helium, context)); + yield* fileSystem.symlink("foreign-host-4242", `${root}/SingletonLock`); + assert.isTrue(yield* isSourceRunning(helium, context)); + }), + ), + ); +}); + +describe("Helium on Windows", () => { + it.effect("uses Helium's local app-data profile while other Chromium forks stay disabled", () => + run( + Effect.gen(function* () { + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { + USERPROFILE: "C:\\Users\\browser-user", + LOCALAPPDATA: "C:\\Users\\browser-user\\AppData\\Local", + }), + Effect.provideService(HostProcessPlatform, "win32"), + ); + + assert.include(helium.platforms, "win32"); + assert.equal( + helium.userDataDirectory(context), + context.path.join( + "C:\\Users\\browser-user\\AppData\\Local", + "imput", + "Helium", + "User Data", + ), + ); + for (const source of BROWSER_IMPORT_SOURCES) { + if (source.engine === "chromium" && source.id !== "helium") { + assert.notInclude(source.platforms, "win32"); + } + } + }), + ), + ); +}); + +describe("isSourceRunning", () => { + it.effect("uses the held cookie database as Chromium's Windows running signal", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-helium-windows-lock-", + }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { + HOME: home, + LOCALAPPDATA: home, + }), + Effect.provideService(HostProcessPlatform, "win32"), + ); + const profile = context.path.join(helium.userDataDirectory(context)!, "Default"); + const database = context.path.join(profile, "Network", "Cookies"); + yield* fileSystem.makeDirectory(context.path.join(profile, "Network"), { recursive: true }); + yield* writeCookieDatabase(database, 1); + + const probed: string[] = []; + assert.isTrue( + yield* windowsChromiumCookiesAreHeld(helium, context, (path) => + Effect.sync(() => { + probed.push(path); + return true; + }), + ), + ); + assert.deepEqual(probed, [database]); + }), + ), + ); + + it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + assert.isFalse(yield* isSourceRunning(helium, context)); + + // Chromium points the lock at `-`, a target that never + // exists on disk. A check that follows the link reports a running + // browser as closed, letting an import read a live, mid-write database. + yield* fileSystem.symlink( + "host-that-does-not-exist-1234", + `${userDataDirectory(context)}/SingletonLock`, + ); + + assert.isTrue(yield* isSourceRunning(helium, context)); + }), + ), + ); + + it.effect("uses the provided hostname to classify Chromium locks", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + yield* fileSystem.symlink( + "lock-owner-99999999", + `${helium.userDataDirectory(paths)}/SingletonLock`, + ); + + assert.isTrue( + yield* isSourceRunning(helium, paths).pipe( + Effect.provideService(HostProcessHostname, "another-host"), + ), + ); + assert.isFalse( + yield* isSourceRunning(helium, paths).pipe( + Effect.provideService(HostProcessHostname, "lock-owner"), + ), + ); + }), + ), + ); +}); + +describe("chromiumSingletonLockIsHeld", () => { + it.effect("ignores a positively dead PID on the current host", () => + Effect.gen(function* () { + const checked: number[] = []; + const held = yield* chromiumSingletonLockIsHeld("current-host-4321", "current-host", (pid) => + Effect.sync(() => { + checked.push(pid); + return false; + }), + ); + assert.isFalse(held); + assert.deepEqual(checked, [4321]); + }), + ); + + it.effect("keeps a live PID on the current host", () => + chromiumSingletonLockIsHeld("current-host-4321", "current-host", () => + Effect.succeed(true), + ).pipe(Effect.tap((held) => Effect.sync(() => assert.isTrue(held)))), + ); + + it.effect("keeps foreign-host and malformed targets without probing a PID", () => + Effect.gen(function* () { + let probes = 0; + const probe = (_pid: number) => + Effect.sync(() => { + probes += 1; + return false; + }); + assert.isTrue(yield* chromiumSingletonLockIsHeld("another-host-4321", "current-host", probe)); + assert.isTrue( + yield* chromiumSingletonLockIsHeld("current-host-no-pid", "current-host", probe), + ); + assert.isTrue(yield* chromiumSingletonLockIsHeld("current-host-0", "current-host", probe)); + assert.strictEqual(probes, 0); + }), + ); +}); + +describe("chromiumProcessIsAlive", () => { + it.effect("returns false only when signal 0 reports a missing process", () => + Effect.gen(function* () { + const missing = Object.assign(new Error("missing"), { code: "ESRCH" }); + const denied = Object.assign(new Error("denied"), { code: "EPERM" }); + assert.isFalse( + yield* chromiumProcessIsAlive(4321, () => { + throw missing; + }), + ); + assert.isTrue( + yield* chromiumProcessIsAlive(4321, () => { + throw denied; + }), + ); + assert.isTrue( + yield* chromiumProcessIsAlive(4321, () => { + throw undefined; + }), + ); + assert.isTrue( + yield* chromiumProcessIsAlive(4321, () => { + throw "unknown failure"; + }), + ); + assert.isTrue( + yield* chromiumProcessIsAlive(4321, () => { + throw null; + }), + ); + assert.isTrue(yield* chromiumProcessIsAlive(4321, () => true)); + }), + ); +}); + +describe("isSourceInstalled", () => { + it.effect("ignores a user-data directory that holds no cookie database", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + + // Installers for native messaging hosts create an empty user-data + // directory for every Chromium fork they know about, so treating the + // directory as evidence lists browsers the user does not have. + yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); + assert.isFalse(yield* isSourceInstalled(helium, context)); + + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); + assert.isTrue(yield* isSourceInstalled(helium, context)); + + // A real install whose cookies live outside `Default` still counts: + // reporting it as absent hides the source from the menu entirely. + yield* fileSystem.remove(`${root}/Default`, { recursive: true }); + yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); + assert.isTrue(yield* isSourceInstalled(helium, context)); + + yield* fileSystem.remove(root, { recursive: true }); + assert.isFalse(yield* isSourceInstalled(helium, context)); + }), + ), + ); + + it.effect("detects a Chromium 127+ install with cookies under Network/", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + + yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db"); + assert.isTrue(yield* isSourceInstalled(helium, context)); + }), + ), + ); + + it.effect("follows cookie database symlinks when detecting profiles", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* fileSystem.symlink("missing-cookies", `${root}/Default/Cookies`); + + assert.deepEqual(yield* listSourceProfiles(helium, context), []); + assert.isFalse(yield* isSourceInstalled(helium, context)); + + yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db"); + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Default", name: "Default" }, + ]); + assert.isTrue(yield* isSourceInstalled(helium, context)); + }), + ), + ); +}); + +describe("listSourceProfiles", () => { + it.effect("ignores a profile whose Cookies entry is not a file", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + const root = helium.userDataDirectory(paths); + // A directory named `Cookies` would list as importable and then fail + // the SQLite open, so only a regular file counts as a database. + yield* fileSystem.makeDirectory(`${root}/Broken/Cookies`, { recursive: true }); + yield* fileSystem.makeDirectory(`${root}/Real`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Real/Cookies`, "db"); + + assert.deepEqual(yield* listSourceProfiles(helium, paths), [ + { directory: "Real", name: "Real" }, + ]); + assert.isTrue(yield* isSourceInstalled(helium, paths)); + }), + ), + ); + + it.effect("discovers profiles by their cookie database when Local State is absent", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + // Assuming `Default` would report a browser whose cookies live in + // `Profile 1` as having nothing to import, and it is then hidden. + yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); + yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); + + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Profile 1", name: "Profile 1" }, + ]); + }), + ), + ); + + it.effect("reads the profile names the browser shows", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + yield* fileSystem.writeFileString( + `${userDataDirectory(context)}/Local State`, + `{"profile":{"info_cache":{"Default":{"name":"You"},"Profile 2":{"name":" "}}}}`, + ); + + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Default", name: "You" }, + // Blank display name falls back to the directory rather than + // rendering an empty row. + { directory: "Profile 2", name: "Profile 2" }, + ]); + }), + ), + ); + + it.effect("scans for profiles when Local State is malformed", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + yield* fileSystem.writeFileString(`${root}/Local State`, "{not-json"); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); + + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Default", name: "Default" }, + ]); + }), + ), + ); + + it.effect("reports nothing when no directory holds a cookie database", () => + run( + Effect.gen(function* () { + const context = yield* withSourceHome(); + assert.deepEqual(yield* listSourceProfiles(helium, context), []); + }), + ), + ); + + it.effect("drops Firefox profiles that hold no cookie database", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = firefox.userDataDirectory(context)!; + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* fileSystem.writeFileString( + `${root}/profiles.ini`, + `[Profile0] +Name=original +IsRelative=1 +Path=Profiles/abcd.default-release +Default=1 + +[Profile1] +Name=empty +IsRelative=1 +Path=Profiles/wxyz.empty +`, + ); + yield* fileSystem.makeDirectory(`${root}/Profiles/abcd.default-release`, { + recursive: true, + }); + yield* fileSystem.writeFileString( + `${root}/Profiles/abcd.default-release/cookies.sqlite`, + "db", + ); + yield* fileSystem.makeDirectory(`${root}/Profiles/wxyz.empty`, { recursive: true }); + + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory: "Profiles/abcd.default-release", name: "original" }, + ]); + }), + ), + ); + + it.effect("drops empty profiles when falling back to the Profiles/ scan", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = firefox.userDataDirectory(context)!; + yield* fileSystem.makeDirectory(`${root}/Profiles/filled.default`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Profiles/filled.default/cookies.sqlite`, "db"); + yield* fileSystem.makeDirectory(`${root}/Profiles/empty.default`, { recursive: true }); + + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { + directory: context.path.join("Profiles", "filled.default"), + name: "filled.default", + }, + ]); + }), + ), + ); + + it.effect("discovers profiles with cookies under Network/ (Chromium 127+)", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db"); + + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Default", name: "Default" }, + ]); + }), + ), + ); + + it.effect("counts a profile's cookies without decrypting them", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + const root = helium.userDataDirectory(paths); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* writeCookieDatabase(`${root}/Default/Cookies`, 3); + + const [profile] = yield* listSourceProfiles(helium, paths); + assert.equal(profile?.cookieCount, 3); + }), + ), + ); + + it.effect("falls through to the legacy database when Network/Cookies is a directory", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + const root = helium.userDataDirectory(paths); + // A folder squatting on the preferred candidate path must not shadow + // the real legacy database behind it. + yield* fileSystem.makeDirectory(`${root}/Default/Network/Cookies`, { recursive: true }); + yield* writeCookieDatabase(`${root}/Default/Cookies`, 2); + + const [profile] = yield* listSourceProfiles(helium, paths); + assert.equal(profile?.directory, "Default"); + assert.equal(profile?.cookieCount, 2); + }), + ), + ); +}); + +describe("cookieDatabaseCandidatePaths", () => { + it.effect("prefers Network/Cookies and falls back to the legacy Cookies", () => + run( + Effect.gen(function* () { + const context = yield* withSourceHome(); + const profile = `${context.home}/Library/Application Support/net.imput.helium/Profile 1`; + assert.deepEqual(cookieDatabaseCandidatePaths(helium, context, "Profile 1"), [ + `${profile}/Network/Cookies`, + `${profile}/Cookies`, + ]); + }), + ), + ); + + it.effect("resolves the live Network/ jar over a leftover root Cookies", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = helium.userDataDirectory(context); + // Chromium 96+ keeps sessions in Network/; a root Cookies left behind + // by the move is stale and must not be the one imported. + yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "live"); + yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale"); + + assert.equal( + yield* resolveCookieDatabase(helium, context, "Default"), + `${root}/Default/Network/Cookies`, + ); + // A fresh install with only the Network/ jar is installed, not hidden. + yield* fileSystem.remove(`${root}/Default/Cookies`); + assert.isTrue(yield* isSourceInstalled(helium, context)); + }), + ), + ); + + it.effect("returns only cookies.sqlite for Firefox", () => + run( + Effect.gen(function* () { + const path = yield* Path.Path; + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: "/tmp/test" }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const candidates = cookieDatabaseCandidatePaths(firefox, context, "Profiles/abc.default"); + assert.deepEqual(candidates, [ + path.join( + "/tmp/test", + "Library/Application Support/Firefox/Profiles/abc.default/cookies.sqlite", + ), + ]); + }), + ), + ); +}); + +const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; + +describe("Firefox Snap profiles", () => { + it.effect("finds Snap profiles with or without profiles.ini and checks their locks", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-snap-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "linux"), + ); + const root = `${home}/snap/firefox/common/.mozilla/firefox`; + const directory = `${root}/abcd.default`; + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* writeFirefoxCookieDatabase(`${directory}/cookies.sqlite`, 2, 1); + yield* fileSystem.writeFileString( + `${root}/profiles.ini`, + "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n", + ); + + assert.isTrue(yield* isSourceInstalled(firefox, context)); + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory, name: "Personal", cookieCount: 2 }, + ]); + assert.equal( + yield* resolveCookieDatabase(firefox, context, directory), + `${directory}/cookies.sqlite`, + ); + assert.isFalse(yield* isSourceRunning(firefox, context)); + yield* fileSystem.symlink("foreign-host:+4242", `${directory}/lock`); + assert.isTrue(yield* isSourceRunning(firefox, context)); + yield* fileSystem.remove(`${directory}/lock`); + assert.isFalse(yield* isSourceRunning(firefox, context)); + + yield* fileSystem.remove(`${root}/profiles.ini`); + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory, name: "abcd.default", cookieCount: 2 }, + ]); + }), + ), + ); + + it.effect("keeps matching profile names in native and Snap installs distinct", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-snap-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "linux"), + ); + const native = `${home}/.mozilla/firefox`; + const snap = `${home}/snap/firefox/common/.mozilla/firefox`; + for (const root of [native, snap]) { + yield* fileSystem.makeDirectory(`${root}/abcd.default`, { recursive: true }); + yield* writeFirefoxCookieDatabase(`${root}/abcd.default/cookies.sqlite`, 1, 0); + yield* fileSystem.writeFileString( + `${root}/profiles.ini`, + "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n" + + `[Profile1]\nName=Shared\nIsRelative=0\nPath=${snap}/abcd.default\n`, + ); + } + + const profiles = yield* listSourceProfiles(firefox, context); + assert.deepEqual( + profiles.map((profile) => profile.directory), + ["abcd.default", `${snap}/abcd.default`], + ); + const databases = yield* Effect.forEach(profiles, (profile) => + resolveCookieDatabase(firefox, context, profile.directory), + ); + assert.deepEqual(databases, [ + `${native}/abcd.default/cookies.sqlite`, + `${snap}/abcd.default/cookies.sqlite`, + ]); + }), + ), + ); +}); + +describe("listSourceProfiles Firefox fallback", () => { + const cases = [ + { platform: "linux" as const, profileDirectory: "linux.default" }, + { platform: "darwin" as const, profileDirectory: "Profiles/macos.default" }, + { platform: "win32" as const, profileDirectory: "Profiles/windows.default" }, + ]; + + for (const { platform, profileDirectory } of cases) { + it.effect(`scans the ${platform} profile location and excludes stale entries`, () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: `t3code-firefox-${platform}-`, + }); + const appData = path.join(home, "AppData", "Roaming"); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { + HOME: home, + APPDATA: appData, + }), + Effect.provideService(HostProcessPlatform, platform), + ); + const root = firefox.userDataDirectory(context)!; + const scanRoot = platform === "linux" ? root : path.join(root, "Profiles"); + yield* fileSystem.makeDirectory(path.join(root, profileDirectory), { recursive: true }); + yield* fileSystem.writeFileString( + path.join(root, profileDirectory, "cookies.sqlite"), + "db", + ); + yield* fileSystem.makeDirectory(path.join(scanRoot, "stale.default"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(scanRoot, "stale-file.default"), "not-dir"); + + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { + directory: profileDirectory, + name: path.basename(profileDirectory), + }, + ]); + }), + ), + ); + } + + it.effect("scans for profiles when profiles.ini declares only ones without cookies", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-firefox-stale-ini-", + }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = firefox.userDataDirectory(context)!; + // `profiles.ini` names a profile that was never launched (no cookie + // database), while the real cookies sit in an undeclared one. + yield* fileSystem.makeDirectory(path.join(root, "Profiles", "stale.default"), { + recursive: true, + }); + const realDirectory = path.join(root, "Profiles", "real.default"); + yield* fileSystem.makeDirectory(realDirectory, { recursive: true }); + yield* writeFirefoxCookieDatabase(path.join(realDirectory, "cookies.sqlite"), 3, 0); + yield* fileSystem.writeFileString( + path.join(root, "profiles.ini"), + ["[Profile0]", "Name=Stale", "IsRelative=1", "Path=Profiles/stale.default"].join("\n"), + ); + + // Returning the empty declared list would hide the browser entirely. + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory: "Profiles/real.default", name: "real.default", cookieCount: 3 }, + ]); + assert.isTrue(yield* isSourceInstalled(firefox, context)); + }), + ), + ); + + it.effect("counts only importable cookies for declared and fallback profiles", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-firefox-counts-", + }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = firefox.userDataDirectory(context)!; + const declaredDirectory = path.join(root, "Profiles", "declared.default"); + yield* fileSystem.makeDirectory(declaredDirectory, { recursive: true }); + yield* writeFirefoxCookieDatabase(path.join(declaredDirectory, "cookies.sqlite"), 2, 3); + yield* fileSystem.writeFileString( + path.join(root, "profiles.ini"), + ["[Profile0]", "Name=Declared", "IsRelative=1", "Path=Profiles/declared.default"].join( + "\n", + ), + ); + + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory: "Profiles/declared.default", name: "Declared", cookieCount: 2 }, + ]); + + yield* fileSystem.remove(path.join(root, "profiles.ini")); + const fallbackDirectory = path.join(root, "Profiles", "fallback.default"); + yield* fileSystem.makeDirectory(fallbackDirectory, { recursive: true }); + yield* writeFirefoxCookieDatabase(path.join(fallbackDirectory, "cookies.sqlite"), 1, 4); + + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory: "Profiles/declared.default", name: "declared.default", cookieCount: 2 }, + { directory: "Profiles/fallback.default", name: "fallback.default", cookieCount: 1 }, + ]); + }), + ), + ); +}); + +describe("isSourceRunning for Firefox", () => { + it.effect("finds the lock inside the profile, not at the root", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = firefox.userDataDirectory(context)!; + const profile = `${root}/Profiles/abcd.default-release`; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); + + assert.isFalse(yield* isSourceRunning(firefox, context)); + + // Firefox keeps its locks per profile. A root-level lock is not one, + // and looking there was why a running Firefox read as importable. + yield* fileSystem.writeFileString(`${root}/lock`, ""); + assert.isFalse(yield* isSourceRunning(firefox, context)); + + // `.parentlock` is deliberately left on disk after a clean exit as a + // last-used marker, so an unlocked one is not evidence of a running + // browser — treating it as one blocked every import after first use. + yield* fileSystem.writeFileString(`${profile}/.parentlock`, ""); + assert.isFalse(yield* isSourceRunning(firefox, context)); + + // The `lock` symlink is what Firefox removes on exit; a live pid in + // its target means the profile is held. + yield* fileSystem.symlink(`127.0.0.1:+${process.pid}`, `${profile}/lock`); + assert.isTrue(yield* isSourceRunning(firefox, context)); + }), + ), + ); + + it.effect("reports not-held when no interpreter can run the fcntl probe", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-lock-" }); + const lock = `${directory}/.parentlock`; + yield* fileSystem.writeFileString(lock, ""); + // A Mac without the developer tools has only Apple's shim, which + // refuses to run the script; a machine with no python at all has + // nothing. Either way the probe is unavailable, not the lock held — + // treating it as held would block Firefox import on that machine for + // good. + assert.isFalse(yield* posixLockIsHeld(lock, ["/nonexistent/python3"])); + // And a fake "interpreter" that exits non-zero without a verdict, as + // the shim does, is the same case. + assert.isFalse(yield* posixLockIsHeld(lock, ["/usr/bin/false"])); + }), + ), + ); + + it.effect("detects a live fcntl lock on .parentlock, as macOS Firefox leaves it", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = firefox.userDataDirectory(context)!; + const profile = `${root}/Profiles/abcd.default-release`; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); + const parentLock = `${profile}/.parentlock`; + yield* fileSystem.writeFileString(parentLock, ""); + + // Hold the lock from a child the way Firefox does (F_SETLK, write), + // and keep it until the scope closes. + const holder = yield* spawner.spawn( + ChildProcess.make( + "python3", + [ + "-c", + "import fcntl,os,sys,time\n" + + "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + + "fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" + + "print('locked',flush=True)\n" + + "time.sleep(30)", + parentLock, + ], + { stdin: "ignore" }, + ), + ); + // Wait for the child to confirm it holds the lock before probing. + yield* holder.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.filter((line) => line.trim() === "locked"), + Stream.take(1), + Stream.runDrain, + ); + + assert.isTrue(yield* isSourceRunning(firefox, context)); + yield* holder.kill(); + }), + ), + ); + + it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () => + Effect.gen(function* () { + const alive = (pid: number) => Effect.succeed(pid === 4242); + // The resolver may hand Firefox any of the machine's addresses, not + // just 127.0.0.1 — 127.0.1.1 on Debian-style hosts, a LAN address + // elsewhere — so every local address counts as ours. + const local = new Set(["127.0.0.1", "127.0.1.1", "192.168.1.20"]); + // Both the plain and the fcntl-marked (`+`) forms carry the pid. + assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.0.1:4242", local, alive)); + assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.1.1:+4242", local, alive)); + assert.isTrue(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+4242", local, alive)); + // A crash leaves the symlink behind with a dead pid, on any local address. + assert.isFalse(yield* firefoxSymlinkLockIsHeld("127.0.0.1:+9999", local, alive)); + assert.isFalse(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+9999", local, alive)); + // Anything unparseable stays conservative. + assert.isTrue(yield* firefoxSymlinkLockIsHeld("garbage", local, alive)); + // A foreign owner (a shared profile locked from another machine) names + // a pid we cannot probe, so it is held regardless of local liveness. + assert.isTrue(yield* firefoxSymlinkLockIsHeld("10.0.0.7:+9999", local, alive)); + }), + ); + + it.effect("does not treat a stale parent.lock file as a running browser", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); + const context = yield* sourcePathContext.pipe( + // Firefox's win32 root hangs off %APPDATA%; without it the root is + // undefined and the fixture would escape the sandbox into the repo. + Effect.provideService(HostProcessEnvironment, { + HOME: home, + APPDATA: `${home}/AppData/Roaming`, + }), + Effect.provideService(HostProcessPlatform, "win32"), + ); + const root = firefox.userDataDirectory(context)!; + const profile = `${root}/Profiles/gx7x7fqx.default-release`; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); + + // On Windows, Firefox creates parent.lock as a regular file that + // persists after the process exits. The file is only locked while + // Firefox is running; the old stat-based check always found it. + yield* fileSystem.writeFileString(`${profile}/parent.lock`, ""); + assert.isFalse(yield* isSourceRunning(firefox, context)); + }), + ), + ); +}); + +describe("Windows user-data directories", () => { + it.effect("keeps app-bound Chromium forks unsupported on win32", () => + Effect.sync(() => { + // Helium retains the older DPAPI-backed store. Other Chromium forks use + // App-Bound Encryption, so omitting win32 makes `unavailableReason` + // report `unsupportedPlatform` and keeps them out of the menu. + for (const source of BROWSER_IMPORT_SOURCES) { + if (source.engine === "chromium" && source.id !== "helium") { + assert.notInclude(source.platforms, "win32"); + } + } + }), + ); +}); + +describe("listSourceProfiles hardening", () => { + it.effect("drops profile directories that are not a single plain segment", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + // `Local State` is writable by anything running as the user, so a + // crafted key must not reach `cookieDatabasePath` and read a database + // outside the browser's user-data directory. + yield* fileSystem.writeFileString( + `${userDataDirectory(context)}/Local State`, + `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`, + ); + + const profiles = yield* listSourceProfiles(helium, context); + + assert.deepEqual( + profiles.map((profile) => profile.directory), + ["Default"], + ); + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts new file mode 100644 index 000000000000..702933a432b3 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -0,0 +1,832 @@ +/** + * Importable browser sources. + * + * Two engines are modelled. Chromium-family browsers keep cookies in an + * encrypted SQLite database whose key lives in an OS credential store; Firefox + * keeps them in plain SQLite with no key at all, so it needs no keychain and + * works the same on every platform. + * + * Each entry pins its own paths and credential-store coordinates rather than + * deriving them, because the forks do not agree. macOS uses service/account + * pairs, while Linux Chromium uses a custom libsecret schema keyed by an + * `application` attribute. The user-data directory also differs per fork and + * per platform. + * + * @module BrowserImportSources + */ +import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { + HostProcessEnvironment, + HostProcessAddresses, + HostProcessHostname, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export type BrowserImportEngine = "chromium" | "firefox"; + +/** + * Directory roots a definition builds its paths from. Passed in rather than + * read from `process`, so source resolution stays testable for platforms the + * host is not currently running. + */ +export interface BrowserImportPathContext { + readonly path: Path.Path; + readonly platform: NodeJS.Platform; + readonly home: string; + /** `%APPDATA%` on Windows; unused elsewhere. */ + readonly appData: string | undefined; + /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */ + readonly localAppData: string | undefined; +} + +export interface BrowserImportSourceDefinition { + readonly id: BrowserImportSourceId; + readonly name: string; + readonly engine: BrowserImportEngine; + /** Platforms the definition has paths for. */ + readonly platforms: ReadonlyArray; + readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; + /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */ + readonly keychainService?: string; + readonly keychainAccount?: string; + /** Chromium's `application` attribute in the Linux libsecret schema. */ + readonly linuxSecretApplication?: string; +} + +const macApplicationSupport = ( + context: BrowserImportPathContext, + ...segments: ReadonlyArray +) => context.path.join(context.home, "Library", "Application Support", ...segments); + +/** + * One Chromium fork. The leaves differ per fork; omitting a platform's + * segments marks the fork as unavailable there. Most Windows Chromium builds + * use App-Bound Encryption, but forks can retain the older DPAPI-backed store. + */ +const chromiumSource = (input: { + readonly id: BrowserImportSourceId; + readonly name: string; + readonly keychainService: string; + readonly keychainAccount: string; + readonly macSegments: ReadonlyArray; + readonly linuxSegments?: ReadonlyArray; + readonly linuxSecretApplication?: string; + readonly windowsSegments?: ReadonlyArray; +}): BrowserImportSourceDefinition => ({ + id: input.id, + name: input.name, + engine: "chromium", + platforms: [ + "darwin" as NodeJS.Platform, + ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []), + ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []), + ], + keychainService: input.keychainService, + keychainAccount: input.keychainAccount, + ...(input.linuxSecretApplication === undefined + ? {} + : { linuxSecretApplication: input.linuxSecretApplication }), + userDataDirectory: (context) => { + if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments); + if (context.platform === "win32") { + return input.windowsSegments && context.localAppData + ? context.path.join(context.localAppData, ...input.windowsSegments) + : undefined; + } + return input.linuxSegments + ? context.path.join(context.home, ".config", ...input.linuxSegments) + : undefined; + }, +}); + +export const BROWSER_IMPORT_SOURCES: ReadonlyArray = [ + // No Chromium fork is importable on Windows: since Chrome 127 their cookies + // are encrypted to the browser's own identity (App-Bound Encryption), so no + // other process can read them. macOS and Linux keep working, so only the + // Windows segments are omitted. + chromiumSource({ + id: "chrome", + name: "Chrome", + keychainService: "Chrome Safe Storage", + keychainAccount: "Chrome", + macSegments: ["Google", "Chrome"], + linuxSegments: ["google-chrome"], + linuxSecretApplication: "chrome", + }), + chromiumSource({ + id: "edge", + name: "Microsoft Edge", + keychainService: "Microsoft Edge Safe Storage", + keychainAccount: "Microsoft Edge", + macSegments: ["Microsoft Edge"], + linuxSegments: ["microsoft-edge"], + linuxSecretApplication: "msedge", + }), + chromiumSource({ + id: "brave", + name: "Brave", + keychainService: "Brave Safe Storage", + keychainAccount: "Brave", + macSegments: ["BraveSoftware", "Brave-Browser"], + linuxSegments: ["BraveSoftware", "Brave-Browser"], + linuxSecretApplication: "brave", + }), + chromiumSource({ + id: "vivaldi", + name: "Vivaldi", + keychainService: "Vivaldi Safe Storage", + keychainAccount: "Vivaldi", + macSegments: ["Vivaldi"], + linuxSegments: ["vivaldi"], + linuxSecretApplication: "vivaldi", + }), + chromiumSource({ + id: "opera", + name: "Opera", + keychainService: "Opera Safe Storage", + keychainAccount: "Opera", + macSegments: ["com.operasoftware.Opera"], + linuxSegments: ["opera"], + linuxSecretApplication: "opera", + }), + // Arc has no Linux build. + chromiumSource({ + id: "arc", + name: "Arc", + keychainService: "Arc Safe Storage", + keychainAccount: "Arc", + macSegments: ["Arc", "User Data"], + }), + chromiumSource({ + id: "helium", + name: "Helium", + keychainService: "Helium Storage Key", + keychainAccount: "Helium", + macSegments: ["net.imput.helium"], + linuxSegments: ["net.imput.helium"], + windowsSegments: ["imput", "Helium", "User Data"], + // Helium retains Chromium's libsecret application name on Linux. + linuxSecretApplication: "chromium", + }), + { + id: "firefox", + name: "Firefox", + engine: "firefox", + platforms: ["darwin", "win32", "linux"], + userDataDirectory: (context) => { + if (context.platform === "darwin") return macApplicationSupport(context, "Firefox"); + if (context.platform === "win32") { + return context.appData + ? context.path.join(context.appData, "Mozilla", "Firefox") + : undefined; + } + return context.path.join(context.home, ".mozilla", "firefox"); + }, + }, +]; + +/** + * Where a profile's cookie database may live, most current first. Chromium 96 + * moved the live jar to `Network/Cookies`; a root-level `Cookies` is either a + * pre-96 install or a leftover from before the move. Importing the leftover + * while sessions live in `Network/` would snapshot a stale or empty database, + * and a fresh install with only `Network/Cookies` would read as not installed. + * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini` + * may already be absolute. + * + * Chrome 96+ moved network-related files (including Cookies) into a `Network` + * subdirectory for sandboxing. The candidate list includes both locations so + * callers tolerate fresh and legacy installs alike. + */ +export const cookieDatabaseCandidatePaths = ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, + profileDirectory: string, +): ReadonlyArray => { + const root = definition.userDataDirectory(context); + if (root === undefined) return []; + const profilePath = context.path.isAbsolute(profileDirectory) + ? profileDirectory + : context.path.join(root, profileDirectory); + if (definition.engine === "firefox") { + return [context.path.join(profilePath, "cookies.sqlite")]; + } + // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade + // leaves the legacy file behind, so prefer the current one and fall back. + return [ + context.path.join(profilePath, "Network", "Cookies"), + context.path.join(profilePath, "Cookies"), + ]; +}; + +/** The first candidate that is a regular file, or undefined when none is. */ +export const resolveCookieDatabase = Effect.fnUntraced(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, + profileDirectory: string, +) { + for (const candidate of cookieDatabaseCandidatePaths(definition, context, profileDirectory)) { + if (yield* databaseFileExists(candidate)) return candidate; + } + return undefined; +}); + +/** + * Firefox records its profiles in `profiles.ini`. `Install*` sections point at + * a default profile but do not describe one, so only `[ProfileN]` blocks + * count. + */ +export function parseFirefoxProfiles( + ini: string, + path: Path.Path, + root: string, +): ReadonlyArray { + const profiles: BrowserImportSourceProfile[] = []; + let current: { name?: string; path?: string; isRelative?: string } | null = null; + + const flush = () => { + if (current?.path) { + const candidate = current.path; + const isRelative = current.isRelative === undefined || current.isRelative === "1"; + const validIsRelative = current.isRelative === undefined || /^[01]$/.test(current.isRelative); + if (!validIsRelative || candidate.includes("\u0000")) { + current = null; + return; + } + + let directory: string | undefined; + if (isRelative) { + if (!path.isAbsolute(candidate)) { + const resolved = path.resolve(root, candidate); + const relative = path.relative(root, resolved); + const escapesRoot = + relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + if (!escapesRoot) directory = path.normalize(candidate); + } + } else if (path.isAbsolute(candidate)) { + // Firefox supports profiles on arbitrary custom roots when + // IsRelative=0. Do not constrain them to the standard Firefox root. + directory = path.normalize(candidate); + } + + if (directory !== undefined) { + profiles.push({ directory, name: current.name?.trim() || directory }); + } + } + current = null; + }; + + for (const rawLine of ini.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.startsWith("[")) { + flush(); + current = /^\[Profile\d+\]$/i.test(line) ? {} : null; + continue; + } + if (!current) continue; + const separator = line.indexOf("="); + if (separator === -1) continue; + const key = line.slice(0, separator).trim().toLowerCase(); + const value = line.slice(separator + 1).trim(); + if (key === "name") current.name = value; + if (key === "path") current.path = value; + if (key === "isrelative") current.isRelative = value; + } + flush(); + return profiles; +} + +/** + * Resolves the roots the registry builds its paths from, from the ambient + * process. Tests build a context directly instead. + */ +export const sourcePathContext = Effect.gen(function* () { + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const environment = yield* HostProcessEnvironment; + return { + path, + platform, + home: environment.HOME ?? environment.USERPROFILE ?? "", + appData: environment.APPDATA, + localAppData: environment.LOCALAPPDATA, + } satisfies BrowserImportPathContext; +}); + +/** Shape of the slice of Chromium's `Local State` that names its profiles. */ +const LocalState = Schema.Struct({ + profile: Schema.optional( + Schema.Struct({ + info_cache: Schema.optional( + Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })), + ), + }), + ), +}); +const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState)); + +/** A single plain path segment: no separators, no `.`/`..`, not empty. */ +const isSafeProfileDirectory = (directory: string): boolean => + directory.length > 0 && + directory !== "." && + directory !== ".." && + !/[\\/]/.test(directory) && + !directory.includes("\u0000"); + +const CookieCountRow = Schema.Struct({ count: Schema.Number }); +const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); + +/** + * How many importable cookies a profile holds, counted without decrypting + * anything. Firefox containers use identities Electron cannot represent, so + * its count uses the same default-container predicate as the reader. Best + * effort: a locked, missing or unexpected database yields `undefined` rather + * than failing the listing. + */ +const countProfileCookies = Effect.fnUntraced(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, + directory: string, +): Effect.fn.Return { + const database = yield* resolveCookieDatabase(definition, context, directory); + if (database === undefined) return undefined; + return yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = + definition.engine === "firefox" + ? yield* sql`select count(*) as count from moz_cookies where originAttributes = ''` + : yield* sql`select count(*) as count from cookies`; + const [row] = yield* decodeCookieCount(rows); + return row?.count; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: database, readonly: true })), + Effect.orElseSucceed(() => undefined), + ); +}); + +const withCookieCounts = ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, + profiles: ReadonlyArray, +) => + Effect.forEach(profiles, (profile) => + countProfileCookies(definition, context, profile.directory).pipe( + Effect.map((cookieCount) => + cookieCount === undefined ? profile : { ...profile, cookieCount }, + ), + ), + ); + +/** + * Profiles the source browser knows about. + * + * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When + * that metadata is missing, unreadable or malformed, the directories that + * actually hold a cookie database are scanned instead. Assuming a single + * `Default` would report a browser whose cookies live in `Profile 1` as having + * nothing to import — and it is then left out of the menu entirely. + */ +const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, +): Effect.fn.Return, never, FileSystem.FileSystem> { + const fileSystem = yield* FileSystem.FileSystem; + const root = definition.userDataDirectory(context); + if (root === undefined) return []; + + if (definition.engine === "firefox") { + const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe( + Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + // `profiles.ini` also lists profiles the installer created but the user + // never launched, which hold no cookie database and nothing to import. + // Only keep the ones a database proves exist, like the directory scans + // below do. When none of the declared profiles has one, fall through to + // the scan rather than returning empty: `profiles.ini` can list stale or + // never-launched profiles while the cookies live in one it does not + // mention, and an empty answer here hides the browser entirely. + if (declared.length > 0) { + const found = yield* Effect.forEach(declared, (profile) => + Effect.forEach( + cookieDatabaseCandidatePaths(definition, context, profile.directory), + (candidate) => databaseFileExists(candidate), + ).pipe(Effect.map((results) => (results.some(Boolean) ? profile : undefined))), + ); + const withDatabase = found.filter((profile) => profile !== undefined); + if (withDatabase.length > 0) { + return yield* withCookieCounts(definition, context, withDatabase); + } + } + + // No usable `profiles.ini`, so fall back to scanning the directory the + // profiles actually live in, keeping only the ones a cookie database + // proves were launched. + const fallbackDirectory = + context.platform === "linux" ? root : context.path.join(root, "Profiles"); + const scanned = yield* fileSystem + .readDirectory(fallbackDirectory) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const found = yield* Effect.forEach(scanned, (entry) => { + const directory = context.platform === "linux" ? entry : context.path.join("Profiles", entry); + return resolveCookieDatabase(definition, context, directory).pipe( + Effect.map((database) => (database === undefined ? undefined : { directory, name: entry })), + ); + }); + return yield* withCookieCounts( + definition, + context, + found.filter((profile) => profile !== undefined), + ); + } + + const declared = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe( + Effect.flatMap(decodeLocalState), + Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})), + // The keys are directory names from the browser's own metadata file, which + // anything running as the user can write. Anything but a single plain + // segment is dropped: `..` or a path separator would otherwise be handed + // to `cookieDatabasePath` and read a database outside the user-data + // directory. + Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))), + Effect.map((entries) => + entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })), + ), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + if (declared.length > 0) return yield* withCookieCounts(definition, context, declared); + + // `Local State` is missing, unreadable or malformed. Scanning for directories + // that hold a cookie database finds the profiles anyway. + const entries = yield* fileSystem + .readDirectory(root) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const found = yield* Effect.forEach(entries.filter(isSafeProfileDirectory), (directory) => + resolveCookieDatabase(definition, context, directory).pipe( + Effect.map((database) => + database === undefined ? undefined : { directory, name: directory }, + ), + ), + ); + return yield* withCookieCounts( + definition, + context, + found.filter((profile) => profile !== undefined), + ); +}); + +/** + * Include Firefox's Snap home alongside its native home. Snap profiles use + * absolute directories so cookie reads and lock checks keep pointing at the + * installation they came from, even when both installs use the same name. + */ +export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, +): Effect.fn.Return, never, FileSystem.FileSystem> { + if (definition.engine !== "firefox" || context.platform !== "linux") { + return yield* listSourceProfilesInDirectory(definition, context); + } + + const root = definition.userDataDirectory(context); + if (root === undefined) return []; + const roots = [ + root, + context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), + ]; + const profiles = new Map(); + for (const directory of roots) { + const found = yield* listSourceProfilesInDirectory( + { ...definition, userDataDirectory: () => directory }, + context, + ); + for (const profile of found) { + const absolute = context.path.resolve(directory, profile.directory); + if (!profiles.has(absolute)) { + profiles.set(absolute, directory === root ? profile : { ...profile, directory: absolute }); + } + } + } + return [...profiles.values()]; +}); + +/** + * Whether a cookie database candidate is a regular file. Presence alone is + * not enough: a directory at the path would list as an importable profile and + * then fail the SQLite open, so anything but a file is treated as absent. + */ +const databaseFileExists = Effect.fnUntraced(function* (path: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.stat(path).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false), + ); +}); + +type ProcessLivenessProbe = (pid: number) => Effect.Effect; + +export const chromiumProcessIsAlive = ( + pid: number, + signalProcess: (pid: number, signal: 0) => unknown = process.kill.bind(process), +) => + Effect.sync(() => { + try { + // Signal 0 performs a read-only existence/permission check. + signalProcess(pid, 0); + return true; + } catch (cause) { + // Only ESRCH positively proves the process is gone. Permission errors + // and unknown failures stay conservative so an active browser is never + // mistaken for a stale lock. + return !( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ESRCH" + ); + } + }); + +const processIsAlive: ProcessLivenessProbe = (pid) => chromiumProcessIsAlive(pid); + +/** Whether a Chromium `-` lock target may still name its owner. */ +export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( + target: string, + currentHost: string, + isProcessAlive: ProcessLivenessProbe, +) { + const separator = target.lastIndexOf("-"); + if (separator <= 0) return true; + const host = target.slice(0, separator); + const pidText = target.slice(separator + 1); + if (!/^\d+$/.test(pidText)) return true; + const pid = Number(pidText); + if (!Number.isSafeInteger(pid) || pid <= 0) return true; + // A PID is meaningful only on this host. A foreign hostname can come from a + // shared home directory, and cannot safely be declared stale from here. + if (host !== currentHost) return true; + return yield* isProcessAlive(pid); +}); + +/** Windows sharing and lock violations are translated by libuv to `Busy`. */ +export const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean => + error.reason._tag === "Busy"; + +/** + * Whether a Windows `parent.lock` is actually held by a running process. It + * is opened with no sharing, so it persists on disk after the process exits + * and `stat` always succeeds; only trying to open it for write reveals an + * active holder, which surfaces as `Busy`. + */ +const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { + // Permission failures are distinct: they do not prove a browser owns the + // lock, so they must not hide the source as running. + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe( + Effect.as(false), + Effect.catchIf(isWindowsLockHeldError, () => Effect.succeed(true)), + Effect.orElseSucceed(() => false), + Effect.scoped, + ); +}); + +type WindowsLockProbe = (path: string) => Effect.Effect; + +/** + * Chromium does not create its POSIX `SingletonLock` symlink on Windows. The + * live cookie database is opened without sharing instead, so probing each + * profile's current jar is the reliable running signal there. + */ +export const windowsChromiumCookiesAreHeld = Effect.fnUntraced(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, + lockIsHeld: WindowsLockProbe = windowsLockIsHeld, +) { + const profiles = yield* listSourceProfiles(definition, context); + const held = yield* Effect.forEach(profiles, (profile) => + resolveCookieDatabase(definition, context, profile.directory).pipe( + Effect.flatMap((database) => + database === undefined ? Effect.succeed(false) : lockIsHeld(database), + ), + ), + ); + return held.some(Boolean); +}); + +/** + * Whether a Firefox `lock` symlink's `:[+]` target still names a + * live owner. Firefox writes this symlink beside the profile while it runs and + * unlinks it on a clean exit, so a dangling one is either live or a crash. + */ +export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( + target: string, + localAddresses: ReadonlySet, + isProcessAlive: ProcessLivenessProbe, +) { + const separator = target.lastIndexOf(":"); + if (separator < 0) return true; + // The owner half is whatever Firefox's resolver returned for the machine's + // hostname — 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN + // address — so a pid is only meaningful when that address is one of ours. + // A shared (NFS) profile locked from another machine names a foreign + // address whose pid cannot be probed here, nor could a reused local pid + // vouch for it, so it stays conservatively held. + const owner = target.slice(0, separator); + if (!localAddresses.has(owner)) return true; + // A `+` marks an fcntl-holding owner; the pid follows either way. + const pidText = target.slice(separator + 1).replace(/^\+/, ""); + if (!/^\d+$/.test(pidText)) return true; + const pid = Number(pidText); + if (!Number.isSafeInteger(pid) || pid <= 0) return true; + return yield* isProcessAlive(pid); +}); + +/** + * Interpreters that can run the fcntl probe, tried in order. `/usr/bin/python3` + * is named absolutely first so a Dock-launched app with launchd's bare `PATH` + * still finds it without depending on the login-shell PATH merge; Linux + * distributions carry python3 on the default path. + */ +const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const; + +/** + * The probe prints exactly one of these. Anything else means the script never + * ran — most importantly Apple's `/usr/bin/python3` shim, which on a Mac + * without the Command Line Tools exits non-zero after printing an install + * prompt, without ever reaching our code. + */ +const FCNTL_PROBE_SCRIPT = + "import fcntl,os,sys\n" + + "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + + "try:\n" + + " fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" + + "except BlockingIOError:\n" + + " print('held')\n" + + "else:\n" + + " print('free')"; + +/** + * Whether another process holds an fcntl write lock on `path`. + * + * Firefox's `.parentlock` is an empty file whose only signal is the kernel + * lock, and Node exposes no fcntl, so a throwaway interpreter tries a + * non-blocking `F_SETLK` and reports `EWOULDBLOCK`. The lock is never + * acquired for real: on success the child exits and the kernel drops it. + * + * The answer is trusted only when the script itself spoke. A verdict of + * `held` or `free` on stdout is the probe's own, and stands. Anything else — + * no interpreter on any candidate path, or one that refused to run the script + * (Apple's shim without the developer tools) — is the probe being unavailable, + * not evidence about the lock. That case falls back to "not held" rather than + * "held": reporting every profile as locked forever would block Firefox import + * outright on such machines, and the SQLite snapshot already copes with a + * live database's WAL, as it does for every other engine. + */ +export const posixLockIsHeld = Effect.fnUntraced(function* ( + path: string, + interpreters: ReadonlyArray = FCNTL_PROBE_INTERPRETERS, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = yield* HostProcessEnvironment; + for (const interpreter of interpreters) { + const verdict = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make(interpreter, ["-c", FCNTL_PROBE_SCRIPT, path], { + stdin: "ignore", + env: environment, + }), + ); + const [stdout] = yield* Effect.all( + [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], + { concurrency: "unbounded" }, + ); + return stdout.trim(); + }), + ).pipe(Effect.orElseSucceed(() => "")); + if (verdict === "held") return true; + if (verdict === "free") return false; + } + return false; +}); + +/** + * Whether Firefox holds a profile. + * + * Firefox leaves two kinds of lock behind, and they mean different things. + * On Linux the `lock` symlink (target `:+`) is removed on a clean + * exit, so its presence is evidence — provided the pid it names is alive. But + * `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files + * held with fcntl or a Windows handle and are *deliberately left on disk* + * after exit, as a last-used marker; treating them as proof of a running + * browser blocks every import after Firefox has been used once. On POSIX the + * fcntl lock itself is the truth, and macOS in particular writes nothing else + * (no symlink, no pid), so `.parentlock` is probed for the kernel lock. On + * Windows the held handle denies our open, which `windowsLockIsHeld` reads as `Busy`. + */ +const firefoxProfileIsHeld = Effect.fnUntraced(function* ( + directory: string, + context: BrowserImportPathContext, + // Resolved once by the caller: it involves a DNS lookup of the hostname and + // is the same for every profile. + localAddresses: ReadonlySet, +) { + const fileSystem = yield* FileSystem.FileSystem; + if (context.platform === "win32") { + return yield* windowsLockIsHeld(context.path.join(directory, "parent.lock")); + } + // Linux additionally writes the `lock` symlink; a live pid there settles it + // without spawning anything. + const symlinkHeld = yield* fileSystem.readLink(context.path.join(directory, "lock")).pipe( + Effect.flatMap((target) => firefoxSymlinkLockIsHeld(target, localAddresses, processIsAlive)), + Effect.orElseSucceed(() => false), + ); + if (symlinkHeld) return true; + const parentLock = context.path.join(directory, ".parentlock"); + const present = yield* fileSystem.stat(parentLock).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false), + ); + if (!present) return false; + return yield* posixLockIsHeld(parentLock); +}); + +/** Whether the browser is running, which leaves its cookie DB mid-write. */ +export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, +): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner +> { + const fileSystem = yield* FileSystem.FileSystem; + const root = definition.userDataDirectory(context); + if (root === undefined) return false; + // Probe the source's own lock state rather than scanning the process table. + // Chromium exposes its lock through the cookie jar on Windows and through a + // user-data SingletonLock on POSIX. Firefox keeps its locks inside each + // profile under three names across platforms (`lock` on macOS and Linux, + // `.parentlock` beside it, `parent.lock` on Windows). Looking for Firefox's + // at the root finds nothing and reports a running browser as importable. + if (definition.engine !== "firefox") { + if (context.platform === "win32") { + return yield* windowsChromiumCookiesAreHeld(definition, context); + } + const currentHost = yield* HostProcessHostname; + const lock = context.path.join(root, "SingletonLock"); + return yield* fileSystem.readLink(lock).pipe( + Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)), + Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")), + ); + } + + const profiles = yield* listSourceProfiles(definition, context); + // Only the Linux `lock` symlink names an address, so Windows skips the lookup. + const localAddresses: ReadonlySet = + context.platform === "win32" ? new Set() : yield* yield* HostProcessAddresses; + const found = yield* Effect.forEach(profiles, (profile) => { + const directory = context.path.isAbsolute(profile.directory) + ? profile.directory + : context.path.join(root, profile.directory); + return firefoxProfileIsHeld(directory, context, localAddresses); + }); + return found.some(Boolean); +}); + +/** + * Whether the source has cookies to import. + * + * Keyed off the cookie database rather than the user-data directory, because + * that directory is not evidence the browser exists: installers for native + * messaging hosts create an empty one for every Chromium fork they know about, + * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as + * present. The database is the thing an import actually needs, so its absence + * is the honest answer either way. + * + * Existence is checked without opening the file, which matters for Safari: TCC + * permits `stat` on the jar inside its container but refuses a read, so this + * still sees it and the user gets the Full Disk Access prompt rather than + * having Safari disappear. + */ +export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, +): Effect.fn.Return { + const profiles = yield* listSourceProfiles(definition, context); + const found = yield* Effect.forEach(profiles, (profile) => + resolveCookieDatabase(definition, context, profile.directory).pipe( + Effect.map((database) => database !== undefined), + ), + ); + return found.some(Boolean); +}); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 75271d76386a..a7b3afabd3c3 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -677,6 +677,67 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () => + withManager((manager) => + Effect.gen(function* () { + // Real Electron throws on any `wc.debugger` access once the + // WebContents is destroyed, so cleanup must go through the debugger + // reference captured at attach time (electron/electron#53376). + let destroyed = false; + let attached = false; + const debuggerOff = vi.fn(); + const debuggerDetach = vi.fn(() => { + attached = false; + }); + const wcDebugger = { + isAttached: () => attached, + attach: vi.fn(() => { + attached = true; + }), + detach: debuggerDetach, + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: debuggerOff, + }; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => destroyed, + getType: () => "webview", + getURL: () => "http://localhost:3200/", + getTitle: () => "Preview", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + reload: vi.fn(), + loadURL: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + get debugger() { + if (destroyed) throw new Error("Object has been destroyed"); + return wcDebugger; + }, + } as never); + yield* manager.createTab("tab_pinned_debugger"); + yield* manager.registerWebview("tab_pinned_debugger", 42); + yield* manager.setColorScheme("tab_pinned_debugger", "dark"); + expect(attached).toBe(true); + destroyed = true; + + yield* manager.navigate("tab_pinned_debugger", "https://example.com/"); + + expect(debuggerOff).toHaveBeenCalledWith("message", expect.any(Function)); + expect(debuggerDetach).toHaveBeenCalledOnce(); + }), + ), + ); + effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () => withManager((manager) => Effect.gen(function* () { @@ -3137,6 +3198,164 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("settles the pick when the annotation screenshot never arrives", () => + withManager((manager) => + Effect.gen(function* () { + let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isFocused: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + once: vi.fn(), + off: vi.fn(), + // A wedged compositor leaves `capturePage` pending forever. + capturePage: vi.fn(() => new Promise(() => {})), + ipc: { + on: vi.fn((channel: string, listener: typeof onPicked) => { + if (channel === "preview:element-picked") onPicked = listener; + }), + off: vi.fn(), + removeListener: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + + onPicked?.( + {}, + { + id: "annotation_1", + pageUrl: "https://example.com", + pageTitle: "Example", + comment: "Tighten this spacing", + elements: [], + regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + }, + null, + "send", + ); + yield* Effect.yieldNow; + expect(pick.pollUnsafe()).toBeUndefined(); + + yield* TestClock.adjust("6 seconds"); + // The pick has to give up on the crop rather than strand the renderer, + // which would leave the composer stuck on "Capturing…". + const result = yield* Fiber.join(pick); + expect(result?.annotation.screenshot).toBeNull(); + expect(result?.screenshotFailed).toBe(true); + expect(result?.submission).toBe("send"); + expect(webviewSend).toHaveBeenCalledWith("preview:annotation-captured"); + }), + ), + ); + + effectIt.effect("a stale capture from a replaced pick never touches the next pick", () => + withManager((manager) => + Effect.gen(function* () { + let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isFocused: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + once: vi.fn(), + off: vi.fn(), + capturePage: vi.fn(() => new Promise(() => {})), + ipc: { + on: vi.fn((channel: string, listener: typeof onPicked) => { + if (channel === "preview:element-picked") onPicked = listener; + }), + off: vi.fn(), + removeListener: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const annotation = { + id: "annotation_1", + pageUrl: "https://example.com", + pageTitle: "Example", + comment: "Tighten this spacing", + elements: [], + regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + }; + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const firstPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + // The first pick submits and its crop hangs. + onPicked?.({}, annotation, null, "send"); + yield* Effect.yieldNow; + + // A second pick on the same tab replaces the first, which resumes null. + const secondPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + expect(yield* Fiber.join(firstPick)).toBeNull(); + webviewSend.mockClear(); + + // The first pick's crop times out while the second pick is live. It + // must not signal the overlay, which would tear down the second pick. + yield* TestClock.adjust("6 seconds"); + yield* Effect.yieldNow; + expect(webviewSend).not.toHaveBeenCalledWith("preview:annotation-captured"); + expect(secondPick.pollUnsafe()).toBeUndefined(); + + onPicked?.({}, { ...annotation, id: "annotation_2" }, null, "attach"); + yield* TestClock.adjust("6 seconds"); + const result = yield* Fiber.join(secondPick); + expect(result?.annotation.id).toBe("annotation_2"); + expect(result?.submission).toBe("attach"); + }), + ), + ); + effectIt.effect("navigates the guest history when the thumb-button ipc fires", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 01398721dd58..324b92034f36 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -306,13 +306,24 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { }; }; +/** `capturePage` never settles when the guest's compositor is wedged. */ +const ANNOTATION_SCREENSHOT_TIMEOUT = "5 seconds"; + +/** + * Crops the guest for a picked annotation. A stalled `capturePage` resolves to + * `null` after the timeout: the annotation is still sendable without its + * screenshot, and the pick session must settle either way. + */ const captureAnnotationScreenshot = ( tabId: string, wc: Electron.WebContents, cropRect: PreviewAnnotationRect | null, ): Effect.Effect => Effect.tryPromise({ - try: () => + // The unused abort signal is what makes this interruptible, and therefore + // what lets the timeout below fire. Drop the parameter and a stalled + // capture strands the pick session again. + try: (_signal) => wc.capturePage( cropRect ? { @@ -331,7 +342,7 @@ const captureAnnotationScreenshot = ( cause, }), }).pipe( - Effect.map((image) => { + Effect.map((image): PreviewAnnotationPayload["screenshot"] => { const size = image.getSize(); return { dataUrl: image.toDataURL(), @@ -340,6 +351,15 @@ const captureAnnotationScreenshot = ( cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, }; }), + Effect.timeoutOption(ANNOTATION_SCREENSHOT_TIMEOUT), + Effect.flatMap((screenshot) => + Option.isSome(screenshot) + ? Effect.succeed(screenshot.value) + : Effect.logWarning("preview annotation screenshot timed out").pipe( + Effect.annotateLogs({ tabId, webContentsId: wc.id }), + Effect.as(null), + ), + ), ); const findZoomStep = (current: number): number => { @@ -417,6 +437,12 @@ interface PickSession { interface BrowserControlSession { readonly webContentsId: number; + // Pins the WebContents' Debugger wrapper for the session's lifetime. + // Electron's Debugger is GC-managed but registered with Chromium as a raw + // DevToolsAgentHostClient pointer; collecting it while attached crashes the + // browser process (electron/electron#53376). Detach must also go through + // this reference: `wc.debugger` throws once the WebContents is destroyed. + readonly debugger: Electron.Debugger; readonly semaphore: Semaphore.Semaphore; readonly scope: Scope.Closeable; readonly onMessage: ( @@ -1164,6 +1190,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { const semaphore = yield* Semaphore.make(1); const scope = yield* Scope.fork(parentScope, "sequential"); + const wcDebugger = wc.debugger; const handleDebuggerMessage = Effect.fnUntraced(function* ( method: string, params: Record, @@ -1176,7 +1203,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function operation: "ackScreencastFrame", webContentsId: wc.id, }, - () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), + () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }), ).pipe(Effect.ignore); } const tabId = yield* tabIdForWebContents(wc.id); @@ -1224,8 +1251,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ), attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => { - wc.debugger.off("message", onMessage); - if (wc.debugger.isAttached()) wc.debugger.detach(); + wcDebugger.off("message", onMessage); + if (wcDebugger.isAttached()) wcDebugger.detach(); }).pipe(Effect.ignore), ], { discard: true }, @@ -1233,6 +1260,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const control: BrowserControlSession = { webContentsId: wc.id, + debugger: wcDebugger, semaphore, scope, onMessage, @@ -1248,15 +1276,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => { - wc.debugger.on("message", onMessage); - wc.debugger.attach("1.3"); + wcDebugger.on("message", onMessage); + wcDebugger.attach("1.3"); }); yield* Effect.all( ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( (method) => attemptPromise( { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, - () => wc.debugger.sendCommand(method), + () => wcDebugger.sendCommand(method), ), ), { concurrency: "unbounded", discard: true }, @@ -1345,7 +1373,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const result = yield* attemptPromise( { operation: `${action}.${method}`, tabId, webContentsId: wc.id }, - () => wc.debugger.sendCommand(method, commandParams), + () => control.debugger.sendCommand(method, commandParams), ); const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; if (after !== epoch) { @@ -1369,7 +1397,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId, webContentsId: wc.id, }, - () => wc.debugger.sendCommand(method, commandParams), + () => control.debugger.sendCommand(method, commandParams), ); }, ); @@ -2370,30 +2398,52 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationTheme = yield* Ref.get(annotationThemeRef); return yield* Effect.callback( (resume) => { + // Declared first so cleanup can check slot ownership by identity + // without a type cycle through the cancel effect it builds. + const session: PickSession = { cancel: Effect.suspend(() => cancelPickSession()) }; const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); }).pipe(Effect.ignore); + // Only drop the slot while it is still ours. A newer session may + // already have swapped itself in before cancelling this one. yield* Ref.update(pickSessionsRef, (sessions) => - replaceMap(sessions, (copy) => { - copy.delete(tabId); - }), + sessions.get(tabId) === session + ? replaceMap(sessions, (copy) => { + copy.delete(tabId); + }) + : sessions, ); }); - const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( + // Every exit from this session runs through `claimSettle`, so the + // renderer's `pickElement` promise resolves exactly once. The previous + // identity check let a cancelled or replaced session return without + // resuming, which left the composer waiting forever. + let settled = false; + const claimSettle = (): boolean => { + if (settled) return false; + settled = true; + return true; + }; + const finishPick = Effect.fn("PreviewManager.finishPickElement")(function* ( payload: PreviewAnnotationSubmissionResult | null, ) { - const active = (yield* Ref.get(pickSessionsRef)).get(tabId); - if (!active || active.cancel !== cancel) return; yield* cleanup(); resume(Effect.succeed(payload)); }); + const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( + payload: PreviewAnnotationSubmissionResult | null, + ) { + if (!claimSettle()) return; + yield* finishPick(payload); + }); const settle = (payload: PreviewAnnotationSubmissionResult | null) => { runFork(settlePick(payload)); }; const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () { + if (!claimSettle()) return; yield* cleanup(); const tabs = yield* SynchronizedRef.get(tabsRef); const activeTab = tabs.get(tabId); @@ -2412,7 +2462,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } resume(Effect.succeed(null)); }); - const cancel = cancelPickSession(); const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { const payload = args[0]; if (!isPreviewAnnotationPayload(payload)) { @@ -2423,19 +2472,32 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const submission = args[2] === "send" ? "send" : "attach"; runFork( captureAnnotationScreenshot(tabId, wc, cropRect).pipe( - Effect.matchEffect({ - onFailure: () => Effect.sync(() => settle({ annotation: payload, submission })), - onSuccess: (screenshot) => - Effect.sync(() => settle({ annotation: { ...payload, screenshot }, submission })), + // The renderer cannot tell a dropped crop from a comment-only + // pick by the null alone, so a failed or timed-out capture is + // flagged on the result. + Effect.match({ + onFailure: (): PreviewAnnotationSubmissionResult => ({ + annotation: payload, + submission, + screenshotFailed: true, + }), + onSuccess: (screenshot): PreviewAnnotationSubmissionResult => + screenshot === null + ? { annotation: payload, submission, screenshotFailed: true } + : { annotation: { ...payload, screenshot }, submission }, }), - Effect.ensuring( - attempt( + Effect.flatMap((result) => { + // A capture that outlives its session must not touch the + // overlay: the preload tears down on the captured signal, and + // by now it may be running a newer pick. + if (!claimSettle()) return Effect.void; + return attempt( { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, () => { if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); }, - ).pipe(Effect.ignore), - ), + ).pipe(Effect.ignore, Effect.andThen(finishPick(result))); + }), ), ); }; @@ -2449,6 +2511,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (isMainFrame) settle(null); }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { + // Two picks on one tab can overlap. Swap this session in and cancel + // the previous holder in one step, so no third pick can slip into an + // empty slot in between and the session we push out still resumes + // its renderer. + const replaced = yield* Ref.modify(pickSessionsRef, (sessions) => [ + sessions.get(tabId) ?? null, + replaceMap(sessions, (copy) => { + copy.set(tabId, session); + }), + ]); + if (replaced) yield* replaced.cancel; + // A newer pick may have cancelled this session while the previous + // one was torn down. Cleanup already ran, so attaching listeners now + // would leak them and start an overlay nobody is waiting on. + if (settled) return; yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); wc.once("destroyed", onDestroyed); @@ -2456,21 +2533,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!wc.isFocused()) wc.focus(); wc.send(START_PICK_CHANNEL, annotationTheme); }); - yield* Ref.update(pickSessionsRef, (sessions) => - replaceMap(sessions, (copy) => { - copy.set(tabId, { cancel }); - }), - ); }); runFork( registerPickElement().pipe( Effect.catch((error: PreviewManagerError) => { + if (!claimSettle()) return Effect.void; resume(Effect.fail(error)); return cleanup(); }), ), ); - return cancel; + return session.cancel; }, ); }); @@ -2513,9 +2586,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc: Electron.WebContents, colorScheme: DesktopPreviewColorScheme, ) { - yield* ensureControlSession(wc); + const control = yield* ensureControlSession(wc); yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => - wc.debugger.sendCommand("Emulation.setEmulatedMedia", { + control.debugger.sendCommand("Emulation.setEmulatedMedia", { features: [ { name: "prefers-color-scheme", @@ -2535,7 +2608,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Effect.gen(function* () { const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (beforeAttach?.webContentsId !== wc.id) return; - yield* ensureControlSession(wc); + const control = yield* ensureControlSession(wc); const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (afterAttach?.webContentsId !== wc.id) { yield* detachControlSession(wc.id); @@ -2543,7 +2616,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } if (afterAttach.colorScheme !== "system") { yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => - wc.debugger.sendCommand("Emulation.setEmulatedMedia", { + control.debugger.sendCommand("Emulation.setEmulatedMedia", { features: [ { name: "prefers-color-scheme", diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index f315bdcec738..6155c4119ec8 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. +// @effect-diagnostics globalDate:off globalTimers:off - This isolated Electron preload does not run inside an Effect runtime. import { ipcRenderer } from "electron"; import { getElementContext } from "react-grab/primitives"; import type { @@ -30,6 +30,8 @@ const Z_INDEX_OVERLAY = 2147483646; const PRIMARY = "var(--t3-primary)"; const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; const MAX_MARQUEE_ELEMENTS = 20; +/** Upper bound on one element's React context lookup during submit. */ +const ELEMENT_CONTEXT_TIMEOUT_MS = 5_000; const CONTENT_LAYER_Z_INDEX = 1; const CHROME_LAYER_Z_INDEX = 10; @@ -279,25 +281,67 @@ function toStackFrame(frame: { }; } -async function captureElement(element: Element): Promise { +/** + * Resolves to `null` instead of hanging when `promise` outlives `millis`. + * `getElementContext` walks the inspected page's React internals, and some + * pages leave it pending forever. Without a bound, the whole submit chain + * stalls and the overlay sits on "Capturing…". + */ +function withCaptureTimeout(promise: Promise, millis: number): Promise { + let timer: ReturnType | undefined; + return Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), millis); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** Truncation for the DOM-only preview used when React context is unavailable. */ +const HTML_PREVIEW_MAX_CHARS = 500; + +/** + * Describes a picked element. The React context lookup can stall or throw on + * some pages, so the element is never dropped: without context it still + * carries its tag, a short HTML preview, and its rect so the crop stays on the + * pick instead of falling back to the whole viewport. + */ +async function captureElement(element: Element): Promise { + const base = { + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + tagName: element.tagName.toLowerCase(), + pickedAt: new Date().toISOString(), + }; try { - const context = await getElementContext(element); - const stack = (context.stack ?? []).map(toStackFrame); - return { - pageUrl: location.href, - pageTitle: document.title?.trim() || null, - tagName: element.tagName.toLowerCase(), - selector: context.selector, - htmlPreview: context.htmlPreview ?? "", - componentName: context.componentName, - source: stack[0] ?? null, - stack, - styles: context.styles ?? "", - pickedAt: new Date().toISOString(), - }; + const context = await withCaptureTimeout( + Promise.resolve(getElementContext(element)), + ELEMENT_CONTEXT_TIMEOUT_MS, + ); + if (context) { + const stack = (context.stack ?? []).map(toStackFrame); + return { + ...base, + selector: context.selector, + htmlPreview: context.htmlPreview ?? "", + componentName: context.componentName, + source: stack[0] ?? null, + stack, + styles: context.styles ?? "", + }; + } } catch { - return null; + // Fall through to the DOM-only payload. } + return { + ...base, + selector: null, + htmlPreview: element.outerHTML.slice(0, HTML_PREVIEW_MAX_CHARS), + componentName: null, + source: null, + stack: [], + styles: "", + }; } function createButton(label: string, title: string): HTMLButtonElement { @@ -1225,12 +1269,20 @@ function startAnnotation(): void { pendingCapture = true; submit.disabled = true; submit.textContent = "Capturing…"; + // Snapshot everything the annotation will carry before the capture runs. + // The element context lookup can take up to its timeout, and the user can + // keep editing meanwhile; the annotation must describe what they submitted. + const submittedComment = comment.value.trim(); + const submittedRegions = [...regions]; + const submittedStrokes = [...strokes]; + const submittedStyleChanges = Array.from(styleChanges.values(), (change) => ({ ...change })); void Promise.all( Array.from(selected.values()).map(async (target) => { const element = await captureElement(target.element); - if (!element) return null; - for (const change of styleChanges.values()) { - if (change.targetId === target.id) change.selector = element.selector; + for (const change of submittedStyleChanges) { + if (change.targetId === target.id && element.selector !== null) { + change.selector = element.selector; + } } return { id: target.id, @@ -1238,30 +1290,39 @@ function startAnnotation(): void { rect: rectFromDomRect(target.element.getBoundingClientRect()), }; }), - ).then((captured) => { - const elements = captured.filter((target) => target !== null); - const annotation: PreviewAnnotationPayload = { - id: nextId("annotation"), - pageUrl: location.href, - pageTitle: document.title?.trim() || null, - comment: comment.value.trim(), - elements, - regions: [...regions], - strokes: [...strokes], - styleChanges: Array.from(styleChanges.values()), - screenshot: null, - createdAt: new Date().toISOString(), - }; - editor.style.display = "none"; - toolbar.style.display = "none"; - hoverOutline.style.display = "none"; - const screenshotRect = unionRects([ - ...elements.map((target) => target.rect), - ...regions.map((region) => region.rect), - ...strokes.map((stroke) => stroke.bounds), - ]); - ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect, submission); - }); + ) + .then((elements) => { + // The overlay may have been cancelled or replaced while the capture + // ran. A late submit must not deliver into the next pick's listener. + if (finished) return; + const annotation: PreviewAnnotationPayload = { + id: nextId("annotation"), + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + comment: submittedComment, + elements, + regions: submittedRegions, + strokes: submittedStrokes, + styleChanges: submittedStyleChanges, + screenshot: null, + createdAt: new Date().toISOString(), + }; + editor.style.display = "none"; + toolbar.style.display = "none"; + hoverOutline.style.display = "none"; + const screenshotRect = unionRects([ + ...elements.map((target) => target.rect), + ...submittedRegions.map((region) => region.rect), + ...submittedStrokes.map((stroke) => stroke.bounds), + ]); + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect, submission); + }) + .catch(() => { + // Last resort. Main is waiting on this message, so hand it an empty + // pick rather than leaving the button stuck on "Capturing…" and the + // renderer's pick promise pending. teardown is a no-op once finished. + teardown(true); + }); }; submit.addEventListener("click", () => submitAnnotation("attach")); root.addEventListener("keydown", (event) => { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 97f4ca85c506..28cce3cfb507 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -28,6 +28,8 @@ const clientSettings: ClientSettings = { confirmThreadUnpin: false, continueThreadsAfterServerUpdate: true, contextWindowMeterEnabled: false, + composerCollapseOnBlur: false, + composerCollapseOnScroll: true, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffLayout: "stacked", diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index fb12be2162c1..c4bf2f34b0a1 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -368,14 +368,14 @@ describe("makeQuitShortcutHandler", () => { expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); }); - it("does not treat two quick presses as a quit in hold mode", async () => { + it("quits on a quick second press in hold mode", async () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); await harness.send(makeInput({})); - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("cancels the hold when another key interrupts it", async () => { diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index 7088f4f28ce8..4095e3d4354b 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -181,11 +181,9 @@ export function makeQuitShortcutHandler( quitNow(); return; } - if ( - resolvedMode === "double-click" && - previousPressAt !== 0 && - now - previousPressAt <= QUIT_DOUBLE_PRESS_MS - ) { + // Keep a second press as an escape hatch when macOS misses the events + // that would complete a hold. + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) { quitNow(); return; } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 9f25204f1630..ce74cf58e0a3 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -14,18 +14,20 @@ export default defineConfig({ run: { tasks: { build: { - command: "node scripts/build-preview-annotation-css.mjs && vp pack", + command: + "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && vp pack", dependsOn: ["t3#build"], cache: false, }, dev: { command: - "node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", + "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", dependsOn: ["t3#build"], cache: false, }, "dev:bundle": { - command: "node scripts/build-preview-annotation-css.mjs && vp pack --watch", + command: + "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && vp pack --watch", cache: false, }, "dev:electron": { diff --git a/apps/marketing/public/app-desktop.webp b/apps/marketing/public/app-desktop.webp new file mode 100644 index 000000000000..11b51331eef3 Binary files /dev/null and b/apps/marketing/public/app-desktop.webp differ diff --git a/apps/marketing/public/harnesses/antigravity.png b/apps/marketing/public/harnesses/antigravity.png new file mode 100644 index 000000000000..df1e22dbbd21 Binary files /dev/null and b/apps/marketing/public/harnesses/antigravity.png differ diff --git a/apps/marketing/public/updated-screenshot.webp b/apps/marketing/public/updated-screenshot.webp deleted file mode 100644 index c245ddb64a1a..000000000000 Binary files a/apps/marketing/public/updated-screenshot.webp and /dev/null differ diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index f5e34d0e0485..686b555fd4c0 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -14,7 +14,7 @@ interface Props { const { title = "T3 Code", - description = "T3 Code — The open-source control plane for coding agents.", + description = "T3 Code. The open-source control plane for coding agents.", pageClass, } = Astro.props; --- @@ -268,21 +268,17 @@ const { } } - @keyframes pulse { - 50% { opacity: 0.4; } + /* Page-load sequence: [data-rise] plays once with delay --d. */ + @keyframes rise { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: none; } } - - @keyframes spin { - to { transform: rotate(360deg); } - } - - @keyframes floatDrift { - 0%, 100% { translate: 0 0; } - 50% { translate: 0 -10px; } + [data-rise] { + animation: rise 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both; + animation-delay: var(--d, 0ms); } - - @keyframes blink { - 50% { opacity: 0; } + @media (prefers-reduced-motion: reduce) { + [data-rise] { animation: none; } } diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts index 0bf89db8c0f2..4d0c6da86c43 100644 --- a/apps/marketing/src/lib/site.ts +++ b/apps/marketing/src/lib/site.ts @@ -7,6 +7,6 @@ export const ANDROID_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=com.t3tools.t3code"; export const MARKETING_STATS = { - githubStars: "14k+", - users: "100,000", + githubStars: "21k+", + users: "200,000", } as const; diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index e45cb7602873..6b45f1a1bbc3 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -26,34 +26,31 @@ const mobileEndorsementRows = [ +
+
+
+
Antigravity
+
Google sign-in
+
+
- + diff --git a/apps/web/src/bootstrap.test.ts b/apps/web/src/bootstrap.test.ts new file mode 100644 index 000000000000..c5c0d89597aa --- /dev/null +++ b/apps/web/src/bootstrap.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { showBootError } from "./lib/bootError"; + +class BootElement extends EventTarget { + children: BootElement[] = []; + textContent = ""; + + constructor(readonly tagName: string) { + super(); + } + + setAttribute() {} + + append(child: BootElement) { + this.children.push(child); + } + + replaceChildren(...children: BootElement[]) { + this.children = children; + } + + get text(): string { + return this.textContent + this.children.map((child) => child.text).join(" "); + } +} + +describe("app startup failures", () => { + let bootShell: BootElement | null; + + beforeEach(() => { + vi.resetModules(); + bootShell = new BootElement("div"); + vi.stubGlobal("document", { + getElementById: () => bootShell, + createElement: (tagName: string) => new BootElement(tagName), + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("shows failures from asynchronous app startup", async () => { + vi.doMock("./main", () => ({ startup: Promise.reject(new Error("Startup chunks failed")) })); + + await import("./bootstrap"); + await vi.dynamicImportSettled(); + + expect(bootShell?.text).toContain("Startup chunks failed"); + }); + + afterEach(() => { + vi.doUnmock("./main"); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("replaces the splash when an app import throws before main can run", async () => { + vi.doMock("./main", () => { + throw new Error("@vitejs/plugin-react can't detect preamble. Something is wrong."); + }); + const reload = vi.fn(); + vi.stubGlobal("window", { location: { reload } }); + + await import("./bootstrap"); + await vi.dynamicImportSettled(); + + expect(bootShell?.text).toContain("T3 Code could not load."); + const reloadButton = bootShell?.children[0]?.children.find( + (element) => element.tagName === "button", + ); + expect(reloadButton?.text).toBe("Reload"); + reloadButton?.dispatchEvent(new Event("click")); + expect(reload).toHaveBeenCalledOnce(); + }); + + it.each([true, false])("shows startup error details only in dev mode, DEV=%s", (dev) => { + vi.stubEnv("DEV", dev); + + showBootError(new Error("internal module path")); + + expect(bootShell?.text).toContain("T3 Code could not load."); + expect(bootShell?.text.includes("internal module path")).toBe(dev); + }); + + it("does not replace the app after React removes the splash", () => { + bootShell = null; + const createElement = vi.spyOn(document, "createElement"); + + showBootError(new Error("late failure")); + + expect(createElement).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/bootstrap.ts b/apps/web/src/bootstrap.ts new file mode 100644 index 000000000000..9a3d4a03b3ca --- /dev/null +++ b/apps/web/src/bootstrap.ts @@ -0,0 +1,5 @@ +import { showBootError } from "./lib/bootError"; + +// Bundled dev can move UI code into shared chunks. Load it only after this +// entry runs the React refresh preamble, and catch failures before React mounts. +void import("./main").then(({ startup }) => startup).catch(showBootError); diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index a9d3f541ff19..3de3ed586cb0 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -8,6 +8,7 @@ export function BrowserSurfaceSlot(props: { readonly tabId: string; readonly visible: boolean; readonly cornerRadius?: number; + readonly zIndex?: number; readonly layoutVersion?: string | number; readonly className?: string; readonly fitSourceContent?: boolean; @@ -16,12 +17,13 @@ export function BrowserSurfaceSlot(props: { tabId, visible, cornerRadius = 0, + zIndex = 30, layoutVersion, className, fitSourceContent = false, } = props; const elementRef = useRef(null); - const presentationRef = useRef({ visible, cornerRadius }); + const presentationRef = useRef({ visible, cornerRadius, zIndex }); const updateRef = useRef<(() => void) | null>(null); useLayoutEffect(() => { @@ -40,6 +42,7 @@ export function BrowserSurfaceSlot(props: { }, presentation.visible && rect.width > 0 && rect.height > 0, presentation.cornerRadius, + presentation.zIndex, ); if (presentation.visible && !presented) { lease.release(); @@ -53,6 +56,7 @@ export function BrowserSurfaceSlot(props: { }, rect.width > 0 && rect.height > 0, presentation.cornerRadius, + presentation.zIndex, ); } }; @@ -72,9 +76,9 @@ export function BrowserSurfaceSlot(props: { }, [fitSourceContent, tabId]); useLayoutEffect(() => { - presentationRef.current = { visible, cornerRadius }; + presentationRef.current = { visible, cornerRadius, zIndex }; updateRef.current?.(); - }, [cornerRadius, layoutVersion, visible]); + }, [cornerRadius, layoutVersion, visible, zIndex]); return
; } diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 564a2453b2be..0f01960ce52b 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -83,6 +83,7 @@ export function HostedBrowserWebview(props: { fittedSourceContent: current?.fittedSourceContent ?? null, rect: resolveBrowserSurfacePanelRect(state.byTabId, runtimeTabId), visible: current?.visible ?? false, + zIndex: current?.zIndex ?? 30, }; }), ); @@ -259,6 +260,7 @@ export function HostedBrowserWebview(props: { // suspend them, and automation continues to see the macOS guests as inactive. keepPaintableWhenInactive: isMacPlatform(navigator.platform), cornerRadius: presentation.cornerRadius, + zIndex: presentation.zIndex, rect: lastRect, hiddenSize, }); @@ -315,7 +317,7 @@ export function HostedBrowserWebview(props: { } aria-hidden={active ? undefined : true} className={cn( - "absolute flex overflow-hidden bg-background", + "absolute flex overflow-hidden bg-white", active && !layout.fillsPanel && "ring-1 ring-border/70 shadow-sm", )} style={{ diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index 249d3dcb2f44..456377a4d641 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -107,6 +107,7 @@ describe("browserSurfaceStore", () => { hidden: { rect: staleRect, visible: false, + zIndex: 30, content: null, fittedSourceContent: null, fitSourceContent: false, @@ -117,6 +118,7 @@ describe("browserSurfaceStore", () => { active: { rect: liveRect, visible: true, + zIndex: 30, content: null, fittedSourceContent: null, fitSourceContent: false, @@ -162,6 +164,17 @@ describe("browserSurfaceStore", () => { }); }); + it("keeps the requested layer with the active surface lease", () => { + const tabId = "layered-browser-surface"; + const lease = acquireBrowserSurface(tabId); + lease.present({ x: 10, y: 20, width: 320, height: 200 }, true, 12, 48); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ + visible: true, + zIndex: 48, + }); + }); + it("clears fitted presentation state when its lease is released", () => { const tabId = "released-fitted-browser-surface"; const fittedLease = acquireBrowserSurface(tabId, true); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index fe85c9e38b21..a49154ed8def 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -10,6 +10,7 @@ export interface BrowserSurfaceRect { export interface BrowserSurfacePresentation { readonly rect: BrowserSurfaceRect | null; readonly visible: boolean; + readonly zIndex: number; readonly content: BrowserSurfaceContentPresentation | null; readonly fittedSourceContent: BrowserSurfaceContentPresentation | null; readonly fitSourceContent: boolean; @@ -39,13 +40,19 @@ interface BrowserSurfaceStoreState { rect: BrowserSurfaceRect, visible: boolean, cornerRadius: number, + zIndex: number, ) => void; readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; readonly release: (tabId: string, owner: symbol) => void; } export interface BrowserSurfaceLease { - readonly present: (rect: BrowserSurfaceRect, visible: boolean, cornerRadius?: number) => boolean; + readonly present: ( + rect: BrowserSurfaceRect, + visible: boolean, + cornerRadius?: number, + zIndex?: number, + ) => boolean; readonly release: () => void; } @@ -97,6 +104,7 @@ export const useBrowserSurfaceStore = create()((set) = [tabId]: { rect: current?.rect ?? null, visible: false, + zIndex: current?.zIndex ?? 30, content: current?.content ?? null, fittedSourceContent: fitSourceContent ? (current?.content ?? null) : null, fitSourceContent, @@ -107,7 +115,7 @@ export const useBrowserSurfaceStore = create()((set) = }, }; }), - present: (tabId, owner, rect, visible, cornerRadius) => + present: (tabId, owner, rect, visible, cornerRadius, zIndex) => set((state) => { const current = state.byTabId[tabId]; if (current?.owner !== owner) return state; @@ -115,6 +123,7 @@ export const useBrowserSurfaceStore = create()((set) = current && current.visible === visible && current.cornerRadius === cornerRadius && + current.zIndex === zIndex && rectEquals(current.rect, rect) ) { return state; @@ -122,7 +131,7 @@ export const useBrowserSurfaceStore = create()((set) = return { byTabId: { ...state.byTabId, - [tabId]: { ...current, rect, visible, cornerRadius, updatedAt: Date.now() }, + [tabId]: { ...current, rect, visible, cornerRadius, zIndex, updatedAt: Date.now() }, }, }; }), @@ -136,6 +145,7 @@ export const useBrowserSurfaceStore = create()((set) = [tabId]: { rect: null, visible: false, + zIndex: 30, content, fittedSourceContent: null, fitSourceContent: false, @@ -206,10 +216,10 @@ export function acquireBrowserSurface( useBrowserSurfaceStore.getState().claim(tabId, owner, fitSourceContent); return { - present: (rect, visible, cornerRadius = 0) => { + present: (rect, visible, cornerRadius = 0, zIndex = 30) => { if (released) return false; if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner !== owner) return false; - useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius); + useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius, zIndex); return true; }, release: () => { diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index cbce157f9a05..c2b3432402ed 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -25,7 +25,7 @@ describe("browser target resolver", () => { }); }); - it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => { + it("preserves explicit loopback URL navigation for a remote Tailscale environment", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -35,13 +35,29 @@ describe("browser target resolver", () => { }), ).toEqual({ requestedUrl: "http://localhost:5173/dashboard?mode=test#results", - resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results", - resolutionKind: "direct-private-network", + resolvedUrl: "http://localhost:5173/dashboard?mode=test#results", + resolutionKind: "direct", environmentId: "environment-1", }); }); - it("preserves URL credentials when mapping localhost onto a remote host", async () => { + it("preserves explicit IPv4 loopback URL navigation for a private network environment", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.50:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://127.0.0.1:5999/", + }), + ).toEqual({ + requestedUrl: "http://127.0.0.1:5999/", + resolvedUrl: "http://127.0.0.1:5999/", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + + it("preserves URL credentials on explicit loopback navigation", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -49,10 +65,10 @@ describe("browser target resolver", () => { kind: "url", url: "http://user:p%40ss@localhost:5173/dashboard", }).resolvedUrl, - ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); + ).toBe("http://user:p%40ss@localhost:5173/dashboard"); }); - it("maps credentialed localhost URLs onto private IPv6 hosts", async () => { + it("preserves credentialed loopback URLs for private IPv6 environments", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", }); @@ -62,10 +78,10 @@ describe("browser target resolver", () => { kind: "url", url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", }).resolvedUrl, - ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results"); + ).toBe("http://user:p%40ss@localhost:5173/dashboard?mode=test#results"); }); - it("maps schemeless localhost navigation onto a remote environment host", async () => { + it("preserves schemeless localhost navigation for a remote environment", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -73,7 +89,7 @@ describe("browser target resolver", () => { kind: "url", url: "localhost:3000/app", }).resolvedUrl, - ).toBe("http://192.168.1.25:3000/app"); + ).toBe("localhost:3000/app"); }); it("keeps localhost navigation local for a local environment", async () => { @@ -117,12 +133,12 @@ describe("browser target resolver", () => { port: 5173, }), ).toThrow(/authenticated preview gateway/); - expect(() => + expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { kind: "url", url: "http://localhost:5173", }), - ).toThrow(/authenticated preview gateway/); + ).toMatchObject({ resolvedUrl: "http://localhost:5173", resolutionKind: "direct" }); }); it("normalizes schemeless localhost server-picker values", async () => { @@ -136,6 +152,14 @@ describe("browser target resolver", () => { ).toBe("http://localhost:3000/app"); }); + it("maps discovered loopback servers onto a remote environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:3000/app"), + ).toBe("http://192.168.1.25:3000/app"); + }); + it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 684247e28022..c06c60b5f740 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -207,30 +207,6 @@ export function resolveBrowserNavigationTarget( target: BrowserNavigationTarget, ): PreviewUrlResolution { if (target.kind === "url") { - let parsed: URL | null = null; - try { - parsed = new URL(normalizePreviewUrl(target.url)); - } catch { - // Preserve the existing direct-navigation behavior so the preview host - // reports malformed URL errors through its normal navigation path. - } - if (parsed && isLoopbackHost(parsed.hostname)) { - const environmentUrl = readEnvironmentUrl(environmentId); - if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { - return resolveEnvironmentPortTarget( - environmentId, - { - kind: "environment-port", - port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), - protocol: parsed.protocol === "https:" ? "https" : "http", - path: `${parsed.pathname}${parsed.search}${parsed.hash}`, - }, - environmentUrl, - target.url, - parsed, - ); - } - } return { requestedUrl: target.url, resolvedUrl: target.url, @@ -244,10 +220,20 @@ export function resolveBrowserNavigationTarget( export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); - return resolveBrowserNavigationTarget(environmentId, { - kind: "url", - url: normalizedUrl, - }).resolvedUrl; + const parsed = new URL(normalizedUrl); + if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; + return resolveEnvironmentPortTarget( + environmentId, + { + kind: "environment-port", + port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, + readEnvironmentUrl(environmentId), + rawUrl, + parsed, + ).resolvedUrl; } catch { return rawUrl; } diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts index 69216796af9f..831167095fa1 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -30,6 +30,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { active: true, renderingActive: true, cornerRadius: 12, + zIndex: 48, rect: { x: 12, y: 34, width: 360, height: 203 }, hiddenSize: { width: 1280, height: 800 }, }), @@ -39,6 +40,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { width: 360, height: 203, borderRadius: 12, + zIndex: 48, }); }); diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index a59a4a8b0083..5bdf9b7c4f6d 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -23,6 +23,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly renderingActive: boolean; readonly keepPaintableWhenInactive?: boolean; readonly cornerRadius?: number; + readonly zIndex?: number; readonly rect: BrowserSurfaceRect | null; readonly hiddenSize: HostedBrowserWebviewSize; }): HostedBrowserWebviewWrapperStyle { @@ -33,6 +34,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { keepPaintableWhenInactive = false, rect, renderingActive, + zIndex = 30, } = input; if (active && rect) { return { @@ -40,7 +42,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { top: rect.y, width: rect.width, height: rect.height, - zIndex: 30, + zIndex, pointerEvents: "auto", ...(cornerRadius > 0 ? { borderRadius: cornerRadius } : {}), }; diff --git a/apps/web/src/bundledDev.test.ts b/apps/web/src/bundledDev.test.ts new file mode 100644 index 000000000000..599b45d22524 --- /dev/null +++ b/apps/web/src/bundledDev.test.ts @@ -0,0 +1,228 @@ +// @effect-diagnostics nodeBuiltinImport:off - builds and executes real dev bundles on disk. +import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeUtil from "node:util"; + +import react from "@vitejs/plugin-react"; +import { createLogger, createServer } from "vite-plus"; +import { expect, it } from "vite-plus/test"; + +import { tailwindPlugins } from "../vite/tailwind"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); + +it("initializes React refresh before a shared UI chunk runs in bundled dev", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-bootstrap-")); + const output = NodePath.join(root, "output"); + let resolveBundle!: (files: Map) => void; + let rejectBundle!: (error: unknown) => void; + const bundled = new Promise>((resolve, reject) => { + resolveBundle = resolve; + rejectBundle = reject; + }); + let server: Awaited> | undefined; + + try { + await NodeFSP.mkdir(NodePath.join(root, "src/lib"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(root, "package.json"), '{"type":"module"}'); + for (const file of ["index.html", "src/bootstrap.ts", "src/lib/bootError.ts"]) { + await NodeFSP.copyFile(new URL(`../${file}`, import.meta.url), NodePath.join(root, file)); + } + await NodeFSP.writeFile( + NodePath.join(root, "src/shared.tsx"), + "export function Shared() { return
ready
; }", + ); + await NodeFSP.writeFile( + NodePath.join(root, "src/main.tsx"), + `import { Shared } from "./shared"; +export const startup = Promise.resolve().then(() => globalThis.onStarted(Shared()));`, + ); + + server = await createServer({ + configFile: false, + root, + publicDir: NodeURL.fileURLToPath(new URL("../public", import.meta.url)), + logLevel: "silent", + resolve: { + alias: { react: NodePath.dirname(NodeURL.fileURLToPath(import.meta.resolve("react"))) }, + }, + experimental: { bundledDev: true }, + plugins: [ + react(), + { + name: "capture-bootstrap-bundle", + buildEnd(error) { + if (error) rejectBundle(error); + }, + generateBundle(_options, bundle) { + resolveBundle( + new Map( + Object.values(bundle) + .filter((file) => file.type === "chunk") + .map((file) => [file.fileName, file.code]), + ), + ); + }, + }, + ], + build: { + rolldownOptions: { + experimental: { devMode: { lazy: false } }, + output: { + // Reproduce the shared chunks Vite creates after lazy routes load, + // without needing a browser to trigger the lazy compiler first. + codeSplitting: { + groups: [ + { name: "vendor", test: /node_modules|@react-refresh/, priority: 10 }, + { name: "shared-ui", test: /shared\.tsx$/, includeDependenciesRecursively: false }, + ], + }, + }, + }, + }, + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + for (const [file, code] of await bundled) { + const target = NodePath.join(output, file); + await NodeFSP.mkdir(NodePath.dirname(target), { recursive: true }); + await NodeFSP.writeFile(target, code); + } + + // Run the actual generated ES modules so their import order and refresh + // checks execute. These stubs replace only the browser and HMR transport. + const runner = NodePath.join(output, "check.mjs"); + await NodeFSP.writeFile( + runner, + `import assert from "node:assert/strict"; +const started = Promise.withResolvers(); +globalThis.window = globalThis; +globalThis.document = { + createElement: () => ({ relList: { supports: () => true } }), + getElementById: () => null, +}; +globalThis.__rolldown_runtime__ = { + registerGraph() {}, + registerModule() {}, + createModuleHotContext: () => ({ accept() {} }), +}; +globalThis.onStarted = started.resolve; +console.error = (_message, error) => started.reject(error); +await import("./assets/index.js"); +const element = await started.promise; +assert.equal(element.props.children, "ready"); +assert.equal(typeof window.$RefreshReg$, "function"); +console.log("App started with React refresh ready.");`, + ); + const result = await execFile("node", [runner]); + expect(result.stdout).toContain("App started with React refresh ready."); + } finally { + await server?.close(); + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); + +it("hot updates Tailwind classes when a source file changes in bundled dev", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tailwind-")); + const events = new NodeEvents.EventEmitter(); + let server: Awaited> | undefined; + let socket: WebSocket | undefined; + let css = ""; + + try { + await NodeFSP.writeFile( + NodePath.join(root, "index.html"), + '', + ); + const source = + 'import "./style.css"; export const margin = "m-[13px]"; import.meta.hot?.accept();'; + await NodeFSP.writeFile(NodePath.join(root, "main.ts"), source); + await NodeFSP.writeFile( + NodePath.join(root, "style.css"), + '@import "tailwindcss" source(none); @source "./main.ts";', + ); + const logger = createLogger("silent"); + logger.error = (message) => events.emit("error", new Error(message)); + const connected = NodeEvents.EventEmitter.once(events, "connected"); + const ready = NodeEvents.EventEmitter.once(events, "ready"); + server = await createServer({ + configFile: false, + root, + customLogger: logger, + resolve: { + alias: { + tailwindcss: NodeURL.fileURLToPath( + new URL("../node_modules/tailwindcss/index.css", import.meta.url), + ), + }, + }, + experimental: { bundledDev: true }, + plugins: [ + ...tailwindPlugins(true), + { + name: "observe-tailwind-output", + enforce: "pre", + transform(code, id) { + if (id.endsWith("/style.css")) css = code; + }, + async generateBundle() { + // Keep the build pending until the socket can receive Vite's ready message. + await connected; + }, + }, + ], + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + const address = server.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not bind a port"); + + server.ws.on("vite:client-connected", () => events.emit("connected")); + socket = new WebSocket( + `ws://127.0.0.1:${address.port}/?token=${server.config.webSocketToken}`, + "vite-hmr", + ); + socket.addEventListener("open", () => { + socket?.send( + JSON.stringify({ + type: "custom", + event: "vite:client-connected", + data: { clientId: "tailwind-test" }, + }), + ); + }); + socket.addEventListener("message", ({ data }) => { + const message: unknown = JSON.parse(String(data)); + if (message !== null && typeof message === "object" && "type" in message) { + // generateBundle runs before Vite stores the files for HTTP requests. + if ( + message.type === "full-reload" && + "ifFallback" in message && + message.ifFallback === true + ) { + events.emit("ready"); + } else if (message.type === "bundled-dev-update") { + events.emit("updated"); + } + } + }); + await connected; + await ready; + const entry = await fetch(`http://127.0.0.1:${address.port}/assets/index.js`); + expect(entry.headers.get("content-type")).toContain("javascript"); + await entry.text(); + + const updated = NodeEvents.EventEmitter.once(events, "updated"); + await NodeFSP.writeFile(NodePath.join(root, "main.ts"), source.replace("13px", "137px")); + await updated; + expect(css).toContain("margin: 137px"); + } finally { + socket?.close(); + await server?.close(); + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 62044a8659d7..459506efc78c 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -140,6 +140,8 @@ function agentActivityText(agent: RuntimeSubagent): string | null { /** Flat, non-interactive agent status line. No unfold. */ function AgentRow({ agent }: { agent: RuntimeSubagent }) { const visuals = STATUS_VISUALS[agent.status]; + const statusLabel = + agent.kind === "subagent_batch" && agent.status === "idle" ? "Idle" : visuals.label; const activity = agentActivityText(agent); const modelLabel = formatSubagentModelLabel(agent.model, agent.effort); const role = @@ -180,12 +182,12 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) { agent.status === "failed" ? "text-destructive-foreground" : "text-muted-foreground", )} > - {activity ?? visuals.label} + {activity ?? statusLabel} {metadata.join(" · ")} - {visuals.label} + {statusLabel}
); } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 1780c8b9acb8..3bef170bcf51 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,8 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { - lazy, - Suspense, useEffect, useState, useSyncExternalStore, @@ -20,6 +18,7 @@ import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../ho import { usePanelAnimationSettings } from "../panelAnimations"; import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; +import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { resolveSidebarStageFocusRingOffsetClass, @@ -45,14 +44,6 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; -// The settings nav (and the Clerk profile surfaces behind it) only renders on -// settings routes; lazy-loading it keeps that subtree out of the startup chunk. -const SettingsSidebarNav = lazy(() => - import("./settings/SettingsSidebarNav").then((module) => ({ - default: module.SettingsSidebarNav, - })), -); - function subscribeToViewportWidth(onChange: () => void): () => void { window.addEventListener("resize", onChange); return () => window.removeEventListener("resize", onChange); @@ -247,9 +238,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {isOnSettings ? ( <> - - - + ) : legacySidebarEnabled ? ( diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 0496bef06ef6..07407bcf21b3 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -40,6 +40,7 @@ import { } from "./ui/menu"; import { Separator } from "./ui/separator"; import { ComposerSurface } from "./chat/ComposerSurface"; +import { composerFloatingLayerProps } from "./chat/composerEventScope"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; @@ -160,7 +161,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {triggerContent} - + {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( <> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 67f6cbe7b9c0..27bf2ede9b9a 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -34,6 +34,7 @@ import { vcsEnvironment } from "../state/vcs"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; +import { composerFloatingLayerProps } from "./chat/composerEventScope"; import { deriveLocalBranchNameFromRemoteRef, resolveBranchTriggerLabel, @@ -744,7 +745,11 @@ export function BranchToolbarBranchSelector({ /> } > - + - +
- + Workspace diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 6304e37cf88d..fabda55688bc 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -3,6 +3,7 @@ import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { composerFloatingLayerProps } from "./chat/composerEventScope"; import { Select, SelectGroup, @@ -101,7 +102,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir - + Run on {availableEnvironments.map((env) => ( diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 7649d6b50e49..8e9c80641f2b 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -50,6 +50,7 @@ import { shouldReleaseTimelineAnchorForToolActivity, shouldOpenProactivePullRequest, shouldOpenProactiveTurnDiff, + shouldRenderPreviewMiniPlayer, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, @@ -93,6 +94,27 @@ describe("agent browser close confirmation", () => { }); }); +describe("floating browser preview", () => { + it("only hides the duplicate while the same browser is rendered in the panel", () => { + expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false); + expect( + shouldRenderPreviewMiniPlayer("tab-1", { + id: "browser:one", + kind: "preview", + resourceId: "tab-1", + }), + ).toBe(false); + expect( + shouldRenderPreviewMiniPlayer("tab-1", { + id: "browser:two", + kind: "preview", + resourceId: "tab-2", + }), + ).toBe(true); + expect(shouldRenderPreviewMiniPlayer("tab-1", { id: "diff", kind: "diff" })).toBe(true); + }); +}); + describe("proactive panels", () => { it("opens a pull request only after a newly observed link appears", () => { expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false); @@ -1544,6 +1566,42 @@ describe("hasServerAcknowledgedLocalDispatch", () => { expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true); expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true); + expect( + hasServerAcknowledgedLocalDispatch({ + ...common, + latestTurnStartFailureId: "turn-start-failure-1", + }), + ).toBe(true); expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); }); + + it("acknowledges only a new turn-start failure", () => { + const localDispatch = { + ...createLocalDispatchSnapshot(makeThread()), + latestTurnStartFailureId: "turn-start-failure-old", + }; + const common = { + localDispatch, + phase: "ready" as const, + latestTurn: null, + latestUserMessageId: localDispatch.latestUserMessageId, + session: null, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }; + + expect( + hasServerAcknowledgedLocalDispatch({ + ...common, + latestTurnStartFailureId: "turn-start-failure-old", + }), + ).toBe(false); + expect( + hasServerAcknowledgedLocalDispatch({ + ...common, + latestTurnStartFailureId: "turn-start-failure-new", + }), + ).toBe(true); + }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index cef14f240d97..1a6b1b775f41 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -86,6 +86,19 @@ export function agentControlledBrowserCloseConfirmation( ].join("\n"); } +export function shouldRenderPreviewMiniPlayer( + miniPlayerTabId: string | null, + renderedRightPanelSurface: RightPanelSurface | null, +): boolean { + return ( + miniPlayerTabId !== null && + !( + renderedRightPanelSurface?.kind === "preview" && + renderedRightPanelSurface.resourceId === miniPlayerTabId + ) + ); +} + export function shouldOpenProactivePullRequest( previousTargetKey: string | null | undefined, targetKey: string | null, @@ -882,6 +895,24 @@ export interface LocalDispatchSnapshot { latestTurnCompletedAt: string | null; sessionStatus: NonNullable["status"] | null; sessionUpdatedAt: string | null; + latestTurnStartFailureId: string | null; +} + +export function latestTurnStartFailureId( + activeThread: Thread | undefined, + latestUserMessageId: ChatMessage["id"] | null, +): string | null { + if (latestUserMessageId === null) return null; + return ( + activeThread?.activities.findLast((activity) => { + if (activity.kind !== "provider.turn.start.failed") return false; + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as { readonly requestId?: unknown }) + : null; + return payload?.requestId === latestUserMessageId; + })?.id ?? null + ); } export function createLocalDispatchSnapshot( @@ -905,6 +936,7 @@ export function createLocalDispatchSnapshot( latestTurnCompletedAt: latestTurn?.completedAt ?? null, sessionStatus: session?.status ?? null, sessionUpdatedAt: session?.updatedAt ?? null, + latestTurnStartFailureId: latestTurnStartFailureId(activeThread, latestUserMessage?.id ?? null), }; } @@ -916,6 +948,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { session: Thread["session"] | null; hasPendingApproval: boolean; hasPendingUserInput: boolean; + latestTurnStartFailureId?: string | null; threadError: string | null | undefined; }): boolean { if (!input.localDispatch) { @@ -924,6 +957,13 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } + if ( + input.latestTurnStartFailureId !== undefined && + input.latestTurnStartFailureId !== null && + input.latestTurnStartFailureId !== input.localDispatch.latestTurnStartFailureId + ) { + return true; + } if (input.phase === "connecting") { return false; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0d33aec7bc20..6cd83054b8e6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -209,8 +209,8 @@ import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, - sortProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + sortProviderInstanceEntries, } from "../providerInstances"; import { useClientSettings, @@ -298,6 +298,7 @@ import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; +import { resolveComposerTimelineInset } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -330,7 +331,7 @@ import { import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { - hasAvailableClaudeCompactionProvider, + hasAvailableCompactionProvider, hasDismissedResumeCompaction, shouldOfferResumeCompaction, } from "./chat/ContextWindowMeter.logic"; @@ -357,6 +358,7 @@ import { deriveComposerSendState, dismissBranchMismatchForSession, hasEnvironmentReconnectWarningGraceElapsed, + latestTurnStartFailureId, scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -366,6 +368,7 @@ import { shouldShowPlanFollowUpPrompt, shouldOpenProactivePullRequest, shouldOpenProactiveTurnDiff, + shouldRenderPreviewMiniPlayer, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -570,14 +573,24 @@ function eventPathContainsSelector(event: Event, selector: string): boolean { return path.some((target) => target instanceof Element && target.closest(selector)); } -function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { - if (event.defaultPrevented || event.isComposing) return false; - if (event.metaKey || event.ctrlKey || event.altKey) return false; - if (event.key.length !== 1) return false; - +/** + * Whether input that landed outside any editable or interactive element + * should be redirected into the composer. Shared by type-to-focus and + * paste-to-focus so both honour the same surfaces. + */ +function shouldRedirectInputToComposer(event: Event): boolean { + if (event.defaultPrevented) return false; if (eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false; if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; + return true; +} + +function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { + if (event.isComposing) return false; + if (event.metaKey || event.ctrlKey || event.altKey) return false; + if (event.key.length !== 1) return false; + if (!shouldRedirectInputToComposer(event)) return false; // The right-panel surface launcher claims its shortcut letters while it is // visible (data attribute set in RightPanelTabs); those keys open surfaces @@ -590,6 +603,17 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { return true; } +/** + * Plain text pasted with nothing editable focused, such as after the resting + * composer blurred. Files are left to the composer's own paste handler. + */ +function pasteTextToFocusComposer(event: ClipboardEvent): string | null { + if (!event.clipboardData || event.clipboardData.files.length > 0) return null; + if (!shouldRedirectInputToComposer(event)) return null; + const text = event.clipboardData.getData("text/plain"); + return text.length > 0 ? text : null; +} + function formatOutgoingPrompt(params: { provider: ProviderDriverKind; model: string | null; @@ -604,6 +628,11 @@ function formatOutgoingPrompt(params: { const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +function isCompactCommandMessage(message: ChatMessage): boolean { + const text = message.text.trim().toLowerCase(); + return message.role === "user" && text === "/compact" && !message.attachments?.length; +} + type ChatViewProps = | { environmentId: EnvironmentId; @@ -647,6 +676,10 @@ function useLocalDispatchState(input: { (message) => message.role === "user", ); const latestUserMessageId = latestUserMessage?.id ?? null; + const currentTurnStartFailureId = + localDispatch === null + ? null + : latestTurnStartFailureId(input.activeThread, latestUserMessageId); const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); @@ -662,6 +695,7 @@ function useLocalDispatchState(input: { session: input.activeThread?.session ?? null, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, + latestTurnStartFailureId: currentTurnStartFailureId, threadError: input.threadError, }), [ @@ -672,6 +706,7 @@ function useLocalDispatchState(input: { input.phase, input.threadError, latestUserMessageId, + currentTurnStartFailureId, localDispatch, ], ); @@ -1300,6 +1335,8 @@ function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } +const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; + /** * Drops the send-time anchored end space. That space is what holds a sent * message near the top while its turn streams, and it keeps LegendList's @@ -1594,12 +1631,19 @@ function ChatViewContent(props: ChatViewProps) { const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); const composerOverlayHeightRef = useRef(0); + // Space the timeline keeps clear above its end. Tracks the overlay while the + // composer is expanded and holds that height while it rests, so the resting + // composer never exposes rows that its expansion will cover. + const [composerTimelineInset, setComposerTimelineInset] = useState(0); + const composerTimelineInsetRef = useRef(0); + const composerRestingRef = useRef(false); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); const isAtEndRef = useRef(true); const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + const environmentUnavailableSendToastSlotRef = useRef(0); const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); @@ -1843,10 +1887,14 @@ function ChatViewContent(props: ChatViewProps) { panelAnimationDurationMs, ); const rightPanelPresent = rightPanelPresence.present; - const rightPanelControlsInPanel = - rightPanelPresent && (!shouldUseRightPanelSheet || rightPanelOpen); + const rightPanelControlsInPanel = shouldUseRightPanelSheet && rightPanelPresent && rightPanelOpen; + const rightPanelControlsAtRoot = rightPanelPresent && !shouldUseRightPanelSheet; const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; + const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( + activePreviewMiniPlayer?.tabId ?? null, + renderedRightPanelSurface, + ); const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; @@ -1862,20 +1910,10 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { if (!activeThreadRef || !activePreviewMiniPlayer) return; const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]); - const sameTabOpenInPanel = - previewPanelOpen && - activeRightPanelSurface?.kind === "preview" && - activeRightPanelSurface.resourceId === activePreviewMiniPlayer.tabId; - if (!miniTabStillExists || sameTabOpenInPanel) { + if (!miniTabStillExists) { usePreviewMiniPlayerStore.getState().close(activeThreadRef); } - }, [ - activePreviewMiniPlayer, - activePreviewState.sessions, - activeRightPanelSurface, - activeThreadRef, - previewPanelOpen, - ]); + }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef]); const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); @@ -2572,7 +2610,33 @@ function ChatViewContent(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError, }); - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const optimisticCompactionMessage = optimisticUserMessages.at(-1); + const pendingCompactionMessage = + isSendBusy && + optimisticCompactionMessage !== undefined && + isCompactCommandMessage(optimisticCompactionMessage) + ? optimisticCompactionMessage + : activeThread?.messages.findLast(isCompactCommandMessage); + const compactRequestIsActive = + pendingCompactionMessage !== undefined && + (pendingCompactionMessage.createdAt > + (activeLatestTurn?.requestedAt ?? pendingCompactionMessage.createdAt) || + (activeLatestTurn?.state === "running" && + pendingCompactionMessage.createdAt === activeLatestTurn.requestedAt)); + const compactionSettled = + pendingCompactionMessage !== undefined && + (latestTurnStartFailureId(activeThread, pendingCompactionMessage.id) !== null || + activeThread?.activities.some((activity) => { + if (activity.kind !== "context-compaction") return false; + const payload = activity.payload as { readonly requestId?: unknown } | null | undefined; + return payload?.requestId === pendingCompactionMessage.id; + })); + const isCompacting = + (isSendBusy || phase === "connecting" || phase === "running") && + compactRequestIsActive && + !compactionSettled; + const isWorking = + phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint || isCompacting; const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -2942,10 +3006,11 @@ function ChatViewContent(props: ChatViewProps) { }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - const compactionProviderAvailable = useMemo( + const manualCompactionProviderAvailable = useMemo( () => - hasAvailableClaudeCompactionProvider({ + hasAvailableCompactionProvider({ providers: providerInstanceEntries, + driverKind: selectedProvider, instanceId: activeProviderInstanceId, lockedInstanceId: lockedProvider ? (activeThread?.session?.providerInstanceId ?? @@ -2959,6 +3024,7 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.session?.providerInstanceId, lockedProvider, providerInstanceEntries, + selectedProvider, ], ); const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = @@ -4404,11 +4470,11 @@ function ChatViewContent(props: ChatViewProps) { return getAnchoredTurnMetrics({ state, anchorIndex, - composerOverlayHeight, + composerOverlayHeight: composerTimelineInset, anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, }); }, - [composerOverlayHeight], + [composerTimelineInset], ); const timelineRealContentOverflowsViewport = useCallback( (list?: LegendListRef | null) => { @@ -4433,11 +4499,11 @@ function ChatViewContent(props: ChatViewProps) { const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); const visibleScrollLength = Math.max( 0, - (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET, + (state.scrollLength ?? 0) - composerTimelineInset - CHAT_TIMELINE_ANCHOR_OFFSET, ); return realContentBottom > visibleScrollLength; }, - [composerOverlayHeight], + [composerTimelineInset], ); const pageScrollControllerRef = useRef | null>( null, @@ -4909,10 +4975,35 @@ function ChatViewContent(props: ChatViewProps) { composerOverlayHeightRef.current = nextHeight; setComposerOverlayHeight(nextHeight); } + const nextInset = resolveComposerTimelineInset({ + currentInset: composerTimelineInsetRef.current, + overlayHeight: nextHeight, + isResting: composerRestingRef.current, + }); + if (composerTimelineInsetRef.current !== nextInset) { + composerTimelineInsetRef.current = nextInset; + setComposerTimelineInset(nextInset); + } setScrollToEndClearance((currentClearance) => currentClearance === nextHeight ? currentClearance : nextHeight, ); }, []); + // The composer reports its resting flag from a layout effect, which runs + // before this component's own layout effects and before any resize + // observation, so every measurement below sees the flag for its layout. + // Only the flag is stored here: the stored height still belongs to the + // previous layout, and the composer publishes the new layout's height + // itself once it has measured it. + const onComposerRestingChange = useCallback((resting: boolean) => { + composerRestingRef.current = resting; + }, []); + // A held reservation belongs to the previous thread's draft. Rebuild it from + // this thread's overlay so a tall draft elsewhere does not pad this one. + useLayoutEffect(() => { + if (!composerOverlayElement) return; + composerTimelineInsetRef.current = 0; + publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); + }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeight]); useLayoutEffect(() => { if (!composerOverlayElement) return; @@ -5378,12 +5469,16 @@ function ChatViewContent(props: ChatViewProps) { activeThread && activeContextWindow ? `${activeThread.id}:${activeContextWindow.updatedAt}` : null; - const compactDisabled = + const activeThreadHasCompactableConversation = + activeThread?.messages.some( + (message) => message.role === "user" && !isCompactCommandMessage(message), + ) ?? false; + const compactThreadUnavailable = !activeThread || + !activeThreadHasCompactableConversation || !activeProject || !isServerThread || - selectedProvider !== "claudeAgent" || - !compactionProviderAvailable || + !manualCompactionProviderAvailable || isWorking || threadDetailLoading || isPreparingWorktree || @@ -5391,15 +5486,15 @@ function ChatViewContent(props: ChatViewProps) { feedbackUploading || pendingApprovals.length > 0 || pendingUserInputs.length > 0 || - showPlanFollowUpPrompt || - composerHasUnsentContent; + showPlanFollowUpPrompt; + const compactDisabled = compactThreadUnavailable || composerHasUnsentContent; const compactDisabledReason = compactDisabled ? composerHasUnsentContent ? "Send or clear your draft before compacting" : !activeProject ? "Choose a project before compacting" - : !compactionProviderAvailable - ? "Enable a Claude provider before compacting" + : !manualCompactionProviderAvailable + ? "Compaction is unavailable for this provider" : "Compacting is unavailable right now" : null; const resumeCompactionBannerItem = useMemo(() => { @@ -5864,6 +5959,25 @@ function ChatViewContent(props: ChatViewProps) { composerRef, ]); + // Paste-to-focus: the resting composer blurs on a click into the timeline, + // so a paste that follows has no editable target and would be dropped. + // Route it to the composer like a typed key, which also expands it. + useEffect(() => { + const handler = (event: ClipboardEvent) => { + if (!activeThreadId || isCommandPaletteOpen()) return; + if (getTerminalFocusOwner() !== null) return; + if (composerRef.current?.isModelPickerOpen()) return; + const text = pasteTextToFocusComposer(event); + if (text === null) return; + if (composerRef.current?.insertTextAtEnd(text)) { + event.preventDefault(); + event.stopPropagation(); + } + }; + window.addEventListener("paste", handler, true); + return () => window.removeEventListener("paste", handler, true); + }, [activeThreadId, composerRef]); + const onRevertToTurnCount = useCallback( async (turnCount: number) => { const localApi = readLocalApi(); @@ -5963,13 +6077,17 @@ function ChatViewContent(props: ChatViewProps) { return; } if (activeEnvironmentUnavailable) { - toastManager.add( - stackedThreadToast({ + const toastSlot = environmentUnavailableSendToastSlotRef.current; + environmentUnavailableSendToastSlotRef.current = + (toastSlot + 1) % ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE; + toastManager.add({ + ...stackedThreadToast({ type: "warning", title: "Not connected: message not sent", description: "Reconnecting to the environment. Try again once it is connected.", }), - ); + id: `chat-send-environment-unavailable:${toastSlot}`, + }); return; } if (activePendingProgress) { @@ -7562,6 +7680,7 @@ function ChatViewContent(props: ChatViewProps) { return (
+ {rightPanelControlsAtRoot ? panelLayoutControls : null}
- {!shouldUseRightPanelSheet || !rightPanelControlsInPanel ? panelLayoutControls : null} + {isElectron && rightPanelControlsAtRoot ? ( + + ) : null} + {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} - { - setThreadError(activeThread.id, null); - dismissThreadErrorBannerForSession(threadErrorBannerKey); - setThreadErrorBannerDismissTick((tick) => tick + 1); - }} - /> {/* Main content area with optional plan sidebar */}
{/* Chat column */} @@ -7640,13 +7757,21 @@ function ChatViewContent(props: ChatViewProps) {
) : null} - {/* Provider status overlays the timeline without changing its content height. */} -
+ {/* Banners overlay the timeline without changing its content height. */} +
setDismissedProviderStatusBannerKey(providerStatusBannerKey)} onOpenProviderSetup={openProviderSetup} /> + { + setThreadError(activeThread.id, null); + dismissThreadErrorBannerForSession(threadErrorBannerKey); + setThreadErrorBannerDismissTick((tick) => tick + 1); + }} + />
{/* Messages Wrapper */}
@@ -7660,6 +7785,7 @@ function ChatViewContent(props: ChatViewProps) { key={activeThread.id} isWorking={isWorking} isPreparingWorktree={isPreparingWorktree} + isCompacting={isCompacting} activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} @@ -7687,7 +7813,7 @@ function ChatViewContent(props: ChatViewProps) { } anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} - contentInsetEndAdjustment={composerOverlayHeight} + contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} @@ -7819,6 +7945,7 @@ function ChatViewContent(props: ChatViewProps) { } activeThreadModelSelection={activeThread?.modelSelection} activeContextWindow={activeContextWindow} + compactThreadUnavailable={compactThreadUnavailable} compactDisabled={compactDisabled} compactDisabledReason={compactDisabledReason} resolvedTheme={resolvedTheme} @@ -7834,6 +7961,7 @@ function ChatViewContent(props: ChatViewProps) { getTimelineScrollableNode={getTimelineScrollableNode} isTimelineAtLogicalEnd={isTimelineAtLogicalEnd} onComposerOverlayHeightChange={publishComposerOverlayHeight} + onRestingChange={onComposerRestingChange} promptRef={promptRef} composerImagesRef={composerImagesRef} composerFilesRef={composerFilesRef} @@ -7919,7 +8047,7 @@ function ChatViewContent(props: ChatViewProps) {
- {activeThreadRef && activePreviewMiniPlayer ? ( + {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( void; -}>({ openComment: null, onOpenChange: () => {} }); + onSubmitAndSend: () => void; +}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); /** Consume a cite action once its controlled prompt has been committed to the editor. */ export function $consumeComposerCitationCommentRequest(requestRef: { @@ -127,6 +128,11 @@ function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey commentContext.onOpenChange(props.nodeKey, open); }, onSave: onSaveComment, + onSaveAndSend: (comment) => { + if (!onSaveComment(comment)) return false; + commentContext.onSubmitAndSend(); + return true; + }, }} onRemove={onRemove} /> diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 1676b4f01e7d..695c5e3d0858 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -924,6 +924,7 @@ interface ComposerPromptEditorProps { onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; + onCitationSubmitAndSend?: () => void; onPaste: React.ClipboardEventHandler; editorRef: React.RefObject; } @@ -1571,6 +1572,7 @@ function ComposerPromptEditorInner({ onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, + onCitationSubmitAndSend, onPaste, editorRef, }: ComposerPromptEditorProps) { @@ -1603,8 +1605,9 @@ function ComposerPromptEditorInner({ open ? { nodeKey } : current?.nodeKey === nodeKey ? null : current, ); }, + onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), }), - [openCitationComment], + [onCitationSubmitAndSend, openCitationComment], ); const terminalContextActions = useMemo( () => ({ onRemoveTerminalContext }), @@ -1969,6 +1972,7 @@ export function ComposerPromptEditor({ onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, + onCitationSubmitAndSend, onPaste, editorRef, }: ComposerPromptEditorProps) { @@ -2013,6 +2017,7 @@ export function ComposerPromptEditor({ onChange={onChange} {...(onVisibleSelectionChange ? { onVisibleSelectionChange } : {})} onPaste={onPaste} + {...(onCitationSubmitAndSend ? { onCitationSubmitAndSend } : {})} editorRef={editorRef} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} {...(onPageScrollKeyDown ? { onPageScrollKeyDown } : {})} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..edb41868879e 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -653,9 +653,19 @@ export const AntigravityIcon: Icon = (props) => ( export const OpenCodeIcon: Icon = (props) => ( - + - + diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 4c8515246c85..ea910905efe0 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -720,7 +720,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr onContextMenu={handleRowContextMenu} >
- {prStatus && ( + {prStatus && pr && ( event.stopPropagation()} onClick={handlePrClick} > - + } /> @@ -2344,7 +2348,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index 557f4d722adc..bfb5487031b7 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -115,28 +115,40 @@ describe("ProjectFavicon", () => { testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg"; }); - it("shows a project-name emoji when no favicon exists", () => { + it("shows a project-name icon when no favicon exists", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ environmentId: "environment-test" as EnvironmentId, cwd: "/workspace/analytics-db", projectName: "analytics-db", - }) as ReactElement<{ readonly emoji?: string }>; + }) as ReactElement<{ + readonly colorClassName?: string; + readonly emoji?: string; + readonly icon?: ComponentType<{ className?: string }>; + }>; - expect(element.props.emoji).toBe("🗄️"); + expect(element.props.icon).toBeDefined(); + expect(element.props.emoji).toBeUndefined(); + expect(element.props.colorClassName).toContain("text-cyan-600"); }); - it("chooses a deterministic semantic emoji", () => { + it("chooses a deterministic semantic icon", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ environmentId: "environment-test" as EnvironmentId, cwd: "/workspace/agent-runtime", projectName: "agent-runtime", - }) as ReactElement<{ readonly emoji?: string }>; + }) as ReactElement<{ + readonly colorClassName?: string; + readonly emoji?: string; + readonly icon?: ComponentType<{ className?: string }>; + }>; - expect(element.props.emoji).toBe("🤖"); + expect(element.props.icon).toBeDefined(); + expect(element.props.emoji).toBeUndefined(); + expect(element.props.colorClassName).toContain("text-violet-600"); }); it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx index e3468034396b..9f4838f5666a 100644 --- a/apps/web/src/components/RightPanelSheet.tsx +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -1,12 +1,16 @@ import { type ReactNode } from "react"; -import { RIGHT_PANEL_SHEET_CLASS_NAME } from "../rightPanelLayout"; +import { + RIGHT_PANEL_SHEET_CLASS_NAME, + RIGHT_PANEL_SHEET_LAYER_CLASS_NAME, +} from "../rightPanelLayout"; import { Sheet, SheetPopup } from "./ui/sheet"; export function RightPanelSheet(props: { animationDurationMs: number; children: ReactNode; open: boolean; + underFloatingPreview?: boolean; onClose: () => void; }) { return ( @@ -23,6 +27,12 @@ export function RightPanelSheet(props: { side="right" showCloseButton={false} keepMounted + {...(props.underFloatingPreview + ? { + backdropClassName: RIGHT_PANEL_SHEET_LAYER_CLASS_NAME, + viewportClassName: RIGHT_PANEL_SHEET_LAYER_CLASS_NAME, + } + : {})} className={RIGHT_PANEL_SHEET_CLASS_NAME} > {props.children} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5a9356a0fffd..e9dab0c9d2b4 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -9,6 +9,8 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { Bot, ChevronDown, + ChevronLeft, + ChevronRight, FileDiff, Files, GitPullRequest, @@ -170,6 +172,12 @@ type TabContextMenuAction = | "close-to-right" | "close-all"; +const TAB_SCROLL_EDGE_TOLERANCE = 1; + +function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { + return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; +} + /** * Desktop preview tab backing a surface, or null for non-preview surfaces, the * "new browser tab" placeholder, and the web build where no desktop tab exists. @@ -720,6 +728,42 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); + const [tabScrollState, setTabScrollState] = useState({ + hasOverflow: false, + canScrollLeft: false, + canScrollRight: false, + }); + + const updateTabScrollState = useCallback(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE; + const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE; + const canScrollRight = + hasOverflow && + viewport.scrollLeft + viewport.clientWidth < viewport.scrollWidth - TAB_SCROLL_EDGE_TOLERANCE; + setTabScrollState((current) => { + if ( + current.hasOverflow === hasOverflow && + current.canScrollLeft === canScrollLeft && + current.canScrollRight === canScrollRight + ) { + return current; + } + return { hasOverflow, canScrollLeft, canScrollRight }; + }); + }, []); + + const scrollTabs = useCallback((direction: -1 | 1) => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + viewport.scrollBy({ + left: direction * Math.max(120, viewport.clientWidth * 0.75), + behavior: reduceMotion ? "auto" : "smooth", + }); + }, []); const addSurfaceActions = [ { @@ -886,9 +930,49 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ); useEffect(() => { + if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return; const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); - }, [props.activeSurfaceId]); + }, [props.activeSurfaceId, tabScrollState.hasOverflow]); + + useEffect(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const content = viewport.firstElementChild; + const resizeObserver = new ResizeObserver(updateTabScrollState); + resizeObserver.observe(viewport); + if (content) resizeObserver.observe(content); + viewport.addEventListener("scroll", updateTabScrollState, { passive: true }); + updateTabScrollState(); + + return () => { + resizeObserver.disconnect(); + viewport.removeEventListener("scroll", updateTabScrollState); + }; + }, [updateTabScrollState]); + + useEffect(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey) return; + let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16; + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= viewport.clientWidth; + if (delta === 0) return; + + const previousScrollLeft = viewport.scrollLeft; + viewport.scrollLeft += delta; + if (viewport.scrollLeft === previousScrollLeft) return; + event.preventDefault(); + updateTabScrollState(); + }; + + viewport.addEventListener("wheel", handleWheel, { passive: false }); + return () => viewport.removeEventListener("wheel", handleWheel); + }, [updateTabScrollState]); return (
@@ -940,6 +1028,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onContextMenu={(event) => void handleTabContextMenu(event, surface)} className={cn( "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", + ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", active ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", @@ -1099,7 +1188,57 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ) : null}
+ {tabScrollState.hasOverflow ? ( +
+ + + + + } + /> + Scroll tabs left + + + + + + } + /> + Scroll tabs right + +
+ ) : null} {props.layoutControls} + {ownsDesktopTitleBar ? ( + + ) : null}
{props.activeSurfaceId === null ? ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 38a26c7ac4e2..d0ededf6ed83 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -276,6 +276,7 @@ function terminalProcessLabel(count: number): string { function SidebarThreadTooltip({ thread, projectTitle, + projectDisplayName, projectCwd, projectFaviconPath, projectIcon, @@ -291,6 +292,7 @@ function SidebarThreadTooltip({ }: { thread: SidebarThreadSummary; projectTitle: string | null; + projectDisplayName: string | null; projectCwd: string | null; projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; @@ -321,17 +323,17 @@ function SidebarThreadTooltip({ {thread.title}
- {projectTitle ? ( + {projectDisplayName ? (
-
{projectTitle}
+
{projectDisplayName}
) : null} {environmentLabel ? ( @@ -497,6 +499,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { session: DraftSessionState; composer: ComposerThreadDraftState; projectTitle: string | null; + projectDisplayName: string | null; projectCwd: string | null; projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; @@ -571,7 +574,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { className="size-4 shrink-0" /> - {props.projectTitle} + {props.projectDisplayName} @@ -609,6 +612,7 @@ interface SidebarDraftRowData { // subscription + closing divider) so per-keystroke composer updates // re-render only this block, never the whole sidebar. Vanishes at count 0. const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { + projectTitleByKey: ReadonlyMap; projectDisplayNameByKey: ReadonlyMap; projectCwdByKey: ReadonlyMap; projectFaviconPathByKey: ReadonlyMap; @@ -706,7 +710,8 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { draftId={draftId} session={session} composer={composer} - projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null} + projectTitle={props.projectTitleByKey.get(projectKey) ?? null} + projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null} projectCwd={props.projectCwdByKey.get(projectKey) ?? null} projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null} projectIcon={props.projectIconByKey.get(projectKey) ?? null} @@ -759,6 +764,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; projectTitle: string | null; + projectDisplayName: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; @@ -948,7 +954,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { linkedPullRequestStatus, }); const prStatus = prStatusIndicator(pr, prProvider); - const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; + const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined; useEffect(() => { const nextSnapshot = nextThreadChangeRequestSnapshot({ threadBranch: thread.branch, @@ -994,6 +1000,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { - + - {props.projectTitle ? ( + {props.projectDisplayName ? ( - {props.projectTitle} + {props.projectDisplayName} ) : ( @@ -1600,7 +1608,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {thread.branch ? ( <> - {thread.branch} + + {thread.branch} + ) : ( @@ -1669,6 +1679,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; projectTitle: string | null; + projectDisplayName: string | null; environmentLabel: string | null; environmentMachine: EnvironmentMachineKind; providerEntryByInstanceId: ReadonlyMap; @@ -1735,7 +1746,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { aria-selected={props.isHighlighted} aria-current={props.isRouteActive ? "page" : undefined} aria-label={ - props.projectTitle ? `${thread.title}, ${props.projectTitle}` : thread.title + props.projectDisplayName + ? `${thread.title}, ${props.projectDisplayName}` + : thread.title } onMouseMove={props.onHighlight} onClick={props.onSelect} @@ -1751,7 +1764,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { + new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), + [projects], + ); const projectDisplayNameByKey = useMemo( () => new Map( @@ -3631,7 +3651,7 @@ export default function Sidebar() { { + it.each([ + ["open", "open", false, GitPullRequestIcon], + ["draft", "open", true, GitPullRequestDraftIcon], + ["closed", "closed", false, GitPullRequestClosedIcon], + ["merged", "merged", false, GitMergeIcon], + ] as const)("uses the %s pull request glyph", (_label, state, isDraft, expectedIcon) => { + expect(ChangeRequestStatusIcon({ state, isDraft }).type).toBe(expectedIcon); + }); +}); + function status(overrides: Partial = {}): VcsStatusResult { return { isRepo: true, @@ -559,6 +578,18 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { }); expect(displayed?.state).toBe("merged"); }); + + it("refreshes a cached snapshot when a pull request becomes ready", () => { + const readyPr = { ...mergedPr, state: "open" as const }; + const draftPr = { ...readyPr, isDraft: true }; + + expect( + threadChangeRequestSnapshotsEqual( + snapshotFor(featureBranch, draftPr), + snapshotFor(featureBranch, readyPr), + ), + ).toBe(false); + }); }); describe("threadChangeRequestSnapshotsAtom", () => { @@ -600,6 +631,17 @@ describe("prStatusIndicator", () => { "text-red-600", ); }); + + it("uses gray and draft wording for draft pull requests", () => { + const draftPr = status().pr; + if (!draftPr) throw new Error("Expected pull request fixture"); + + expect(prStatusIndicator({ ...draftPr, isDraft: true }, undefined)).toMatchObject({ + label: "PR draft", + colorClass: "text-zinc-500 dark:text-zinc-400/80", + tooltipLead: "PR #42 - Draft", + }); + }); }); describe("settledPrHoverColorClass", () => { @@ -610,4 +652,8 @@ describe("settledPrHoverColorClass", () => { ] as const)("restores the %s pull request color on row hover", (state, colorClass) => { expect(settledPrHoverColorClass(state)).toContain(`group-hover/v2-row:${colorClass}`); }); + + it("keeps draft pull requests gray on row hover", () => { + expect(settledPrHoverColorClass("open", true)).toContain("group-hover/v2-row:text-zinc-500"); + }); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 1ad0c3139fd8..e7b24f80cb71 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -11,7 +11,7 @@ import { type VcsStatusResult, } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; +import { FolderGit2Icon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; @@ -24,6 +24,7 @@ import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; +import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import type { SidebarThreadSummary } from "../types"; import { formatWorktreePathForDisplay } from "../worktreeCleanup"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -84,9 +85,15 @@ export function useLinkedThreadPullRequest( ); } -export function settledPrHoverColorClass(state: NonNullable["state"]): string { +export function settledPrHoverColorClass( + state: NonNullable["state"], + isDraft = false, +): string { switch (state) { case "open": + if (isDraft) { + return "group-hover/v2-row:text-zinc-500 dark:group-hover/v2-row:text-zinc-400/80"; + } return "group-hover/v2-row:text-emerald-600 dark:group-hover/v2-row:text-emerald-300/90"; case "merged": return "group-hover/v2-row:text-violet-600 dark:group-hover/v2-row:text-violet-300/90"; @@ -99,12 +106,13 @@ export function prStatusIndicator( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): PrStatusIndicator | null { - function formatPrState(state: NonNullable["state"]): string { - return state.charAt(0).toUpperCase() + state.slice(1); + function formatPrState(pr: NonNullable): string { + if (pr.state === "open" && pr.isDraft === true) return "Draft"; + return pr.state.charAt(0).toUpperCase() + pr.state.slice(1); } function formatPrStatusLead(pr: NonNullable, changeRequestShortName: string): string { - return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`; + return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr)}`; } if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); @@ -113,9 +121,12 @@ export function prStatusIndicator( const tooltip = `${tooltipLead}: ${pr.title}`; if (pr.state === "open") { + const isDraft = pr.isDraft === true; return { - label: `${presentation.shortName} open`, - colorClass: "text-emerald-600 dark:text-emerald-300/90", + label: `${presentation.shortName} ${isDraft ? "draft" : "open"}`, + colorClass: isDraft + ? "text-zinc-500 dark:text-zinc-400/80" + : "text-emerald-600 dark:text-emerald-300/90", tooltip, tooltipLead, tooltipTitle: pr.title, @@ -145,8 +156,16 @@ export function prStatusIndicator( return null; } -export function ChangeRequestStatusIcon({ className }: { className?: string }) { - return ; +export function ChangeRequestStatusIcon({ + state, + isDraft = false, + className, +}: Pick, "state"> & { + readonly isDraft?: boolean | undefined; + readonly className?: string | undefined; +}) { + const presentation = resolvePullRequestState({ state, isDraft }); + return ; } export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { @@ -230,6 +249,7 @@ export function threadChangeRequestSnapshotsEqual( left.pr.baseRef === right.pr.baseRef && left.pr.headRef === right.pr.headRef && left.pr.state === right.pr.state && + left.pr.isDraft === right.pr.isDraft && (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) @@ -574,7 +594,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar return ( - {prStatus ? ( + {prStatus && pr ? ( } > - + diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 4afaefe3f489..7582bb19c5c6 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -42,6 +42,7 @@ export function AssistantCitationChip({ sourceAnchor?: AssistantCitationSourceAnchor | undefined; onOpenChange: (open: boolean) => void; onSave: (comment: string) => boolean; + onSaveAndSend?: (comment: string) => boolean; }; }) { const navigate = useNavigate(); @@ -158,6 +159,15 @@ export function AssistantCitationChip({ commentEditor.onOpenChange(false); return true; }} + {...(commentEditor.onSaveAndSend + ? { + onSubmitAndSend: (comment: string) => { + if (!commentEditor.onSaveAndSend?.(comment)) return false; + commentEditor.onOpenChange(false); + return true; + }, + } + : {})} onCancel={() => commentEditor.onOpenChange(false)} /> diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index 3968d4568655..4dc422210de0 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -7,11 +7,13 @@ export function AssistantCitationCommentEditor({ citation, inputRef, onSubmit, + onSubmitAndSend, onCancel, }: { citation: AssistantCitation; inputRef?: Ref; onSubmit: (comment: string) => boolean; + onSubmitAndSend?: (comment: string) => boolean; onCancel: () => void; }) { const [comment, setComment] = useState(citation.comment ?? ""); @@ -19,6 +21,14 @@ export function AssistantCitationCommentEditor({ const submit = () => { if (!commentTooLong) onSubmit(comment); }; + const submitAndSend = () => { + if (commentTooLong) return; + if (onSubmitAndSend) { + onSubmitAndSend(comment); + } else { + onSubmit(comment); + } + }; return (
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 93ae287c702a..0b034d44338c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -60,6 +60,7 @@ import { } from "./composerMentionDrag"; import { composerFloatingLayerProps, + isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope, } from "./composerEventScope"; @@ -86,6 +87,7 @@ import { } from "../../promptStashStore"; import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; +import { useComposerMenuState } from "./useComposerMenuState"; import { ComposerTasksBadge, ComposerTasksContent, @@ -185,7 +187,10 @@ import { renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; -import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; +import { + providerSupportsManualCompaction, + resolveContextWindowModelDisplayName, +} from "./ContextWindowMeter.logic"; import { attachVideoThumbnail, buildExpandedImagePreview, @@ -205,6 +210,7 @@ import { resetComposerScrollGesture, suppressActiveComposerScrollGesture, } from "./composerScrollGesture"; +import { selectionHoldsComposerOpen } from "./composerSelectionHold"; import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; function ComposerVideoThumbnail({ file }: { file: File }) { @@ -245,12 +251,14 @@ const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4; function useComposerRestingTransition( isCollapsed: boolean, + isResting: boolean, restingControlsRef: React.RefObject, onOverlayHeightChange: (height: number) => void, ) { const elementRef = useRef(null); const isCollapsedRef = useRef(isCollapsed); const previousCollapsedRef = useRef(isCollapsed); + const previousRestingRef = useRef(isResting); const previousHeightRef = useRef(null); const previousContentOffsetsRef = useRef<{ promptFromTop: number | null; @@ -349,6 +357,15 @@ function useComposerRestingTransition( const nextRect = element.getBoundingClientRect(); const nextHeight = nextRect.height; + // The chat view resize-observes the overlay to place the timeline + // inset, the scroll-to-end pill, and the mini player. Publishing the + // destination height here turns that feedback into one update instead + // of a ChatView re-render on every animation frame. + const overlay = element.closest('[data-chat-composer-overlay="true"]'); + const overlayHeight = overlay?.getBoundingClientRect().height ?? null; + if (overlayHeight !== null) { + onOverlayHeightChange(overlayHeight); + } const nextPromptRect = prompt?.getBoundingClientRect() ?? null; const nextPromptTop = nextPromptRect?.top ?? null; const nextActionTop = action?.getBoundingClientRect().top ?? null; @@ -379,18 +396,13 @@ function useComposerRestingTransition( element.style.overflow = "clip"; surface.style.height = "100%"; - // The chat view resize-observes the overlay to place the timeline - // inset, the scroll-to-end pill, and the mini player. Pinning the - // overlay at the destination height turns that feedback into one - // update instead of a ChatView re-render on every animation frame; - // bottom alignment keeps the animating surface glued to the overlay's - // stable bottom edge. The pin lasts only for the tween so later - // attachment, thread, font, and viewport changes remain natural. - const overlay = element.closest('[data-chat-composer-overlay="true"]'); - let pinnedOverlayHeight: number | null = null; - if (overlay) { - pinnedOverlayHeight = overlay.getBoundingClientRect().height; - overlay.style.height = `${String(pinnedOverlayHeight)}px`; + // Pinning the overlay at the destination height keeps the resize + // observer quiet for the tween; bottom alignment keeps the animating + // surface glued to the overlay's stable bottom edge. The pin lasts + // only for the tween so later attachment, thread, font, and viewport + // changes remain natural. + if (overlay && overlayHeight !== null) { + overlay.style.height = `${String(overlayHeight)}px`; overlay.style.display = "flex"; overlay.style.flexDirection = "column"; overlay.style.justifyContent = "flex-end"; @@ -424,11 +436,6 @@ function useComposerRestingTransition( ); animationRef.current = animation; animationTargetHeightRef.current = nextHeight; - // Publish the destination overlay geometry in the same layout pass; - // ResizeObserver remains the fallback for non-transition changes. - if (pinnedOverlayHeight !== null) { - onOverlayHeightChange(pinnedOverlayHeight); - } const animatedRect = element.getBoundingClientRect(); const previousPromptTop = @@ -606,6 +613,18 @@ function useComposerRestingTransition( }; }, [isCollapsed, transitionToCurrentGeometry]); + // The resting flag can change while the collapsed layout stays the same, + // for example when an unfocused thread crosses the phone breakpoint. The + // chat view pairs overlay heights with that flag, so republish the natural + // height for the new flag. A transition in flight publishes its own. + useLayoutEffect(() => { + if (previousRestingRef.current === isResting) return; + previousRestingRef.current = isResting; + if (animationRef.current) return; + const overlay = elementRef.current?.closest('[data-chat-composer-overlay="true"]'); + if (overlay) onOverlayHeightChange(overlay.getBoundingClientRect().height); + }, [isResting, onOverlayHeightChange]); + useLayoutEffect(() => { const element = elementRef.current; if (!element || typeof ResizeObserver === "undefined") return; @@ -739,7 +758,7 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: return createPortal(
{ - const next = resolveRestingComposerControlsLayout({ ...measurement, hostWidth }); + const next = resolveRestingComposerControlsLayout({ + ...measurement, + hostWidth, + previous: current, + }); return next.hiddenCount === current.hiddenCount && next.visible === current.visible ? current : next; @@ -902,10 +925,12 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; size?: "sm" | "xs"; + hidden?: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { const size = props.size ?? "sm"; + const [open, setOpen] = useComposerMenuState(props.hidden); const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; const interactionModeTooltip = @@ -963,6 +988,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop onQueryChange(event.currentTarget.value)} - placeholder={searchLabel} - aria-label={searchLabel} - size="compact" - /> + +
+
+
-
+ {isPending ? ( ) : error !== null ? ( @@ -110,18 +154,18 @@ export function PullRequestCandidatePicker({ {query.length > 0 ? noMatchLabel : emptyLabel}

) : ( - candidates.map((candidate) => ( - // Stays open on press: a change is confirmed by the row's own check turning over, - // and a second label or reviewer is usually wanted right after the first. - ( + onSelect(candidate)} className="min-h-0 py-1.5 text-xs sm:min-h-0 sm:text-xs" + contentClassName="flex min-w-0 items-center gap-2" > {children(candidate)} - + )) )} {truncated ? ( @@ -129,8 +173,8 @@ export function PullRequestCandidatePicker({ // list is rather than offering a search that would find nothing further.

{truncatedLabel}

) : null} -
- - + +
+ ); } diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 59aa1333896b..70533f06488f 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -4,6 +4,7 @@ import { type EnvironmentId, type PullRequestAction, type PullRequestMergeMethod, + type PullRequestListEntry, type PullRequestUpdateMethod, type PullRequestRef, type PullRequestState, @@ -63,7 +64,11 @@ import { useProjects } from "~/state/entities"; import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; -import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests"; +import { + pullRequestEnvironment, + usePullRequestTurnRefresh, + useSharedPullRequestSummary, +} from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; @@ -447,6 +452,7 @@ export function PullRequestDetailPanel({ environmentId, threadRef = null, reference, + listEntry = null, refreshToken: forcedRefreshToken = 0, onActed, onClose, @@ -463,6 +469,8 @@ export function PullRequestDetailPanel({ */ threadRef?: ScopedThreadRef | null; reference: PullRequestRef; + /** Row fields already loaded by the pull-request list, used while richer detail arrives. */ + listEntry?: PullRequestListEntry | null; /** * Bumped by whatever holds the panel when a reader asks for everything on screen to be read * again. The panel owns its own reads, so the page cannot refresh them for it — it says when, @@ -491,6 +499,12 @@ export function PullRequestDetailPanel({ composerDraftTarget?: ScopedThreadRef | DraftId; }) { const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; + const matchingListEntry = + listEntry?.projectId === reference.projectId && + listEntry.repository.toLowerCase() === reference.repository.toLowerCase() && + listEntry.number === reference.number + ? listEntry + : null; const [tab, setTab] = useState("summary"); const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); const [codeCommitScope, setCodeCommitScope] = useState<{ @@ -567,6 +581,7 @@ export function PullRequestDetailPanel({ const activityQuery = useEnvironmentQuery( pullRequestEnvironment.activity({ environmentId, input: reference }), ); + const turnRefresh = usePullRequestTurnRefresh(environmentId); const [cachedDetail, setCachedDetail] = useState(() => readPullRequestDetailSnapshot( typeof window === "undefined" ? undefined : window.localStorage, @@ -610,7 +625,13 @@ export function PullRequestDetailPanel({ () => resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail ? resolvedCoreDetail - : { ...resolvedCoreDetail, ...sharedSummary }, + : { + ...resolvedCoreDetail, + ...sharedSummary, + // A summary may come from an older server that does not report draft state. Keep the + // detail's required value instead of making the complete detail shape partial. + isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, + }, [resolvedCoreDetail, sharedSummary], ); const activity = activityQuery.data; @@ -665,6 +686,8 @@ export function PullRequestDetailPanel({ detailQuery.refresh(); activityQuery.refresh(); }, [activityQuery.refresh, detailQuery.refresh]); + const [refreshToken, setRefreshToken] = useState(0); + const codeRefreshToken = refreshToken + (turnRefresh ?? 0); const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>( null, ); @@ -688,15 +711,18 @@ export function PullRequestDetailPanel({ // revision effect above reads it only after this same pull request reports a change. Keyed by // the pull request rather than by the panel, because this one panel shows a different pull // request every time it is opened. - useLiveRefresh(detailQuery.refresh, { - key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}`, - }); + useLiveRefresh( + () => { + detailQuery.refresh(); + setRefreshToken((token) => token + 1); + }, + { key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}` }, + ); // The button, on the other hand, goes around the server's cache rather than through it: it is // the answer for a reader who can see that what they are looking at is behind. The // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run // and at worst answer from it. const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); - const [refreshToken, setRefreshToken] = useState(0); const refreshFromHost = useCallback(async () => { await invalidate({ environmentId, input: { reference } }); refreshDetail(); @@ -1277,7 +1303,7 @@ export function PullRequestDetailPanel({ !conflicting && allowedMergeMethods.length > 1; // The pull request number carries this state in the overview and the right-panel tab mirrors - // it. The conflict action is separate from this state: an open pull request remains green. + // it. Conflicts take the action slot while they need a person, but do not change the PR state. const statePresentation = detail ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) : null; @@ -1296,10 +1322,10 @@ export function PullRequestDetailPanel({ ).length : 0; - // A reopen already has last time's title, author, and counts. Keep them on screen - // and let the live read replace fields — especially the diff counts — in place. + // The list already has the pull request's identity and summary. Keep them on screen + // and let the richer detail read replace the remaining placeholders in place. if (detailQuery.isPending && !detail) { - return ; + return ; } return ( @@ -1468,41 +1494,6 @@ export function PullRequestDetailPanel({ ) : null} - {workflowApprovalsRequired > 0 && can("approve-workflows") ? ( - - - - - } - /> - - {pendingAction === "approve-workflows" - ? "Approving..." - : "Approve workflows to run"} - - - ) : null} {/* Said where the Merge button is, because it is the answer to why nobody has pressed it: the merge is already asked for, and the host is holding it. */} {autoMergeArmed && primaryAction !== "auto-merge-armed" ? ( @@ -2155,20 +2146,58 @@ export function PullRequestDetailPanel({ ))} {tab === "summary" ? ( - - {checksState !== null ? ( - + + {workflowApprovalsRequired > 0 && can("approve-workflows") ? ( + + + + + } + /> + + {pendingAction === "approve-workflows" + ? "Approving..." + : "Approve workflows to run"} + +
) : ( - + + {checksState !== null ? ( + + ) : ( + + )} + {checksSummary} + )} - {checksSummary} ) : tab === "timeline" ? (
@@ -2339,7 +2368,7 @@ export function PullRequestDetailPanel({ fixFindingLabel={handoffLabels.fixFinding} onFixFinding={startFixFinding} onRefresh={refreshDetail} - refreshToken={refreshToken} + refreshToken={codeRefreshToken} />
diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 38a3ab70d642..f8c922356548 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -3,13 +3,23 @@ * and a detail panel opening — use bars in the geometry of the content they stand for, pulsing * on one composited layer. Diff loading uses the shared diff-panel skeleton instead. * - * Deliberately not the app's shimmer skeleton. The sweep is a `transform` animation per bar — - * compositor-safe, but a layer for every bar on screen — and its white highlight over the - * near-white `muted` base all but disappears in light mode. Here one `animate-ghost-pulse` on the - * container is a single opacity animation however many bars sit under it, and the bars take - * their tone from `muted-foreground` at low alpha, which reads on both themes. + * The bars share the app-wide `Skeleton` tone (`muted-foreground` at low alpha, which reads on + * both themes) and the single `animate-skeleton` pulse, applied once on the container so any + * number of bars costs one opacity animation. */ +import type { PullRequestListEntry } from "@t3tools/contracts"; +import { ArrowLeftIcon } from "lucide-react"; + import { cn } from "~/lib/utils"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { pullRequestLabelColor } from "./pullRequestList.logic"; +import { + PullRequestActorLabel, + PullRequestDiffStat, + pullRequestChecksStatePresentation, + resolvePullRequestState, +} from "./pullRequestPresentation"; function GhostBar({ className }: { className?: string | undefined }) { return
; @@ -32,7 +42,7 @@ export function PullRequestListGhost({
{caption ? (

{caption}

@@ -62,18 +72,46 @@ export function PullRequestListGhost({ * boundaries in the ghost prevents the loaded pull request from replacing one layout with * another a moment later. */ -export function PullRequestDetailGhost() { +export function PullRequestDetailGhost({ seed }: { seed?: PullRequestListEntry | null }) { + const statePresentation = seed + ? resolvePullRequestState({ + state: seed.state, + isDraft: seed.isDraft, + }) + : null; + const checksPresentation = seed?.checksState + ? pullRequestChecksStatePresentation(seed.checksState) + : null; + return (
- - + {seed && statePresentation ? ( + <> + + {seed.repository} + + + #{seed.number} + + + ) : ( + <> + + + + )}
@@ -82,18 +120,58 @@ export function PullRequestDetailGhost() {
- + {seed ? ( +

{seed.title}

+ ) : ( + + )}
- - + {seed ? ( + <> + + + updated {formatRelativeTimeLabel(seed.updatedAt)} + + + ) : ( + <> + + + + )}
- - - + {seed ? ( + + {seed.baseBranch} + + {seed.headBranch} + + ) : ( + <> + + + + + )}
- + {seed ? ( + + ) : ( + + )}
@@ -104,7 +182,19 @@ export function PullRequestDetailGhost() {
- + {checksPresentation ? ( + + + {checksPresentation.label} + + ) : ( + + )}
@@ -127,8 +217,29 @@ export function PullRequestDetailGhost() {
- - + {seed ? ( + seed.labels.slice(0, 3).map((label) => { + const color = pullRequestLabelColor(label.color); + return ( + + + {label.name} + + ); + }) + ) : ( + <> + + + + )}
@@ -160,7 +271,11 @@ export function PullRequestDetailGhost() { /** People-shaped: an avatar and a name, in the reviewer picker's own row height. */ export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) { return ( -
+
{Array.from({ length: rows }, (_, index) => (
@@ -174,7 +289,11 @@ export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) { /** The timeline's own shape: dots on the rail, a line and a date to each. */ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) { return ( -
+
{Array.from({ length: rows }, (_, index) => (
@@ -194,7 +313,7 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
{Array.from({ length: rows }, (_, index) => (
diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 473aadcffbf7..e45a687e981d 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -78,7 +78,6 @@ export function PullRequestFilterOptionIcon({ projectName={option.label} faviconPath={option.favicon.faviconPath} projectIcon={option.favicon.projectIcon} - fallbackIcon={FolderGit2Icon} className="size-3.5" /> ) : ( diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 08757b275b7e..b3fc1da8d0a3 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -23,6 +23,7 @@ import { rankPullRequestMatches, rankPullRequestsByMergeReadiness, scorePullRequestMatch, + sortPullRequestGroups, retainVisiblePullRequestStatsBatches, withDiffStat, resolveProjectScope, @@ -328,8 +329,8 @@ describe("pull request grouping", () => { VIEWERS, ); expect(groups.map((group) => [group.key, group.entries.length])).toEqual([ - ["reviewRequested", 1], ["authored", 1], + ["reviewRequested", 1], ]); }); @@ -753,6 +754,63 @@ describe("default merge-readiness ranking", () => { rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number), ).toEqual([2, 1, 3]); }); + + it("keeps authored work first and ranks each group by readiness", () => { + const authoredWaiting = entry({ number: 1, checksState: "pending" }); + const authoredReady = entry({ + number: 2, + checksState: "passing", + reviewDecision: "approved", + }); + const otherReady = entry({ + number: 3, + checksState: "passing", + reviewDecision: "approved", + }); + const sorted = sortPullRequestGroups( + [ + { key: "authored", label: "Authored", entries: [authoredWaiting, authoredReady] }, + { key: "others", label: "Others", entries: [otherReady] }, + ], + "ready", + "", + ); + + expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]); + expect(sorted.flatMap((group) => group.entries).map((row) => row.number)).toEqual([2, 1, 3]); + }); + + it.each([ + ["updated", [1, 2]], + ["newest", [2, 1]], + ["oldest", [1, 2]], + ["largest", [1, 2]], + ["smallest", [2, 1]], + ] as const)("keeps authored first while applying the %s sort inside groups", (sort, order) => { + const olderLarger = entry({ + number: 1, + additions: 20, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-08-01T00:00:00Z", + }); + const newerSmaller = entry({ + number: 2, + additions: 2, + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-07-01T00:00:00Z", + }); + const sorted = sortPullRequestGroups( + [ + { key: "authored", label: "Authored", entries: [olderLarger, newerSmaller] }, + { key: "others", label: "Others", entries: [entry({ number: 3 })] }, + ], + sort, + "", + ); + + expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]); + expect(sorted[0]!.entries.map((row) => row.number)).toEqual(order); + }); }); describe("line counts that arrive after the rows", () => { @@ -843,9 +901,9 @@ describe("partitioning with the hosts' own priority reads", () => { updatedAt: "2026-06-02T00:00:00Z", }); const groups = partitionPullRequestsWithPriority([], [both], [both, requestedOlder, requested]); - expect(groups.map((group) => group.key)).toEqual(["reviewRequested", "authored"]); - expect(groups[0]!.entries.map((item) => item.number)).toEqual([2, 3]); - expect(groups[1]!.entries.map((item) => item.number)).toEqual([1]); + expect(groups.map((group) => group.key)).toEqual(["authored", "reviewRequested"]); + expect(groups[0]!.entries.map((item) => item.number)).toEqual([1]); + expect(groups[1]!.entries.map((item) => item.number)).toEqual([2, 3]); }); it("lets the feed's copy of a partitioned row replace the partition's", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 576f771e7e7e..af1bd6ab4fbe 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -18,6 +18,9 @@ import type { PullRequestListState, } from "@t3tools/contracts"; +import { toSortableTimestamp } from "../../lib/threadSort"; +import type { PullRequestListSort } from "./pullRequestListPreferences"; + /** * A listed change request with the environment that read it. Nothing on a row says which machine * it came from, and the page unions every connected one — so acting on a row, refreshing it, or @@ -415,7 +418,7 @@ export function groupPullRequestsByInvolvement( buckets.others.push(entry); } } - return (["reviewRequested", "authored", "others"] as const) + return (["authored", "reviewRequested", "others"] as const) .filter((key) => buckets[key].length > 0) .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] })); } @@ -615,8 +618,8 @@ export function partitionPullRequestsWithPriority right.updatedAt.localeCompare(left.updatedAt); return ( [ - { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) }, { key: "authored", entries: [...authoredByKey.values()].toSorted(byRecency) }, + { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) }, { key: "others", entries: others }, ] as const ) @@ -1027,6 +1030,45 @@ export function rankPullRequestsByMergeReadiness( + groups: ReadonlyArray>, + sort: PullRequestListSort, + searchText: string, + hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, +): ReadonlyArray> { + const sortWithinGroups = (rank: (entries: ReadonlyArray) => ReadonlyArray) => + groups.map((group) => ({ ...group, entries: rank(group.entries) })); + + if (sort === "ready") { + return searchText.trim().length === 0 + ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize)) + : groups; + } + if (sort === "updated") return groups; + + const timestamp = (entry: Entry) => + toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0; + return sortWithinGroups((entries) => + entries.toSorted((left, right) => { + if (sort === "newest" || sort === "oldest") { + const leftCreated = toSortableTimestamp(left.createdAt); + const rightCreated = toSortableTimestamp(right.createdAt); + const measured = Number(rightCreated !== null) - Number(leftCreated !== null); + const dated = (leftCreated ?? 0) - (rightCreated ?? 0); + return ( + measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left) + ); + } + const measured = Number(hasMeasuredSize(right)) - Number(hasMeasuredSize(left)); + const sized = left.additions + left.deletions - (right.additions + right.deletions); + return ( + measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left) + ); + }), + ); +} + /** * A row with the line counts that arrived after it did. Only where the host left them out — a * listing that carried them is not second-guessed — and only where they have arrived, since a row diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index d9fd35e0c8b4..f4eb7958a1d3 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -33,9 +33,9 @@ interface StatePresentation { } /** - * How a pull request's state reads on this page. Open, closed and merged use the same ink as - * the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two - * different things in two places; draft and conflicts are states that badge never shows. + * How a pull request's state reads on this page. Open, closed, merged, and draft use the same + * ink as the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two + * different things in two places. * * Draft outranks conflicts: a draft is not heading for a merge yet, so conflicts only surface * once it is real work. diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx new file mode 100644 index 000000000000..80727a8e970f --- /dev/null +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -0,0 +1,157 @@ +import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts"; +import { useState } from "react"; + +import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; + +/** + * Stable per hub and readable in settings.json. Dots and dashes in the host + * are kept so `foo-bar.com` and `foo.bar.com` do not collide; anything else + * (a port's colon, a path) is folded to a dash. + */ +function sourceIdFromUrl(url: string): UsageLimitSourceId { + let host = url; + try { + host = new URL(url).host; + } catch { + // Keep the raw text; the server reports the bad URL on its row. + } + const slug = host + .toLowerCase() + .replace(/[^a-z0-9.-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`); +} + +/** + * Adds a CLIProxyAPI hub from provider settings on one environment. The + * management key is sent once and kept in that server's secret store; + * settings only ever carry a redaction marker for it afterwards. + */ +export function AddUsageLimitSourceDialog({ + open, + onOpenChange, + environmentId, + environmentLabel, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const [label, setLabel] = useState(""); + const [url, setUrl] = useState(""); + const [managementKey, setManagementKey] = useState(""); + const trimmedUrl = url.trim(); + const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0; + + const reset = () => { + setLabel(""); + setUrl(""); + setManagementKey(""); + }; + + const save = () => { + if (!canSave) return; + const id = sourceIdFromUrl(trimmedUrl); + // The patch names only this entry; the server merges it into its map. + updateSettings({ + usageLimitSources: { + [id]: { + kind: "cliproxy", + ...(label.trim() ? { label: label.trim() } : {}), + url: trimmedUrl, + managementKey: managementKey.trim(), + enabled: true, + }, + }, + }); + reset(); + onOpenChange(false); + }; + + return ( + { + if (!next) reset(); + onOpenChange(next); + }} + > + + + Add a CLIProxyAPI hub + + Show the quota of every account the hub pools, next to the providers on{" "} + {environmentLabel}. The key stays on that server. + + + +
{ + event.preventDefault(); + save(); + }} + > +
+ + setUrl(event.target.value)} + autoFocus + /> +
+
+ + setManagementKey(event.target.value)} + /> +
+
+ + setLabel(event.target.value)} + /> +
+
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx new file mode 100644 index 000000000000..a18cb9173ff6 --- /dev/null +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -0,0 +1,475 @@ +import type { BrowserImportSource } from "@t3tools/contracts"; +import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts"; +import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { cn, randomUUID } from "~/lib/utils"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Spinner } from "../ui/spinner"; +import { + initialWizardStep, + initialTargetSelection, + canCloseWizard, + isRetryableReason, + formatSkippedDomains, + outcomeToStep, + refreshedSourceProfileDirectory, + refreshedSourceStep, + resolveWizardTarget, + type ImportOutcome, + type WizardTarget, + type WizardTargetProfile, + type WizardTargetSelection, + type WizardStep, +} from "./browserImportWizard.logic"; + +export type { WizardTarget } from "./browserImportWizard.logic"; + +interface BrowserImportWizardProps { + readonly source: BrowserImportSource; + /** Captured when the wizard opens so destination copy and writes stay stable. */ + readonly destinationEnvironmentName: string; + /** Existing profiles the import can go into. Incognito is excluded upstream. */ + readonly targetProfiles: ReadonlyArray; + /** Whether a new profile can still be created (profile cap). */ + readonly canCreateProfile: boolean; + /** + * Runs the import and returns how it went. For a new target the caller only + * registers the profile once the import succeeds, so a blocked attempt never + * leaves an empty profile behind. + */ + readonly onImport: (input: { + readonly sourceProfileDirectory: string; + readonly target: WizardTarget; + }) => Promise; + /** Re-checks the source's availability after the user quits the browser. */ + readonly onRefreshSource: () => Promise; + readonly onClose: () => void; +} + +/** + * Guides one browser's cookies into a profile. + * + * Every state the import can be in — the browser is open, a profile has to be + * chosen, the read failed — is a screen the user can move forward from, rather + * than a disabled row that only says no. + */ +export function BrowserImportWizard({ + source: initialSource, + destinationEnvironmentName, + targetProfiles, + canCreateProfile, + onImport, + onRefreshSource, + onClose, +}: BrowserImportWizardProps) { + const [source, setSource] = useState(initialSource); + const [step, setStep] = useState(() => initialWizardStep(initialSource)); + const [sourceProfileDirectory, setSourceProfileDirectory] = useState( + () => initialSource.profiles[0]?.directory ?? "", + ); + const [target, setTarget] = useState(() => + initialTargetSelection(canCreateProfile, targetProfiles), + ); + const [targetError, setTargetError] = useState(); + // Stable across retries so a keychain re-approval lands in one profile, not + // a new one each time. + const newProfileId = useRef(`profile-${randomUUID()}`); + // A second Import click before React has left the configure screen would + // start a second run; the parent refuses it, and applying that refusal here + // would drop the wizard out of the importing step while the first write is + // still going. The ref settles synchronously where state does not. + const importInFlight = useRef(false); + + const runImport = () => { + if (importInFlight.current) return; + const chosen = resolveWizardTarget(target, newProfileId.current, targetProfiles); + if (chosen === undefined) { + setTargetError("That profile is no longer available. Choose where to import these cookies."); + setStep({ step: "configure" }); + return; + } + setTargetError(undefined); + importInFlight.current = true; + setStep({ step: "importing" }); + void onImport({ sourceProfileDirectory, target: chosen }) + .then((outcome) => setStep(outcomeToStep(outcome))) + .catch(() => setStep({ step: "blocked", reason: "readFailed" })) + .finally(() => { + importInFlight.current = false; + }); + }; + + const recheckAfterQuit = () => { + setStep({ step: "checking" }); + void onRefreshSource() + .then((refreshed) => { + if (refreshed) { + setSource(refreshed); + setSourceProfileDirectory((current) => + refreshedSourceProfileDirectory(current, refreshed), + ); + } + setStep(refreshedSourceStep(refreshed)); + }) + .catch(() => setStep({ step: "blocked", reason: "readFailed" })); + }; + + return ( + (open || !canCloseWizard(step) ? undefined : onClose())}> + + {step.step === "quit" ? ( + + ) : step.step === "importing" ? ( + + ) : step.step === "checking" ? ( + + ) : step.step === "done" ? ( + + ) : step.step === "blocked" ? ( + + ) : ( + { + setTarget(selection); + setTargetError(undefined); + }} + targetError={targetError} + onCancel={onClose} + onImport={runImport} + /> + )} + + + ); +} + +function QuitStep({ + source, + onCancel, + onRechecked, +}: { + readonly source: BrowserImportSource; + readonly onCancel: () => void; + readonly onRechecked: () => void; +}) { + return ( + <> + + Quit {source.name} to import + + {source.name} is open, so its cookies can’t be read yet. Quit it, then continue. + + + + + + + + ); +} + +/** "5,065 cookies", or "no cookies", or nothing when the store is unreadable. */ +function cookieCountLabel(count: number | undefined): string | undefined { + if (count === undefined) return undefined; + if (count === 0) return "no cookies"; + return `${count.toLocaleString()} ${count === 1 ? "cookie" : "cookies"}`; +} + +function cookieResultCount(count: number): string { + return `${count.toLocaleString()} ${count === 1 ? "cookie" : "cookies"}`; +} + +type ConfigureStepProps = { + readonly source: BrowserImportSource; + readonly destinationEnvironmentName: string; + readonly targetProfiles: ReadonlyArray; + readonly canCreateProfile: boolean; + readonly sourceProfileDirectory: string; + readonly onSourceProfileChange: (directory: string) => void; + readonly target: WizardTargetSelection; + readonly targetError: string | undefined; + readonly onTargetChange: (target: WizardTargetSelection) => void; + readonly onCancel: () => void; + readonly onImport: () => void; +}; + +function ConfigureStep({ + source, + destinationEnvironmentName, + targetProfiles, + canCreateProfile, + sourceProfileDirectory, + onSourceProfileChange, + target, + targetError, + onTargetChange, + onCancel, + onImport, +}: ConfigureStepProps) { + const targetMissing = + target.kind === "existing" && + !targetProfiles.some((profile) => profile.id === target.profileId); + // The "New profile" tile is unrendered once the cap is reached, so a target + // chosen before that leaves nothing selected in "Into" — say so, the same + // way a vanished existing target is explained. + const targetUncreatable = target.kind === "new" && !canCreateProfile; + const targetFeedback = + targetError ?? + (targetMissing + ? "That profile is no longer available. Choose where to import these cookies." + : targetUncreatable + ? "You've reached the profile limit. Choose an existing profile to import into." + : undefined); + return ( + <> + + Import from {source.name} + + Choose which cookies to import for {destinationEnvironmentName}. + + + + {/* Side by side when the dialog has room, stacked when it doesn't. */} +
+
+

+ From +

+ {source.profiles.map((profile) => ( + onSourceProfileChange(profile.directory)} + /> + ))} +
+
+ + +
+
+

+ Into +

+ {canCreateProfile ? ( + onTargetChange({ kind: "new" })} + /> + ) : null} + {targetProfiles.map((profile) => ( + onTargetChange({ kind: "existing", profileId: profile.id })} + /> + ))} +
+
+ {targetFeedback ? ( +

+ {targetFeedback} +

+ ) : null} +
+ + + + + + ); +} + +/** One selectable option: a name, an optional detail line, and a check. */ +function SelectableTile({ + selected, + title, + subtitle, + onSelect, +}: { + readonly selected: boolean; + readonly title: string; + readonly subtitle?: string | undefined; + readonly onSelect: () => void; +}) { + return ( + + ); +} + +function ImportingStep() { + return ( + <> + + Importing cookies + This may take a moment. + + + + Importing… + + + ); +} + +function CheckingStep({ sourceName }: { readonly sourceName: string }) { + return ( + <> + + Checking {sourceName} + Checking whether the browser has closed. + + + + Checking… + + + ); +} + +function DoneStep({ + imported, + skipped, + skippedDomains, + targetName, + destinationEnvironmentName, + onClose, +}: { + readonly imported: number; + readonly skipped: number; + readonly skippedDomains: ReadonlyArray; + readonly targetName: string; + readonly destinationEnvironmentName: string; + readonly onClose: () => void; +}) { + return ( + <> + + + {imported > 0 + ? `Imported ${cookieResultCount(imported)}` + : skipped > 0 + ? `Skipped ${cookieResultCount(skipped)}` + : "No cookies found"} + + + {imported > 0 + ? `Added to ${targetName} for ${destinationEnvironmentName}.${skipped > 0 ? ` ${cookieResultCount(skipped)} skipped.` : ""}` + : skipped > 0 + ? `No cookies were imported for ${destinationEnvironmentName}.` + : `There were no cookies to import for ${destinationEnvironmentName}.`} + + + {skippedDomains.length > 0 ? ( + +

+ Skipped +

+

{formatSkippedDomains(skippedDomains)}

+
+ ) : null} + + } onClick={onClose}> + Done + + + + ); +} + +function BlockedStep({ + source, + reason, + onClose, + onRetry, +}: { + readonly source: BrowserImportSource; + readonly reason: keyof typeof BROWSER_IMPORT_FAILURE_COPY; + readonly onClose: () => void; + readonly onRetry: (() => void) | undefined; +}) { + return ( + <> + + Couldn’t import from {source.name} + {BROWSER_IMPORT_FAILURE_COPY[reason]} + + + + {onRetry ? : null} + + + ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 6b81a7f70dda..1c4e034cf6a1 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -25,6 +25,7 @@ import { type AuthClientSession, type AuthEnvironmentScope, type AuthPairingLink, + type AuthPairingCredentialResult, type AdvertisedEndpoint, type DesktopDiscoveredSshHost, type DesktopSshEnvironmentTarget, @@ -541,6 +542,7 @@ function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string { type PairingLinkListRowProps = { pairingLink: ServerPairingLinkRecord; + credential: string | undefined; endpointUrl: string | null | undefined; endpoints: ReadonlyArray; defaultEndpointKey: string | null; @@ -551,6 +553,7 @@ type PairingLinkListRowProps = { const PairingLinkListRow = memo(function PairingLinkListRow({ pairingLink, + credential, endpointUrl, endpoints, defaultEndpointKey, @@ -571,20 +574,22 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ const qrPanelId = useId(); const currentOriginPairingUrl = useMemo( - () => resolveCurrentOriginPairingUrl(pairingLink.credential), - [pairingLink.credential], + () => (credential ? resolveCurrentOriginPairingUrl(credential) : null), + [credential], ); const hostedPairingUrl = useMemo( () => - endpointUrl != null && endpointUrl !== "" - ? resolveHostedPairingUrl(endpointUrl, pairingLink.credential) + credential && endpointUrl != null && endpointUrl !== "" + ? resolveHostedPairingUrl(endpointUrl, credential) : null, - [endpointUrl, pairingLink.credential], + [endpointUrl, credential], ); const endpointPairingUrl = useMemo(() => { const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey); - return endpoint ? resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential) : null; - }, [defaultEndpointKey, endpoints, pairingLink.credential]); + return endpoint && credential + ? resolveAdvertisedEndpointPairingUrl(endpoint, credential) + : null; + }, [defaultEndpointKey, endpoints, credential]); const endpointCopyOptions = useMemo(() => { const options: Array<{ readonly id: string; @@ -594,11 +599,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ readonly detail: string; readonly qrShareable: boolean; }> = []; + if (!credential) return options; for (const endpoint of endpoints) { if (endpoint.status === "unavailable") { continue; } - const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential); + const url = resolveAdvertisedEndpointPairingUrl(endpoint, credential); options.push({ id: endpoint.id, preferenceKey: endpointDefaultPreferenceKey(endpoint), @@ -609,19 +615,19 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ }); } return options; - }, [endpoints, pairingLink.credential]); + }, [endpoints, credential]); const shareablePairingUrl = endpointPairingUrl ?? - (endpointUrl != null && endpointUrl !== "" - ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)) + (credential && endpointUrl != null && endpointUrl !== "" + ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, credential)) : isLoopbackHostname(window.location.hostname) ? null : currentOriginPairingUrl); // Value of the copy attempt that last failed. The clipboard-failure reveal // dialog must show exactly what failed to copy, not the row's default URL. const [failedCopyValue, setFailedCopyValue] = useState(null); - const revealValue = failedCopyValue ?? shareablePairingUrl ?? pairingLink.credential; - const isRevealValueUrl = revealValue !== pairingLink.credential; + const revealValue = failedCopyValue ?? shareablePairingUrl ?? credential ?? ""; + const isRevealValueUrl = revealValue !== credential; const isRevealValueHostedAppPairingUrl = isRevealValueUrl && isHostedAppPairingUrl(revealValue); // Never render a QR for a loopback URL, even in the manual-copy fallback. const isRevealValueQrShareable = @@ -686,8 +692,8 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ ); const handleCopyCode = useCallback(() => { - copyPairingValue(pairingLink.credential, "code"); - }, [copyPairingValue, pairingLink.credential]); + if (credential) copyPairingValue(credential, "code"); + }, [copyPairingValue, credential]); const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); @@ -727,7 +733,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ ·

- {shareablePairingUrl === null ? ( + {!credential ? ( +

+ Create a new link to share from this client. +

+ ) : shareablePairingUrl === null ? (

Copy the token and pair from another client using this backend's reachable host.

@@ -747,13 +757,13 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ ) : null} { setIsRevealDialogOpen(open); if (!open) setFailedCopyValue(null); }} > - {canCopyToClipboard ? ( + {!credential ? null : canCopyToClipboard ? ( shareablePairingUrl ? null : ( + open && loadSources()}> + + } + > + + Add profile + + + createProfile("New profile")} + > + Blank profile + + {atProfileLimit ? ( + You’ve reached the profile limit + ) : null} + + + Import from + {sources === null ? ( + Looking for browsers… + ) : importableSources.length === 0 ? ( + No supported browsers found + ) : ( + // Every source is a plain row — running, needs-permission and + // ready all look the same here. The wizard picks up whatever + // state the source is in and walks the user forward from there. + <> + {importableSources.map((source) => ( + { + if (!settingsHydrated || primaryEnvironment == null) return; + setImportSession({ + source, + environmentId: primaryEnvironment.environmentId, + environmentName: resolveEnvironmentOptionLabel({ + isPrimary: true, + environmentId: primaryEnvironment.environmentId, + runtimeLabel: primaryEnvironment.label, + }), + }); + }} + > + {source.name} + + ))} + {primaryEnvironment == null ? ( + Connect to an environment to import cookies + ) : null} + + )} + + + } > {/* - Each profile is its own bounded row, and the list carries the bottom - spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows - stack on narrow viewports with a larger gap inside a row than between - rows, which reads as the remove button belonging to the profile below. + The bordered container groups rows unambiguously at any width, and + carries the bottom spacing `SettingsRow` leaves to its children + (`pt-3 pb-1`). */} -
- {resolveBrowserProfiles(userProfiles).map((profile) => { +
+ {listedProfiles.map((profile, index) => { const builtIn = isBuiltInBrowserProfileId(profile.id); + const isDefault = profile.id === resolvedDefaultId; return (
0 && "border-t border-border/60", )} > - {builtIn ? ( - // Dimmed here rather than on the list, which is the only - // content in the row without a disabled treatment of its own: - // a wrapper-level dim would stack with the rename field's and - // the remove button's, landing them near 0.41 while every - // other disabled control in the block sits at 0.64. - - {profile.name} - - {profile.kind === "incognito" ? "Ephemeral" : "Built-in"} - - - ) : ( - renameProfile(profile.id, next)} - /> - )} - {builtIn ? null : ( - - - - - } + + {builtIn ? ( + // Dimmed here rather than on the table: a wrapper-level dim + // stacks with the rename field's and the row menu button's + // own, landing them near 0.41 while every other disabled + // control in the block sits at 0.64. + + {profile.name} + + ) : ( + renameProfile(profile.id, next)} /> - - {removalAvailable - ? "Remove profile and its data" - : "Connect to an environment to remove this profile"} - - - )} + )} + {/* + Dimmed with the rest of the row: a `Badge` has no disabled + treatment of its own, so a solid `bg-primary` pill would + otherwise sit at full strength beside a name, rename field + and menu button that are all at 0.64. + */} + {isDefault ? ( + Default + ) : null} + + + + } + > + + + + { + if (settingsHydrated) { + updateSettings({ browserDefaultProfileId: profile.id }); + } + }} + > + Set as default + + clearProfileData(profile.id, profile.name)} + > + Clear cookies and cache + + {builtIn ? null : ( + { + if (settingsHydrated) setProfilePendingRemoval(profile); + }} + > + Remove profile and data + + )} + {!removalAvailable ? ( + <> + + + {environmentsReady + ? "Connect to an environment to clear profile data" + : "Checking environments…"} + + + ) : null} + +
); })} @@ -798,8 +1123,8 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { Remove “{profilePendingRemoval?.name}”? - Its cookies, logins, and cache are deleted with it. Tabs already open in this profile - stay open until you close them. + Its cookies and logins are deleted. Tabs already open in this profile stay open until + you close them. {profileRemovalError ? (

@@ -833,86 +1158,29 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { + {importSession ? ( + ({ id: profile.id, name: profile.name }))} + canCreateProfile={settingsHydrated && !atProfileLimit} + onImport={(input) => + runWizardImport(importSession.source, importSession.environmentId, input) + } + onRefreshSource={() => refreshImportSource(importSession.source.id)} + onClose={() => setImportSession(null)} + /> + ) : null} ); } -function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) { - const userProfiles = useClientSettings((settings) => settings.browserProfiles); - const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); - const settingsHydrated = useClientSettingsHydrated(); - const updateSettings = useUpdatePrimarySettings(); - const profileWritesDisabled = disabled || !settingsHydrated; - // Incognito is deliberately absent: as a default it would open every tab - // into storage that is discarded on close. - const profiles = resolveBrowserProfiles(userProfiles).filter( - (profile) => profile.kind !== "incognito", - ); - const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0]; - - return ( - { - if (settingsHydrated) { - updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }); - } - }} - /> - ) : null - } - control={ - - } - /> - ); -} - export function IntegrationsSettingsPanel() { // Client-local preview defaults are editable only where the preview exists. const previewDefaultsDisabled = !isElectron; const previewDefaults = ( <> - diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx index 9098b359d1ea..2280395ecf40 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx @@ -39,13 +39,13 @@ vi.mock("../ui/toggle-group", () => ({ import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; describe("ProjectIconPickerDialog", () => { - it("shows emoji first and selects it for an automatic project", () => { + it("shows icons first and selects them for an automatic project", () => { const markup = renderToStaticMarkup( {}} onSelect={() => {}} />, ); - expect(markup).toContain('data-current="emoji"'); - expect(markup.indexOf(">Emoji<")).toBeLessThan(markup.indexOf(">Icons<")); - expect(markup).toContain("Or paste any emoji"); + expect(markup).toContain('data-current="lucide"'); + expect(markup.indexOf(">Icons<")).toBeLessThan(markup.indexOf(">Emoji<")); + expect(markup).toContain('aria-label="Icon color"'); }); }); diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx index 4ecdb0f653c5..7fce7a4fbb5b 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx @@ -45,7 +45,7 @@ export function ProjectIconPickerDialog({ readonly onSelect: (icon: ProjectIconOverride) => void; }) { const [mode, setMode] = useState<"lucide" | "emoji">( - current?.kind === "lucide" ? "lucide" : "emoji", + current?.kind === "emoji" ? "emoji" : "lucide", ); const [iconName, setIconName] = useState( current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, @@ -60,7 +60,7 @@ export function ProjectIconPickerDialog({ useEffect(() => { if (open && !previousOpenRef.current) { - setMode(current?.kind === "lucide" ? "lucide" : "emoji"); + setMode(current?.kind === "emoji" ? "emoji" : "lucide"); setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR); setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); @@ -84,7 +84,7 @@ export function ProjectIconPickerDialog({ Choose project icon - Pick an emoji, or choose any Lucide icon and color. + Pick any Lucide icon and color, or use an emoji. - Emoji Icons + Emoji {mode === "lucide" ? ( diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 032d9955eba8..41b942d09f83 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -857,7 +857,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { hexToHsv(props.value), [props.value]); const [hsv, setHsv] = useState(initialHsv); const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v); + const [hexDraft, setHexDraft] = useState(null); const commitHsv = useCallback( (nextHsv: typeof hsv) => { @@ -162,13 +153,15 @@ function ProviderCustomColorPanel(props: { />

{ const nextColor = event.currentTarget.value; + setHexDraft(nextColor); if (!/^#[\da-f]{6}$/i.test(nextColor)) return; setHsv(hexToHsv(nextColor)); props.onCommit(nextColor); }} + onBlur={() => setHexDraft(null)} className="h-8 rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground outline-none transition-colors focus:border-ring" aria-label="Custom hex accent color" spellCheck={false} @@ -181,8 +174,8 @@ function ProviderCustomColorPanel(props: { function ProviderCustomColorPicker(props: { readonly displayName: string; readonly value: string | undefined; - readonly selected: boolean; readonly onCommit: (value: string) => void; + readonly onClear: () => void; }) { const normalized = normalizeProviderAccentColor(props.value) ?? FALLBACK_ACCENT_COLOR; @@ -193,20 +186,13 @@ function ProviderCustomColorPicker(props: { } /> @@ -217,6 +203,21 @@ function ProviderCustomColorPicker(props: { className="overflow-hidden rounded-md p-0 [--viewport-inline-padding:0px] [&_[data-slot=popover-viewport]]:p-0" > + + + Clear color + + } + /> ); @@ -297,59 +298,23 @@ export function ProviderAccentColorPicker(props: { ); const normalized = normalizeProviderAccentColor(optimisticValue); - const selectedValue = - normalized && - PROVIDER_ACCENT_SWATCHES.includes(normalized as (typeof PROVIDER_ACCENT_SWATCHES)[number]) - ? normalized - : ""; - const customSelected = Boolean(normalized && selectedValue === ""); - - const swatchRow = ( -
- - - -
+ const picker = ( + commitAccentColor("")} + /> ); if (layout === "inline") { - return swatchRow; + return picker; } return (
Accent color - {swatchRow} + {picker} {description ? {description} : null}
); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 02ca1e20ef54..265030a73ff2 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -32,17 +32,16 @@ import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; -import { ScrollArea } from "../ui/scroll-area"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { DriverOption } from "./providerDriverMeta"; -import { providerSettingsTabClassName } from "./providerSettingsTabs"; import { ProviderSettingsForm } from "./ProviderSettingsForm"; import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/ProviderInstanceIcon"; import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; import { getProviderVersionAdvisoryPresentation, PROVIDER_STATUS_STYLES, @@ -53,13 +52,6 @@ import { const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; -/** Label-left field grid for the Configuration tab: one row per field. */ -const PROVIDER_FIELD_GRID_CLASS_NAME = - "grid gap-x-4 gap-y-2.5 sm:grid-cols-[8rem_minmax(0,1fr)] sm:items-start"; -/** Full-width divider row that names the group of fields below it. */ -const PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME = - "col-span-full mt-1 border-t border-border/60 pt-2.5 text-[11px] text-muted-foreground"; - let environmentVariableDraftId = 0; const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; @@ -254,7 +246,7 @@ function ProviderEnvironmentSection(props: { ]); return ( -
+
{rows.map((variable, index) => (
))} -
+
+ {rows.length > 0 ? ( + + Sensitive values are stored separately and never returned to the app. + + ) : null} - - {rows.length === 0 - ? "API keys, base URLs, or other per-instance CLI settings." - : "Sensitive values are stored separately and never returned to the app."} -
); @@ -421,7 +413,6 @@ export function ProviderInstanceCard({ onRunUpdate, isUpdating = false, }: ProviderInstanceCardProps) { - const [activeTab, setActiveTab] = useState<"configuration" | "models">("configuration"); const enabled = resolveProviderInstanceEnabled(instance); // A locally disabled provider reads "Disabled" with a muted dot even if its // last server status is stale. Enabled providers use the server status. @@ -433,9 +424,6 @@ export function ProviderInstanceCard({ ? getProviderSummary(liveProvider) : { headline: "Disabled", detail: null }; const authEmail = liveProvider?.auth.email?.trim(); - // The editor header folds the account email into the status line — - // "Authenticated as · " — with the email redacted until its - // reveal toggle is clicked. const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated"; const authLabel = enabled && liveProvider?.auth.status === "authenticated" @@ -474,8 +462,6 @@ export function ProviderInstanceCard({ const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver) ? instance.driver : null; - const visibleTab = driverOption === undefined ? "configuration" : activeTab; - const customModels = instance.driver === "antigravity" ? [] : readConfigStringArray(instance.config, "customModels"); // Server-returned models may lag behind settings writes. Treat probe @@ -485,10 +471,6 @@ export function ProviderInstanceCard({ liveModels: liveProvider?.models, customModels, }); - const hiddenModelCount = modelsForDisplay.filter( - (model) => !model.isCustom && hiddenModels.includes(model.slug), - ).length; - const updateDisplayName = (value: string) => { const trimmed = value.trim(); const { displayName: _omit, ...rest } = instance; @@ -561,25 +543,6 @@ export function ProviderInstanceCard({ ); - const titleHeadNode = ( - <> - {titleIconNode} -

- {displayName} -

- {String(instanceId) !== String(instance.driver) ? ( - - {instanceId} - - ) : null} - {driverOption?.badgeLabel ? ( - - {driverOption.badgeLabel} - - ) : null} - - ); - const titleTailNode = headerAction ? ( {headerAction} ) : null; @@ -593,29 +556,43 @@ export function ProviderInstanceCard({ statusKey === "warning" || statusKey === "error" ? ( ) : null; - const statusHeadlineNode = {summary.headline}; // Trouble states carry the server's explanation (a failed probe, a shadow // home entry that is not a symlink, a missing binary). Show it wherever the // headline shows so the user can act without opening the editor. const needsAttention = statusKey === "warning" || statusKey === "error"; - const statusLineClassName = - "flex min-w-0 flex-wrap items-center gap-x-1.5 text-[13px] leading-[1.45] text-muted-foreground/80"; - + const editorStatusNode = + isAuthenticated && authEmail ? ( + <> + {needsAttention ? statusDotNode : null} + Authenticated as + + {authLabel ? · {authLabel} : null} + {summary.detail ? ( + · {summary.detail} + ) : null} + + ) : ( + <> + {statusDotNode} + {summary.headline} + {summary.detail ? ( + · {summary.detail} + ) : null} + + ); if (mode === "list") { return (
+ } + /> + - {versionAdvisory ? ( - - - - - } - /> - +
+

+ Update available +

+

-

-
-

- Update available -

-

- {versionAdvisory.detail} -

-
- {onRunUpdate ? ( - - ) : null} - {onRunUpdate && updateCommand ? ( -
- - or, update manually using - -
- ) : null} - {updateCommand ? ( -
- - - {updateCommand} - - - - - copyToClipboard(updateCommand, { - providerName: displayName, - }) - } - aria-label="Copy update command" - > - - - } - /> - Copy command - -
- ) : null} -
- - - ) : null} - {titleTailNode} - -
-

- {statusDotNode} - {isAuthenticated && authEmail ? ( - <> - Authenticated as - - {authLabel ? · {authLabel} : null} - - ) : ( - statusHeadlineNode - )} - {summary.detail && !needsAttention ? · {summary.detail} : null} -

- {summary.detail && needsAttention ? ( -

- {summary.detail} -

- ) : null} -
- {onDelete ? ( - - - + {versionAdvisory.detail} +

+
+ {onRunUpdate ? ( + + ) : null} + {onRunUpdate && updateCommand ? ( +
+ + or, update manually using + +
+ ) : null} + {updateCommand ? ( +
+ + {updateCommand} + + + + copyToClipboard(updateCommand, { providerName: displayName }) + } + aria-label="Copy update command" + > + + + } + /> + Copy command + +
+ ) : null} +
+ + ) : null} -
- -
- - {driverOption !== undefined ? ( - + + ) : null} -
+ +
+ ); -
-
- - {driverOption !== undefined ? ( - -
+ + ) : null} + ); } diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index c94b7da9d34f..902fd408b54f 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -17,6 +17,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import type { ProviderClientDefinition } from "./providerDriverMeta"; +import { SettingsRow } from "./settingsLayout"; export interface ProviderSettingsFieldModel { readonly key: string; @@ -167,11 +168,9 @@ interface ProviderSettingsFormProps { readonly idPrefix: string; /** * `card` stacks label over control, `dialog` is the compact wizard layout, - * `grid` emits a label cell and a control cell per field for a parent - * two-column grid (label column left, control right), with the description - * beside a fixed-width control so each field stays on one line. + * and `settings` renders the shared settings row treatment. */ - readonly variant: "card" | "dialog" | "grid"; + readonly variant: "card" | "dialog" | "settings"; readonly onChange: (nextConfig: Record | undefined) => void; } @@ -252,74 +251,64 @@ function ProviderSettingsFieldRow({ {field.description} ) : null; - if (variant === "grid") { - // Label cell, then a control cell where the description sits beside a - // fixed-width control and wraps under it when the pane is narrow. The - // description is outside the label, so the control points at it instead. + if (variant === "settings") { const descriptionId = field.description ? `${inputId}-description` : undefined; + const control = + field.control === "switch" ? ( + + onChange(nextProviderConfigWithFieldValue(value, field, Boolean(checked))) + } + aria-label={field.label} + aria-describedby={descriptionId} + /> + ) : field.control === "select" ? ( + + ) : field.control === "textarea" ? ( +