diff --git a/.gitattributes b/.gitattributes index c7612b2aeaf8..d72319addcf3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,6 @@ # git autocrlf=true converts LF to CRLF on Windows, causing issues with oxfmt * text=auto eol=lf +# Batch files need CRLF for cmd.exe to parse them reliably. +*.cmd text eol=crlf +*.bat text eol=crlf patches/*.patch whitespace=-trailing-space,-space-before-tab diff --git a/.github/actions/setup-apt-mirrors/action.yml b/.github/actions/setup-apt-mirrors/action.yml new file mode 100644 index 000000000000..5beff201d188 --- /dev/null +++ b/.github/actions/setup-apt-mirrors/action.yml @@ -0,0 +1,22 @@ +name: Setup APT mirrors +description: Configure Ubuntu package downloads with automatic mirror failover. +runs: + using: composite + steps: + - shell: bash + run: | + # Replace the existing Blacksmith mirror list as well as direct sources. + printf '%s\tpriority:%s\n' \ + https://archive.ubuntu.com/ubuntu 1 \ + https://mirrors.edge.kernel.org/ubuntu 2 \ + https://mirror.math.princeton.edu/pub/ubuntu 3 \ + | sudo tee /etc/apt/blacksmith-ubuntu-mirrors.txt > /dev/null + + # APT's mirror transport retries each file against the next server. + sudo find /etc/apt -maxdepth 2 -type f \( -name '*.list' -o -name '*.sources' \) \ + -exec sed -i -E \ + 's#https?://(([^/]+\.)?archive|security)\.ubuntu\.com/ubuntu/?#mirror+file:/etc/apt/blacksmith-ubuntu-mirrors.txt#g' {} + + + # Move on to a fallback before an unreachable server exhausts the job. + printf '%s\n' 'Acquire::http::Timeout "15";' 'Acquire::https::Timeout "15";' \ + | sudo tee /etc/apt/apt.conf.d/80-mirror-timeouts > /dev/null diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fee5f57a83c..4bcb1e21ab99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,14 +45,22 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + # Files/dependencies are repo-wide; export checks cover clean workspaces only. + - name: Check unused code + run: vp run knip:check + - name: Check run: vp check - name: Typecheck run: vpr typecheck + - uses: ./.github/actions/setup-apt-mirrors + - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - name: Build desktop pipeline run: vp run build:desktop @@ -88,8 +96,12 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + - uses: ./.github/actions/setup-apt-mirrors + - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + 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/release.yml b/.github/workflows/release.yml index 404e4e8075cc..2785ecb8fa78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -216,6 +216,8 @@ jobs: - name: Typecheck run: vp run typecheck + - uses: ./.github/actions/setup-apt-mirrors + - name: Install browser secret helper build libraries run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config @@ -519,7 +521,7 @@ jobs: $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" $proc = Start-Process -FilePath $setupExe ` -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` - "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", "--quiet", "--norestart" ` + "Microsoft.VisualStudio.Component.VC.Runtimes.x86.x64.Spectre", "--quiet", "--norestart" ` -Wait -PassThru -NoNewWindow if ($null -eq $proc -or $proc.ExitCode -ne 0) { $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } @@ -527,6 +529,9 @@ jobs: exit $code } + - uses: ./.github/actions/setup-apt-mirrors + if: matrix.platform == 'linux' + - name: Install Linux desktop build libraries if: matrix.platform == 'linux' shell: bash diff --git a/AGENTS.md b/AGENTS.md index 97ff25bde3cf..f9102ba145ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,7 @@ The most common defect in this repo is a change that works on the path you teste - **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. - **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. - **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real. -- **Docs.** `docs/` splits by audience. Behavior changes that a user would notice belong in `docs/user/` (shipped-product voice, no repo tooling or source paths); architecture and contributor changes in `docs/internals/`; runbooks in `docs/operations/`; new vocabulary in `docs/internals/glossary.md`. +- **Docs.** Check whether the change makes existing guidance inaccurate. Apply the [documentation rules](#documentation) before adding anything. ## Dev servers @@ -134,11 +134,22 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - One concern per PR. If the description says "also", split it. - When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. +## Documentation + +Most code changes do not need an internal documentation change. Agents can read the code. + +- `docs/internals/` is for architectural decisions and their reasons, constraints that span components, and implementation traps that are hard to discover from the source. Before adding a paragraph, ask what a maintainer would get wrong without it. If reading the relevant code answers the question, leave it out. +- Do not document every feature, enumerate fields or methods, narrate control flow, maintain file catalogs, or append PR summaries. Types, tests, and code already record the implementation. The glossary defines shared vocabulary; it is not a feature index. +- Keep a local implementation explanation in a nearby code comment. Use an internal doc when the reasoning crosses boundaries or needs context the code cannot carry well. Link to the relevant source instead of copying it. +- When a documented decision or constraint changes, rewrite or remove the affected text. Do not append another account of the new behavior. A new internal page needs a distinct, durable reason to exist. +- `docs/user/` helps users accomplish tasks. Give each major feature a concise section explaining what it does, how to start, and anything unintuitive. A settings path is useful; descriptions of visible buttons, icons, layouts, animations, or every UI state are not. Before adding text, ask what task or decision it helps the user with. +- Keep user docs in the shipped product's voice, without implementation details or contributor tooling. Update the relevant feature section when how to use it changes. A UI tweak does not need a documentation entry, and a new control does not need its own page. +- `docs/operations/` holds maintainer setup, release, and debugging procedures. Keep instructions for operating an installed T3 Code server in the user guides. + ## Plans and work artifacts - Do not commit implementation plans, research notes, or agent scratch files. Keep temporary working material outside the worktree. `.plans/` is gitignored only as a safety net for legacy tooling. - Track active maintainer work in the GitHub issue or project item that owns it. External proposals follow `CONTRIBUTING.md` and belong in Ideas discussions. -- Put durable architecture, constraints, and decisions in `docs/internals/`. Update those docs when the product changes so agents find current facts instead of abandoned intentions. - A merged PR is the implementation record. Close or update its tracking item when the work lands; do not preserve a second checklist in the repository. ## How it works diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6cdb8ebcd540..c15dcbf346ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Developer Setup -See the [maintainer scripts guide](docs/internals/scripts.md#first-checkout) for the initial checkout, +See the [development runbook](docs/operations/development.md#first-checkout) for the initial checkout, development commands, tests, and platform-specific desktop packaging prerequisites. ## Read This First @@ -53,6 +53,10 @@ Explain exactly what changed. Explain exactly why the change should exist. +Follow the [documentation rules](AGENTS.md#documentation). Keep internal docs for decisions and +hard-to-discover constraints. Update user guides when how to use a feature changes; skip descriptions +of obvious controls and cosmetic changes. + Do not mix unrelated fixes together. If the PR makes anything resembling a UI change, include clear before/after images. diff --git a/README.md b/README.md index e959d66d8115..b3c74fd295c0 100644 --- a/README.md +++ b/README.md @@ -117,12 +117,12 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Install and first run](./docs/user/install.md) - [Permission modes](./docs/user/permission-modes.md) - [Keyboard shortcuts](./docs/user/keybindings.md) -- [Customize a project icon](./docs/user/project-settings.md) +- [Project settings](./docs/user/project-settings.md) - [Remote access from a phone or another machine](./docs/user/remote-access.md) - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) - Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) -- Linux: [run T3 Code as a background service](./docs/user/background-service.md) +- [Run T3 Code as a background service](./docs/user/background-service.md) Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md). diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 07fb87b051f1..496a8a27a7b6 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -165,10 +165,14 @@ function registerMacLauncherBundle(appBundlePath) { } } +// Bundle-internal paths are macOS paths whatever host builds them. export function resolveMacLauncherIconPaths(runtimeDir, development = isDevelopment) { return { sourceIconPath: development ? developmentMacIconPngPath : productionMacIconPngPath, - generatedIconPath: NodePath.join(runtimeDir, development ? "icon-dev.icns" : "icon-prod.icns"), + generatedIconPath: NodePath.posix.join( + runtimeDir, + development ? "icon-dev.icns" : "icon-prod.icns", + ), }; } @@ -280,12 +284,12 @@ function readJson(path) { } export function resolveMacLauncherPaths(appBundlePath, displayName = APP_DISPLAY_NAME) { - const executableDir = NodePath.join(appBundlePath, "Contents", "MacOS"); + const executableDir = NodePath.posix.join(appBundlePath, "Contents", "MacOS"); const launcherExecutableName = `${displayName} Launcher`; return { launcherExecutableName, - launcherBinaryPath: NodePath.join(executableDir, launcherExecutableName), - runtimeElectronBinaryPath: NodePath.join(executableDir, "Electron"), + launcherBinaryPath: NodePath.posix.join(executableDir, launcherExecutableName), + runtimeElectronBinaryPath: NodePath.posix.join(executableDir, "Electron"), }; } diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 1ed5a1b8ebf9..9d2a907c73d6 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -84,9 +84,10 @@ describe("electron development launcher", () => { const development = resolveMacLauncherIconPaths("/runtime", true); const production = resolveMacLauncherIconPaths("/runtime", false); - assert.match(development.sourceIconPath, /assets\/dev\/blueprint-macos-1024\.png$/); + // The source icons are real repo paths, joined for the host. + assert.match(development.sourceIconPath, /assets[\\/]dev[\\/]blueprint-macos-1024\.png$/); assert.equal(development.generatedIconPath, "/runtime/icon-dev.icns"); - assert.match(production.sourceIconPath, /assets\/prod\/black-macos-1024\.png$/); + assert.match(production.sourceIconPath, /assets[\\/]prod[\\/]black-macos-1024\.png$/); assert.equal(production.generatedIconPath, "/runtime/icon-prod.icns"); }); }); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 5c39ff304b3b..71bcf5f7aef1 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -88,6 +89,7 @@ const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodePath.layerPosix, DesktopConfig.layerTest({ ...env, }), diff --git a/apps/desktop/src/app/DesktopAssets.test.ts b/apps/desktop/src/app/DesktopAssets.test.ts index bb118d43d29a..78819d06e6cc 100644 --- a/apps/desktop/src/app/DesktopAssets.test.ts +++ b/apps/desktop/src/app/DesktopAssets.test.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -20,7 +21,11 @@ const environmentLayer = DesktopEnvironment.layer({ isPackaged: true, resourcesPath: "/Applications/T3 Code.app/Contents/Resources", runningUnderArm64Translation: false, -}).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({})))); +}).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, NodePath.layerPosix, DesktopConfig.layerTest({})), + ), +); describe("DesktopAssets", () => { it.effect("uses canonical source-tree icons for unpackaged development", () => @@ -39,6 +44,7 @@ describe("DesktopAssets", () => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodePath.layerPosix, DesktopConfig.layerTest({ VITE_DEV_SERVER_URL: "http://localhost:5733" }), ), ), diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index c58830b30a7f..aa28ff8d86eb 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -4,6 +4,7 @@ import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; @@ -226,10 +227,11 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("surfaces malformed catalog documents without deleting them", () => withStore( Effect.gen(function* () { + const path = yield* Path.Path; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const catalogPath = `${environment.stateDir}/connection-catalog.json`; + const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString(catalogPath, "{not-json"); @@ -247,6 +249,7 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("surfaces catalog filesystem failures instead of treating them as missing", () => Effect.gen(function* () { + const path = yield* Path.Path; const baseFileSystem = yield* FileSystem.FileSystem; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", @@ -255,7 +258,7 @@ describe("DesktopConnectionCatalogStore", () => { _tag: "PermissionDenied", module: "FileSystem", method: "readFileString", - pathOrDescriptor: `${baseDir}/userdata/connection-catalog.json`, + pathOrDescriptor: path.join(baseDir, "userdata", "connection-catalog.json"), }); const fileSystemLayer = Layer.succeed( FileSystem.FileSystem, @@ -272,11 +275,11 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, ); - assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); + assert.equal(error.catalogPath, path.join(baseDir, "userdata", "connection-catalog.json")); assert.strictEqual(error.cause, permissionError); assert.equal( error.message, - `Failed to read the desktop connection catalog at ${baseDir}/userdata/connection-catalog.json.`, + `Failed to read the desktop connection catalog at ${path.join(baseDir, "userdata", "connection-catalog.json")}.`, ); assert.notEqual(error.message, permissionError.message); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), @@ -285,6 +288,7 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("reports the failed catalog write operation and path", () => Effect.gen(function* () { const baseFileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", }); @@ -292,7 +296,7 @@ describe("DesktopConnectionCatalogStore", () => { _tag: "PermissionDenied", module: "FileSystem", method: "makeDirectory", - pathOrDescriptor: `${baseDir}/userdata`, + pathOrDescriptor: path.join(baseDir, "userdata"), }); const fileSystemLayer = Layer.succeed( FileSystem.FileSystem, @@ -310,11 +314,11 @@ describe("DesktopConnectionCatalogStore", () => { DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreWriteError, ); assert.equal(error.operation, "create-directory"); - assert.equal(error.path, `${baseDir}/userdata`); + assert.equal(error.path, path.join(baseDir, "userdata")); assert.strictEqual(error.cause, permissionError); assert.equal( error.message, - `Desktop connection catalog write failed during create-directory at ${baseDir}/userdata.`, + `Desktop connection catalog write failed during create-directory at ${path.join(baseDir, "userdata")}.`, ); assert.notEqual(error.message, permissionError.message); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), @@ -323,6 +327,7 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("reports the legacy migration stage", () => withStore( Effect.gen(function* () { + const path = yield* Path.Path; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; @@ -335,7 +340,7 @@ describe("DesktopConnectionCatalogStore", () => { DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreMigrationError, ); assert.equal(error.operation, "read-legacy-registry"); - assert.equal(error.catalogPath, `${environment.stateDir}/connection-catalog.json`); + assert.equal(error.catalogPath, path.join(environment.stateDir, "connection-catalog.json")); assert.instanceOf( error.cause, DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, @@ -345,7 +350,7 @@ describe("DesktopConnectionCatalogStore", () => { assert.exists(registryError.cause); assert.equal( error.message, - `Legacy desktop saved-environment migration failed during read-legacy-registry into ${environment.stateDir}/connection-catalog.json.`, + `Legacy desktop saved-environment migration failed during read-legacy-registry into ${path.join(environment.stateDir, "connection-catalog.json")}.`, ); assert.notEqual(error.message, registryError.message); }), @@ -355,10 +360,11 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("reports invalid encrypted catalog data without exposing it", () => withStore( Effect.gen(function* () { + const path = yield* Path.Path; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const catalogPath = `${environment.stateDir}/connection-catalog.json`; + const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString(catalogPath, '{"version":1,"encryptedCatalog":"%%%"}\n'); @@ -381,6 +387,7 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () => Effect.gen(function* () { + const path = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", @@ -399,14 +406,14 @@ describe("DesktopConnectionCatalogStore", () => { DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError, ); assert.equal(error.operation, "decrypt-catalog"); - assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); + assert.equal(error.catalogPath, path.join(baseDir, "userdata", "connection-catalog.json")); assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageDecryptError); const decryptError = error.cause as ElectronSafeStorage.ElectronSafeStorageDecryptError; assert.instanceOf(decryptError.cause, Error); assert.equal(decryptError.cause.message, "invalid encrypted catalog"); assert.equal( error.message, - `Desktop connection catalog protection failed during decrypt-catalog at ${baseDir}/userdata/connection-catalog.json.`, + `Desktop connection catalog protection failed during decrypt-catalog at ${path.join(baseDir, "userdata", "connection-catalog.json")}.`, ); assert.notEqual(error.message, decryptError.message); yield* Ref.set(failDecrypt, false); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 218e2c3e4ba2..89cc592831a7 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -26,7 +27,11 @@ const makeEnvironmentLayer = ( DesktopEnvironment.layer({ ...defaultInput, ...overrides, - }).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest(env)))); + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, NodePath.layerPosix, DesktopConfig.layerTest(env)), + ), + ); const makeEnvironment = ( overrides: Partial = {}, diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d1..4a8b516cb936 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -272,8 +272,6 @@ describe("DesktopServerExposure", () => { modeError, DesktopServerExposure.DesktopServerExposureModePersistenceError, ); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureSetModeError(modeError)); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(modeError)); assert.equal(modeError.mode, "network-accessible"); assert.strictEqual(modeError.cause, settingsFailure); assert.strictEqual(modeError.cause.cause, diskFailure); @@ -290,7 +288,6 @@ describe("DesktopServerExposure", () => { tailscaleError, DesktopServerExposure.DesktopTailscaleServePersistenceError, ); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(tailscaleError)); assert.equal(tailscaleError.enabled, true); assert.equal(tailscaleError.port, 8443); assert.strictEqual(tailscaleError.cause, settingsFailure); diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f04d2af7b1f6..6c3cd55527eb 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -244,7 +244,6 @@ export const DesktopServerExposureSetModeError = Schema.Union([ DesktopServerExposureModePersistenceError, ]); export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type; -export const isDesktopServerExposureSetModeError = Schema.is(DesktopServerExposureSetModeError); export const DesktopServerExposureError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, @@ -252,7 +251,6 @@ export const DesktopServerExposureError = Schema.Union([ DesktopTailscaleServePersistenceError, ]); export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; -export const isDesktopServerExposureError = Schema.is(DesktopServerExposureError); export interface DesktopServerExposureBackendConfig { readonly port: number; diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 3acaf7154508..2ed5a1f2f913 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -43,7 +43,6 @@ describe("ElectronDialog", () => { ); assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); - assert.isTrue(ElectronDialog.isElectronDialogError(error)); assert.strictEqual(error.ownerWindowId, 7); assert.strictEqual(error.defaultPath, "/workspace"); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 4300d9ab0d39..30ca73a5e143 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -73,7 +73,6 @@ export const ElectronDialogError = Schema.Union([ ElectronDialogShowErrorBoxError, ]); export type ElectronDialogError = typeof ElectronDialogError.Type; -export const isElectronDialogError = Schema.is(ElectronDialogError); export interface ElectronDialogPickFolderInput { readonly owner: Option.Option; diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9ae6f502b000..17f3e06039b6 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -36,6 +36,20 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens the Full Disk Access settings anchor", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openSystemSettings("full-disk-access"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [ + ["x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles"], + ]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("opens remote SSH editor URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 2ed13bfebd0f..0ac4f8f9cc6a 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,4 +1,8 @@ -import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts"; +import { + REMOTE_CAPABLE_EDITOR_IDS, + remoteSchemeForEditor, + type SystemSettingsPane, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,6 +10,20 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; +/** + * Deep links to individual System Settings panes. These are app-fixed, not + * renderer-supplied, so they skip `parseSafeExternalUrl` — which exists to keep + * arbitrary link schemes from reaching the OS handler — and open through their + * own path below. The pane rather than the URL crosses the IPC boundary, so a + * renderer can only ask for one of these known destinations. + * + * Full Disk Access uses the post-Ventura `PrivacySecurity.extension` anchor. + */ +const SYSTEM_SETTINGS_URLS: Record = { + "full-disk-access": + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", +}; + // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) // must reach the OS handler; every other non-web scheme stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); @@ -43,6 +61,8 @@ export class ElectronShell extends Context.Service< ElectronShell, { readonly openExternal: (rawUrl: unknown) => Effect.Effect; + /** Opens a known System Settings pane by identifier, not by URL. */ + readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronShell") {} @@ -59,6 +79,13 @@ export const make = ElectronShell.of({ ), ), }), + openSystemSettings: (pane) => + Effect.promise(() => + Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then( + () => true, + () => false, + ), + ), copyText: (text) => Effect.sync(() => { Electron.clipboard.writeText(text); diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 4b81943eff2b..b4028930af66 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -64,7 +64,6 @@ describe("ElectronTheme", () => { const error = yield* Effect.flip(electronTheme.setSource("dark")); assert.instanceOf(error, ElectronTheme.ElectronThemeSetSourceError); - assert.isTrue(ElectronTheme.isElectronThemeSetSourceError(error)); assert.strictEqual(error.source, "dark"); assert.strictEqual(error.cause, cause); assert.include(error.message, "dark"); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index ef47e3d0954f..24b2d856b9d2 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -19,8 +19,6 @@ export class ElectronThemeSetSourceError extends Schema.TaggedErrorClass { const error = yield* updater.checkForUpdates.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "beta"); assert.strictEqual(error.cause, cause); assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); @@ -89,7 +88,6 @@ describe("ElectronUpdater", () => { const error = yield* updater.downloadUpdate.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, cause); assert.equal( @@ -126,7 +124,6 @@ describe("ElectronUpdater", () => { .pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "alpha"); assert.equal(error.isSilent, true); assert.equal(error.isForceRunAfter, false); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 4157d29a9df8..8e044de65ad6 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -54,7 +54,6 @@ export const ElectronUpdaterError = Schema.Union([ ElectronUpdaterQuitAndInstallError, ]); export type ElectronUpdaterError = typeof ElectronUpdaterError.Type; -export const isElectronUpdaterError = Schema.is(ElectronUpdaterError); export class ElectronUpdater extends Context.Service< ElectronUpdater, diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index bebb0e5c4178..c802e595633a 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -79,7 +79,6 @@ describe("ElectronWindow", () => { const error = yield* electronWindow.create(options).pipe(Effect.flip); assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); - assert.isTrue(ElectronWindow.isElectronWindowCreateError(error)); assert.deepEqual(error.options, { title: "T3 Code", width: 1100, diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 5f6a9d34280b..9234399191cf 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -58,8 +58,6 @@ export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( "ElectronWindowOperationError", { diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts index fc311877f829..5533831f9b55 100644 --- a/apps/desktop/src/ipc/DesktopIpc.test.ts +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -41,7 +41,6 @@ describe("DesktopIpc", () => { const error = yield* Effect.flip(Effect.scoped(ipc.handle(invokeMethod))); assert.instanceOf(error, DesktopIpc.DesktopIpcRegistrationError); - assert.isTrue(DesktopIpc.isDesktopIpcError(error)); assert.strictEqual(error.handlerKind, "invoke"); assert.strictEqual(error.channel, invokeMethod.channel); assert.strictEqual(error.cause, cause); @@ -69,7 +68,6 @@ describe("DesktopIpc", () => { if (exit._tag === "Success") return; const error = Cause.squash(exit.cause); assert.instanceOf(error, DesktopIpc.DesktopIpcUnregistrationError); - assert.isTrue(DesktopIpc.isDesktopIpcError(error)); assert.strictEqual(error.handlerKind, "sync"); assert.strictEqual(error.channel, syncMethod.channel); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index e948571cc628..643543d4ec33 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -55,7 +55,6 @@ export const DesktopIpcError = Schema.Union([ DesktopIpcUnregistrationError, ]); export type DesktopIpcError = typeof DesktopIpcError.Type; -export const isDesktopIpcError = Schema.is(DesktopIpcError); export interface DesktopIpcMethod { readonly channel: string; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 2cdffbefb7ad..3e30083064af 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -38,6 +38,7 @@ import { getSystemLocale, getWindowFullscreenState, openExternal, + openSystemSettings, probeRemoteEditors, pickFolder, pickProjectFavicon, @@ -94,6 +95,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(openSystemSettings); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 81b50d165d24..5b2c815eaa42 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index edae8394302c..61de1361a311 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -9,6 +9,7 @@ import { PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, REMOTE_CAPABLE_EDITOR_IDS, + SystemSettingsPaneSchema, type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; @@ -298,6 +299,16 @@ export const openExternal = DesktopIpc.makeIpcMethod({ }), }); +export const openSystemSettings = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, + payload: SystemSettingsPaneSchema, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.openSystemSettings")(function* (pane) { + const shell = yield* ElectronShell.ElectronShell; + return yield* shell.openSystemSettings(pane); + }), +}); + export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, payload: Schema.Undefined, diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index a91790200771..5827e38e406f 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { normalizeLinuxPasswordStorePreference, resolveLinuxPasswordStoreSwitch, - resolveLinuxSecretStorageUnavailableMessage, } from "./linuxSecretStorage.ts"; const autoSwitch = (env: NodeJS.ProcessEnv) => @@ -124,80 +123,4 @@ describe("linuxSecretStorage", () => { }), ).toBe("gnome-libsecret"); }); - - it("uses GNOME Keyring remediation for libsecret and unknown backends", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit libsecret selection over KDE desktop heuristics", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "gnome-libsecret", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit KWallet preference over selected gnome-libsecret backend", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet6", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet", - selectedBackend: "gnome-libsecret", - env: {}, - }), - ).toContain("KWallet"); - }); - - it("uses KWallet remediation wording for KDE-looking sessions", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "kwallet6", - env: {}, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { DESKTOP_SESSION: "plasmawayland" }, - }), - ).toContain("KWallet"); - // A desktop name outranks a bare KDE marker when choosing the wording. - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { GDMSESSION: "gnome", KDE_FULL_SESSION: "true" }, - }), - ).toContain("GNOME Keyring"); - }); }); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index fe3e21eadb92..3aa7a440d1e8 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -25,9 +25,6 @@ const ELECTRON_KDE_DESKTOP = "KDE"; // Chromium recognizes LXQt and still selects basic text for it, so it does need a forced backend. const ELECTRON_UNPROTECTED_DESKTOPS = new Set(["LXQt"]); -const KDE_NAME_PREFIXES = ["kde", "plasma"]; -const NEGATIVE_FLAG_VALUES = new Set(["0", "false", "no", "off"]); - export function normalizeLinuxPasswordStorePreference( value: unknown, ): LinuxPasswordStorePreference { @@ -77,102 +74,6 @@ function electronSelectsProtectedBackend(env: NodeJS.ProcessEnv): boolean { return false; } -export function resolveLinuxSecretStorageUnavailableMessage(input: { - readonly configuredPreference: LinuxPasswordStorePreference; - readonly selectedBackend: string | null; - readonly env: NodeJS.ProcessEnv; -}): string { - if (input.configuredPreference === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - input.configuredPreference === "kwallet" || - input.configuredPreference === "kwallet5" || - input.configuredPreference === "kwallet6" - ) { - return getKWalletRemediationMessage(); - } - - const backend = normalizeSelectedStorageBackend(input.selectedBackend); - if (backend === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - backend === "kwallet" || - backend === "kwallet5" || - backend === "kwallet6" || - looksLikeKdeSession(input.env) - ) { - return getKWalletRemediationMessage(); - } - - return getGnomeKeyringRemediationMessage(); -} - -function getGnomeKeyringRemediationMessage(): string { - return "T3 Code could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart T3 Code."; -} - -function getKWalletRemediationMessage(): string { - return "T3 Code could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart T3 Code."; -} - -// Advisory only: this picks between the GNOME Keyring and KWallet wording in the failure notice. It -// never decides which backend to select, so a loose match costs a user slightly wrong instructions -// rather than an unprotected credential store. -function looksLikeKdeSession(env: NodeJS.ProcessEnv): boolean { - const currentDesktopNames = nonEmptyDesktopNames(env.XDG_CURRENT_DESKTOP); - if (currentDesktopNames.length > 0) { - return currentDesktopNames.some(isKdeDesktopName); - } - - const legacyNames = legacyDesktopNames(env); - if (legacyNames.length > 0) { - return legacyNames.some(isKdeDesktopName); - } - - return isSet(env.KDE_SESSION_VERSION) || isAffirmativeFlag(env.KDE_FULL_SESSION); -} - -function isKdeDesktopName(name: string): boolean { - return KDE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix)); -} - -function legacyDesktopNames(env: NodeJS.ProcessEnv): string[] { - return [env.XDG_SESSION_DESKTOP, env.DESKTOP_SESSION, env.GDMSESSION].flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function nonEmptyDesktopNames(value: string | undefined): string[] { - return splitDesktopNameList(value).flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function isSet(value: string | undefined): boolean { - return Boolean(value?.trim()); -} - -function isAffirmativeFlag(value: string | undefined): boolean { - const normalized = value?.trim().toLowerCase(); - return normalized ? !NEGATIVE_FLAG_VALUES.has(normalized) : false; -} - function splitDesktopNameList(value: string | undefined): string[] { return value?.split(":") ?? []; } - -function normalizeDesktopName(value: string | undefined): string | null { - const normalized = value?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeSelectedStorageBackend(value: string | null): string | null { - const normalized = value?.trim().toLowerCase().replace(/_/gu, "-"); - return normalized && normalized.length > 0 ? normalized : null; -} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 685a9b1204db..74001dd785d3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -116,6 +116,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + openSystemSettings: (pane: string) => + ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 9b0a652f09f1..003461085376 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -14,6 +14,7 @@ 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"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; @@ -105,28 +106,30 @@ describe("BrowserImport.importCookies", () => { }).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); + it.effect.skipIf(!symlinksSupported)( + "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), + assert.equal(error.reason, "browserRunning"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index e92f2f05e05c..386b3ef6f813 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -27,6 +27,7 @@ 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 { readSafariCookies, safariAccessDenied, SafariCookieReadError } from "./SafariCookies.ts"; import { BROWSER_IMPORT_SOURCES, resolveCookieDatabase, @@ -92,6 +93,15 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; if (yield* isSourceRunning(definition, context)) return "browserRunning"; + // Safari's jar is found by `stat`, which TCC permits without Full Disk + // Access — so a Safari that lists as ready may still refuse the read. Probe + // the grant here, so the wizard can open on the permission step and a + // post-grant recheck can tell granted from still-denied, rather than only + // discovering it by attempting the import. + if (definition.engine === "safari") { + const jar = yield* resolveCookieDatabase(definition, context, "."); + if (jar !== undefined && (yield* safariAccessDenied(jar))) return "needsFullDiskAccess"; + } return undefined; }); @@ -254,25 +264,29 @@ export const make = Effect.gen(function* BrowserImportMake() { const userDataDirectory = definition.userDataDirectory(pathContext); const read: Effect.Effect< CookieReadResult, - ChromiumCookieReadError | FirefoxCookieReadError, + ChromiumCookieReadError | FirefoxCookieReadError | SafariCookieReadError, FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner > = - definition.engine === "firefox" - ? readFirefoxCookies(databasePath).pipe( + definition.engine === "safari" + ? readSafariCookies(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, - }); + : 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, @@ -289,6 +303,12 @@ export const make = Effect.gen(function* BrowserImportMake() { Effect.fail( new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }), ), + // Safari's reasons are already user-facing: a TCC refusal is the Full + // Disk Access prompt, anything else is a read failure. + SafariCookieReadError: (cause) => + Effect.fail( + new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), + ), }), ); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts new file mode 100644 index 000000000000..f5d07f765943 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -0,0 +1,443 @@ +// @effect-diagnostics nodeBuiltinImport:off - Hand-builds Safari's binary jar +// format byte by byte. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; + +import { + isPermissionDenied, + parseBinaryCookies, + readSafariCookies, + safariAccessDenied, + SafariCookieReadError, +} from "./SafariCookies.ts"; + +const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; + +interface FixtureCookie { + readonly domain: string; + readonly name: string; + readonly path: string; + readonly value: string; + readonly flags: number; + /** Seconds since 2001-01-01, as Safari stores them. */ + readonly expiry: number; +} + +/** Encodes one cookie exactly as Safari lays it out. */ +function encodeCookie(cookie: FixtureCookie): Buffer { + const strings = [cookie.domain, cookie.name, cookie.path, cookie.value]; + const headerSize = 56; + const offsets: number[] = []; + let cursor = headerSize; + for (const value of strings) { + offsets.push(cursor); + cursor += Buffer.byteLength(value) + 1; + } + const size = cursor; + + const buffer = Buffer.alloc(size); + buffer.writeUInt32LE(size, 0); + buffer.writeUInt32LE(0, 4); + buffer.writeUInt32LE(cookie.flags, 8); + buffer.writeUInt32LE(0, 12); + buffer.writeUInt32LE(offsets[0]!, 16); + buffer.writeUInt32LE(offsets[1]!, 20); + buffer.writeUInt32LE(offsets[2]!, 24); + buffer.writeUInt32LE(offsets[3]!, 28); + buffer.writeUInt32LE(0, 32); + buffer.writeUInt32LE(0, 36); + buffer.writeDoubleLE(cookie.expiry, 40); + buffer.writeDoubleLE(0, 48); + strings.forEach((value, index) => { + buffer.write(value, offsets[index]!, "utf8"); + }); + return buffer; +} + +/** Builds a single-page `Cookies.binarycookies` file. */ +function encodeBinaryCookies(cookies: ReadonlyArray): Buffer { + const encoded = cookies.map(encodeCookie); + const headerSize = 12 + encoded.length * 4; + const offsets: number[] = []; + let cursor = headerSize; + for (const cookie of encoded) { + offsets.push(cursor); + cursor += cookie.length; + } + + const page = Buffer.alloc(cursor); + page.writeUInt32BE(0x0000_0100, 0); + page.writeUInt32LE(encoded.length, 4); + offsets.forEach((offset, index) => page.writeUInt32LE(offset, 8 + index * 4)); + encoded.forEach((cookie, index) => cookie.copy(page, offsets[index]!)); + + const header = Buffer.alloc(8 + 4); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(1, 4); + header.writeUInt32BE(page.length, 8); + return Buffer.concat([header, page]); +} + +describe("parseBinaryCookies", () => { + it("reads Safari's format and rebases its 2001 epoch", () => { + const file = encodeBinaryCookies([ + { + domain: ".apple.com", + name: "session", + path: "/", + value: "abc", + // secure | httpOnly + flags: 0x1 | 0x4, + expiry: 800_000_000, + }, + { + domain: "example.test", + name: "plain", + path: "/app", + value: "v", + flags: 0, + expiry: 0, + }, + ]); + + expect(parseBinaryCookies(file)).toEqual([ + { + url: "https://apple.com/", + name: "session", + value: "abc", + domain: ".apple.com", + path: "/", + secure: true, + httpOnly: true, + // Safari counts from 2001-01-01, Electron from 1970. + expirationDate: 800_000_000 + APPLE_EPOCH_OFFSET_SECONDS, + // The format predates SameSite; Lax is the safe modern default. + sameSite: "lax", + }, + { + url: "http://example.test/app", + name: "plain", + value: "v", + // Host-only: no leading dot in the jar, so no `domain` for Electron, + // which would otherwise re-add the dot and widen it to subdomains. + domain: undefined, + path: "/app", + secure: false, + httpOnly: false, + expirationDate: undefined, + sameSite: "lax", + }, + ]); + }); + + it("keeps __Host- cookies host-only so Electron accepts them", () => { + const file = encodeBinaryCookies([ + { domain: "example.test", name: "__Host-id", path: "/", value: "v", flags: 0x1, expiry: 0 }, + ]); + + expect(parseBinaryCookies(file)[0]).toMatchObject({ + url: "https://example.test/", + name: "__Host-id", + domain: undefined, + }); + }); + + it("brackets IPv6 hosts in the cookie URL", () => { + const file = encodeBinaryCookies([ + { domain: "::1", name: "local", path: "/", value: "v", flags: 0, expiry: 0 }, + ]); + + expect(parseBinaryCookies(file)[0]).toMatchObject({ + url: "http://[::1]/", + domain: undefined, + }); + }); + + it("reads cookies spread across multiple pages", () => { + // Safari pages its cookie file, and a single-page reader would silently + // return only the first slice. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, + ]); + const second = encodeBinaryCookies([ + { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, + ]); + // Splice the two single-page files into one two-page file. + const firstPage = first.subarray(12); + const secondPage = second.subarray(12); + const header = Buffer.alloc(16); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(2, 4); + header.writeUInt32BE(firstPage.length, 8); + header.writeUInt32BE(secondPage.length, 12); + + const parsed = parseBinaryCookies(Buffer.concat([header, firstPage, secondPage])); + + expect(parsed.map((cookie) => cookie.name)).toEqual(["one", "two"]); + }); + + it("rejects a page that runs past the end of the file", () => { + // `Buffer.subarray` clamps rather than throwing, so an overlong first page + // swallows the second one's bytes and advances the cursor past the end. + // Every cookie after the boundary then vanishes from a "successful" import. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, + ]); + const second = encodeBinaryCookies([ + { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, + ]); + const firstPage = first.subarray(12); + const secondPage = second.subarray(12); + const header = Buffer.alloc(16); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(2, 4); + // Declares more bytes for page one than the file holds in total. + header.writeUInt32BE(firstPage.length + secondPage.length + 32, 8); + header.writeUInt32BE(secondPage.length, 12); + + expect(() => parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]))).toThrow( + SafariCookieReadError, + ); + }); + + it("rejects a record whose declared size runs past its page", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + ]); + // The record's own length is what bounds its string offsets; an inflated + // one lets them read the following record's bytes as this cookie's value. + const pageStart = 8 + 4; + const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); + const corrupt = Buffer.from(valid); + corrupt.writeUInt32LE(0xffff, recordStart); + + expect(() => parseBinaryCookies(corrupt)).toThrow(SafariCookieReadError); + }); + + it("rejects records truncated inside the 56-byte header", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + ]); + const pageStart = 8 + 4; + const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); + + for (let size = 48; size < 56; size += 1) { + const corrupt = Buffer.from(valid); + corrupt.writeUInt32LE(size, recordStart); + expect(() => parseBinaryCookies(corrupt), `record size ${size}`).toThrow( + SafariCookieReadError, + ); + } + }); + + it("rejects record offsets that point into the page header or an earlier record", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + { domain: "b.test", name: "m", path: "/", value: "w", expiry: 1_000, flags: 0 }, + ]); + const pageStart = 8 + 4; + const firstRecord = valid.readUInt32LE(pageStart + 8); + + // Pointing the second offset at the page's offset table would let those + // table bytes parse as a fabricated record. + const intoTable = Buffer.from(valid); + intoTable.writeUInt32LE(4, pageStart + 12); + expect(() => parseBinaryCookies(intoTable)).toThrow(SafariCookieReadError); + + // Pointing it back at the first record makes the same bytes count twice. + const overlapping = Buffer.from(valid); + overlapping.writeUInt32LE(firstRecord, pageStart + 12); + expect(() => parseBinaryCookies(overlapping)).toThrow(SafariCookieReadError); + + // And a well-formed two-record page still parses. + expect(parseBinaryCookies(valid)).toHaveLength(2); + }); + + it("rejects string offsets that point into the record header", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + ]); + const pageStart = 8 + 4; + const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); + + for (const offsetField of [16, 20, 24, 28]) { + const corrupt = Buffer.from(valid); + corrupt.writeUInt32LE(55, recordStart + offsetField); + expect(() => parseBinaryCookies(corrupt), `offset field ${offsetField}`).toThrow( + SafariCookieReadError, + ); + } + }); + + it("accepts the checksum and property-list trailer Safari writes", () => { + const file = encodeBinaryCookies([ + { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, + ]); + const checksum = Buffer.alloc(8); + const plist = Buffer.from("bplist00 stub"); + const plistLength = Buffer.alloc(4); + plistLength.writeUInt32BE(plist.length, 0); + + expect(parseBinaryCookies(Buffer.concat([file, checksum]))).toHaveLength(1); + expect(parseBinaryCookies(Buffer.concat([file, checksum, plistLength, plist]))).toHaveLength(1); + }); + + it("rejects a jar whose page table stops short of its contents", () => { + // A second, undeclared page after the first would be silently dropped — + // the cookies it holds vanish from the import with no error — so a file + // the header does not fully describe is refused instead. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, + ]); + const extraPage = encodeBinaryCookies([ + { domain: "b.test", name: "d", path: "/", value: "w", flags: 0, expiry: 0 }, + ]).subarray(12); + + expect(() => parseBinaryCookies(Buffer.concat([first, extraPage]))).toThrow( + SafariCookieReadError, + ); + // A trailer that claims a property list it doesn't contain is refused too. + const badLength = Buffer.alloc(4); + badLength.writeUInt32BE(99, 0); + expect(() => + parseBinaryCookies(Buffer.concat([first, Buffer.alloc(8), badLength, Buffer.from("x")])), + ).toThrow(SafariCookieReadError); + }); + + it("rejects a file that is not binarycookies", () => { + expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow( + SafariCookieReadError, + ); + }); +}); + +describe("readSafariCookies", () => { + it.effect("adds the cookie path and parser cause to malformed jar failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fileSystem.writeFileString(jar, "not a cookie jar"); + + const error = yield* readSafariCookies(jar).pipe(Effect.flip); + + assert.equal(error.reason, "readFailed"); + assert.equal(error.cookieDatabasePath, jar); + assert.instanceOf(error.cause, SafariCookieReadError); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports a TCC denial as a permission the user can grant", () => + Effect.gen(function* () { + // What Full Disk Access actually looks like: the file is there, the read + // is refused with EPERM. Effect tags that `Unknown`, not + // `PermissionDenied`, so the reader has to look at the errno. Reporting + // it as a generic failure would send the user looking for a missing + // browser instead of a checkbox. + const denied = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: "/protected/Cookies.binarycookies", + cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), + }); + + const error = yield* readSafariCookies("/protected/Cookies.binarycookies").pipe( + Effect.flip, + Effect.provide(FileSystem.layerNoop({ readFile: () => Effect.fail(denied) })), + ); + + assert.equal(error.reason, "needsFullDiskAccess"); + }), + ); + + it.effect("reports an ordinary permission failure as a plain read failure", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); + // A mode-bits refusal is EACCES: granting Full Disk Access cannot fix + // it, so it must not be routed to that grant. + yield* fileSystem.chmod(jar, 0o000); + + const error = yield* readSafariCookies(jar).pipe(Effect.flip); + + assert.equal(error.reason, "readFailed"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports a missing jar as a plain read failure", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + + const error = yield* readSafariCookies(`${directory}/absent.binarycookies`).pipe(Effect.flip); + + assert.equal(error.reason, "readFailed"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); + +describe("safariAccessDenied", () => { + const eperm = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "open", + pathOrDescriptor: "/protected/Cookies.binarycookies", + cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), + }); + const denied = (error: PlatformError.PlatformError) => + FileSystem.layerNoop({ open: () => Effect.fail(error) }); + + it.effect("reports TCC's EPERM as a missing Full Disk Access grant", () => + Effect.gen(function* () { + // `stat` finds the jar without the grant, so only an open tells the + // listing whether the import would actually be allowed. + assert.isTrue( + yield* safariAccessDenied("/protected/Cookies.binarycookies").pipe( + Effect.provide(denied(eperm)), + ), + ); + }), + ); + + it.effect("does not read a readable jar, or any other failure, as denied", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); + assert.isFalse(yield* safariAccessDenied(jar)); + // Missing entirely is "not installed", not "denied". + assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); + +describe("isPermissionDenied", () => { + // Shapes taken from a real `FileSystem.readFile` failure on macOS — verified + // against Safari's TCC-protected jar, whose denial is EPERM, tagged + // `Unknown` rather than `PermissionDenied`. + const platformError = (reasonTag: string, code: string): PlatformError.PlatformError => + ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never; + + it("treats a TCC EPERM denial as permission denied", () => { + // The regression: EPERM is tagged `Unknown`, so checking the tag alone + // reported Safari's Full Disk Access refusal as a generic read failure. + expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true); + }); + + it("does not send an ordinary EACCES failure to the Full Disk Access grant", () => { + // A POSIX permission or ACL refusal cannot be fixed by granting Full Disk + // Access, so it stays a plain read failure; only TCC's EPERM routes there. + expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(false); + }); + + it("does not treat an unrelated failure as permission denied", () => { + expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false); + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts new file mode 100644 index 000000000000..88aa856b83e9 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -0,0 +1,263 @@ +/** + * Safari cookie extraction. + * + * Safari does not encrypt its cookies; it stores them in a proprietary + * `Cookies.binarycookies` file inside its app container. The protection is + * TCC, not cryptography — the file lives under a path only apps with Full Disk + * Access may read, so the gate is a permission the user grants in System + * Settings rather than a key to obtain. + * + * The format, big-endian throughout except the page bodies: + * + * magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page: + * u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount], + * then each cookie: + * u32le size, u32le unknown, u32le flags, u32le unknown, + * u32le urlOffset, nameOffset, pathOffset, valueOffset, + * u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated + * strings at the offsets above (relative to the cookie start). + * + * @module SafariCookies + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +import { cookieScope, type ImportedCookie } from "./CookieDatabase.ts"; + +/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */ +const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; + +/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */ +const COOKIE_PAGE_HEADER_SIZE = 12; +/** Through the `f64 creation` field; string bytes follow. */ +const COOKIE_RECORD_HEADER_SIZE = 56; + +const FLAG_SECURE = 0x1; +const FLAG_HTTP_ONLY = 0x4; + +export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]); +export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type; + +export class SafariCookieReadError extends Schema.TaggedErrorClass()( + "SafariCookieReadError", + { + reason: SafariCookieReadFailure, + /** + * Which jar the read was for. The parser raises this before a path is in + * hand, so it is optional rather than required. + */ + cookieDatabasePath: Schema.optional(Schema.String), + /** Kept for the log; never surfaced to the user. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.cookieDatabasePath === undefined + ? `Could not read Safari cookies: ${this.reason}.` + : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`; + } +} + +const isSafariCookieReadError = Schema.is(SafariCookieReadError); + +/** Reads a NUL-terminated ASCII string at an offset. */ +function readCString(buffer: Buffer, start: number): string { + const end = buffer.indexOf(0, start); + return buffer.toString("utf8", start, end === -1 ? buffer.length : end); +} + +export function parseBinaryCookies(buffer: Buffer): ReadonlyArray { + if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + + const pageCount = buffer.readUInt32BE(4); + // Every declared structure is bounds-checked against what the file actually + // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently, + // so accepting a short page or an overlong record would return a cookie set + // that is quietly missing entries or carrying fields read out of the next + // record — a partial import the user has no way to notice. + if (8 + pageCount * 4 > buffer.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + const pageSizes: number[] = []; + for (let index = 0; index < pageCount; index += 1) { + pageSizes.push(buffer.readUInt32BE(8 + index * 4)); + } + + const cookies: ImportedCookie[] = []; + let pageStart = 8 + pageCount * 4; + + for (const pageSize of pageSizes) { + if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + const page = buffer.subarray(pageStart, pageStart + pageSize); + pageStart += pageSize; + + // Page bodies switch to little-endian after the big-endian header. + const cookieCount = page.readUInt32LE(4); + const offsetTableEnd = COOKIE_PAGE_HEADER_SIZE + cookieCount * 4; + if (offsetTableEnd > page.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + // Every record accepted so far, so a later offset cannot point back into + // one of them: the page header, the offset table, and earlier records are + // all bytes that would otherwise parse as a fabricated cookie. + const accepted: Array = []; + for (let index = 0; index < cookieCount; index += 1) { + const cookieStart = page.readUInt32LE(8 + index * 4); + if (cookieStart < offsetTableEnd || cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + // Bounded by the record's own length so a string offset cannot run past + // it into the following record's bytes. + const recordSize = page.readUInt32LE(cookieStart); + const cookieEnd = cookieStart + recordSize; + if ( + recordSize < COOKIE_RECORD_HEADER_SIZE || + cookieEnd > page.length || + accepted.some(([start, end]) => cookieStart < end && cookieEnd > start) + ) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + accepted.push([cookieStart, cookieEnd]); + const cookie = page.subarray(cookieStart, cookieEnd); + + const flags = cookie.readUInt32LE(8); + const urlOffset = cookie.readUInt32LE(16); + const nameOffset = cookie.readUInt32LE(20); + const pathOffset = cookie.readUInt32LE(24); + const valueOffset = cookie.readUInt32LE(28); + const expiry = cookie.readDoubleLE(40); + + // Offsets are relative to the record; one pointing outside it would + // otherwise read a neighbouring cookie's bytes as this one's value. + if ( + [urlOffset, nameOffset, pathOffset, valueOffset].some( + (offset) => offset < COOKIE_RECORD_HEADER_SIZE || offset >= cookie.length, + ) + ) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + const domain = readCString(cookie, urlOffset); + const name = readCString(cookie, nameOffset); + const path = readCString(cookie, pathOffset); + const value = readCString(cookie, valueOffset); + if (domain === "" || name === "") continue; + + const secure = (flags & FLAG_SECURE) !== 0; + const expirationDate = + expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined; + + cookies.push({ + // Safari marks domain cookies with a leading dot like the other + // engines, so the shared scope rule applies: host-only cookies keep + // `domain` undefined, or Electron widens them to every subdomain. + ...cookieScope(domain, path || "/", secure), + name, + value, + path: path || "/", + secure, + httpOnly: (flags & FLAG_HTTP_ONLY) !== 0, + expirationDate, + // Bits 3–5 of the flags carry something SameSite-shaped, but no public + // description of them agrees and real jars do not match any of them + // cleanly. Lax is the modern browser default; claiming "none" would + // widen every imported cookie's scope. + sameSite: "lax", + }); + } + } + + // Safari writes an 8-byte checksum after the pages, then an optional + // length-prefixed property list. Anything else past the declared pages — + // in particular whole extra pages — means the page table does not describe + // the file, and a jar the header lies about is refused rather than + // imported with cookies silently missing. + const trailer = buffer.length - pageStart; + // Legal shapes: nothing, the 8-byte checksum alone, or checksum + u32 + // length + exactly that many property-list bytes. + const validTrailer = + trailer === 0 || + trailer === 8 || + (trailer >= 12 && trailer === 8 + 4 + buffer.readUInt32BE(pageStart + 8)); + if (!validTrailer) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + + return cookies; +} + +/** + * Whether a filesystem error is the OS refusing access. + * + * A TCC denial arrives as EPERM, which Effect tags `Unknown` rather than + * `PermissionDenied` (reserved for EACCES), so the underlying errno is checked + * too — otherwise a Full Disk Access refusal is reported as a generic read + * failure and the user is never told what to grant. + */ +export const isPermissionDenied = (error: PlatformError.PlatformError): boolean => { + // TCC denies with EPERM, which Effect tags `Unknown` rather than + // `PermissionDenied` — so the errno is what identifies it. EACCES (and the + // `PermissionDenied` tag it maps to) is an ordinary POSIX permission or + // ACL failure that granting Full Disk Access cannot fix, so it stays a plain + // read failure rather than sending the user to a grant that won't help. + const code = (error.reason as { cause?: { code?: unknown } }).cause?.code; + return code === "EPERM"; +}; + +/** + * Whether reading the jar is refused by TCC. `stat` succeeds on the jar + * inside Safari's container even without Full Disk Access — that is what lets + * the listing find it — so presence alone cannot tell granted from denied. + * Opening it for read is what TCC gates: EPERM means the grant is missing. + * Anything else (including a missing jar) is not a permission answer. + */ +export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe( + Effect.as(false), + Effect.catch((cause) => Effect.succeed(isPermissionDenied(cause))), + Effect.scoped, + ); +}); + +export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* ( + cookiePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const contents = yield* fileSystem.readFile(cookiePath).pipe( + Effect.mapError((cause) => { + // TCC denies the read even though the file exists — a permission the user + // grants in System Settings rather than a missing browser. macOS never + // prompts for Full Disk Access, so there is no dialog to wait on; the + // read just fails, and it fails with EPERM, which Effect surfaces as an + // `Unknown` system error rather than `PermissionDenied` (that is EACCES). + return new SafariCookieReadError({ + reason: isPermissionDenied(cause) ? "needsFullDiskAccess" : "readFailed", + cookieDatabasePath: cookiePath, + cause, + }); + }), + ); + // The parser throws on a malformed jar; catch it here so callers see a typed + // failure rather than a defect. + return yield* Effect.try({ + try: () => parseBinaryCookies(Buffer.from(contents)), + catch: (cause) => + isSafariCookieReadError(cause) + ? new SafariCookieReadError({ + reason: cause.reason, + cookieDatabasePath: cookiePath, + cause, + }) + : new SafariCookieReadError({ + reason: "readFailed", + cookieDatabasePath: cookiePath, + cause, + }), + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index feaac842cbef..a867f78497b4 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off - Builds a Chromium-shaped cookie // table with the same native bindings the source reads. +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { @@ -32,6 +33,7 @@ import { sourcePathContext, windowsChromiumCookiesAreHeld, } from "./Sources.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; @@ -125,7 +127,7 @@ const writeFirefoxCookieDatabase = ( }); describe("Helium on Linux", () => { - it.effect("discovers its profiles and checks the user-data lock", () => + it.effect.skipIf(!symlinksSupported)("discovers its profiles and checks the user-data lock", () => run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -221,48 +223,52 @@ describe("isSourceRunning", () => { ), ); - 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`, - ); + it.effect.skipIf(!symlinksSupported)( + "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)); - }), - ), + 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`, - ); + it.effect.skipIf(!symlinksSupported)( + "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"), - ), - ); - }), - ), + assert.isTrue( + yield* isSourceRunning(helium, paths).pipe( + Effect.provideService(HostProcessHostname, "another-host"), + ), + ); + assert.isFalse( + yield* isSourceRunning(helium, paths).pipe( + Effect.provideService(HostProcessHostname, "lock-owner"), + ), + ); + }), + ), ); }); @@ -385,25 +391,27 @@ describe("isSourceInstalled", () => { ), ); - 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`); + it.effect.skipIf(!symlinksSupported)( + "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)); + 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)); - }), - ), + yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db"); + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Default", name: "Default" }, + ]); + assert.isTrue(yield* isSourceInstalled(helium, context)); + }), + ), ); }); @@ -524,7 +532,7 @@ Path=Profiles/wxyz.empty yield* fileSystem.makeDirectory(`${root}/Profiles/wxyz.empty`, { recursive: true }); assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory: "Profiles/abcd.default-release", name: "original" }, + { directory: context.path.join("Profiles", "abcd.default-release"), name: "original" }, ]); }), ), @@ -605,10 +613,16 @@ describe("cookieDatabaseCandidatePaths", () => { run( Effect.gen(function* () { const context = yield* withSourceHome(); - const profile = `${context.home}/Library/Application Support/net.imput.helium/Profile 1`; + const profile = context.path.join( + context.home, + "Library", + "Application Support", + "net.imput.helium", + "Profile 1", + ); assert.deepEqual(cookieDatabaseCandidatePaths(helium, context, "Profile 1"), [ - `${profile}/Network/Cookies`, - `${profile}/Cookies`, + context.path.join(profile, "Network", "Cookies"), + context.path.join(profile, "Cookies"), ]); }), ), @@ -619,7 +633,7 @@ describe("cookieDatabaseCandidatePaths", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const context = yield* withSourceHome(); - const root = helium.userDataDirectory(context); + const root = 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 }); @@ -628,7 +642,7 @@ describe("cookieDatabaseCandidatePaths", () => { assert.equal( yield* resolveCookieDatabase(helium, context, "Default"), - `${root}/Default/Network/Cookies`, + context.path.join(root, "Default", "Network", "Cookies"), ); // A fresh install with only the Network/ jar is installed, not hidden. yield* fileSystem.remove(`${root}/Default/Cookies`); @@ -660,44 +674,48 @@ describe("cookieDatabaseCandidatePaths", () => { 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", - ); + it.effect.skipIf(!symlinksSupported)( + "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 = context.path.join(home, "snap", "firefox", "common", ".mozilla", "firefox"); + const directory = context.path.join(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)); + assert.isTrue(yield* isSourceInstalled(firefox, context)); + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory, name: "Personal", cookieCount: 2 }, + ]); + assert.equal( + yield* resolveCookieDatabase(firefox, context, directory), + context.path.join(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 }, - ]); - }), - ), + 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", () => @@ -709,8 +727,8 @@ describe("Firefox Snap profiles", () => { Effect.provideService(HostProcessEnvironment, { HOME: home }), Effect.provideService(HostProcessPlatform, "linux"), ); - const native = `${home}/.mozilla/firefox`; - const snap = `${home}/snap/firefox/common/.mozilla/firefox`; + const native = context.path.join(home, ".mozilla", "firefox"); + const snap = context.path.join(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); @@ -724,14 +742,14 @@ describe("Firefox Snap profiles", () => { const profiles = yield* listSourceProfiles(firefox, context); assert.deepEqual( profiles.map((profile) => profile.directory), - ["abcd.default", `${snap}/abcd.default`], + ["abcd.default", context.path.join(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`, + context.path.join(native, "abcd.default", "cookies.sqlite"), + context.path.join(snap, "abcd.default", "cookies.sqlite"), ]); }), ), @@ -741,8 +759,8 @@ describe("Firefox Snap profiles", () => { 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" }, + { platform: "darwin" as const, profileDirectory: NodePath.join("Profiles", "macos.default") }, + { platform: "win32" as const, profileDirectory: NodePath.join("Profiles", "windows.default") }, ]; for (const { platform, profileDirectory } of cases) { @@ -813,7 +831,11 @@ describe("listSourceProfiles Firefox fallback", () => { // 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 }, + { + directory: path.join("Profiles", "real.default"), + name: "real.default", + cookieCount: 3, + }, ]); assert.isTrue(yield* isSourceInstalled(firefox, context)); }), @@ -844,7 +866,11 @@ describe("listSourceProfiles Firefox fallback", () => { ); assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory: "Profiles/declared.default", name: "Declared", cookieCount: 2 }, + { + directory: path.join("Profiles", "declared.default"), + name: "Declared", + cookieCount: 2, + }, ]); yield* fileSystem.remove(path.join(root, "profiles.ini")); @@ -853,8 +879,16 @@ describe("listSourceProfiles Firefox fallback", () => { 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 }, + { + directory: path.join("Profiles", "declared.default"), + name: "declared.default", + cookieCount: 2, + }, + { + directory: path.join("Profiles", "fallback.default"), + name: "fallback.default", + cookieCount: 1, + }, ]); }), ), @@ -862,7 +896,7 @@ describe("listSourceProfiles Firefox fallback", () => { }); describe("isSourceRunning for Firefox", () => { - it.effect("finds the lock inside the profile, not at the root", () => + it.effect.skipIf(!symlinksSupported)("finds the lock inside the profile, not at the root", () => run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -917,53 +951,56 @@ describe("isSourceRunning for Firefox", () => { ), ); - 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, - ); + // Holds the lock with python3's fcntl, which does not exist on Windows. + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "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(); - }), - ), + assert.isTrue(yield* isSourceRunning(firefox, context)); + yield* holder.kill(); + }), + ), ); it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () => @@ -1056,3 +1093,106 @@ describe("listSourceProfiles hardening", () => { ), ); }); + +describe("Safari profiles", () => { + const safari = BROWSER_IMPORT_SOURCES.find((source) => source.id === "safari")!; + const workUuid = "C561D071-67AD-4537-866F-54F65FB8E8DD"; + const otherUuid = "2875EB19-B938-4E38-BE92-5AE97C256BDD"; + + const fixture = Effect.fnUntraced(function* () { + const context = yield* withSourceHome(); + const fileSystem = yield* FileSystem.FileSystem; + const root = safari.userDataDirectory(context)!; + const library = context.path.dirname(root); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* fileSystem.writeFileString(context.path.join(root, "Cookies.binarycookies"), "default"); + const store = (uuid: string) => + context.path.join(library, "WebKit", "WebsiteDataStore", uuid.toLowerCase(), "Cookies"); + for (const uuid of [workUuid, otherUuid]) { + yield* fileSystem.makeDirectory(store(uuid), { recursive: true }); + yield* fileSystem.writeFileString( + context.path.join(store(uuid), "Cookies.binarycookies"), + uuid, + ); + } + yield* fileSystem.makeDirectory(context.path.join(library, "Safari"), { recursive: true }); + const metadata = context.path.join(library, "Safari", "SafariTabs.db"); + return { context, root, store, metadata }; + }); + + it.effect("discovers named profiles and resolves only the selected profile's cookies", () => + run( + Effect.gen(function* () { + const { context, root, store, metadata } = yield* fixture(); + yield* Effect.sync(() => { + const database = new NodeSqlite.DatabaseSync(metadata); + try { + database.exec(`CREATE TABLE bookmarks ( + title TEXT, external_uuid TEXT, parent INTEGER DEFAULT 0, + type INTEGER DEFAULT 1, subtype INTEGER DEFAULT 2, + deleted INTEGER DEFAULT 0, order_index INTEGER DEFAULT 0 + )`); + const insert = database.prepare( + "INSERT INTO bookmarks (title, external_uuid, deleted) VALUES (?, ?, ?)", + ); + insert.run("", "DefaultProfile", 0); + insert.run("Ping", workUuid, 0); + insert.run("Deleted", otherUuid, 1); + insert.run("Unsafe", "../../outside", 0); + database.exec( + "INSERT INTO bookmarks (title, external_uuid, subtype) VALUES ('Tab group', 'group', 1)", + ); + } finally { + database.close(); + } + }); + const profiles = yield* listSourceProfiles(safari, context); + assert.deepEqual(profiles, [ + { directory: ".", name: "Personal" }, + { directory: store(workUuid), name: "Ping" }, + ]); + assert.strictEqual( + yield* resolveCookieDatabase(safari, context, "."), + context.path.join(root, "Cookies.binarycookies"), + ); + const selected = yield* resolveCookieDatabase(safari, context, profiles[1]!.directory); + assert.strictEqual(selected, context.path.join(store(workUuid), "Cookies.binarycookies")); + const fileSystem = yield* FileSystem.FileSystem; + assert.strictEqual(yield* fileSystem.readFileString(selected!), workUuid); + yield* fileSystem.remove(selected!); + assert.isUndefined(yield* resolveCookieDatabase(safari, context, profiles[1]!.directory)); + assert.deepEqual(yield* listSourceProfiles(safari, context), profiles); + }), + ), + ); + + for (const metadataState of ["missing", "corrupt"] as const) { + it.effect(`recovers separate cookie stores when metadata is ${metadataState}`, () => + run( + Effect.gen(function* () { + const { context, store, metadata } = yield* fixture(); + const fileSystem = yield* FileSystem.FileSystem; + if (metadataState === "corrupt") yield* fileSystem.writeFileString(metadata, "invalid"); + yield* fileSystem.remove(context.path.join(store(otherUuid), "Cookies.binarycookies")); + assert.deepEqual(yield* listSourceProfiles(safari, context), [ + { directory: ".", name: "Safari" }, + { directory: store(workUuid), name: workUuid.toLowerCase() }, + ]); + assert.isTrue(yield* isSourceInstalled(safari, context)); + }), + ), + ); + } + + it.effect("keeps Safari without profiles available", () => + run( + Effect.gen(function* () { + const context = yield* withSourceHome(); + assert.deepEqual(yield* listSourceProfiles(safari, context), [ + { directory: ".", name: "Safari" }, + ]); + assert.isFalse(yield* isSourceInstalled(safari, context)); + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 702933a432b3..6075f0ad56a3 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -1,10 +1,11 @@ /** * Importable browser sources. * - * Two engines are modelled. Chromium-family browsers keep cookies in an + * 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. + * works the same on every platform. Safari uses binary cookie files, with + * separate WebKit data stores for named profiles. * * Each entry pins its own paths and credential-store coordinates rather than * deriving them, because the forks do not agree. macOS uses service/account @@ -31,7 +32,7 @@ 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"; +export type BrowserImportEngine = "chromium" | "firefox" | "safari"; /** * Directory roots a definition builds its paths from. Passed in rather than @@ -177,6 +178,26 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray + context.platform === "darwin" + ? context.path.join( + context.home, + "Library", + "Containers", + "com.apple.Safari", + "Data", + "Library", + "Cookies", + ) + : undefined, + }, { id: "firefox", name: "Firefox", @@ -220,6 +241,9 @@ export const cookieDatabaseCandidatePaths = ( if (definition.engine === "firefox") { return [context.path.join(profilePath, "cookies.sqlite")]; } + if (definition.engine === "safari") { + return [context.path.join(profilePath, "Cookies.binarycookies")]; + } // 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 [ @@ -386,6 +410,64 @@ const withCookieCounts = ( ), ); +const SafariProfileRows = Schema.Array( + Schema.Struct({ title: Schema.NullOr(Schema.String), external_uuid: Schema.String }), +); +const decodeSafariProfiles = Schema.decodeUnknownEffect(SafariProfileRows); +const isSafariProfileUuid = (value: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); + +const listSafariProfiles = Effect.fnUntraced(function* ( + context: BrowserImportPathContext, + root: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const library = context.path.dirname(root); + const metadata = context.path.join(library, "Safari", "SafariTabs.db"); + const declared = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* decodeSafariProfiles( + yield* sql` + select title, external_uuid from bookmarks + where parent = 0 and type = 1 and subtype = 2 and deleted = 0 + order by order_index + `, + ); + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: metadata, readonly: true })), + Effect.orElseSucceed(() => []), + ); + const defaultProfile = declared.find((profile) => profile.external_uuid === "DefaultProfile"); + const profiles: Array = [ + { + directory: ".", + name: defaultProfile ? defaultProfile.title?.trim() || "Personal" : "Safari", + }, + ]; + const stores = context.path.join(library, "WebKit", "WebsiteDataStore"); + const profileDirectory = (uuid: string) => + context.path.join(stores, uuid.toLowerCase(), "Cookies"); + for (const profile of declared) { + if (!isSafariProfileUuid(profile.external_uuid)) continue; + profiles.push({ + directory: profileDirectory(profile.external_uuid), + name: profile.title?.trim() || profile.external_uuid, + }); + } + // If Safari's metadata is unavailable, recover stores that have cookies. + // With readable metadata, avoid resurrecting deleted profiles left on disk. + if (declared.length === 0) { + const entries = yield* fileSystem.readDirectory(stores).pipe(Effect.orElseSucceed(() => [])); + for (const entry of entries.filter(isSafariProfileUuid).sort()) { + const directory = context.path.join(stores, entry, "Cookies"); + if (yield* databaseFileExists(context.path.join(directory, "Cookies.binarycookies"))) { + profiles.push({ directory, name: entry }); + } + } + } + return profiles; +}); + /** * Profiles the source browser knows about. * @@ -403,6 +485,10 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( const root = definition.userDataDirectory(context); if (root === undefined) return []; + if (definition.engine === "safari") { + return yield* listSafariProfiles(context, root); + } + if (definition.engine === "firefox") { const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe( Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), @@ -773,11 +859,15 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") const root = definition.userDataDirectory(context); if (root === undefined) return false; // Probe the source's own lock state rather than scanning the process table. + // Safari keeps no lock and writes its jar atomically, so a running instance + // is not a hazard there. + // // 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 === "safari") return false; if (definition.engine !== "firefox") { if (context.platform === "win32") { return yield* windowsChromiumCookiesAreHeld(definition, context); diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index ff22f3dd2272..aaf34c3578f9 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -172,8 +172,6 @@ describe("BrowserSession", () => { const error = yield* browserSessions.getPartition("environment-a").pipe(Effect.flip); assert.instanceOf(error, BrowserSession.BrowserSessionPartitionDerivationError); - assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); - assert.isTrue(BrowserSession.isBrowserSessionError(error)); assert.equal(error.scope, "environment-a"); assert.strictEqual(error.cause, platformCause); assert.strictEqual(error.cause.reason.cause, nativeCause); @@ -196,8 +194,6 @@ describe("BrowserSession", () => { const error = yield* browserSessions.getSession("environment-b").pipe(Effect.flip); assert.instanceOf(error, BrowserSession.BrowserSessionCreationError); - assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); - assert.isTrue(BrowserSession.isBrowserSessionError(error)); assert.equal(error.scope, "environment-b"); assert.equal(error.partition, partition); assert.strictEqual(error.cause, cause); @@ -270,7 +266,6 @@ describe("BrowserSession", () => { const storageError = yield* browserSessions.clearCookies().pipe(Effect.flip); assert.instanceOf(storageError, BrowserSession.BrowserSessionStorageClearError); - assert.isTrue(BrowserSession.isBrowserSessionError(storageError)); assert.equal(storageError.partition, secondPartition); assert.strictEqual(storageError.cause, storageCause); assert.equal( @@ -287,7 +282,6 @@ describe("BrowserSession", () => { const cacheError = yield* browserSessions.clearCache().pipe(Effect.flip); assert.instanceOf(cacheError, BrowserSession.BrowserSessionCacheClearError); - assert.isTrue(BrowserSession.isBrowserSessionError(cacheError)); assert.equal(cacheError.partition, firstPartition); assert.strictEqual(cacheError.cause, cacheCause); assert.equal( diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 7f3c9ec5d7ac..7ff879852283 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -93,7 +93,6 @@ export const BrowserSessionGetSessionError = Schema.Union([ BrowserSessionCreationError, ]); export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type; -export const isBrowserSessionGetSessionError = Schema.is(BrowserSessionGetSessionError); export const BrowserSessionError = Schema.Union([ BrowserSessionPartitionDerivationError, @@ -102,7 +101,6 @@ export const BrowserSessionError = Schema.Union([ BrowserSessionCacheClearError, ]); export type BrowserSessionError = typeof BrowserSessionError.Type; -export const isBrowserSessionError = Schema.is(BrowserSessionError); export class BrowserSession extends Context.Service< BrowserSession, diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index a7b3afabd3c3..79c7fd1725e1 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -272,6 +272,7 @@ const makeTestPreviewWebContents = ( ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -382,6 +383,7 @@ const makeFaviconWebContents = (options?: { send: webviewSend, session: { fetch }, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), executeJavaScriptInIsolatedWorld, debugger: { @@ -474,6 +476,67 @@ describe("PreviewManager", () => { webviewSend.mockClear(); }); + effectIt.effect("keeps preview shortcuts out of the host window", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const sendInputEvent = vi.fn(); + const hostWebContents = { sendInputEvent }; + Object.assign(preview.webContents, { hostWebContents }); + fromId.mockReturnValue(preview.webContents); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: hostWebContents, + } as never); + yield* manager.createTab("tab_keys"); + yield* manager.registerWebview("tab_keys", 42); + + expect( + (preview.webContents as Electron.WebContents).setIgnoreMenuShortcuts, + ).toHaveBeenCalledWith(true); + const beforeInput = preview.listeners.get("before-input-event")!; + for (const control of [false, true]) { + for (const key of ["k", ",", "w", "j", "q", "+", "a", "c", "v", "x"]) { + for (const type of ["keyDown", "keyUp"]) { + const preventDefault = vi.fn(); + beforeInput( + { preventDefault } as never, + { type, key, meta: !control, control, shift: key === "j", alt: false } as never, + ); + yield* Effect.yieldNow; + expect(preventDefault).not.toHaveBeenCalled(); + } + } + } + expect(sendInputEvent).not.toHaveBeenCalled(); + + const preventDefault = vi.fn(); + beforeInput( + { preventDefault } as never, + { + type: "keyDown", + key: "r", + meta: true, + control: false, + shift: false, + alt: false, + } as never, + ); + yield* Effect.yieldNow; + expect(preventDefault).toHaveBeenCalledOnce(); + expect(preview.reload).toHaveBeenCalledOnce(); + expect(sendInputEvent).not.toHaveBeenCalled(); + + const setIgnoreMenuShortcuts = vi.fn(); + preview.listeners.get("did-create-window")!({ + webContents: { setIgnoreMenuShortcuts, setWindowOpenHandler: vi.fn() }, + } as never); + expect(setIgnoreMenuShortcuts).toHaveBeenCalledWith(true); + }), + ), + ); + effectIt.effect("reports an unregistered webview as temporarily unavailable", () => withManager((manager) => Effect.gen(function* () { @@ -617,6 +680,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -718,6 +782,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), get debugger() { if (destroyed) throw new Error("Object has been destroyed"); @@ -1222,6 +1287,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1286,6 +1352,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1326,6 +1393,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1372,6 +1440,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1427,6 +1496,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1524,6 +1594,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1859,6 +1930,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1951,6 +2023,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1987,10 +2060,30 @@ describe("PreviewManager", () => { /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, ); + // Chromium reports UnknownVizError while a hidden guest warms its + // first compositor frame, so transient failures are retried. + capturePage.mockClear(); + capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); + capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); + const retriedFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(1_000); + const retriedExit = yield* Fiber.join(retriedFiber); + expect(Exit.isSuccess(retriedExit)).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(3); + + // A persistent failure still surfaces once the retries are spent. + capturePage.mockClear(); const captureCause = new Error("capture failed"); - capturePage.mockRejectedValueOnce(captureCause); - const exit = yield* Effect.exit(manager.captureScreenshot("tab_1")); + capturePage.mockRejectedValue(captureCause); + const failingFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(1_000); + const exit = yield* Fiber.join(failingFiber); expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(3); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); expect(error).toMatchObject({ @@ -2324,6 +2417,127 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("stops capture retries when the tab swaps during the retry delay", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toPNG: () => Buffer.from("png"), + toJPEG: () => Buffer.from("jpeg"), + getSize: () => ({ width: 100, height: 80 }), + })); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42)); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); + const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + // Let the rejection schedule its retry before replacing the guest. + yield* TestClock.adjust(60); + expect(capturePage).toHaveBeenCalledTimes(1); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43)); + yield* manager.registerWebview("tab_1", 43); + yield* TestClock.adjust(1_000); + const exit = yield* Fiber.join(fiber); + + expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(1); + expect(writeFile).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => + withManager((manager) => + Effect.gen(function* () { + const image = { + toPNG: () => Buffer.from("stale-png"), + toJPEG: () => Buffer.from("stale-jpeg"), + getSize: () => ({ width: 100, height: 80 }), + }; + const pending = Promise.withResolvers(); + const capturePage = vi.fn(() => pending.promise); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42)); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(0); + expect(capturePage).toHaveBeenCalledOnce(); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43)); + yield* manager.registerWebview("tab_1", 43); + pending.resolve(image); + const exit = yield* Fiber.join(fiber); + + expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledOnce(); + expect(writeFile).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("releases snapshot control when every capture attempt stalls", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(() => new Promise(() => {})); + const wc = makeTestPreviewWebContents(capturePage); + Object.assign(wc, { isDevToolsOpened: () => false }); + Object.assign(wc.debugger, { + sendCommand: vi.fn(async (method: string, params?: Record) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: + params?.["expression"] === "42" + ? 42 + : { + url: "https://example.com", + title: "Example", + loading: false, + visibleText: "Example", + interactiveElements: [], + }, + }, + }; + } + return method === "Accessibility.getFullAXTree" ? { nodes: [] } : undefined; + }), + }); + fromId.mockReturnValue(wc); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + const snapshot = yield* Effect.exit(manager.automationSnapshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(100); + expect(capturePage).toHaveBeenCalledOnce(); + const evaluate = yield* manager + .automationEvaluate("tab_1", { expression: "42" }) + .pipe(Effect.forkChild({ startImmediately: true })); + expect(evaluate.pollUnsafe()).toBeUndefined(); + + yield* TestClock.adjust(4_000); + const exit = yield* Fiber.join(snapshot); + expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(3); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error).toMatchObject({ + _tag: "PreviewOperationError", + operation: "automationSnapshot.capturePage", + tabId: "tab_1", + webContentsId: 42, + cause: { _tag: "TimeoutError" }, + }); + expect(yield* Fiber.join(evaluate)).toBe(42); + }), + ), + ); + effectIt.effect("grants each concurrent preview recording its own tab frame", () => withManager((manager) => Effect.gen(function* () { @@ -2368,6 +2582,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -2670,6 +2885,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3173,6 +3389,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn(), removeListener: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3228,6 +3445,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3304,6 +3522,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3389,6 +3608,7 @@ describe("PreviewManager", () => { goBack, goForward, }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3518,6 +3738,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3618,6 +3839,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3773,6 +3995,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3837,6 +4060,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 324b92034f36..900ba5fe983c 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -48,6 +48,7 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; @@ -113,6 +114,13 @@ const MAX_SCREENSHOT_WIDTH = 1280; const RECORDING_ARM_GRACE_MS = 10_000; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; +/** + * Cold guests can reject capturePage with UnknownVizError or never settle it. + * Bound each attempt so snapshots release control even when Chromium stalls. + */ +const CAPTURE_PAGE_RETRY_ATTEMPTS = 3; +const CAPTURE_PAGE_RETRY_DELAY_MS = 120; +const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000; const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; const PICTURE_IN_PICTURE_MIN_WIDTH = 240; @@ -465,22 +473,6 @@ interface ExpectedAgentInput { readonly expiresAt: number; } -const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ - key: string; - meta: boolean; - shift: boolean; - control: boolean; -}> = Object.freeze([ - // mod+shift+J → preview.toggle - { key: "j", meta: true, shift: true, control: false }, - // mod+K → command palette - { key: "k", meta: true, shift: false, control: false }, - // mod+, → settings (macOS convention) - { key: ",", meta: true, shift: false, control: false }, - // mod+W → close tab/panel - { key: "w", meta: true, shift: false, control: false }, -]); - /** * Protocols a preview page may open in a real popup window. * @@ -655,6 +647,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function try: evaluate, catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), }); + const capturePageWithRetry = Effect.fn("PreviewManager.capturePageWithRetry")(function* ( + errorContext: PreviewOperationContext, + tabId: string, + wc: Electron.WebContents, + ) { + const requireCurrentGuest = Effect.gen(function* () { + const tabs = yield* SynchronizedRef.get(tabsRef); + if (wc.isDestroyed() || tabs.get(tabId)?.webContentsId !== wc.id) { + return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId: wc.id }); + } + }); + const capture = Effect.gen(function* () { + // Check after the retry delay, and again before accepting its result. + yield* requireCurrentGuest; + const image = yield* Effect.tryPromise({ + // An abort-signal parameter makes a stalled promise interruptible. + try: (_signal) => wc.capturePage(), + catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), + }).pipe( + Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail(new PreviewOperationError({ ...errorContext, cause })), + }), + ); + yield* requireCurrentGuest; + return image; + }); + return yield* capture.pipe( + Effect.retry({ + times: CAPTURE_PAGE_RETRY_ATTEMPTS - 1, + schedule: Schedule.spaced(CAPTURE_PAGE_RETRY_DELAY_MS), + while: isPreviewOperationError, + }), + ); + }); const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); const currentMillis = Clock.currentTimeMillis; const encodeJson = (errorContext: PreviewOperationContext, value: unknown) => @@ -1535,16 +1563,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }); - const isAppShortcut = (input: Electron.Input): boolean => - input.type === "keyDown" && - APP_FORWARDED_SHORTCUTS.some( - (shortcut) => - shortcut.key.toLowerCase() === input.key.toLowerCase() && - shortcut.meta === input.meta && - shortcut.shift === input.shift && - shortcut.control === input.control, - ); - const computeNavStatus = (wc: Electron.WebContents): PreviewNavStatus => { const url = wc.getURL(); const title = wc.getTitle(); @@ -1819,30 +1837,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }).pipe(Effect.ignore), ); }; - const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( - event: Electron.Event, - input: Electron.Input, - ) { - const mainWindow = yield* Ref.get(mainWindowRef); - if (!isAppShortcut(input) || Option.isNone(mainWindow) || mainWindow.value.isDestroyed()) { - return; - } - event.preventDefault(); - mainWindow.value.webContents.sendInputEvent({ - type: "keyDown", - keyCode: input.key, - modifiers: [ - ...(input.meta ? (["meta"] as const) : []), - ...(input.shift ? (["shift"] as const) : []), - ...(input.control ? (["control"] as const) : []), - ...(input.alt ? (["alt"] as const) : []), - ], - }); - }); // A popup opens with Electron's default handler, so the page inside it could // otherwise spawn native windows without limit. Nothing in an OAuth flow // opens a second popup, so the chain stops at the first one. const windowCreated = (window: Electron.BrowserWindow): void => { + window.webContents.setIgnoreMenuShortcuts(true); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); }; const beforeInput = (event: Electron.Event, input: Electron.Input): void => { @@ -1855,7 +1854,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); return; } - runFork(forwardShortcut(event, input)); }; yield* Scope.addFinalizer( scope, @@ -1878,6 +1876,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { + // Preview input belongs to the page, including keys injected through CDP. + // Never let it invoke the host application's menu accelerators. + wc.setIgnoreMenuShortcuts(true); wc.on("did-start-navigation", navigationStarted); wc.on("did-navigate", syncNavigation); wc.on("did-navigate-in-page", syncInPageNavigation); @@ -2691,13 +2692,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const [createdAt, millis, image] = yield* Effect.all([ currentIso, currentMillis, - attemptPromise( + capturePageWithRetry( { operation: "captureScreenshot.capturePage", tabId, webContentsId: wc.id, }, - () => wc.capturePage(), + tabId, + wc, ), ]); const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`; @@ -3556,13 +3558,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ send("Accessibility.getFullAXTree"), - attemptPromise( + capturePageWithRetry( { operation: "automationSnapshot.capturePage", tabId, webContentsId: wc.id, }, - () => wc.capturePage(), + tabId, + wc, ), Ref.get(diagnosticsRef), Ref.get(actionTimelineRef), diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts index 5034df44cf70..d2fd166e878c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts @@ -54,7 +54,7 @@ const readWithLogs = (fileSystemLayer: Layer.Layer) => { const environment = yield* DesktopEnvironment.DesktopEnvironment; const settings = yield* DesktopClientSettings.DesktopClientSettings; return { - result: yield* settings.get, + result: yield* Effect.result(settings.get), settingsPath: environment.clientSettingsPath, records, }; @@ -73,12 +73,13 @@ describe("DesktopClientSettings diagnostics", () => { Effect.gen(function* () { const result = yield* readWithLogs(FileSystem.layerNoop({})); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Success") return assert.fail("expected a successful read"); + assert.isTrue(Option.isNone(result.result.success)); assert.deepEqual(result.records, []); }), ); - it.effect("logs non-missing filesystem failures with the settings path", () => { + it.effect("reports non-missing filesystem failures and logs the settings path", () => { const permissionError = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -93,7 +94,12 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Failure") return assert.fail("expected a read failure"); + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.strictEqual(result.result.failure.cause, permissionError); assert.equal(result.records.length, 1); assert.deepEqual(result.records[0]?.message, [ "Could not read desktop client settings.", @@ -103,7 +109,7 @@ describe("DesktopClientSettings diagnostics", () => { }); }); - it.effect("logs malformed settings documents with the settings path", () => + it.effect("reports malformed settings documents and logs the settings path", () => Effect.gen(function* () { const result = yield* readWithLogs( FileSystem.layerNoop({ @@ -111,7 +117,12 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Failure") return assert.fail("expected a decode failure"); + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.equal(result.result.failure.operation, "decode-document"); assert.equal(result.records.length, 1); const message = result.records[0]?.message; if (!Array.isArray(message)) { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 28cce3cfb507..9fbacc832a90 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -26,7 +26,6 @@ const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, confirmThreadUnpin: false, - continueThreadsAfterServerUpdate: true, contextWindowMeterEnabled: false, composerCollapseOnBlur: false, composerCollapseOnScroll: true, @@ -45,6 +44,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + onboardingCompletedAt: null, panelAnimationDurationMs: 0, planModeEnabled: false, proactivePanelsEnabled: true, @@ -137,6 +137,59 @@ describe("DesktopClientSettings", () => { ), ); + for (const failure of [ + { label: "permission", reason: "PermissionDenied" }, + { label: "I/O", reason: "Unknown" }, + ] as const) { + it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + const savedSettings = { + ...clientSettings, + onboardingCompletedAt: "2026-09-05T12:00:00.000Z", + }; + yield* settings.set(savedSettings); + const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); + const cause = PlatformError.systemError({ + _tag: failure.reason, + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: environment.clientSettingsPath, + }); + let failRead = true; + const retryableSettings = yield* DesktopClientSettings.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (path) => + Effect.suspend(() => + failRead ? Effect.fail(cause) : fileSystem.readFileString(path), + ), + }), + ), + ); + + const error = yield* retryableSettings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "read-file"); + assert.equal(error.path, environment.clientSettingsPath); + assert.strictEqual(error.cause, cause); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + savedContents, + ); + + failRead = false; + assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); + }), + ), + ); + } + it.effect("reports the failed client settings write operation and path", () => withClientSettings( Effect.gen(function* () { @@ -223,17 +276,31 @@ describe("DesktopClientSettings", () => { ), ); - it.effect("treats malformed client settings documents as absent", () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json"); + for (const document of [ + { label: "malformed JSON", contents: "{not-json" }, + { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' }, + { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' }, + ]) { + it.effect(`reports ${document.label} without treating the settings file as absent`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); - assert.isTrue(Option.isNone(yield* settings.get)); - }), - ), - ); + const error = yield* settings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "decode-document"); + assert.equal(error.path, environment.clientSettingsPath); + assert.instanceOf(error.cause, Schema.SchemaError); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + document.contents, + ); + }), + ), + ); + } }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 4ff091e27a27..5eadd27d5454 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -12,25 +12,33 @@ import * as Ref from "effect/Ref"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -const ClientSettingsDocumentSchema = Schema.Struct({ - settings: ClientSettingsSchema, -}); - const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); -const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); -const decodeLegacyClientSettingsDocumentJson = Schema.decodeEffect( - LegacyClientSettingsDocumentJson, +const decodeClientSettingsDocument = Schema.decodeEffect( + fromLenientJson(Schema.Record(Schema.String, Schema.Unknown)), ); -const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson); -const decodeClientSettingsJson = (raw: string): Effect.Effect => - decodeLegacyClientSettingsDocumentJson(raw).pipe( - Effect.map((document) => document.settings), - Effect.catchTags({ - SchemaError: () => decodeClientSettingsJsonValue(raw), - }), +const decodeClientSettingsValue = Schema.decodeUnknownEffect(ClientSettingsSchema); +const decodeClientSettingsJson = Effect.fnUntraced(function* (raw: string) { + const document = yield* decodeClientSettingsDocument(raw); + // Select the shape before validation so invalid legacy settings cannot become defaults. + return yield* decodeClientSettingsValue( + Object.hasOwn(document, "settings") ? document.settings : document, ); +}); const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); +export class DesktopClientSettingsReadError extends Schema.TaggedErrorClass()( + "DesktopClientSettingsReadError", + { + operation: Schema.Literals(["read-file", "decode-document"]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop client settings read failed during ${this.operation} at ${this.path}.`; + } +} + const DesktopClientSettingsWriteOperation = Schema.Literals([ "create-temporary-file-name", "encode-document", @@ -55,7 +63,7 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass>; + readonly get: Effect.Effect, DesktopClientSettingsReadError>; readonly set: ( settings: ClientSettings, ) => Effect.Effect; @@ -65,7 +73,7 @@ export class DesktopClientSettings extends Context.Service< const readClientSettings = ( fileSystem: FileSystem.FileSystem, settingsPath: string, -): Effect.Effect> => +): Effect.Effect, DesktopClientSettingsReadError> => fileSystem.readFileString(settingsPath).pipe( Effect.map(Option.some), Effect.catchTags({ @@ -74,7 +82,15 @@ const readClientSettings = ( ? Effect.succeed(Option.none()) : Effect.logWarning("Could not read desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), + Effect.andThen( + Effect.fail( + new DesktopClientSettingsReadError({ + operation: "read-file", + path: settingsPath, + cause, + }), + ), + ), ), }), Effect.flatMap( @@ -87,7 +103,15 @@ const readClientSettings = ( SchemaError: (cause) => Effect.logWarning("Could not decode desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), + Effect.andThen( + Effect.fail( + new DesktopClientSettingsReadError({ + operation: "decode-document", + path: settingsPath, + cause, + }), + ), + ), ), }), ), diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 05b1ca144444..348f6cb3843e 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; @@ -400,11 +401,12 @@ describe("DesktopSavedEnvironments", () => { it.effect("reports saved environment filesystem reads separately from document decoding", () => Effect.gen(function* () { + const path = yield* Path.Path; const baseFileSystem = yield* FileSystem.FileSystem; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-saved-environments-test-", }); - const registryPath = `${baseDir}/userdata/saved-environments.json`; + const registryPath = path.join(baseDir, "userdata", "saved-environments.json"); const permissionError = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -433,6 +435,7 @@ describe("DesktopSavedEnvironments", () => { it.effect("reports the failed saved environment write operation and path", () => Effect.gen(function* () { const baseFileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-saved-environments-test-", }); @@ -440,7 +443,7 @@ describe("DesktopSavedEnvironments", () => { _tag: "PermissionDenied", module: "FileSystem", method: "makeDirectory", - pathOrDescriptor: `${baseDir}/userdata`, + pathOrDescriptor: path.join(baseDir, "userdata"), }); const fileSystemLayer = Layer.succeed( FileSystem.FileSystem, @@ -456,11 +459,11 @@ describe("DesktopSavedEnvironments", () => { const error = yield* savedEnvironments.setRegistry([savedRegistryRecord]).pipe(Effect.flip); assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsWriteError); assert.equal(error.operation, "create-directory"); - assert.equal(error.path, `${baseDir}/userdata`); + assert.equal(error.path, path.join(baseDir, "userdata")); assert.strictEqual(error.cause, permissionError); assert.equal( error.message, - `Desktop saved-environment write failed during create-directory at ${baseDir}/userdata.`, + `Desktop saved-environment write failed during create-directory at ${path.join(baseDir, "userdata")}.`, ); assert.notEqual(error.message, permissionError.message); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 1978337df3e7..509778521511 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -794,7 +794,6 @@ describe("DesktopUpdates", () => { const error = yield* updates.setChannel("nightly").pipe(Effect.flip); assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); - assert.isTrue(DesktopUpdates.isDesktopUpdateSetChannelError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, settingsFailure); assert.strictEqual(error.cause.cause, diskFailure); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 20f005f2ab2d..344d135a1024 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -155,7 +155,6 @@ export const DesktopUpdateSetChannelError = Schema.Union([ DesktopUpdateChannelPersistenceError, ]); export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type; -export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError); export class DesktopUpdates extends Context.Service< DesktopUpdates, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index abf6f220eca4..bdd03865c7bf 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -280,6 +280,7 @@ function makeTestLayer(input: { input.openedExternalUrls?.push(url); return true; }), + openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, @@ -380,6 +381,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), + openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index c4bf2f34b0a1..58809d8eb14d 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -252,24 +252,31 @@ describe("makeQuitShortcutHandler", () => { expect(harness.notifications).toEqual([]); }); - it("honors a quick double press when both key releases beat their mode reads", async () => { - const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; - const harness = makeHarness({ - getMode: () => new Promise((resolve) => resolvers.push(resolve)), - }); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - - resolvers[1]?.("double-click"); - await Promise.resolve(); - await Promise.resolve(); - - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([]); - }); + it.each(["direct", "hold", "double-click"] as const)( + "quits on a quick second press without waiting for a pending %s mode read", + async (mode) => { + const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; + const harness = makeHarness({ + getMode: () => new Promise((resolve) => resolvers.push(resolve)), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); + await harness.send(makeInput({})); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + + await harness.send(makeInput({ type: "keyUp" })); + + resolvers[0]?.(mode); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + }, + ); it("discards a stale mode resolution from a superseded press", async () => { // Press #1's mode is still pending when the user releases and @@ -378,6 +385,38 @@ describe("makeQuitShortcutHandler", () => { expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); + it("quits on a quick second press in hold mode when the first release is unseen", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); + await harness.send(makeInput({})); + + expect(harness.concealWindow).not.toHaveBeenCalled(); + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); + }); + + it("does not count auto-repeat as a second press", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(QUIT_DOUBLE_PRESS_MS - 100); + + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([HOLD_DOWN]); + }); + + it("does not count a released tap after another shortcut interrupts it", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + await harness.send(makeInput({ key: "c" })); + vi.advanceTimersByTime(100); + await harness.send(makeInput({})); + + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); + }); + it("cancels the hold when another key interrupts it", async () => { const harness = makeHarness(); await harness.send(makeInput({})); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index 4095e3d4354b..a995184ddd70 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -12,7 +12,8 @@ export const QUIT_DOUBLE_PRESS_MS = 500; // tap release can go completely unseen and a release-based timer would quit // anyway. Once held, quitting waits for Q keyUp or a quiet grace period after // repeats stop so they cannot reach the next app. Keyboards with -// auto-repeat disabled fall back to the application menu Quit action. +// auto-repeat disabled must use a double press or the application menu Quit action. +// Supporting holds without repeats requires a native physical key-state check. export const QUIT_HOLD_RELEASE_GRACE_MS = 600; // A slow repeat rate can exceed the fixed grace. Waiting for two observed // cadences keeps the timer behind the next repeat without slowing normal rates. @@ -52,8 +53,8 @@ export function makeQuitShortcutHandler( let lastRepeatAt = 0; let repeatCadenceMs = 0; // Incremented when a press is superseded or explicitly cancelled. A plain - // key release does not invalidate its pending mode read: direct mode and a - // completed second press must still be honored after that read settles. + // key release does not invalidate its pending mode read: a direct-mode + // press must still quit after that read settles. let generation = 0; const clearWatchdog = () => { @@ -64,9 +65,9 @@ export function makeQuitShortcutHandler( }; const release = (cancelPendingMode = true, keepDoublePressHint = false) => { + if (cancelPendingMode) generation += 1; if (!holding && !notified) return; const keepHint = keepDoublePressHint && mode === "double-click" && notified; - if (cancelPendingMode) generation += 1; holding = false; armed = false; quitOnRelease = false; @@ -85,6 +86,7 @@ export function makeQuitShortcutHandler( // Dismisses any overlay first so a cancelled quit cannot leave a stale hint. const quitNow = () => { release(); + lastPressAt = 0; options.quit(); }; @@ -138,13 +140,10 @@ export function makeQuitShortcutHandler( // quit shortcut, so it must not cancel an active double-press window. if (key === modifierKey && !input.alt && !input.shift) return; - // Any other key (or an extra modifier) pressed mid-hold breaks the - // gesture; without this the hold timer keeps running through the - // interruption and the next qualifying repeat would quit early. The - // interrupted press also stops counting toward a double press, but only - // here, not in release(), which runs mid-restart on an unseen-release - // re-press and must not wipe that press's own tap timestamp. - if ((holding || notified) && !input.isAutoRepeat) { + // Other keys cancel the hold and the first tap, even after release. + // Keep this separate from release(), which also runs when a fresh Q + // keydown follows a keyUp that macOS did not deliver. + if (!input.isAutoRepeat) { lastPressAt = 0; release(); } @@ -171,6 +170,13 @@ export function makeQuitShortcutHandler( if (holding || notified) release(); generation += 1; + // Every mode accepts two presses. Quit before reading settings so a slow + // read cannot delay the second press. Repeats never reach this branch. + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) { + quitNow(); + return; + } + const pressGeneration = generation; holding = true; heldSince = now; @@ -181,13 +187,6 @@ export function makeQuitShortcutHandler( quitNow(); return; } - // 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; - } - if (resolvedMode === "double-click") { const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now); if (remainingMs <= 0) { diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index ce74cf58e0a3..294a02030a83 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,3 +1,4 @@ +import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; @@ -83,4 +84,10 @@ export default defineConfig({ entry: ["src/preview-pip-preload.ts"], }, ], + test: { + // The Windows lane runs workspace suites concurrently; filesystem-heavy + // desktop integration tests can exceed Vitest's 5 second default there. + testTimeout: 15_000, + setupFiles: ["../../packages/shared/src/testing/longTempDir.ts"], + }, }); diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 912faf88164d..78c121d14fe2 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "@t3tools/shared": "workspace:*", - "astro": "^7.0.3" + "astro": "^7.0.3", + "sharp": "0.34.5" }, "devDependencies": { "@astrojs/check": "^0.9.7", diff --git a/apps/marketing/public/fonts/dm-sans-OFL.txt b/apps/marketing/public/fonts/dm-sans-OFL.txt new file mode 100644 index 000000000000..a0eedfbf0df0 --- /dev/null +++ b/apps/marketing/public/fonts/dm-sans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/marketing/public/fonts/jetbrains-mono-OFL.txt b/apps/marketing/public/fonts/jetbrains-mono-OFL.txt new file mode 100644 index 000000000000..821a3dac22af --- /dev/null +++ b/apps/marketing/public/fonts/jetbrains-mono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/marketing/public/app-desktop.webp b/apps/marketing/src/assets/app-desktop.webp similarity index 100% rename from apps/marketing/public/app-desktop.webp rename to apps/marketing/src/assets/app-desktop.webp diff --git a/apps/marketing/src/assets/fonts/dm-sans-latin-ext.woff2 b/apps/marketing/src/assets/fonts/dm-sans-latin-ext.woff2 new file mode 100644 index 000000000000..6e84cba1949c Binary files /dev/null and b/apps/marketing/src/assets/fonts/dm-sans-latin-ext.woff2 differ diff --git a/apps/marketing/src/assets/fonts/dm-sans-latin.woff2 b/apps/marketing/src/assets/fonts/dm-sans-latin.woff2 new file mode 100644 index 000000000000..c75f7bf1b465 Binary files /dev/null and b/apps/marketing/src/assets/fonts/dm-sans-latin.woff2 differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic-ext.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic-ext.woff2 new file mode 100644 index 000000000000..289de93149ab Binary files /dev/null and b/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic-ext.woff2 differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic.woff2 new file mode 100644 index 000000000000..de39acbc4069 Binary files /dev/null and b/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic.woff2 differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-greek.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-greek.woff2 new file mode 100644 index 000000000000..a53d52d6d02c Binary files /dev/null and b/apps/marketing/src/assets/fonts/jetbrains-mono-greek.woff2 differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-latin-ext.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-latin-ext.woff2 new file mode 100644 index 000000000000..82f96681c076 Binary files /dev/null and b/apps/marketing/src/assets/fonts/jetbrains-mono-latin-ext.woff2 differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-latin.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-latin.woff2 new file mode 100644 index 000000000000..4d09cda4a4c3 Binary files /dev/null and b/apps/marketing/src/assets/fonts/jetbrains-mono-latin.woff2 differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-vietnamese.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-vietnamese.woff2 new file mode 100644 index 000000000000..e38f5538f84e Binary files /dev/null and b/apps/marketing/src/assets/fonts/jetbrains-mono-vietnamese.woff2 differ diff --git a/apps/marketing/public/icon.webp b/apps/marketing/src/assets/icon.webp similarity index 100% rename from apps/marketing/public/icon.webp rename to apps/marketing/src/assets/icon.webp diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 686b555fd4c0..ca5ea15f61de 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -1,4 +1,8 @@ --- +import { Image } from "astro:assets"; +import appIcon from "../assets/icon.webp"; +import dmSansLatinUrl from "../assets/fonts/dm-sans-latin.woff2?url"; +import "../styles/fonts.css"; import { ANDROID_PLAY_STORE_URL, GITHUB_REPOSITORY_URL, @@ -24,11 +28,12 @@ const { - - @@ -67,7 +72,17 @@ const {