diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index 7a6a1aa122a70..e0ef427bd1d36 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -1,5 +1,10 @@ name: Setup CI dependencies description: Install Vite+ and the workspace dependencies from cache. +inputs: + task-cache-read-token: + description: Read-only credential for the private maintainer task cache + required: false + default: "" runs: using: composite steps: @@ -65,3 +70,20 @@ runs: - name: Install dependencies shell: bash run: vp install + + - name: Enable shared task cache + if: >- + github.repository == 'pingdotgg/t3code' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && + hashFiles('.task-cache.json') != '' && + inputs.task-cache-read-token != '' + shell: bash + env: + T3_TASK_CACHE_READ_TOKEN: ${{ inputs.task-cache-read-token }} + run: | + node infra/task-cache/scripts/install-ci.ts + if [[ ( "$GITHUB_EVENT_NAME" == pull_request && "$GITHUB_BASE_REF" == main ) || ( "$GITHUB_EVENT_NAME" == push && "$GITHUB_REF" == refs/heads/main ) ]]; then + echo 'VP_REMOTE_CACHE=read-write' >> "$GITHUB_ENV" + else + echo 'VP_REMOTE_CACHE=read' >> "$GITHUB_ENV" + fi diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index d7ebb1a0790cd..2ff10b84fd3a9 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -1,21 +1,10 @@ -// Decides which CI jobs a change needs. Prints `key=value` lines for -// $GITHUB_OUTPUT; a job runs unless its key is "false". -// -// node ci-changes.cjs --paths gate by the listed paths -// node ci-changes.cjs --paths --full run every Linux job, gate only the macOS lint -// node ci-changes.cjs changed paths unknown: run everything -// -// scripts/check-changed.ts requires the same decisions so local runs match CI. -// -// Every changed path maps to workspace packages, which then pull in their -// dependents from package.json. A path this file does not recognize runs -// everything, so a new root file or directory can only cost minutes. +// Unknown paths run every job; recognized packages pull in their dependents. +// scripts/check-changed.ts shares these decisions so local checks match CI. const fs = require("node:fs"); const path = require("node:path"); const root = path.resolve(__dirname, "../.."); -// Never read by a build, typecheck, or test. Lint still formats them. const INERT = [ /^docs\//, /^\.(agents|cursor|vscode|devcontainer|macroscope|repos)\//, @@ -23,15 +12,13 @@ const INERT = [ /^LICENSE$/, ]; -// Run everything: the root vite.config.ts loads the first as setup for every -// package's tests, and the vendored mobile tarballs are installed dependencies. +// Shared test setup and vendored mobile dependencies affect every package. const RUNS_EVERYTHING = [ /^packages\/shared\/src\/(HostProcess|testing\/longTempDir)\.ts$/, /^apps\/mobile\/deps\//, ]; -// Paths outside a package, or files inside one that other packages read -// through relative imports rather than a package.json dependency. +// Includes cross-package reads absent from package.json dependencies. const EXTRA_OWNERS = [ // Vite configs and tsconfigs of these apps import scripts/lib directly. [/^scripts\/lib\//, ["@t3tools/desktop", "@t3tools/mobile", "@t3tools/web", "t3"]], @@ -43,9 +30,7 @@ const EXTRA_OWNERS = [ ]; const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//; -// Tests that read another package's files without declaring it. The scripts -// tests read app manifests, assets, and sources (cursor-sdk-packaging, -// third-party-licenses, brand-assets, build-desktop-artifact). +// These tests read other packages without declaring dependencies. const TEST_ONLY_DEPS = { "@t3tools/provider-pi": ["@t3tools/client-runtime"], "@t3tools/scripts": [ @@ -56,17 +41,12 @@ const TEST_ONLY_DEPS = { "@t3tools/provider-cursor", ], }; -// Declared dependencies that only feed a build step: the server bundles the -// built web app (apps/server/vite.config.ts) but never imports it. +// The server bundles the web build without importing it. const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] }; -// Sources scripts/mobile-native-static-check.ts lints, plus the tool and rule -// configuration that decides how it lints them, plus the root package.json -// that defines the lint:mobile command. const MOBILE_NATIVE = /^apps\/mobile\/.*\.(swift|kt|kts)$|^apps\/mobile\/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts\/mobile-native-static-check\.ts$|^package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//; -// Jobs that test every package except these, which have jobs of their own. const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]); function readWorkspace() { @@ -108,8 +88,7 @@ function readWorkspace() { return packages; } -// Returns the packages a path touches, [] for an inert path, or null when the -// path is not understood and everything must run. +// [] means inert; null means unknown and requires every job. function ownersOf(file, packages) { if (INERT.some((pattern) => pattern.test(file))) return []; // A manifest can change dependencies or versions other packages read. @@ -148,7 +127,6 @@ function decide(files, packages, full) { if (everything) { affected = all; } else { - // Add dependents until nothing changes. let grew = true; while (grew) { grew = false; diff --git a/.github/scripts/mirror-play-apk.cjs b/.github/scripts/mirror-play-apk.cjs index 90b368b2aa036..d017fc907f124 100644 --- a/.github/scripts/mirror-play-apk.cjs +++ b/.github/scripts/mirror-play-apk.cjs @@ -1,7 +1,4 @@ -// Publishes closed-testing betas, or downloads the internal APK for a normal -// nightly release. Play generates and signs the APK from the -// uploaded bundle with the app signing key, so it carries the same signature -// as the Play Store install. Run by .github/workflows/mobile-android-apk.yml. +// Play signs generated APKs with the app signing key, matching Play Store installs. const { execFileSync } = require("node:child_process"); const crypto = require("node:crypto"); const fs = require("node:fs"); @@ -50,10 +47,7 @@ async function play(token, method, url) { return response; } -// Highest version code the track serves, with its release name. Play names a -// release after the bundle's versionName unless the uploader sets one. This -// endpoint reads without opening an edit; an edit would invalidate any open -// EAS Submit edit made with the same service account. +// Opening an edit would invalidate any EAS Submit edit using the same service account. async function newestTrackBuild(token, appUrl, trackName) { const { releases = [] } = await ( await play(token, "GET", `${appUrl}/tracks/${trackName}/releases`) @@ -82,8 +76,7 @@ function readRelease(tag) { } } -// Download to a temporary directory so a failed request cannot leave a partial -// APK among the release assets. +// Stage downloads so failed requests cannot leave partial release assets. async function downloadApk({ token, appUrl, build, apkName }) { const generated = await ( await play(token, "GET", `${appUrl}/generatedApks/${build.versionCode}`) diff --git a/.github/scripts/stage-preview-bundle.py b/.github/scripts/stage-preview-bundle.py index 0b3a53285916b..1730ee5c8d752 100644 --- a/.github/scripts/stage-preview-bundle.py +++ b/.github/scripts/stage-preview-bundle.py @@ -12,8 +12,7 @@ "server/dist/client/index.html", "desktop/dist-electron/main.cjs", } -# The current bundle is about 32 MiB compressed. Bound extraction on the -# trusted runner even when the PR replaces the uploader entirely. +# Bound trusted-runner extraction even when a PR replaces the uploader. MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 MAX_ENTRIES = 50_000 @@ -58,9 +57,7 @@ def stage_bundle(archive: Path, destination: Path): files.add(name) if not REQUIRED_FILES <= files: raise ValueError("Preview bundle is missing required entry points") - # Validate all names before writing anything. This is a fresh directory - # outside the checkout; neither pre-existing links nor trusted files - # can be followed or overwritten. ZIP permissions are never restored. + # Validate before writing into a fresh directory so existing links/files cannot be followed or overwritten. destination.mkdir(parents=True, exist_ok=False) for entry in entries: target = destination / entry.filename diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index 5ed6a6ff498a9..ca57769ed490c 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -294,8 +294,7 @@ async function resolve({ github, context, core }) { const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id); const sourceArtifact = findResultArtifact(sourceArtifacts); - // CI requires the artifact whenever the server tests run, so a passing run - // without one skipped them because the PR cannot affect the server. + // Successful server tests require this artifact; its absence means CI skipped them. if (!sourceArtifact && source.conclusion === "success") { core.info("CI skipped the server tests for this change; nothing to report."); core.setOutput("publish", "false"); diff --git a/.macroscope/ignore.md b/.macroscope/ignore.md index 0541e7407c0c9..66e5434c5ed13 100644 --- a/.macroscope/ignore.md +++ b/.macroscope/ignore.md @@ -255,3 +255,27 @@ ENV/** # (see scripts/lib/reference-repos.ts). Nothing imports from them; findings # there belong upstream. .repos/** + +# Generated code. Review its source (the script, GraphQL document, schema +# definition, or theme) instead; review agents can still read these on demand. +**/*.generated.ts +**/*.graphql.ts +apps/server/schema/expo.graphql +packages/source-control-github/schema/github.graphql +apps/mobile/generated-uniwind-* +apps/mobile/clerk-theme.json +apps/server/src/testUtils/shardWeights.json +infra/relay/migrations/**/snapshot.json + +# Provider transcripts recorded from real CLI runs for the orchestration replay +# tests. Each scenario's input.ts and *_output.ts assertions stay reviewed. +**/*.ndjson + +# Ghostty build output vendored from upstream. +apps/mobile/modules/t3-terminal/Vendor/** +native/libghostty-vt/include/** + +# @pierre/highlights compiled to C by wasm2c, plus the wasm2c runtime and simde +# it needs. Regenerated by apps/mobile/scripts/generate-highlights.mts; review +# that script and cpp/t3_highlights.c instead. +apps/mobile/modules/t3-highlights/Vendor/** diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index e79d0e71dc863..a7b10f64404a6 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -1,8 +1,5 @@ -// Windows WSL dev mode runs the server under Linux from the same node_modules -// as the Windows client, so Windows installs also need Linux x64 glibc native -// modules. Other platforms install only their own binaries; setting this -// everywhere added ~2.2 GB of foreign binaries to every macOS install. -// Release scripts stage their own installs with explicit architectures. +// Windows installs need Linux x64 glibc binaries because WSL shares node_modules. +// Keep other platforms native; release scripts set their own architectures. module.exports = { hooks: { // `typescript` is @tsc-rs/typescript (the catalog entry). Its npm version (0.8.x) is outside diff --git a/.task-cache.json b/.task-cache.json new file mode 100644 index 0000000000000..0e62827f9d64e --- /dev/null +++ b/.task-cache.json @@ -0,0 +1,3 @@ +{ + "endpoint": "https://t3-task-cache.pinglabs.workers.dev/projects/t3code" +} diff --git a/AGENTS.md b/AGENTS.md index 41a777116cc9f..ca86bf54bd824 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,7 +153,7 @@ Most code changes do not need an internal documentation change. Agents can read - `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. +- A local reason the code can't show goes in a short 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. @@ -199,7 +199,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ Named `Effect.fn` creates a span; the unnamed form does not. Preserve existing span names and options, and pass combinators as additional function-builder arguments. - Inferred types over annotations. `any` is the enemy. -- Comments say why: a constraint, an external quirk, or an invariant the code can't show. Don't restate names or signatures, narrate steps, or record history. `t3code/no-low-value-comments` catches the mechanical cases. +- Default to no comment. Write one only for a constraint, an external quirk, or an invariant the code can't show, in one line where you can and four at most. Don't restate names or signatures, narrate steps, record history, or explain the fix you just made; the PR carries that. `t3code/no-low-value-comments` enforces the length cap and the mechanical cases. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. - If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it. diff --git a/apps/coil-home/src/lib/install.ts b/apps/coil-home/src/lib/install.ts index 5277aac5fe099..ddd026a9a4c8c 100644 --- a/apps/coil-home/src/lib/install.ts +++ b/apps/coil-home/src/lib/install.ts @@ -1,16 +1,7 @@ /* - * First-run install instructions for the download page. - * - * The data is NOT defined here. It lives in scripts/coil/install-instructions.json, which the - * release workflow also renders into every GitHub release body via - * scripts/coil/render-release-notes.mjs. Two surfaces, one source — before issue #72 the release - * body was the only copy, and this page merely said the release notes "carry the commands to get - * past it" without linking them, so a reader could arrive here, download a build, and meet a - * Gatekeeper dialog calling it damaged with nothing on screen to explain it. - * - * The JSON is imported rather than fetched, on purpose: these steps have to be on the page when - * it renders, including when the update relay is down and there is nothing else to show. The - * types come from the import — hand-written interfaces here would be a third copy of the shape. + * The data lives in scripts/coil/install-instructions.json, which the release workflow also + * renders into every release body, so both surfaces share one source. Imported rather than + * fetched so the steps render even when the update relay is down; types come from the import. */ import instructions from "../../../../scripts/coil/install-instructions.json"; diff --git a/apps/coil-home/src/lib/releases.ts b/apps/coil-home/src/lib/releases.ts index 4d72d4da2ade6..76673d492f009 100644 --- a/apps/coil-home/src/lib/releases.ts +++ b/apps/coil-home/src/lib/releases.ts @@ -1,19 +1,8 @@ /* - * Downloads come from the fork's update relay, not GitHub's releases API. - * - * This used to be forced. Every fork release was published as a GitHub *pre-release*, and - * `api.github.com/repos/radroid/t3code/releases/latest` — which upstream's marketing site uses — - * skips pre-releases and 404'd for this repo. Releases now carry a real Latest pointer, so that - * API would answer. - * - * The relay is still the right source, for the reason that outlives the 404: it serves the same - * manifest the desktop app's updater consumes, so the site and the app agree on what "latest" - * means by construction rather than by coincidence. + * Downloads come from the fork's update relay, not GitHub's releases API: it serves the same + * manifest the desktop app's updater consumes, so the site and the app agree on "latest". Unlike + * the desktop app, this page is fetched fresh, so it can point straight at the renamed relay. */ -// Moved to the renamed relay by #71. Safe to move here, unlike in the desktop app: this page is -// fetched fresh on every visit, so it has no old copies of itself in the field. The old hostname -// still answers — it proxies to this one for builds that predate the rename — but pointing the -// site through a shim it does not need would just add a hop. const MANIFEST_URL = "https://coil-update-relay.businesses.workers.dev/latest"; const CACHE_KEY = "coil-latest-manifest"; diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 0fb4377f07be0..e433dc498cada 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -372,9 +372,7 @@ function buildMacLauncher(electronBinaryPath) { JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { if (isDevelopment) { - // The launcher also handles protocol activations outside the dev runner, - // so refresh its fallback environment on every launch. Never let a value - // captured by an older parent app override the live dev-runner environment. + // Protocol activations bypass the dev runner, so refresh the fallback environment on every launch. writeDevelopmentEnvironmentScript(); if (writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath)) { signMacLauncherBundle(targetAppBundlePath); @@ -385,10 +383,7 @@ function buildMacLauncher(electronBinaryPath) { } NodeFS.rmSync(targetAppBundlePath, { recursive: true, force: true }); - // verbatimSymlinks keeps the framework's relative symlinks intact - // (e.g. Resources -> Versions/Current/Resources). Without it cpSync - // rewrites them to absolute paths into node_modules, which escape the - // bundle and crash sandboxed helper processes (icudtl.dat not found). + // Without verbatimSymlinks, cpSync makes framework symlinks point outside the bundle and crashes helpers. NodeFS.cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true, verbatimSymlinks: true, @@ -400,10 +395,7 @@ function buildMacLauncher(electronBinaryPath) { ); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { - // Launching the node_modules copy makes macOS associate the process (and Dock label) with - // Electron.app even though this bundle's Info.plist has the T3 Code name. Its conventional - // executable name also keeps Electron's default-app runtime in development mode instead of - // making app.isPackaged report true. + // Electron.app's executable name keeps default-app mode active without making app.isPackaged true. writeDevelopmentEnvironmentScript(); writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 7dffe9fe3b1f8..f57e709bca7af 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -339,11 +339,7 @@ const scopedProgram = Effect.scoped( const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; yield* Effect.addFinalizer(() => - // Stop every backend in the pool, not just the primary. The - // electronApp.quit() path can race ahead of the layer-scope - // cascade, so leaving the WSL instance for its parent scope - // finalizer means it gets hard-killed by the OS instead of - // receiving SIGTERM + grace. + // Quit can outrun layer finalizers; stop pooled backends here so they receive SIGTERM before OS teardown. stopAllPoolInstances().pipe( Effect.ensuring(rendererHistory.shutdown), Effect.ensuring(shutdown.markComplete), diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 688bd789572f7..05d5a3254a84f 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -186,10 +186,7 @@ export async function startDesktopAppControlServer(input: { server.listen(address); }); - // Closing a Unix socket server unlinks the path it was bound to, even when - // another app's socket lives there now. Bind a staging path and move it onto - // the address instead, so a later close only unlinks the staging path, which - // is already gone. + // Closing unlinks the original bind path, even if another app replaced it. Bind a staging path first. const bindUnix = async (directory: string, mode: "take-over" | "claim-free") => { const staging = NodePath.join(directory, `${NodeCrypto.randomBytes(6).toString("hex")}.tmp`); @@ -213,7 +210,6 @@ export async function startDesktopAppControlServer(input: { let server: NodeNet.Server; let inode: number | null = null; if (input.directory === null) { - // Named pipes close with the app that owns them, so no other app can remove this one. server = await listen(input.address); } else { await prepareUnixDirectory({ directory: input.directory, userId: input.userId }); @@ -222,10 +218,10 @@ export async function startDesktopAppControlServer(input: { let closed = false; const reclaimOnce = async () => { - // Never replace a socket that exists, so two apps cannot trade the path back and forth. + // Replacing an existing socket would let two apps trade the path indefinitely. if (closed || input.directory === null || (await inodeAt(input.address)) !== null) return; if (await prepareUnixDirectory({ directory: input.directory, userId: input.userId })) { - // A watch follows the directory's inode, so a recreated directory needs a new one. + // Directory watches follow inodes, so recreate the watch after directory replacement. watchDirectory(input.directory); } const next = await bindUnix(input.directory, "claim-free").catch( @@ -269,7 +265,7 @@ export async function startDesktopAppControlServer(input: { close: async () => { if (closed) return; closed = true; - // A running reclaim can replace the watcher, so close the watcher after it. + // Reclaim can replace the watcher; close it afterward. await pendingReclaim; watcher?.close(); for (const socket of sockets) socket.destroy(); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 395c06141d64c..5fd6983c66682 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -38,7 +38,7 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const electronApp = yield* ElectronApp.ElectronApp; @@ -91,9 +91,7 @@ export const make = Effect.gen(function* () { const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; - // Electron removes spaces from this name to build the native User-Agent - // product token, but leaves parentheses intact. Keep the runtime name valid - // without rewriting preview sessions (which breaks Turnstile, #7110). + // Electron strips spaces but keeps parentheses in its User-Agent token, breaking Turnstile in preview sessions. yield* electronApp.setName( `${environment.branding.baseName} ${environment.branding.stageLabel}`, ); @@ -107,9 +105,7 @@ export const make = Effect.gen(function* () { yield* electronApp.setAppUserModelId(environment.appUserModelId); } - // Unpackaged runs only. A packaged bundle already carries its icon in - // Info.plist, so setting the dock tile again changes nothing except to - // overwrite a custom icon the user attached to the app themselves. + // Packaged bundles carry their icon in Info.plist; setting it again overwrites the user's custom icon. if (environment.platform === "darwin" && !environment.isPackaged) { const iconPaths = yield* assets.iconPaths; yield* Option.match(iconPaths.png, { diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 505b48194cf7c..3e0d753d1bfba 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -95,7 +95,7 @@ function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { }); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const electronApp = yield* ElectronApp.ElectronApp; @@ -105,14 +105,12 @@ export const make = Effect.gen(function* () { const userDataPath = yield* DesktopUserData.resolveUserDataPath(environment); yield* electronApp.setPath("userData", userDataPath); - // T3 owns the profile-scoped lock on every platform; Clerk skips it on macOS. - // Quit is asynchronous, so interrupt before the application runtime is built. + // Clerk skips the profile lock on macOS. Interrupt before runtime construction because quit is asynchronous. if (!(yield* electronApp.requestSingleInstanceLock)) { yield* electronApp.quit; return yield* Effect.interrupt; } - // Retain launch arguments while asynchronous startup builds the application runtime. const pendingSecondInstances: Array = []; let handleSecondInstance = (argv: readonly string[]) => { pendingSecondInstances.push(argv); diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index c9a79a81d3b55..db56aa3c83b03 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -83,7 +83,6 @@ const fakePowerShell = (initial: string) => { return { registry, spawner }; }; -// The machine's own PATH may already hold a t3; each test sets the PATH it means. const machinePath = process.env.PATH; beforeEach(() => { process.env.PATH = ""; diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 294f8a8b984e7..95d844e602124 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -28,14 +28,8 @@ const pathEntries = (value: string | undefined, separator: string) => const sameWindowsPath = (left: string, right: string) => left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase(); -/** - * Reads, or with `T3_SET` set writes, the user's PATH in the registry, keeping - * `%VAR%` entries unexpanded. Writes keep REG_EXPAND_SZ (Windows' default for - * PATH; `SetEnvironmentVariable` would store REG_SZ and break every `%VAR%` - * entry), then broadcast WM_SETTINGCHANGE so Explorer and the terminals it - * opens see the change. A failed read exits nonzero rather than reading as - * empty, so a write never replaces the user's PATH wholesale. - */ +// Preserve REG_EXPAND_SZ and %VAR% expansion; notify Explorer with WM_SETTINGCHANGE. +// Failed reads must exit nonzero to prevent replacing PATH wholesale. const WINDOWS_USER_PATH_SCRIPT = ` $ErrorActionPreference = 'Stop' if ($env:T3_SET -eq '1') { @@ -64,7 +58,7 @@ export class DesktopCliCommand extends Context.Service< } >()("@t3tools/desktop/app/DesktopCliCommand") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fs = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index e043b4d16d8de..50fcb08d9c64a 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -81,7 +81,6 @@ describe("renderCliShim", () => { executable: NodePath.join(root, "app"), entry: "/bin.mjs", }); - // sudo clears the environment, so the launcher supplies its own. expect(run(shim, []).stdout).toContain(`home=${root}/home`); expect(run(shim, [], { T3CODE_HOME: "/elsewhere" }).stdout).toContain("home=/elsewhere"); const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" }); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index a9d9f04c4e829..3d4b94abff462 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -6,8 +6,7 @@ import * as Option from "effect/Option"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; -// An AppImage mounts somewhere new each run, so its launcher mounts the -// AppImage itself instead of pointing into it. +// AppImage mounts change each run; the launcher must mount the image itself. const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; @@ -26,12 +25,7 @@ export type CliShimTarget = | { readonly kind: "direct"; readonly executable: string; readonly entry: string } | { readonly kind: "windows"; readonly executable: string; readonly entry: string }; -/** - * Electron runs the server as plain Node with `ELECTRON_RUN_AS_NODE`, which - * reads the entry from inside the asar archive. The launcher's own path and the - * app's T3 home are written in, so the command the server shows is absolute and - * `sudo`, which clears the environment, still runs against this install's home. - */ +/** Embed this install's home and executable path so `sudo`, which clears the environment, uses the same install. */ export const renderCliShim = (input: { readonly target: CliShimTarget; readonly shimPath: string; @@ -48,9 +42,7 @@ export const renderCliShim = (input: { `rem ${MARKER}`, // Delayed expansion would rewrite `!name!` in paths and arguments. "setlocal EnableExtensions DisableDelayedExpansion", - // Only a non-ASCII path needs the console switched to UTF-8 to read it; a - // Ctrl-C that ends the batch skips the restore, so the switch is left out - // for the common all-ASCII install. + // Ctrl-C skips codepage restoration, so switch to UTF-8 only for non-ASCII install paths. ...(utf8 ? [ `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`, @@ -97,8 +89,7 @@ export const renderCliShim = (input: { ...header, // Extract-and-run would make the runtime start the app instead of mounting it. "unset APPIMAGE_EXTRACT_AND_RUN", - // The AppImage runtime prints its mount point, then stays mounted until - // killed. Background jobs ignore Ctrl-C, so the traps unmount it on any exit. + // The runtime stays mounted until killed; traps unmount it because background jobs ignore Ctrl-C. "work=$(mktemp -d) || exit 1", '"$app" --appimage-mount >"$work/mount" 2>/dev/null &', "mounter=$!", diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 773ea24509cf1..11f975a6b86af 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -55,6 +55,7 @@ export const DesktopConfig = Config.all({ otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe( Config.withDefault("http/json"), ), + desktopLauncherPath: trimmedString("T3CODE_DESKTOP_LAUNCHER_PATH"), appImagePath: trimmedString("APPIMAGE"), disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index 0015e642672c0..00a355bf49617 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -86,8 +86,7 @@ const WAYLAND_COLOR_MANAGER_FEATURE = "WaylandWpColorManagerV1"; const featureListNames = (value: string | null): ReadonlyArray => (value ?? "").split(",").flatMap((entry) => entry.trim().match(/^\*?([^<:]+)/)?.[1] ?? []); -// A stale HYPRLAND_INSTANCE_SIGNATURE is often inherited into other desktops, so only -// trust the session's desktop names. +// HYPRLAND_INSTANCE_SIGNATURE can be stale; trust only the session's desktop names. const isHyprlandSession = (env: NodeJS.ProcessEnv): boolean => (trimNonEmpty(env.XDG_CURRENT_DESKTOP) ?? trimNonEmpty(env.XDG_SESSION_DESKTOP) ?? "") .split(":") @@ -103,12 +102,8 @@ const HyprctlVersionJson = fromLenientJson( ); const decodeHyprctlVersionJson = Schema.decodeSync(HyprctlVersionJson); -// Hyprland 0.48.0 added wp-color-manager-v1 (hyprwm/Hyprland#9444) advertising -// FEATURE_SET_PRIMARIES while rejecting set_primaries outside debug mode, which Chromium -// treats as a fatal protocol error. 0.51.0 accepts them (hyprwm/Hyprland#11499). -// Only stock builds of these releases count. Matching the commit as well as the tag -// keeps later development builds that still report an old VERSION and tag (Nix -// synthesizes `v${VERSION}`) on the default. +// Hyprland 0.48.0 rejects advertised set_primaries (hyprwm/Hyprland#9444); 0.51.0 accepts it (hyprwm/Hyprland#11499). +// Check commits because dev and Nix builds can retain release versions and tags. const AFFECTED_HYPRLAND_RELEASE_COMMITS: ReadonlyMap = new Map([ ["0.48.0", "5ee35f914f921e5696030698e74fb5566a804768"], ["0.48.1", "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc"], diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index ecf08255feb3f..1066198b8f610 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -77,6 +77,7 @@ export class DesktopEnvironment extends Context.Service< readonly linuxDesktopEntryName: string; readonly linuxWmClass: string; readonly linuxApplicationsDir: string; + readonly desktopLauncherPath: Option.Option; readonly appImagePath: Option.Option; readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; readonly runtimeInfo: DesktopRuntimeInfo; @@ -233,6 +234,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( linuxDesktopEntryName: resolveLinuxDesktopEntryName(isDevelopment), linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", linuxApplicationsDir, + desktopLauncherPath: config.desktopLauncherPath, appImagePath: config.appImagePath, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), runtimeInfo: resolveDesktopRuntimeInfo({ diff --git a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts index 122aa3393f9cd..90043d9398a2c 100644 --- a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts +++ b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts @@ -10,10 +10,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import { readChromiumLocalStorage } from "./chromiumLocalStorage.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -/** - * The V1 profile is only read, never opened by Chromium, so this works while V1 - * is still running. - */ +/** Reads the V1 profile without opening it in Chromium, even while V1 runs. */ export class DesktopLegacyLocalStorage extends Context.Service< DesktopLegacyLocalStorage, { diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 7512d2bc1741d..93347949e00fa 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -208,7 +208,7 @@ function quitFromSignal( ); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = DesktopLifecycle.of({ relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { const electronApp = yield* ElectronApp.ElectronApp; diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 854f43c5623c1..feedbbd580074 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -31,6 +31,7 @@ const makeEnvironment = (path: Path.Path, overrides: Record = { linuxDesktopEntryName: "com.t3tools.T3Code.desktop", linuxWmClass: "t3code", linuxApplicationsDir: "/home/alice/.local/share/applications", + desktopLauncherPath: Option.none(), appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), path, ...overrides, @@ -228,6 +229,18 @@ describe("DesktopLinuxUrlHandler", () => { }); }); + it.effect("launches a distribution package through its configured launcher", () => { + const recorded = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(recorded, { + environment: { desktopLauncherPath: Option.some("/opt/T3 Code/t3code") }, + }); + + assert.include(recorded.files[0]?.content, 'Exec="/opt/T3 Code/t3code" %U'); + }); + }); + it.effect("does not rewrite the pre-ready entry while the portal can be reading it", () => { const recorded = emptyRecording(); @@ -457,9 +470,7 @@ const themeIconFixture = (source: Uint8Array) => const hicolorDir = path.join(root, "icons", "hicolor"); const appsDir = path.join(hicolorDir, "256x256", "apps"); const cachePath = path.join(hicolorDir, "icon-theme.cache"); - // A fixed time in the past, in whole seconds as fs.utimes expects. const pastSeconds = 1_600_000_000; - // An existing cache that predates any installed icon. const seedCache = Effect.gen(function* () { yield* fileSystem.makeDirectory(appsDir, { recursive: true }); yield* fileSystem.writeFile(cachePath, new Uint8Array([0])); @@ -547,7 +558,6 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { commands: [], onIconCacheRefresh: fixture.rewriteCache, }; - // A cache stamped later than the new icons, as after a clock correction. const future = fixture.pastSeconds + 10 * 365 * 24 * 60 * 60; yield* fileSystem.utimes(fixture.cachePath, future, future); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index e03418964e33b..dd38acbc3894e 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -12,12 +12,7 @@ import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; -// Linux ships as an AppImage, so the .desktop entry users end up with is -// created by whatever integration tool they use (AppImageLauncher names it -// appimagekit_-….desktop) and its filename is not under our control. -// Electron's app.setAsDefaultProtocolClient resolves the desktop id from -// setDesktopName, which cannot match those files — so the browser keeps -// prompting "Choose an application" for every OAuth callback. +// AppImage integrators choose the .desktop filename, so setDesktopName cannot identify it for protocol registration. const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError()( @@ -62,9 +57,7 @@ const escapeDesktopEntryString = (value: string): string => .replaceAll("\r", "\\r") .replaceAll("\t", "\\t"); -// Exec values are unescaped twice by implementations: first the general -// string-value rules, then the Exec quoting rules — so writing composes the -// layers in reverse. +// Exec values undergo general unescaping then Exec unquoting; escape in reverse order. export function escapeDesktopEntryExecArgument(value: string): string { const quoted = value .replaceAll("\\", () => "\\\\") @@ -103,9 +96,7 @@ export function renderUrlHandlerDesktopEntry(input: { ].join("\n"); } -// Some docks and bars look the Wayland app id up in the icon theme when no -// visible desktop entry matches the window, for example an AppImage run without -// integration. Theme lookups are case-sensitive, so install both spellings. +// Docks may look up the Wayland app id without a desktop entry; icon theme lookups are case-sensitive. const LINUX_THEME_ICON_SIZE = "256x256"; function linuxThemeIconNames(desktopEntryName: string): readonly string[] { @@ -123,7 +114,7 @@ export class DesktopLinuxUrlHandler extends Context.Service< } >()("@t3tools/desktop/app/DesktopLinuxUrlHandler") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; @@ -141,17 +132,21 @@ export const make = Effect.gen(function* () { const themeIconDir = environment.path.join(hicolorDir, LINUX_THEME_ICON_SIZE, "apps"); const writeDesktopEntry = Effect.gen(function* () { + // Distribution packages that run on a shared Electron set + // T3CODE_DESKTOP_LAUNCHER_PATH, because process.execPath is that Electron. // Inside the mounted AppImage, process.execPath points at a transient // /tmp/.mount_* path — the handler must launch the AppImage itself. - const execTarget = Option.getOrElse(environment.appImagePath, () => process.execPath); + const execTarget = environment.desktopLauncherPath.pipe( + Option.orElse(() => environment.appImagePath), + Option.getOrElse(() => process.execPath), + ); const content = renderUrlHandlerDesktopEntry({ displayName: environment.displayName, execTarget, scheme, ...(environment.isPackaged ? { iconPath } : {}), }); - // Pre-ready setup normally wrote this already. Avoid truncating a valid - // entry while the portal may be reading it during startup. + // Avoid truncating the pre-ready entry while the portal may be reading it. const existing = yield* fileSystem .readFileString(desktopEntryPath) .pipe(Effect.orElseSucceed(() => null)); @@ -235,10 +230,8 @@ export const make = Effect.gen(function* () { yield* fileSystem.writeFile(target, icon); changed = true; } - // Only keep an existing cache current. Creating one would leave GTK reading - // a cache that other tools installing user icons do not update. An icon - // newer than the cache also retries a refresh that failed on an earlier - // launch. + // Creating a cache would leave GTK reading stale icons from other installers. + // A newer icon retries a refresh that failed on an earlier launch. const cachePath = environment.path.join(hicolorDir, "icon-theme.cache"); const cachedAt = yield* modifiedAt(cachePath).pipe(Effect.option); if (Option.isNone(cachedAt)) return; @@ -313,10 +306,7 @@ export const make = Effect.gen(function* () { yield* installThemeIcons; yield* updateDesktopDatabase.pipe( - // Some MIME implementations, including GIO, use mimeinfo.cache to verify - // that a desktop entry is associated with a scheme. Cache refresh is - // independently best-effort so a missing update-desktop-database executable - // does not prevent xdg-mime from recording the requested default. + // GIO verifies scheme associations through mimeinfo.cache. A missing cache updater must not block xdg-mime. Effect.catch((error) => logWarning("desktop MIME cache refresh failed", { applicationsDir: environment.linuxApplicationsDir, diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 55d200a159177..6baf2c4117f9d 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -428,8 +428,7 @@ const backendLogFilePathForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, ): string => { - // Primary keeps the historical "server-child.log" path so ops scripts - // and packaged-build log inspection still find it where it always lived. + // Keep the primary log path stable for ops scripts and packaged-build inspection. if (id === PRIMARY_BACKEND_LOG_INSTANCE_ID) { return environment.path.join(environment.logDir, "server-child.log"); } @@ -437,8 +436,7 @@ const backendLogFilePathForInstance = ( return environment.path.join(environment.logDir, `server-child-${sanitized}.log`); }; -// Cacheable by resolved file path so two ids that sanitize to the same filename -// share a single RotatingLogFileWriter (no race on currentSize tracking). +// IDs sharing a sanitized filename must share a writer to avoid currentSize races. const makeBackendOutputSinkForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, @@ -609,16 +607,7 @@ const layerTelemetry = Layer.unwrap( }, }; - // `Logger.layer` writes the whole logger set rather than adding to it, so - // every logger the main process wants has to be named in this one call. - // Splitting the OTLP logger back out into a layer of its own silently - // drops either it or the console output. - // Swapping `Logger.tracerLogger` out for the OTLP logger matches the - // server: both reach a collector, but the tracer logger covers only - // messages logged inside a recorded span and files them under traces, - // while the OTLP logger carries every message as a log record stamped - // with its trace and span ids. Keeping both would export every in-span - // message twice. + // Logger.layer replaces the logger set. tracerLogger would duplicate OTLP in-span messages. const layerLogger = Logger.layer( endpoints.logs === undefined ? [Logger.consolePretty(), Logger.tracerLogger] diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts index 5af13f94413f5..6a7473e2f1b1a 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts @@ -32,9 +32,7 @@ const syncFs = (method: string, path: string, run: () => A) => }), }); -/** - * @public Service construction is part of the canonical Effect module API. - */ +/** @public */ export const make = FileSystem.makeNoop({ exists: (path) => syncFs("exists", path, () => { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 143a6e5fd87c7..f2b176ee3761f 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -166,6 +166,28 @@ describe("DesktopPreReadyPlatform", () => { ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }); + it.effect("points the early portal entry at a configured distribution launcher", () => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + vi.stubEnv("XDG_DATA_HOME", "/xdg"); + vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); + vi.stubEnv("T3CODE_DESKTOP_LAUNCHER_PATH", " /opt/T3 Code/t3code "); + getSwitchValueMock.mockReturnValue(""); + + return Effect.scoped( + Effect.gen(function* () { + yield* Layer.build( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), + ), + ); + const entry = writeFileSyncMock.mock.calls.find( + ([path]) => path === "/xdg/applications/com.t3tools.T3Code.desktop", + )?.[1]; + assert.include(entry, 'Exec="/opt/T3 Code/t3code" %U'); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }); + it.effect( "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", () => diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 58670439a5255..8bef7366e378a 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -49,7 +49,7 @@ export class DesktopPreReadyElectronOptions extends Context.Service< } >()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyElectronOptions") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; const homeDirectory = yield* HostProcess.HomeDirectory; @@ -109,15 +109,17 @@ export const make = Effect.gen(function* () { isDevelopment: linux.isDevelopment, appVersion: Electron.app.getVersion(), }).displayName, - execTarget: process.env.APPIMAGE?.trim() || process.execPath, + execTarget: + process.env.T3CODE_DESKTOP_LAUNCHER_PATH?.trim() || + process.env.APPIMAGE?.trim() || + process.execPath, scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), ...(iconPath === undefined ? {} : { iconPath }), }), "utf8", ); } catch {} - // Chromium caches its portal registration during startup. Set the identity - // before any asynchronous work can initialize it with Electron's default. + // Chromium caches portal identity; set it before any asynchronous initialization. Electron.app.setDesktopName(linux.linuxDesktopEntryName); Electron.app.commandLine.appendSwitch("class", linux.linuxWmClass); if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) { @@ -146,8 +148,7 @@ export const make = Effect.gen(function* () { }); }).pipe(Effect.withSpan("desktop.electron.configureBeforeReady")); -// Keep Electron's strict pre-ready setup isolated so later runtime layers cannot -// observe app readiness before scheme privileges and command-line switches exist. +// Complete scheme privileges and switches before later layers can observe app readiness. export const layer = Layer.mergeAll( ElectronProtocol.layerSchemePrivileges, Layer.effect(DesktopPreReadyElectronOptions, make), diff --git a/apps/desktop/src/app/chromiumLocalStorage.ts b/apps/desktop/src/app/chromiumLocalStorage.ts index 98de2e91eb063..51beceb9e463f 100644 --- a/apps/desktop/src/app/chromiumLocalStorage.ts +++ b/apps/desktop/src/app/chromiumLocalStorage.ts @@ -4,12 +4,7 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -/** - * It reads the database files directly and never takes LevelDB's lock, so it - * works while another app version has the profile open. Live files come from - * the MANIFEST that CURRENT names, and the newest sequence number wins, which - * is how LevelDB itself resolves overwrites and deletions. - */ +/** Read without LevelDB's lock so another app can keep the profile open. */ const LOG_BLOCK_SIZE = 32 * 1024; const LOG_HEADER_SIZE = 7; @@ -145,11 +140,7 @@ function decompressSnappy(input: Uint8Array): Uint8Array { return output; } -/** - * Like LevelDB's own reader, a corrupt record or zero padding skips the rest of - * its block, since a reused log keeps appending after a torn write, and a - * record cut off by the end of the file ends the log. - */ +/** Corruption skips the remaining block; a truncated final record ends the log, matching LevelDB recovery. */ function readLogRecords(bytes: Uint8Array): Uint8Array[] { const records: Uint8Array[] = []; let pending: Uint8Array[] | null = null; @@ -422,9 +413,7 @@ export const readChromiumLocalStorage = Effect.fn("desktop.chromiumLocalStorage. }).pipe(wrap); } - // A flush while another process has the database open can move entries - // from a log we read into a table our MANIFEST copy predates. Fail so the - // caller retries rather than import a snapshot missing those entries. + // Concurrent flushes can move entries beyond this MANIFEST snapshot; detect changes so the caller retries. const currentAfter = textDecoderUtf8.decode(yield* readFile("CURRENT").pipe(wrap)).trim(); const manifestAfter = yield* fs.stat(path.join(directory, current)).pipe(wrap); if (currentAfter !== current || Number(manifestAfter.size) !== manifest.length) { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 9b95c648a32b5..029602af36052 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -81,8 +81,7 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_TAILSCALE_SERVE_PORT", ] as const; -// Env vars that the WSL backend needs but Windows process.env won't forward -// across the wsl.exe boundary without WSLENV. +// Windows forwards these variables into WSL only through WSLENV. const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", @@ -92,9 +91,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_SDK_DISABLED", "T3CODE_OTLP_HEADERS", "T3CODE_OTLP_PROTOCOL", - // Forwarded without a WSLENV flag, so the values arrive untranslated. The - // server prefers an OTEL endpoint over the bootstrap envelope, so the T3 URLs - // travel as variables to keep winning inside the distro as they do on Windows. + // Untranslated T3 URLs must override bootstrap endpoints inside WSL, as they do on Windows. "T3CODE_OTLP_TRACES_URL", "T3CODE_OTLP_METRICS_URL", "T3CODE_OTLP_LOGS_URL", @@ -117,20 +114,15 @@ const WSL_FORWARDED_ENV_NAMES = [ const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; -// Give the backend a predictable V8 old-space ceiling instead of inheriting the -// default. This is headroom for transient heap spikes (large diffs, big file -// reads) so a memory-constrained host is less likely to push the backend into a -// GC-thrash / stall while it is busy — part of the robustness work for issue -// #21. It is a ceiling, not a reservation, so it does not raise steady-state -// memory use (the backend's normal RSS is well under this). +// Headroom for transient heap spikes (large diffs, big file reads) so a +// memory-constrained host is less likely to stall the backend in GC. It is a +// ceiling, not a reservation, so it does not raise steady-state memory use. const DESKTOP_BACKEND_MAX_OLD_SPACE_MB = 4096; const DESKTOP_BACKEND_MAX_OLD_SPACE_FLAG = `--max-old-space-size=${DESKTOP_BACKEND_MAX_OLD_SPACE_MB}`; -// Append our heap-headroom flag to any NODE_OPTIONS already exported (by the -// user or the dev-runner) rather than replacing it. If an explicit -// --max-old-space-size is already present we leave it untouched so an operator -// can override the ceiling. Electron-run-as-node honours the NODE_OPTIONS -// safelist, which includes --max-old-space-size. +// Append to any existing NODE_OPTIONS rather than replacing it, and leave an +// explicit --max-old-space-size alone so an operator can override the ceiling. +// Electron-run-as-node honours it through the NODE_OPTIONS safelist. const backendNodeOptions = (existing: string | undefined): string => { const trimmed = existing?.trim() ?? ""; if (trimmed.includes("--max-old-space-size")) { @@ -169,10 +161,7 @@ const mergeWslEnv = ( const additions = forwardedEnvNames.filter((name) => !seenNames.has(name)); - // Preserve the user's WSLENV exactly as Windows handed it to us — empty - // "::" segments and duplicate entries are harmless no-ops to WSL and not - // ours to normalize — and only append the secrets we need to forward - // across the wsl.exe boundary. + // Preserve empty segments and duplicates in the user's WSLENV; only append required variables. const parts = [existing, ...additions].filter((part) => part.length > 0); return parts.length > 0 ? parts.join(":") : undefined; }; @@ -277,10 +266,7 @@ type WslPreflightRuntime = } | { readonly kind: "node-script"; - // Absolute path to the node binary the preflight validated after the - // shared remote resolver repaired PATH. The launch must use this exact - // path so it doesn't fall through to a different/old node than the one - // node-pty was probed with. + // Launch the exact Node path probed with node-pty, even if later PATH lookup would resolve another version. readonly nodePath: string; readonly linuxEntryPath: string; }; @@ -421,8 +407,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f if (input.runtimeArchive !== null) { const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); if (runtime.ok) { - // The staged runtime supplies its own Node and node-pty. Provider PATH - // discovery must not require either dependency for runtime readiness. + // Staged runtimes bundle Node and node-pty; provider PATH discovery must not require them. const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); if (stagedProbe.ok) { yield* wslServerTree.cleanupLegacy; @@ -462,10 +447,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f nodePtyOptions, ); if (!nodePtyResult.ok) { - // Substituting the staged verdict for a transient mounted failure would - // turn a retryable failure into a fatal one, ending the WSL attempt (and, - // in wsl-only mode, persisting Windows) before the slow /mnt path had a - // chance to answer and clear the bad cache. + // Only substitute a staged failure for a fatal mounted failure; transient /mnt failures must remain retryable. return stagedFailure && nodePtyResult.fatal ? failedStaged(stagedFailure) : failedNodePty(nodePtyResult); @@ -488,16 +470,12 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f } as const; }); -// In WSL2 mirrored mode the distro's eth0 IP equals the host's, which is the -// signature we use to detect that mode and switch the renderer URL to loopback. +// In WSL2 mirrored mode, eth0 matches a host IP; use loopback for the renderer. const isLocalHostIpv4 = (ip: string): boolean => { const interfaces = NodeOS.networkInterfaces(); for (const list of Object.values(interfaces)) { if (!list) continue; for (const entry of list) { - // os.networkInterfaces() reports IPv4 `family` as the string "IPv4" on - // the Node build Electron ships (41 / Node 22, verified), but some Node - // builds report the numeric 4. const family = String(entry.family); if ((family === "IPv4" || family === "4") && entry.address === ip) return true; } @@ -544,7 +522,6 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv host: backendExposure.bindHost, desktopBootstrapToken: input.bootstrapToken, desktopBootstrapSecret: input.bootstrapSecret, - // Failed Windows probes leave server hydration enabled for another attempt. shellEnvironmentPrepared: input.shellEnvironmentPrepared, tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, tailscaleServePort: backendExposure.tailscaleServePort, @@ -561,10 +538,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv return { executablePath: process.execPath, - // Packaged builds only, so a dev instance never shares the cache with the - // prod app it is often run from. `--require` rather than NODE_COMPILE_CACHE, - // so the setting does not leak into the provider and terminal processes - // the backend starts. + // Packaged builds have a separate cache; --require prevents cache settings leaking to provider subprocesses. args: [ ...(environment.isPackaged ? ["--require", environment.compileCachePath] : []), environment.backendEntryPath, @@ -606,25 +580,18 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const fileSystem = yield* FileSystem.FileSystem; - // wslhost forwarding is unreliable on some Windows hosts: the desktop's - // readiness probe and the renderer's saved-env-style fetch both saw "Failed - // to fetch" when the backend only bound to 127.0.0.1 inside WSL. Binding to - // 0.0.0.0 plus advertising the WSL IP as the renderer-visible URL avoids that - // dependency. + // wslhost loopback forwarding is unreliable on some Windows hosts; advertise the WSL IP instead. const wslBindHost = "0.0.0.0"; const bootstrap = { mode: "desktop" as const, noBrowser: true, port: input.port, - // Omit t3Home so the Linux backend uses its own home dir instead of - // the Windows-side baseDir (which would be a /mnt/c path and share - // the SQLite file with the primary). + // Omit t3Home to keep the Linux backend from sharing the primary's Windows SQLite file. host: wslBindHost, desktopBootstrapToken: input.bootstrapToken, desktopBootstrapSecret: input.bootstrapSecret, - // PortSchema rejects 0, so when tailscale serve is disabled we still need a - // valid number in this slot. + // PortSchema rejects 0 even when Tailscale Serve is disabled. tailscaleServeEnabled: false, tailscaleServePort: 443, ...buildObservabilityFragment(input.observabilitySettings), @@ -665,9 +632,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl allowBuild: !environment.isPackaged, }); - // Every operation after preflight uses the same concrete distro. In - // default-tracking mode this closes the race where the system default - // changes between probing and spawning the backend. + // Pin the preflight distro so a default change cannot race backend startup. const runningDistro = preflight._tag === "Ready" ? preflight.runningDistro : null; const distroForConfig = runningDistro ?? input.distro; @@ -710,8 +675,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...forwardedEnv, ...(wslEnv !== undefined ? { WSLENV: wslEnv } : {}), }, - // env is already a complete process.env minus T3CODE_HOME; pass it - // verbatim instead of letting the spawner re-merge process.env on top. + // env already excludes T3CODE_HOME; merging process.env would restore it. extendEnv: false, bootstrap, bootstrapDelivery: "stdin" as const, @@ -720,8 +684,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...(runningDistro !== null ? { runningDistro } : {}), }; - // Forward the dev-server URL as an explicit CLI flag so the WSL backend's - // config resolution lands in dev/ instead of userdata/. + // Explicit --dev-url keeps WSL state in dev/ rather than userdata/. const devUrlArgs = Option.match(environment.devServerUrl, { onNone: () => [] as ReadonlyArray, onSome: (url) => ["--dev-url", url.href], @@ -741,10 +704,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl } satisfies DesktopBackendManager.DesktopBackendStartConfig; } - // The WSL server spawns commands its providers reference by name — `npm`/`npx` - // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those - // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on - // the process PATH, so `npm install -g ...` fails with NotFound. + // wsl.exe --exec omits the login-shell PATH needed to find provider CLIs and npm. const runtime = preflight.runtime; const launchPath = runtime.kind === "executable" @@ -775,7 +735,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl } satisfies DesktopBackendManager.DesktopBackendStartConfig; }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; @@ -785,12 +745,7 @@ export const make = Effect.gen(function* () { const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; const settings = yield* DesktopAppSettings.DesktopAppSettings; const crypto = yield* Crypto.Crypto; - // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. - // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can - // resolve concurrently; with a plain Ref both could observe None, generate - // distinct secrets, and one would overwrite the other — leaving the two - // backends deriving mismatched tokens and breaking the shared-token invariant - // the renderer relies on. + // Random generation yields; serialize it so concurrent backends cannot capture distinct bootstrap secrets. const secretRef = yield* SynchronizedRef.make(Option.none()); const getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) => Option.match(current, { @@ -861,18 +816,11 @@ export const make = Effect.gen(function* () { ); }); - // Dispatch happens at resolve time so toggling wsl-only between restarts - // is picked up on the next start cycle (the pool's primary instance is - // created once at layer init, but configResolve fires on each restart). + // Resolve on each restart so toggling wsl-only takes effect without recreating the pool. const describePrimary = Effect.gen(function* () { const persistedSettings = yield* settings.get; const wslRequested = persistedSettings.wslOnly && persistedSettings.wslBackendEnabled; - // Only honor wsl-only when WSL is actually usable. If the user - // persisted wsl-only but WSL has since become unavailable (wsl.exe - // removed, no distro), fall back to the Windows primary instead of - // looping forever on preflight failures: the Connections backend - // control is hidden while WSL is unavailable, so a stuck WSL primary - // would otherwise leave no in-app way back to Windows. + // When WSL is unavailable its controls are hidden; fall back to Windows to keep recovery possible. const useWsl = wslRequested && (yield* wslEnvironment.isAvailable); return { useWsl, wslRequested, distro: persistedSettings.wslDistro }; }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index b4057f4d0194b..007f7369365a2 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -37,8 +37,7 @@ const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5; const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); -// Same as the service launcher. A killed backend leaves its agents running, -// and a busy one needs a few seconds to stop every provider session. +// Match the service launcher's grace period so backends can stop provider sessions. const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; @@ -63,9 +62,7 @@ export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; export interface DesktopBackendStartConfig extends BackendProcessContext { readonly args: ReadonlyArray; readonly env: Record; - // When true the spawner merges the desktop process.env on top of `env`; - // when false `env` is passed verbatim. WSL mode opts out so a leaking - // T3CODE_HOME can't pin the WSL backend to /mnt/c/...\.t3. + /** False passes env verbatim, preventing the Windows T3CODE_HOME from leaking into WSL. */ readonly extendEnv: boolean; readonly bootstrap: DesktopBackendBootstrapValue; readonly bootstrapDelivery: DesktopBackendBootstrapDelivery; @@ -234,11 +231,7 @@ export interface BackendInstanceSpec { readonly id: BackendInstanceId; readonly label: Effect.Effect; readonly configResolve: Effect.Effect; - // Receives the *resolved* httpBaseUrl of the run that just became - // ready. The window service uses this to decide what URL to load - // (the WSL backend reports its distro IP, the Windows backend reports - // 127.0.0.1). Splitting this off from configResolve avoids races - // between "fired onReady" and "currentConfig already advanced". + /** Receives the ready run's resolved URL; reading current config could race a subsequent restart. */ readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; readonly onShutdown?: () => Effect.Effect; readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect; @@ -431,16 +424,12 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( cwd: options.cwd, env: options.env, extendEnv: options.extendEnv, - // In Electron main, process.execPath points to the Electron binary. - // Run the child in Node mode so this backend process does not become a GUI app instance. stdin: options.bootstrapDelivery === "stdin" ? bootstrapStream : "ignore", stdout: options.captureOutput ? "pipe" : "inherit", stderr: options.captureOutput ? "pipe" : "inherit", killSignal: "SIGTERM", forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, - // wsl.exe drops additional file descriptors when forwarding to the Linux - // side, so the WSL spawn path delivers the bootstrap envelope via stdin - // (`--bootstrap-fd 0`) instead. + // wsl.exe drops extra file descriptors; deliver its bootstrap via stdin. ...(options.bootstrapDelivery === "fd3" ? { additionalFds } : {}), }); @@ -542,12 +531,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ).pipe(Effect.forkScoped), ); } - // Probe readiness in a loop while the backend process is still alive instead - // of giving up after the first budget. A slow cold boot (the WSL bundle - // loading across /mnt/c, or a first launch right after an update) can exceed - // the initial readiness budget while the backend is about to come up moments - // later; a one-shot probe left the app stuck on "Connecting to WSL…" forever - // even though the backend kept running and became healthy. + // Cold WSL boots across /mnt/c can exceed one probe budget; retry while the backend is alive. const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() => waitForHttpReady({ executablePath: options.executablePath, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index a8576a2b9e99e..0583e8df21705 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -96,8 +96,6 @@ describe("DesktopBackendPool", () => { const instance = yield* pool.register({ id, label: Effect.succeed("WSL (Ubuntu)"), - // A transient preflight failure leaves a restart fiber scheduled - // without needing a real child process. configResolve: Effect.succeed({ executablePath: "/electron", args: [], diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 135df79c63ada..7b2f685190761 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -106,16 +106,11 @@ export const layer = Layer.effect( const electronDialog = yield* ElectronDialog.ElectronDialog; const electronApp = yield* ElectronApp.ElectronApp; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; - // Without this, instance scopes are orphaned: they only close via explicit unregister() calls, - // so on app shutdown the WSL backend child process gets hard-killed by the OS instead of - // receiving the graceful SIGTERM + grace period the instance's stop finalizer would otherwise - // run. + // Parent instance scopes to the pool so shutdown runs stop finalizers before the OS kills backends. const layerScope = yield* Scope.Scope; const factoryContext = yield* Effect.context(); - // Fatal configuration failures persist the Windows fallback. Bounded - // transport failures use an in-memory fallback for this launch so the app - // opens without overwriting the user's WSL preference. + // Persist fatal Windows fallbacks; transient transport failures must preserve the user's WSL preference. const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")( function* (failure: DesktopBackendManager.PreflightFailure) { const { reason, fatal } = failure; @@ -139,9 +134,8 @@ export const layer = Layer.effect( "WSL backend couldn't start", `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`, ); - // Clearing wslBackendEnabled alone would leave a stale wslOnly:true that silently re-traps - // the user in wsl-only mode the next time they enable WSL. If the persisted write fails, - // keep this process recoverable by applying the fallback to in-memory settings. + // Clear wslOnly too so enabling WSL cannot restore a failed mode. + // Keep the in-memory fallback if persistence fails. yield* appSettings.applyWslWindowsFallback.pipe( Effect.catch((error) => logBackendPoolWarning( @@ -158,14 +152,10 @@ export const layer = Layer.effect( const primary = yield* DesktopBackendManager.makeBackendInstance({ id: DesktopBackendManager.PRIMARY_INSTANCE_ID, - // Keep this lazy. The pool layer is initialized before startup loads - // persisted desktop settings, so resolving the primary label here would - // permanently capture DEFAULT_DESKTOP_SETTINGS and mislabel WSL-only - // primaries as Windows. + // Resolve lazily: startup loads persisted settings after pool initialization. label: configuration.resolvePrimaryLabel, configResolve: configuration.resolvePrimary, - // Window creation errors propagating out of handleBackendReady must not block the readiness - // callback (that would prevent restartAttempt from being reset), so we absorb them here. + // Absorb window errors so readiness can reset restartAttempt. onReady: (httpBaseUrl) => desktopWindow.handleBackendReady(httpBaseUrl).pipe( Effect.catch((error) => @@ -219,10 +209,7 @@ export const layer = Layer.effect( ] as const); } return Effect.gen(function* () { - // Provide the child scope after the captured factory context. That - // context also holds the pool layer's Scope, so providing it last - // would let it shadow the child scope and leave the instance's - // stop finalizer and restart fibers running after unregister. + // Provide the child Scope last so the captured pool Scope cannot shadow it and orphan stop/restart finalizers. const instanceScope = yield* Scope.fork(layerScope, "sequential"); const instance = yield* DesktopBackendManager.makeBackendInstance(spec).pipe( Scope.provide(instanceScope), diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 4d95dd075cf3e..6481bfd64c897 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -63,7 +63,7 @@ export class DesktopLocalEnvironmentAuth extends Context.Service< } >()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; const httpClient = yield* HttpClient.HttpClient; @@ -85,9 +85,7 @@ export const make = Effect.gen(function* () { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } const config = configOption.value; - // A backend launched with the desktop secret accepts the current - // window's token, not the one frozen into its launch config; this - // exchange can run long after launch (e.g. after a suspend). + // Secret-authenticated backends accept the current window token; the launch token may be stale. const secret = config.bootstrap.desktopBootstrapSecret; const credential = secret === undefined diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f9a69692a2504..8d178c605c4a6 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -71,15 +71,7 @@ const isUsableLanIpv4Address = (address: string): boolean => !address.startsWith("169.254.") && !isTailscaleIpv4Address(address); -/** - * Bridges, tunnels and VM adapters whose private address other devices on the - * LAN usually cannot reach: macOS's Thunderbolt Bridge (`bridge0`, which can - * keep a manual address while nothing is plugged in) and Internet Sharing - * bridges, VPN tunnels, and container or VM networks. Their addresses are only - * advertised when no other interface has one. Host bridges such as `br0`, - * Proxmox's `vmbr0` and Hyper-V's `vEthernet` external switches often carry - * the LAN address, so they are not listed. - */ +// Private bridges and VM adapters are fallbacks; host bridges can carry the real LAN address. const HOST_ONLY_INTERFACE = /^(bridge\d|utun|ipsec|ppp|tun|tap|wg|docker|br-|veth|virbr|vmnet|vboxnet|VirtualBox|VMware)/; @@ -441,7 +433,7 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo previous.bindHost !== next.bindHost || previous.localHttpUrl !== next.localHttpUrl; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; @@ -449,9 +441,7 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const stateRef = yield* Ref.make(initialRuntimeState()); - // Each change reads the runtime state, persists settings, then writes the - // state back. Run them one at a time so a change never writes over another - // with values it read before that change persisted. + // Serialize read-persist-write changes so stale runtime state cannot overwrite another update. const changePermit = yield* Semaphore.make(1); // Key status by interface addresses so network changes bypass stale DNS and @@ -573,9 +563,7 @@ export const make = Effect.gen(function* () { customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, }); - // Don't spawn the Tailscale CLI when the user hasn't opted into any - // network exposure. The spawn itself triggers a macOS "Other apps" - // TCC prompt on Mac App Store Tailscale builds. + // Spawning Mac App Store Tailscale can prompt for TCC access even when exposure is disabled. if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) { return coreEndpoints; } diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 044625a32b445..4b75afccdb5bc 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -33,8 +33,7 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { for (const [interfaceName, interfaceAddresses] of Object.entries(input.networkInterfaces)) { if (!interfaceAddresses) continue; - // CGNAT space is shared with other VPNs. Native adapter identity also - // works when the CLI is missing or its cached status predates a connection. + // CGNAT is shared with other VPNs; adapter identity works without current CLI status. const isTailscaleInterface = /^tailscale(?:\d+|ipv[46])?$/iu.test(interfaceName) || interfaceAddresses.some( diff --git a/apps/desktop/src/boot.ts b/apps/desktop/src/boot.ts index a8abdad9bbc60..f2524f246c664 100644 --- a/apps/desktop/src/boot.ts +++ b/apps/desktop/src/boot.ts @@ -1,4 +1,2 @@ -// Enables the compile cache before the main bundle loads, so the cache also -// covers main.cjs itself. require("./compileCache.cjs"); require("./main.cjs"); diff --git a/apps/desktop/src/coil/updateDelivery/UpdateDelivery.ts b/apps/desktop/src/coil/updateDelivery/UpdateDelivery.ts index d2216b060ec00..24b4a206e86fb 100644 --- a/apps/desktop/src/coil/updateDelivery/UpdateDelivery.ts +++ b/apps/desktop/src/coil/updateDelivery/UpdateDelivery.ts @@ -1,11 +1,8 @@ /** * The fork's update-delivery service: relay in, staged bundle out, one restart on the click. - * - * The three-tier ladder the design specifies lives here, and the ordering of the tiers is the - * point. Push tells you within seconds; the watchdog notices a connection that is open but dead; - * the floor poll is the tier that still works when both of the others have quietly stopped - * working. Every tier ends in the same place — read `/latest`, decide, stage — so there is no path - * that can drift into believing something the others do not. + * Three tiers: push, a watchdog for connections that are open but dead, and a floor poll for when + * both quietly stop. Every tier ends the same way (read `/latest`, decide, stage), so no tier can + * drift into believing something the others do not. */ import * as Clock from "effect/Clock"; @@ -84,12 +81,8 @@ const externalState = (internal: Internal): CoilUpdateState => ({ }); /** - * Everything the service needs after construction. - * - * Captured once and baked into the returned effects, so `CoilUpdateDelivery` presents a - * requirement-free interface. Without this the renderer-facing IPC methods would each have to - * carry `HttpClient | ChildProcessSpawner | ElectronApp | …` in their context, and every caller - * would have to know how this feature installs software. + * Everything the service needs after construction, captured once so `CoilUpdateDelivery` stays + * requirement-free and IPC callers need not know how this feature installs software. */ type DeliveryServices = | ChildProcessSpawner.ChildProcessSpawner @@ -137,12 +130,9 @@ export const make = Effect.gen(function* () { : undefined; /** - * Did the install the last process started actually happen? - * - * Compared here rather than trusted, because the install runs after this process is gone: on + * Did the install the last process started actually happen? Compared rather than trusted: on * Windows an NSIS installer that fails partway leaves the previous build in place and nothing - * anywhere says so. `installCommands.ts` describes exactly this check; it had never been built, - * so every install was assumed to have worked. + * says so. */ const verificationStatus: CoilUpdateStatus | undefined = pendingInstall === undefined @@ -196,12 +186,9 @@ export const make = Effect.gen(function* () { }); /** - * The download and everything after it, run on its own fiber. - * - * Forked rather than awaited by the caller, because the caller is usually inside the SSE read - * loop. Blocking there for a 470 MB download would stop the loop consuming heartbeats, and the - * watchdog — which keys on bytes reaching the parser — would declare a perfectly healthy - * connection stalled fifty seconds into the download it is itself performing. + * The download and everything after it, forked because the caller is usually the SSE read loop. + * Blocking it for a 470 MB download would stop heartbeats reaching the parser, and the watchdog + * would declare a healthy connection stalled mid-download. */ const stageAndAnnounce = Effect.fn("coil.updateDelivery.stageAndAnnounce")(function* ( manifest: UpdateManifest, @@ -262,14 +249,9 @@ export const make = Effect.gen(function* () { }); /** - * Everything funnels through here, from every tier. - * - * Single-flight, and genuinely so: two tiers announcing the SAME build within a second of each - * other is the normal case, not an edge case — the push arrives and the reconcile that follows a - * reconnect finds the same manifest. That one returns early. A DIFFERENT, newer build interrupts - * the download in flight rather than starting a second one beside it: two concurrent 470 MB - * downloads would be worse than either, and finishing the older one would leave the user a - * `ready` toast for a build that is already behind. + * Single-flight entry point for every tier. The same build announced twice (push, then the + * reconnect reconcile) is the normal case and returns early. A newer build interrupts the + * download in flight rather than running a second 470 MB download or finishing a stale one. */ const considerManifest = Effect.fn("coil.updateDelivery.consider")(function* ( manifest: UpdateManifest, @@ -532,14 +514,9 @@ const spawnAwaited = Effect.fn("coil.updateDelivery.spawnAwaited")(function* ( }, Effect.scoped); /** - * Spawned into a scope that is never closed, on purpose. - * - * The NSIS installer has to outlive this process: it needs the app's own files, so the app quits - * and the installer relaunches it. `spawner.spawn` normally ties the child's life to a scope, and - * closing that scope would tear down the very installer we just started. So the scope is made by - * hand and left open — this process is about to call `app.exit`, at which point a `detached` child - * is reparented by the OS and survives. Awaiting the child instead would deadlock: it is waiting - * for us to quit. + * Spawned into a scope that is never closed, on purpose: the NSIS installer must outlive this + * process, and closing the scope would kill it. The app then calls `app.exit` and the `detached` + * child is reparented by the OS. Awaiting the child would deadlock, since it waits for us to quit. */ const spawnDetached = Effect.fn("coil.updateDelivery.spawnDetached")(function* ( bin: string, diff --git a/apps/desktop/src/coil/updateDelivery/config.ts b/apps/desktop/src/coil/updateDelivery/config.ts index 30ceb54d62882..9a9b321305d61 100644 --- a/apps/desktop/src/coil/updateDelivery/config.ts +++ b/apps/desktop/src/coil/updateDelivery/config.ts @@ -1,11 +1,7 @@ /** * Where the running app looks for update announcements, and what it believes it already is. - * - * Both answers have to come from somewhere that survives packaging. Neither can come from - * `process.env`: a `.app` launched from Finder inherits `launchd`'s environment, not a shell's, - * so an env var set in a terminal is simply absent at runtime. That is the same trap that made - * `T3CODE_DISABLE_AUTO_UPDATE` unusable as a delivery mechanism — see the design doc's provenance - * table. Env vars here are a development convenience only, and the packaged path never needs one. + * Neither can come from `process.env`: a `.app` launched from Finder inherits `launchd`'s + * environment, not a shell's. Env vars here are a development convenience only. */ import * as Option from "effect/Option"; @@ -14,19 +10,10 @@ import * as Schema from "effect/Schema"; import { SHORT_SHA_LENGTH } from "./manifest.ts"; /** - * The fork's relay, deployed from `infra/coil-update-relay/`. - * - * Hardcoded rather than injected at build time. It is a public URL that serves public release - * metadata, it is stable across every build, and baking it in through the packaged `package.json` - * would mean editing `scripts/build-desktop-artifact.ts` — an upstream file this fork does not - * touch, and a seam row that buys nothing. - * - * Being hardcoded is also why the #71 rename could not simply move it. Every build shipped before - * this line changed has the OLD hostname compiled in, cannot be reconfigured (the override below - * is never set on a normal install), and has no other channel through which to be told. So the old - * hostname was not renamed — it stays deployed, permanently, as a pass-through to this one. See - * `infra/coil-update-relay/src/legacyWorker.ts`. Changing this constant is safe precisely because - * that file exists; changing it without one would be a silent, unrecoverable strand. + * The fork's relay, deployed from `infra/coil-update-relay/`. Hardcoded, so every shipped build + * keeps its hostname forever. A retired hostname must stay deployed as a pass-through (see + * `infra/coil-update-relay/src/legacyWorker.ts`); changing this without one silently strands + * every older install. */ export const DEFAULT_RELAY_URL = "https://coil-update-relay.businesses.workers.dev"; @@ -61,31 +48,17 @@ export function resolveRelayUrl( } /** - * Marker recording that this install has applied at least one update through this path. - * - * A file rather than a field in memory, because the fact it records survives exactly one event: - * the restart it is about. An in-memory flag set just before `app.exit` is gone by the time the - * new build asks the question, so the first-update App Management note — meant to be shown once — - * would be shown on every single update instead, which is how a warning becomes wallpaper. - * - * A file rather than `DesktopClientSettings`, because that is an upstream-owned persisted schema - * and this is one bit that only the fork cares about. + * Marker recording that this install has applied at least one update through this path. A file, + * because an in-memory flag set before `app.exit` is gone when the new build asks (so the + * once-only App Management note would show on every update), and not `DesktopClientSettings`, + * because that is an upstream-owned schema. */ export const UPDATED_MARKER_NAME = ".t3x-has-updated"; /** - * What the marker records about the install that is about to happen. - * - * The marker used to be written empty, which made it answer only "has this app ever updated?". - * `installCommands.ts` documents a second job for it — "the target is recorded before quitting and - * compared against `t3codeCommitHash` when the app comes back" — and that half was never built, so - * a silently failed install was indistinguishable from a successful one. On Windows the app is - * gone for minutes during the install, so "did that work?" is not a question the user can answer - * by looking. - * - * Written before quitting, read once on the next boot, then cleared. Only the target is stored: - * the running build reports its own identity, and comparing two values written at different times - * by different processes is the entire point. + * The target of the install about to happen: written before quitting, read once on the next boot, + * then cleared. The new build reports its own identity, so comparing the two catches a silently + * failed install (on Windows the app is gone for minutes, so the user cannot tell by looking). */ export const PendingInstall = Schema.Struct({ shortSha: Schema.String, @@ -116,23 +89,10 @@ export function encodePendingInstall(target: PendingInstall): string { } /** - * The build counter of the running app, read out of its own version string. - * - * `coil-release.yml` builds `-coil.` and `build-update-manifest.mjs` asserts that - * the manifest's `buildNumber` is that same number, so parsing it back out here is reading the - * value the release wrote — not re-deriving it. An upstream build (`0.0.31`, no suffix) yields - * `undefined`, which `decideUpdateAction` treats as "no floor", so the first fork build installs. - * - * Both suffixes, because this function is asked about two different version strings. The obvious - * one is the running app's own, and for a `-t3x.` build that is answered by the `-t3x.`-only code - * it shipped with — this branch never runs there. The one that matters is the version the app is - * asked to *compare against* around the cutover: a build carrying this code can still be offered, - * or be preceded by, a `-t3x.` release. Returning `undefined` for those would erase the floor - * entirely and make a genuinely older build look installable. - * - * Note the numbers stay comparable across the rename by construction, not by luck: the counter is - * `github.run_number + 100` precisely so `-coil.101` outranks `-t3x.26`. Nothing here compares the - * suffixes themselves, which is fortunate — `coil` sorts before `t3x`. + * The build counter from a `-coil.` or `-t3x.` version suffix. Both suffixes, because around + * the rename a build can be compared against a `-t3x.` release and `undefined` would erase the + * floor. Counters stay ordered because `coil-release.yml` uses `github.run_number + 100`; never + * compare the suffixes themselves (`coil` sorts before `t3x`). */ export function parseBuildNumber(version: string): number | undefined { const match = /-(?:coil|t3x)\.(\d+)$/u.exec(version.trim()); @@ -143,11 +103,8 @@ export function parseBuildNumber(version: string): number | undefined { /** * The commit of the running app, from the `t3codeCommitHash` that - * `scripts/build-desktop-artifact.ts` writes into the packaged `package.json`. - * - * Read here rather than through `DesktopAppIdentity`, which computes the same value but does not - * expose it on its service interface. Adding an accessor there would put an upstream file that the - * fork does not currently edit into the seam ledger, to reach a field this can parse itself. + * `scripts/build-desktop-artifact.ts` writes into the packaged `package.json`. Parsed here because + * `DesktopAppIdentity` does not expose it, and adding an accessor would mean editing upstream. */ export function readEmbeddedCommitHash(packageJsonText: string): string | undefined { let parsed: unknown; diff --git a/apps/desktop/src/coil/updateDelivery/connectionHealth.ts b/apps/desktop/src/coil/updateDelivery/connectionHealth.ts index 7ff253558bef7..a51d0b44506b6 100644 --- a/apps/desktop/src/coil/updateDelivery/connectionHealth.ts +++ b/apps/desktop/src/coil/updateDelivery/connectionHealth.ts @@ -1,16 +1,8 @@ /** - * Deciding when a push connection that *looks* alive is actually dead. - * - * This is the most important reliability rule in update delivery, and it is deliberately pure so - * it can be tested without a socket. - * - * The motivating failure is not "the connection errored" — that case is easy and self-announcing. - * It is the connection that survives laptop sleep, or a NAT rebind, or a Tailscale route change, - * and comes back with the socket open, no error event, and no bytes ever again. A - * reconnect-on-error ladder never fires for it. The app simply stops hearing about updates, for an - * unbounded length of time, while reporting itself connected. - * - * This fork has already lived that failure once: issue #41, 103 minutes dark, silent throughout. + * Deciding when a push connection that *looks* alive is actually dead, kept pure so it is testable + * without a socket. The failure that matters is the socket that survives sleep, a NAT rebind or a + * route change with no error and no bytes ever again: reconnect-on-error never fires, and the app + * stops hearing about updates indefinitely while reporting itself connected. */ /** Server sends `: ping` at this interval. Must match `HEARTBEAT_INTERVAL_MS` in the relay. */ @@ -66,21 +58,13 @@ export function assessConnection(args: { * lockstep, turning a blip into a thundering herd against a single Durable Object. */ export function reconnectDelayMs(attempt: number, random: () => number): number { - const exponential = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt), RECONNECT_MAX_DELAY_MS); + const exponential = Math.min( + RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt), + RECONNECT_MAX_DELAY_MS, + ); return Math.round(exponential * random()); } -/** - * Every one of these re-reads `/latest` rather than trusting the stream. - * - * `power-resume` and `network-online` earn their place for the same reason the watchdog does: - * after a sleep the stream may already be a corpse nobody has noticed, and waiting out the - * watchdog to discover that is time spent knowingly stale. Being told by the OS that the world - * changed beats inferring it from silence. - * - * There is no variant that trusts the stream instead, which is why this is a list rather than a - * predicate — if a trigger is worth waking for, it is worth reconciling for. - */ /** * A stream that lasted this long was working, whatever ended it. * @@ -93,20 +77,21 @@ export const HEALTHY_STREAM_MS = 60_000; export const MAX_BACKOFF_ATTEMPT = 16; /** - * The next backoff attempt, decided by how long the last connection lasted — **not** by why it - * ended. - * - * `stream-closed` is both the healthy case and the pathological one. The relay caps every - * connection at fifteen minutes, so a clean close is what a working subscriber sees four times an - * hour; a relay that accepts a connection and drops it immediately reports exactly the same thing. - * Resetting on the reason treats those as equivalent and turns the second into a one-second hot - * loop against a single Durable Object — from every installed app at once. + * The next backoff attempt, decided by how long the last connection lasted, **not** by why it + * ended. The relay closes every connection at fifteen minutes, so `stream-closed` is also what an + * instant drop reports; resetting on the reason would hot-loop every installed app against a + * single Durable Object. */ export function nextBackoffAttempt(attempt: number, lastedMs: number): number { if (lastedMs >= HEALTHY_STREAM_MS) return 0; return Math.min(attempt + 1, MAX_BACKOFF_ATTEMPT); } +/** + * Every trigger except `push` re-reads `/latest` rather than trusting the stream. `power-resume` + * and `network-online` exist because after a sleep the stream may already be dead unnoticed, and + * being told by the OS beats waiting out the watchdog. + */ export type ReconciliationTrigger = /** * A frame arrived on the open stream. The only trigger that does NOT re-read `/latest` — the diff --git a/apps/desktop/src/coil/updateDelivery/decision.test.ts b/apps/desktop/src/coil/updateDelivery/decision.test.ts index 75e2aa145a2ea..d339895be473c 100644 --- a/apps/desktop/src/coil/updateDelivery/decision.test.ts +++ b/apps/desktop/src/coil/updateDelivery/decision.test.ts @@ -53,11 +53,9 @@ describe("decideUpdateAction", () => { }); it("refuses to act when this build carries no release counter", () => { - // Changed deliberately on 2026-08-07; it previously acted. `buildNumber` comes from the app's - // own version, and only `coil-release.yml` writes `-coil.` — so `undefined` does not mean - // "first run", it means "this app was built locally". Treating that as "no floor" let a - // locally built app install ANY announced release over itself, including one cut from an - // OLDER commit than the one it was built from: a silent downgrade that reports success. + // `buildNumber` is undefined only for locally built apps (only `coil-release.yml` writes + // `-coil.`), so it must not mean "no floor": that let a local build silently install a + // release cut from an older commit over itself. const decision = decideUpdateAction(MANIFEST, installed({ buildNumber: undefined })); expect(decision).toEqual({ kind: "skip", reason: "unknown-own-build-number" }); }); @@ -89,7 +87,10 @@ describe("decideUpdateAction", () => { it("skips an older build rather than downgrading", () => { // The out-of-order case: a slow Windows leg from run 19 announced after run 25 has landed. // "Different from mine" would accept it and quietly move the user backwards. - const decision = decideUpdateAction({ ...MANIFEST, buildNumber: 19 }, installed({ buildNumber: 25 })); + const decision = decideUpdateAction( + { ...MANIFEST, buildNumber: 19 }, + installed({ buildNumber: 25 }), + ); expect(decision).toEqual({ kind: "skip", reason: "not-newer" }); }); diff --git a/apps/desktop/src/coil/updateDelivery/decision.ts b/apps/desktop/src/coil/updateDelivery/decision.ts index e820ff6ec4af6..c6748a130dd40 100644 --- a/apps/desktop/src/coil/updateDelivery/decision.ts +++ b/apps/desktop/src/coil/updateDelivery/decision.ts @@ -97,16 +97,10 @@ export function decideUpdateAction( return { kind: "skip", reason: "already-running-this-build" }; } - // No counter means no way to order, and "cannot order" must not silently mean "accept". - // - // Only builds from `coil-release.yml` carry `-coil.`; anything built locally (a hand-run - // `build:desktop`) is a bare `0.0.31`. Treating a missing - // counter as "no floor" let such a build install ANY announced release over itself — including - // one cut from an older commit than the one it was built from. That downgrade reports success, - // looks identical to a real update, and is only undone by the next release. - // - // Refusing instead costs a locally built app its automatic updates until a released build is - // installed once. That is a visible, explainable state; a silent downgrade is not. + // No counter means no way to order, and "cannot order" must not silently mean "accept". Only + // `coil-release.yml` builds carry `-coil.`; treating a local build as "no floor" let it + // install a release cut from an older commit, a silent downgrade that reports success. Refusing + // costs a local build its automatic updates until a released build is installed once. if (installed.buildNumber === undefined) { return { kind: "skip", reason: "unknown-own-build-number" }; } diff --git a/apps/desktop/src/coil/updateDelivery/installCommands.ts b/apps/desktop/src/coil/updateDelivery/installCommands.ts index fa8f61037a52a..e3b481f7c5cc1 100644 --- a/apps/desktop/src/coil/updateDelivery/installCommands.ts +++ b/apps/desktop/src/coil/updateDelivery/installCommands.ts @@ -14,19 +14,10 @@ export interface Command { } /** - * macOS staging: mount, copy beside the target, de-quarantine, unmount. - * - * All of it runs in the BACKGROUND, before the user is offered a restart. If any of this happened - * after the click, "the click is an instant restart" would be false — a 470 MB copy plus a - * recursive xattr walk over tens of thousands of files is tens of seconds with the UI stuck. - * - * Two traps encoded here: - * - * - The mount point is under `$TMPDIR`, not `/Volumes`. Mounting under `/Volumes` from inside the - * app raises the macOS "access files on a removable volume" prompt, which a background step has - * no way to answer. - * - The copy destination must NOT already exist. BSD `cp -R src.app dst.app` copies *into* - * `dst.app` when it exists, nesting the new build inside the old one and exiting 0. + * macOS staging (mount, copy beside the target, de-quarantine, unmount), all run in the + * background before a restart is offered so the click stays instant. Mount under `$TMPDIR`, not + * `/Volumes` (that raises a removable-volume prompt nobody can answer). The copy destination must + * not exist: BSD `cp -R src.app dst.app` nests into an existing `dst.app` and still exits 0. */ export function macStagingCommands(args: { readonly dmgPath: string; @@ -45,12 +36,9 @@ export function macStagingCommands(args: { } /** - * Split out because `sourceAppPath` is not knowable until the dmg is mounted. - * - * The `.app` name inside the dmg must be READ, never computed — `resolveMacInstallTarget` refuses - * an install when the mounted name differs from the installed one, and that check is worthless if - * both sides come from the same guess. So the caller attaches, lists the mount point, and only - * then can build the copy step. + * Split out because `sourceAppPath` is not knowable until the dmg is mounted. The `.app` name must + * be read from the mount, never computed: `resolveMacInstallTarget` refuses a name mismatch, and + * that check is worthless if both sides come from the same guess. */ export function macAttachCommands(args: { readonly dmgPath: string; @@ -88,12 +76,8 @@ export function macDetachCommand(mountPoint: string): Command { } /** - * The swap, run on the click. Everything expensive already happened. - * - * Delete-then-rename, in that order and no other. Renaming onto an existing bundle does not - * replace it, and copying before the delete is what nests one app inside another. The window where - * nothing is installed is a single rename long — and the alternative, deleting after a failed - * copy, leaves the user with no app at all. + * The swap, run on the click. Delete-then-rename, in that order only: renaming onto an existing + * bundle does not replace it, and copying before the delete nests one app inside another. */ export function macSwapCommands(args: { readonly targetAppPath: string; @@ -106,30 +90,19 @@ export function macSwapCommands(args: { } /** - * Windows install. A completely different shape, and the design has to say so. - * - * The NSIS installer needs the app's own files, so the app cannot wait for it to finish — a silent - * install that hits a running app is the documented cause of installers that hang forever with no - * UI. The only sequence that works is: spawn detached, quit immediately, let the installer relaunch. - * - * That means `DesktopLifecycle.relaunch` is the wrong primitive here. macOS restarts itself; - * Windows is restarted *by* the installer. - * - * `/S` alone installs silently and does not start the app afterwards, which would leave the user - * staring at a closed app wondering what happened. `--force-run` is what electron-updater's own - * NsisUpdater passes, alongside `--updated`. + * Windows install: spawn detached, quit immediately, let the installer relaunch. NSIS needs the + * app's own files and a silent install that hits a running app hangs forever, so + * `DesktopLifecycle.relaunch` is the wrong primitive. `/S` alone does not restart the app; + * `--force-run` (with `--updated`) is what electron-updater's NsisUpdater passes. */ export function windowsInstallCommand(installerPath: string): Command { return { bin: installerPath, args: ["/S", "--force-run", "--updated"], detached: true }; } /** - * Windows verification happens on the NEXT startup, not in-process. - * - * By the time the installer has done anything, this process is gone — there is nobody left to - * check. So the target is recorded before quitting and compared against `t3codeCommitHash` when - * the app comes back. Without this, a silently failed install is indistinguishable from a - * successful one, which is exactly issue #47's complaint. + * Windows verification happens on the next startup: by the time the installer runs, this process + * is gone. The target is recorded before quitting and compared against `t3codeCommitHash` on + * return, otherwise a silently failed install looks like a successful one. */ export function verifyPostInstall(args: { readonly expectedShortSha: string; diff --git a/apps/desktop/src/coil/updateDelivery/installTarget.ts b/apps/desktop/src/coil/updateDelivery/installTarget.ts index b9c2f537ed9ff..0f4b22ad55211 100644 --- a/apps/desktop/src/coil/updateDelivery/installTarget.ts +++ b/apps/desktop/src/coil/updateDelivery/installTarget.ts @@ -1,13 +1,7 @@ /** - * Resolving *what* to replace, and refusing when replacing it would silently do the wrong thing. - * - * Every rejection here maps to a failure that otherwise reports success. That is the whole reason - * this is a separate, pure, heavily-tested module rather than three lines inside the installer. - * - * No `node:path` import: the repo routes filesystem paths through Effect's `Path` service, and - * pulling that in would make this module effectful for what is entirely string arithmetic. These - * paths are always POSIX — the bundle-walking logic below is macOS-only by construction — so two - * local helpers are both sufficient and cheaper than a service dependency. + * Resolving *what* to replace, and refusing when replacing it would silently do the wrong thing: + * every rejection maps to a failure that otherwise reports success. No `node:path`: these paths + * are always POSIX (macOS-only), and Effect's `Path` service would make string math effectful. */ function posixDirname(path: string): string { @@ -42,11 +36,9 @@ export function describeRefusal(refusal: InstallRefusal): string { ); case "not-a-bundle": return `Could not find the enclosing .app for ${refusal.execPath}, so there is nothing safe to replace.`; - // This refusal fires exactly once per user, on the first build after the app's name changes - // (upstream's Alpha → Beta, or the fork dropping "T3 Coil" for "T3 Code"). Worded as a next - // step rather than a fault, because a mechanism-only message leaves the reader stuck. The check - // itself is unchanged and still the only thing standing between a rename and two installed - // apps, so the message tells them the one safe way through instead of hiding the refusal. + // Fires once per user, on the first build after the app is renamed. Worded as the one safe next + // step rather than a fault, because the check is all that stops a rename leaving two installed + // apps. case "name-mismatch": return ( `The downloaded build is named "${refusal.incoming}" but the running app is ` + @@ -60,15 +52,9 @@ export function describeRefusal(refusal: InstallRefusal): string { /** * macOS runs a quarantined app from a randomised read-only mount under - * `/private/var/folders/.../AppTranslocation//d/...` rather than from where the user put it. - * - * This is the sharpest trap in the whole feature. `DesktopLifecycle.relaunch` re-execs - * `process.execPath`; under translocation that points into the read-only snapshot. An installer - * that wrote to /Applications anyway would then relaunch the *old* translocated bundle, and the - * post-install check would pass because it verified the wrong process. Silent success, wrong - * result — the precise failure shape of issue #47. - * - * The first launch after a manual dmg drag is exactly this case, so it is common, not exotic. + * `/private/var/folders/.../AppTranslocation//d/...`. Relaunching `process.execPath` there + * brings back the old bundle while post-install checks pass against the wrong process. Common: + * the first launch after a manual dmg drag is exactly this case. */ export function isTranslocatedPath(execPath: string): boolean { return execPath.includes("/AppTranslocation/"); @@ -88,13 +74,9 @@ export function findEnclosingAppBundle(execPath: string): string | undefined { } /** - * Resolve the bundle to replace from the running executable. - * - * Deliberately never hardcodes `/Applications`. The existing shell installer does, and its own - * dry-run warns about the consequence: a real install "would create a NEW app and leave whatever - * you currently run untouched". Moving that logic in-process removes the human who reads the - * warning, so the path has to come from the process itself. Real cases this covers: - * `~/Applications`, a second copy, a root-owned `/Applications` under MDM, a read-only volume. + * Resolve the bundle to replace from the running executable, never a hardcoded `/Applications`: + * that would install a NEW app and leave the running one untouched for `~/Applications`, a second + * copy, an MDM root-owned `/Applications`, or a read-only volume. */ export function resolveMacInstallTarget(args: { readonly execPath: string; diff --git a/apps/desktop/src/coil/updateDelivery/manifest.ts b/apps/desktop/src/coil/updateDelivery/manifest.ts index 8ee1d6045829d..c68922f9c6122 100644 --- a/apps/desktop/src/coil/updateDelivery/manifest.ts +++ b/apps/desktop/src/coil/updateDelivery/manifest.ts @@ -2,11 +2,8 @@ import * as Schema from "effect/Schema"; /** * The update manifest, as published by `.github/workflows/coil-release.yml` and rebroadcast - * verbatim by the relay. - * - * The relay does not understand this shape — it parses `buildNumber` for ordering and treats - * everything else as opaque. That means this file is the *only* definition of the contract, and - * adding a field here never requires a relay deploy. + * verbatim by the relay. The relay only parses `buildNumber`, so this file is the only definition + * of the contract and adding a field never requires a relay deploy. */ /** Matches `COMMIT_HASH_DISPLAY_LENGTH` in `DesktopAppIdentity.ts`. */ @@ -30,13 +27,9 @@ export type UpdateAsset = typeof UpdateAsset.Type; export const UpdateManifest = Schema.Struct({ /** - * The comparison key, and the only one. - * - * 12 characters because that is what the app can read about itself: the build script writes - * `t3codeCommitHash` from `git rev-parse --short=12`, and `DesktopAppIdentity` truncates to - * `COMMIT_HASH_DISPLAY_LENGTH`. A 40-char key here would never compare equal to anything the - * app knows, so the app would offer the same update forever — including immediately after - * installing it. + * The only comparison key. 12 characters because that is what the app knows about itself + * (`t3codeCommitHash` is `git rev-parse --short=12`); a longer key would never compare equal, + * so the same update would be offered forever. */ shortSha: ShortSha, /** @@ -53,12 +46,9 @@ export const UpdateManifest = Schema.Struct({ releaseTag: Schema.String, builtAt: Schema.String, /** - * Commit subjects in this build, newest first. - * - * `optionalKey`, and it must stay that way: a client running this build can be offered a - * manifest published *before* the field existed, and a required field would fail the decode — - * which this pipeline treats as "no update", silently withholding a real one. The same reason - * `runUrl` is optional. + * Commit subjects in this build, newest first. Must stay `optionalKey` (as must `runUrl`): + * manifests published before the field existed would otherwise fail to decode, which this + * pipeline treats as "no update", silently withholding a real one. */ changes: Schema.optionalKey(Schema.Array(Schema.String)), /** The workflow run that produced this build. Built from `run_id`, never `run_number`. */ diff --git a/apps/desktop/src/coil/updateDelivery/relayClient.ts b/apps/desktop/src/coil/updateDelivery/relayClient.ts index 679f7417294ad..ec727ad5c418a 100644 --- a/apps/desktop/src/coil/updateDelivery/relayClient.ts +++ b/apps/desktop/src/coil/updateDelivery/relayClient.ts @@ -1,12 +1,7 @@ /** - * Talking to the relay: one poll, and one long-lived stream. - * - * The relay serves the manifest verbatim — `t3x-release.yml` POSTs `t3x-latest.json` to `/notify`, - * `/latest` re-serves that same body, and `/events` frames it as `event: update`. So both tiers - * decode with the same schema, and a manifest that fails to decode is dropped in both. - * - * There is no `EventSource` in Electron's main process, so the stream is an `HttpClient` response - * body fed through `parseSseChunk`. + * Talking to the relay: one poll, and one long-lived stream. `/latest` and `/events` serve the same + * manifest body, so both tiers decode with one schema. There is no `EventSource` in Electron's + * main process, so the stream is an `HttpClient` response body fed through `parseSseChunk`. */ import * as Clock from "effect/Clock"; @@ -76,17 +71,10 @@ export const fetchLatestManifest = Effect.fn("coil.updateDelivery.fetchLatest")( }); /** - * Hold a stream open, handing every decodable `update` frame to `onManifest`. - * - * Never returns successfully: a stream that ends is a `stream-closed` failure, because from the - * caller's point of view "the server closed cleanly after fifteen minutes" and "the socket died" - * need the same response — reconnect and reconcile against `/latest`. Distinguishing them would - * only create a path where one of the two silently stops retrying. - * - * The watchdog runs off ANY byte, including the relay's `: ping` comment. Without the heartbeat, a - * genuinely quiet channel and a dead socket look identical; with it, silence is diagnostic. This - * is the failure the design calls out as the real one — not a connection that drops, which is - * loud, but a connection that stays open and delivers nothing. + * Hold a stream open, handing every decodable `update` frame to `onManifest`. Never succeeds: a + * clean close and a dead socket both fail as `stream-closed` so they get the same reconnect. The + * watchdog runs off ANY byte, including the relay's `: ping`, which is what makes silence + * diagnostic of a connection that stays open and delivers nothing. */ export const streamRelayEvents = Effect.fn("coil.updateDelivery.stream")(function* (args: { readonly eventsUrl: string; diff --git a/apps/desktop/src/coil/updateDelivery/restart.ts b/apps/desktop/src/coil/updateDelivery/restart.ts index 7a833108ebe61..c15b1337ed058 100644 --- a/apps/desktop/src/coil/updateDelivery/restart.ts +++ b/apps/desktop/src/coil/updateDelivery/restart.ts @@ -5,27 +5,10 @@ import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; /** - * Restarting into a freshly installed build. - * - * This deliberately does **not** call `DesktopLifecycle.relaunch`, even though that exists and does - * almost this. Two differences make it unusable here, and both are load-bearing: - * - * 1. **Its shutdown wait is unbounded.** `requestDesktopShutdownAndWait` awaits a bare `Deferred` - * that resolves only once every backend has stopped, and `DesktopBackendManager.closeRun` takes - * a no-timeout branch when given no options. A wedged PTY drain, an SSH tunnel, or a `wsl.exe` - * backend that never returns means the relaunch never fires — the app sits on "Restarting…" - * indefinitely. That is issue #41 reached through a different door: an app that is dark, for an - * unbounded time, with no error anywhere. - * - * 2. **It re-execs `process.execPath`.** Under macOS App Translocation that points into a - * randomised read-only snapshot, so the app would come back on the OLD build while - * post-install verification passed against the wrong process. `installTarget.ts` refuses - * translocated paths before we ever get here, but the exec path still has to come from the - * install target rather than from the running process. - * - * Editing `DesktopLifecycle.ts` to add a timeout was the other option. It would have added seam - * row 36 to a ledger whose own tripwire asks for re-isolation first, on a file the fork does not - * currently touch — so this restart path lives here instead. + * Restarting into a freshly installed build, deliberately without `DesktopLifecycle.relaunch`. Its + * shutdown wait is unbounded (a wedged backend leaves the app on "Restarting…" forever), and it + * re-execs `process.execPath`, which under App Translocation would come back on the old build. + * The exec path must come from the install target, not the running process. */ /** @@ -40,15 +23,9 @@ export const SHUTDOWN_GRACE = Duration.seconds(10); export type RestartOutcome = "graceful" | "forced-after-timeout"; /** - * Quit and come back on the new build. - * - * Ordering is the point of this function: - * - * 1. Ask for shutdown and wait, bounded. - * 2. Arm the relaunch — immediately before exiting, never earlier. `app.relaunch()` registers - * intent for the *next* quit, so arming it before a wait that might fail would leave the app - * alive with a booby-trapped Cmd-Q that silently resurrects it later. - * 3. Exit, whether or not the shutdown finished. + * Quit and come back on the new build: request shutdown and wait (bounded), then arm the relaunch + * immediately before exiting, then exit regardless. `app.relaunch()` arms the *next* quit, so + * arming it before a wait that might fail leaves a Cmd-Q that silently resurrects the app. */ export const restartIntoInstalledBuild = Effect.fn("coil.updateDelivery.restart")(function* (args: { readonly execPath: string; diff --git a/apps/desktop/src/coil/updateDelivery/sseParser.ts b/apps/desktop/src/coil/updateDelivery/sseParser.ts index 1938ae2549014..6522344e72beb 100644 --- a/apps/desktop/src/coil/updateDelivery/sseParser.ts +++ b/apps/desktop/src/coil/updateDelivery/sseParser.ts @@ -1,15 +1,8 @@ /** - * A minimal Server-Sent Events frame parser. - * - * Hand-rolled on purpose. There is no `EventSource` in the Electron main process: Node's global is - * Stability-1 behind `--experimental-eventsource`, and a packaged Electron app cannot pass Node CLI - * flags because the `nodeOptions` fuse is disabled. The alternative is an npm dependency that would - * have to be threaded through `resolveDesktopRuntimeDependencies` into the staged production - * install — and if that threading is ever missed, development works and the packaged app throws - * `ERR_MODULE_NOT_FOUND` at runtime. This file is ~60 lines and cannot fail that way. - * - * It implements only what the relay sends: `event:`, `data:`, and `:` comments. No `id:`, no - * `retry:`, no multi-line data reassembly beyond the spec's newline join. + * A minimal Server-Sent Events parser for what the relay sends (`event:`, `data:`, `:` comments). + * Hand-rolled: Electron's main process has no usable `EventSource` (Node's needs a CLI flag the + * disabled `nodeOptions` fuse blocks), and an npm dependency missed in the staged production + * install would only fail with `ERR_MODULE_NOT_FOUND` in the packaged app. */ export interface SseEvent { diff --git a/apps/desktop/src/coil/updateDelivery/stager.ts b/apps/desktop/src/coil/updateDelivery/stager.ts index a3e817b0a6ad1..32a316edbf71c 100644 --- a/apps/desktop/src/coil/updateDelivery/stager.ts +++ b/apps/desktop/src/coil/updateDelivery/stager.ts @@ -1,14 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -- The ~470 MB download is hashed incrementally as it streams; Effect Crypto only digests a whole buffer. /** - * Getting the announced build onto this disk, swap-ready, before the user is told anything. - * - * The user chose "pre-download quietly, click = instant restart". That only holds if everything - * expensive happens here: the ~470 MB download, the checksum, the dmg mount, the full bundle copy - * and the recursive de-quarantine. What remains for the click is a delete and a rename. - * - * Nothing in this file is silent about failure. Every abandoned attempt returns a - * `StagingError` the service turns into a visible `failed` state — the 103-minute outage in - * issue #41 was invisible precisely because the failing path had no way to speak. + * Getting the announced build onto this disk, swap-ready, before the user is told anything, so the + * click is only a delete and a rename. Every abandoned attempt returns a `StagingError` that the + * service shows as `failed`: a failing path that cannot speak makes an outage invisible. */ import * as NodeCrypto from "node:crypto"; @@ -102,17 +96,10 @@ const runOptionalXattr = (command: Command, step: StagingError["step"]) => : runCommand(command, step); /** - * Free bytes, parsed out of `df -Pk`. - * - * `-P` is the point: it forces the POSIX output format, which guarantees one line per filesystem - * and a fixed column order. Without it, a long device name wraps onto its own line and the - * "available" column moves — a misparse that would let a download start on a disk that cannot - * hold it. - * - * Exported for its test. Returns `undefined` for anything it does not fully recognise, and the - * caller treats that as "skip the check": being unable to measure free space is not evidence that - * there is none, and refusing an update over an unreadable `df` would be worse than the disk - * filling up. + * Free bytes, parsed out of `df -Pk`. `-P` forces one line per filesystem and fixed columns, so a + * long device name cannot shift the "available" column. Returns `undefined` for anything + * unrecognised, and the caller then skips the check: an unreadable `df` is not evidence of a full + * disk. */ export function parseDfAvailableBytes(output: string): number | undefined { const lines = output.trim().split("\n"); @@ -133,30 +120,17 @@ const freeBytesOn = Effect.fn("coil.updateDelivery.freeBytes")(function* (path: }, Effect.scoped); /** - * Following redirects is load-bearing here, not defensive hygiene. - * - * A GitHub release asset URL (`/releases/download//`) ALWAYS answers 302 with a signed, - * short-lived `release-assets.githubusercontent.com` location. The bytes are never served from - * github.com. The desktop app provides `NodeHttpClient.layerUndici` (main.ts), and undici does not - * follow redirects unless asked — so every update attempt died on the status check in - * `downloadAsset` with "HTTP 302 for https://github.com/...", which reads like a GitHub fault - * rather than a client that stopped one hop early. - * - * Named and exported so the redirect hop is covered by a test: inlining the `.pipe(...)` made the - * one thing that broke the whole update path invisible to the suite. + * Following redirects is load-bearing: GitHub release asset URLs always answer 302 to a signed + * `release-assets.githubusercontent.com` location, and undici does not follow redirects unless + * asked. Exported so the redirect hop stays covered by a test. */ export const assetDownloadClient = (client: HttpClient.HttpClient): HttpClient.HttpClient => client.pipe(HttpClient.followRedirects()); /** - * Download the asset, hashing as the bytes arrive. - * - * One pass, not two. Re-reading 470 MB from disk to checksum it doubles the I/O for no benefit, - * and on this machine — which runs at 95 percent full — the download is already the expensive part. - * - * Writes to `.part` and renames only after the checksum matches, so a crash mid-download can - * never leave a file that looks complete. `selectStagingSweep` deletes `.part` files - * unconditionally on the next run: their bytes were never verified, so they cannot be resumed. + * Download the asset, hashing as the bytes arrive (one pass, not a 470 MB re-read). Writes to + * `.part` and renames only after the checksum matches, so a crash cannot leave a file that + * looks complete; `selectStagingSweep` deletes `.part` files since they cannot be resumed. */ const downloadAsset = Effect.fn("coil.updateDelivery.download")(function* (args: { readonly asset: UpdateAsset; @@ -249,12 +223,8 @@ function shortShaFromName(name: string): string | undefined { } /** - * Stage a build so the click is a rename. - * - * macOS only mounts and copies; Windows stops at the verified installer, because an NSIS installer - * cannot be "pre-applied" — it needs the app's own files, so it can only run after the app quits. - * That asymmetry is real and the design says so: macOS restarts itself, Windows is restarted *by* - * the installer. + * Stage a build so the click is a rename. macOS mounts and copies; Windows stops at the verified + * installer, because NSIS needs the app's own files and can only run after the app quits. */ export const stageUpdate = Effect.fn("coil.updateDelivery.stage")(function* (args: { readonly manifest: UpdateManifest; diff --git a/apps/desktop/src/coil/updateDelivery/staging.ts b/apps/desktop/src/coil/updateDelivery/staging.ts index 2b804f71c00de..6929bb356451e 100644 --- a/apps/desktop/src/coil/updateDelivery/staging.ts +++ b/apps/desktop/src/coil/updateDelivery/staging.ts @@ -57,14 +57,9 @@ export function checkDiskSpace(args: { } /** - * Everything in the staging directory that is not the current target. - * - * Runs on startup as well as after a successful install, because the case that matters is the one - * where no install ever completed: the app crashed, or the user quit mid-stage, and nothing ran - * the cleanup path. - * - * Partial downloads are always swept, including one for the current target — a `.part` file from a - * previous run cannot be resumed, because its bytes were never checksummed. + * Everything in the staging directory that is not the current target. Runs on startup too, because + * the case that matters is a crash or quit mid-stage where no cleanup ran. `.part` files are always + * swept, even for the current target: their bytes were never checksummed, so they cannot resume. */ export function selectStagingSweep(args: { readonly entries: readonly StagedEntry[]; diff --git a/apps/desktop/src/compileCache.ts b/apps/desktop/src/compileCache.ts index da0fd66f5e582..abf8918df2af0 100644 --- a/apps/desktop/src/compileCache.ts +++ b/apps/desktop/src/compileCache.ts @@ -3,10 +3,8 @@ import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -// Linux uses the user's cache dir because /tmp is shared between users; the macOS and Windows -// temp dirs are already per user. Skipped under AppImage: it mounts the app at a new -// /tmp/.mount_* path each launch, and Node keys entries by path, so every launch would miss -// and leave another copy behind. +// Linux /tmp is shared; macOS and Windows temp dirs are per user. +// AppImage mounts change paths each launch, so Node's path-keyed cache would miss and accumulate copies. try { if (!process.env.APPIMAGE) { const cacheRoot = diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 0aae1148a4828..7190c70aedc21 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -43,12 +43,7 @@ export class ElectronApp extends Context.Service< { readonly metadata: Effect.Effect; readonly name: Effect.Effect; - /** - * The OS locale, read from the operating system rather than from Chromium's - * resolved application locale — the packaged app ships only the `en-US` - * locale pak, so `app.getLocale()` and the renderer's `Intl` default are - * pinned to `en-US` however the machine is configured. - */ + /** Packaged builds ship only en-US; app.getLocale() and Intl defaults do not reflect the OS locale. */ readonly systemLocale: Effect.Effect; readonly whenReady: Effect.Effect; readonly requestSingleInstanceLock: Effect.Effect; @@ -98,7 +93,7 @@ const addScopedAppListener = >( }), ).pipe(Effect.asVoid); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronApp.of({ metadata: Effect.gen(function* () { const appVersion = yield* Effect.try({ @@ -127,9 +122,7 @@ export const make = ElectronApp.of({ }; }), name: Effect.sync(() => Electron.app.name), - // macOS derives this from NSLocale, which uses POSIX-style identifiers - // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so - // the tag is normalized here rather than in the renderer that consumes it. + // NSLocale returns en_GB-style tags; Intl requires hyphens. systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index b48953f117d38..8f0df22016169 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -104,7 +104,7 @@ export class ElectronDialog extends Context.Service< } >()("@t3tools/desktop/electron/ElectronDialog") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronDialog.of({ pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { const ownerWindowId = Option.match(input.owner, { diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 352a5bc97a21a..a58eb0c743fb0 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -68,8 +68,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM continue; } - // Header items are decorative section labels for the web fallback only — - // Electron's native menu has no equivalent affordance, so we skip them. + // Native menus cannot display the web fallback's decorative headers. if (sourceItem.header === true) { continue; } @@ -97,9 +96,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM return normalizedItems; } -// Renderer positions arrive in CSS pixels; popup() expects window points, so -// page zoom must be factored in or menus drift proportionally to their -// distance from the window origin. +// popup() takes window points; renderer CSS coordinates must account for page zoom. const normalizePosition = ( position: Option.Option, zoomFactor: number, @@ -112,7 +109,7 @@ const normalizePosition = ( Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; let destructiveMenuIconCache: Option.Option | undefined; diff --git a/apps/desktop/src/electron/ElectronPowerMonitor.ts b/apps/desktop/src/electron/ElectronPowerMonitor.ts index 4fb42dfdc9f47..41c36acf2250a 100644 --- a/apps/desktop/src/electron/ElectronPowerMonitor.ts +++ b/apps/desktop/src/electron/ElectronPowerMonitor.ts @@ -75,7 +75,7 @@ const onSpeedLimitChange: ElectronPowerMonitor["Service"]["onSpeedLimitChange"] ).pipe(Effect.asVoid); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronPowerMonitor.of({ isOnBatteryPower: Effect.sync(() => Electron.powerMonitor.isOnBatteryPower()), getSystemIdleTime: Effect.sync(() => Electron.powerMonitor.getSystemIdleTime()), diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index d6f476205f4c5..d84b6f360c89e 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -78,11 +78,8 @@ function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInpu "https://challenges.cloudflare.com", ]; - // The renderer connects directly to user-configured environments in addition to - // the build-configured Clerk, relay, and OTLP endpoints. Those environment - // origins are not known when this response policy is created, so restrict - // connections by the network schemes the client supports instead of by host. - // GLTFLoader fetches embedded textures through blob URLs after parsing the model. + // User-configured environment origins are unknown here, so allow supported network schemes. + // GLTFLoader fetches embedded textures through blob URLs. const connectSources = ["'self'", "blob:", "http:", "https:", "ws:", "wss:"]; return [ @@ -110,9 +107,7 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } -/** - * Must run synchronously during process bootstrap, before Electron emits `ready`. - */ +// Must run synchronously before Electron emits ready. function registerDesktopSchemePrivilegesSync(): void { Electron.protocol.registerSchemesAsPrivileged([ { @@ -123,8 +118,7 @@ function registerDesktopSchemePrivilegesSync(): void { supportFetchAPI: true, corsEnabled: true, stream: true, - // Custom schemes skip Chromium's V8 code cache unless they opt in. - // Dev stays off: Vite serves changing code at stable URLs. + // Custom schemes must opt into V8 caching; dev URLs serve changing code. codeCache: true, }, }, @@ -246,7 +240,7 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( }); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const registered = yield* Ref.make(false); const context = yield* Effect.context(); diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index cd01613d92fd0..0395d20e9bd38 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -65,7 +65,7 @@ export class ElectronSafeStorage extends Context.Service< } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 0216d7b2c7dd1..a72963c2e258a 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -11,9 +11,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, -// `zed://ssh//`, `jetbrains://gateway/ssh/environment?h=…`) must -// reach the OS handler; every other non-web scheme stays blocked. +// Remote editor deep links must reach the OS handler. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -63,7 +61,7 @@ export class ElectronShell extends Context.Service< } >()("@t3tools/desktop/electron/ElectronShell") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronShell.of({ openExternal: (rawUrl) => Option.match(parseSafeExternalUrl(rawUrl), { diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index 42cac98d0f538..a84eedda13b4e 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -28,7 +28,7 @@ export class ElectronTheme extends Context.Service< } >()("@t3tools/desktop/electron/ElectronTheme") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), setSource: (theme) => diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index e42ebc1003ab4..397044932607b 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -80,7 +80,7 @@ export class ElectronUpdater extends Context.Service< } >()("@t3tools/desktop/electron/ElectronUpdater") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronUpdater.of({ setFeedURL: (options) => Effect.suspend(() => { diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 993e930678ed7..60908ee90dc89 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -127,11 +127,10 @@ export class ElectronWindow extends Context.Service< } >()("@t3tools/desktop/electron/ElectronWindow") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; - // The focus worker loads a native accessibility module. Start it on the first - // capture reveal so users who never capture pay nothing at launch. + // Lazy-load native accessibility on capture reveal to avoid startup cost for other users. let windowsForegroundFocus: ReturnType | undefined; const ensureWindowsForegroundFocus = () => { windowsForegroundFocus ??= startWindowsForegroundFocusThread( diff --git a/apps/desktop/src/ipc/methods/legacyLocalStorage.ts b/apps/desktop/src/ipc/methods/legacyLocalStorage.ts index 13f657e7812f8..a0c2fb27a5eac 100644 --- a/apps/desktop/src/ipc/methods/legacyLocalStorage.ts +++ b/apps/desktop/src/ipc/methods/legacyLocalStorage.ts @@ -22,8 +22,7 @@ export const completeLegacyLocalStorage = makeIpcMethod({ result: Schema.Void, handler: Effect.fn("desktop.ipc.legacyLocalStorage.complete")(function* (_, event) { const legacy = yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage; - // Chromium writes localStorage to disk lazily; persist the merge before - // the marker stops later launches from retrying it. + // Flush Chromium's lazy writes before the marker prevents migration retries. if (event) Electron.webContents.fromId(event.sender.id)?.session.flushStorageData(); yield* legacy.complete; }), diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index b659054540c23..c6c2cd8fbdf9a 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -262,11 +262,7 @@ const clearCache = DesktopIpc.makeIpcMethod({ }), }); -/** - * The default profile keeps the bare environment id it used before profiles - * existed, so upgrading does not strand anyone's existing logins in an orphaned - * partition. - */ +/** Keep the default profile's bare environment id so existing logins retain their partition. */ export function resolvePartitionScope( environmentId: string, profileId: string | undefined, @@ -278,9 +274,7 @@ export function resolvePartitionScope( if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) { return { scope: environmentId, persistent: true }; } - // JSON's tuple framing is injective for strings, including lone UTF-16 - // surrogates (which it escapes). URI encoding throws on those supported ids, - // while replacing them with U+FFFD would collapse distinct identities. + // JSON framing preserves lone UTF-16 surrogates; URI encoding throws and replacement would collide. return { scope: JSON.stringify([environmentId, profileId]), persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID, @@ -288,11 +282,7 @@ export function resolvePartitionScope( }; } -/** - * Clearing without a profile keeps the historical "everything" behaviour for - * an explicit all-profiles action; naming a profile confines it to that - * profile's partition so one profile's sign-out cannot reach the others. - */ +/** Omitting a profile clears every partition; a named profile clears only its own partition. */ const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartitions")(function* ( manager: PreviewManager.PreviewManager["Service"], environmentId: string, @@ -300,10 +290,7 @@ const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartit ) { if (profileId === undefined) return undefined; const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); - // Loading the session is what puts the partition in the map the clear walks. - // Deriving the partition string alone leaves nothing to match, so clearing a - // profile with no tab open this run — after a restart, or when deleting a - // profile — would report success and delete nothing. + // getBrowserSession registers the partition; deriving its name alone leaves unopened profiles uncleared. yield* manager.getBrowserSession(scope, persistent, namespace); return [yield* manager.getBrowserPartition(scope, persistent, namespace)]; }); @@ -315,9 +302,7 @@ const getPreviewConfig = DesktopIpc.makeIpcMethod({ handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId, profileId }) { const manager = yield* PreviewManager.PreviewManager; const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); - // Creating the session first is what installs the UA rewrite and permission - // handlers; a guest that attached to an untouched partition would run with - // Electron's default UA and Chromium's default permission behaviour. + // Session creation installs UA and permission handlers before a guest can attach. yield* manager.getBrowserSession(scope, persistent, namespace); return { partition: yield* manager.getBrowserPartition(scope, persistent, namespace), @@ -346,8 +331,6 @@ export const importBrowserCookies = DesktopIpc.makeIpcMethod({ ...importInput }) { const browserImport = yield* BrowserImport.BrowserImport; - // Derived in main from the same helper the webview config uses, so cookies - // land in exactly the partition the profile's tabs attach to. const { scope, persistent, namespace } = resolvePartitionScope( environmentId, importInput.targetProfileId, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 9066cf6f1a67c..f0d661b58bc9a 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -138,9 +138,7 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ runningDistro, httpBaseUrl: httpBaseUrl.href, wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), - // A backend launched with the desktop secret accepts whichever token the secret derives - // for the current window, so hand out that one rather than the token frozen into its - // launch config. + // Use the current window's derived token; the launch config's token may be stale. ...(bootstrap.desktopBootstrapSecret ? { bootstrapToken: yield* configuration.currentBootstrapToken } : bootstrap.desktopBootstrapToken @@ -194,9 +192,7 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ targetId !== undefined && targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX); - // Fall back to the persisted wslDistro when the id is the - // "wsl:default" sentinel; the orchestrator uses the same fallback - // for the actual backend. + // Match the orchestrator's persisted distro fallback for wsl:default. const wslDistro = useWsl ? (wslDistroFromTarget ?? settings.wslDistro) : null; const defaultPath = useWsl ? Option.fromNullishOr( diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts index 3c10f04512b4c..dac7550871460 100644 --- a/apps/desktop/src/ipc/methods/wsl.ts +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -22,8 +22,7 @@ const readWslState: Effect.Effect< const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const settings = yield* appSettings.get; const available = settings.localEnvironmentEnabled && (yield* wslEnvironment.isAvailable); - // Only enumerate distros when WSL is actually available — listDistros on a - // non-WSL host would spawn wsl.exe and hit the timeout for nothing. + // Listing distros on non-WSL hosts spawns wsl.exe only to time out. const distros = available ? yield* wslEnvironment.listDistros : []; const preflightError = yield* wslBackend.lastPreflightError; return { @@ -67,8 +66,7 @@ export const setWslBackendEnabled = makeIpcMethod({ yield* lifecycle.relaunch(`wslBackendEnabled=${enabled}`); return state; } - // Reconcile is idempotent and never fails; no need for a swap-style - // rollback when the WSL side has trouble coming up. + // Reconcile never fails, so no rollback is needed. yield* wslBackend.reconcile; return yield* readWslState; }), @@ -84,8 +82,7 @@ export const setWslDistro = makeIpcMethod({ const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const change = yield* appSettings.setWslDistro(distro); const settings = yield* appSettings.get; - // In active wsl-only mode the pool's primary IS the WSL backend, and its - // distro is captured when that backend starts, so relaunch to replace it. + // WSL-only primary backends capture their distro at startup; relaunch to change it. if (settings.wslBackendEnabled && settings.wslOnly && change.changed) { const state = yield* readWslState; yield* lifecycle.relaunch(`wslDistro=${distro ?? "default"}`); @@ -101,10 +98,7 @@ export const setWslOnly = makeIpcMethod({ payload: Schema.Boolean, result: DesktopWslStateSchema, handler: Effect.fn("desktop.ipc.wsl.setOnly")(function* (enabled) { - // wsl-only decides which backend the pool spins up as "primary", and that - // decision is captured once at layer init. A disabled WSL backend always - // leaves Windows primary active, so mode changes can be staged without a - // relaunch and applied by the subsequent enable call. + // The pool captures its primary at initialization; disabled WSL can stage mode changes until enable. const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const change = yield* appSettings.setWslOnly(enabled); diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index aaf0c11bcce92..ce7bc4004cf4c 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -26,14 +26,12 @@ describe("linuxSecretStorage", () => { it("forces gnome-libsecret for desktops Electron recognizes but leaves on basic text", () => { expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt" })).toBe("gnome-libsecret"); - // Chromium stops at the first recognized value, so a later name cannot rescue the session. expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:GNOME" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:KDE" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:plasma" })).toBe("gnome-libsecret"); }); it("does not treat lowercase desktop names as ones Electron recognizes", () => { - // Chromium matches these case-sensitively, so lowercase spellings reach basic text. expect(autoSwitch({ XDG_CURRENT_DESKTOP: "gnome" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "xfce" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "kde", KDE_SESSION_VERSION: "6" })).toBe( diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index 81f891985b9d0..b6d41b43a6640 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -8,8 +8,7 @@ export type LinuxPasswordStoreSwitch = Exclude layerDesktopApplicationRuntime.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))), diff --git a/apps/desktop/src/permissions/MacPermissionHelper.ts b/apps/desktop/src/permissions/MacPermissionHelper.ts index 4a8e38961f20a..0b50e147785f7 100644 --- a/apps/desktop/src/permissions/MacPermissionHelper.ts +++ b/apps/desktop/src/permissions/MacPermissionHelper.ts @@ -88,8 +88,7 @@ export class MacPermissionHelper { const bundle = macAppBundlePath(Electron.app.getPath("exe")); if (!bundle) return; if (owner?.isDestroyed()) return; - // Finder's bundle-icon lookup can return the generic app icon for mounted artifacts. - // Use the same PNG that packaging uses to generate the app's macOS icon. + // Finder can return generic icons for mounted artifacts; use the packaging PNG. const appIcon = iconPaths .map((iconPath) => Electron.nativeImage.createFromPath(iconPath)) .find((image) => !image.isEmpty()); diff --git a/apps/desktop/src/permissions/MacSettingsWindow.ts b/apps/desktop/src/permissions/MacSettingsWindow.ts index f529d82b59413..e5acc09abbf92 100644 --- a/apps/desktop/src/permissions/MacSettingsWindow.ts +++ b/apps/desktop/src/permissions/MacSettingsWindow.ts @@ -15,9 +15,8 @@ const SettingsWindow = Schema.NullOr( export type SettingsWindow = typeof SettingsWindow.Type; const decodeSettingsWindow = Schema.decodeUnknownSync(Schema.fromJsonString(SettingsWindow)); -// Window bounds and owner PIDs are available before Screen Recording is granted. -// Use the bundle identifier rather than the localized app/window title. A single -// process avoids launching osascript repeatedly while the user moves Settings. +// Window bounds/PIDs need no Screen Recording grant. Match the bundle id to avoid localized titles. +// Keep one osascript process while Settings moves. const SETTINGS_WINDOW_SCRIPT = ` ObjC.import("CoreGraphics"); ObjC.import("AppKit"); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c137629d914a6..45e1ee4064e19 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -46,8 +46,7 @@ try { const clientPlatform = process.platform; if (clientPlatform === "darwin") { - // Native window buttons do not scale with Chromium zoom. Keep their reserved - // space in native points, including when a zoomed page is reloaded. + // Native buttons ignore Chromium zoom; reserve native points even after reload. const syncWindowControlInset = () => { document.documentElement.style.setProperty( "--desktop-window-controls-inset", diff --git a/apps/desktop/src/preview/AnnotationIds.test.ts b/apps/desktop/src/preview/AnnotationIds.test.ts index 1104c422f3eea..f1f4be518455c 100644 --- a/apps/desktop/src/preview/AnnotationIds.test.ts +++ b/apps/desktop/src/preview/AnnotationIds.test.ts @@ -4,8 +4,6 @@ import { createAnnotationIdSource } from "./AnnotationIds.ts"; describe("createAnnotationIdSource", () => { it("gives annotations picked on separate page loads distinct ids", () => { - // The picker preload runs afresh on every page load, while the composer - // keeps earlier annotations keyed by id. const firstLoad = createAnnotationIdSource(); firstLoad("element"); const firstAnnotation = firstLoad("annotation"); diff --git a/apps/desktop/src/preview/AnnotationIds.ts b/apps/desktop/src/preview/AnnotationIds.ts index 2262febdd1c15..082d6540a64d8 100644 --- a/apps/desktop/src/preview/AnnotationIds.ts +++ b/apps/desktop/src/preview/AnnotationIds.ts @@ -1,11 +1,4 @@ -/** - * Id source for picker annotations and their targets, one per page load. - * - * The composer keeps annotations keyed by id across preview reloads, and this - * preload restarts on every page load, so a bare counter reissues ids that a - * draft already holds. `getRandomValues` rather than `randomUUID`, because - * plain-http previews (LAN, tailnet) are not secure contexts. - */ +/** Per-load nonces avoid draft ID collisions; getRandomValues works on plain HTTP. */ export function createAnnotationIdSource(): (prefix: string) => string { const pageLoad = Array.from(crypto.getRandomValues(new Uint32Array(2)), (part) => part.toString(36), diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 4815da11cba1a..c691ac15174d6 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -46,8 +46,6 @@ const withImporter = Effect.fnUntraced(function* () { const root = helium.userDataDirectory(context); if (root === undefined) throw new Error("Helium has no macOS user-data directory"); yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); - // The cookie database is what marks a source as installed, so a fixture - // without one is reported as absent before any other check runs. yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); const importer = yield* BrowserImport.BrowserImport.pipe( @@ -96,8 +94,6 @@ describe("BrowserImport.importCookies", () => { 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 diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index e7f97eca26c02..c567b27411c0c 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -41,8 +41,7 @@ export class BrowserImportFailedError extends Schema.TaggedError 0) { yield* Effect.tryPromise(() => session.cookies.flushStore()).pipe( Effect.tapError((error) => @@ -152,7 +147,7 @@ export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( return { imported, skipped, skippedDomains: [...skippedDomains].slice(0, 20) }; }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* BrowserImportMake() { const browserSession = yield* BrowserSession.BrowserSession; const platform = yield* HostProcess.Platform; @@ -200,19 +195,14 @@ export const make = Effect.gen(function* BrowserImportMake() { } if (platform === "darwin" && definition.engine === "chromium") { - // macOS attributes the Keychain prompt and the resulting ACL grant to the - // executable that asks, so record which one that was — in a packaged build - // it is the signed app, in dev whatever binary hosts the main process. + // macOS attributes Keychain prompts and ACL grants to the executable, which differs in dev and packaged builds. yield* Effect.logInfo("Reading browser cookie key from the keychain", { sourceId: definition.id, executablePath, }); } - // The profile directory arrives over IPC, so it is only honoured when the - // source itself reported it. Forwarding it unchecked would let `..` - // segments walk out of the browser's user-data directory and read any - // cookie database reachable on disk. + // IPC profile paths must match the source's listing to prevent traversal outside browser user-data. const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe( Effect.provide(platformServices), ); @@ -226,8 +216,7 @@ export const make = Effect.gen(function* BrowserImportMake() { }); } - // The profile was listed against a database moments ago; resolve it again - // rather than assume a path, since a Chromium jar may sit under `Network/`. + // Re-resolve listed paths because Chromium jars may be under Network/. const databasePath = yield* resolveCookieDatabase( definition, pathContext, @@ -296,8 +285,7 @@ export const make = Effect.gen(function* BrowserImportMake() { ), ); - // Written one at a time rather than in parallel: Chromium's cookie store - // serialises writes anyway, and a rejected cookie should only cost itself. + // Chromium serializes writes; a rejected cookie should not prevent the others. return yield* writeCookies(session, result); }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index 91be0a43d1be8..e5aa05f268e43 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -38,9 +38,6 @@ const encryptWindowsV10 = (value: string | Buffer, key: Buffer): Uint8Array => { describe("cookieScope", () => { it("keeps a host-only cookie host-only", () => { - // Chromium stores a host-only cookie without a leading dot. Passing any - // `domain` to Electron makes it a domain cookie and re-adds the dot, which - // would expose the cookie to every subdomain it was never scoped to. expect(cookieScope("example.test", "/", true)).toEqual({ url: "https://example.test/", domain: undefined, @@ -288,8 +285,7 @@ describe("readChromiumCookieDatabase", () => { const filename = `${directory}/Cookies`; const cbcV10 = Buffer.from("0123456789abcdef"); const cbcV11 = Buffer.from("fedcba9876543210"); - // The key some Linux clients actually encrypted with (crbug.com/1195256): - // OSCrypt's derivation over an empty passphrase. + // Some Linux clients encrypted with an empty passphrase (crbug.com/1195256). const cbcEmpty = NodeCrypto.pbkdf2Sync("", "saltysalt", 1, 16, "sha1"); yield* Effect.gen(function* () { @@ -352,9 +348,6 @@ describe("readChromiumCookieDatabase", () => { ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); - // Chromium's OSCrypt returns unprefixed data as-is on both platforms - // (os_crypt_mac.mm and os_crypt_linux.cc: "old data saved as clear - // text"), so neither counts it as undecryptable. const mac = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); const linux = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "linux"); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index d5f7592b26068..63fef9fb3c808 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -73,12 +73,7 @@ const decodeSchemaVersion = Schema.decodeUnknownEffect( Schema.Tuple([Schema.Struct({ value: SchemaVersion })]), ); -/** - * Unspecified is imported as Electron's own `unspecified` rather than pinned to - * Lax, so the target browser applies its default just as the source did; - * anything unrecognised lands there too, since guessing "none" would widen a - * cookie's scope on import. - */ +/** Preserve unspecified SameSite; guessing no_restriction would widen cookie scope. */ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { if (value === 0) return "no_restriction"; if (value === 1) return "lax"; @@ -86,21 +81,14 @@ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { return "unspecified"; }; -/** - * The microsecond value overflows JavaScript's safe integer range and - * `node:sqlite` refuses to narrow it, so the division happens in SQL and this - * only ever sees seconds. - */ +/** Convert microseconds in SQL: node:sqlite refuses integers beyond JavaScript's safe range. */ const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600; const toUnixSeconds = (webkitSeconds: number): number | undefined => { if (webkitSeconds <= 0) return undefined; return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS; }; -/** - * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding - * a cookie to its domain. - */ +// Chromium 127+ prefixes plaintext with the host-key SHA-256 to bind its domain. const stripDomainBinding = ( plaintext: Buffer, domain: string, @@ -161,17 +149,14 @@ export function decryptChromiumValue( const prefix = buffer.subarray(0, 3).toString("latin1"); const payload = buffer.subarray(3); - // Windows' legacy v10 format is AES-256-GCM. App-bound records use v20 and - // intentionally have no key here, so they fall through as undecryptable. + // Windows v10 uses AES-256-GCM; app-bound v20 records have no key here. if (platform === "win32") { return prefix === "v10" && keys.gcmV10 ? decryptGcm(payload, keys.gcmV10, domain, schemaVersion) : null; } - // Chromium retries a failed record with a key derived from an empty - // passphrase, because some Linux clients wrote data that way - // (crbug.com/1195256). + // Retry the empty-passphrase key used by some Linux clients (crbug.com/1195256). if (prefix === "v10") { if (!keys.cbcV10) return null; return ( diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts index 0d91ebe5a3746..bc9edcfe52a80 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -14,7 +14,6 @@ import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; const KEY_SALT = "saltysalt"; const KEY_LENGTH = 16; -/** macOS stretches the keychain secret; Linux uses a single iteration. */ const MAC_KEY_ITERATIONS = 1003; const LINUX_KEY_ITERATIONS = 1; /** Chromium's documented fallback passphrase when no Linux keyring is present. */ @@ -42,11 +41,7 @@ export interface ChromiumKeyMaterial { readonly cbcV10?: Buffer; readonly cbcV11?: Buffer; readonly cbcV11Error?: ChromiumKeyError; - /** - * AES-128-CBC from an empty passphrase. Some Linux clients wrote records - * with it (crbug.com/1195256), so Chromium — and this import — retry with it - * after a record's own key fails. - */ + /** Linux clients can encrypt with an empty passphrase; retry it on failure (crbug.com/1195256). */ readonly cbcEmpty?: Buffer; readonly gcmV10?: Buffer; } @@ -54,21 +49,12 @@ export interface ChromiumKeyMaterial { const derive = (passphrase: string, iterations: number) => NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1"); -/** - * Via the CLI the prompt says "security" and "Always Allow" grants trust to a - * tool every process on the machine can invoke; in-process it names this app - * and the grant belongs to it. - * - * Deliberately untimed: macOS answers this with a modal, and a timeout racing - * the user means the prompt can be approved while nothing is left listening, - * which reads as "approving did nothing". - */ +// In-process Keychain access attributes grants to this app. Keep modal prompts untimed to preserve their listener. const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function* ( service: string, account: string, ) { - // Only macOS cookie imports need this binding; loading it at startup can - // prevent the desktop from opening on platforms that never use it. + // Loading the macOS binding at startup can prevent other platforms from opening. const Keyring = yield* Effect.tryPromise({ try: () => import("@napi-rs/keyring"), catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), @@ -90,10 +76,7 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function return secret; }); -/** - * Its exit codes distinguish a missing key, denied access, and an unavailable - * keyring without parsing localized error messages. - */ +/** Exit codes distinguish missing keys, denied access, and unavailable keyrings without localized message parsing. */ export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(function* ( application: string, ) { @@ -267,13 +250,10 @@ export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys") } if (request.platform === "linux") { - // The fallback passphrase always applies to `v10` records; a keyring - // secret, when one is reachable, additionally unlocks `v11`. + // v10 always uses the fallback passphrase; a keyring secret additionally unlocks v11. const keyringSecret = request.linuxSecretApplication ? yield* readLinuxSecret(request.linuxSecretApplication).pipe( - // v10 remains importable when Secret Service is absent or does not - // contain a key. An explicit denial/lock/cancel remains a consent - // failure rather than being silently downgraded. + // Missing Secret Service or keys must not block v10; explicit denial, lock, or cancellation remains a consent failure. Effect.catchIf( (error) => error.reason !== "needsKeychainApproval", (error) => Effect.succeed(error), diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts index 2eb6cdbb5a17b..6c708e6023df2 100644 --- a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts @@ -9,11 +9,7 @@ export interface ImportedCookie { readonly url: string; readonly name: string; readonly value: string; - /** - * A host-only cookie leaves this undefined: Electron treats any `domain` it is given as marking a - * domain cookie and re-adds the dot, which would widen the cookie to every subdomain of the host - * it was scoped to, and rejects `__Host-` cookies, which require it to be absent. - */ + /** Leave host-only cookies undefined; Electron widens any supplied domain and rejects __Host- cookies with one. */ readonly domain: string | undefined; readonly path: string; readonly secure: boolean; @@ -47,10 +43,7 @@ export const cookieScope = ( export const bareHost = (host: string): string => (host.startsWith(".") ? host.slice(1) : host); -/** - * Both engines keep the file open with WAL while the browser runs, so reading in place can observe - * a torn write. Copying also guarantees we never open the browser's own file for writing. - */ +/** Copy before reading to avoid live WAL writes and never open the source browser database for writing. */ export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(function* ( cookiePath: string, tempPrefix = "t3code-cookie-import-", diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index d764b5bd6d007..be41318fa01e6 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -37,8 +37,6 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( rawSameSite?: number; originAttributes?: string; }>, - // Firefox stamps `PRAGMA user_version`; schema 16+ stores `expiry` in - // milliseconds, earlier ones in seconds. schemaVersion = 15, ) { const fileSystem = yield* FileSystem.FileSystem; @@ -46,7 +44,6 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( const file = `${directory}/cookies.sqlite`; const database = new NodeSqlite.DatabaseSync(file); database.exec(`pragma user_version = ${schemaVersion}`); - // Only schemas 10–14 have `rawSameSite`; the schema-15 migration dropped it. const hasRawSameSite = schemaVersion >= 10 && schemaVersion <= 14; database.exec( `create table moz_cookies ( @@ -87,8 +84,6 @@ describe("readFirefoxCookies", () => { it.effect("converts millisecond expiries from schema 16 and newer", () => run( Effect.gen(function* () { - // Firefox 129 (schema 16) migrated `expiry` to milliseconds; older - // profiles still hold seconds. Both must land as seconds for Electron. const row = { host: "example.test", name: "c", @@ -169,9 +164,6 @@ describe("readFirefoxCookies", () => { it.effect("keeps an unset SameSite unspecified instead of widening it to none", () => run( Effect.gen(function* () { - // nsICookie::SAMESITE_UNSET is 256, a cookie that carried no SameSite - // attribute. It is not SAMESITE_NONE (0), which is an explicit opt-in - // to cross-site use; importing it as "none" would widen its scope. const row = { host: "example.test", name: "c", @@ -198,8 +190,6 @@ describe("readFirefoxCookies", () => { it.effect("imports rows whose SameSite was never written", () => run( Effect.gen(function* () { - // Schema 9 added `sameSite` without a default, so rows from before the - // upgrade hold NULL. One such row must not fail the whole import. const row = { host: "example.test", name: "c", @@ -229,10 +219,6 @@ describe("readFirefoxCookies", () => { it.effect("applies the schema-15 rawSameSite rule to older databases", () => run( Effect.gen(function* () { - // Schemas 10–14 defaulted `sameSite` to Lax and kept the declared value - // in `rawSameSite`. Firefox's own migration to 15 turns "Lax by - // default, None declared" into Unset; an unmigrated database has to be - // read the same way or an undeclared cookie becomes an explicit Lax. const row = { host: "example.test", name: "c", @@ -276,9 +262,6 @@ describe("readFirefoxCookies", () => { sameSite: 1, }, { - // Same host, name and path as above: Firefox keeps these apart by - // container, Electron cannot, so importing both would hand the - // profile whichever one happened to be written last. host: "mail.test", name: "session", value: "work-container", @@ -340,8 +323,6 @@ describe("readFirefoxCookies", () => { describe("parseFirefoxProfiles", () => { it.effect("reads named profiles and ignores Install sections", () => Effect.gen(function* () { - // `Install*` sections name a default profile but do not describe one, so - // counting them would invent a profile whose directory does not exist. const parsed = yield* parsePosixFirefoxProfiles( [ "[Install4F96D1932A9F858E]", diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts index 16ad488f5cc2f..a5fddb14d22df 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -17,23 +17,12 @@ export class FirefoxCookieReadError extends Schema.TaggedError= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; - // Firefox isolates cookies per container and per private window via - // `originAttributes` (`^userContextId=2`, `^privateBrowsingId=1`); - // Electron has no equivalent, so importing them all would collapse - // several identities onto one host/name/path and hand the profile an - // arbitrary container's session. + // Electron cannot represent Firefox containers/private windows; importing them would collapse distinct sessions. const raw = hasRawSameSite ? yield* sql` select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index 5420b9fc8bd50..7e92334c957de 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -158,8 +158,6 @@ describe("parseBinaryCookies", () => { }); 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. const first = encodeBinaryCookies([ { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, ]); @@ -215,9 +213,6 @@ describe("parseBinaryCookies", () => { describe("readSafariCookies", () => { 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. const denied = PlatformError.systemError({ _tag: "Unknown", module: "FileSystem", diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts index 15f8de8d3a36e..118f9af1f776a 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -8,7 +8,6 @@ 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; const COOKIE_RECORD_HEADER_SIZE = 56; @@ -46,10 +45,7 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray buffer.length) { throw new SafariCookieReadError({ reason: "readFailed" }); } @@ -74,9 +70,7 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray 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. + // Reject offsets into the header, table, or earlier records to avoid fabricated cookies. const accepted: Array = []; for (let index = 0; index < cookieCount; index += 1) { const cookieStart = page.readUInt32LE(8 + index * 4); @@ -120,9 +114,6 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray 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, @@ -130,17 +121,13 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray { - // 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. + // TCC returns EPERM (Effect Unknown); EACCES is a POSIX denial that Full Disk Access cannot fix. const code = (error.reason as { cause?: { code?: unknown } }).cause?.code; return code === "EPERM"; }; -/** - * `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. - * */ +/** stat succeeds without Full Disk Access, so presence alone does not prove readable cookies. */ export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe( diff --git a/apps/desktop/src/preview/BrowserImport/SafariPermission.ts b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts index 1bad2ec2b3572..8e1d2c3d62d4d 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariPermission.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts @@ -17,8 +17,7 @@ export const safariPermissionCheck = Effect.gen(function* () { if (!safari || context.platform !== "darwin") return false; const defaultJar = yield* resolveCookieDatabase(safari, context, "."); if (defaultJar !== undefined) return yield* safariAccessGranted(defaultJar); - // A Safari installation can have cookies only in a named profile. Rediscover - // those stores after a grant, since TCC may have hidden their metadata before. + // Rediscover named profiles after a grant; TCC may have hidden their metadata. const profiles = yield* listSourceProfiles(safari, context); for (const profile of profiles) { const jar = yield* resolveCookieDatabase(safari, context, profile.directory); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index cc92563c5847c..898d94221b8d4 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -119,9 +119,6 @@ describe("isSourceRunning", () => { 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`, @@ -209,9 +206,6 @@ describe("isSourceInstalled", () => { const context = yield* withSourceHome(); const root = userDataDirectory(context); - // Installers for native messaging hosts create an empty user-data - // directory for every Chromium fork they know about, so treating the - // directory as evidence lists browsers the user does not have. yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); assert.isFalse(yield* isSourceInstalled(helium, context)); @@ -307,8 +301,6 @@ describe("cookieDatabaseCandidatePaths", () => { const fileSystem = yield* FileSystem.FileSystem; const context = yield* withSourceHome(); 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 }); yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "live"); yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale"); @@ -326,6 +318,237 @@ describe("cookieDatabaseCandidatePaths", () => { const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; +const zen = BROWSER_IMPORT_SOURCES.find((source) => source.id === "zen")!; + +describe("Zen", () => { + it.effect("resolves Zen's native user-data roots on every supported platform", () => + run( + Effect.gen(function* () { + const darwinContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: "/Users/zen-user" }), + Effect.provideService(HostProcess.Platform, "darwin"), + ); + const windowsContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { + HOME: "C:\\Users\\zen-user", + APPDATA: "C:\\Users\\zen-user\\AppData\\Roaming", + }), + Effect.provideService(HostProcess.Platform, "win32"), + ); + const linuxContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: "/home/zen-user" }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + + assert.equal(zen.name, "Zen"); + assert.equal(zen.engine, "firefox"); + assert.deepEqual([...zen.platforms], ["darwin", "win32", "linux"]); + assert.equal( + zen.userDataDirectory(darwinContext), + darwinContext.path.join("/Users/zen-user", "Library", "Application Support", "zen"), + ); + assert.equal( + zen.userDataDirectory(windowsContext), + windowsContext.path.join("C:\\Users\\zen-user\\AppData\\Roaming", "zen"), + ); + assert.equal( + zen.userDataDirectory(linuxContext), + linuxContext.path.join("/home/zen-user", ".config", "zen"), + ); + assert.deepEqual(zen.alternateLinuxRoots?.(linuxContext), [ + linuxContext.path.join("/home/zen-user", ".zen"), + linuxContext.path.join("/home/zen-user", ".var", "app", "app.zen_browser.zen", "zen"), + ]); + }), + ), + ); + + it.effect("discovers Zen profiles through its Firefox-compatible metadata", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "darwin"), + ); + const root = zen.userDataDirectory(context)!; + yield* fileSystem.makeDirectory(root, { recursive: true }); + const profile = context.path.join(root, "Profiles", "zen.default"); + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 2, 1); + yield* fileSystem.makeDirectory(context.path.join(root, "Profiles", "empty.default"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + "Name=Zen", + "IsRelative=1", + "Path=Profiles/zen.default", + "ZenAvatarPath=chrome://browser/content/zen-avatars/avatar-55.svg", + "Default=1", + "", + "[Profile1]", + "Name=Empty", + "IsRelative=1", + "Path=Profiles/empty.default", + "", + ].join("\n"), + ); + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { + directory: context.path.join("Profiles", "zen.default"), + name: "Zen", + cookieCount: 2, + }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.isFalse(yield* isSourceRunning(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, context.path.join("Profiles", "zen.default")), + context.path.join(profile, "cookies.sqlite"), + ); + }), + ), + ); + + it.effect("discovers Zen profiles in both current and legacy Linux roots", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-linux-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const currentRoot = context.path.join(home, ".config", "zen"); + const legacyRoot = context.path.join(home, ".zen"); + const currentProfile = context.path.join(currentRoot, "current.default"); + const legacyProfile = context.path.join(legacyRoot, "legacy.default"); + + for (const [root, profile, name] of [ + [currentRoot, currentProfile, "Current"], + [legacyRoot, legacyProfile, "Legacy"], + ] as const) { + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 1, 0); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + `Name=${name}`, + "IsRelative=1", + `Path=${context.path.basename(profile)}`, + ].join("\n"), + ); + } + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: "current.default", name: "Current", cookieCount: 1 }, + { directory: legacyProfile, name: "Legacy", cookieCount: 1 }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, legacyProfile), + context.path.join(legacyProfile, "cookies.sqlite"), + ); + + yield* fileSystem.writeFileString( + context.path.join(currentRoot, "profiles.ini"), + `[Profile0]\nName=Shared\nIsRelative=0\nPath=${legacyProfile}\n`, + ); + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: legacyProfile, name: "Shared", cookieCount: 1 }, + ]); + + yield* fileSystem.remove(currentRoot, { recursive: true }); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: legacyProfile, name: "Legacy", cookieCount: 1 }, + ]); + }), + ), + ); + + it.effect("discovers a Flatpak-only Zen installation and resolves its cookies", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-flatpak-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const root = context.path.join(home, ".var", "app", "app.zen_browser.zen", "zen"); + const profile = context.path.join(root, "flatpak.default"); + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 2, 1); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + "[Profile0]\nName=Flatpak\nIsRelative=1\nPath=flatpak.default\n", + ); + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: profile, name: "Flatpak", cookieCount: 2 }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, profile), + context.path.join(profile, "cookies.sqlite"), + ); + }), + ), + ); + + it.effect("does not mistake Firefox Snap installs for Zen profiles", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-snap-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const zenRoot = context.path.join(home, ".config", "zen"); + const snapRoot = context.path.join( + home, + "snap", + "firefox", + "common", + ".mozilla", + "firefox", + ); + const zenProfile = context.path.join(zenRoot, "zen.default"); + const snapProfile = context.path.join(snapRoot, "snap.default"); + for (const root of [zenRoot, snapRoot]) { + const profile = root === zenRoot ? zenProfile : snapProfile; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 1, 0); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + "Name=Personal", + "IsRelative=1", + `Path=${profile === zenProfile ? "zen.default" : "snap.default"}`, + ].join("\n"), + ); + } + + const profiles = yield* listSourceProfiles(zen, context); + assert.deepEqual( + profiles.map((profile) => profile.directory), + ["zen.default"], + ); + assert.isTrue(yield* isSourceInstalled(zen, context)); + }), + ), + ); +}); + describe("Firefox Snap profiles", () => { it.effect.skipIf(!symlinksSupported)( "finds Snap profiles with or without profiles.ini and checks their locks", @@ -565,14 +788,9 @@ describe("isSourceRunning for Firefox", () => { yield* fileSystem.writeFileString(`${root}/lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); - // `.parentlock` is deliberately left on disk after a clean exit as a - // last-used marker, so an unlocked one is not evidence of a running - // browser — treating it as one blocked every import after first use. yield* fileSystem.writeFileString(`${profile}/.parentlock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); - // The `lock` symlink is what Firefox removes on exit; a live pid in - // its target means the profile is held. yield* fileSystem.symlink(`127.0.0.1:+${process.pid}`, `${profile}/lock`); assert.isTrue(yield* isSourceRunning(firefox, context)); }), @@ -582,11 +800,7 @@ describe("isSourceRunning for Firefox", () => { it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () => Effect.gen(function* () { const alive = (pid: number) => Effect.succeed(pid === 4242); - // The resolver may hand Firefox any of the machine's addresses, not - // just 127.0.0.1 — 127.0.1.1 on Debian-style hosts, a LAN address - // elsewhere — so every local address counts as ours. const local = new Set(["127.0.0.1", "127.0.1.1", "192.168.1.20"]); - // Both the plain and the fcntl-marked (`+`) forms carry the pid. assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.0.1:4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.1.1:+4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+4242", local, alive)); @@ -603,8 +817,7 @@ describe("isSourceRunning for Firefox", () => { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); const context = yield* sourcePathContext.pipe( - // Firefox's win32 root hangs off %APPDATA%; without it the root is - // undefined and the fixture would escape the sandbox into the repo. + // Without APPDATA, the Windows Firefox fixture would escape into the repo. Effect.provideService(HostProcess.Environment, { HOME: home, APPDATA: `${home}/AppData/Roaming`, @@ -616,8 +829,6 @@ describe("isSourceRunning for Firefox", () => { yield* fileSystem.makeDirectory(profile, { recursive: true }); yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); - // On Windows, Firefox creates parent.lock as a regular file that - // persists after the process exits. yield* fileSystem.writeFileString(`${profile}/parent.lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); }), diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 3e1cae508a69e..14c4bca77b7e6 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -26,6 +26,7 @@ export interface BrowserImportSourceDefinition { readonly engine: BrowserImportEngine; readonly platforms: ReadonlyArray; readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; + readonly alternateLinuxRoots?: (context: BrowserImportPathContext) => ReadonlyArray; readonly keychainService?: string; readonly keychainAccount?: string; readonly linuxSecretApplication?: string; @@ -73,9 +74,7 @@ const chromiumSource = (input: { }); export const BROWSER_IMPORT_SOURCES: ReadonlyArray = [ - // No Chromium fork is importable on Windows: since Chrome 127 their cookies - // are encrypted to the browser's own identity (App-Bound Encryption), so no - // other process can read them. + // Chrome 127+ App-Bound Encryption prevents other processes from reading Windows Chromium cookies. chromiumSource({ id: "chrome", name: "Chrome", @@ -171,6 +170,26 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray [ + context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), + ], + }, + { + id: "zen", + name: "Zen", + engine: "firefox", + platforms: ["darwin", "win32", "linux"], + userDataDirectory: (context) => { + if (context.platform === "darwin") return macApplicationSupport(context, "zen"); + if (context.platform === "win32") { + return context.appData ? context.path.join(context.appData, "zen") : undefined; + } + return context.path.join(context.home, ".config", "zen"); + }, + alternateLinuxRoots: (context) => [ + context.path.join(context.home, ".zen"), + context.path.join(context.home, ".var", "app", "app.zen_browser.zen", "zen"), + ], }, ]; @@ -190,8 +209,7 @@ const cookieDatabaseCandidatePaths = ( 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. + // Prefer Chromium 96+'s Network/Cookies; upgrades leave the pre-96 Cookies file behind. return [ context.path.join(profilePath, "Network", "Cookies"), context.path.join(profilePath, "Cookies"), @@ -209,11 +227,7 @@ export const resolveCookieDatabase = Effect.fnUntraced(function* ( return undefined; }); -/** - * Firefox records its profiles in `profiles.ini`. `Install*` sections point at - * a default profile but do not describe one, so only `[ProfileN]` blocks - * count. - */ +/** Install* sections select defaults, not profiles; only ProfileN blocks describe profiles. */ export function parseFirefoxProfiles( ini: string, path: Path.Path, @@ -242,8 +256,7 @@ export function parseFirefoxProfiles( if (!escapesRoot) directory = path.normalize(candidate); } } else if (path.isAbsolute(candidate)) { - // Firefox supports profiles on arbitrary custom roots when - // IsRelative=0. Do not constrain them to the standard Firefox root. + // IsRelative=0 permits custom roots outside Firefox's standard directory. directory = path.normalize(candidate); } @@ -308,10 +321,7 @@ const isSafeProfileDirectory = (directory: string): boolean => const CookieCountRow = Schema.Struct({ count: Schema.Number }); const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); -/** - * Firefox containers use identities Electron cannot represent, so its count - * uses the same default-container predicate as the reader. - */ +// Count only default-container cookies because Electron cannot represent Firefox containers. const countProfileCookies = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -420,12 +430,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), Effect.orElseSucceed(() => [] as ReadonlyArray), ); - // `profiles.ini` also lists profiles the installer created but the user - // never launched, which hold no cookie database and nothing to import. When - // none of the declared profiles has one, fall through to the scan rather - // than returning empty: `profiles.ini` can list stale or never-launched - // profiles while the cookies live in one it does not mention, and an empty - // answer here hides the browser entirely. + // Declared profiles may be stale or never launched; scan for unlisted stores if none has a cookie database. if (declared.length > 0) { const found = yield* Effect.forEach(declared, (profile) => Effect.forEach( @@ -460,11 +465,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( const declared = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe( Effect.flatMap(decodeLocalState), Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})), - // The keys are directory names from the browser's own metadata file, which - // anything running as the user can write. Anything but a single plain - // segment is dropped: `..` or a path separator would otherwise be handed - // to `cookieDatabasePath` and read a database outside the user-data - // directory. + // Browser metadata is user-writable; reject traversal segments before resolving cookie database paths. Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))), Effect.map((entries) => entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })), @@ -490,11 +491,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( ); }); -/** - * Include Firefox's Snap home alongside its native home. Snap profiles use - * absolute directories so cookie reads and lock checks keep pointing at the - * installation they came from, even when both installs use the same name. - */ +/** Keep alternate-root profile paths absolute so cookie reads and locks use the correct installation. */ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -505,10 +502,7 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf const root = definition.userDataDirectory(context); if (root === undefined) return []; - const roots = [ - root, - context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), - ]; + const roots = [root, ...(definition.alternateLinuxRoots?.(context) ?? [])]; const profiles = new Map(); for (const directory of roots) { const found = yield* listSourceProfilesInDirectory( @@ -544,9 +538,7 @@ export const chromiumProcessIsAlive = ( signalProcess(pid, 0); return true; } catch (cause) { - // Only ESRCH positively proves the process is gone. Permission errors - // and unknown failures stay conservative so an active browser is never - // mistaken for a stale lock. + // Only ESRCH proves a stale lock; permission and unknown failures may still hide an active browser. return !( typeof cause === "object" && cause !== null && @@ -570,8 +562,7 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( if (!/^\d+$/.test(pidText)) return true; const pid = Number(pidText); if (!Number.isSafeInteger(pid) || pid <= 0) return true; - // A PID is meaningful only on this host. A foreign hostname can come from a - // shared home directory, and cannot safely be declared stale from here. + // Foreign-host PIDs may come from a shared home; never declare them stale here. if (host !== currentHost) return true; return yield* isProcessAlive(pid); }); @@ -580,15 +571,9 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean => error.reason._tag === "Busy"; -/** - * Whether a Windows `parent.lock` is actually held by a running process. It - * is opened with no sharing, so it persists on disk after the process exits - * and `stat` always succeeds; only trying to open it for write reveals an - * active holder, which surfaces as `Busy`. - */ +/** Windows leaves parent.lock after exit; only opening it for write reveals a live holder as Busy. */ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { - // Permission failures are distinct: they do not prove a browser owns the - // lock, so they must not hide the source as running. + // Permission failures do not prove a running browser owns the lock. const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe( Effect.as(false), @@ -600,11 +585,7 @@ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { type WindowsLockProbe = (path: string) => Effect.Effect; -/** - * Chromium does not create its POSIX `SingletonLock` symlink on Windows. The - * live cookie database is opened without sharing instead, so probing each - * profile's current jar is the reliable running signal there. - */ +/** Windows Chromium locks cookie databases without sharing instead of creating POSIX SingletonLock symlinks. */ export const windowsChromiumCookiesAreHeld = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -628,9 +609,7 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( ) { const separator = target.lastIndexOf(":"); if (separator < 0) return true; - // The owner half is whatever Firefox's resolver returned for the machine's - // hostname — 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN - // address — so a pid is only meaningful when that address is one of ours. + // Firefox resolves the hostname to loopback or LAN addresses; only local owners' PIDs are meaningful. const owner = target.slice(0, separator); if (!localAddresses.has(owner)) return true; // A `+` marks an fcntl-holding owner; the pid follows either way. @@ -641,18 +620,10 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( return yield* isProcessAlive(pid); }); -/** - * `/usr/bin/python3` is named absolutely first so a Dock-launched app with - * launchd's bare `PATH` still finds it without depending on the login-shell - * PATH merge; Linux distributions carry python3 on the default path. - */ +/** Dock launches have a bare PATH; try the absolute interpreter before relying on shell discovery. */ const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const; -/** - * Anything else means the script never ran — most importantly Apple's - * `/usr/bin/python3` shim, which on a Mac without the Command Line Tools exits - * non-zero after printing an install prompt, without ever reaching our code. - */ +/** Apple's python3 shim can fail with an install prompt before the probe script runs. */ const FCNTL_PROBE_SCRIPT = "import fcntl,os,sys\n" + "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + @@ -664,12 +635,9 @@ const FCNTL_PROBE_SCRIPT = " print('free')"; /** - * Anything else — no interpreter on any candidate path, or one that refused to - * run the script (Apple's shim without the developer tools) — is the probe - * being unavailable, not evidence about the lock. That case falls back to "not - * held" rather than "held": reporting every profile as locked forever would - * block Firefox import outright on such machines, and the SQLite snapshot - * already copes with a live database's WAL, as it does for every other engine. + * A missing or refusing interpreter (Apple's shim without developer tools) means + * the probe is unavailable, so report "not held": "held" would block Firefox import + * forever, and the SQLite snapshot already copes with a live WAL. */ const posixLockIsHeld = Effect.fnUntraced(function* ( path: string, @@ -699,12 +667,7 @@ const posixLockIsHeld = Effect.fnUntraced(function* ( return false; }); -/** - * But `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files - * held with fcntl or a Windows handle and are *deliberately left on disk* after - * exit, as a last-used marker; treating them as proof of a running browser - * blocks every import after Firefox has been used once. - */ +/** Firefox leaves regular lock files after exit; test the lock, not file existence. */ const firefoxProfileIsHeld = Effect.fnUntraced(function* ( directory: string, context: BrowserImportPathContext, @@ -739,8 +702,7 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") const fileSystem = yield* FileSystem.FileSystem; const root = definition.userDataDirectory(context); if (root === undefined) return false; - // Safari keeps no lock and writes its jar atomically, so a running instance - // is not a hazard there. + // Safari writes its jar atomically and needs no lock, even while running. if (definition.engine === "safari") return false; if (definition.engine !== "firefox") { if (context.platform === "win32") { @@ -766,18 +728,7 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") return found.some(Boolean); }); -/** - * Keyed off the cookie database rather than the user-data directory, because - * that directory is not evidence the browser exists: installers for native - * messaging hosts create an empty one for every Chromium fork they know about, - * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as - * present. - * - * Existence is checked without opening the file, which matters for Safari: TCC - * permits `stat` on the jar inside its container but refuses a read, so this - * still sees it and the user gets the Full Disk Access prompt rather than - * having Safari disappear. - */ +/** Require a cookie database; installers create empty directories. Safari stat needs no Full Disk Access. */ export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 45d2aeafdbfac..d9b797dd0451f 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -85,8 +85,6 @@ describe("BrowserSession", () => { Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; - // TextEncoder folds a lone surrogate to U+FFFD, so without escaping these - // two supported ids would hash to one partition and share every cookie. const loneSurrogate = yield* browserSessions.getPartition("p\ud800"); const replacementChar = yield* browserSessions.getPartition("p\ufffd"); assert.notStrictEqual(loneSurrogate, replacementChar); @@ -116,13 +114,8 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); - // A rewritten session UA — any variant, even ones that keep the Electron - // token — makes Cloudflare Turnstile loop with error 600010 (#5002), so the - // guest must end up with Electron's native User-Agent. it.effect("keeps the guest's effective User-Agent equal to Electron's native one", () => Effect.gen(function* () { - // Electron's real UA shape: app token, then Chrome, then Electron, then - // Safari — the token order and casing matter to any strip regex. const nativeUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Electron/41.5.0 Safari/537.36"; fromPartition.mockReset(); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 3847ec3323316..0f900d521b739 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -12,37 +12,21 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; -/** - * Incognito partitions deliberately omit the `persist:` prefix, which is what - * makes Chromium keep them in memory and discard them with the process. They - * still carry the product prefix so `isPartition` can admit them — the - * `will-attach-webview` gate rejects anything it does not recognise. - */ +/** Ephemeral partitions omit persist: but retain a product prefix so the webview gate recognizes them. */ const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-"; const PROFILE_PARTITION_MARKER = "profile-"; export type BrowserSessionPartitionNamespace = "profile"; -// `clipboard-sanitized-write` is the Electron permission behind -// `navigator.clipboard.writeText()` — note it is NOT `clipboard-write`, which -// is not a valid Electron permission name. Async clipboard writes are gated by -// the permission *check* handler (not only the request handler), so both -// handlers must allow it; otherwise built-in "Copy" buttons — e.g. the Next.js -// / Vercel error overlay — fail with `Failed to execute 'writeText' on -// 'Clipboard': Write permission denied`. +// Electron's clipboard API uses clipboard-sanitized-write and checks both permission handlers. const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-read", "clipboard-sanitized-write", "geolocation", - // `DesktopWindow` disables HTML fullscreen window resizing for the app window - // and every preview guest, so a page that goes fullscreen fills its own - // webview instead of taking over the whole T3 window. + // DesktopWindow confines HTML fullscreen to the guest's webview. "fullscreen", - // Deliberately NOT local-fonts: preview sessions run untrusted web content, - // and silently granting it would hand every page the user's installed-font - // fingerprint (and font file bytes via FontData.blob()). - // Deliberately NOT notifications: a page could post OS notifications branded - // as T3 Code. T3's own come from the app window on the default session. + // Deny local-fonts to untrusted previews: it exposes installed-font fingerprints and file bytes. + // Deny notifications: pages could impersonate T3 Code; app notifications use the default session. ]); const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet = new Set([ @@ -166,12 +150,7 @@ const selectSessions = ( ([partition]) => partitions === undefined || partitions.includes(partition), ); -/** - * `TextEncoder` replaces a lone UTF-16 surrogate with U+FFFD, so `"p\ud800"` - * and `"p\ufffd"` would hash to the same partition and share cookies. Those - * are distinct, supported ids, so lone surrogates are escaped to `\uXXXX` - * first — and a literal backslash is doubled so the escape cannot be forged. - */ +/** Escape lone surrogates before UTF-8 hashing, which otherwise merges them with U+FFFD; escape backslashes too. */ const encodeScopeForDigest = (scope: string): Uint8Array => new TextEncoder().encode( scope @@ -182,10 +161,7 @@ const encodeScopeForDigest = (scope: string): Uint8Array => ), ); -/** - * A `` guest has no window of its own, so look it up through the page - * that embeds it. - */ +// Webview guests have no window; use their embedding page. const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined => { if (guest === null || guest.isDestroyed()) return undefined; const host = guest.hostWebContents ?? guest; @@ -193,18 +169,14 @@ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined return BrowserWindow.fromWebContents(host) ?? undefined; }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* BrowserSessionMake() { const crypto = yield* Crypto.Crypto; const electronDialog = yield* ElectronDialog.ElectronDialog; const runFork = Effect.runForkWith(yield* Effect.context()); // One prompt at a time, so a page cannot stack dialogs by looping a deep link. let externalPromptOpen = false; - /** - * Electron grants an external-protocol navigation through the `openExternal` - * permission and then launches the OS handler itself. Ask first: previews run - * untrusted pages, and a silent grant would let any of them launch apps. - */ + /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ const confirmOpenExternal = ( requester: WebContents | null, rawUrl: string | undefined, @@ -276,11 +248,7 @@ export const make = Effect.gen(function* BrowserSessionMake() { return Effect.try({ try: () => { const browserSession = session.fromPartition(partition); - // The guest keeps Electron's native User-Agent. Rewriting it in any - // form — even variants that keep the Electron token — makes Cloudflare - // Turnstile fail its integrity check with error 600010 and recreate - // the challenge every few seconds, so logins behind it never complete - // (#5002). + // Rewriting Electron's native User-Agent makes Turnstile fail integrity checks with error 600010. browserSession.setPermissionRequestHandler( (webContents, permission, callback, details) => { if (permission === "openExternal") { diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 2d17a28744545..ec314d1f9d478 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -104,7 +104,6 @@ describe("CDP relay", () => { expect(written).toEqual([]); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); await settle(); - // Electron passes an empty session id for the page's own events. relay.event("Page.loadEventFired", { timestamp: 1 }, ""); relay.event("Runtime.consoleAPICalled", {}, "iframe-session"); expect(written.slice(-2)).toEqual([ diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 581b692882b30..82264cc4b870b 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -1,7 +1,4 @@ -/** - * Electron allows one debugger client per webContents, so the relay shares the - * attach the preview manager already holds. - */ +// Electron allows one debugger per webContents; share the manager's attachment. export interface CdpRelayTarget { readonly send: ( @@ -14,10 +11,7 @@ export interface CdpRelayTarget { readonly url: () => string; readonly title: () => string; readonly userAgent: () => string; - /** - * The desktop app and the server it launched share a disk, so the server's - * Playwright reads the file where it asked for it. - */ + /** The desktop and its server share a disk; download paths need no translation. */ readonly setDownloadDirectory: (directory: string | null) => void; } @@ -56,11 +50,7 @@ export function createCdpRelayConnection( ): CdpRelayConnection { const send = (message: Record) => write(JSON.stringify(message)); let attached = false; - /** - * Sessions this relay invented. Extra page sessions (Playwright's - * `newCDPSession`) all reach the one real debugger session, so each gets the - * page's events; other ids are the page's own child sessions, such as iframes. - */ + /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */ const sessions = new Map([[PAGE_SESSION_ID, "page"]]); let sessionSequence = 0; const newSession = (kind: "browser" | "page") => { @@ -143,10 +133,7 @@ export function createCdpRelayConnection( const pageCommand = (command: CdpCommand): Promise => { const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId; - // The page itself is already running, but its child frames are not: - // Playwright auto-attaches with waitForDebuggerOnStart, so Chromium pauses - // every new cross-site iframe and worker until this reaches it. Swallowing - // it left sign-in checks and captchas, which run in such frames, frozen. + // Child frames/workers pause on auto-attach until resumed; only the main page can skip this command. if (command.method === "Runtime.runIfWaitingForDebugger" && child === undefined) { return Promise.resolve({}); } @@ -167,8 +154,7 @@ export function createCdpRelayConnection( ? browserCommand(command) : pageCommand(command); const route = command.sessionId === undefined ? {} : { sessionId: command.sessionId }; - // Replies leave as commands finish, as Chromium's do, so a slow command - // such as a screenshot never holds up the ones behind it. + // Reply as commands finish so slow commands cannot block later replies. void result.then( (value) => { const announce = diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 7028d16b4be2d..849ae658a8219 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -1,8 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - Names download files on the shared disk. -/** - * The preview manager owns the tab's single debugger session and hands it here; - * the relay shares it. - */ +/** The manager owns the single debugger attachment; the relay shares it. */ import { DesktopBrowserCommand, DesktopBrowserEvent, @@ -156,10 +153,7 @@ export const make = Effect.gen(function* () { return relay; }; - /** - * Without a path Electron would open its Save dialog over the app for a file - * the agent asked for. CDP names the download just before this runs. - */ + // CDP supplies the path before this runs; otherwise Electron opens a Save dialog. const placeDownload = (source: Electron.WebContents, item: Electron.DownloadItem) => { const tab = [...tabs.values()].find( (candidate) => candidate.debuggee.webContents === source && candidate.downloadDirectory, diff --git a/apps/desktop/src/preview/FaviconCapture.ts b/apps/desktop/src/preview/FaviconCapture.ts index a2b36d182a8f3..4716645c9defa 100644 --- a/apps/desktop/src/preview/FaviconCapture.ts +++ b/apps/desktop/src/preview/FaviconCapture.ts @@ -669,8 +669,7 @@ async function rasterizeFavicon( ); if (signal.aborted) onAbort(); }); - // The logical timeout does not cancel Electron's renderer work. Keep the - // gate closed until that physical execution actually settles. + // Logical timeouts leave renderer work running; keep the gate closed until it settles. const launchAllowed = execution.then( () => undefined, () => undefined, diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index 6cb297ff81e90..b531182c92959 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { sendPageCommand, toKeyboardInputEvents } from "./GuestInput.ts"; -/** A guest whose page reports `focusedDocument` for the focus probe. */ const makeGuest = ({ focused, focusedDocument = true, diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index 35006dfcec08b..42201b48ed61c 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -1,26 +1,12 @@ -/** - * Keyboard and text input for a preview tab's page that never lands outside it. - * - * Chromium sends a page's CDP key and text input to whichever frame holds focus - * in the window. A `` guest shares that focus with the app around it, - * so while a person works in the app, `Input.insertText` on the guest's own - * debugger types into the app's focused field. Such input goes through CDP only - * while the guest holds focus. Otherwise it goes straight to the guest's own - * widget, which nothing redirects. - */ +// CDP input follows shared window focus; send directly to unfocused guests to avoid typing into the app. -/** CDP input Chromium delivers to the window's focused frame rather than the page's. */ const FOCUS_ROUTED_INPUT = new Set([ "Input.insertText", "Input.dispatchKeyEvent", "Input.imeSetComposition", ]); -/** - * Page script: the document holding the page's focus, or null when a cross-site - * frame holds it. Electron's direct input reaches only the main frame's process, - * and `insertText` crashes the page when focus is in another one. - */ +/** Direct input reaches only the main-frame process; insertText crashes when a cross-site frame has focus. */ const FOCUSED_DOCUMENT = `(() => { let doc = document; for (;;) { @@ -31,11 +17,7 @@ const FOCUSED_DOCUMENT = `(() => { } })()`; -/** - * macOS editing commands a key press asks for (Cmd+A is `selectAll`), which - * Chromium runs only for CDP keys. Clipboard commands stay out: they would read - * or replace the person's clipboard. - */ +/** Electron runs editing commands only for CDP keys; exclude clipboard commands to preserve the user's clipboard. */ const EDITING_COMMANDS = new Set(["selectAll", "undo", "redo"]); const CROSS_SITE_FOCUS_MESSAGE = @@ -74,18 +56,13 @@ const MODIFIER_BITS = [ [8, "shift"], ] as const; -/** Electron's accelerator name for a DOM key value, or null when it has none. */ const keyCodeFor = (key: string) => { if ([...key].length === 1) return key; if (ARROW_KEYS[key]) return ARROW_KEYS[key]; return NAMED_KEYS.has(key) || /^F(?:[1-9]|1\d|2[0-4])$/u.test(key) ? key : null; }; -/** - * The Electron key events for one CDP `Input.dispatchKeyEvent`. Electron - * derives key, code, and a char's text from `keyCode`, so typed text travels - * as `char` events of its own. - */ +/** Electron derives text from keyCode, so typed text needs separate char events. */ export const toKeyboardInputEvents = ( params: Record, ): Array => { diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6ded53bfaf1ba..85fa6115477a0 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -80,8 +80,6 @@ describe("previewWindowOpenAction", () => { }); it("opens a real window for scripted popups so the opener survives", () => { - // OAuth SDKs read a null `window.open()` as a blocked popup, and they need - // the opener alive to receive the credential back. expect(PreviewManager.previewWindowOpenAction(details({}))).toBe("popup"); expect( PreviewManager.previewWindowOpenAction(details({ url: "http://localhost:5173/auth" })), @@ -89,7 +87,6 @@ describe("previewWindowOpenAction", () => { }); it("keeps a form POST with a body on the in-place path", () => { - // A new tab can only reopen the URL as a GET, which would drop the body. const postBody = { data: [], contentType: "application/x-www-form-urlencoded" }; expect( PreviewManager.previewWindowOpenAction({ @@ -100,9 +97,6 @@ describe("previewWindowOpenAction", () => { }); it("does not hand a window to schemes that cannot be hardened", () => { - // A popup skips the `will-attach-webview` hardening, so it only gets a window - // when its preferences can be overridden. Chromium copies the guest's - // preferences for `about:blank` and forbids overriding them. for (const url of [ "javascript:alert(1)", "file:///etc/passwd", @@ -114,8 +108,6 @@ describe("previewWindowOpenAction", () => { }); it("denies a blank window instead of blanking the opener", () => { - // MSAL opens `about:blank` first and falls back to a redirect on `null`; - // loading the blank page into the tab would destroy that fallback. for (const url of ["about:blank", ""]) { for (const disposition of ["new-window", "foreground-tab"] as const) { expect(PreviewManager.previewWindowOpenAction(details({ url, disposition }))).toBe("deny"); @@ -249,7 +241,6 @@ const withManager = ( interface TestCapturedPreviewImage { readonly toJPEG: () => Buffer; - /** Only fixtures that deliver picture-in-picture frames need pixels to fingerprint. */ readonly toBitmap?: () => Buffer; readonly getSize: () => { readonly width: number; readonly height: number }; } @@ -608,7 +599,6 @@ describe("PreviewManager", () => { const older = makeFaviconWebContents({ id: 42 }); const newer = makeFaviconWebContents({ id: 43 }); for (const tab of [older, newer]) Object.assign(tab.webContents, { hostWebContents: host }); - // Electron names one arbitrary guest here when several share a focused window. getFocusedWebContents.mockReturnValue(newer.webContents as never); fromId.mockReturnValue(older.webContents); yield* manager.createTab("tab_older"); @@ -634,7 +624,6 @@ describe("PreviewManager", () => { expect(preventDefault).toHaveBeenCalledOnce(); expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", "view.reopenClosed"); - // The person types in the older tab, so keys reaching the newer one are an agent's. Object.assign(newer.webContents, { focusedFrame: null }); const agentInput = newer.listeners.get("before-input-event")!; const agentPreventDefault = vi.fn(); @@ -770,9 +759,6 @@ describe("PreviewManager", () => { effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () => withManager((manager) => Effect.gen(function* () { - // Real Electron throws on any `wc.debugger` access once the - // WebContents is destroyed, so cleanup must go through the debugger - // reference captured at attach time (electron/electron#53376). let destroyed = false; let attached = false; const debuggerOff = vi.fn(); @@ -1061,7 +1047,6 @@ describe("PreviewManager", () => { id, isDestroyed: () => destroyed, isDevToolsOpened: () => { - // Electron throws from native methods once a WebContents is destroyed. if (destroyed) throw new TypeError("Object has been destroyed"); return false; }, @@ -1173,11 +1158,7 @@ describe("PreviewManager", () => { audible = next; listeners.get("audio-state-changed")?.({ audible: next } as never); }, - /** - * Starts playing between the attach-time read and the post-attach - * reconcile, without a delivered event — the window in which - * audio-state-changed fires against a guest the tab does not own yet. - */ + /** Simulate playback starting during attach before the tab owns the guest and can receive its events. */ startPlayingAfterFirstRead: () => { audibleAfterFirstRead = true; }, @@ -1237,9 +1218,6 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_audio_window", 42); yield* Effect.yieldNow; - // audio-state-changed for this transition was dropped: it fired before - // the tab owned the guest. Without a post-attach reconcile the icon - // stays wrong until the next real transition, which may never come. expect(states.at(-1)?.audible).toBe(true); }), ), @@ -1369,7 +1347,6 @@ describe("PreviewManager", () => { fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); - // it.effect keeps TestClock fixed until explicitly advanced. const first = yield* manager.captureScreenshot("tab_1"); const second = yield* manager.captureScreenshot("tab_1"); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index e9578c711ae40..b9e0192ca8a7d 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1,10 +1,3 @@ -/** - * Desktop side of the in-app browser preview. - * - * Hosts per-tab Chromium WebContents references (the actual - * elements live in the renderer; we only attach listeners and forward state - * here). Single layer-scoped browser session partition. - */ import * as NodeZlib from "node:zlib"; import { DesktopPreviewRecordingInputSchema, @@ -123,10 +116,7 @@ const ZOOM_EPSILON = 0.001; 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. - */ +// Cold guests can reject capturePage with UnknownVizError or stall; bound each attempt. const CAPTURE_PAGE_RETRY_ATTEMPTS = 3; const CAPTURE_PAGE_RETRY_DELAY_MS = 120; const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000; @@ -325,22 +315,12 @@ const cropAnnotationScreenshot = ( return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect }; }; -/** - * Electron converts `toBitmap()` output to sRGB by default. Captures on P3 Macs are - * P3, so that conversion costs more per tick than the JPEG encode the fingerprint - * replaces. An invalid target color space has no Skia color space, so `readPixels` - * copies the pixels without a color transform. The fingerprint only compares frames - * with each other, so color accuracy is irrelevant. - */ +// An invalid color space skips costly P3-to-sRGB conversion; fingerprints need no color accuracy. const FINGERPRINT_BITMAP_OPTIONS: Electron.ToBitmapOptions = { colorSpace: { primaries: "invalid", transfer: "invalid", matrix: "invalid", range: "invalid" }, }; -/** - * Identity of a captured frame: cheap enough to compute every tick, unlike a JPEG - * encode. Null when the pixels could not be read (Electron reports a failed - * `readPixels` as an empty buffer), so the caller treats the frame as changed. - */ +/** Empty readPixels results have no fingerprint and must be treated as changed frames. */ const fingerprintCapturedFrame = (image: Electron.NativeImage, size: Electron.Size) => { let bitmap: Buffer; try { @@ -421,10 +401,7 @@ interface PickSession { interface BrowserControlSession { readonly webContentsId: number; - // Electron's Debugger is GC-managed but registered with Chromium as a raw - // DevToolsAgentHostClient pointer; collecting it while attached crashes the - // browser process (electron/electron#53376). Detach must also go through - // this reference: `wc.debugger` throws once the WebContents is destroyed. + // Retain the attached Debugger to prevent GC crashes (electron/electron#53376); wc.debugger throws after destruction. readonly debugger: Electron.Debugger; readonly semaphore: Semaphore.Semaphore; readonly scope: Scope.Closeable; @@ -443,13 +420,7 @@ type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; -/** - * `about:blank` stays out: Chromium skips browser-side navigation for it, so the child copies - * the guest's `contextIsolation: false` preferences and Electron gives no way to override them. - * Deliberately not `ElectronShell.parseSafeExternalUrl`: that also admits - * `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal` and not in a - * window spawned by a third-party page in the preview. - */ +// about:blank inherits relaxed guest preferences; external editor schemes must reach the OS handler. const POPUP_PROTOCOLS = new Set(["http:", "https:"]); const isPopupUrl = (rawUrl: string): boolean => { @@ -460,12 +431,7 @@ const isPopupUrl = (rawUrl: string): boolean => { } }; -/** - * A popup is not a webview attach, so the `will-attach-webview` hardening in `DesktopWindow` - * never sees it, and an unoverridden child would inherit the guest's relaxed posture: the - * picker preload needs `contextIsolation: false` to share `globalThis` with the previewed page, - * and no OAuth provider should get that. - */ +/** Popups bypass will-attach-webview hardening and would inherit the picker's contextIsolation:false. */ const POPUP_WINDOW_OPTIONS = { webPreferences: { contextIsolation: true, @@ -474,18 +440,13 @@ const POPUP_WINDOW_OPTIONS = { }, } satisfies Electron.BrowserWindowConstructorOptions; -/** - * `"popup"` opens a real window, which scripted popups need: denying them makes `window.open()` - * return `null` (OAuth SDKs report that as a blocked popup), and navigating the preview tab - * instead destroys the opener the popup has to `postMessage` its result back to. Schemes a - * popup cannot be hardened for keep loading in place, and so does a form POST with a body, - * which a new tab could only reopen as a GET. - */ +/** OAuth needs a surviving popup opener; unhardenable schemes and form POSTs must stay in place. */ export const previewWindowOpenAction = (details: { readonly url: string; readonly disposition: Electron.HandlerDetails["disposition"]; readonly postBody?: Electron.PostBody; }): "popup" | "new-tab" | "navigate" | "deny" => { + // MSAL redirects on a denied blank popup; navigating the tab would destroy that fallback. if (details.url === "" || details.url === "about:blank") return "deny"; if (!isPopupUrl(details.url)) return "navigate"; if (details.disposition === "new-window") return "popup"; @@ -590,9 +551,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const pictureInPictureAspectRatiosRef = yield* Ref.make>(new Map()); const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); const closingTabIdsRef = yield* Ref.make>(new Set()); - // Tab recording uses `setDisplayMediaRequestHandler` because Electron's legacy - // `getMediaSourceId` + `chromeMediaSource: "tab"` capture path was removed upstream - // (electron#44618) and now always rejects with NotAllowedError. + // Legacy tab capture is unsupported (electron/electron#44618); recording uses setDisplayMediaRequestHandler. let pendingRecording: PendingRecording | null = null; const displayMediaHandlerSessions = new WeakSet(); let frameCaptureWindowOpen = true; @@ -897,10 +856,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ] as const; }); - // emitIfCurrent, not emit: an event-driven writer such as syncTabAudible - // can commit between the modify above and here, and republishing this - // snapshot would roll the UI back to a value that writer will not send - // again because it suppresses unchanged audibility. + // syncTabAudible can commit after this update; emitIfCurrent prevents publishing a stale snapshot. if (Option.isSome(next)) { if (patch.controller !== undefined && next.value.webContentsId != null) { const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); @@ -917,11 +873,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }); - /** - * Anything that applies zoom after an await goes through here: a snapshot taken before the - * await can be older than a zoom action that landed in between, and re-applying it would roll - * that action back. - */ + /** Read zoom after awaits so concurrent zoom actions cannot be rolled back by an older snapshot. */ const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab || tab.webContentsId == null) return; @@ -932,11 +884,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ).pipe(Effect.ignore); }); - /** - * Mute counterpart to {@link assertTabZoom}: pushes the tab's committed mute - * onto whichever guest it currently owns, reading both at call time so an - * older snapshot can never roll back a mute action that landed after it. - */ + /** Read committed mute and current guest at call time so stale snapshots cannot roll back newer actions. */ const assertTabAudioMuted = Effect.fn("PreviewManager.assertTabAudioMuted")(function* ( tabId: string, ) { @@ -1098,8 +1046,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const scope = yield* Scope.fork(parentScope, "sequential"); const wcDebugger = wc.debugger; const consoleReleases = yield* Queue.sliding(1); - // Console message eviction does not release the debugger's strong object handles. We - // keep text only, so release the whole group, including its object-id bookkeeping. + // Console eviction leaves strong debugger handles; release the group because we keep only text. yield* Effect.forkIn( Effect.forever( Queue.take(consoleReleases).pipe( @@ -1198,9 +1145,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wcDebugger.on("detach", onDetach); wcDebugger.attach("1.3"); }); - // Without this, dark-scheme pages with no background of their own (text/plain, e.g. - // .md files) render white text on white. Sent first because a document that paints - // before it arrives keeps the transparent base until its next load. + // Set an opaque base before first paint or background-less dark pages retain white-on-white text. yield* attemptPromise( { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id }, () => @@ -1250,9 +1195,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const url = wc.getURL(); const title = wc.getTitle(); if (url === "" || url === "about:blank") return { kind: "Idle" }; - // Main frame only. `isLoading()` covers the whole frame tree, so a - // cross-origin iframe that loads after the page can leave it true with no - // later event to clear it, and the tab's loading bar never finishes. + // isLoading() includes late iframes whose completion may never clear the tab's loading bar. if (wc.isLoadingMainFrame()) return { kind: "Loading", url, title }; return { kind: "Success", url, title }; }; @@ -1291,9 +1234,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc) { return [Option.none(), tabs] as const; } - // Electron emits did-stop-loading after did-fail-load. At that point the - // failed guest is no longer "loading", but it has not successfully - // navigated anywhere. + // did-stop-loading follows did-fail-load without proving successful navigation. const navStatus = preserveLoadFailure && current.navStatus.kind === "LoadFailed" && @@ -1311,9 +1252,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus, canGoBack, canGoForward, - // zoomFactor is deliberately not read back from the guest: Chromium - // reports the level it inherited from the app window, so mirroring it - // would turn an app zoom into the preview's own zoom. + // Guest zoom inherits app zoom; mirroring it would overwrite the tab's own setting. updatedAt, }; return [ @@ -1517,8 +1456,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }).pipe(Effect.ignore), ); }; - // Not getFocusedWebContents(): every guest of a focused window reports focus. - // An agent's keys reach a guest without a focused frame through GuestInput. + // Every guest of a focused window reports focus; GuestInput handles guests without a focused frame. const holdsKeyboardFocus = (contents: Electron.WebContents) => contents.isFocused() && contents.focusedFrame !== null; const syncMenuShortcuts = (contents: Electron.WebContents, input: Electron.Input): void => { @@ -1527,9 +1465,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function !isPreviewEditingShortcut(input, hostPlatform) || !holdsKeyboardFocus(contents), ); }; - // 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. + // Popups inherit Electron's unrestricted window handler; block nested popups to bound the chain. const windowCreated = (window: Electron.BrowserWindow): void => { window.webContents.setIgnoreMenuShortcuts(true); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); @@ -1872,15 +1808,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { return yield* new PreviewTabNotFoundError({ tabId }); } - // Always assert the tab's own zoom rather than reading the guest's: a guest - // attaching while the app UI is zoomed starts at the embedder's inherited - // zoom level, which is not the preview's zoom. Done before the guest is - // published so it never paints a frame at the inherited zoom. + // Restore the tab's zoom before publishing the guest so it never paints at inherited app zoom. yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => wc.setZoomFactor(currentTab.zoomFactor), ); - // A replacement guest attaches unmuted, so reassert the tab's mute before it is published - // rather than letting it emit audio the user already silenced. + // Replacement guests start unmuted; restore mute before publishing them. yield* attempt({ operation: "registerWebview.restoreAudioMuted", tabId, webContentsId }, () => wc.setAudioMuted(currentTab.audioMuted), ); @@ -1934,16 +1866,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; - // A zoom or mute action that landed while this attach was in flight - // addressed the guest this one replaced, so settle the new guest on the - // committed values. + // Concurrent actions targeted the previous guest; reconcile this one with committed zoom and mute. yield* assertTabZoom(tabId); yield* assertTabAudioMuted(tabId).pipe(Effect.ignore); runFork(restoreControlSession(tabId, wc)); yield* emitIfCurrent(tabId, registered); - // Transitions that fired before the tab owned this guest were dropped by - // syncTabAudible's ownership check, so re-read and reconcile through the - // same path the event uses. + // Ownership checks dropped pre-attach audibility events; reconcile through the event path. yield* syncTabAudible(tabId, wc, yield* readAudible); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => { wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme); @@ -1975,9 +1903,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Called when a guest attaches to the window, before its first document paints. A tab opened - // straight to a URL often paints before the renderer gets to registerWebview, which would - // leave that page on the transparent base. + // URL tabs can paint before registerWebview; set the background at attach time. const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( wc: Electron.WebContents, ) { @@ -2016,10 +1942,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", - // Mute is user intent, and the old document keeps playing until loadURL - // actually replaces it, so clearing audibility here would drop the - // speaker with no transition left to restore it. Chromium reports the - // change when it happens. + // Keep audibility until Chromium replaces the document; clearing it here can lose the final transition. audioMuted: current?.audioMuted ?? false, audible: current?.audible ?? false, controller: current?.controller ?? "none", @@ -2177,8 +2100,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); }).pipe(Effect.ignore); - // Only drop the slot while it is still ours. A newer session may - // already have swapped itself in before cancelling this one. + // A newer session may already own the slot when this one is cancelled. yield* Ref.update(pickSessionsRef, (sessions) => sessions.get(tabId) === session ? replaceMap(sessions, (copy) => { @@ -2270,9 +2192,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function : { annotation: { ...payload, screenshot }, submission }, }), Effect.flatMap((result) => { - // A capture that outlives its session must not touch the - // overlay: the preload tears down on the captured signal, and - // by now it may be running a newer pick. + // Late captures must not signal an overlay that may now belong to a newer pick. if (!claimSettle()) return Effect.void; return attempt( { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, @@ -2291,10 +2211,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (event.isMainFrame) settle(null); }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { - // Two picks on one tab can overlap. Swap this session in and cancel - // the previous holder in one step, so no third pick can slip into an - // empty slot in between and the session we push out still resumes - // its renderer. + // Atomically swap/cancel overlapping picks so another pick cannot claim a transient empty slot. const replaced = yield* Ref.modify(pickSessionsRef, (sessions) => [ sessions.get(tabId) ?? null, replaceMap(sessions, (copy) => { @@ -2302,9 +2219,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ]); if (replaced) yield* replaced.cancel; - // A newer pick may have cancelled this session while the previous - // one was torn down. Cleanup already ran, so attaching listeners now - // would leak them and start an overlay nobody is waiting on. + // A newer pick may cancel during teardown; do not leak listeners or start an abandoned overlay. if (settled) return; yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); @@ -2352,9 +2267,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* update(tabId, { zoomFactor: next }); }); - // Emulated media lives on the CDP debugger session, not the WebContents, so - // it is lost whenever the session detaches (webview swap, DevTools - // open/close) and must be re-applied after every (re)attach. + // Media emulation belongs to the debugger session and must be restored after every reattach. const applyColorScheme = Effect.fn("PreviewManager.applyColorScheme")(function* ( tabId: string, wc: Electron.WebContents, @@ -2440,8 +2353,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (tab.colorScheme !== colorScheme) { yield* update(tabId, { colorScheme }); } - // Re-read after the update: registerWebview may have swapped the guest - // in the meantime and the override must land on the current one. + // registerWebview may swap guests during the update; apply to the current one. const webContentsId = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.webContentsId; if (webContentsId == null) return; const wc = webContents.fromId(webContentsId); @@ -2457,10 +2369,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!tab) { return yield* new PreviewTabNotFoundError({ tabId }); } - // Commit and apply under the tab's lifecycle lock, then assert the - // committed value rather than this call's argument. Two overlapping toggles - // would otherwise be free to commit in one order and reach Chromium in the - // other, leaving the icon disagreeing with the guest. + // Serialize commit/apply, then read committed mute so concurrent toggles cannot reach Chromium out of order. yield* withTabLifecycleLock( tabId, Effect.gen(function* () { @@ -2469,8 +2378,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (committed) { yield* update(tabId, { audioMuted }); } - // Roll the commit back if Chromium refused: reporting success here - // would leave the tab drawn as muted while it keeps playing. + // Roll back rejected mute commits so the UI reflects actual playback. yield* assertTabAudioMuted(tabId).pipe( Effect.tapError(() => committed ? update(tabId, { audioMuted: previous }) : Effect.void, @@ -2582,9 +2490,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const frameSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); if (frameSession?.scope !== captureSession.scope) return; - // Fingerprint the raw pixels before encoding so an unchanged page skips the - // synchronous JPEG encode, the base64 copy and the IPC send. An unreadable - // frame has no fingerprint and is always delivered. + // Fingerprint before encoding so unchanged frames skip JPEG, base64, and IPC; unreadable frames must still be sent. const fingerprint = fingerprintCapturedFrame(image, size); const pictureInPicture = frameSession.consumers.has("picture-in-picture") && @@ -2939,8 +2845,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (hostPlatform === "darwin") { pictureInPictureWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, - // Electron otherwise temporarily transforms the entire app into - // a UIElement process, which removes the owning app from the Dock. + // Electron's temporary UIElement transformation would remove the app from the Dock. skipTransformProcessType: true, }); } @@ -3075,10 +2980,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (pendingRecording?.tabId === tabId) pendingRecording = null; }; - /** - * A display-media request carries no tab identity, so the slot is exclusive: a second tab - * arming before the first request lands would redirect the first renderer's stream. - */ + /** Display-media requests lack tab identity; only one tab may arm a recording to prevent stream redirection. */ const armPendingRecording = Effect.fn("PreviewManager.armPendingRecording")(function* ( tabId: string, wc: Electron.WebContents, @@ -3105,8 +3007,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function armedAtMillis: now, }; pendingRecording = armed; - // The handler callback is sync and cannot read a clock, so expiry is driven from here. - // Identity compare: a re-arm replaces the object, and this fiber must not clobber it. + // The synchronous callback cannot read a clock. Compare identity so expiry cannot clobber a re-arm. yield* Effect.forkIn( Effect.sleep(RECORDING_ARM_GRACE_MS).pipe( Effect.andThen( @@ -3119,8 +3020,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Installed once per session: answers the renderer's `getDisplayMedia()` with the tab that - // `startRecording` armed, and denies anything else so pages cannot capture on their own. + // Only serve the tab armed by startRecording; pages must not capture independently. const installDisplayMediaRequestHandler = (session: Session) => { if (displayMediaHandlerSessions.has(session)) return; displayMediaHandlerSessions.add(session); @@ -3679,7 +3579,7 @@ export class PreviewManager extends Context.Service< } >()("@t3tools/desktop/preview/Manager/PreviewManager") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; diff --git a/apps/desktop/src/preview/PasskeyBackendMac.ts b/apps/desktop/src/preview/PasskeyBackendMac.ts index ccdffa2a70c08..91cefaddc42f1 100644 --- a/apps/desktop/src/preview/PasskeyBackendMac.ts +++ b/apps/desktop/src/preview/PasskeyBackendMac.ts @@ -6,8 +6,7 @@ import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; const ES256 = -7; -// WebAuthn rejects RP IDs that are public suffixes, private registries -// included, so one github.io site cannot mint passkeys for every other one. +// WebAuthn rejects public suffix RP IDs, including private registries such as github.io. const isPublicSuffix = (domain: string) => domain !== "localhost" && getPublicSuffix(domain, { allowPrivateDomains: true }) === domain; @@ -32,13 +31,10 @@ const toBase64Url = (value: BufferSource) => : new Uint8Array(value), ).toString("base64url"); -/** - * Needs Apple's managed browser entitlement in the signed build. - */ +/** Requires Apple's managed browser entitlement in the signed build. */ export const macPasskeyBackend: PasskeyBackend = { create: async (publicKey, context): Promise => { - // The native layer only converts P-256 keys and never settles for any - // other algorithm, so ES256 is the only one it may negotiate. + // The native layer only converts P-256 keys, so negotiate only ES256. const params: unknown = publicKey.pubKeyCredParams; if (params !== undefined && !Array.isArray(params)) return { success: false, error: "TypeError" }; @@ -56,8 +52,7 @@ export const macPasskeyBackend: PasskeyBackend = { options, ); if (!result.success) return { success: false, error: result.error }; - // The native layer reports parsed authenticator data as JSON and claims - // every credential is a synced platform passkey; neither is reliable. + // Native parsed authenticator JSON and synced-platform-passkey claims are unreliable. const authData = authenticatorDataFromAttestation( Buffer.from(result.data.attestationObject, "base64url"), ); @@ -79,8 +74,7 @@ export const macPasskeyBackend: PasskeyBackend = { options, ); if (!result.success) return { success: false, error: result.error }; - // The native layer applies the site's allow list to platform passkeys only, - // so a security key can answer with a credential the site did not ask for. + // Native allow lists cover only platform passkeys; security-key responses need checking here. const allowed = (Array.isArray(publicKey.allowCredentials) ? publicKey.allowCredentials : []) .filter((credential) => credential?.type === "public-key" && isBufferSource(credential.id)) .map((credential) => toBase64Url(credential.id)); diff --git a/apps/desktop/src/preview/PasskeyBridge.test.ts b/apps/desktop/src/preview/PasskeyBridge.test.ts index 3b72ab5890757..998a9298baf3c 100644 --- a/apps/desktop/src/preview/PasskeyBridge.test.ts +++ b/apps/desktop/src/preview/PasskeyBridge.test.ts @@ -27,10 +27,6 @@ const illegalInvocation = (): never => { throw new TypeError("Illegal invocation"); }; -/** - * Installs the bridge over fresh stand-ins for the page's DOM classes. Like - * Chromium's, their getters only work on objects the browser created itself. - */ const install = (respond: (channel: string) => unknown) => { const nativeCreate = vi.fn(async () => "native-create"); const nativeGet = vi.fn(async () => "native-get"); diff --git a/apps/desktop/src/preview/PasskeyBridge.ts b/apps/desktop/src/preview/PasskeyBridge.ts index fbb9fefa4aa5f..bc77b38c1cf0a 100644 --- a/apps/desktop/src/preview/PasskeyBridge.ts +++ b/apps/desktop/src/preview/PasskeyBridge.ts @@ -44,10 +44,7 @@ const ceremonyError = (name: string) => DOM_EXCEPTION_NAMES.has(name) ? name : "NotAllowedError", ); -/** - * Chromium ignores members it does not know, so functions and other uncloneable - * values drop out here instead of failing the whole ceremony. - */ +// Chromium drops unknown members; discard uncloneable values instead of failing the ceremony. const toCloneable = (value: unknown): unknown => { if (value instanceof ArrayBuffer) return value.slice(0); if (ArrayBuffer.isView(value)) { @@ -64,9 +61,7 @@ const toCloneable = (value: unknown): unknown => { return value; }; -// The objects below borrow the native prototypes so `instanceof` checks pass. -// Their own properties shadow the native getters, which only work on objects -// Chromium created itself. +// Shadow native getters: they reject objects Chromium did not create, even with the correct prototype. const withValues = (target: T, values: Record) => { for (const [key, value] of Object.entries(values)) { Object.defineProperty(target, key, { value, enumerable: true, configurable: true }); @@ -202,10 +197,7 @@ const credentialFromGetResult = (data: Extract signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); -/** - * Conditional (autofill and automatic upgrade) requests stay native: the system - * sheet is modal and must not open on its own. - */ +/** Keep conditional requests native so autofill and upgrades cannot open a modal sheet unprompted. */ export function installPasskeyBridge(invoke: InvokePasskeyCeremony) { if (typeof CredentialsContainer === "undefined" || typeof PublicKeyCredential === "undefined") { return; diff --git a/apps/desktop/src/preview/Passkeys.test.ts b/apps/desktop/src/preview/Passkeys.test.ts index f848aff47b8f9..219c67fac5061 100644 --- a/apps/desktop/src/preview/Passkeys.test.ts +++ b/apps/desktop/src/preview/Passkeys.test.ts @@ -120,7 +120,6 @@ describe("PreviewPasskeys", () => { assert.isFalse(options.isPublicSuffix("example.com")); assert.isFalse(options.isPublicSuffix("localhost")); - // WebAuthn defaults the RP ID to the caller's own host. yield* call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]) }); assert.strictEqual(webauthn.getCredential.mock.calls[1]?.[0].rpId, "accounts.example.com"); @@ -218,7 +217,6 @@ describe("PreviewPasskeys", () => { allowCredentials: [{ type: "public-key", id: new Uint8Array([1]) }], }; - // macOS applies the allow list to passkeys but not to security keys. webauthn.getCredential.mockResolvedValueOnce(assertion("Ag")); const other = yield* call(PASSKEY_GET_CHANNEL, publicKey); assert.deepStrictEqual(other, { success: false, error: "NotAllowedError" }); @@ -244,7 +242,6 @@ describe("PreviewPasskeys", () => { const lan = makeGuest("http://192.168.1.5:3000"); passkeys.attachGuest(lan.guest); assert.deepStrictEqual(yield* lan.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); - // macOS refuses `*.localhost`, so the page hears no rather than a native error. const subdomain = makeGuest("http://app.localhost:3000"); passkeys.attachGuest(subdomain.guest); assert.deepStrictEqual(yield* subdomain.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); diff --git a/apps/desktop/src/preview/Passkeys.ts b/apps/desktop/src/preview/Passkeys.ts index 5456aaab6a8a2..34db1404aacb8 100644 --- a/apps/desktop/src/preview/Passkeys.ts +++ b/apps/desktop/src/preview/Passkeys.ts @@ -84,8 +84,7 @@ const chooseAccount = async (details: Electron.SelectWebauthnAccountDetails) => const NATIVE_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; const NATIVE_MAX_TIMEOUT_MS = 60 * 60 * 1000; -// Mirrors the native layer's own clamp, plus a margin: it can leave a ceremony -// unsettled, and the page must still get an answer. +// Native ceremonies can remain unsettled; match its deadline clamp with a margin. const ceremonyDeadline = (timeout: unknown) => (typeof timeout === "number" && timeout > 0 ? Math.min(timeout, NATIVE_MAX_TIMEOUT_MS) @@ -99,9 +98,7 @@ const withDeadline = (ceremony: Promise, milliseconds: nu return Promise.race([ceremony, deadline]).finally(() => clearTimeout(timer)); }; -// Secure contexts only: https, or http on this machine. Chromium also trusts -// `*.localhost`, but the macOS layer refuses it, so it is turned away here -// instead of failing deeper with a less useful error. +// macOS refuses *.localhost even though Chromium trusts it. const isTrustworthyOrigin = (origin: string) => { if (!URL.canParse(origin)) return false; const { protocol, hostname } = new URL(origin); @@ -112,9 +109,7 @@ const isTrustworthyOrigin = (origin: string) => { ); }; -/** - * Windows needs none: Chromium already hands WebAuthn to Windows Hello there. - */ +// Chromium already delegates to Windows Hello. const backendFor = (platform: NodeJS.Platform): PasskeyBackend | undefined => platform === "darwin" ? macPasskeyBackend : undefined; @@ -168,9 +163,7 @@ const make = Effect.gen(function* () { // Only the guest's main frame runs the bridge preload. const frame = event.senderFrame; if (!frame || frame !== guest.mainFrame) return notAllowed; - // The page shares a JS world with the preload, so nothing it sends can - // be trusted for the origin. Read the committed origin before any await: - // the frame object outlives a navigation. + // The page shares the preload's JS world; read the frame's committed origin before any await/navigation. const origin = frame.origin; if (!isTrustworthyOrigin(origin) || ceremonyPending || !guest.isFocused()) { return notAllowed; diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 4059468da22e9..4cb5987cdeb85 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -197,11 +197,7 @@ const navigationDirectionForButton = (button: number): "back" | "forward" | null return null; }; -// Chromium routes thumb-button history navigation to the *focused* WebContents, -// so hovering this guest without focusing it sends the host app's router back -// instead of the preview. Suppress Chromium's default here and drive this tab's -// history explicitly so the buttons always navigate the browser the pointer is -// over — never the host app. +// Chromium routes thumb-button navigation to the focused WebContents; navigate this hovered guest explicitly. const suppressNavigationButton = (event: MouseEvent): void => { if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; event.preventDefault(); @@ -391,11 +387,7 @@ function toStackFrame(frame: { }; } -/** - * `getElementContext` walks the inspected page's React internals, and some pages leave it - * pending forever. Without a bound, the whole submit chain stalls and the overlay sits on - * "Capturing…". - */ +/** React-internals lookups can remain pending forever; bound them so submit cannot stall the overlay. */ function withCaptureTimeout(promise: Promise, millis: number): Promise { let timer: ReturnType | undefined; return Promise.race([ @@ -408,11 +400,7 @@ function withCaptureTimeout(promise: Promise, millis: number): Promise { const base = { pageUrl: location.href, @@ -1399,8 +1387,7 @@ function startAnnotation(sendEnabled: boolean): void { pendingCapture = true; submit.disabled = true; submit.textContent = "Capturing…"; - // The element context lookup can take up to its timeout, and the user can - // keep editing meanwhile; the annotation must describe what they submitted. + // Snapshot the submitted comment before context lookup lets the user edit it. const submittedComment = comment.value.trim(); const submittedRegions = [...regions]; const submittedStrokes = [...strokes]; @@ -1421,8 +1408,7 @@ function startAnnotation(sendEnabled: boolean): void { }), ) .then((elements) => { - // The overlay may have been cancelled or replaced while the capture - // ran. A late submit must not deliver into the next pick's listener. + // A late submit must not reach a replacement pick's listener. if (finished) return; const annotation: PreviewAnnotationPayload = { id: nextId("annotation"), @@ -1461,8 +1447,7 @@ function startAnnotation(sendEnabled: boolean): void { root.addEventListener("keydown", (event) => { const submission = event.target === comment ? resolveAnnotationSubmission(event, sendEnabled) : null; - // Keep this in the bubble phase so editor inputs receive the event before - // it is isolated from listeners installed by the inspected page. + // Bubble phase lets editor inputs handle events before hiding them from the page. event.stopImmediatePropagation(); if (!submission) return; event.preventDefault(); diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts index 4524b0d017b61..5b9fb3c13bc37 100644 --- a/apps/desktop/src/preview/RecordingCursor.ts +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -11,11 +11,7 @@ import { type RecordingKeyPress, } from "./RecordingInput.ts"; -/** - * Chromium's capture cursor uses native window bounds, which do not follow a - * webview's CSS placement or scale. Draw it in the guest's coordinate space - * while recording, and make the native cursor transparent to avoid two cursors. - */ +/** Draw in guest coordinates; Chromium's native cursor ignores webview CSS placement/scale. Hide it to avoid duplicates. */ export function installRecordingCursor( document: Document, window: Window, diff --git a/apps/desktop/src/preview/WebviewPreferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts index 182b6a505d166..572a290e2b669 100644 --- a/apps/desktop/src/preview/WebviewPreferences.test.ts +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -6,7 +6,6 @@ function parseWebPreferences(input: string): Record { const out: Record = {}; for (const pair of input.split(",")) { if (pair !== pair.trim()) { - // Electron's parser doesn't trim; surface the bug as undefined-key. out[pair] = pair.split("=")[1]; continue; } @@ -21,9 +20,6 @@ describe("PREVIEW_WEBVIEW_PREFERENCES", () => { const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { - // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so - // `contextIsolation="no"` would silently leave isolation ENABLED. Lock - // the values to `"true"` / `"false"` so the parser does the right thing. for (const value of Object.values(parsed)) { expect(value).toMatch(/^(true|false)$/); } diff --git a/apps/desktop/src/preview/WebviewPreferences.ts b/apps/desktop/src/preview/WebviewPreferences.ts index 4236b78293ab1..4dc9d9aa6b732 100644 --- a/apps/desktop/src/preview/WebviewPreferences.ts +++ b/apps/desktop/src/preview/WebviewPreferences.ts @@ -1,9 +1,3 @@ -/** - * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical - * when paired with `contextIsolation=false` — without sandbox, the preload - * has full Node access (`require`, `fs`, `child_process`, ...) and that - * `require` would land on the page's shared `globalThis`, giving any - * third-party page in the preview full Node + IPC access to the host. - */ +/** sandbox:true is required with contextIsolation:false or the shared page global would expose Node and IPC. */ export const PREVIEW_WEBVIEW_PREFERENCES = "contextIsolation=false,sandbox=true,nodeIntegration=false"; diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 1c131681295fa..433e8127a5098 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -37,8 +37,7 @@ export interface DesktopSettings { readonly updateChannelConfiguredByUser: boolean; readonly wslBackendEnabled: boolean; readonly wslDistro: string | null; - // Changing this requires a desktop restart because the pool's primary spec is - // chosen once at layer init. + // Requires restart because the pool chooses its primary spec at layer initialization. readonly wslOnly: boolean; } @@ -466,7 +465,6 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp }); /** @public */ -/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 248fb9b6f8482..cccc0585024af 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -221,7 +221,7 @@ function decodeSecretBytes( ); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 3fdec7932366b..c80fe9b87cd6a 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -360,11 +360,6 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin function* ( config: ShellEnvironmentConfig, ): Effect.fn.Return { - // Concurrent, not sequential: these two probes are independent (only their - // results are combined below) and each spawns its own PowerShell. Run in - // series they sit at offset 0 of desktop.startup, before anything else, and - // launch traces measured them at 2718ms then 2066ms — the entire 4.8s - // startup span, of which desktop.bootstrap is ~30ms. const [noProfile, profile] = yield* Effect.all( [ readWindowsEnvironment(["PATH"], { loadProfile: false }), @@ -432,9 +427,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "XDG_SESSION_TYPE", - // mise's record of the PATH entries it added. It must travel with the - // shell's PATH, or `mise x` can rebuild PATH behind a same-named wrapper - // (`exec mise x claude -- claude`) that then re-runs itself forever. + // Forward mise's PATH delta too, or mise x can rebuild PATH into a recursive same-named wrapper. "__MISE_DIFF", ] as const; for (const name of shellPreferredEnvNames) { @@ -452,9 +445,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "XDG_DATA_HOME", "XDG_RUNTIME_DIR", "WAYLAND_DISPLAY", - // CA trust set in the shell profile. Without it, a Node or OpenSSL build - // that has no bundled roots (a shared-OpenSSL node on PATH) cannot verify - // TLS, so provider updaters and agents fail to reach their registries. + // Shell CA settings are required by Node/OpenSSL builds without bundled roots for registry TLS. "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", @@ -465,8 +456,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } - // Locale variables form one precedence group: LC_ALL can override an inherited - // LANG or LC_CTYPE, so only hydrate the group when the process has none of them. + // Hydrate locale variables together; inherited LC_ALL can override LANG or LC_CTYPE. if ( config.platform === "darwin" && LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name]))) @@ -478,11 +468,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } - // GUI launches inherit no locale from launchd, so spawned agents land in the C - // locale and pbcopy decodes their UTF-8 output as MacRoman. Older supported - // macOS releases do not provide C.UTF-8, so set only LC_CTYPE to a UTF-8 locale - // available on those releases. Leaving LANG unset keeps C-stable collation and - // formatting, so output parsing is unaffected. + // launchd lacks locale; UTF-8 LC_CTYPE fixes pbcopy's MacRoman decoding without changing C formatting or collation. if (LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) { config.env.LC_CTYPE = FALLBACK_LC_CTYPE; } @@ -522,7 +508,7 @@ const installShellEnvironment = ( return Effect.succeed(false); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/snapShot/ActiveWindow.ts b/apps/desktop/src/snapShot/ActiveWindow.ts index f62669a39bf36..20f56cc19442c 100644 --- a/apps/desktop/src/snapShot/ActiveWindow.ts +++ b/apps/desktop/src/snapShot/ActiveWindow.ts @@ -6,10 +6,7 @@ import * as Schema from "effect/Schema"; import { loadWindowsForegroundApi } from "../electron/WindowsForeground.ts"; -/** - * `id` is the CGWindowNumber on macOS and the HWND on Windows, which is what - * the capture backends key on. - */ +/** id is CGWindowNumber on macOS or HWND on Windows, matching the capture backend. */ export type ActiveWindow = { readonly platform: "macos" | "windows"; readonly id: number; diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 39a7dddf5de72..8532dee0038a0 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -798,7 +798,6 @@ it.effect.each(["darwin", "win32"] as const)( for (const finish of heldFlights) finish(); yield* Fiber.join(landing); - // Landing releases the guard even before the first capture is acknowledged. yield* Effect.promise(fixture.trigger); assert.equal(fixture.state.snapshots, 2); assert.lengthOf(fixture.readyIds, 2); @@ -1049,9 +1048,15 @@ it.effect("matches Windows accessibility windows on a scaled display", () => { ); }); -it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( - "persists %s text without inventing AT-SPI screen coordinates", - (backend) => { +it.effect.each([ + { backend: "gnome", screenshot: "PNG" }, + { backend: "niri", screenshot: "PNG" }, + { backend: "kde", screenshot: "PNG" }, + { backend: "hyprland", screenshot: "PNG" }, + { backend: "gnome", screenshot: "invalid" }, +] as const)( + "persists $backend accessibility text with a $screenshot screenshot", + ({ backend, screenshot }) => { vi.stubEnv("XDG_SESSION_TYPE", "wayland"); flashWindows.length = 0; const window = { @@ -1068,7 +1073,16 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( ...(backend === "niri" ? { accessibilityBoundsReliable: false } : {}), ...(backend === "kde" ? { clientBounds: { x: 479, y: 371, width: 700, height: 520 } } : {}), }; - linuxCaptureMock.mockResolvedValueOnce({ png: Buffer.from([1, 2, 3]), window }); + // The captured pixels differ from the compositor's logical window bounds. + const imageSize = screenshot === "PNG" ? { width: 1400, height: 1040 } : window.bounds; + const png = + screenshot === "PNG" + ? Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAABXgAAAQQAQAAAABwoA4ZAAAAyElEQVR4nO3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgYyx4AAepc/c0AAAAASUVORK5CYII=", + "base64", + ) + : Buffer.from([1, 2, 3]); + linuxCaptureMock.mockResolvedValueOnce({ png, window }); activeWindowMock.mockClear(); getSourcesMock.mockClear(); accessibilityByPidMock.mockReset().mockResolvedValue({ @@ -1108,7 +1122,7 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( assert.deepInclude(saved.source.accessibility, { format: "element-tree", coordinateSpace: "captured-image", - imageSize: { width: 700, height: window.bounds.height }, + imageSize: { width: imageSize.width, height: imageSize.height }, truncated: false, }); assert.deepInclude( @@ -1118,7 +1132,7 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( { role: "window", name: "Editor", - bounds: { x: 0, y: 0, width: 700, height: window.bounds.height }, + bounds: { x: 0, y: 0, width: imageSize.width, height: imageSize.height }, state: { active: true }, }, ); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 164c97b4f22fc..9958fad3705ae 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -90,6 +90,7 @@ import { snapShotShortcutSystemConflict, } from "./snapShot.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { readImageDimensions } from "@t3tools/shared/imageDimensions"; const MAX_CAPTURE_WIDTH = 2_560; const MAX_CAPTURE_HEIGHT = 1_600; @@ -211,7 +212,6 @@ export class DesktopSnapShotSetupError extends Schema.TaggedError 0 && size.height > 0) return size; - } catch {} + // Capture backends already validate or encode the PNG. Reading its header + // avoids decoding every pixel again just to size accessibility coordinates. + const size = readImageDimensions(png); + if (size) return size; return { width: Math.max(1, Math.round(fallback.width)), height: Math.max(1, Math.round(fallback.height)), @@ -903,8 +899,7 @@ export const make = Effect.gen(function* () { const imageTempPath = path.join(captureDirectory, `${id}.tmp.png`); return yield* Effect.gen(function* () { - // Retire feedback before reading screen pixels, so a rapid capture cannot - // photograph the previous capture's overlay. + // Retire feedback before capture so rapid captures cannot photograph the previous overlay. closeLinuxFeedback(); flash.dispose(); transition.dispose(); @@ -1033,8 +1028,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.snapShot.capture")); const captureFromShortcut = Effect.gen(function* () { - // Repeated shortcuts must not tear down a moving capture and start another - // renderer. Drop them while the flight runs, just as during pixel acquisition. + // Repeated shortcuts must not replace a capture during its flight. if (shortcutSuppressed || transition.isAnimating) return; shortcutVerified = true; const now = yield* Clock.currentTimeNanos; @@ -1140,9 +1134,7 @@ export const make = Effect.gen(function* () { transition.dispose(); closeLinuxFeedback(); } - // Every client-settings save lands here. Only the fields that decide which - // shortcut listener runs may tear it down; a font-size change must not - // uninstall a global keyboard hook or drop an approved portal session. + // Unrelated settings saves must not uninstall the keyboard hook or drop an approved portal session. const shortcutInputsChanged = settings.snapShotEnabled !== previousSettings.snapShotEnabled || settings.snapShotIncludeAccessibility !== previousSettings.snapShotIncludeAccessibility || diff --git a/apps/desktop/src/snapShot/KdeSnapShot.ts b/apps/desktop/src/snapShot/KdeSnapShot.ts index bfd6b2984147d..50d9e700ee51e 100644 --- a/apps/desktop/src/snapShot/KdeSnapShot.ts +++ b/apps/desktop/src/snapShot/KdeSnapShot.ts @@ -182,8 +182,7 @@ export class KdeCaptureSetup { } // KService must see the new desktop entry before KWin checks the executable. await run("kbuildsycoca6", ["--noincremental"]); - // AppImages change XDG search paths, and KService caches are also locale-specific. - // Refresh Plasma's cache in its session environment, not just the app's cache. + // AppImage search paths and locale affect KService caches; refresh in Plasma's session environment. await run("systemd-run", [ "--user", "--quiet", diff --git a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts index ad7b2e03c4954..117ffa652566b 100644 --- a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts +++ b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts @@ -11,8 +11,7 @@ const MAC_MODIFIER_PAIR_DEVICE_MASKS: Record { send(event, { ScreenshotCaptured: { path: "/another-app.png" } }); send(event, { ScreenshotCaptured: { path: screenshot.path } }); } - // The event may beat the command reply. send(socket, { Ok: "Handled" }); } else send(socket, { Ok: "Handled" }); }; diff --git a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts index 374745c96e60c..f0ac7b9c1bba2 100644 --- a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts +++ b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts @@ -1,5 +1,4 @@ -// Runs in a forked Node-mode child so the native screenshot call cannot crash -// or stall the main process. +// Isolate native screenshot crashes and stalls in a Node-mode child. import type { RegionSnapShotRequest, RegionSnapShotResult } from "./RegionSnapShot.ts"; process.once("disconnect", () => process.exit(0)); diff --git a/apps/desktop/src/snapShot/SnapShotAccessibility.ts b/apps/desktop/src/snapShot/SnapShotAccessibility.ts index 3361aafc3c78a..9021c8904e3fe 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibility.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibility.ts @@ -172,8 +172,7 @@ async function readCapturedWindowAccessibility( .catch(() => { progress.flatComplete = true; }); - // A decorated screenshot contains more than the accessibility client area. Keep its - // frame origin/scale so element coordinates include the actual decoration offset. + // Preserve the screenshot frame origin and scale so accessibility coordinates include decorations. const sourceBounds = matchMode === "wayland" && active.clientBounds ? active.bounds diff --git a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts index dd1474b885052..542e3f13c08dc 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts @@ -49,8 +49,7 @@ export function startSnapShotAccessibilityProcess(workerPath: string): Accessibi let worker: NodeChildProcess.ChildProcess; try { worker = NodeChildProcess.fork(workerPath, ["read"], { - // Electron defaults to its Helper executable on macOS, which does not - // share the main app's Accessibility grant checked during setup. + // Electron's default macOS Helper executable lacks the main app's Accessibility grant. execPath: process.execPath, env: { ...process.env, diff --git a/apps/desktop/src/snapShot/SnapShotTransition.ts b/apps/desktop/src/snapShot/SnapShotTransition.ts index a8ca237009bbe..4b6ce04a1bf27 100644 --- a/apps/desktop/src/snapShot/SnapShotTransition.ts +++ b/apps/desktop/src/snapShot/SnapShotTransition.ts @@ -310,8 +310,7 @@ export class SnapShotTransition { } } if (this.waitForCompositorFrame) { - // Cover the source with a composited snapshot before the caller reveals T3. - // Decoding and renderer animation frames alone can leave a transparent gap. + // Renderer frames alone can leave a transparent gap; composite the snapshot before revealing T3. await Promise.all( overlays.map(async (overlay) => { if (this.active !== active || overlay.window.isDestroyed()) return; @@ -408,8 +407,7 @@ export class SnapShotTransition { ); if (this.active !== active || overlay.window.isDestroyed()) return; if (this.waitForCompositorFrame) { - // Read back one pixel after preparation to flush queued compositor work - // before the animation clock starts on any of the display surfaces. + // Read back one pixel to flush compositor work before starting any display's animation clock. await overlay.window.webContents.capturePage({ x: 0, y: 0, width: 1, height: 1 }); } return overlay; diff --git a/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts b/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts index eb02eb7a03d13..ca4165626b260 100644 --- a/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts +++ b/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts @@ -4,8 +4,7 @@ const WINDOW_ANIMATIONS_DISABLED = "wm-window-animations-disabled"; /** Show capture feedback without Aura's extra 200 ms fade/scale on transparent windows. */ export function showWindowsCaptureOverlay(window: Electron.BaseWindow): void { - // Chromium reads this switch synchronously inside Show(). Restore it before returning - // so other windows and CSS animations keep their normal behavior. + // Show() reads the switch synchronously; restore it before other windows or animations can be affected. const alreadyDisabled = Electron.app.commandLine.hasSwitch(WINDOW_ANIMATIONS_DISABLED); if (!alreadyDisabled) Electron.app.commandLine.appendSwitch(WINDOW_ANIMATIONS_DISABLED); try { diff --git a/apps/desktop/src/snapShot/linuxCaptureSession.ts b/apps/desktop/src/snapShot/linuxCaptureSession.ts index c35fee18db45e..59fe40b7c5212 100644 --- a/apps/desktop/src/snapShot/linuxCaptureSession.ts +++ b/apps/desktop/src/snapShot/linuxCaptureSession.ts @@ -4,8 +4,7 @@ import * as NodeURL from "node:url"; import type { SnapShotKeyChord } from "@t3tools/contracts"; import { nativeImage } from "electron"; -// Linux helpers that need no D-Bus. Keep this module free of dbus-next so the main -// process can answer "which desktop is this" and read a PNG without loading it. +// Keep dbus-next out of desktop detection and PNG reads in the main process. export const HYPRLAND_CAPTURE_ACTION = "capture-window"; export const NIRI_CAPTURE_PATH = "/com/t3tools/SnapShot"; diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 958e017299e0e..28bd301c557e6 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -520,8 +520,7 @@ export function findAccessibleWindow< const browserMatch = findBrowserTitleMatch(windows, titles, captured, matchMode, matchesBounds); if (browserMatch) return browserMatch; } - // GTK4/libadwaita often exposes the frame as an unnamed group. A PID-scoped - // lookup can accept the one window whose bounds match; size-only guesses cannot. + // GTK4 frames may be unnamed; accept only a PID-scoped unique bounds match, never size alone. if (!options.allowUntitledUniqueBounds) return undefined; const boundsMatches = windows.filter( (window) => normalizeTitle(window.name ?? "") === "" && matchesBounds(window), diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 61b104b4ac62b..eac148ae4d0f3 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -125,7 +125,7 @@ const makePasswordPrompt = ( prompts.request(request).pipe(Effect.mapError(toSshPasswordPromptError)), }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const manager = yield* SshTunnel.SshEnvironmentManager; const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 296b81339e80e..7442282aa15ad 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -218,7 +218,7 @@ const failPending = ( error: DesktopSshPasswordPromptRequestError, ) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( options: DesktopSshPasswordPromptsOptions = {}, ) { diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 5e4b1de81b833..8800c4f428331 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -141,7 +141,7 @@ function sampleInterval( return LIVE_SAMPLE_INTERVAL; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { const electronApp = yield* ElectronApp.ElectronApp; const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor; diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 49acc3f86a483..253a015d8ec9e 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -22,9 +22,7 @@ import * as DesktopRemoteUpdates from "./DesktopRemoteUpdates.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { makeHarness } from "./updatesTestHarness.ts"; -// The remote flow hops between the test runtime's fibers and the updater's -// runPromise-driven event handlers, so settling needs real microtask turns, -// not just fiber yields. +// Updater runPromise handlers need real microtasks between test-runtime fiber yields. const settle = Effect.gen(function* () { for (let i = 0; i < 20; i += 1) { yield* Effect.yieldNow; @@ -326,8 +324,6 @@ describe("DesktopRemoteUpdates", () => { }); it.effect("retries a download refused while the check still holds the reservation", () => { - // electron-updater emits update-available from inside checkForUpdates, - // before the check action releases its reservation. const releaseCheck = Deferred.makeUnsafe(); const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) }); @@ -359,9 +355,6 @@ describe("DesktopRemoteUpdates", () => { }); it.effect("waits for the download reservation before reporting prepared", () => { - // update-downloaded fires from inside downloadUpdate. If the flow - // reported "installing" right then, install would be refused for the - // held reservation after the irrevocable terminal already went out. const releaseDownload = Deferred.makeUnsafe(); const harness = makeHarness({ downloadUpdate: Deferred.await(releaseDownload) }); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index cb1b86c07754b..cb606d8fbd253 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -175,11 +175,7 @@ export const listen: Effect.Effect< const { latest, changes } = yield* updates.subscribe; const disabledReason = Option.getOrNull(yield* updates.disabledReason); let attempts: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 }; - // A state event can land while the action that produced it still holds - // the reservation (e.g. "available" before the check releases), so a - // forked action can be refused with no later state event to retry on. - // Rejected actions re-enqueue their state here after a short pause so - // the step runs again once the reservation is free. + // State events can precede reservation release; re-enqueue refused actions so they retry after release. const retries = yield* Queue.unbounded(); const retryLater = (state: DesktopUpdateState) => Effect.sleep(ACTION_RETRY_DELAY).pipe( @@ -416,11 +412,7 @@ export const listen: Effect.Effect< result.state.downloadedVersion === claim.prepared.downloadedVersion && (yield* updates.isInstallActive) ) { - // Another install (local, or an earlier remote request) already owns - // the shutdown and will relaunch the app on the same downloaded - // version. This commit joins it: no failure marker, and the client - // proves the handoff the same way, by transport loss then the - // target version on reconnect. + // Join the existing install; reconnect and target-version checks prove its handoff. yield* logInfo("remote update commit joining an in-progress install", { requestId: commit.requestId, }); @@ -433,9 +425,7 @@ export const listen: Effect.Effect< } return; } - // A successful install tears down this backend. Do not send a success - // marker from the old process: transport loss followed by the target - // version is the only proof that the handoff succeeded. + // Installation kills this backend; only transport loss followed by the target version proves handoff. }).pipe( Effect.ensuring(clearActiveRequest(commit.requestId)), Effect.catchCause((cause) => diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index ae3c2588fab2b..5265d65523ba2 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -64,9 +64,7 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; const UpdateInfo = Schema.Struct({ version: Schema.String, - // Left unvalidated on purpose: a malformed release-notes payload must never - // fail the decode and block the update state transition. The shape is - // validated defensively in normalizeDesktopUpdateReleaseNotes. + // Malformed notes must not block update-state decoding; normalizeDesktopUpdateReleaseNotes validates separately. releaseNotes: Schema.optional(Schema.Unknown), }); @@ -335,7 +333,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const pool = yield* DesktopBackendPool.DesktopBackendPool; @@ -364,8 +362,7 @@ export const make = Effect.gen(function* () { ); const stateChanges = yield* PubSub.sliding(16); - // Makes ref writes + publishes atomic against subscribe, so a snapshot - // never overlaps with the first change a subscriber receives. + // Serialize writes and publishes against subscription so snapshots cannot overlap the first change. const stateMutex = yield* Semaphore.make(1); const emitState = Ref.get(updateStateRef).pipe( @@ -399,8 +396,7 @@ export const make = Effect.gen(function* () { ), ); - // The .deb carries electron-builder's resources/package-type marker. - // electron-updater reads the same file and installs updates with dpkg. + // electron-updater uses this marker to install .deb updates with dpkg. const isDebPackage = environment.platform === "linux" && environment.isPackaged ? yield* fileSystem @@ -597,8 +593,7 @@ export const make = Effect.gen(function* () { ), ); - // A failed or interrupted install brings no updated backend, so a later - // quit must release the tunnel. + // Failed or interrupted installs leave no replacement backend; later quits must release the tunnel. const removeUpdateRestartMarker = fileSystem .remove(updateRestartMarkerPath, { force: true }) .pipe(Effect.ignore); @@ -742,11 +737,7 @@ export const make = Effect.gen(function* () { yield* writeUpdateRestartMarker; const { downloadedVersion } = yield* Ref.get(updateStateRef); if (downloadedVersion !== null) yield* writeInstallRecord(downloadedVersion); - // Stop every backend in the pool, not just the primary. With parallel - // WSL + Windows backends, leaving the WSL instance up means - // quitAndInstall's app.quit() exits before the pool's scope cascade - // has a chance to run its stop finalizer, so the WSL child gets - // hard-killed by the OS instead of receiving SIGTERM + grace. + // Stop pooled backends before quitAndInstall can outrun their scope finalizers. const instances = yield* pool.list; // stop clears desiredRunning, so retain the recovery set beforehand. const runningInstances = yield* Effect.filter(instances, (instance) => diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 008a83eab03db..028e42fa6e3b7 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -81,8 +81,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("drops releases from other trains before grouping on nightly", () => { - // electron-updater's full changelog is "every version above the running - // one", and preview sorts above nightly, so the preview cuts come first. const releaseNotes = [ { version: "0.0.41-preview.20260914.1683", note: "- Maintainer test build" }, { version: "0.0.41-preview.20260913.1669", note: "- Maintainer test build" }, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index ef6d217299d09..69d710ff7b269 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -30,8 +30,6 @@ const HTML_ENTITY_REPLACEMENTS: Readonly> = { }; function decodeCodePoint(codePoint: number, entity: string): string { - // String.fromCodePoint throws RangeError outside the valid Unicode range, and - // Number.isFinite alone lets oversized values (e.g. �) through. if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { return `&${entity};`; } @@ -124,12 +122,7 @@ interface NormalizedDesktopUpdateReleaseNotes { readonly omittedReleaseCount: number; } -/** - * With `fullChangelog` on (nightly), electron-updater collects every GitHub - * release whose version is semver-greater than the running one, whatever train - * it belongs to; a maintainers' `-preview.` cut sorts above every `-nightly.` - * of the same base version and would lead the list. - */ +/** fullChangelog includes all greater semvers, so preview cuts sort above nightlies of the same base version. */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, diff --git a/apps/desktop/src/updates/remoteUpdateFlow.ts b/apps/desktop/src/updates/remoteUpdateFlow.ts index f7c65b35820cc..1d9588315559c 100644 --- a/apps/desktop/src/updates/remoteUpdateFlow.ts +++ b/apps/desktop/src/updates/remoteUpdateFlow.ts @@ -11,12 +11,7 @@ export type RemoteDesktopUpdateStep = readonly reason?: string; }; -/** - * The caps are what stop a check -> up-to-date -> check loop and endless - * download retries; they are counts rather than booleans because a state event - * raced by the local 4-minute poller can re-show an already-handled status - * once. - */ +/** Bound retries with counts: background poller races can repeat an already-handled status once. */ export interface RemoteDesktopUpdateAttempts { readonly checks: number; readonly downloads: number; @@ -46,9 +41,7 @@ export function nextRemoteDesktopUpdateStep( reason: disabledReason ?? "Automatic updates are disabled on this machine.", }; } - // An install of this version already failed to apply here. Installing it - // again from afar would stop the app's threads for nothing, so only the user - // on this machine retries it. + // Reinstalling a failed version disrupts threads without updating; retries require the local user. if ( state.notAppliedVersion !== undefined && (state.downloadedVersion ?? state.availableVersion) === state.notAppliedVersion @@ -59,11 +52,7 @@ export function nextRemoteDesktopUpdateStep( reason: `Update ${state.notAppliedVersion} did not apply on this machine. Retry it from the desktop app there.`, }; } - // Mirror installDownloadedUpdate's own admission rule exactly, so the run - // never reports an install that the updater then refuses. A download - // survives an unrelated background updater error (errorContext null) and - // a previous failed install (errorContext "install"); it does not survive - // a check or download error, which fall through to the error branch. + // Match install admission, including retained downloads after unrelated background or earlier install errors. if (isInstallableDesktopUpdateState(state)) { return { action: "install" }; } @@ -80,9 +69,7 @@ export function nextRemoteDesktopUpdateStep( } return { action: "download" }; } - // "up-to-date" and "error" are retained from earlier/background checks, - // so before this run has issued its own check they are stale, not - // terminal: the whole point of a remote request is to look again. + // Background up-to-date/error states are stale until this request has issued its own check. if (state.status === "up-to-date") { if (attempts.checks === 0) { return { action: "check" }; @@ -109,11 +96,7 @@ export function nextRemoteDesktopUpdateStep( return { action: "check" }; } -/** - * Updater messages are plain strings and may be blank; a blank reason would - * fail encoding and silently drop the terminal report, leaving the server to - * wait for its timeout. - */ +/** Blank reasons fail encoding and drop the terminal report, forcing the server to time out. */ export function normalizeRemoteUpdateReason(reason: string | undefined): string | undefined { const trimmed = reason?.trim(); return trimmed ? trimmed : undefined; diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index 1b1a1ec1efa99..d79a374a668a5 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,10 +1,7 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; -// They share nightly's branding but are packaged without an update feed (see -// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the channel -// a preview install reports is cosmetic: it never checks for updates and no -// updater feed ever lists a preview release. +// Preview builds share nightly branding but ship without an update feed. const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index aeaca83507093..7c70656c1ac4c 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -106,14 +106,13 @@ const handleCheckForUpdatesMenuClick = Effect.gen(function* () { return; } - // The result dialogs need no main window. Opening one before the backend - // answers strands the renderer on a failed auth bootstrap. + // Opening a window before backend confirmation can strand it on failed auth bootstrap. const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.activate; yield* checkForUpdatesFromMenu; }).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const electronMenu = yield* ElectronMenu.ElectronMenu; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -143,10 +142,7 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; - // Chromium already pastes as plain text for this chord, so the accelerator - // needs nothing from the menu: the composer and the terminal each arm - // themselves from the same keydown. Routing it through the renderer anyway - // lands a second, injected paste and doubles the text. + // Chromium and the renderer already handle this chord; injecting another paste duplicates text. const pasteAsTextClick = ( _item: Electron.MenuItem, _window: Electron.BaseWindow | undefined, @@ -238,11 +234,7 @@ export const make = Effect.gen(function* () { { label: "View", submenu: [ - /* - Not the reload, DevTools or zoom roles: those act on the focused - webContents, so with a browser page focused they reload or zoom - the guest page and the app UI appears stuck. - */ + /* Native reload/DevTools/zoom roles target focused guests; these commands must target the app. */ { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") }, { label: "Force Reload", @@ -274,8 +266,7 @@ export const make = Effect.gen(function* () { ] : []), { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") }, - // The numeric keypad sends its own key codes, so the number-row - // accelerators above never match it. + // Keypad codes do not match number-row accelerators. { label: "Actual Size", accelerator: "CmdOrCtrl+num0", diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index dbdde033cae76..8b6ab7c7d8a5e 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -585,8 +585,6 @@ describe("DesktopWindow", () => { }), ); - // Ctrl+wheel bypasses the menu accelerators, so it has to take the same - // zoom step, preview restore included, from the window's zoom-changed event. it.effect("zooms the app from Ctrl+wheel like the menu does", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 297c67ddace08..6270f80a4a7e6 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -37,8 +37,7 @@ import { makeQuitShortcutHandler } from "./QuitHold.ts"; import { copyContextMenuImage } from "./ContextMenuImage.ts"; const TITLEBAR_HEIGHT = 40; -// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS -// buttons are 14 points tall and do not scale with the renderer's zoom. +// Match --workspace-topbar-height in apps/web/src/index.css; native macOS buttons stay 14 points at any zoom. const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52; const MACOS_WINDOW_BUTTON_RADIUS = 7; @@ -109,9 +108,7 @@ export class DesktopWindow extends Context.Service< readonly dispatchSnapShotEvent: ( event: DesktopSnapShotEvent, ) => Effect.Effect; - // The Electron `zoomIn`/`zoomOut` menu roles act on whichever webContents - // has keyboard focus, so with an embedded preview WebContentsView (or - // DevTools) focused they zoom the guest page instead of the app UI. + // Electron zoom roles target focused guests; zoomMain must always target the app. readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; readonly runMainContentsCommand: (command: MainWindowContentsCommand) => Effect.Effect; readonly syncAppearance: Effect.Effect; @@ -222,8 +219,7 @@ function concealPendingQuitWindow( if (window.isFullScreen()) { window.setFullScreen(false); } - // Electron implements window opacity on macOS and Windows. Linux keeps the - // release-gated quit behavior but cannot make the pending window disappear. + // Linux lacks Electron window opacity; release-gated quits remain visible there. window.setOpacity(0); } @@ -286,7 +282,7 @@ function bindFirstRevealTrigger( } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopState = yield* DesktopState.DesktopState; @@ -315,11 +311,7 @@ export const make = Effect.gen(function* () { } }); - // currentMainOrFirst / focusedMainOrFirst fall back to "any first window", - // which during WSL-only boot is the connecting splash. The splash is never - // registered via setMain, so it must be treated as "no real main window" -- - // otherwise ensureMain/activate/dispatchMenuAction latch onto it and never - // open (or retry) the real main. + // Electron's first-window fallback can select the unregistered connecting splash; exclude it so main creation retries. const withoutSplash = (window: Option.Option) => Ref.get(splashWindowRef).pipe( Effect.map((splash) => @@ -341,9 +333,7 @@ export const make = Effect.gen(function* () { direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); if (environment.platform === "darwin") syncMacosWindowButtons(window); - // Chromium pushes the new level down to embedded guests, which would zoom - // the previewed page along with the app UI. The preview browser keeps its - // own zoom, so put each guest back where the preview left it. + // App zoom propagates to guests; restore each preview's own zoom. yield* previewManager.reapplyZoom(); }); @@ -394,18 +384,13 @@ export const make = Effect.gen(function* () { ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), webPreferences: { preload: environment.preloadPath, - // Chromium throttles hidden renderers: timers coalesce and rAF stops, - // which stalls first paint. Boot unthrottled; the first-reveal trigger - // re-enables throttling so a hidden or minimized window goes back to - // being cheap after it has been shown once. + // Hidden-renderer throttling stalls first paint; re-enable it after the first reveal. backgroundThrottling: false, contextIsolation: true, nodeIntegration: false, sandbox: true, webviewTag: true, - // A preview guest's fullscreen request is mirrored onto this embedder, - // which would otherwise put the whole window into OS fullscreen. With - // both sides opted out the page fills its webview and the window stays. + // Guest fullscreen propagates to the embedder; both must opt out to keep it inside the webview. disableHtmlFullscreenWindowResize: true, }, }); @@ -541,8 +526,7 @@ export const make = Effect.gen(function* () { contents.on("context-menu", (event, params) => { event.preventDefault(); if (contents.isDestroyed() || ownerWindow.isDestroyed()) return; - // Native editing roles act on the focused contents, which may still be - // the host renderer when the user right-clicks inside a browser guest. + // Right-clicking a guest may leave native editing roles targeting the host renderer. contents.focus(); const menuTemplate: Electron.MenuItemConstructorOptions[] = []; @@ -635,9 +619,7 @@ export const make = Effect.gen(function* () { } }); - // Electron's windowMenu close role owns CmdOrCtrl+W. Holding the - // close-terminal shortcut can outlive the terminal that handled its first - // press, so reject repeats before they reach the native window accelerator. + // Terminal-close repeats can outlive the terminal; reject them before the native close-window accelerator. const quitShortcutHandler = makeQuitShortcutHandler({ platform: environment.platform, getMode: () => @@ -655,8 +637,7 @@ export const make = Effect.gen(function* () { window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); } }, - // Keep the transparent window focused until the physical shortcut is - // released so its remaining repeats cannot reach the next app. + // Hold focus until shortcut release so remaining repeats cannot reach the next app. concealWindow: () => concealPendingQuitWindow(window), quit: () => { void runPromise(electronApp.quit); @@ -673,9 +654,7 @@ export const make = Effect.gen(function* () { window.webContents.on("input-event", (_event, input) => { if (input.type === "gestureScrollEnd") window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); }); - // Ctrl+wheel never reaches the menu accelerators: Chromium reports it here - // and leaves applying the zoom to the app. A preview guest under the - // pointer emits this on its own webContents and keeps its own zoom. + // Ctrl+wheel bypasses menus; guest contents emit their own zoom-changed event. window.webContents.on("zoom-changed", (_event, direction) => { if (window.isDestroyed()) return; runFork(zoomWindow(window, direction)); @@ -793,8 +772,6 @@ export const make = Effect.gen(function* () { details.reason === "oom" || details.reason === "killed" || details.reason === "abnormal-exit"; - // Recovery attempts are bounded so a renderer that dies immediately on - // boot cannot reload-loop forever. runFork( Effect.gen(function* () { @@ -959,8 +936,7 @@ export const make = Effect.gen(function* () { const send = Effect.sync(() => { if (!targetWindow.isDestroyed()) targetWindow.webContents.send(channel, payload); }); - // The renderer must learn about the event even when another process refuses to - // yield the foreground, so send first and treat the reveal as best effort. + // Send before best-effort reveal; another process may refuse to yield foreground focus. const dispatch = reveal ? send.pipe(Effect.andThen(electronWindow.reveal(targetWindow).pipe(Effect.ignoreCause))) : send; @@ -1034,8 +1010,7 @@ export const make = Effect.gen(function* () { }), runMainContentsCommand: Effect.fn("desktop.window.runMainContentsCommand")(function* (command) { yield* Effect.annotateCurrentSpan({ command }); - // The registered main window, never the focused one: with an OAuth popup - // focused, Reload would otherwise reload the popup mid sign-in. + // A focused OAuth popup must not receive the app's Reload command. const window = yield* electronWindow.main; if (Option.isNone(window) || window.value.isDestroyed()) return; const webContents = window.value.webContents; diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index b87ae9ceab29a..f7db33f17ed5e 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -4,11 +4,9 @@ import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contr export const QUIT_HOLD_DURATION_MS = 1200; export const QUIT_DOUBLE_PRESS_MS = 500; -// "Still held" is proven by auto-repeat keydowns, not by the absence of a -// release: macOS suppresses a letter keyUp while the command key is down, so a -// 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. +// Auto-repeat keydowns prove the hold: macOS drops a letter keyUp while Command is down, +// so a release-based timer would quit anyway. Once held, quitting waits for Q keyUp or a +// quiet grace period so trailing repeats cannot reach the next app. 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. @@ -46,9 +44,7 @@ export function makeQuitShortcutHandler( let lastPressAt = 0; 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: a direct-mode - // press must still quit after that read settles. + // A key release must not invalidate a pending direct-mode read; only superseding/cancelled presses do. let generation = 0; const clearWatchdog = () => { @@ -125,8 +121,7 @@ export function makeQuitShortcutHandler( if (!modifierDown || input.alt || input.shift || key !== "q") { if (key === modifierKey && !input.alt && !input.shift) return; - // Keep this separate from release(), which also runs when a fresh Q - // keydown follows a keyUp that macOS did not deliver. + // release() also runs on fresh keydown after a missing macOS keyUp. if (!input.isAutoRepeat) { lastPressAt = 0; release(); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index d1f61ecc0bded..e77d896be2469 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -15,8 +15,6 @@ import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; -// Exported so callers that parse pool ids (e.g. the pickFolder IPC handler in -// ipc/methods/window.ts) reference the same prefix this module produces. export const WSL_INSTANCE_ID_PREFIX = "wsl:"; const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`; const MAX_TCP_PORT = 65_535; @@ -43,9 +41,7 @@ const isWslInstanceId = (id: DesktopBackendPool.BackendInstanceId): boolean => const buildLabel = (distro: string | null): string => distro === null ? "WSL (default distro)" : `WSL (${distro})`; -// Loopback-only port scan starting one above the primary's port. The -// WSL backend is reachable via 127.0.0.1 from Windows (wslhost -// auto-forwards), so we only need to verify the IPv4 loopback can bind. +// WSL auto-forwards loopback ports, so only IPv4 loopback availability matters. const scanForWslPort = Effect.fn("desktop.wslBackend.scanForWslPort")(function* ( startPort: number, ): Effect.fn.Return { @@ -69,11 +65,7 @@ export const layer = Layer.effect( const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const net = yield* NetService.NetService; - // Serialize reconcile so the bootstrap fork and the IPC handlers - // (setWslBackendEnabled, setWslDistro) can't interleave. Without - // this, two reconciles could both observe "no WSL instance - // registered" between their pool reads and both call startNew - // with different distros, leaving the loser stranded. + // Serialize bootstrap and IPC reconciles so concurrent reads cannot register competing WSL instances. const reconcileMutex = yield* Semaphore.make(1); const preflightErrorRef = yield* Ref.make(Option.none()); @@ -169,11 +161,7 @@ export const layer = Layer.effect( const existing = yield* findExistingWslInstance; const existingId = Option.map(existing, (instance) => instance.id); - // In wsl-only mode the pool's primary IS the WSL backend (see - // DesktopBackendConfiguration.resolvePrimary), so the - // orchestrator skips registering a parallel "wsl:" - // secondary. Without this skip we'd spin up two WSL processes - // on the same distro for users who explicitly asked for one. + // In wsl-only mode WSL is already primary; do not start a second backend on the same distro. const shouldRun = settings.wslBackendEnabled && available && !settings.wslOnly; const targetId = shouldRun ? Option.some(resolveTargetInstanceId(settings.wslDistro)) @@ -210,8 +198,7 @@ export const layer = Layer.effect( } if (Option.isSome(targetId)) { - // Pre-warm the WSL VM before registering so the readiness probe doesn't - // race wsl.exe's first-spawn cold start. + // Pre-warm WSL so readiness cannot race its cold start. yield* wslEnvironment.preWarm(settings.wslDistro); yield* startNew({ distro: settings.wslDistro }); } diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 711733cb36b54..67549f6b8a54f 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -39,8 +39,7 @@ const posixShellRunner = (() => { const runShell = (script: string) => { if (posixShellRunner === null) throw new Error("no POSIX shell runner available"); - // The install script arrives on stdin in production too, which is what lets - // its own /proc scan not match itself. + // Pass the script on stdin, as production does, to avoid matching its own /proc scan. const result = NodeChildProcess.spawnSync( posixShellRunner.file, [...posixShellRunner.args, "-s"], @@ -54,10 +53,7 @@ const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; const awaitHolder = (condition: string) => `i=0; until ${condition}; do i=$((i + 1)); [ "$i" -lt 500 ] || exit 1; sleep 0.01; done`; -// Holds a cache busy the way a running server does: the holder's argv[0] is the -// runtime entry, which the prune and install scripts look for in /proc cmdline. -// It reads a pipe the script keeps open on fd 7, so it exits with the script -// instead of outliving it on a timer. +// Use runtime-entry argv[0] for /proc detection; an open pipe keeps it alive only while this script runs. const holdRuntimeBusy = (entry: string) => [ `exec 7> >(exec -a ${entry} cat >/dev/null 2>&1)`, @@ -124,8 +120,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed const archivePath = `${work}/wsl-runtime.tar.gz`; const archiveSha = readField(result.stdout, "archiveSha"); const runtimeId = `sha256-${archiveSha}`; - // The script reads $HOME, and WSL does not inherit the parent process's - // environment, so the home override rides in the script itself. + // WSL does not inherit HOME; embed the fixture's override in the script. const installScript = (archive = archivePath, sha = archiveSha) => [ `HOME=${sh(`${work}/home`)}`, @@ -288,8 +283,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed `cat > "$work/install.sh" <<'T3CODE_INSTALL_SCRIPT'`, fixture.installScript(), "T3CODE_INSTALL_SCRIPT", - // Both racers run the same file, and neither file path contains the - // runtime root, so the script's own /proc scan cannot see them. + // Keep runtime roots out of both racers' argv so their /proc scans cannot match themselves. 'sh "$work/install.sh" > "$work/first.out" 2>&1 &', "first=$!", 'sh "$work/install.sh" > "$work/second.out" 2>&1 &', diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 12d66660ab9c8..5017793d07357 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -89,9 +89,7 @@ export class DesktopWslEnvironment extends Context.Service< DesktopWslEnvironment, { readonly isAvailable: Effect.Effect; - // Best-effort enumeration for renderer UX. Backend health checks must use - // probeDistros so a transient command failure is not mistaken for a - // successful empty installation. + // UX-only best effort; health checks need probeDistros to distinguish failures from an empty installation. readonly listDistros: Effect.Effect; readonly probeDistros: Effect.Effect; readonly preWarm: (distro: string | null) => Effect.Effect; @@ -100,11 +98,7 @@ export class DesktopWslEnvironment extends Context.Service< windowsPath: string, ) => Effect.Effect>; readonly getUserHome: (distro: string | null) => Effect.Effect>; - // The orchestrator uses this for the WSL backend's httpBaseUrl so the - // renderer can reach it without relying on wslhost's localhost→WSL - // automatic forwarding, which is flaky in practice (the backend can be - // listening for 30+ seconds before wslhost starts forwarding 127.0.0.1:port - // to WSL-side localhost). + // Use the distro IP to avoid unreliable wslhost localhost forwarding. readonly getDistroIp: (distro: string | null) => Effect.Effect>; readonly prepareRuntime: ( distro: string | null, @@ -177,8 +171,7 @@ const buildWslNodeEnvPreamble = ( ensure_remote_node_path || true `; -// wsl.exe re-escapes args before forwarding them to the Linux side, which -// mangles quotes inside `bash -lc " ", + "

The moon garden opens at dusk

", + '', + ].join(""), + }); + expect(preview.consoleMessages).toContainEqual({ + level: "log", + text: appearance === "light" ? "false rgb(24, 24, 27)" : "true rgb(250, 250, 250)", + }); + } + }).pipe(Effect.provide(layerHtmlRender(executable))); + }), + ); + it.live("keeps every local file but the page itself out of the browser", (ctx) => Effect.gen(function* () { const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; diff --git a/apps/server/src/htmlRender/HtmlRender.ts b/apps/server/src/htmlRender/HtmlRender.ts index 810143c75a796..44ea627ceab72 100644 --- a/apps/server/src/htmlRender/HtmlRender.ts +++ b/apps/server/src/htmlRender/HtmlRender.ts @@ -77,11 +77,8 @@ export class HtmlRenderViewportHeightError extends Schema.TaggedError|$)|]*(?:>|$)([\s\S]*?)(?:<\/style\s*>|$)|\sstyle\s*=\s*(?:"([^"]*)(?:"|$)|'([^']*)(?:'|$)|([^\s>]+))/gi; // The lookbehind skips base64 runs, such as an inlined image that happens to contain "1vh". @@ -181,10 +178,7 @@ const dataUriPrefix = (path: string) => const latin1 = (bytes: Uint8Array, start: number, end: number) => String.fromCharCode(...bytes.subarray(start, end)); -/** - * Whether file bytes are an image, whatever the file is named, so a symlink or - * renamed file cannot carry other data, such as a secret, into a page. - */ +// Validate bytes, not filenames, so renamed files or symlinks cannot inline secrets. const isImageBytes = (bytes: Uint8Array) => { const head = latin1(bytes, 0, 12); if ( @@ -272,8 +266,7 @@ const inlineLocalImages = Effect.fn("HtmlRender.inlineLocalImages")(function* (h if (pageBytes > MAX_PAGE_BYTES) { return yield* new HtmlRenderPageTooLargeError({ sizeBytes: pageBytes }); } - // Files can grow after `stat`. Each read stops one byte past the image - // limit, and reading stops once the images read so far cannot fit the page. + // Files can grow after stat; bound each read and stop when accumulated images cannot fit. let readBytes = 0; const images = yield* Effect.forEach( files.filter((file) => file.size !== undefined), @@ -345,8 +338,7 @@ const make = Effect.gen(function* () { FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner >(); const browsers = yield* Semaphore.make(MAX_CONCURRENT_BROWSERS); - // Chrome's sandbox stays on unless the operator explicitly turns it off. - // Chrome also refuses it as root, where that opt-out is the only way to run. + // Keep Chrome sandboxed unless explicitly disabled; root must opt out to run. const noSandbox = PreviewBrowserHost.sandboxDisabled(yield* HostProcess.Environment); const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); @@ -492,6 +484,7 @@ const make = Effect.gen(function* () { html: inlined.html, width, urlFragment: htmlRenderThemeFragment(theme), + appearance, }).pipe(Effect.provideContext(services)), ); return { diff --git a/apps/server/src/htmlRender/headlessChrome.ts b/apps/server/src/htmlRender/headlessChrome.ts index d9339b9c46946..5562d43826c76 100644 --- a/apps/server/src/htmlRender/headlessChrome.ts +++ b/apps/server/src/htmlRender/headlessChrome.ts @@ -1,3 +1,4 @@ +import type { ThemeAppearance } from "@t3tools/shared/themePalettes"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -45,14 +46,8 @@ const MAX_CONSOLE_TEXT_CHARS = 500; const VIEWPORT_HEIGHT = 800; const MAX_CAPTURE_HEIGHT = 4_000; const CAPTURE_TIMEOUT = "20 seconds"; -// Chrome refuses local files to every web page, frame, worker, and popup, so a -// page cannot read files the agent's provider withholds; local images reach it -// already inlined as data URIs. `.localhost` keeps it a secure context that may -// still load plain-http resources, and the request never leaves the browser. -// All of the browser's traffic goes through `publicProxy`, which only reaches -// public addresses, so a page cannot reach this machine's local network. The -// main frame also stays on the page and the browser opens no popups, so a -// capture always shows the page itself. +// Chrome blocks file access; images arrive inlined. Browser-only .localhost permits secure contexts and HTTP resources. +// publicProxy blocks local networks; pinning the main frame and forbidding popups keeps captures on the page. const PAGE_ORIGIN = "http://t3-page.localhost"; const PAGE_URL = `${PAGE_ORIGIN}/page.html`; const PAGE_NAME = "page.html"; @@ -60,9 +55,7 @@ const MEASURE_CONCURRENCY = 3; const textEncoder = new TextEncoder(); -/** - * Node's encoder, since pages run to 25 MiB. - */ +// Pages reach 25 MiB; use Node's encoder. const pageBody = (html: string) => Buffer.from(Buffer.from(html, "utf8").toString("base64"), "latin1"); @@ -151,11 +144,10 @@ const consoleMessageFromEvent = (method: string, params: unknown): ConsoleMessag return undefined; }; -// Resolves after web fonts load and two frames paint, so late layout lands in the capture. +// Wait for fonts and two paint frames to capture late layout. const SETTLE_EXPRESSION = "document.fonts.ready.then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)))))"; -// The root's scroll height never drops below the viewport, so a short page -// reports its own box height instead. +// scrollHeight is at least viewport height; use the root box for shorter pages. const MEASURE_EXPRESSION = "(() => { const root = document.documentElement; return root.scrollHeight > root.clientHeight ? root.scrollHeight : root.getBoundingClientRect().height; })()"; @@ -174,10 +166,7 @@ interface PageEvents { omittedConsoleMessages: number; } -/** - * Starts the headless shell for the life of the scope, speaking CDP over - * `--remote-debugging-pipe` (fd 3 in, fd 4 out, NUL-delimited JSON). - */ +// CDP pipe uses fd 3 in, fd 4 out and NUL-delimited JSON. const launchBrowser = Effect.fnUntraced(function* (input: { readonly executable: string; readonly noSandbox: boolean; @@ -266,9 +255,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ), ).pipe(Effect.asVoid); - // Serves the page. Its body is shared bytes between two small JSON halves, - // queued together, so loading a page of up to 25 MiB at several widths never - // copies it on this side. + // Queue shared body bytes between JSON halves to avoid copies across capture widths. const fulfillPage = (sessionId: string, requestId: string, body: Uint8Array) => Queue.offerAll(outgoing, [ textEncoder.encode( @@ -308,8 +295,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { if (url === PAGE_URL && body !== undefined) { return fulfillPage(sessionId, paused.requestId, body); } - // A frame inside the page may show another site; Local Network Access - // still covers it. The main frame and T3's own origin serve nothing else. + // Subframes may show other sites covered by Local Network Access; the main frame stays on T3's page. const otherSiteFrame = paused.resourceType === "Document" && paused.frameId !== page.mainFrameId && @@ -337,8 +323,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { return Effect.void; }; - // The pipe closes when the browser exits. A startup abort, such as a missing - // sandbox, says why on stderr, which may still be draining, so give it a moment. + // Startup aborts close the pipe; let stderr drain to recover the reason. const disconnect = Effect.gen(function* () { yield* Fiber.await(stderrReader).pipe(Effect.timeout("1 second"), Effect.ignore); const exit = yield* child.exitCode.pipe( @@ -367,7 +352,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ); }); - // Decoded text chunks may split a message anywhere, and a screenshot reply spans many. + // Chunks may split messages; screenshot replies span many chunks. let partial: Array = []; yield* child.getOutputFd(4).pipe( Stream.decodeText(), @@ -413,7 +398,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ); }); - const openPage = Effect.fnUntraced(function* (width: number) { + const openPage = Effect.fnUntraced(function* (width: number, appearance: ThemeAppearance) { const { targetId } = yield* send( "Target.createTarget", { url: "about:blank" }, @@ -436,6 +421,12 @@ const launchBrowser = Effect.fnUntraced(function* (input: { yield* send("Page.enable", {}, Ignored, sessionId); yield* send("Runtime.enable", {}, Ignored, sessionId); yield* send("Log.enable", {}, Ignored, sessionId); + yield* send( + "Emulation.setEmulatedMedia", + { features: [{ name: "prefers-color-scheme", value: appearance }] }, + Ignored, + sessionId, + ); // Previews have no use for WebRTC, whose ICE servers can make the browser // resolve names outside the proxy. Runs before page scripts in every frame. yield* send( @@ -447,8 +438,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { Ignored, sessionId, ); - // Pauses T3's own origin, to serve the page, and every document, to keep - // the main frame on it. + // Intercept T3's page and all document navigations to keep the main frame on it. yield* send( "Fetch.enable", { @@ -550,6 +540,7 @@ export const captureHtmlScreenshot = Effect.fn("headlessChrome.captureHtmlScreen readonly html: string; readonly width: number; readonly urlFragment: string; + readonly appearance: ThemeAppearance; }) { const path = yield* Path.Path; const directory = yield* scratchDirectory; @@ -558,7 +549,7 @@ export const captureHtmlScreenshot = Effect.fn("headlessChrome.captureHtmlScreen noSandbox: input.noSandbox, profileDirectory: path.join(directory, "profile"), }); - const page = yield* browser.openPage(input.width); + const page = yield* browser.openPage(input.width, input.appearance); const contentHeight = yield* page.load(pageBody(input.html), input.urlFragment); const capturedHeight = Math.max(1, Math.min(contentHeight, MAX_CAPTURE_HEIGHT)); const png = yield* page.screenshot(capturedHeight); @@ -576,10 +567,7 @@ export const captureHtmlScreenshot = Effect.fn("headlessChrome.captureHtmlScreen }), ); -/** - * Content heights of the page at each width, each from a fresh load, since - * pages often lay themselves out from the width once at load. - */ +/** Fresh load per width, since pages may lay out only at load. */ export const measureHtmlHeights = Effect.fn("headlessChrome.measureHtmlHeights")(function* (input: { readonly executable: string; readonly noSandbox: boolean; @@ -598,7 +586,7 @@ export const measureHtmlHeights = Effect.fn("headlessChrome.measureHtmlHeights") return yield* Effect.forEach( input.widths, (width) => - browser.openPage(width).pipe( + browser.openPage(width, "dark").pipe( Effect.flatMap((page) => page.load(body, input.urlFragment).pipe(Effect.ensuring(page.close)), ), diff --git a/apps/server/src/htmlRender/publicProxy.ts b/apps/server/src/htmlRender/publicProxy.ts index ce6b7796fe138..e73135e5a8f10 100644 --- a/apps/server/src/htmlRender/publicProxy.ts +++ b/apps/server/src/htmlRender/publicProxy.ts @@ -19,8 +19,7 @@ for (const [network, prefix] of [ ] as const) { LOCAL_ADDRESSES.addSubnet(network, prefix, "ipv4"); } -// Also the IPv6 ranges that carry an IPv4 address a translator may route to -// without checking it: IPv4-compatible, local-use NAT64, and Teredo. +// Block IPv4-carrying IPv6 ranges that translators may route without checking. for (const [network, prefix] of [ ["::", 96], ["64:ff9b:1::", 48], @@ -52,11 +51,7 @@ const ipv6Groups = (address: string) => { ); }; -/** - * The IPv4 address a NAT64 or 6to4 address stands for, which a translator - * will route to, so it is checked as well. Public NAT64 targets stay - * reachable, as on IPv6-only networks. - */ +// Check the translated IPv4 too, while allowing public NAT64 targets on IPv6-only networks. const embeddedIPv4 = (address: string) => { const at = NAT64.check(address, "ipv6") ? 6 : SIX_TO_FOUR.check(address, "ipv6") ? 1 : -1; if (at === -1) return undefined; @@ -81,10 +76,7 @@ const isLocal = (address: string, family: number): boolean => { return embedded !== undefined && isLocal(embedded, 4); }; -/** - * The caller connects only to addresses checked here, so a name that later resolves elsewhere - * (DNS rebinding) changes nothing. - */ +// Connect only to checked addresses to prevent DNS rebinding. const publicAddresses = async (host: string) => { const addresses = NodeNet.isIP(host) ? [{ address: host, family: NodeNet.isIP(host) }] @@ -121,10 +113,7 @@ const readRequest = (data: Buffer) => { return { command: data[1], host, port: data.readUInt16BE(end - 2), rest: data.subarray(end) }; }; -/** - * Chrome's Local Network Access misses some requests, such as speculation-rules prefetches; - * nothing misses this. - */ +/** Covers requests Chrome's Local Network Access misses, including speculation prefetches. */ export const publicProxy = Effect.acquireRelease( Effect.callback< { readonly server: NodeNet.Server; readonly sockets: Set }, @@ -171,8 +160,7 @@ export const publicProxy = Effect.acquireRelease( client.off("data", holdEarly); if (addresses === undefined) return refuse(client, 2); client.pause(); - // Tries every checked address, IPv6 and IPv4 alike, so a host is - // still reached on a network whose IPv6 route is broken. + // Try all checked addresses so broken IPv6 routes do not block IPv4 access. const upstream = track( NodeNet.connect({ host: request.host, diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 48c75a1240351..daefb3b913508 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -148,9 +148,7 @@ const makeStaticRequest = Effect.fn("HttpTest.makeStaticRequest")(function* (sta ServerHttp.layerStaticAndDevRoute, ServerHttp.layerHttpCompression, ).pipe(Layer.provideMerge(NodeHttpPlatform.layer)); - // HttpRouter.serve reuses services from the layers around it before the - // app's own, and NodeHttpServer.layerTest brings a real FileSystem, so the - // caller's FileSystem and static directory go between the two. + // HttpRouter.serve prefers outer services; inject the test FileSystem between it and NodeHttpServer.layerTest. const services = yield* Layer.build( HttpRouter.serve(layerApp, { disableListenLog: true }).pipe( Layer.provideMerge(ServerConfig.layer({ ...config, staticDir })), diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index b6c6833a87d61..d281144cd9774 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -58,15 +58,12 @@ import { const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; -// The sandbox gives the document an opaque origin: scripts run, but same-origin -// cookies, storage, and API calls are out of reach. No modals: agent HTML can -// open without a click (inline renders, and mobile loads it as the top document), -// and must not raise blocking dialogs. +// Opaque origin blocks cookies, storage and API access while allowing scripts. +// Agent HTML can open without a click, so disallow blocking modals. const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-downloads"; -// Types a browser may render as a document if a proxy strips the disposition -// header. Downloads of these fall back to octet-stream. +// Proxies may strip disposition; force document-capable downloads to octet-stream. const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/; const isSafeDownloadMimeType = (mimeType: string): boolean => DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && @@ -196,9 +193,7 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined; const isMedia = /^(?:audio|video)\//i.test(headers["Content-Type"] ?? ""); if (isMedia) { - // Do not invite conditional range requests with validators that cannot establish - // byte-for-byte identity. Attachment media carries no `file`, and must not - // outlive the signed URL that granted it either. + // Without file identity, validators cannot guarantee byte equality; attachments must also expire with their URL. headers["Cache-Control"] = "private, no-store"; } let status = 200; @@ -259,8 +254,7 @@ export const layerBrowserApiCors = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const devOrigin = config.devUrl?.origin; - // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be - // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin. + // Credentialed dev requests need explicit Vite/Electron origins; packaged desktop uses the default wildcard. return HttpRouter.cors({ ...(devOrigin ? { @@ -333,8 +327,7 @@ class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecor readonly cause: unknown; }> {} -// Renderers export up to once a second while they have spans buffered, so -// tracing this proxy would add more server spans than it forwards. +// Tracing once-per-second renderer exports would create more spans than it forwards. export const layerOtlpTracesProxyRoute = HttpRouter.add( "POST", OTLP_TRACES_PROXY_PATH, @@ -391,9 +384,7 @@ export const layerOtlpTracesProxyRoute = HttpRouter.add( const UNTRACED_REQUEST_PATHS: ReadonlySet = new Set([OTLP_TRACES_PROXY_PATH]); -// TracerDisabledWhen is one predicate for the whole server, and -// HttpRouter.serve builds its routes privately, so it is provided to the served -// layer, never merged into the routes. +// HttpRouter.serve builds routes privately; provide the server-wide TracerDisabledWhen to the served layer. const layerUntracedRequests = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => { const queryIndex = request.url.indexOf("?"); const path = queryIndex === -1 ? request.url : request.url.slice(0, queryIndex); @@ -669,8 +660,7 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( const etag = headers.ETag; const lastModified = headers["Last-Modified"]; - // If-None-Match takes precedence over dates and uses weak comparison for - // GET/HEAD, including when compression changes the transferred bytes. + // If-None-Match precedes dates and uses weak GET/HEAD comparison even across compression. const ifNoneMatch = request.headers["if-none-match"]; const ifModifiedSince = request.headers["if-modified-since"]; const unchanged = diff --git a/apps/server/src/httpResponseErrorGuard.test.ts b/apps/server/src/httpResponseErrorGuard.test.ts index ae23fc37a08e8..ed3c7e65c7974 100644 --- a/apps/server/src/httpResponseErrorGuard.test.ts +++ b/apps/server/src/httpResponseErrorGuard.test.ts @@ -87,9 +87,7 @@ describe("guardHttpResponseWriteErrors", () => { }); server.on("upgrade", (_request, socket) => { - // Simulate the client vanishing while the auth rejection response is - // written to the upgrade socket: the write failure surfaces as an - // "error" event on a socket Node's http server no longer listens to. + // Node's HTTP server no longer listens for errors on an upgraded socket. socket.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" })); }); diff --git a/apps/server/src/httpResponseErrorGuard.ts b/apps/server/src/httpResponseErrorGuard.ts index b243fba20a872..0f215ba797f87 100644 --- a/apps/server/src/httpResponseErrorGuard.ts +++ b/apps/server/src/httpResponseErrorGuard.ts @@ -3,11 +3,6 @@ import type * as NodeHttp from "node:http"; import type * as NodeStream from "node:stream"; -/** - * An "error" event without a listener escalates into an uncaught exception and - * terminates the whole server process, taking every other client and all - * in-flight provider work with it. - */ export function guardHttpResponseWriteErrors( server: T, onError?: (error: unknown) => void, diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index b180dda51a095..338bbd62ce592 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -45,10 +45,7 @@ function isBase64Whitespace(code: number): boolean { return code === 0x0d || code === 0x0a || code === 0x20; } -// Data URLs carry the full image payload, so this parser must never run a -// regex across the payload: V8's regex engine borrows the JS call stack, and -// matching a multi-megabyte string from a deep call stack (e.g. inside fiber -// execution) throws "Maximum call stack size exceeded". +// V8 regexes use the JS stack; scanning image payloads can overflow a deep fiber stack. export function parseBase64DataUrl( dataUrl: string, ): { readonly mimeType: string; readonly base64: string } | null { @@ -100,8 +97,7 @@ export function parseBase64DataUrl( if (base64.length === 0 || base64.length % 4 !== 0) return null; const firstPad = base64.indexOf("="); if (firstPad !== -1) { - // '=' is only valid as one or two trailing padding characters; Node's - // decoder would otherwise silently truncate at the first '='. + // Node silently truncates at the first =; allow only one or two trailing padding characters. if (base64.length - firstPad > 2) return null; for (let index = firstPad; index < base64.length; index += 1) { if (base64.charCodeAt(index) !== 0x3d) return null; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 7fbfdbe9e651c..bf87ee2eabc01 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -548,8 +548,7 @@ const make = Effect.gen(function* () { } } - // Keep only defaults that fit, before persisted rules so existing - // custom shortcuts retain priority when their conditions overlap. + // Put defaults before persisted rules so custom shortcuts win overlapping conditions. const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length); const defaultsToAppend = missingDefaults.slice(0, availableSlots); const skippedDefaults = missingDefaults.slice(availableSlots); @@ -594,8 +593,7 @@ const make = Effect.gen(function* () { const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); - // Editors emit multiple events per save (truncate, write, rename) and - // `fs.watch` can fire before the content has been flushed to disk. + // Saves emit multiple events; fs.watch may fire before content is flushed. const debouncedKeybindingsEvents = fs.watch(keybindingsConfigDir).pipe( Stream.filter((event) => { return ( diff --git a/apps/server/src/launcherWatch.ts b/apps/server/src/launcherWatch.ts index d887028024741..ca109422c95a8 100644 --- a/apps/server/src/launcherWatch.ts +++ b/apps/server/src/launcherWatch.ts @@ -4,22 +4,13 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; const DEFAULT_POLL_INTERVAL = Duration.seconds(2); -// Sampled when this module loads, before config resolution and the bootstrap -// read, so a launcher that dies during startup is still noticed. +// Capture before config and bootstrap reads to detect launcher loss during startup. const startupParentPid = process.ppid; /** - * Completes when the process that launched this backend is gone. - * - * A desktop-launched backend reads its bootstrap envelope once and never looks - * at its launcher again. When the launcher dies without a graceful stop (a WSL - * relay killed during an app update, for instance), the backend would keep - * serving and holding its ownership lock while no desktop can reach it, and - * every replacement launch is refused. An orphan is adopted by init or, on - * Linux, by a subreaper, so any change of parent counts rather than only a - * move to pid 1. A backend that already starts under init has no launcher to - * lose, and Windows keeps the original parent id after the parent exits, so - * neither is watched. + * Any parent change signals launcher loss, including adoption by a Linux subreaper; + * otherwise an orphan backend keeps its ownership lock and blocks replacement launches. + * Skip init-launched backends and Windows, which retains the exited parent's id. */ export const awaitLauncherLoss = Effect.fn("launcherWatch.awaitLauncherLoss")(function* (options?: { readonly launcherPid?: number; diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.ts b/apps/server/src/mcp/AcpMcpStdioBridge.ts index ea9a4c93b00f7..2e7c22ce00405 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -1,5 +1,4 @@ -// The bridge relays opaque JSON-RPC lines verbatim; schema-decoding foreign -// payloads here would reject traffic it must pass through untouched. +// Relay JSON-RPC verbatim; schema decoding would reject foreign payloads. // @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off import * as NodeReadline from "node:readline"; @@ -10,10 +9,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { discardResponseBody, responsePayloads } from "@t3tools/shared/mcpResponsePayloads"; -/** - * ACP agents must support stdio MCP servers, while optional http/sse support is - * unevenly implemented. - */ +/** ACP mandates stdio MCP support; HTTP/SSE support varies. */ export class AcpMcpBridgeError extends Error { readonly _tag = "AcpMcpBridgeError"; @@ -253,10 +249,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. ), ); - // Only session establishment is ordered; client responses to - // server-initiated SSE requests must bypass the barrier or they would - // deadlock the still-open HTTP exchange, and normal MCP calls stay - // concurrent after initialization. + // Order only session establishment; SSE responses must bypass it to avoid deadlock. Calls remain concurrent after initialization. let sessionBarrier: Effect.Effect = Effect.void; let sessionBarrierPending = false; @@ -327,10 +320,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. }); } -/** - * Kept free of heavy imports: these commands run on the ACP first-message - * critical path. - */ +/** Avoid heavy imports on the ACP first-message path. */ export async function runAcpMcpCliFastPath( command: "acp-mcp-bridge" | "acp-mcp-call", args: ReadonlyArray, diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 85ccc3ab926e4..8b73483380e2c 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -132,7 +132,6 @@ const callTool = (name: string, args: Record) => const callSnapshot = (args: Record) => callTool("preview_snapshot", args); -// Real tool definitions with handlers that record what they decoded. const layerRecordingTools = (calls: Array) => { const RecordingToolkit = Toolkit.make( OrchestratorToolkit.tools.delegate_task, @@ -365,7 +364,6 @@ it.effect("decodes null optional arguments the way it decodes omitted ones", () yield* callTool("t3_thread_read", { ...thread, afterPosition: null, view: null }); yield* callTool("delegate_task", { task: "Inspect", target: {} }); yield* callTool("delegate_task", { task: "Inspect", title: null, target: { model: null } }); - // A null the schema declares keeps its meaning: here it clears the project icon. yield* callTool("t3_project_update", { projectId: "project-1", projectIcon: null }); expect(calls).toHaveLength(5); @@ -422,6 +420,15 @@ it.effect.each(["limit", "runLimit", "maxCharsPerItem"])( }, ); +it("keeps cross-tool routing out of the delegate_task description", () => { + const description = OrchestratorToolkit.tools.delegate_task.description ?? ""; + expect(description).toContain("Start one child agent with only `task`"); + // Review rounds, follow-ups, and polling are routed in the system text. + expect(description).not.toContain("t3_thread_send"); + expect(description).not.toContain("Do not poll"); + expect(description).not.toContain("Prefer native"); +}); + it.effect( "registers the pull request toolkit and surfaces a missing capability as a tool error", () => @@ -438,7 +445,8 @@ it.effect( const linkTool = server.tools.find(({ tool }) => tool.name === "link_pull_request"); expect(linkTool?.tool.annotations?.idempotentHint).toBe(true); expect(linkTool?.tool.annotations?.openWorldHint).toBe(false); - expect(linkTool?.tool.description).toContain("Register every pull request you open"); + expect(linkTool?.tool.description).toContain("Link a PR to this thread"); + expect(linkTool?.tool.description).toContain("settles the thread on merge"); const denied = yield* server .callTool({ name: "list_thread_pull_requests", arguments: {} }) @@ -533,7 +541,6 @@ it.effect("keeps the snapshot text under the agent's output ceiling", () => expect(parsed.consoleEntries[0]?.text).toBe("entry 60"); expect(notice?.type === "text" ? notice.text : "").toContain("accessibilityTree"); expect(notice?.type === "text" ? notice.text : "").toContain("60 older console entries"); - // Claude Code shows the model structuredContent instead of the text, so it is bounded too. expect(snapshot.structuredContent).toEqual({ ...parsed, omitted: expect.arrayContaining(["60 older console entries"]), @@ -809,3 +816,90 @@ it.effect("admits provider and OAuth client credentials and points only clients }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + +it.effect("lists pull request watch tools only to threads that are not delegated children", () => + Effect.scoped( + Effect.gen(function* () { + const scopeFor = ( + token: string, + subagent: boolean, + ): McpInvocationContext.McpThreadInvocationScope => ({ + environmentId, + requestNamespace: token, + thread: { + threadId: ThreadId.make(`thread-${token}`), + providerSessionId: token, + providerInstanceId: ProviderInstanceId.make("codex"), + subagent, + }, + client: undefined, + capabilities: new Set(["pull-requests"]), + issuedAt: 1, + }); + const scopes = new Map([ + ["parentToken", scopeFor("parentToken", false)], + ["childToken", scopeFor("childToken", true)], + ]); + const serverLayer = McpHttpServer.layerPullRequestsToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: (token) => Effect.succeed(scopes.get(token)), + }), + ), + Layer.provide( + Layer.mergeAll( + McpToolAccessTestkit.liveThreadsLayer, + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}), + Layer.mock(Orchestrator.OrchestratorV2)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({}), + NodeServices.layer, + ), + ), + ); + yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ); + const httpClient = yield* HttpClient.HttpClient; + const listedTools = (token: string) => + Effect.gen(function* () { + const headers = { + accept: "application/json, text/event-stream", + authorization: `Bearer ${token}`, + }; + const initialize = yield* httpClient.post("/mcp", { + headers, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, + "application/json", + ), + }); + const listed = yield* httpClient.post("/mcp", { + headers: { + ...headers, + "mcp-session-id": initialize.headers["mcp-session-id"]!, + "mcp-protocol-version": "2025-06-18", + }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, + "application/json", + ), + }); + const body = (yield* listed.json) as { + readonly result: { readonly tools: ReadonlyArray<{ readonly name: string }> }; + }; + return body.result.tools.map((tool) => tool.name); + }); + + const parentTools = yield* listedTools("parentToken"); + const childTools = yield* listedTools("childToken"); + expect(parentTools).toEqual( + expect.arrayContaining(["link_pull_request", "watch_pull_request", "unwatch_pull_request"]), + ); + expect(childTools).toContain("link_pull_request"); + expect(childTools).not.toContain("watch_pull_request"); + expect(childTools).not.toContain("unwatch_pull_request"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 2e0d16c535c8d..5a4e62e3b2417 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -78,11 +78,7 @@ const mcpResourceMetadataUrl = (request: HttpServerRequest.HttpServerRequest) => Option.map((url) => `${url.origin}/.well-known/oauth-protected-resource/mcp`), ); -/** - * Agents T3 Code launched carry a registry token and must never be sent into an - * OAuth flow when it dies: they cannot open a browser, and a sign-in would mint - * a credential that outlives their session. - */ +/** Launched agents cannot open OAuth browsers; OAuth would mint credentials outliving their session. */ const unauthorized = (input: { readonly request: HttpServerRequest.HttpServerRequest; readonly presentedToken: boolean; @@ -189,13 +185,7 @@ const layerMcpAuthMiddleware = HttpRouter.middleware<{ provides: McpInvocationContext.McpInvocationContext; }>()(makeMcpAuthMiddleware).layer; -/** - * Claude Code moves an MCP result above its output limit to a file and hands - * the agent a notice instead, so a snapshot that carries the full accessibility - * tree and page text loses its locators too. Claude Code also shows the model - * `structuredContent` in place of the text blocks when a result has both, so - * both carry the same bounded snapshot. - */ +/** Claude Code spills oversized results to files, losing locators; bound both text and its preferred structuredContent. */ export const MAX_SNAPSHOT_TEXT_BYTES = 20_000; const MAX_SNAPSHOT_VISIBLE_TEXT_CHARS = 8_000; const MAX_SNAPSHOT_ELEMENT_NAME_CHARS = 200; @@ -280,8 +270,7 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { actionTimeline: tail(metadata.actionTimeline, "action timeline entries"), }; - // Per-field caps do not sum below the ceiling: three log arrays of 40 capped - // entries alone can pass 60 KB, and the caps count characters, not bytes. + // Per-field character caps can still exceed the total byte ceiling. const shedOrder = [ "actionTimeline", "networkEntries", @@ -330,7 +319,6 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { } else { const keep = Math.floor(lists[key].length / 2); dropped[key] += lists[key].length - keep; - // slice(-0) keeps everything, so spell out the empty case. lists[key] = keep === 0 ? [] @@ -400,16 +388,10 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* ( return screenshotPath; }); -/** - * Tool input schemas are advertised through each schema's JSON codec, which - * offers `null` for every `Schema.optional` field, but Toolkit decodes with the - * schema itself and rejects that `null`. Decoding through the same codec makes - * a tool accept what it advertises. - */ +/** Decode with the advertised JSON codec, which permits null for optional fields that the raw schema rejects. */ const decodeAsAdvertised = >( toolkit: Toolkit.Toolkit, ): Toolkit.Toolkit => - // The clones keep each tool's id, name and decoded parameter type, so handlers still match. Toolkit.make( ...Object.values(toolkit.tools).map((tool) => tool.setParameters(Schema.toCodecJson(tool.parametersSchema)), @@ -431,8 +413,7 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { typeof firstFailure._tag === "string" ? firstFailure._tag : "PreviewSnapshotError"; - // Preview errors build their message on the server, never from page output, - // and it tells the agent what to do next, such as falling back to a shell browser. + // Preview errors contain server-authored recovery guidance, never page output. const message = isPreviewAutomationError(firstFailure) ? firstFailure.message : undefined; const result = new McpSchema.CallToolResult({ isError: true, @@ -508,8 +489,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot const png = new Uint8Array(Buffer.from(screenshot.data, "base64")); const screenshotPath = payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined; - // Images stay out of tool history unless asked for: providers replay them on every - // later request, and some reject inline images outright. + // Providers replay images on later requests, and some reject them; include images only when requested. const includeImage = payload?.includeImage === true; if (screenshotPath !== undefined && !includeImage) { const saved = { @@ -581,11 +561,7 @@ interface ImageToolResult { readonly [key: string]: unknown; } -/** - * Failures surface only their tag unless the tool describes them: the remote - * message may carry renderer or device output the agent should not see, and the - * tag is what it can act on. - */ +/** Expose only undescribed failures' tags; remote messages may contain renderer or device output. */ const imageToolFailure = (toolName: string, operation: string, failureText: string | ((error: E) => string)) => (cause: Cause.Cause) => { @@ -815,15 +791,7 @@ const handoffTargetIds = ( return typeof threadId === "string" ? [threadId] : []; }; -/** - * Runs a tool registration against a server that records an anonymous - * `mcp.tool.invoked` event for every handoff call and every failed call of - * other tools: the tool, the calling agent's provider, the outcome with its - * failure code, and the duration. A handoff tool also - * reports the caller's model, origin, and delegation depth, the settings the - * agent chose, and the provider, model, and modes of each thread that received - * the work. Ids, prompts, and free text are never recorded. - */ +/** Tool analytics must never include IDs, prompts or free text. */ const withToolAnalytics = /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* (registration: Effect.Effect) { diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index f830da3d4a03c..cc45af6bae4df 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; const ALL_MCP_CAPABILITIES = [ "preview", @@ -24,6 +25,8 @@ export interface McpThreadCaller { readonly threadId: ThreadId; readonly providerSessionId: string; readonly providerInstanceId: ProviderInstanceId; + /** A delegated child task; its parent owns the work it hands back. */ + readonly subagent?: boolean; } export interface McpClientCaller { @@ -32,18 +35,11 @@ export interface McpClientCaller { readonly access: AuthMcpClientAccess; } -/** - * A read-only client never reaches a write (`McpToolAccess` refuses it first), - * so it maps to the lowest mode rather than to nothing. - */ +/** Read-only clients map to the lowest mode because McpToolAccess blocks writes first. */ export const clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode => client === undefined || client.access === "read-only" ? "approval-required" : client.access; -/** - * A thread caller's omitted target falls back to its own thread; a client - * caller has no own thread, so tools that act as the caller (delegate_task, - * preview, worktree handoff) need `thread`. - */ +/** Thread callers default to their own thread; client callers need thread for tools that act as the caller. */ export interface McpInvocationScope { readonly environmentId: EnvironmentId; readonly capabilities: ReadonlySet; @@ -58,6 +54,16 @@ export class McpInvocationContext extends Context.Service< McpInvocationScope >()("t3/mcp/McpInvocationContext") {} +/** + * `McpSchema.EnabledWhen` for tools a delegated child must not see or call. + * EnabledWhen only receives the MCP client profile, so read the T3 caller from the request fiber. + */ +export const hiddenFromSubagents = (): boolean => { + const fiber = Fiber.getCurrent(); + const scope = fiber && Context.getOrUndefined(fiber.context, McpInvocationContext); + return scope?.thread?.subagent !== true; +}; + export type McpCapabilityError = C extends "preview" ? PreviewAutomationUnavailableError : McpCapabilityUnavailableError; diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 04c33a1509e94..ce424d7b662cd 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -15,13 +15,10 @@ import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; export interface McpCredentialRequest { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; - /** - * When false, the credential is minted without the "preview" capability so - * the user's choice to withhold agent browser access holds everywhere the - * token is honored (#7083). Defaults to full access. - */ + /** False omits the preview capability wherever the token is honored. Defaults to full access. */ readonly browserToolsAvailable?: boolean; readonly capabilities?: ReadonlySet; + readonly subagent?: boolean; } export interface McpIssuedCredential { @@ -109,6 +106,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + subagent: request.subagent === true, }, client: undefined, capabilities: new Set([ diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 7bd0d66fa0406..ecfb8a9993b30 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -216,7 +216,6 @@ const supervised = threadCaller(supervisedThreadId); const plan = threadCaller(planThreadId); const fullAccess = threadCaller(fullAccessThreadId); const ended = threadCaller(endedThreadId); -// Its turn ended, but a background subagent it started still runs. const background = threadCaller(backgroundThreadId); const oldProviderBackground = threadCaller(oldProviderThreadId); const archivedBackground = threadCaller(archivedThreadId); diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index 5cc8dc0c38b8f..9d99fb7da89e3 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -27,10 +27,7 @@ import { unavailable, } from "./threadAccess.ts"; -/** - * Their private constructors only stop the type checker; this also stops - * `Reflect.construct` and friends at runtime. - */ +/** Private constructors only enforce types; the token also blocks runtime construction. */ const builtHere: unique symbol = Symbol("t3/mcp/McpToolAccess/builtHere"); const refuseOutsideConstruction = (token: symbol) => { @@ -55,11 +52,7 @@ let handlersLayer: , EX, RX>( layer: Layer.Layer, EX, RX>, ) => HandlersLayer; -/** - * Effect's own registration functions accept any handler, so the - * `t3code/no-raw-mcp-registration` lint rule keeps Effect's `McpServer` inside - * McpHttpServer. - */ +/** t3code/no-raw-mcp-registration confines Effect's unchecked registration API to McpHttpServer. */ export class Declaration { readonly #handle: Handler; private constructor(token: typeof builtHere, handle: Handler) { @@ -117,10 +110,7 @@ export const actsAsCaller = (handle: (params: P) => Effect.Effect(handle: (params: P) => Effect.Effect) => declare((params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params)))); -/** - * Unlike `writes` it does not need thread control; the handler checks its - * capability, as the capability toolkits do. - */ +/** Requires handler capability checks, not thread control. */ export const writesWithCapability = (handle: (params: P) => Effect.Effect) => declare((params: P) => writingCaller.pipe(Effect.flatMap(() => handle(params)))); @@ -140,9 +130,7 @@ export const writesThreads = ( yield* assertTargetWithinLimits(caller.limits, target); } } - // The target's user can raise its modes after the check above; the - // orchestrator checks again under the thread's lock and records its - // refusal here, since handlers wrap dispatch errors their own way. + // Modes can rise after this check; the orchestrator rechecks under lock and records refusal before handlers wrap errors. const refused = yield* Ref.make(undefined); return yield* handle(params).pipe( Effect.provideService(DispatchModeLimit, { ...caller.limits, refused }), @@ -196,10 +184,7 @@ export const startsThreads = ( const fullAccessRequired = "Changing projects or environment settings needs a live full-access/default calling thread or a full-access client."; -/** - * `check` re-checks the caller wherever the handler waits before writing, such - * as for a lock, since the caller's modes can change meanwhile. - */ +/** Recheck after waits before writing; caller modes may have changed. */ export const writesEnvironment = ( handle: ( params: P, diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 9d2f10bc6c02b..d7f9746f76ea5 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -468,7 +468,6 @@ it("readThread and sendToThread reach threads in other projects", async () => { .pipe(Effect.flip); expect(staleDelete.code).toBe("parent_not_active"); - // A background item keeps only its owning instance live, even after a switch. const backgroundTasks = [ { taskId: "background-subagent", @@ -512,7 +511,6 @@ it("readThread and sendToThread reach threads in other projects", async () => { .pipe(Effect.flip); expect(mismatched.code).toBe("parent_not_active"); - // A fresh active run wins over the earlier inactive projection and empty roster. parentShell = liveThreadShell(parentThreadId); const restarted = yield* service.sendToThread(makeScope(), { threadId: foreignThreadId, diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index bba4e816cacae..6ce02cbb32e2a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -5,6 +5,7 @@ import { EnvironmentId, MessageId, NodeId, + ORCHESTRATOR_MCP_RUNNING_TASK_NEXT, type OrchestratorMcpCapabilitiesResult, OrchestratorMcpTarget, type OrchestrationV2ThreadShell, @@ -89,7 +90,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [ { id: childRunId, @@ -223,7 +224,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [ { id: RunId.make("run:mcp-restart-child"), @@ -292,6 +293,114 @@ describe("OrchestratorMcpService", () => { }), ); + it.effect("tells the parent to end its turn only while its task still runs", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-next-parent"); + const childThreadId = ThreadId.make("thread:mcp-next-child"); + const taskId = NodeId.make("node:mcp-next-task"); + let finished = false; + let pendingRequest = false; + const parentProjection = () => + ({ + thread: { id: parentThreadId }, + runs: [], + contextTransfers: [], + subagents: [ + { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId, + driver: "codex", + model: "gpt-5.6-terra", + status: finished ? "completed" : "running", + result: finished ? "done" : null, + completionDelivery: { state: "pending" }, + }, + ], + }) as unknown as OrchestrationV2ThreadProjection; + const childRun = () => ({ + id: RunId.make("run:mcp-next-child"), + ordinal: 1, + status: finished ? ("completed" as const) : ("running" as const), + delegatedTaskId: taskId, + }); + const childProjection = () => + ({ + thread: { id: childThreadId, title: "Child task" }, + runs: [childRun()], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + runtimeRequests: pendingRequest ? [{ status: "pending" }] : [], + nodes: [], + turnItems: [], + }) as unknown as OrchestrationV2ThreadProjection; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection() : childProjection()), + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + getProjectThreadRecords: () => Effect.succeed(childProjection()), + waitForThread: () => + Effect.succeed({ + threadId: childThreadId, + run: childRun() as unknown as OrchestrationV2ThreadProjection["runs"][number], + timedOut: !finished, + }), + dispatch: () => Effect.succeed({} as never), + }), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-next"), + requestNamespace: "provider-session:mcp-next", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-next", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const running = yield* service.taskStatus(scope, taskId); + assert.equal(running.status, "running"); + assert.equal(running.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + const waiting = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.isTrue(waiting.timedOut); + assert.equal(waiting.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + + // A child waiting on a question or approval needs an answer, not a wake. + pendingRequest = true; + const asking = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.isTrue(asking.timedOut); + assert.notProperty(asking, "next"); + pendingRequest = false; + + finished = true; + const completed = yield* service.taskStatus(scope, taskId); + assert.equal(completed.status, "completed"); + assert.notProperty(completed, "next"); + const waited = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.equal(waited.status, "completed"); + assert.notProperty(waited, "next"); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect("does not dispose delivery when a nonterminal task has no active child run", () => Effect.gen(function* () { const parentThreadId = ThreadId.make("thread:mcp-cancel-parent"); @@ -316,7 +425,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [], contextTransfers: [], messages: [], @@ -398,9 +507,9 @@ describe("OrchestratorMcpService", () => { }, ], }) as unknown as OrchestrationV2ThreadProjection; - // A finished run whose binding to the follow-up was lost (#19002). + // A finished run whose binding to the follow-up was lost. const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: childState === "no runs" ? [] @@ -503,7 +612,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -583,7 +692,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -669,7 +778,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -764,7 +873,7 @@ describe("OrchestratorMcpService", () => { [ childThreadId, { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: RunId.make("run:mcp-cancel-grandchild-child"), status: "running" }], contextTransfers: [], messages: [], @@ -992,7 +1101,7 @@ describe("OrchestratorMcpService provider resolution", () => { }) as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [], contextTransfers: [], messages: [], diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 53d57406c7cb9..d69a8649d13cf 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -5,6 +5,7 @@ import { MessageId, type ModelSelection, NodeId, + ORCHESTRATOR_MCP_RUNNING_TASK_NEXT, TurnItemId, type OrchestrationV2Run, type OrchestrationV2Subagent, @@ -67,6 +68,7 @@ import { } from "@t3tools/contracts"; import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, @@ -174,9 +176,7 @@ export interface OrchestratorMcpServiceShape { scope: McpInvocationScope, input: OrchestratorMcpDeleteScheduledTaskInput, ) => Effect.Effect; - /** - * The value never reaches the agent: the result is a status. - */ + /** Only status reaches the agent, never this value. */ readonly requestSecret: ( scope: McpInvocationScope, input: OrchestratorMcpRequestSecretInput, @@ -214,13 +214,7 @@ function failure(code: OrchestratorMcpFailure["code"], message: string): Orchest return new OrchestratorMcpFailure({ code, message }); } -/** - * A caller's thread is live while it is not archived, runs on the caller's - * provider instance, and has an active run or a background subagent that - * instance started. A background subagent outlives its run and calls tools - * with its thread's credential, which is revoked when the provider process - * goes away. - */ +/** Background subagents retain credentials until provider exit; archived threads and other instances cannot act. */ export const isLiveCallerShell = ( shell: Pick< OrchestrationV2ThreadShell, @@ -377,7 +371,6 @@ function providerConstraints( constraints.push("No V2 provider adapter is registered."); } if (provider === undefined) return constraints; - // Driver availability comes from the registry, even when health probes are skipped. if (!isProviderAvailable(provider)) { constraints.push(provider.unavailableReason ?? "Provider driver is unavailable."); } @@ -396,11 +389,7 @@ function providerConstraints( return constraints; } -/** - * Models without descriptors skip the descriptor checks (mirroring how model slugs are only - * validated when the provider advertises models), but duplicate ids always fail: downstream - * consumers disagree on whether the first or last value of a duplicated id wins. - */ +/** Skip checks without descriptors; reject duplicate IDs because consumers disagree on precedence. */ function invalidOptionSelections( selections: ReadonlyArray, descriptors: ReadonlyArray | undefined, @@ -848,7 +837,6 @@ function threadSnooze( }; } -/** The same links `list_thread_pull_requests` reports, without the snapshot details. */ function threadPullRequests( links: OrchestrationV2ThreadShell["pullRequests"], ): OrchestratorMcpThreadPullRequest[] { @@ -869,6 +857,7 @@ function listItemFromShell( return { threadId: shell.id, title: shell.title, + link: formatThreadLink(shell.id, shell.title), createdBy: shell.createdBy, creationSource: shell.creationSource, status: shell.activityRunStatus ?? shell.status, @@ -901,6 +890,7 @@ function threadDetail( threadId: projection.thread.id, projectId: projection.thread.projectId, title: projection.thread.title, + link: formatThreadLink(projection.thread.id, projection.thread.title), createdBy: projection.thread.createdBy, creationSource: projection.thread.creationSource, status: active?.status ?? latest?.status ?? "idle", @@ -1228,10 +1218,7 @@ const make = Effect.gen(function* () { return { ...caller, target } as const; }); - /** - * A thread caller writes to another thread only while its own run is live, - * so a credential that outlived its session cannot reach across threads. - */ + /** Cross-thread writes require a live caller run; stale session credentials cannot authorize them. */ const assertLiveCallerForOtherThread = ( scope: McpInvocationScope, parent: Pick | undefined, @@ -1307,11 +1294,7 @@ const make = Effect.gen(function* () { const loadOrchestrationCapableInstanceIds = () => providerAdapters.list().pipe(Effect.map((instanceIds) => new Set(instanceIds))); - /** - * Provider snapshots only re-probe while a client is in the foreground, so an - * unattended agent can see a provider as unavailable after it was fixed. - * Re-probe the requested instance once before refusing it. - */ + /** Background agents miss foreground-only health probes; re-probe once before refusing. */ const assertTargetEnabledForProject = ( parent: Pick, commandId: CommandId, @@ -1348,13 +1331,7 @@ const make = Effect.gen(function* () { readonly parent: Pick; readonly target: OrchestratorMcpTarget | undefined; readonly providers: ReadonlyArray; - /** - * Set only when the command that will dispatch this target already has a receipt: that - * dispatch can only replay the recorded result, never start anything new, so filtering - * candidates by the project's current settings would just refuse a retry that does no new - * work. Never set this for a resolution whose dispatch uses a different command id than the - * one checked for a receipt. - */ + /** Receipt replays bypass current settings because they start no work. Dispatch must reuse the checked command ID. */ readonly skipProjectEnablementFilter?: boolean; }): Effect.fn.Return { const requestedInstanceId = input.target?.providerInstanceId; @@ -1621,9 +1598,10 @@ const make = Effect.gen(function* () { instanceId: task.providerInstanceId, model: task.model, }; - const response = { + const result = { taskId: task.id, childThreadId: task.childThreadId, + link: formatThreadLink(task.childThreadId, childControls.thread.title), childRunId: childRun?.id ?? null, childNodeId: task.id, status, @@ -1656,6 +1634,10 @@ const make = Effect.gen(function* () { : pendingTaskApproval(childControls, task.id, childRun), waitTimedOut, } satisfies OrchestratorMcpDelegateTaskResult; + const response: OrchestratorMcpDelegateTaskResult = + endsTaskWait(result) || task.completionDelivery?.state === "disposed" + ? result + : { ...result, next: ORCHESTRATOR_MCP_RUNNING_TASK_NEXT }; if ( acknowledgeTerminal && isTerminalTaskStatus(status) && @@ -1698,8 +1680,7 @@ const make = Effect.gen(function* () { "orchestration_error", `Unable to watch delegated task ${taskId}: ${errorMessage(error)}`, ); - // Sequences are global, so one cursor taken before the first read - // replays anything either thread records after it. + // One global cursor before both reads captures later events from either thread. const afterSequence = yield* threadManagement .getThreadEventSequence(scope.thread.threadId) .pipe(Effect.mapError(streamError)); @@ -1907,9 +1888,7 @@ const make = Effect.gen(function* () { : input.bindToCurrentThread && parent !== undefined ? parent.thread.id : null; - // Rebinding changes where runs execute, so the workspace strategy must - // follow: unbinding a root-strategy task would otherwise run loose - // prompts in the shared project checkout. + // Update workspace strategy on rebinding so unbound root tasks cannot run in the shared checkout. const workspaceStrategy = input.bindToCurrentThread === undefined ? existing.workspaceStrategy @@ -1990,6 +1969,7 @@ const make = Effect.gen(function* () { label: input.label, reason: input.reason, ...(input.placeholder === undefined ? {} : { placeholder: input.placeholder }), + ...(input.delivery === "file" ? { delivery: "file" as const } : {}), secretStatus, }) .pipe( @@ -2068,6 +2048,12 @@ const make = Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "secret_request.status": status }); yield* Metrics.increment(Metrics.secretRequestsTotal, { status }); if (status !== "saved") return { status }; + if (input.delivery === "file") { + const file = yield* secretRequests + .saveToFile({ threadId, turnItemId }) + .pipe(Effect.mapError((error) => failure("orchestration_error", error.message))); + return { status, file }; + } const secretRef = yield* secretRequests.savedRef({ threadId: threadId, turnItemId }); if (Option.isNone(secretRef)) { return yield* failure( @@ -2460,7 +2446,7 @@ const make = Effect.gen(function* () { ), ), ); - // A follow-up that lost its child run (#19002) stays running on the parent, while its + // A follow-up that lost its child run stays running on the parent, while its // status here comes from the child's last run. Stop on the parent cancels that task. const cancelOrphan = Effect.gen(function* () { if (parentTask?.runId == null || !isOrchestrationV2WorkActive(parentTask.status)) @@ -2678,6 +2664,7 @@ const make = Effect.gen(function* () { ); return { threadId, + link: formatThreadLink(threadId, projection.thread.title), runId: run?.id ?? null, status: run?.status ?? "idle", title: projection.thread.title, @@ -2869,6 +2856,7 @@ const make = Effect.gen(function* () { ); return { threadId: input.threadId, + link: formatThreadLink(input.threadId, target.thread.title), messageId, runId: result.run.id, ...(result.run.delegatedTaskId === undefined ? {} : { taskId: result.run.delegatedTaskId }), @@ -2877,7 +2865,7 @@ const make = Effect.gen(function* () { } satisfies OrchestratorMcpThreadSendResult; }), waitForThread: Effect.fn("OrchestratorMcpService.waitForThread")(function* (scope, input) { - const { target } = yield* loadScopedThread(scope, input.threadId); + const { parent, target } = yield* loadScopedThread(scope, input.threadId); const result = yield* threadManagement .waitForThread({ projectId: target.thread.projectId, @@ -2889,11 +2877,33 @@ const make = Effect.gen(function* () { ), }) .pipe(Effect.mapError(threadManagementFailure)); + const status = result.run === null ? "idle" : runOutcome(result.run); + const taskId = result.run?.delegatedTaskId; + const ownedTask = + result.timedOut && + status !== "waiting" && + parent?.subagents.some( + (task) => + task.id === taskId && + task.origin === "app_owned" && + task.threadId === parent.thread.id && + task.childThreadId === input.threadId && + task.completionDelivery?.state !== "disposed", + ) === true; + // A child waiting on a question or approval needs an answer, not a wake. + const childRequests = ownedTask + ? (yield* threadManagement + .getThreadRecords(input.threadId, ["runtimeRequests"]) + .pipe(Effect.mapError(threadManagementFailure))).runtimeRequests + : []; + const wakesCaller = + ownedTask && !childRequests.some((request) => request.status === "pending"); return { threadId: input.threadId, runId: result.run?.id ?? null, - status: result.run === null ? "idle" : runOutcome(result.run), + status, timedOut: result.timedOut, + ...(wakesCaller ? { next: ORCHESTRATOR_MCP_RUNNING_TASK_NEXT } : {}), } satisfies OrchestratorMcpThreadWaitResult; }), interruptThread: Effect.fn("OrchestratorMcpService.interruptThread")(function* (scope, input) { diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 7ae8bfbefead5..f9d618f0520e3 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -1027,7 +1027,6 @@ it.effect("keeps each thread's current tab when threads share a provider session ["thread-1", "tab-thread-1"], ["thread-2", "tab-thread-2"], ]); - // Each thread's agent owns its own tabs and tab budget. expect(routed[0]!.agentSessionId).not.toBe(routed[1]!.agentSessionId); }), ), diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index c1ae21dfd3b4c..2aced7dc3684b 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -93,11 +93,7 @@ interface PendingRequest { readonly context: PreviewAutomationRequestErrorContext; } -/** - * It lives exactly as long as the connection it names: `connectionId`/`queue` - * identity is what makes a lease valid, so a disconnected or replaced host is - * dropped on the next lookup. - */ +/** A lease belongs to this exact connectionId/queue; replacing the host invalidates it. */ interface HostAssignment { readonly clientId: ClientConnection["clientId"]; readonly connectionId: ClientConnection["connectionId"]; @@ -166,11 +162,7 @@ const selectorDiagnosticsFromInput = ( return {}; }; -/** - * One agent: a provider session on one thread. Codex and OpenCode run every - * thread of a provider instance in one session, so the session alone would - * share one current tab, tab budget, and tab ownership across chats. - */ +/** Codex/OpenCode share sessions across threads; include thread id to isolate tabs and ownership. */ const hostAssignmentKey = (scope: McpInvocationContext.McpThreadInvocationScope): string => `${scope.environmentId}\u0000${scope.thread.providerSessionId}\u0000${scope.thread.threadId}`; @@ -205,6 +197,14 @@ function remoteDetailKind(detail: unknown): RemoteDetailKind { } } +const readRemoteTimeoutMs = (detail: unknown): number | undefined => { + if (typeof detail !== "object" || detail === null || !("timeoutMs" in detail)) return undefined; + const timeoutMs = detail.timeoutMs; + return typeof timeoutMs === "number" && Number.isInteger(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : undefined; +}; + const MAX_REASON_CHARS = 500; const classifyResponseError = ( @@ -253,11 +253,16 @@ const classifyResponseError = ( ...context, ...remoteDiagnostics, }); - case "PreviewAutomationTimeoutError": + case "PreviewAutomationTimeoutError": { + const remoteTimeoutMs = readRemoteTimeoutMs(error.detail); return new PreviewAutomationTimeoutError({ ...context, ...remoteDiagnostics, + ...(remoteTimeoutMs === undefined + ? {} + : { timeoutMs: Math.min(context.timeoutMs, remoteTimeoutMs) }), }); + } case "PreviewAutomationControlInterruptedError": { const reason = decodeControlReason(error.detail); return new PreviewAutomationControlInterruptedError({ @@ -357,8 +362,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { completeStream = false, ) { if (completeStream) { - // Discard this generation's commands and complete the RPC stream so a - // responsive desktop can re-register after a timeout eviction. + // Close the evicted generation's RPC stream so a responsive desktop can re-register. yield* Queue.clear(queue); yield* Queue.end(queue); } else { @@ -515,9 +519,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const assigned = assignments.get(assignmentKey); const assignedConnection = assigned ? current.clients.get(assigned.clientId) : undefined; const hasLiveAssignment = assignedConnection?.environmentId === input.scope.environmentId; - // Keep one provider session on one physical desktop runtime so a - // multi-step browser interaction cannot jump between independent Electron - // cookie/DOM state. + // Pin interactions to one desktop runtime so cookie/DOM state cannot jump between hosts. const ownsTargetTab = (host: ClientConnection, visibleOnly = false) => host.liveTabs.some( (tab) => @@ -550,8 +552,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { if (!hasLiveAssignment) assignments.delete(assignmentKey); return [undefined, { ...current, assignments }] as const; } - // The environment host may install Chromium on its first open (up to - // ten minutes). Keep that request alive without replaying its effects. + // First open may install Chromium for ten minutes; keep it alive without replaying effects. const timeoutMs = input.timeoutMs ?? (input.operation === "open" && connection.preferred ? 660_000 : 15_000); const canReuseAssignedTab = @@ -612,8 +613,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () { const offered = yield* SynchronizedRef.modifyEffect(state, (current) => { - // A route can outlive its generation while another request evicts it. - // Serialize the live-generation check and offer with queue closure. + // Serialize generation checks and offers with queue closure; routes can outlive eviction. if ( current.clients.get(connection.clientId)?.queue !== connection.queue || !current.pending.has(requestId) @@ -642,18 +642,14 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { } return yield* new PreviewAutomationRequestQueueClosedError(requestContext); } - // The browser starts its operation timer after delivery. Allow its timeout - // response to arrive before treating the entire host as unresponsive. + // The browser timer starts on delivery; allow its timeout response before evicting the host. const responseTimeoutMs = input.updateCurrentTab === false ? timeoutMs : timeoutMs + HOST_RESPONSE_GRACE_MS; const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(responseTimeoutMs)); return yield* Option.match(result, { onNone: () => Effect.gen(function* () { - // Do not replay actions: the client may have applied them before - // becoming unreachable. A background metadata read has a short budget - // and changes nothing, so a slow one must not cut the host off from the - // agent's next call. + // Never replay possibly applied actions; slow metadata reads must not evict the host. if (input.updateCurrentTab !== false) { yield* disconnect(connection.clientId, connection.queue, true); } diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index 9c36a26045dc5..504ae749a78ac 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -50,9 +50,6 @@ const driver = ProviderDriverKind.make("claudeAgent"); const instanceId = ProviderInstanceId.make("claudeAgent"); const modelSelection = { instanceId, model: "claude-test" }; -// A delegated Claude child ends its turn while a background native subagent it -// started keeps running (#17154). - it.effect("task_cancel ends a delegated child's background native subagent", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.ts b/apps/server/src/mcp/ThreadMetadataMcpService.ts index e05200fd8eda0..421d728461eb1 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.ts @@ -203,8 +203,7 @@ const make = Effect.gen(function* () { const metadataEvent = dispatched.storedEvents.find( (stored) => stored.event.type === "thread.metadata-updated", ); - // A retried commandId replays the stored events of the first dispatch; - // compaction may have deleted them by then, so answer from the projection. + // Retries may find stored events compacted away; answer from the projection. const thread = metadataEvent?.event.type === "thread.metadata-updated" ? metadataEvent.event.payload diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 3902068894d63..c6a00aabcb0cc 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -348,9 +348,7 @@ const expectTypedFailure = (exit: Exit.Exit, expected: object) expect(reason.error).toMatchObject(expected); }; -// Resolves the service once from the harness layer; used by tests that must -// make several calls against the SAME instance (the in-flight guard lives in -// the layer's closure, so a fresh layer per call would never see it). +// Resolve once so calls share the in-flight guard. const resolveService = (harness: ReturnType) => Effect.gen(function* () { return yield* WorktreeMcpService.WorktreeMcpService; @@ -371,7 +369,6 @@ const runStatus = (harness: ReturnType) => return yield* service.status(harness.scope); }).pipe(Effect.provide(harness.layer)); -// Decodes tool arguments the way the MCP tool does before calling the service. const decodeHandoffInput = Schema.decodeUnknownEffect(WorktreeMcpHandoffInput); describe("t3_worktree_handoff", () => { diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 461b7044ba87f..8f02ee1546424 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -63,9 +63,7 @@ const make = Effect.gen(function* () { const setupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - // Serializes handoffs per thread: two concurrent calls could otherwise both - // pass the worktreePath === null check and each create a worktree, leaving - // one untracked on disk. + // Serialize handoffs per thread to avoid creating two worktrees and orphaning one. const handoffThreadsInFlight = new Set(); const requireThreadScope = (scope: McpInvocationScope) => @@ -225,14 +223,7 @@ const make = Effect.gen(function* () { const ids = yield* handoffIds(scope); - // uninterruptibleMask: only the potentially slow worktree creation itself - // stays interruptible (restore). From the moment it succeeds, through the - // binding, continuation queue, setup script launch, and result - // construction, there is no interruptible gap: a client cancel can - // therefore neither orphan the created worktree before the rollback is - // armed, nor skip setting up a worktree the thread was just bound to - // (once the binding commits, the scheduled session detach can sever this - // request's connection and interrupt the fiber). + // Mask interruption after worktree creation through setup; binding can detach and interrupt the caller. return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const worktree = yield* restore( @@ -273,10 +264,7 @@ const make = Effect.gen(function* () { ).pipe(Effect.ignoreCause({ log: true })); const recheckAndBind = Effect.gen(function* () { - // The projection was read before the potentially slow git work - // above; a concurrent binding (for example from the UI) could have - // attached the thread in the meantime. Re-check before committing so - // the race cannot leave a second, untracked worktree. + // A concurrent UI binding may have landed during git work; do not orphan a second worktree. const recheck = yield* loadThread(scope); if (recheck.thread.worktreePath !== null) { return yield* alreadyInWorktree(recheck.thread.worktreePath); @@ -309,18 +297,13 @@ const make = Effect.gen(function* () { ), ); }).pipe( - // Interrupt-only causes skip the removal: the binding may have - // committed, and force-deleting a worktree the thread now points at - // would be worse than leaking one. + // Do not remove on interruption alone; the thread binding may already have committed. Effect.onError((cause) => Cause.hasInterruptsOnly(cause) ? Effect.void : removeCreatedWorktree, ), ); - // Queue the continuation right after the binding commits: the detach - // that the metadata update schedules will terminate the calling - // session, and a durably queued message is what guarantees the thread - // re-launches inside the worktree. + // Queue continuation immediately after binding; detachment terminates the caller, so relaunch needs a durable message. const queueContinuation: Effect.Effect = Effect.suspend(() => input.continuationPrompt === undefined @@ -401,11 +384,7 @@ const make = Effect.gen(function* () { function* (callerScope, input) { yield* requireCapability(callerScope); const scope = yield* requireThreadScope(callerScope); - // uninterruptibleMask: the guard acquisition and the registration of the - // releasing finalizer happen with no interruptible gap in between. An - // interrupt landing between a bare add() and the start of an ensured - // effect would otherwise leak the guard entry and block every future - // handoff for this thread until restart. + // Acquire and register cleanup atomically so cancellation cannot strand the guard until restart. return yield* Effect.uninterruptibleMask((restore) => Effect.suspend(() => { if (handoffThreadsInFlight.has(scope.thread.threadId)) { diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index ba8dc4c3e5765..9f3a8a57c05b2 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -102,12 +102,7 @@ export const assertTargetWithinLimits = ( Effect.asVoid, ); -/** - * A thread caller acts only while it owns a live run, or a background - * subagent that run started, of a thread that is not archived, so a provider - * token that outlived its session cannot act. An OAuth client has no run; its - * session and ceiling are its authority. - */ +/** Thread callers need a live run or its background subagent in an unarchived thread. OAuth clients rely on their session and ceiling. */ export function assertLiveCaller({ caller, scope }: Caller) { if (caller === undefined) return Effect.void; return OrchestrationMcp.isLiveCallerShell(caller, scope.thread?.providerInstanceId) diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index e1a6428eba8d3..4c7f168f60128 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -34,10 +34,7 @@ export function resolveAttachmentReferences( }); } -/** - * The sweep counts its 24 hours from when the upload finishes, which can be up - * to the upload URL's lifetime after issue. - */ +/** The 24-hour sweep starts at upload completion, possibly one upload-URL lifetime after issue. */ const UPLOAD_OWNER_TTL_MS = 24 * 60 * 60 * 1000 + ATTACHMENT_UPLOAD_URL_TTL_MS; const uploadOwner = McpInvocationContext.McpInvocationContext.pipe( diff --git a/apps/server/src/mcp/toolkits/attachment/input.ts b/apps/server/src/mcp/toolkits/attachment/input.ts index f29d00d1f9f9d..1f37edbbf6d4d 100644 --- a/apps/server/src/mcp/toolkits/attachment/input.ts +++ b/apps/server/src/mcp/toolkits/attachment/input.ts @@ -2,8 +2,7 @@ import { ChatFileAttachment, ChatImageAttachment } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -// Screenshot provenance is app-owned. Existing attachments recover it from stored -// metadata; exposing its recursive accessibility tree breaks model tool schemas. +// Keep screenshot provenance in app metadata; its recursive tree breaks model tool schemas. export const McpAttachmentInput = Schema.Union([ ChatImageAttachment.mapFields(Struct.omit(["source"])), ChatFileAttachment, diff --git a/apps/server/src/mcp/toolkits/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts index 62c44c9363abe..567de103e95f3 100644 --- a/apps/server/src/mcp/toolkits/attachment/tools.ts +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -33,21 +33,20 @@ const shared = { const AttachmentUploadTool = Tool.make("t3_attachment_prepare_upload", { ...shared, description: - "Create the app's signed upload URL. POST the exact bytes to relativeUrl on this MCP server's HTTP origin, then pass the attachment metadata and returned ID to t3_thread_send_attachments. Upload is separate from sending; provider attachment support is decided by its adapter.", + "Get an upload URL. POST the file's exact bytes to `relativeUrl` on this MCP server's HTTP origin, then send via `t3_thread_send_attachments` with the returned `attachmentId`.", parameters: Schema.Struct({ upload: AttachmentCreateUploadUrlInput }), success: AttachmentCreateUploadUrlResult, }).annotate(Tool.Destructive, true); const AttachmentDiscardTool = Tool.make("t3_attachment_discard", { ...shared, - description: - "Discard a pending upload. Already-delivered thread attachments are never deleted by this operation.", + description: "Discard a pending upload (not a sent attachment).", parameters: AttachmentDeleteInput, success: Schema.Struct({}), }).annotate(Tool.Destructive, true); const AttachmentSendTool = Tool.make("t3_thread_send_attachments", { ...shared, description: - "Send uploaded attachments to this thread or any other thread in this environment. Each call is a new message, without a retry key. Acceptance does not mean the provider can consume the attachment or has finished the turn. The target cannot have broader permission modes than the caller; failures retain claimed files when dispatch outcome is uncertain.", + "Send uploaded attachments as a new message to a thread. No retry key. Target modes cannot exceed yours. Success does not mean the provider read them or finished.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 1e4abf2062e46..76d8131e27bb9 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -59,8 +59,6 @@ const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), ); -// Effect returns a declared tool failure as `isError` with its encoded payload -// as JSON text, never as `structuredContent`. const declaredFailure = (result: McpSchema.CallToolResult) => { const text = result.content[0]; return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined; diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index 31d8c41dccf4d..f02f39b32462a 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -29,7 +29,7 @@ const DeviceToolFailure = Schema.Union([DeviceToolError, OrchestratorMcpFailure] const DeviceListTool = Tool.make("device_list", { description: - "List iOS Simulators and Android Emulators on this environment's device hosts, which platforms each host can run, and which devices are already open in this thread's Device panel. Call this before device_open when you do not know a device id.", + "List iOS Simulators and Android Emulators here, what each host can run, and what is open in this thread's Device panel. Call before `device_open` when you do not know a device id.", // An empty struct serializes as `anyOf [object, array]`, which some // providers reject and then drop every tool on the server with it. parameters: Schema.Struct({ @@ -49,7 +49,7 @@ const DeviceListTool = Tool.make("device_list", { const DeviceOpenTool = Tool.make("device_open", { description: - "Open a simulator or emulator for this thread: boots it if needed, starts its live stream, and shows it in the user's Device panel so they can watch. Returns the agent-device CLI invocation pinned to the device; drive the device with that CLI afterwards.", + "Open a simulator or emulator for this thread. Boots it if needed and shows it in the user's Device panel. Returns the `agent-device` CLI call pinned to that device. Drive the device with that CLI.", parameters: DeviceToolOpenInput, success: DeviceToolOpenResult, failure: DeviceToolFailure, @@ -70,7 +70,7 @@ const DeviceOpenTool = Tool.make("device_open", { export const DeviceScreenshotTool = Tool.make("device_screenshot", { description: - "Capture the current screen of an open device as a PNG image. Use it to see what the user sees; for taps and text use the agent-device CLI.", + "Get a PNG of an open device's screen. For taps and text, use the `agent-device` CLI.", parameters: DeviceToolTargetInput, success: DeviceToolScreenshotResult, failure: DeviceToolFailure, @@ -84,11 +84,9 @@ export const DeviceScreenshotTool = Tool.make("device_screenshot", { const DeviceCloseTool = Tool.make("device_close", { description: - "Remove a device from this thread's Device panel. Pass shutdown=true to also power the simulator or emulator off.", + "Remove a device from this thread's Device panel. `shutdown=true` also turns it off.", parameters: DeviceToolCloseInput, - success: Schema.Record(Schema.String, Schema.Never).annotate({ - description: "The device was closed.", - }), + success: Schema.Record(Schema.String, Schema.Never), failure: DeviceToolFailure, dependencies, }) diff --git a/apps/server/src/mcp/toolkits/environment/tools.ts b/apps/server/src/mcp/toolkits/environment/tools.ts index e441174900ce1..d684ff02e3402 100644 --- a/apps/server/src/mcp/toolkits/environment/tools.ts +++ b/apps/server/src/mcp/toolkits/environment/tools.ts @@ -40,7 +40,7 @@ const shared = { const EnvironmentReadTool = Tool.make("t3_environment_read", { ...shared, description: - "Read this server's identity and selected environment preferences. Provider/model availability is exposed by orchestrator_capabilities. Writing instructions are limited to 4,000 characters.", + "Read this server's identity and preferences. Providers and models: `orchestrator_capabilities`.", success: Schema.Struct({ environmentId: ExecutionEnvironmentDescriptor.fields.environmentId, label: Schema.String, @@ -54,7 +54,7 @@ const EnvironmentReadTool = Tool.make("t3_environment_read", { const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update", { ...shared, description: - "Update selected environment-wide preferences through normal settings persistence and notifications. Requires a live full-access/default calling thread. Omitted fields are preserved; empty customInstructions clears them.", + "Update environment-wide preferences. Live full-access or default caller thread only. Omitted fields are unchanged; `customInstructions: ''` clears it.", parameters: Schema.Struct({ defaultThreadEnvMode: ServerSettingsPatch.fields.defaultThreadEnvMode, newWorktreesStartFromOrigin: ServerSettingsPatch.fields.newWorktreesStartFromOrigin, diff --git a/apps/server/src/mcp/toolkits/html/handlers.ts b/apps/server/src/mcp/toolkits/html/handlers.ts index edd7146f81d00..1a402ca0bb4e1 100644 --- a/apps/server/src/mcp/toolkits/html/handlers.ts +++ b/apps/server/src/mcp/toolkits/html/handlers.ts @@ -20,8 +20,7 @@ const toFailure = (error: { readonly _tag: string; readonly message: string }) = }); const handlers = { - // The headless browser runs on the host and can open local files, so only - // agents T3 launched, which already work on this machine, get it. + // Local-file access limits this host browser to T3-launched agents. html_preview: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { const htmlRender = yield* HtmlRender.HtmlRender; diff --git a/apps/server/src/mcp/toolkits/html/tools.ts b/apps/server/src/mcp/toolkits/html/tools.ts index 24859c5762886..6f67d58a8a38c 100644 --- a/apps/server/src/mcp/toolkits/html/tools.ts +++ b/apps/server/src/mcp/toolkits/html/tools.ts @@ -21,20 +21,20 @@ const Html = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(512_0 }); const PAGE_RULES = - 'Write one self-contained document with inline