diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index d7a034f91106..06983eaa71fd 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -1,6 +1,5 @@ import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config"; -// Org members and collaborators can approve their own pull requests. const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? ""); @@ -17,11 +16,7 @@ export default defineConfig((ctx) => ({ docstrings: { mode: "off" }, override_requested_reviewers_only: isGated(ctx), }, - path_filters: [ - // Vendored read-only reference checkouts of upstream Effect and Alchemy - // (see scripts/lib/reference-repos.ts). Nothing imports from them. - "!.repos/**", - ], + path_filters: ["!.repos/**"], path_instructions: [ { path: "{apps,packages,infra}/**/*.ts", diff --git a/.gitattributes b/.gitattributes index 00b0e5eccb98..ffcc4445fe8a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,6 @@ # Batch files need CRLF for cmd.exe to parse them reliably. *.cmd text eol=crlf *.bat text eol=crlf +# GitHub's GraphQL schema and the operations graphqlgen writes from it. +packages/source-control-github/schema/** linguist-generated +**/*.graphql.ts linguist-generated diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 793e8a18b2a5..353a1f41a261 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -16,6 +16,7 @@ github:aoright github:arhxam github:bil0000 github:binbandit +github:bompus github:Brechard github:btsouth github:cestercian @@ -52,6 +53,7 @@ github:mackinleysmith github:maria-rcks github:MatthewFeroz github:maxwellyoung +github:mweinbach github:mwolson github:myacoub91 github:naMqe-h diff --git a/.github/actions/ci-changes/action.yml b/.github/actions/ci-changes/action.yml new file mode 100644 index 000000000000..ecf787404657 --- /dev/null +++ b/.github/actions/ci-changes/action.yml @@ -0,0 +1,63 @@ +name: CI changes +description: Decide which CI jobs a change needs. See .github/scripts/ci-changes.cjs. +outputs: + typecheck: + value: ${{ steps.detect.outputs.typecheck }} + build: + value: ${{ steps.detect.outputs.build }} + test: + value: ${{ steps.detect.outputs.test }} + test_packages: + value: ${{ steps.detect.outputs.test_packages }} + test_web: + value: ${{ steps.detect.outputs.test_web }} + test_server: + value: ${{ steps.detect.outputs.test_server }} + rust: + value: ${{ steps.detect.outputs.rust }} + release_smoke: + value: ${{ steps.detect.outputs.release_smoke }} + mobile_native: + value: ${{ steps.detect.outputs.mobile_native }} +runs: + using: composite + steps: + - id: detect + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE_SHA: ${{ github.event.before }} + run: | + set -uo pipefail + + decide() { + node .github/scripts/ci-changes.cjs "$@" | tee -a "$GITHUB_OUTPUT" + exit "${PIPESTATUS[0]}" + } + + fail_open() { + echo "$* Running every job." + decide + } + + # Reads the change from git rather than the REST API: every job that + # runs this shares the repository's 1,000 requests/hour GITHUB_TOKEN quota. + if [[ "$EVENT_NAME" == pull_request ]]; then + # A pull request checks out GitHub's merge commit with fetch-depth: 2, + # so its first parent is the base branch and the diff is the PR's change. + base=HEAD^1 + mode=() + else + base=$BEFORE_SHA + git fetch --quiet --no-tags --depth=1 --filter=blob:none origin "$base" \ + || fail_open "Could not fetch ${base}." + # main keeps a full run as the backstop for anything the gate misses. + mode=(--full) + fi + + # --no-renames lists a rename as its old and new path: renaming a file out + # of a package changes that package just like editing it. It also keeps + # git from fetching file contents to score renames. + git diff --name-only --no-renames "$base" HEAD > "$RUNNER_TEMP/changed-paths" \ + || fail_open "Could not diff against ${base}." + decide --paths "$RUNNER_TEMP/changed-paths" "${mode[@]}" diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml new file mode 100644 index 000000000000..ab3fb7deba6b --- /dev/null +++ b/.github/actions/setup-ci-deps/action.yml @@ -0,0 +1,30 @@ +name: Setup CI dependencies +description: Install Vite+ and the workspace dependencies from cache. +runs: + using: composite + steps: + # setup-vp reuses an installed exact version, so restoring its home skips the + # installer download on every job. + - name: Resolve Vite+ version + id: vp + shell: bash + run: | + echo "version=$(sed -nE 's/^ vite-plus: ([0-9][^ ]*)$/\1/p' pnpm-workspace.yaml)" >> "$GITHUB_OUTPUT" + + - name: Cache Vite+ + if: steps.vp.outputs.version != '' + uses: actions/cache@v6 + with: + path: ~/.vite-plus + key: vite-plus-home-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.vp.outputs.version }} + + # The pnpm store stays a cache tarball on the runner's own disk. A store on a + # Blacksmith sticky disk mounts in under a second, but pnpm then copies every + # package across filesystems instead of hard-linking it: install took 28-49s + # against 9s plus an 11s restore. + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs index 377ac36a22a8..943bf116909d 100644 --- a/.github/scripts/check-nightly-release.cjs +++ b/.github/scripts/check-nightly-release.cjs @@ -40,7 +40,6 @@ async function assertReleaseSource({ github, context, releaseChannel }) { const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v"); -// Newest published nightly by publication time, or undefined when none exists. async function findLatestNightly({ github, context }) { const releases = await github.paginate(github.rest.repos.listReleases, { ...context.repo, @@ -51,7 +50,6 @@ async function findLatestNightly({ github, context }) { .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; } -// Runs after the workflow acquires the nightly concurrency lock. async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { const lastNightly = await findLatestNightly({ github, context }); @@ -81,9 +79,6 @@ async function shouldReleaseNightly({ github, context, core, now = Date.now() }) return true; } -// Stable releases build the commit the latest nightly shipped, so the stable -// build is one nightly users already ran. Returns the nightly tag, its commit, -// and the stable version that nightly was a preview of. async function resolveLatestNightlyCommit({ github, context, core }) { const lastNightly = await findLatestNightly({ github, context }); if (!lastNightly) { @@ -91,7 +86,6 @@ async function resolveLatestNightlyCommit({ github, context, core }) { } const tag = lastNightly.tag_name; - // repos.getCommit dereferences annotated tags, so this is the commit either way. const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag }); const version = /^(?:nightly-)?v(\d+\.\d+\.\d+)-nightly\./.exec(tag)?.[1]; if (!version) { diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs new file mode 100644 index 000000000000..1904f3790a4a --- /dev/null +++ b/.github/scripts/ci-changes.cjs @@ -0,0 +1,203 @@ +// 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 +// +// 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. +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)\//, + /^[^/]+\.md$/, + /^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. +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. +const EXTRA_OWNERS = [ + // Vite configs and tsconfigs of these apps import scripts/lib directly. + [/^scripts\/lib\//, ["@t3tools/desktop", "@t3tools/mobile", "@t3tools/web", "t3"]], + // scripts/build-desktop-artifact.ts reads the GNOME extension manifest. + [/^apps\/desktop\/gnome-extension\//, ["@t3tools/scripts"]], + [/^native\/browser-secret\//, ["@t3tools/desktop"]], + [/^native\/libghostty-vt\//, ["@t3tools/web"]], +]; +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). +const TEST_ONLY_DEPS = { + "@t3tools/provider-pi": ["@t3tools/client-runtime"], + "@t3tools/scripts": [ + "@t3tools/desktop", + "@t3tools/mobile", + "@t3tools/web", + "t3", + "@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. +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() { + const patterns = []; + let inPackages = false; + for (const line of fs.readFileSync(path.join(root, "pnpm-workspace.yaml"), "utf8").split("\n")) { + if (/^\S/.test(line)) inPackages = line.startsWith("packages:"); + const entry = inPackages && line.match(/^\s+-\s+["']?([^"'\s]+)/); + if (entry) patterns.push(entry[1]); + } + + const dirs = patterns.flatMap((pattern) => { + if (!pattern.endsWith("/*")) return [pattern]; + const parent = pattern.slice(0, -2); + return fs.readdirSync(path.join(root, parent)).map((name) => `${parent}/${name}`); + }); + + const packages = []; + for (const dir of dirs) { + const manifestPath = path.join(root, dir, "package.json"); + if (!fs.existsSync(manifestPath)) continue; + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const deps = { + ...manifest.dependencies, + ...manifest.devDependencies, + ...manifest.peerDependencies, + }; + packages.push({ + name: manifest.name, + dir, + hasTest: Boolean(manifest.scripts?.test), + deps: Object.keys(deps).filter( + (dep) => + String(deps[dep]).startsWith("workspace:") && + !BUILD_ONLY_DEPS[manifest.name]?.includes(dep), + ), + }); + } + return packages; +} + +// Returns the packages a path touches, [] for an inert path, or null when the +// path is not understood and everything must run. +function ownersOf(file, packages) { + if (INERT.some((pattern) => pattern.test(file))) return []; + // A manifest can change dependencies or versions other packages read. + if (path.posix.basename(file) === "package.json") return null; + if (RUNS_EVERYTHING.some((pattern) => pattern.test(file))) return null; + // scripts/build-desktop-artifact.test.ts reads the crates' sources. + if (RUST.test(file)) return ["@t3tools/scripts"]; + + const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : [])); + const owner = packages + .filter((pkg) => file.startsWith(`${pkg.dir}/`)) + .sort((a, b) => b.dir.length - a.dir.length)[0]; + if (owner) return [owner.name, ...extra]; + return extra.length > 0 ? extra : null; +} + +function decide(files, packages, full) { + const all = new Set(packages.map((pkg) => pkg.name)); + let affected = new Set(); + let everything = full || files === null; + let rust = false; + + for (const file of files ?? []) { + if (RUST.test(file)) rust = true; + const owners = ownersOf(file, packages); + if (owners === null) { + everything = true; + console.error(`Runs everything: ${file}`); + } else { + for (const owner of owners) affected.add(owner); + } + } + + // Release smoke reads scripts/ itself, not what the scripts tests read. + let scriptsChanged = everything; + if (everything) { + affected = all; + } else { + // Add dependents until nothing changes. + let grew = true; + while (grew) { + grew = false; + for (const pkg of packages) { + if (!affected.has(pkg.name) && pkg.deps.some((dep) => affected.has(dep))) { + affected.add(pkg.name); + grew = true; + } + } + } + scriptsChanged = affected.has("@t3tools/scripts"); + for (const [name, deps] of Object.entries(TEST_ONLY_DEPS)) { + if (deps.some((dep) => affected.has(dep))) affected.add(name); + } + } + + const tested = packages.filter( + (pkg) => pkg.hasTest && !OWN_TEST_JOB.has(pkg.name) && affected.has(pkg.name), + ); + const has = (name) => affected.has(name); + + return { + affected: [...affected].sort(), + outputs: { + typecheck: affected.size > 0, + build: has("@t3tools/desktop") || has("t3") || has("@t3tools/web"), + test: tested.length > 0, + // Empty means every package the Test job covers. + test_packages: everything ? "" : tested.map((pkg) => pkg.name).join(" "), + test_web: has("@t3tools/web"), + test_server: has("t3"), + rust: everything || rust, + release_smoke: scriptsChanged, + mobile_native: files === null || files.some((file) => MOBILE_NATIVE.test(file)), + }, + }; +} + +function main() { + const args = process.argv.slice(2); + const pathsIndex = args.indexOf("--paths"); + const files = + pathsIndex === -1 + ? null + : fs + .readFileSync(args[pathsIndex + 1], "utf8") + .split("\n") + .filter(Boolean); + const { affected, outputs } = decide(files, readWorkspace(), args.includes("--full")); + + console.error(`Affected packages: ${affected.join(", ") || "none"}`); + for (const [key, value] of Object.entries(outputs)) console.log(`${key}=${value}`); +} + +main(); diff --git a/.github/scripts/mirror-play-apk.cjs b/.github/scripts/mirror-play-apk.cjs index 3a7af06bee1c..90b368b2aa03 100644 --- a/.github/scripts/mirror-play-apk.cjs +++ b/.github/scripts/mirror-play-apk.cjs @@ -1,5 +1,5 @@ -// Mirrors the newest build on a Google Play track to a rolling GitHub -// prerelease as a universal APK. Play generates and signs that APK from the +// 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. const { execFileSync } = require("node:child_process"); @@ -18,7 +18,6 @@ function requireEnv(name) { return value; } -// Service account key -> OAuth access token via a self-signed JWT grant. async function playAccessToken(key) { const now = Math.floor(Date.now() / 1000); const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url"); @@ -77,109 +76,133 @@ function gh(...args) { function readRelease(tag) { try { - return JSON.parse(gh("release", "view", tag, "--json", "assets,body")); + return JSON.parse(gh("release", "view", tag, "--json", "assets,body,isDraft")); } catch { return undefined; } } -// Downloads the build's universal APK and uploads it, creating the release on -// first use. Returns false when Play lists no universal APK, which happens -// while it is still processing a fresh upload. -async function uploadApk({ token, appUrl, build, tag, apkName, notes, release }) { +// Download to a temporary directory so a failed request cannot leave a partial +// APK among the release assets. +async function downloadApk({ token, appUrl, build, apkName }) { const generated = await ( await play(token, "GET", `${appUrl}/generatedApks/${build.versionCode}`) ).json(); - // One entry per app signing key. More than one means a key rotation, so - // fail rather than guess which signature testers should get. const universal = (generated.generatedApks ?? []).filter((entry) => entry.generatedUniversalApk); - if (universal.length === 0) return false; + if (universal.length === 0) return undefined; if (universal.length > 1) { throw new Error(`Play returned ${universal.length} universal APKs, one per signing key.`); } const { downloadId } = universal[0].generatedUniversalApk; + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "apk-")); + const apkPath = path.join(directory, apkName); + try { + const download = await play( + token, + "GET", + `${appUrl}/generatedApks/${build.versionCode}/downloads/${encodeURIComponent(downloadId)}:download?alt=media`, + ); + await pipeline(Readable.fromWeb(download.body), fs.createWriteStream(apkPath)); + return apkPath; + } catch (error) { + fs.rmSync(directory, { recursive: true, force: true }); + throw error; + } +} - const apkPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "apk-")), apkName); - const download = await play( - token, - "GET", - `${appUrl}/generatedApks/${build.versionCode}/downloads/${encodeURIComponent(downloadId)}:download?alt=media`, - ); - await pipeline(Readable.fromWeb(download.body), fs.createWriteStream(apkPath)); - +function publishBeta(tag, apkPath, notes, title) { + const release = readRelease(tag); + if ( + release && + !release.isDraft && + release.assets.some( + (asset) => + asset.name === path.basename(apkPath) && asset.state === "uploaded" && asset.size > 0, + ) + ) + return; if (!release) { - // Prerelease with a non-v tag: release.yml, the desktop updater, the CLI, - // and the marketing site's latest-release lookup all ignore it. gh( "release", "create", tag, + "--draft", "--prerelease", "--latest=false", "--title", - "T3 Code for Android (nightly)", + title, "--notes", notes, ); } - // --clobber replaces an asset a failed upload left behind. gh("release", "upload", tag, apkPath, "--clobber"); - return true; + gh( + "release", + "edit", + tag, + "--draft=false", + "--prerelease", + "--latest=false", + "--title", + title, + "--notes", + notes, + ); } async function main() { const packageName = requireEnv("PACKAGE_NAME"); - const trackName = requireEnv("PLAY_TRACK"); - const tag = requireEnv("RELEASE_TAG"); - const key = JSON.parse(requireEnv("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON")); - const summary = (line) => - process.env.GITHUB_STEP_SUMMARY && - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${line}\n`); - - const token = await playAccessToken(key); + const outputDirectory = process.env.APK_OUTPUT_DIR; + const channel = outputDirectory ? "internal" : "beta"; + const track = outputDirectory ? "internal" : "alpha"; + const token = await playAccessToken(JSON.parse(requireEnv("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"))); const appUrl = `${PLAY_API}/${packageName}`; - const build = await newestTrackBuild(token, appUrl, trackName); + const build = await newestTrackBuild(token, appUrl, track); if (!build) { - console.log(`The ${trackName} track has no live release.`); + console.log(`The ${track} track has no live release.`); return; } - const safeName = build.versionName.replace(/[^0-9A-Za-z.]+/g, "-").replace(/^-|-$/g, ""); - const apkName = `t3code-android-${safeName}-${build.versionCode}.apk`; - const notes = [ - `T3 Code for Android that works with nightly servers, mirrored from the Google Play ${trackName} track. Current build: ${build.versionName} (${build.versionCode}).`, - "", - "The Play Store release can't connect to a nightly server yet. Install this APK if you can't join the Play testing group. Only the newest build is kept here.", - ].join("\n"); - - const release = readRelease(tag); - const assets = release?.assets ?? []; - // A failed upload can leave an empty asset under the final name. - const published = assets.some( - (asset) => asset.name === apkName && asset.state === "uploaded" && asset.size > 0, - ); - if (published) { + const apkName = `t3code-android-${channel}-${safeName}-${build.versionCode}.apk`; + const tag = `android-beta-${build.versionCode}`; + const release = outputDirectory ? undefined : readRelease(tag); + if ( + release && + !release.isDraft && + release.assets.some( + (asset) => asset.name === apkName && asset.state === "uploaded" && asset.size > 0, + ) + ) { console.log(`${apkName} is already published.`); - } else if (await uploadApk({ token, appUrl, build, tag, apkName, notes, release })) { - console.log(`Published ${apkName}.`); - summary(`:white_check_mark: Published \`${apkName}\` to the \`${tag}\` release.`); - } else { - // The next run retries. - console.log(`Play lists no universal APK for ${build.versionCode}.`); - summary( - `:hourglass: Play lists no universal APK for ${build.versionName} (${build.versionCode}).`, - ); return; } - - // Reconciled on every run so an interrupted cleanup finishes on the next one. - for (const stale of assets) { - if (stale.name.endsWith(".apk") && stale.name !== apkName) { - gh("release", "delete-asset", tag, stale.name, "--yes"); - } + const apkPath = await downloadApk({ token, appUrl, build, apkName }); + if (!apkPath) { + console.log(`Play is still processing the ${track} APK for ${build.versionCode}.`); + return; } - if (release && release.body.replace(/\r\n/g, "\n").trim() !== notes) { - gh("release", "edit", tag, "--notes", notes); + try { + if (outputDirectory) { + fs.mkdirSync(outputDirectory, { recursive: true }); + const destination = path.join(outputDirectory, apkName); + const temporary = `${destination}.tmp`; + try { + fs.copyFileSync(apkPath, temporary); + fs.renameSync(temporary, destination); + } finally { + fs.rmSync(temporary, { force: true }); + } + return; + } + const notes = [ + `T3 Code for Android beta, mirrored from Google Play closed testing. Build: ${build.versionName} (${build.versionCode}).`, + "", + "These builds have been promoted for wider testing, but may still contain bugs. The beta is optional; the store app also works with Nightly servers.", + ].join("\n"); + // The build number makes retries and unchanged hourly checks idempotent. + publishBeta(tag, apkPath, notes, `Android beta ${build.versionName} (${build.versionCode})`); + } finally { + fs.rmSync(path.dirname(apkPath), { recursive: true, force: true }); } } diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index 264a92b1c8c2..a85fec9ee04a 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -278,6 +278,13 @@ 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. + if (!sourceArtifact && source.conclusion === "success") { + core.info("CI skipped the server tests for this change; nothing to report."); + core.setOutput("publish", "false"); + return; + } const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, { owner, repo, diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index 3c8ab2578a55..5b1749fc53f4 100644 --- a/.github/scripts/thread-transfer-report.test.cjs +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -216,6 +216,48 @@ test("does not guess when a fallback commit belongs to multiple PRs", async () = assert.equal(fetchedPull, false); }); +test("does not report a passing run that skipped the server tests", async () => { + const outputs = {}; + const listWorkflowRunArtifacts = () => {}; + await resolve({ + github: { + paginate: async (method) => { + assert.equal(method, listWorkflowRunArtifacts); + return []; + }, + rest: { + actions: { listWorkflowRunArtifacts }, + pulls: { + get: async () => ({ + data: { head: { sha: "head-sha" }, base: { sha: "base-sha", ref: "main" } }, + }), + }, + }, + }, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { + id: 2, + event: "pull_request", + workflow_id: 3, + head_sha: "head-sha", + conclusion: "success", + pull_requests: [{ number: 5350 }], + }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + + assert.equal(outputs.publish, "false"); +}); + test("does not publish a stale result after the PR head advances", async () => { let listedComments = false; const info = []; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbdc1ec40e64..9b92d4095f20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,50 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - # Lint, typecheck, and the desktop build each get a runner so they run side by - # side instead of one after another. The `Check` job at the end of this file - # passes only when every other job does. + # Skips jobs a pull request cannot affect: a web change does not run the + # server shards, a server change does not run the mobile tests, and the macOS + # lint (~6.7x a Linux minute) runs only for native mobile sources. + # .github/scripts/ci-changes.cjs maps changed files to workspace packages and + # their dependents. Pushes to main still run every Linux job. Detection fails + # open: if the diff cannot be resolved, everything runs. + changes: + name: Changes + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 5 + outputs: + typecheck: ${{ steps.detect.outputs.typecheck }} + build: ${{ steps.detect.outputs.build }} + test: ${{ steps.detect.outputs.test }} + test_packages: ${{ steps.detect.outputs.test_packages }} + test_web: ${{ steps.detect.outputs.test_web }} + test_server: ${{ steps.detect.outputs.test_server }} + rust: ${{ steps.detect.outputs.rust }} + release_smoke: ${{ steps.detect.outputs.release_smoke }} + mobile_native: ${{ steps.detect.outputs.mobile_native }} + steps: + # Only the package manifests the script reads to build the dependency graph. + # Depth 2 brings the base commit the change is diffed against. + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 2 + sparse-checkout: | + /pnpm-workspace.yaml + /.github/scripts/ci-changes.cjs + /.github/actions/ci-changes/ + /*/package.json + /*/*/package.json + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Detect affected jobs + id: detect + uses: ./.github/actions/ci-changes + + # Lint and the desktop build each get a runner so they run side by side. + # Typecheck shares the build's install. Lint always runs: it formats every + # file, docs included. The `Check` job at the end of this file passes only + # when every other job passes or was skipped by `changes`. lint: name: Lint runs-on: blacksmith-8vcpu-ubuntu-2404 @@ -28,6 +69,7 @@ jobs: sparse-checkout: | /* !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false - name: Reject repository-owned PR assets @@ -39,11 +81,7 @@ jobs: fi - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true + uses: ./.github/actions/setup-ci-deps - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -52,37 +90,16 @@ jobs: - name: Check unused code run: vp run knip:check + - name: Check generated GitHub GraphQL operations + run: vp run --filter @t3tools/source-control-github generate --check + - name: Check run: vp check - typecheck: - name: Typecheck - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true - - - name: Ensure Electron runtime is installed - run: vp run --filter @t3tools/desktop ensure:electron - - - name: Typecheck - run: vpr typecheck - build: - name: Build + name: Build and Typecheck + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: @@ -92,12 +109,15 @@ jobs: sparse-checkout: | /* !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false - uses: ./.github/actions/setup-apt-mirrors + if: needs.changes.outputs.build != 'false' # Runs in the background while Vite+ installs; the step before the build waits for it. - name: Start installing browser secret helper build libraries + if: needs.changes.outputs.build != 'false' run: | sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources ( @@ -107,16 +127,27 @@ jobs: ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true + uses: ./.github/actions/setup-ci-deps - name: Ensure Electron runtime is installed + id: electron run: vp run --filter @t3tools/desktop ensure:electron + # Runs in the background while the build runs, and reports in the last + # step: neither result hides the other. + - name: Start typecheck + id: typecheck + if: ${{ !cancelled() && steps.electron.outcome == 'success' && needs.changes.outputs.typecheck != 'false' }} + run: | + ( + set +e + vpr typecheck + echo "$?" > "$RUNNER_TEMP/typecheck-status" + ) > "$RUNNER_TEMP/typecheck.log" 2>&1 < /dev/null & + - name: Finish installing browser secret helper build libraries + id: apt + if: ${{ !cancelled() && needs.changes.outputs.build != 'false' }} timeout-minutes: 5 run: | until test -s "$RUNNER_TEMP/apt-status"; do sleep 1; done @@ -124,11 +155,22 @@ jobs: exit "$(cat "$RUNNER_TEMP/apt-status")" - name: Build desktop pipeline + id: build + if: ${{ !cancelled() && steps.electron.outcome == 'success' && steps.apt.outcome == 'success' && needs.changes.outputs.build != 'false' }} run: vp run build:desktop - name: Verify preload bundle output + if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: node apps/desktop/scripts/verify-preload-bundle.mjs + - name: Typecheck + if: ${{ !cancelled() && steps.typecheck.outcome == 'success' }} + timeout-minutes: 5 + run: | + until test -s "$RUNNER_TEMP/typecheck-status"; do sleep 1; done + cat "$RUNNER_TEMP/typecheck.log" + exit "$(cat "$RUNNER_TEMP/typecheck-status")" + # Everything except `t3` (apps/server) and the web app, which has its own job # below. `--parallel` drops the package dependency ordering that `vp run` # applies by default: these `test` tasks declare no `dependsOn` and resolve @@ -137,6 +179,8 @@ jobs: # per runner is unchanged. test: name: Test + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: @@ -146,6 +190,7 @@ jobs: sparse-checkout: | /* !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false # Blacksmith boots GitHub's Ubuntu runner image (gcc is usually present), @@ -172,11 +217,7 @@ jobs: ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true + uses: ./.github/actions/setup-ci-deps - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -184,7 +225,7 @@ jobs: - name: Test preview artifact validation run: python3 -B .github/scripts/stage-preview-bundle.test.py - - name: Test nightly release checks + - name: Test release checks run: node --test .github/scripts/check-nightly-release.test.cjs - name: Finish installing browser secret helper build libraries @@ -194,13 +235,24 @@ jobs: cat "$RUNNER_TEMP/apt.log" exit "$(cat "$RUNNER_TEMP/apt-status")" + # `changes` lists the affected packages, or nothing when all of them are. - name: Test - run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test + env: + TEST_PACKAGES: ${{ needs.changes.outputs.test_packages }} + run: | + filters=(--filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web') + if [[ -n "$TEST_PACKAGES" ]]; then + filters=() + for package in $TEST_PACKAGES; do filters+=(--filter "$package"); done + fi + vp run --parallel --concurrency-limit 4 "${filters[@]}" test # The web suite is the largest outside the server. In the shared Test job it # started last and ran alone for about 40s after everything else finished. test_web: name: Test Web + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: @@ -210,14 +262,36 @@ jobs: sparse-checkout: | /* !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: ./.github/actions/setup-ci-deps + + # The browser project runs React tests in headless Chromium. Same cache key as + # test_server, so both jobs share one downloaded browser. + - name: Resolve Playwright version + id: playwright_version + run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Chromium + id: chromium_cache + uses: actions/cache@v6 with: - node-version-file: package.json - cache: true - run-install: true + path: ~/.cache/ms-playwright + key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} + + - name: Install Chromium for web browser tests + if: steps.chromium_cache.outputs.cache-hit != 'true' + run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium + + - name: Install missing Chromium system dependencies + run: | + browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit) + test -n "$browser" + if ldd "$browser" | grep '=> not found'; then + node apps/web/node_modules/playwright/cli.js install-deps chromium + fi - name: Test run: vp run --filter @t3tools/web test @@ -228,6 +302,8 @@ jobs: # isolation that flag buys is preserved exactly. One file at a time keeps # about 1.5 cores busy, so 4 vCPU runners lose nothing against 8. The shards # split files by recorded duration (apps/server/src/testUtils/weightedShardSequencer.ts). + # The shards are the longest jobs, so they gate themselves instead of waiting + # ~15s for `changes`: a shard the change cannot affect stops after checkout. test_server: name: Test Server ${{ matrix.shard }} runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -235,30 +311,59 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4, 5, 6] + shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: + # Depth 2 brings the base commit the change is diffed against. - name: Checkout uses: actions/checkout@v6 with: + fetch-depth: 2 sparse-checkout: | /* !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false + - name: Detect affected jobs + id: changes + uses: ./.github/actions/ci-changes + - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true + if: steps.changes.outputs.test_server != 'false' + uses: ./.github/actions/setup-ci-deps # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` # job, which covers @t3tools/desktop, needs the download. + # Cache the pinned browser and skip apt unless shared libraries are missing. + - name: Resolve Playwright version + id: playwright_version + if: steps.changes.outputs.test_server != 'false' + run: echo "version=$(node -p "require('./apps/server/node_modules/playwright-core/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Chromium + id: chromium_cache + if: steps.changes.outputs.test_server != 'false' + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} + - name: Install Chromium for server browser integration tests - run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium + if: steps.changes.outputs.test_server != 'false' && steps.chromium_cache.outputs.cache-hit != 'true' + run: node apps/server/node_modules/playwright-core/cli.js install --only-shell chromium + + - name: Install missing Chromium system dependencies + if: steps.changes.outputs.test_server != 'false' + run: | + browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit) + test -n "$browser" + if ldd "$browser" | grep '=> not found'; then + node apps/server/node_modules/playwright-core/cli.js install-deps chromium + fi - name: Test + if: steps.changes.outputs.test_server != 'false' env: T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json @@ -296,25 +401,12 @@ jobs: if-no-files-found: ignore retention-days: 30 - transfer-report: - name: Transfer report artifact - needs: test_server - # On Blacksmith like every other job here: the GitHub-hosted queue is shared - # with the PR bots and can leave this job waiting until it is abandoned, - # which fails `Check` on an otherwise green run. - runs-on: blacksmith-2vcpu-ubuntu-2404 - timeout-minutes: 5 - steps: - - name: Require transfer measurements - uses: actions/download-artifact@v8 - with: - name: thread-transfer-results - path: ${{ runner.temp }}/thread-transfer - # Split out of Check and Test: both paid ~7-9s to install a Rust toolchain # for checks that take under 3s, on the critical path of every PR. rust: name: Rust + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.rust != 'false' }} runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 steps: @@ -324,6 +416,7 @@ jobs: sparse-checkout: | /* !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false - name: Setup Rust @@ -343,90 +436,10 @@ jobs: cargo test --locked --manifest-path "native/$crate/Cargo.toml" done - # The static analysis below needs a macOS runner, which bills ~6.7x a Linux - # minute, so gate it on the native sources it actually lints instead of paying - # for it on every push. Detection is API-only (no checkout) and fails open: if - # the diff cannot be resolved, the lint runs. - mobile_native_changes: - name: Mobile Native Changes - runs-on: blacksmith-2vcpu-ubuntu-2404 - timeout-minutes: 5 - permissions: - contents: read - pull-requests: read - outputs: - changed: ${{ steps.detect.outputs.changed }} - steps: - - name: Detect mobile native changes - id: detect - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - BEFORE_SHA: ${{ github.event.before }} - run: | - set -uo pipefail - - fail_open() { - echo "$* Running native static analysis." - echo "changed=true" >> "$GITHUB_OUTPUT" - exit 0 - } - - count_rows() { - printf '%s\n' "$1" | grep -c . || true - } - - # One row per changed file, holding the new path and, for a rename, - # the path it replaced: renaming a matched file out of the matched - # paths removes a lint input just like editing it. - row='[.filename, (.previous_filename // empty)] | @tsv' - - if [[ -n "${PR_NUMBER}" ]]; then - # The PR files endpoint stops at 3000 files and pagination cannot - # extend it, so cross-check against the count the PR itself reports. - expected=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.changed_files') \ - || fail_open "Could not read the pull request." - rows=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq ".[] | ${row}") \ - || fail_open "Could not resolve changed files." - - listed=$(count_rows "$rows") - if [[ "$listed" -lt "$expected" ]]; then - fail_open "GitHub listed only ${listed} of ${expected} changed files." - fi - else - rows=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BEFORE_SHA}...${GITHUB_SHA}" --jq ".files[]? | ${row}") \ - || fail_open "Could not resolve changed files." - - # The compare endpoint reports at most 300 files and pagination does - # not extend that list, so a full list may be hiding native changes. - listed=$(count_rows "$rows") - if [[ "$listed" -ge 300 ]]; then - fail_open "GitHub listed ${listed} changed files, the compare endpoint maximum." - fi - fi - - paths=$(tr '\t' '\n' <<< "$rows") - - # 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. - pattern='^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$' - - if grep -qE "$pattern" <<< "$paths"; then - echo "Native sources or lint configuration changed:" - grep -E "$pattern" <<< "$paths" - echo "changed=true" >> "$GITHUB_OUTPUT" - else - echo "No mobile native sources or lint configuration changed." - echo "changed=false" >> "$GITHUB_OUTPUT" - fi - mobile_native_static_analysis: name: Mobile Native Static Analysis - needs: mobile_native_changes - # Skip only on an explicit "no": a gate job that failed or errored leaves the - # output empty, and that must run the lint rather than silently skip it. - if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' }} + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }} runs-on: blacksmith-6vcpu-macos-26 timeout-minutes: 10 steps: @@ -455,6 +468,8 @@ jobs: release_smoke: name: Release Smoke + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: @@ -464,16 +479,13 @@ jobs: sparse-checkout: | /* !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false + # A full install: a filtered one that misses the cache first saves its + # partial store under the shared lockfile key for every later job. - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + uses: ./.github/actions/setup-ci-deps - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts @@ -488,15 +500,13 @@ jobs: if: ${{ always() }} needs: [ + changes, lint, - typecheck, build, test, test_web, test_server, - transfer-report, rust, - mobile_native_changes, mobile_native_static_analysis, release_smoke, ] @@ -508,7 +518,21 @@ jobs: RESULTS: ${{ toJSON(needs) }} run: | echo "$RESULTS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' - # Only the macOS lint skips on purpose, when no native mobile code changed. - echo "$RESULTS" | jq -e 'all(to_entries[]; - .value.result == "success" - or (.key == "mobile_native_static_analysis" and .value.result == "skipped"))' > /dev/null + # A job may skip only when `changes` said every gate it represents was + # not needed. Gates are keyed by job name, except merged Build and the + # macOS lint. + echo "$RESULTS" | jq -e '.changes.outputs as $gates + | {build: ["build", "typecheck"], mobile_native_static_analysis: ["mobile_native"]} as $renamed + | all(to_entries[]; + .value.result == "success" + or (.value.result == "skipped" + and (($renamed[.key] // [.key]) | all(.[]; $gates[.] == "false"))))' > /dev/null + + # thread-transfer-report.yml reads this artifact after CI, so a run whose + # server tests ran must have produced it. + - name: Require transfer measurements + if: needs.test_server.result == 'success' && needs.changes.outputs.test_server != 'false' + uses: actions/download-artifact@v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml index 43b6c8220aaf..6dcdd702c2da 100644 --- a/.github/workflows/desktop-macos-preview.yml +++ b/.github/workflows/desktop-macos-preview.yml @@ -78,7 +78,10 @@ jobs: - name: Install browser secret helper build libraries run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + # desktop-macos-preview-publish.yml packages this bundle as the preview channel. - name: Build JS bundle + env: + T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && 'preview' || 'dev' }} run: vp run build:desktop # Same layout as release.yml's js-bundle so release-desktop.yml can diff --git a/.github/workflows/mobile-android-apk.yml b/.github/workflows/mobile-android-apk.yml index 9a81b25c6d5b..c3db7142e67f 100644 --- a/.github/workflows/mobile-android-apk.yml +++ b/.github/workflows/mobile-android-apk.yml @@ -1,12 +1,7 @@ name: Mobile Android APK -# Publishes the newest nightly-compatible Android build as a sideloadable APK -# on the rolling "android-nightly" prerelease, for nightly users who can't join -# the Play testing group. Play re-signs uploaded bundles with the app signing -# key, so its universal APK matches the Play Store install and needs no signing -# secrets here. EAS submits asynchronously (--no-wait), so this polls Play -# instead of chaining off mobile-eas-production.yml; runs with nothing new exit -# early. +# Checks closed testing hourly. Each new beta gets one versioned prerelease. +# Unchanged checks publish nothing. on: schedule: # Avoid minute zero, when GitHub scheduled jobs are busiest. @@ -19,18 +14,13 @@ concurrency: jobs: mirror: - name: Mirror Play APK + name: Mirror beta APK runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 10 permissions: contents: write env: PACKAGE_NAME: com.t3tools.t3code - # eas.json submits v2-preview builds to this closed testing track; they - # speak the orchestration protocol nightly servers use, which the Play - # Store release can't. - PLAY_TRACK: v2 - RELEASE_TAG: android-nightly steps: - id: credentials name: Check for Play credentials diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 2ce1236c970b..16be6ee5f296 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -6,32 +6,15 @@ name: Mobile EAS Production # fingerprint (platform-specific deps + pnpm version) and errors. On this Linux # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. # -# Every merge to main that touches the mobile app reconciles, per platform: -# 1. Store builds: when no production build matches the current native -# fingerprint, or the latest build's version differs from app.config.ts, -# cut a new build and submit it (TestFlight + Play internal track). Native -# changes therefore ship a binary on merge, and bumping `version` is all -# it takes to start the next release train โ€” the first build of a version enters -# external-TestFlight beta review immediately, and later builds of the -# same version auto-approve until that version is released. After App -# Store approval, Apple closes the release train and `version` must be -# bumped before another iOS build can be submitted. Releasing to the App -# Store stays a manual App Store Connect step. -# 2. OTA: publish a production-channel update for each platform where at -# least one finished production build matches the current native -# fingerprint. Older binaries of the same major version with a matching -# fingerprint receive it too; the major version is part of the -# fingerprint (apps/mobile/fingerprint.config.js), so a new major never -# reaches the previous major's store binaries over the air. Until a -# build for the new fingerprint finishes, the OTA is skipped rather than -# published into the void. +# Every merge reconciles native fingerprints and publishes matching OTAs. +# iOS prereleases use the testflight profile (3.0.0); App Store binaries use +# production and the committed app version, built manually. Both receive OTAs +# for their own major version. Android continues using production automatically. +# TestFlight beta review may still be required for subsequent builds. # Hold native-change PRs (the "๐Ÿ“ฑ Native Change" label) and merge them as a # batch to avoid a store build per PR. # workflow_dispatch remains as a manual override for both modes (e.g. to # retry an errored build or force an OTA). -# Manual v2-preview builds keep the production app identity and disable OTA. -# They auto-submit to TestFlight and the Google Play closed testing track v2. -# Select the v2 branch when dispatching a preview build. on: workflow_dispatch: inputs: @@ -44,13 +27,13 @@ on: - build - update profile: - description: "Store build profile (v2-preview: TestFlight + Play closed track v2; build only)" + description: "production: store release; testflight: iOS 3.0.0 beta" required: true type: choice default: production options: - production - - v2-preview + - testflight platform: description: "Target platform" required: true @@ -61,7 +44,7 @@ on: - android - all version: - description: "Optional build version override (blank uses app.config.ts; an override is committed before building)" + description: "Optional build version override (blank uses profile default; store overrides are committed)" required: false type: string message: @@ -99,11 +82,16 @@ jobs: APP_VARIANT: production NODE_OPTIONS: --max-old-space-size=8192 steps: - - name: Reject preview OTA updates - if: github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview' && inputs.mode == 'update' + - name: Validate TestFlight inputs + if: github.event_name == 'workflow_dispatch' && inputs.profile == 'testflight' + env: + PLATFORM: ${{ inputs.platform }} + VERSION: ${{ inputs.version }} run: | - echo "::error::V2 previews use store builds only. Select mode=build." - exit 1 + if [ "$PLATFORM" != "ios" ] || [ -n "$VERSION" ]; then + echo "::error::TestFlight uses platform=ios and its fixed version. Use production for a committed store version." + exit 1 + fi - id: expo-token name: Check for EXPO_TOKEN @@ -119,7 +107,7 @@ jobs: - id: version_app_token name: Mint release app token for version override - if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' && inputs.profile != 'testflight' uses: actions/create-github-app-token@v2 with: app-id: ${{ secrets.RELEASE_APP_ID }} @@ -147,16 +135,6 @@ jobs: args: - --filter=@t3tools/mobile... - - name: Verify v2 preview source - if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview' - run: | - node --input-type=module -e ' - import * as environment from "./packages/contracts/src/environment.ts"; - if (environment.ORCHESTRATION_PROTOCOL_VERSION !== 2) { - throw new Error("V2 previews require a v2 source branch. Select the v2 branch when running this workflow."); - } - ' - - name: Expose pnpm if: steps.expo-token.outputs.present == 'true' run: | @@ -204,10 +182,10 @@ jobs: const path = "apps/mobile/app.config.ts"; const source = fs.readFileSync(path, "utf8"); const next = source.replace( - /^( version: ")[^"]+(".*)$/m, + /^( version: repoEnv\.T3CODE_MOBILE_VERSION \?\? ")[^"]+(".*)$/m, `$1${process.env.RELEASE_VERSION}$2`, ); - if (next === source && !source.includes(` version: "${process.env.RELEASE_VERSION}"`)) { + if (next === source && !source.includes(` version: repoEnv.T3CODE_MOBILE_VERSION ?? "${process.env.RELEASE_VERSION}"`)) { throw new Error("Could not update app version"); } fs.writeFileSync(path, next); @@ -234,6 +212,10 @@ jobs: env: MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} run: | + if [ "$MOBILE_BUILD_PROFILE" = "testflight" ]; then + T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')" + export T3CODE_MOBILE_VERSION + fi version="$(npx expo config --json --type public | jq -r '.version')" { echo "## Manual $MOBILE_BUILD_PROFILE build" @@ -252,28 +234,51 @@ jobs: MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait + # @expo/fingerprint swallows autolinking errors and returns no Expo module + # sources, so native changes stop moving the hash and OTAs reach binaries + # that lack them. Refuse to publish any OTA from such a fingerprint. + - name: Verify fingerprints hash Expo native modules + if: steps.expo-token.outputs.present == 'true' && (github.event_name == 'push' || inputs.mode == 'update') + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + PLATFORM: ${{ inputs.platform || 'all' }} + run: | + if [ "$PLATFORM" = "all" ]; then platforms="ios android"; else platforms="$PLATFORM"; fi + failed=0 + for platform in $platforms; do + # eas-cli prints an environment-loaded notice to stdout before the + # JSON even with --json, so discard everything before the document. + expo_modules="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq --arg reason "expoAutolinking${platform^}" '[.sources[] | select((.reasons // []) | index($reason))] | length')" + if [ "${expo_modules:-0}" -eq 0 ]; then + failed=1 + echo ":x: $platform: the native fingerprint hashes no Expo native modules โ€” OTAs and builds skipped" >> "$GITHUB_STEP_SUMMARY" + fi + done + exit "$failed" + - name: Publish OTA update (manual) if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} + OTA_MESSAGE: ${{ inputs.message || format('Production OTA ({0})', github.sha) }} run: | + if [ "$MOBILE_BUILD_PROFILE" = "testflight" ]; then + T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')" + export T3CODE_MOBILE_VERSION + fi eas update \ --channel production \ --environment production \ --platform ${{ inputs.platform }} \ - --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ + --message "$OTA_MESSAGE" \ --non-interactive - # One pass per platform. The fingerprint decides OTA vs store build: - # - a finished build matches โ†’ publish the OTA; - # - a queued/in-progress build matches โ†’ wait, the next merge after it - # finishes publishes the OTA (no duplicate build per merge); - # - no build matches โ†’ native drift, so cut and submit a new build. - # A version bump also cuts a build even when native code is unchanged, - # because only the major version is part of the fingerprint. - # An errored build for this fingerprint is not retried on push (it would - # rebuild on every merge); retry via workflow_dispatch mode=build. + # Reconcile each version separately because the major is in the fingerprint. + # Production iOS only receives OTAs here; store binaries are manual releases. + # Failed prerelease builds are retried manually, not on every merge. - id: reconcile name: Publish OTA or cut store build if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' @@ -282,17 +287,24 @@ jobs: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: | failed=0 - version="$(npx expo config --json --type public | jq -r '.version')" message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" - for platform in ios android; do + for target in ios:production ios:testflight android:production; do + platform="${target%%:*}" + profile="${target#*:}" + unset T3CODE_MOBILE_VERSION + if [ "$profile" = "testflight" ]; then + T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')" + export T3CODE_MOBILE_VERSION + fi + version="$(npx expo config --json --type public | jq -r '.version')" # eas-cli prints an environment-loaded notice to stdout before the # JSON even with --json, so discard everything before the document. hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" - matches="$(eas build:list --platform "$platform" --build-profile production --fingerprint-hash "$hash" --limit 20 --json --non-interactive)" + matches="$(eas build:list --platform "$platform" --build-profile "$profile" --fingerprint-hash "$hash" --limit 20 --json --non-interactive)" finished="$(jq '[.[] | select(.status == "FINISHED")] | length' <<<"$matches")" active="$(jq '[.[] | select(.status == "NEW" or .status == "IN_QUEUE" or .status == "IN_PROGRESS")] | length' <<<"$matches")" errored="$(jq '[.[] | select(.status == "ERRORED")] | length' <<<"$matches")" - latest_version="$(eas build:list --platform "$platform" --build-profile production --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')" + latest_version="$(eas build:list --platform "$platform" --build-profile "$profile" --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')" if [ "$finished" -gt 0 ]; then if eas update \ @@ -301,34 +313,39 @@ jobs: --platform "$platform" \ --message "$message" \ --non-interactive; then - echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" + echo ":white_check_mark: $platform/$profile: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" else failed=1 - echo ":x: $platform: OTA publish failed (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" + echo ":x: $platform/$profile: OTA publish failed (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" fi fi + # Never rebuild the released iOS version automatically. + if [ "$target" = "ios:production" ]; then + continue + fi + reason="" if [ "$finished" -eq 0 ] && [ "$active" -eq 0 ]; then if [ "$errored" -gt 0 ]; then - echo ":x: $platform: the production build for fingerprint \`$hash\` errored โ€” retry with workflow_dispatch mode=build" >> "$GITHUB_STEP_SUMMARY" + echo ":x: $platform/$profile: the build for fingerprint \`$hash\` errored โ€” retry with workflow_dispatch mode=build profile=$profile platform=$platform" >> "$GITHUB_STEP_SUMMARY" failed=1 continue fi reason="native fingerprint \`$hash\` has no build" elif [ "$latest_version" != "$version" ] && [ "$active" -eq 0 ]; then - reason="latest production build is $latest_version, app.config.ts says $version" + reason="latest $profile build is $latest_version, expected $version" fi if [ -z "$reason" ]; then if [ "$finished" -eq 0 ]; then - echo ":hourglass: $platform: production build for fingerprint \`$hash\` is in progress โ€” OTA resumes on the next merge after it finishes" >> "$GITHUB_STEP_SUMMARY" + echo ":hourglass: $platform/$profile: build for fingerprint \`$hash\` is in progress โ€” OTA resumes on the next merge after it finishes" >> "$GITHUB_STEP_SUMMARY" fi continue fi echo "$platform: $reason โ€” building" - if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then + if eas build --platform "$platform" --profile "$profile" --auto-submit --non-interactive --no-wait; then echo ":building_construction: $platform: scheduled production build and submission for $version ($reason)" >> "$GITHUB_STEP_SUMMARY" else failed=1 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 7c2bcc89bb1a..08552d1cd53a 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -501,6 +501,9 @@ jobs: # the runner, and only exact versions have a dist directory. Keep in # step with SEA_NODE_VERSION in apps/server/vite.config.ts. VP_NODE_VERSION: "26.8.2" + # The executable bundles the server again, so it needs the same stamp + # as release.yml's JS bundle. + T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && inputs.release_channel || 'dev' }} run: node apps/server/scripts/cli.ts build-exe --verbose - name: Import macOS signing certificate for the CLI archive diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb4927a86bdb..1173e99342d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -297,6 +297,10 @@ jobs: sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential + # The web package's browser project runs React tests in headless Chromium. + - name: Install Chromium for web browser tests + run: node apps/web/node_modules/playwright/cli.js install --with-deps --only-shell chromium + - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test @@ -521,7 +525,11 @@ jobs: # Runs t3#build (which depends on @t3tools/web#build) and # @t3tools/desktop#build, so apps/server/dist holds the server bundle # plus the web client and apps/desktop/dist-electron the Electron main. + # The release channel is already stable, nightly, or preview. Builds in + # other repositories report "dev" telemetry. - name: Build JS bundle + env: + T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && needs.preflight.outputs.release_channel || 'dev' }} run: vp run build:desktop # Two paths under apps/ so the artifact root is apps/; consumers download @@ -689,11 +697,8 @@ jobs: resource_key: win32-arm64 cli_archive: true - # npm gets the same bytes as the GitHub Release: the launcher plus one - # package per CLI archive. Preview publishes too, under the `preview` - # dist-tag, which nothing resolves unless asked for by name. - publish_cli: - name: Publish CLI to npm + release: + name: Publish GitHub Release needs: [ preflight, @@ -702,74 +707,13 @@ jobs: test, test_server, desktop_mac_arm64, - desktop_linux_x64, - desktop_linux_arm64, - desktop_win_x64, - desktop_win_arm64, - ] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} - runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 15 - permissions: - contents: read - id-token: write - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3... - - --filter=@t3tools/scripts... - - - name: Download all CLI archives - uses: actions/download-artifact@v8 - with: - pattern: cli-* - merge-multiple: true - path: release-cli - - - name: Build npm packages from CLI archives - run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages - - # A dry run of every package first: an auth or scope error here (the - # @t3code org missing, a package without a trusted publisher) fails - # before anything is live, instead of after some platforms already are. - - name: Check npm publish access (dry run) - run: | - if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --dry-run --verbose; then - echo "::error::npm publish --dry-run failed. Make sure the @t3code npm org exists and that t3 and every @t3code/t3- package has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 - exit 1 - fi - - - name: Publish CLI packages - run: node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose - - release: - name: Publish GitHub Release - needs: - [ - preflight, - desktop_mac_arm64, desktop_mac_x64, desktop_linux_x64, desktop_linux_arm64, desktop_win_x64, desktop_win_arm64, - publish_cli, ] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' && needs.publish_cli.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: @@ -807,6 +751,18 @@ jobs: merge-multiple: true path: release-assets + - name: Download internal Android APK for Nightly + if: needs.preflight.outputs.release_channel == 'nightly' + continue-on-error: true + env: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + PACKAGE_NAME: com.t3tools.t3code + APK_OUTPUT_DIR: release-assets + run: | + if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then + node .github/scripts/mirror-play-apk.cjs + fi + # Installers verify archives against this file, so it is written from the # signed bytes that get uploaded, never from an earlier stage. - name: Write CLI archive checksums @@ -888,6 +844,10 @@ jobs: echo 'release-assets/*.yml' fi echo 'release-assets/*.tar.gz' + # Only list APKs when Play has a processed internal build available. + for apk in release-assets/*.apk; do + [[ -f "$apk" ]] && echo "$apk" + done echo 'release-assets/SHA256SUMS' echo 'EOF' } >> "$GITHUB_OUTPUT" @@ -941,6 +901,49 @@ jobs: fail_on_unmatched_files: true token: ${{ github.token }} + # The `t3` npm package is a launcher that downloads this version's CLI + # archive from the GitHub Release, so it publishes only once that release + # exists. Preview publishes too, under the `preview` dist-tag, which + # nothing resolves unless asked for by name. + publish_cli: + name: Publish CLI to npm + needs: [preflight, release] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 15 + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3... + - --filter=@t3tools/scripts... + + - name: Build the npm launcher + run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages + + - name: Publish the npm launcher + run: | + if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose; then + echo "::error::npm publish failed. Make sure package t3 has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 + exit 1 + fi + publish_aur: name: Publish AUR package needs: [preflight, release] @@ -1058,8 +1061,8 @@ jobs: deploy_web: name: Deploy hosted web app - needs: [preflight, build_web, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build_web.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} + needs: [preflight, build_web, release, publish_cli] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build_web.result == 'success' && needs.release.result == 'success' && needs.publish_cli.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 10 env: diff --git a/.gitignore b/.gitignore index 38bac103f1f4..26b8965ecab8 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ dist-electron/ .showcase/ apps/mobile/.showcase/ apps/mobile/.generated/ +apps/mobile/.android-cache/ /.generated/ artifacts/app-store/screenshots/ .github/pr-assets/ diff --git a/AGENTS.md b/AGENTS.md index f0202081de83..810d26e31341 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,7 +154,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ - Server features are services; transports stay thin. A `ws.ts` RPC handler, HTTP route, or MCP tool decodes input, calls one service method, and maps errors. See [Effect services](docs/internals/effect-services.md). - `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations. See [Web UI](docs/internals/web-ui.md). - Inferred types over annotations. `any` is the enemy. -- Comments describe how a thing is used, and move when the code moves. To be used mostly to describe functions, not to annotate every line of behavior. +- 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. - 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/desktop/gnome-extension/captureFeedback.js b/apps/desktop/gnome-extension/captureFeedback.js index 1278101b57ca..fff63b896021 100644 --- a/apps/desktop/gnome-extension/captureFeedback.js +++ b/apps/desktop/gnome-extension/captureFeedback.js @@ -6,7 +6,6 @@ import St from "gi://St"; import * as Main from "resource:///org/gnome/shell/ui/main.js"; import { captureDestinationFrame, findCaptureDestination } from "./feedbackGeometry.js"; -/** A single, bounded, sender-owned flight. No live window clone or per-frame JS work. */ export class CaptureFeedback { begin(sender, pid, snapshot, options) { this.dispose(); @@ -54,7 +53,6 @@ export class CaptureFeedback { }); } } catch (error) { - // Optional decoration must not prevent attaching a successfully captured image. this._clearActors(); console.warn(`T3 capture effects unavailable: ${error.message}`); } diff --git a/apps/desktop/gnome-extension/captureService.js b/apps/desktop/gnome-extension/captureService.js index 3b436afd5e22..769f797bb4b5 100644 --- a/apps/desktop/gnome-extension/captureService.js +++ b/apps/desktop/gnome-extension/captureService.js @@ -1,4 +1,3 @@ -// This policy is independent of Shell so lifecycle and authorization can be tested without a desktop. export const CLIENT_NAMES = [ "com.t3tools.T3Code.SnapShot", "com.t3tools.T3Code.Development.SnapShot", diff --git a/apps/desktop/gnome-extension/metadata.json b/apps/desktop/gnome-extension/metadata.json index ff51dbb9a486..ce9d435c593d 100644 --- a/apps/desktop/gnome-extension/metadata.json +++ b/apps/desktop/gnome-extension/metadata.json @@ -2,7 +2,7 @@ "uuid": "snap-shot@t3.codes", "name": "T3 Code SnapShots", "description": "Allows T3 Code to capture the focused window, identify it for optional accessibility text, and animate it into T3 Code. Disable this extension to revoke access.", - "shell-version": ["45", "46", "47", "48", "49", "50"], - "version": 2, + "shell-version": ["45", "46", "47", "48", "49", "50", "51"], + "version": 3, "url": "https://github.com/pingdotgg/t3code" } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8e736a7d5410..d78ac0fd286a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.45", + "version": "0.1.0", "private": true, "type": "module", "main": "dist-electron/boot.cjs", @@ -42,5 +42,5 @@ "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, - "productName": "T3 Code (Alpha)" + "productName": "T3 Code (Beta)" } diff --git a/apps/desktop/scripts/build-browser-secret.mjs b/apps/desktop/scripts/build-browser-secret.mjs index c65d16a87e8b..fc069fbbc155 100644 --- a/apps/desktop/scripts/build-browser-secret.mjs +++ b/apps/desktop/scripts/build-browser-secret.mjs @@ -31,9 +31,7 @@ if (hostPlatform === "linux") { NodeFS.statSync(source).mtimeMs, NodeFS.statSync(NodeURL.fileURLToPath(import.meta.url)).mtimeMs, ) && matchesArchitecture(output); - } catch { - /* The first build has no output yet. */ - } + } catch {} if (!current) { let flags; try { diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 22475fb26c16..6cdcb43c9cb6 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -225,7 +225,6 @@ function killChildTree(signal) { return; } - // Kill direct children as a final fallback in case normal shutdown leaves stragglers. NodeChildProcess.spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore", }); diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index b355650f841f..780cf639fd18 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -1,5 +1,3 @@ -// This file mostly exists because we want dev mode to say "T3 Code (Dev)" instead of "electron" - import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeModule from "node:module"; @@ -15,7 +13,7 @@ const repoRoot = NodePath.resolve(desktopDir, "..", ".."); const devBundleIdSuffix = NodePath.basename(repoRoot) .toLowerCase() .replaceAll(/[^a-z0-9]+/g, ""); -const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; +const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Beta)"; const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` : "com.t3tools.t3code"; @@ -402,11 +400,10 @@ function buildMacLauncher(electronBinaryPath) { ); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { - // Keep Electron's native executable inside the branded bundle. 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. + // 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. writeDevelopmentEnvironmentScript(); writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 84d08cd9b6b8..c2440f364e7b 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -145,7 +145,6 @@ describe("electron development launcher", () => { const development = resolveMacLauncherIconPaths("/runtime", true); const production = resolveMacLauncherIconPaths("/runtime", false); - // The source icons are real repo paths, joined for the host. assert.match(development.sourceIconPath, /assets[\\/]dev[\\/]blueprint-macos-1024\.png$/); assert.equal(development.generatedIconPath, "/runtime/icon-dev.icns"); assert.match(production.sourceIconPath, /assets[\\/]prod[\\/]black-macos-1024\.png$/); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 24dbf2232f00..68febbc6386f 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -149,8 +149,6 @@ const fatalStartupCause = (stage: string, cause: Cause.Cause) => export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( function* (): Effect.fn.Return { - // Stop every backend in the pool with a timeout to guarantee the quit - // path makes progress even if a backend hangs during teardown. const pool = yield* DesktopBackendPool.DesktopBackendPool; const instances = yield* pool.list; yield* Effect.forEach( @@ -171,8 +169,6 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap start"); const settings = yield* desktopSettings.get; - // The renderer is served from the bundled client (or Vite in development) - // rather than through the local backend, so the window can open without one. const electronProtocol = yield* ElectronProtocol.ElectronProtocol; yield* electronProtocol.registerDesktopProtocol({ scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), @@ -243,11 +239,6 @@ const bootstrap = Effect.gen(function* () { } if (!(yield* Ref.get(state.quitting))) { - // The main window waits for the primary backend. In wsl-only mode that is - // the WSL backend, which can be slow to cold-boot โ€” show a "Connecting to - // WSL" splash immediately so the app feels responsive instead of presenting - // no window until WSL is ready. (Dual mode opens fast off the Windows - // primary, so no splash there.) if (settings.wslOnly === true && settings.wslBackendEnabled === true) { yield* desktopWindow.showConnectingSplash; } @@ -257,10 +248,6 @@ const bootstrap = Effect.gen(function* () { Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), ); - // Bring up the WSL backend if the user previously enabled it. The - // primary is already starting; reconcile fires off the WSL register - // in parallel rather than blocking primary readiness on a possibly - // slow first wsl.exe spawn. yield* Effect.forkScoped(wslBackend.reconcile); } }).pipe(Effect.withSpan("desktop.bootstrap")); diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts index dfe415644760..6e0c76842f24 100644 --- a/apps/desktop/src/app/DesktopAppActivation.test.ts +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -159,7 +159,6 @@ describe("desktop app control server", () => { }), ); - // Two desktop apps can share one state dir, such as nightly and a preview build. it.effect("keeps a newer app's socket when an older app on the same state dir quits", () => Effect.gen(function* () { const platform = yield* HostProcess.Platform; diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 19f24460106e..688bd789572f 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -47,7 +47,6 @@ export class DesktopAppActivationStartError extends Schema.TaggedError Promise; readonly close: () => Promise; } @@ -75,7 +74,6 @@ function requestIdFromUnknown(value: unknown): string { return "invalid-request"; } -/** Makes sure the socket directory is safe to use. Returns true when it had to create it. */ async function prepareUnixDirectory(input: { readonly directory: string; readonly userId: number | undefined; @@ -106,16 +104,6 @@ function closeServer(server: NodeNet.Server): Promise { return new Promise((resolve) => server.close(() => resolve())); } -/** - * Serves `t3 app` requests on the local control address until `close`. - * - * Two desktop apps can share one state dir, for example nightly and a preview - * build. They share one socket path, so on Unix: - * - The newest app takes the path over. - * - `close` removes the socket file only while it is still this app's socket. - * - An app binds the path again when it is gone, for example after the app that - * took it over quits. - */ export async function startDesktopAppControlServer(input: { readonly address: string; readonly directory: string | null; @@ -201,8 +189,8 @@ export async function startDesktopAppControlServer(input: { // 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. `rename` takes the address over in one step. `link` claims - // it only while it is free, and fails with EEXIST otherwise. + // is already gone. + const bindUnix = async (directory: string, mode: "take-over" | "claim-free") => { const staging = NodePath.join(directory, `${NodeCrypto.randomBytes(6).toString("hex")}.tmp`); const server = await listen(staging); @@ -242,7 +230,6 @@ export async function startDesktopAppControlServer(input: { } const next = await bindUnix(input.directory, "claim-free").catch( (error: NodeJS.ErrnoException) => { - // Another app bound the address first. if (error.code === "EEXIST") return null; throw error; }, @@ -268,7 +255,6 @@ export async function startDesktopAppControlServer(input: { }); watcher.on("error", input.onReclaimError); } catch (error) { - // The socket still works without a watcher. It only cannot recover after removal. input.onReclaimError(error); } }; @@ -309,7 +295,7 @@ export class DesktopAppActivation extends Context.Service< const { logWarning } = makeComponentLogger("desktop-app-activation"); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const desktopEnvironment = yield* DesktopEnvironment.DesktopEnvironment; const desktopWindow = yield* DesktopWindow.DesktopWindow; diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.ts b/apps/desktop/src/app/DesktopAppActivationBroker.ts index 221df9ca86dd..04f3d61717df 100644 --- a/apps/desktop/src/app/DesktopAppActivationBroker.ts +++ b/apps/desktop/src/app/DesktopAppActivationBroker.ts @@ -29,7 +29,6 @@ function failure( }; } -/** Holds CLI requests until the real desktop renderer is ready to handle them. */ export class DesktopAppActivationBroker { readonly #pending = new Map(); readonly #requestTimeoutMs: number; diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index cc0277d3ed10..1cd8485a1ec9 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -44,6 +44,7 @@ const layerElectronApp = (calls: ElectronAppCalls) => name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, + requestSingleInstanceLock: Effect.succeed(true), quit: Effect.void, exit: () => Effect.void, relaunch: () => Effect.void, @@ -217,12 +218,10 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - assert.deepEqual(calls.setName, ["T3 Code Alpha"]); - assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); + assert.deepEqual(calls.setName, ["T3 Code Beta"]); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Beta)"); assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); - // Packaged: the bundle's own icon stands, so a custom one the user - // attached survives. assert.deepEqual(calls.setDockIcon, []); }), { @@ -238,7 +237,7 @@ describe("DesktopAppIdentity", () => { }); it.effect.each([ - { stage: "Alpha", environment: {} }, + { stage: "Beta", environment: {} }, { stage: "Nightly", environment: { appVersion: "0.0.43-nightly.20260929.2428" }, @@ -262,7 +261,6 @@ describe("DesktopAppIdentity", () => { const runtimeName = calls.setName[0]; assert.isDefined(runtimeName); assert.equal(runtimeName, `T3 Code ${stage}`); - // RFC 9110's token grammar, after Electron removes ASCII spaces. assert.match(runtimeName.replaceAll(" ", ""), /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/); assert.equal(calls.setAboutPanelOptions[0]?.applicationName, `T3 Code (${stage})`); }), @@ -282,8 +280,6 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - // Electron shows a generic icon for an unpackaged run, which is the - // reason this call exists at all. assert.deepEqual(calls.setDockIcon, ["/icon.png"]); }), { diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts index 9c81b8ab1c04..479753375863 100644 --- a/apps/desktop/src/app/DesktopAssets.ts +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -119,7 +119,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( return yield* resolveResourcePath(`icon.${ext}`); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const context = yield* Effect.context< FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index b3aa2d14a0f7..58fff8297898 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -10,15 +10,17 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { beforeEach, vi } from "vite-plus/test"; -const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({ - createClerkBridgeMock: vi.fn(), - storageAdapter: { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - }, - storageMock: vi.fn(), -})); +const { createClerkBridgeMock, requestSingleInstanceLockMock, storageAdapter, storageMock } = + vi.hoisted(() => ({ + createClerkBridgeMock: vi.fn(), + requestSingleInstanceLockMock: vi.fn(() => true), + storageAdapter: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }, + storageMock: vi.fn(), + })); vi.mock("@clerk/electron", () => ({ createClerkBridge: createClerkBridgeMock, @@ -39,7 +41,6 @@ import * as DesktopWebLinks from "./DesktopWebLinks.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts"; -/** Clerk forwards web links; tests that are not about links ignore them. */ const ignoreWebLinks = DesktopWebLinks.DesktopWebLinks.of({ receive: () => Effect.void, setRendererReady: () => Effect.void, @@ -57,6 +58,7 @@ const layerDesktopClerk = ( openSystemSettings: () => Effect.succeed(false), copyText: () => Effect.void, }, + listeners = new Map void>(), ) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", @@ -70,6 +72,24 @@ const layerDesktopClerk = ( Effect.sync(() => { events.push(`setPath:${name}:${value}`); }), + requestSingleInstanceLock: Effect.sync(() => { + events.push("requestSingleInstanceLock"); + return requestSingleInstanceLockMock(); + }), + quit: Effect.sync(() => { + events.push("quit"); + }), + on: (name: string, listener: (...args: unknown[]) => void) => + Effect.acquireRelease( + Effect.sync(() => { + events.push(`on:${name}`); + listeners.set(name, listener); + }), + () => + Effect.sync(() => { + listeners.delete(name); + }), + ).pipe(Effect.asVoid), } as unknown as ElectronApp.ElectronApp["Service"]; return DesktopClerk.layer.pipe( @@ -88,6 +108,7 @@ const layerDesktopClerk = ( describe("DesktopClerk", () => { beforeEach(() => { createClerkBridgeMock.mockReset(); + requestSingleInstanceLockMock.mockReset().mockReturnValue(true); storageMock.mockReset(); }); @@ -107,22 +128,37 @@ describe("DesktopClerk", () => { [ { storage: storageAdapter, + manageSingleInstanceLock: false, passkeys: true, renderer: { scheme: "t3code-dev", host: "app" }, }, ], ]); assert.equal(cleanup.mock.calls.length, 1); - // The bridge acquires Electron's single-instance lock at creation, and - // the lock both lives in and creates the userData directory โ€” so the - // real path must be set before the bridge exists. - assert.deepEqual(events, ["setPath:userData:/tmp/app-data/t3code-dev", "createClerkBridge"]); + assert.deepEqual(events, [ + "setPath:userData:/tmp/app-data/t3code-dev", + "requestSingleInstanceLock", + "on:second-instance", + "createClerkBridge", + ]); storageMock.mockClear(); createClerkBridgeMock.mockClear(); }); }); it.each([ + { + name: "packaged macOS", + isDevelopment: false, + platform: "darwin" as const, + userData: "/tmp/app-data/t3code-v2", + }, + { + name: "packaged Linux", + isDevelopment: false, + platform: "linux" as const, + userData: "/tmp/app-data/t3code-v2", + }, { name: "packaged Windows", isDevelopment: false, @@ -144,8 +180,6 @@ describe("DesktopClerk", () => { events.push("createClerkBridge"); return { cleanup: vi.fn(), isPrimaryInstance: true }; }); - // runSync throws if the layer ever suspends, which would let Electron emit - // ready before the bridge exists. main.ts provides the same FileSystem. // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- The assertion IS that the layer builds synchronously; it.effect would mask a regression to async. Effect.runSync( Effect.scoped( @@ -155,7 +189,12 @@ describe("DesktopClerk", () => { ), ); - assert.deepEqual(events, [`setPath:userData:${userData}`, "createClerkBridge"]); + assert.deepEqual(events, [ + `setPath:userData:${userData}`, + "requestSingleInstanceLock", + "on:second-instance", + "createClerkBridge", + ]); }, ); @@ -207,63 +246,122 @@ describe("DesktopClerk", () => { }); }); - it.effect("registers the second-instance handler in the primary instance", () => { - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); - const quit = vi.fn(); - const registeredEvents: string[] = []; - const electronApp = { - quit: Effect.sync(quit), - on: (eventName: string) => - Effect.sync(() => { - registeredEvents.push(eventName); - }), - } as unknown as ElectronApp.ElectronApp["Service"]; - const electronWindow = {} as ElectronWindow.ElectronWindow["Service"]; + it.effect.each(["before configure", "after configure"] as const)( + "reveals the running window when a second instance launches %s", + (entry) => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const quit = vi.fn(); + const listeners = new Map void>(); + const revealed = Promise.withResolvers(); + const window = { id: 1 }; + const electronApp = { + quit: Effect.sync(quit), + on: (eventName: string, listener: (...args: unknown[]) => void) => + Effect.sync(() => { + listeners.set(eventName, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const electronWindow = { + currentMainOrFirst: Effect.succeedSome(window), + reveal: (value: unknown) => Effect.sync(() => revealed.resolve(value)), + } as unknown as ElectronWindow.ElectronWindow["Service"]; - return Effect.gen(function* () { - const clerk = yield* DesktopClerk.DesktopClerk; - const exit = yield* Effect.exit(Effect.scoped(clerk.configure)); + return Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + if (entry === "before configure") listeners.get("second-instance")?.({}, ["t3"]); + yield* clerk.configure; - assert.isTrue(Exit.isSuccess(exit)); - assert.equal(quit.mock.calls.length, 0); - assert.deepEqual(registeredEvents, ["open-url", "open-file", "second-instance"]); - }).pipe( - Effect.provide(layerDesktopClerk()), - Effect.provideService(ElectronApp.ElectronApp, electronApp), - Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), - Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), - ); - }); + assert.equal(quit.mock.calls.length, 0); + if (entry === "after configure") listeners.get("second-instance")!({}, ["t3"]); + assert.strictEqual(yield* Effect.promise(() => revealed.promise), window); + }).pipe( + Effect.scoped, + Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), + ); + }, + ); - it.effect("quits and interrupts startup in a secondary instance", () => { + it.effect("delivers queued startup links and later links once in arrival order", () => { storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: false }); - const quit = vi.fn(); - const registeredEvents: string[] = []; + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const listeners = new Map void>(); + const received: string[] = []; + const first = "t3code://new?prompt=first"; + const second = "t3code://threads/env-1/thread-1"; + const later = "t3code://new?prompt=later"; + const delivered = Promise.withResolvers(); const electronApp = { - quit: Effect.sync(quit), - on: (eventName: string) => + on: (name: string, listener: (...args: unknown[]) => void) => Effect.sync(() => { - registeredEvents.push(eventName); + listeners.set(name, listener); }), } as unknown as ElectronApp.ElectronApp["Service"]; - const electronWindow = {} as ElectronWindow.ElectronWindow["Service"]; return Effect.gen(function* () { const clerk = yield* DesktopClerk.DesktopClerk; - const exit = yield* Effect.exit(Effect.scoped(clerk.configure)); + listeners.get("second-instance")?.({}, ["t3", first]); + listeners.get("second-instance")?.({}, ["t3", second]); + assert.deepEqual(received, []); - assert.isTrue(Exit.hasInterrupts(exit)); - assert.equal(quit.mock.calls.length, 1); - assert.deepEqual(registeredEvents, []); + yield* clerk.configure; + listeners.get("second-instance")!({}, ["t3", later]); + yield* Effect.promise(() => delivered.promise); + + assert.deepEqual(received, [first, second, later]); }).pipe( - Effect.provide(layerDesktopClerk()), + Effect.scoped, + Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), Effect.provideService(ElectronApp.ElectronApp, electronApp), - Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), - Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), + Effect.provideService( + ElectronWindow.ElectronWindow, + {} as ElectronWindow.ElectronWindow["Service"], + ), + Effect.provideService( + DesktopWebLinks.DesktopWebLinks, + DesktopWebLinks.DesktopWebLinks.of({ + receive: (url) => + Effect.sync(() => { + received.push(url); + if (url === later) delivered.resolve(); + }), + setRendererReady: () => Effect.void, + }), + ), ); }); + + it.effect.each(["darwin", "win32", "linux"] as const)( + "quits a second launch before initializing Clerk or the application runtime on %s", + (platform) => + Effect.gen(function* () { + requestSingleInstanceLockMock.mockReturnValueOnce(false); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const events: string[] = []; + const runtime = layerDesktopClerk(false, events, platform).pipe( + Layer.flatMap(() => + Layer.effectDiscard( + Effect.sync(() => { + events.push("initializeApplicationRuntime"); + }), + ), + ), + ); + const exit = yield* Effect.exit(Effect.scoped(Layer.build(runtime))); + + assert.isTrue(Exit.hasInterrupts(exit)); + assert.deepEqual(events, [ + "setPath:userData:/tmp/app-data/t3code-v2", + "requestSingleInstanceLock", + "quit", + ]); + assert.equal(createClerkBridgeMock.mock.calls.length, 0); + assert.equal(storageMock.mock.calls.length, 0); + }), + ); }); it.effect( @@ -305,7 +403,7 @@ it.effect( assert.equal(event.preventDefault.mock.calls.length, 1); }).pipe( Effect.scoped, - Effect.provide(layerDesktopClerk()), + Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), @@ -313,7 +411,7 @@ it.effect( }, ); -it.effect.each(["startup", "open-url"] as const)( +it.effect.each(["startup", "open-url", "early-second-instance"] as const)( "receives hosted web sign-in through the desktop %s handler", (entry) => Effect.gen(function* () { @@ -372,6 +470,9 @@ it.effect.each(["startup", "open-url"] as const)( } as unknown as ElectronApp.ElectronApp["Service"]; yield* Effect.gen(function* () { const clerk = yield* DesktopClerk.DesktopClerk; + if (entry === "early-second-instance") { + listeners.get("second-instance")?.({}, ["t3", link]); + } yield* clerk.configure; if (entry === "open-url") { const event = { preventDefault: vi.fn() }; @@ -384,7 +485,7 @@ it.effect.each(["startup", "open-url"] as const)( assert.strictEqual(delivery?.flowId, request.flowId); assert.strictEqual(delivery?.returnUrl, request.returnUrl); }).pipe( - Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, shell)), + Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, shell, listeners)), Effect.provideService(HostProcess.Arguments, entry === "startup" ? ["t3", link] : ["t3"]), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService( @@ -417,13 +518,10 @@ it.effect("hands web and app links to the renderer and leaves other links alone" listeners.get("open-url")!(event, url); return event.preventDefault.mock.calls.length; }; - // macOS hands the default browser every web link. assert.strictEqual(open("https://example.com/page"), 1); assert.strictEqual(open("http://localhost:3000/"), 1); - // Prompt and thread links ride the same queue; the renderer routes them in the app. assert.strictEqual(open("t3code://new?prompt=hi"), 1); assert.strictEqual(open("t3code://threads/env-1/thread-1"), 1); - // Anything else is not a web page; Electron keeps its own handling. assert.strictEqual(open("mailto:hello@example.com"), 0); assert.strictEqual(open("t3code://somewhere-else"), 0); yield* Effect.yieldNow; @@ -434,7 +532,7 @@ it.effect("hands web and app links to the renderer and leaves other links alone" "t3code://threads/env-1/thread-1", ]); }).pipe( - Effect.provide(layerDesktopClerk(true, [], "darwin")), + Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), Effect.provideService(HostProcess.Arguments, ["t3"]), Effect.provideService(ElectronApp.ElectronApp, electronApp), Effect.provideService( diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 10eb76d7d9a5..505b48194cf7 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -86,6 +86,7 @@ export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHos function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { return createClerkBridge({ storage: storage({ path: stateDir }), + manageSingleInstanceLock: false, passkeys: true, renderer: { scheme: ElectronProtocol.getDesktopScheme(isDevelopment), @@ -100,12 +101,27 @@ export const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const shell = yield* ElectronShell.ElectronShell; - // The SDK bridge acquires Electron's profile-scoped single-instance lock. // Must not yield: the bridge registers a scheme Electron rejects once ready. const userDataPath = yield* DesktopUserData.resolveUserDataPath(environment); yield* electronApp.setPath("userData", userDataPath); - const bridge = yield* Effect.acquireRelease( + // 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. + 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); + }; + yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { + handleSecondInstance(argv); + }); + + yield* Effect.acquireRelease( Effect.try({ try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), catch: (cause) => @@ -135,16 +151,6 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); - // The SDK bridge holds Electron's single-instance lock (acquired at - // bridge creation) so OAuth deep-link callbacks on Windows/Linux are - // forwarded to the running app. In a secondary instance the bridge has - // already begun quitting the app; app.quit() is asynchronous, so stop - // bootstrap here before whenReady can fire. - if (!bridge.isPrimaryInstance) { - yield* electronApp.quit; - return yield* Effect.interrupt; - } - const startProviderAuthHandoff = (value: string | undefined) => { if (!value) return false; const request = readCodexAuthHandoff(value, environment.isDevelopment); @@ -189,8 +195,6 @@ export const make = Effect.gen(function* () { ); return true; }; - // Prompt links and thread links (`t3code://new?โ€ฆ`, `t3code://threads/โ€ฆ`) reach the - // renderer through the same queue as web links; it routes them in the app. const openAppLink = (value: string | undefined) => { if (!value || promptLinkAppPath(value) === null) return false; void runPromise(webLinks.receive(value)); @@ -198,7 +202,6 @@ export const make = Effect.gen(function* () { }; const args = yield* HostProcess.Arguments; args.some((value) => startProviderAuthHandoff(value) || openAppLink(value)); - // As the default browser, macOS hands T3 Code every web link through the same event. const openWebLink = (url: string) => { if (!DesktopWebLinks.isWebLink(url)) return false; void runPromise(webLinks.receive(url)); @@ -213,13 +216,12 @@ export const make = Effect.gen(function* () { ) event.preventDefault(); }); - // A browser opens HTML files too, which macOS hands over by path. yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => { if (!DesktopWebLinks.isWebPageFile(path)) return; event.preventDefault(); void runPromise(webLinks.receive(NodeURL.pathToFileURL(path).href)); }); - yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { + handleSecondInstance = (argv) => { if ( argv?.some( (value) => @@ -233,7 +235,8 @@ export const make = Effect.gen(function* () { if (Option.isSome(mainWindow)) yield* electronWindow.reveal(mainWindow.value); }), ); - }); + }; + for (const argv of pendingSecondInstances.splice(0)) handleSecondInstance(argv); }).pipe(Effect.withSpan("desktop.clerk.configure")), }); }); diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index b2305fe9840a..7b44fab01855 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -27,7 +27,6 @@ const environmentFor = ( appImagePath: Option.none(), } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); -/** The service for a packaged app; the launcher is written on Install. */ const commandIn = ( input: { home: string; baseDir?: string; platform?: NodeJS.Platform; isPackaged?: boolean }, spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"], @@ -48,7 +47,6 @@ const commandIn = ( : make; }); -/** A PowerShell that keeps the user's PATH in memory and can be told to fail reads. */ const fakePowerShell = (initial: string) => { const registry = { path: initial, failReads: false, writes: 0 }; const spawner = ChildProcessSpawner.make((command) => @@ -104,7 +102,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const link = path.join(home, ".local", "bin", "t3"); expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); - // Install writes the launcher itself, even when no local backend ever did. const installed = yield* command.install; expect(installed.installedPath).toBe(link); expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); @@ -112,7 +109,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect((yield* command.uninstall).installedPath).toBeNull(); expect(yield* fs.exists(link)).toBe(false); - // The launcher itself stays for setup commands. expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true); }).pipe(Effect.scoped), ); @@ -126,7 +122,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const theirs = path.join(home, ".local", "bin", "t3"); yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); yield* fs.writeFileString(theirs, "npm's t3\n"); - // Even a broken link in the next folder is someone else's. yield* fs.makeDirectory(path.join(home, "bin"), { recursive: true }); yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "t3")); @@ -148,7 +143,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") }); expect((yield* after.state).installedPath).toBe(link); - // Installing again points the link at this home's launcher. expect((yield* after.install).installedPath).toBe(link); expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3")); yield* after.uninstall; @@ -162,7 +156,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); - // A big executable that happens to contain the marker text is still not ours. const binary = path.join(home, "native-t3"); yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`); yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true }); @@ -187,7 +180,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const command = yield* commandIn({ home }); const theirs = path.join(shadow, "t3"); expect((yield* command.state).shadowedBy).toBe(theirs); - // A link behind it would never run, so nothing is created. const error = yield* Effect.flip(command.install); expect(error.message).toContain(theirs); expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index d79cab92f7cc..aebe8adb4a05 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -12,19 +12,11 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as DesktopCliShim from "./DesktopCliShim.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -// Settings โ†’ Install `t3` command, like VS Code's "Install 'code' command". -// The app's launcher (see DesktopCliShim) lives in the T3 home and is off PATH -// by default. Installing links it into a folder on the user's PATH, or on -// Windows adds the launcher's folder to the user's PATH. Removing undoes only -// what installing did: a link that points at one of the app's launchers, or a -// PATH entry the app recorded adding. - export class DesktopCliCommandError extends Schema.TaggedError()( "DesktopCliCommandError", { message: Schema.String }, ) {} -/** User-writable folders that login shells commonly put on PATH, in preference order. */ const unixCandidates = (home: string, platform: NodeJS.Platform) => platform === "darwin" ? ["/opt/homebrew/bin", "/usr/local/bin", `${home}/.local/bin`, `${home}/bin`] @@ -33,7 +25,6 @@ const unixCandidates = (home: string, platform: NodeJS.Platform) => const pathEntries = (value: string | undefined, separator: string) => (value ?? "").split(separator).filter((entry) => entry.length > 0); -/** Windows paths compare without case or a trailing separator. */ const sameWindowsPath = (left: string, right: string) => left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase(); @@ -82,7 +73,6 @@ export const make = Effect.gen(function* () { const windows = environment.platform === "win32"; const launcher = DesktopCliShim.launcherPath(environment); const binDirectory = path.dirname(launcher); - /** Records that Install added `binDirectory` to the Windows PATH, so Remove takes out only that. */ const ownedPathMarker = path.join(environment.stateDir, "cli-command-path-entry"); const fail = (message: string) => new DesktopCliCommandError({ message }); @@ -93,11 +83,6 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); - /** - * A link to one of the app's launchers, by the marker the launcher carries. - * This also finds links made under a previous T3 home, so Remove can clean - * them up and Install does not add a second. - */ const isOurLink = (link: string) => Effect.gen(function* () { yield* fs.readLink(link); @@ -137,7 +122,6 @@ export const make = Effect.gen(function* () { Effect.mapError(() => fail("Could not update your PATH.")), ); - /** The `t3` a new shell runs, by PATH order, or none. */ const firstOnPath = Effect.gen(function* () { for (const directory of pathEntries(process.env.PATH, ":")) { const candidate = path.join(directory, "t3"); @@ -146,7 +130,6 @@ export const make = Effect.gen(function* () { return Option.none(); }); - /** The `t3` a new shell runs when it is not this app's, on Unix. */ const foreignFirstOnPath = Effect.gen(function* () { if (windows) return Option.none(); const first = yield* firstOnPath; @@ -154,7 +137,6 @@ export const make = Effect.gen(function* () { return first; }); - /** Where this app's command is installed now, if anywhere. */ const installedAt = Effect.gen(function* () { if (windows) { if (!(yield* exists(ownedPathMarker))) return Option.none(); @@ -180,14 +162,11 @@ export const make = Effect.gen(function* () { if (Option.isNone(installed)) { return { supported: true, installedPath: null, onPath: false, ...shadow }; } - // On Windows only terminals opened after the change see it. On Unix the - // first `t3` on PATH must be ours; a `t3` earlier on PATH would shadow it. const first = yield* firstOnPath; const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); return { supported: true, installedPath: installed.value, onPath, ...shadow }; }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); - /** Writes the launcher if the app has not yet, e.g. when no local backend runs. */ const ensureLauncher = DesktopCliShim.install.pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(FileSystem.FileSystem, fs), @@ -216,12 +195,10 @@ export const make = Effect.gen(function* () { if (Option.isSome(existing)) { const target = yield* fs.readLink(existing.value).pipe(Effect.option); if (Option.getOrUndefined(target) === launcher) return yield* state; - // A link to a previous T3 home's launcher: point it at this one instead. yield* fs .remove(existing.value) .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`))); } - // A link behind another `t3` never runs, so installing one would only hide the problem. const shadowedBy = yield* foreignFirstOnPath; if (Option.isSome(shadowedBy)) { return yield* fail( @@ -230,7 +207,6 @@ export const make = Effect.gen(function* () { } const onPath = pathEntries(process.env.PATH, ":"); const candidates = unixCandidates(environment.homeDirectory, environment.platform); - // Prefer a folder already on PATH that the user can write to without admin rights. for (const directory of [ ...candidates.filter((candidate) => onPath.includes(candidate)), ...candidates.filter((candidate) => !onPath.includes(candidate)), @@ -243,7 +219,6 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); if (!created) continue; - // symlink fails if anything, even a broken link, is already there. const linked = yield* fs.symlink(launcher, link).pipe( Effect.as(true), Effect.orElseSucceed(() => false), diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index c5a1bb109e4d..92f42dd2b247 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -26,7 +26,6 @@ const writeExecutable = (path: string, content: string) => { NodeFS.writeFileSync(path, content, { mode: 0o755 }); }; -/** Writes the launcher for `target` at `/bin/t3`, with `/home` as its T3 home. */ const writeShim = (root: string, target: CliShimTarget) => { const shim = NodePath.join(root, "bin", "t3"); writeExecutable(shim, renderCliShim({ target, shimPath: shim, t3Home: `${root}/home` })); @@ -39,16 +38,9 @@ const run = (shim: string, args: ReadonlyArray, env: NodeJS.ProcessEnv = env: { PATH: process.env.PATH, ...env }, }); -/** An app executable that reports its environment and arguments. */ const REPORTER = '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n'; -/** - * A stand-in AppImage whose image holds the reporter. `--appimage-mount` - * prints the mount and stays up until killed, recording that it was; with - * `canMount` false it fails like a host without FUSE. `--appimage-extract` - * unpacks into ./squashfs-root. - */ const fakeAppImage = (canMount = true) => { const root = tempRoot(); const image = NodePath.join(root, "image"); @@ -84,7 +76,6 @@ describe("renderCliShim", () => { "a b", "", ]); - // The trap kills the mount helper as the launcher exits. NodeChildProcess.spawnSync("sh", [ "-c", `i=0; while [ ! -e '${root}/unmounted' ] && [ $i -lt 40 ]; do sleep 0.05; i=$((i+1)); done`, @@ -94,7 +85,6 @@ describe("renderCliShim", () => { it("extracts the AppImage where it cannot be mounted", () => { const { shim } = fakeAppImage(false); - // Extract-and-run is ignored too, so the runtime never starts the app. const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" }); expect(result.status).toBe(3); expect(result.stdout).toMatch( @@ -113,7 +103,6 @@ describe("renderCliShim", () => { // 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"); - // Run by a relative path, it still names itself absolutely. const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" }); expect(relative.stdout).toContain(`cli=${shim}`); }); @@ -160,7 +149,6 @@ describe("renderCliShim", () => { expect(lines).toContain( 'if exist "C:\\Apps\\R&whoami&X 100%%\\!CHANNEL!\\T3 Code.exe" goto run', ); - // Paths only ever appear quoted or inside `set "..."`, never bare where `&` would split them. const bare = lines.filter( (line) => line.includes("whoami") && !/"[^"]*whoami[^"]*"/.test(line), ); @@ -174,7 +162,6 @@ describe("renderCliShim", () => { shimPath: "C:\\Users\\me\\.t3\\bin\\t3.cmd", t3Home: "C:\\Users\\me\\.t3", }); - // A Ctrl-C that ends the batch would leave the console switched. expect(ascii).not.toContain("chcp"); expect(ascii.split("\r\n")).toContain("exit /b %t3_exit%"); }); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index 5cd942f66ced..a9d9f04c4e82 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -6,17 +6,12 @@ import * as Option from "effect/Option"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; -// A desktop install puts no `t3` on PATH, so commands the server asks a person -// to run (`sudo t3 browser setup`) had nothing to call. The app keeps a small -// launcher for its bundled CLI in the T3 home, which is never on PATH and so -// never shadows another `t3`, and the server names it by absolute path in those -// commands through T3CODE_CLI_PATH. An AppImage mounts somewhere new each run, -// so its launcher mounts the AppImage itself instead of pointing into it. +// An AppImage mounts somewhere new each run, so its launcher mounts the +// AppImage itself instead of pointing into it. const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; -/** Server entry inside the app, relative to its server root (an asar archive when packaged). */ const SERVER_ENTRY = "apps/server/dist/bin.mjs"; const shellWord = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; @@ -32,11 +27,10 @@ export type CliShimTarget = | { readonly kind: "windows"; readonly executable: string; readonly entry: string }; /** - * The launcher script. 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. + * 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. */ export const renderCliShim = (input: { readonly target: CliShimTarget; @@ -54,9 +48,9 @@ export const renderCliShim = (input: { `rem ${MARKER}`, // Delayed expansion would rewrite `!name!` in paths and arguments. "setlocal EnableExtensions DisableDelayedExpansion", - // The file is UTF-8. 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. + // 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. ...(utf8 ? [ `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`, @@ -66,6 +60,8 @@ export const renderCliShim = (input: { `set "T3CODE_CLI_PATH=${cmdText(input.shimPath)}"`, `if not defined T3CODE_HOME set "T3CODE_HOME=${cmdText(input.t3Home)}"`, 'set "ELECTRON_RUN_AS_NODE=1"', + // Electron's Windows executable starts without stdin; this tells the CLI to reopen the console. + 'set "T3CODE_DESKTOP_CLI_LAUNCHER=1"', // A goto, not a parenthesized block: "Program Files (x86)" would close the block early. `if exist ${cmdWord(target.executable)} goto run`, ...restore, @@ -125,7 +121,6 @@ export const renderCliShim = (input: { ].join("\n"); }; -/** Where the packaged app keeps its launcher: `/bin/t3`, `t3.cmd` on Windows. */ export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment["Service"]) => environment.path.join( environment.baseDir, @@ -133,11 +128,6 @@ export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment[ environment.platform === "win32" ? "t3.cmd" : "t3", ); -/** - * Writes the packaged app's launcher to `/bin` and returns its path - * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout - * and get none. - */ export const install = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; if (!environment.isPackaged) return Option.none(); @@ -154,7 +144,6 @@ export const install = Effect.gen(function* () { appImage, executableName: path.basename(process.execPath), }), - // macOS and .deb installs live at a fixed path, so the launcher runs the app directly. onNone: () => ({ kind: "direct" as const, executable: process.execPath, entry }), }); const content = renderCliShim({ target, shimPath, t3Home: environment.baseDir }); @@ -162,13 +151,11 @@ export const install = Effect.gen(function* () { return yield* Effect.gen(function* () { const existing = yield* fs.readFileString(shimPath).pipe(Effect.option); if (Option.isSome(existing) && !existing.value.includes(MARKER)) { - // Someone else's file; leave it, and let commands fall back to plain `t3`. yield* logWarning("leaving a t3 launcher the app did not write", { shimPath }); return Option.none(); } if (Option.getOrUndefined(existing) !== content) { yield* fs.makeDirectory(path.dirname(shimPath), { recursive: true }); - // Written beside the launcher and renamed over it, so a running `t3` never reads half a file. const staging = `${shimPath}.${process.pid}.tmp`; yield* fs.writeFileString(staging, content, { mode: 0o755 }); yield* fs.rename(staging, shimPath); @@ -176,7 +163,6 @@ export const install = Effect.gen(function* () { } return Option.some(shimPath); }).pipe( - // Best-effort: nothing here may block the backend's start; commands then fall back to plain `t3`. Effect.catchCause((cause) => logWarning("could not install t3 launcher", { shimPath, cause: Cause.pretty(cause) }).pipe( Effect.as(Option.none()), diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index d20bdf95dd31..a9020c36d1c8 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -376,7 +376,7 @@ const migrateSavedEnvironmentRecords = Effect.fn( }; }); -/** @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/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index b311a9e25ed5..01d1aa1b115e 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -131,7 +131,6 @@ describe("DesktopEarlyElectronStartup", () => { "0.50.0": "c4a4c341568944bd4fb9cd503558b2de602c0213", "0.50.1": "4e242d086e20b32951fdc0ebcbfb4d41b5be8dcc", }; - // Shape of `hyprctl version -j` from a release tarball, trimmed to the fields we read. const hyprctlVersion = (version: string, overrides: Record = {}) => () => diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index c4adf8f3c75e..ad2947013771 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -83,7 +83,6 @@ export function resolveEarlyLinuxPasswordStorePreference( const WAYLAND_COLOR_MANAGER_FEATURE = "WaylandWpColorManagerV1"; -// Chromium feature entries look like `*Name => (value ?? "").split(",").flatMap((entry) => entry.trim().match(/^\*?([^<:]+)/)?.[1] ?? []); @@ -129,14 +128,6 @@ const hasBrokenColorManager = (readHyprctlVersionJson: () => string): boolean => } }; -/** - * Returns the merged `--disable-features` value to apply before Electron is ready, - * or null to leave the command line alone. `ozonePlatform` is the resolved - * `--ozone-platform` value; Electron fills it in before app code runs. - * - * Color management stays on unless `hyprctl version -j` identifies a stock Hyprland - * release known to kill the native Wayland connection right after the first window opens. - */ export function resolveLinuxDisableFeaturesSwitch(input: { readonly env: NodeJS.ProcessEnv; readonly ozonePlatform: string | null; diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 6a8bc8e0ea49..ecf08255feb3 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -54,19 +54,11 @@ export class DesktopEnvironment extends Context.Service< readonly browserArtifactsDir: string; readonly rootDir: string; readonly appRoot: string; - // Root of the tree containing apps/server/dist and node_modules for the - // backend. Equals appRoot everywhere except packaged Windows, where the - // server tree ships as the resources/server.asar sidecar (see - // scripts/build-desktop-artifact.ts) that the asar-aware - // ELECTRON_RUN_AS_NODE primary reads in place and the WSL backend - // extracts on demand (see DesktopWslServerTree). readonly serverRoot: string; readonly backendEntryPath: string; - // Built web client the packaged renderer is served from over t3code://app. readonly clientAssetsDir: string; readonly backendCwd: string; readonly preloadPath: string; - // Preload that turns on the V8 compile cache for the local backend. readonly compileCachePath: string; readonly appUpdateYmlPath: string; readonly devServerUrl: Option.Option; @@ -103,7 +95,7 @@ function resolveDesktopAppStageLabel(input: { return "Dev"; } - return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; + return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Beta"; } export function resolveDesktopAppBranding(input: { diff --git a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts index b29cfb3b7c63..9deba2389922 100644 --- a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts +++ b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts @@ -11,15 +11,8 @@ import { readChromiumLocalStorage } from "./chromiumLocalStorage.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; /** - * Carries the renderer's localStorage (prompt stash, unsent drafts, layout, - * theme) over from the V1 desktop profile, which V2 replaced with its own - * `t3code-v2` profile. The V1 profile is only read, never opened by Chromium, - * so this works while V1 is still running. - * - * `load` runs before the window opens; the preload takes the items once, - * merges them into the new profile, and calls `complete`, which writes a - * marker so later launches skip the read. A failed read or merge, or a window - * that never completes, leaves no marker and retries next launch. + * The V1 profile is only read, never opened by Chromium, so this works while V1 + * is still running. */ export class DesktopLegacyLocalStorage extends Context.Service< DesktopLegacyLocalStorage, @@ -31,7 +24,6 @@ export class DesktopLegacyLocalStorage extends Context.Service< >()("@t3tools/desktop/app/DesktopLegacyLocalStorage") {} const MARKER_FILE_NAME = "v1-local-storage-imported"; -// V1 used "T3 Code (Alpha)" when that folder existed and "t3code" otherwise. const V1_PROFILE_NAMES = ["T3 Code (Alpha)", "t3code"]; const make = Effect.gen(function* () { @@ -57,7 +49,6 @@ const make = Effect.gen(function* () { return newest; }).pipe(Effect.option); - /** The V1 profile whose Local Storage was written most recently, if any. */ const findV1LocalStorage = Effect.gen(function* () { let newest: { readonly directory: string; readonly mtime: number } | null = null; for (const name of V1_PROFILE_NAMES) { @@ -82,7 +73,6 @@ const make = Effect.gen(function* () { }); const load = Effect.fn("desktop.legacyLocalStorage.load")(function* (userDataPath: string) { - // Development already shares its profile between versions. if (environment.isDevelopment) return; const marker = path.join(userDataPath, MARKER_FILE_NAME); if (yield* fs.exists(marker).pipe(Effect.orElseSucceed(() => true))) return; @@ -108,7 +98,6 @@ const make = Effect.gen(function* () { return DesktopLegacyLocalStorage.of({ load, - // Taking clears the items, so a reload before `complete` cannot merge twice. take: Ref.getAndSet(items, Option.none()), complete: writeMarker, }); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 5caa18a53aab..f9f38dea3dd4 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -35,6 +35,7 @@ function layerElectronApp( name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, + requestSingleInstanceLock: Effect.succeed(true), quit, exit: () => Effect.void, relaunch: () => Effect.void, diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index cbabe14dd6fc..e6900a03b7d2 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -168,9 +169,6 @@ describe("DesktopLinuxUrlHandler", () => { assert.include(entry, "[Desktop Entry]"); assert.include(entry, "Name=T3 Code (Nightly)"); - // Exec composes both escaping layers: a literal backslash becomes four - // backslashes in the file, a quote three characters, a dollar sign two - // backslashes plus the sign. assert.include( entry, 'Exec="/home/al ice/Apps/T3 \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U', @@ -392,3 +390,291 @@ describe("DesktopLinuxUrlHandler", () => { }), ); }); + +interface ThemeIconRun { + readonly root: string; + readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>; + readonly iconCacheOptions?: Array>; + readonly sourceMissing?: boolean; + readonly iconCacheExitCode?: number; + readonly iconCacheSpawnFails?: boolean; + readonly iconCacheStalled?: boolean; + readonly iconCacheStarted?: Deferred.Deferred; + // Stands in for gtk-update-icon-cache rewriting the cache on success. + readonly onIconCacheRefresh?: Effect.Effect; +} + +const registerWithRealFileSystem = (run: ThemeIconRun) => + Effect.gen(function* () { + const handler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; + yield* handler.register; + }).pipe( + Effect.provide( + DesktopLinuxUrlHandler.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.effect( + DesktopEnvironment.DesktopEnvironment, + Path.Path.pipe( + Effect.map((path) => + makeEnvironment(path, { + linuxApplicationsDir: path.join(run.root, "applications"), + }), + ), + ), + ), + Layer.effect( + DesktopAssets.DesktopAssets, + Path.Path.pipe( + Effect.map((path) => ({ + iconPaths: Effect.succeed({ + png: Option.none(), + ico: Option.none(), + icns: Option.none(), + }), + resolveResourcePath: (fileName: string) => + Effect.succeed( + fileName === "icons/256x256.png" + ? Option.some( + path.join( + run.root, + run.sourceMissing ? "missing" : "resources", + fileName, + ), + ) + : Option.none(), + ), + })), + ), + ), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + run.commands.push({ command: childProcess.command, args: childProcess.args }); + if (childProcess.command !== "gtk-update-icon-cache") { + return Effect.succeed(mockProcess(0)); + } + run.iconCacheOptions?.push( + (command as unknown as { readonly options: Record }).options, + ); + if (run.iconCacheSpawnFails) { + return Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "gtk-update-icon-cache is not installed", + }), + ); + } + const exitCode = run.iconCacheExitCode ?? 0; + const handle = mockProcess(exitCode, run.iconCacheStalled === true); + const refreshed = + exitCode === 0 && run.onIconCacheRefresh ? run.onIconCacheRefresh : Effect.void; + return refreshed.pipe( + Effect.andThen( + run.iconCacheStarted + ? Deferred.succeed(run.iconCacheStarted, undefined) + : Effect.void, + ), + Effect.as(handle), + ); + }), + ), + ), + ), + ), + ), + ); + +const themeIconFixture = (source: Uint8Array) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-theme-icons-" }); + yield* fileSystem.makeDirectory(path.join(root, "resources", "icons"), { recursive: true }); + yield* fileSystem.writeFile(path.join(root, "resources", "icons", "256x256.png"), source); + 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])); + yield* fileSystem.utimes(cachePath, pastSeconds, pastSeconds); + }); + const rewriteCache = fileSystem + .writeFile(cachePath, new Uint8Array([1])) + .pipe(Effect.orDie, Effect.provideService(FileSystem.FileSystem, fileSystem)); + return { root, hicolorDir, appsDir, cachePath, pastSeconds, seedCache, rewriteCache }; + }); + +const iconCacheRuns = (run: ThemeIconRun) => + run.commands.filter(({ command }) => command === "gtk-update-icon-cache").length; + +it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { + it.effect("installs both app id spellings without creating an icon cache", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const icon = new Uint8Array([137, 80, 78, 71, 1, 2, 3]); + const fixture = yield* themeIconFixture(icon); + const run: ThemeIconRun = { root: fixture.root, commands: [] }; + + yield* registerWithRealFileSystem(run); + assert.deepEqual([...(yield* fileSystem.readDirectory(fixture.appsDir))].sort(), [ + "com.t3tools.T3Code.png", + "com.t3tools.t3code.png", + ]); + for (const name of ["com.t3tools.t3code", "com.t3tools.T3Code"]) { + const target = path.join(fixture.appsDir, `${name}.png`); + assert.deepEqual([...(yield* fileSystem.readFile(target))], [...icon]); + yield* fileSystem.utimes(target, fixture.pastSeconds, fixture.pastSeconds); + } + + yield* registerWithRealFileSystem(run); + for (const name of ["com.t3tools.t3code", "com.t3tools.T3Code"]) { + const info = yield* fileSystem.stat(path.join(fixture.appsDir, `${name}.png`)); + assert.equal(Option.getOrThrow(info.mtime).getTime(), fixture.pastSeconds * 1000); + } + assert.isFalse(yield* fileSystem.exists(fixture.cachePath)); + assert.equal(iconCacheRuns(run), 0); + }).pipe(Effect.scoped), + ); + + it.effect("replaces same-length stale icons and refreshes an existing cache once", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* themeIconFixture(new Uint8Array([1, 2, 3])); + yield* fixture.seedCache; + const lower = path.join(fixture.appsDir, "com.t3tools.t3code.png"); + const upper = path.join(fixture.appsDir, "com.t3tools.T3Code.png"); + yield* fileSystem.writeFile(lower, new Uint8Array([3, 2, 1])); + yield* fileSystem.writeFile(upper, new Uint8Array([1, 2, 3])); + for (const target of [lower, upper]) { + yield* fileSystem.utimes(target, fixture.pastSeconds, fixture.pastSeconds); + } + const run: ThemeIconRun = { + root: fixture.root, + commands: [], + onIconCacheRefresh: fixture.rewriteCache, + }; + + yield* registerWithRealFileSystem(run); + yield* registerWithRealFileSystem(run); + + assert.deepEqual([...(yield* fileSystem.readFile(lower))], [1, 2, 3]); + const upperInfo = yield* fileSystem.stat(upper); + assert.equal(Option.getOrThrow(upperInfo.mtime).getTime(), fixture.pastSeconds * 1000); + assert.deepEqual( + run.commands.filter(({ command }) => command === "gtk-update-icon-cache"), + [{ command: "gtk-update-icon-cache", args: ["-f", "-t", fixture.hicolorDir] }], + ); + }).pipe(Effect.scoped), + ); + + it.effect("refreshes when this launch changed an icon even if the cache looks newer", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* themeIconFixture(new Uint8Array([1, 2, 3])); + yield* fixture.seedCache; + const run: ThemeIconRun = { + root: fixture.root, + 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); + + yield* registerWithRealFileSystem(run); + + assert.isTrue(yield* fileSystem.exists(path.join(fixture.appsDir, "com.t3tools.T3Code.png"))); + assert.equal(iconCacheRuns(run), 1); + }).pipe(Effect.scoped), + ); + + it.effect("retries a failed icon cache refresh on the next launch", () => + Effect.gen(function* () { + const fixture = yield* themeIconFixture(new Uint8Array([1, 2, 3])); + yield* fixture.seedCache; + const failing: ThemeIconRun = { root: fixture.root, commands: [], iconCacheExitCode: 1 }; + yield* registerWithRealFileSystem(failing); + assert.equal(iconCacheRuns(failing), 1); + assert.deepEqual(failing.commands.map(({ command }) => command).slice(1), [ + "update-desktop-database", + "xdg-mime", + ]); + + const recovering: ThemeIconRun = { + root: fixture.root, + commands: [], + onIconCacheRefresh: fixture.rewriteCache, + }; + yield* registerWithRealFileSystem(recovering); + yield* registerWithRealFileSystem(recovering); + assert.equal(iconCacheRuns(recovering), 1); + }).pipe(Effect.scoped), + ); + + it.effect("still registers the handler when the theme icon install fails", () => + Effect.gen(function* () { + const fixture = yield* themeIconFixture(new Uint8Array([1, 2, 3])); + const missingSource: ThemeIconRun = { root: fixture.root, commands: [], sourceMissing: true }; + yield* registerWithRealFileSystem(missingSource); + assert.deepEqual( + missingSource.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); + + yield* fixture.seedCache; + const missingTool: ThemeIconRun = { + root: fixture.root, + commands: [], + iconCacheSpawnFails: true, + }; + yield* registerWithRealFileSystem(missingTool); + assert.deepEqual( + missingTool.commands.map(({ command }) => command), + ["gtk-update-icon-cache", "update-desktop-database", "xdg-mime"], + ); + }).pipe(Effect.scoped), + ); + + it.effect("continues to xdg-mime when the icon cache refresh stalls", () => + Effect.gen(function* () { + const fixture = yield* themeIconFixture(new Uint8Array([1, 2, 3])); + yield* fixture.seedCache; + const started = yield* Deferred.make(); + const iconCacheOptions: Array> = []; + const run: ThemeIconRun = { + root: fixture.root, + commands: [], + iconCacheOptions, + iconCacheStalled: true, + iconCacheStarted: started, + }; + + const registration = yield* registerWithRealFileSystem(run).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("5 seconds"); + yield* Fiber.join(registration); + + assert.deepEqual( + run.commands.map(({ command }) => command), + ["gtk-update-icon-cache", "update-desktop-database", "xdg-mime"], + ); + // Without a forced kill, releasing a helper that ignores SIGTERM after + // the timeout would still wait for it to exit. + assert.equal(iconCacheOptions[0]?.forceKillAfter, "1 second"); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index e67cdcefe9fa..08cd1d47c08d 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -17,10 +17,7 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; // 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. Instead, write -// our own handler entry pointing at the current AppImage, refresh the desktop -// MIME cache so desktop environments recognize that entry as a handler, and -// use xdg-mime to record it as the scheme default in mimeapps.list. +// prompting "Choose an application" for every OAuth callback. const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError()( @@ -67,10 +64,7 @@ const escapeDesktopEntryString = (value: string): string => // 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. The argument is double-quoted with reserved characters -// backslash-escaped and literal percent signs doubled (field codes), and the -// general string escaping is applied on top: a literal backslash ends up as -// four backslashes in the file, a quote as \\", a dollar sign as \\$. +// layers in reverse. export function escapeDesktopEntryExecArgument(value: string): string { const quoted = value .replaceAll("\\", () => "\\\\") @@ -103,6 +97,19 @@ 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. +const LINUX_THEME_ICON_SIZE = "256x256"; + +function linuxThemeIconNames(desktopEntryName: string): readonly string[] { + const appId = desktopEntryName.replace(/\.desktop$/u, ""); + return [...new Set([appId.toLowerCase(), appId])]; +} + +const sameBytes = (left: Uint8Array, right: Uint8Array): boolean => + left.length === right.length && left.every((byte, index) => byte === right[index]); + export class DesktopLinuxUrlHandler extends Context.Service< DesktopLinuxUrlHandler, { @@ -124,6 +131,8 @@ export const make = Effect.gen(function* () { ); const iconsDir = environment.path.join(environment.linuxApplicationsDir, "..", "icons"); const iconPath = environment.path.join(iconsDir, `${environment.linuxDesktopEntryName}.png`); + const hicolorDir = environment.path.join(iconsDir, "hicolor"); + const themeIconDir = environment.path.join(hicolorDir, LINUX_THEME_ICON_SIZE, "apps"); const writeDesktopEntry = Effect.gen(function* () { // Inside the mounted AppImage, process.execPath points at a transient @@ -186,6 +195,63 @@ export const make = Effect.gen(function* () { ), ); + const refreshIconThemeCache = Effect.scoped( + Effect.gen(function* () { + const command = ChildProcess.make("gtk-update-icon-cache", ["-f", "-t", hicolorDir], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + // Releasing a timed-out helper otherwise waits on SIGTERM indefinitely. + forceKillAfter: "1 second", + }); + const handle = yield* spawner.spawn(command); + return yield* handle.exitCode.pipe(Effect.timeout("5 seconds")); + }), + ); + + const modifiedAt = (filePath: string) => + fileSystem + .stat(filePath) + .pipe(Effect.map((info) => Option.getOrUndefined(info.mtime)?.getTime() ?? 0)); + + const installThemeIcons = Effect.gen(function* () { + const source = yield* assets.resolveResourcePath(`icons/${LINUX_THEME_ICON_SIZE}.png`); + if (Option.isNone(source)) return; + const icon = yield* fileSystem.readFile(source.value); + const targets = linuxThemeIconNames(environment.linuxDesktopEntryName).map((name) => + environment.path.join(themeIconDir, `${name}.png`), + ); + let changed = false; + for (const target of targets) { + const current = yield* fileSystem.readFile(target).pipe(Effect.option); + if (Option.isSome(current) && sameBytes(current.value, icon)) continue; + yield* fileSystem.makeDirectory(themeIconDir, { recursive: true }); + 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. + const cachePath = environment.path.join(hicolorDir, "icon-theme.cache"); + const cachedAt = yield* modifiedAt(cachePath).pipe(Effect.option); + if (Option.isNone(cachedAt)) return; + let stale = changed; + for (const target of targets) { + if (stale) break; + if ((yield* modifiedAt(target)) > cachedAt.value) stale = true; + } + if (!stale) return; + const exitCode = yield* refreshIconThemeCache; + if (exitCode !== 0) { + yield* logWarning("icon theme cache refresh failed", { hicolorDir, exitCode }); + } + }).pipe( + Effect.catch((error) => + logWarning("app id theme icon install failed", { themeIconDir, category: error._tag }), + ), + ); + const setDefaultHandler = Effect.scoped( Effect.gen(function* () { const command = ChildProcess.make( @@ -238,6 +304,8 @@ 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 @@ -255,8 +323,6 @@ export const make = Effect.gen(function* () { yield* setDefaultHandler; yield* logInfo("registered URL scheme handler", { scheme }); }).pipe( - // Registration is best-effort: a missing xdg-mime or read-only home must - // never block startup โ€” the OS chooser remains as fallback. Effect.catch((error) => logWarning("URL scheme handler registration failed", { scheme, diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 378bbb9e406b..ef462f19a29b 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -76,7 +76,6 @@ interface ExportedRequest { readonly body: string; } -/** Answers every export with a 200 and keeps what was posted for assertions. */ const layerCollector = (requests: Array) => Layer.succeed( HttpClient.HttpClient, @@ -415,8 +414,6 @@ describe("DesktopObservability", () => { assert.include(request?.body ?? "", "service.runtime"); assert.strictEqual(request?.headers["x-scope"], "desktop"); - // The log record is the export now, so the same message must not also - // ride along as an event on the span. const record = (yield* fileSystem.readFileString(tracePath)) .trim() .split("\n") @@ -601,8 +598,6 @@ describe("DesktopObservability", () => { yield* writeObservabilitySettings(layerEnvironment, { otlpTracesUrl: "https://settings.example.com/v1/traces", otlpLogsUrl: "https://settings.example.com/v1/logs", - // The main process records no metrics yet, so this endpoint must - // not produce a request. otlpMetricsUrl: "https://settings.example.com/v1/metrics", }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 89ed63002059..299db7019dd7 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -54,15 +54,6 @@ export interface DesktopBackendOutputLogShape { readonly discardSession: Effect.Effect; } -// Factory for per-instance backend output logs. `forInstance(id)` returns -// a writer that targets a distinct rotating log file โ€” the primary -// instance keeps `server-child.log` so the historical path stays stable -// for ops; other instances get `server-child-.log`. -// -// Writers are cached per id within a single factory instance so repeated -// `forInstance` calls (e.g. during a backend restart that re-resolves -// services) reuse the same rotating writer rather than racing each other -// on the same file. export class DesktopBackendOutputLogFactory extends Context.Service< DesktopBackendOutputLogFactory, { @@ -351,12 +342,6 @@ const readPersistedObservabilitySettings: Effect.Effect< : parsePersistedServerObservabilitySettings(raw.value); }); -/** - * Resolved as the server resolves them, with persisted Settings as the - * fallback. Settings is read once for every signal, so the main process - * cannot resolve traces against one revision of the file and logs against - * another. - */ const resolveOtlpEndpoints = Effect.gen(function* () { const otel = yield* OtelEnvironment.load; if (otel.disabled) { @@ -450,11 +435,8 @@ const backendLogFilePathForInstance = ( return environment.path.join(environment.logDir, `server-child-${sanitized}.log`); }; -// Just the IO sink. Cacheable by resolved file path so two ids that -// sanitize to the same filename share a single RotatingLogFileWriter -// (no race on currentSize tracking). Splitting the sink off from the -// per-call shape lets the shape annotate writes with the *caller's* -// id rather than whatever id created the cached writer first. +// Cacheable by resolved file path so two ids that sanitize to the same filename +// share a single RotatingLogFileWriter (no race on currentSize tracking). const makeBackendOutputSinkForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, @@ -567,14 +549,6 @@ const layerBackendOutputLogFactory = Layer.effect( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const factoryScope = yield* Scope.Scope; - // Per-file-path cache of the IO sink only. The per-call shape - // wraps the sink with the caller's instance id so a cache hit on - // a path collision (e.g. "wsl:default" and "wsl_default" both - // resolve to server-child-wsl_default.log) doesn't attribute the - // second caller's writes to the first caller's id. Each sink pins - // itself to the factory's scope so all log resources tear down - // together at app exit. Mutex serializes concurrent first-time - // lookups for the same file path. const cacheRef = yield* SynchronizedRef.make< ReadonlyMap> >(new Map()); @@ -617,17 +591,10 @@ const layerBackendOutputLogFactory = Layer.effect( }), ); -/** - * Logs and traces for the main process, assembled together because they share - * one read of the environment and Settings, and because a process gets exactly - * one logger set. - */ const layerTelemetry = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; - // `host.name` keeps machines apart on a shared collector; one set in - // `OTEL_RESOURCE_ATTRIBUTES` wins, since explicit attributes override it. const resource = { serviceName: "t3code-desktop", serviceVersion: environment.appVersion, @@ -643,7 +610,6 @@ const layerTelemetry = Layer.unwrap( // 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, @@ -707,23 +673,6 @@ const layerTelemetry = Layer.unwrap( }), ).pipe(Layer.provide(OtlpExporter.layerFlusher)); - // Metrics stay off until the main process records one. `OtlpMetrics` - // exports on every interval even when the registry is empty, so wiring - // it up today would post an empty payload every ten seconds to any - // collector configured for the backend. Restore this when a desktop - // metric exists, and add it to the `Layer.mergeAll` below. - // - // const metricsLayer = - // endpoints.metrics === undefined - // ? Layer.empty - // : OtlpMetrics.layer({ - // url: endpoints.metrics.url, - // exportInterval: `${endpoints.metrics.export.exportIntervalMs} millis`, - // headers: endpoints.metrics.export.headers, - // resource, - // }).pipe(Layer.provide(SharedObservability.layerOtlpSerialization(endpoints.metrics.export.protocol))); - - // Logged once the loggers above are installed, so the warnings use them. const layerOtelWarnings = Layer.effectDiscard( Effect.forEach(endpoints.warnings, (warning) => Effect.logWarning(warning)), ); diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts index 8ce58f7ebbb3..5af13f94413f 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts @@ -33,14 +33,9 @@ const syncFs = (method: string, path: string, run: () => A) => }); /** - * A synchronous FileSystem for startup work that runs before Electron's ready. - * Electron emits ready as soon as startup yields to the event loop. Only the - * operations userData resolution needs are implemented. - * * @public Service construction is part of the canonical Effect module API. */ export const make = FileSystem.makeNoop({ - // Like Effect's exists: only a missing path is false, other access errors fail. exists: (path) => syncFs("exists", path, () => { NodeFS.accessSync(path); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 29fa706698e0..dd15f879fd8e 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -175,7 +175,7 @@ describe("DesktopPreReadyPlatform", () => { const identity = yield* Effect.promise(() => portalIdentity); assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); - assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); + assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Beta)"); assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); assert.include( identity.desktopEntry ?? "", diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index b021ae02e826..a90246c73b3b 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -89,9 +89,7 @@ export const make = Effect.gen(function* () { ), iconPath, ); - } catch { - // Icon installation is optional; registration retries after readiness. - } + } catch {} } NodeFS.writeFileSync( NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName), @@ -106,9 +104,7 @@ export const make = Effect.gen(function* () { }), "utf8", ); - } catch { - // The URL handler retries with the full environment and logs failures. - } + } catch {} // Chromium caches its portal registration during startup. Set the identity // before any asynchronous work can initialize it with Electron's default. Electron.app.setDesktopName(linux.linuxDesktopEntryName); @@ -122,7 +118,6 @@ export const make = Effect.gen(function* () { ozonePlatform: readCommandLineSwitchValue(Electron.app.commandLine, "ozone-platform"), enableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "enable-features"), disableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "disable-features"), - // Runs only on Hyprland Wayland sessions without an explicit feature override. readHyprctlVersionJson: () => NodeChildProcess.execFileSync("hyprctl", ["version", "-j"], { encoding: "utf8", diff --git a/apps/desktop/src/app/DesktopUserData.ts b/apps/desktop/src/app/DesktopUserData.ts index 3306b244dc72..99a082b438b0 100644 --- a/apps/desktop/src/app/DesktopUserData.ts +++ b/apps/desktop/src/app/DesktopUserData.ts @@ -31,7 +31,6 @@ export class DesktopUserDataInitializationError extends Schema.TaggedError { }, }); const window = { webContents } as unknown as Electron.BrowserWindow; - /** The web app starts loading a new page, as a reload does. */ const reload = () => webContents.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }); const layer = DesktopWebLinks.layer.pipe( @@ -57,7 +56,6 @@ describe("DesktopWebLinks", () => { const { sent, revealed, layer } = makeWindow(); return Effect.gen(function* () { const webLinks = yield* DesktopWebLinks.DesktopWebLinks; - // The link that launched the app arrives before the web app has loaded. yield* webLinks.receive("https://example.com/first"); yield* webLinks.receive("https://example.com/second"); assert.deepStrictEqual(sent, []); @@ -69,7 +67,6 @@ describe("DesktopWebLinks", () => { yield* webLinks.receive("https://example.com/third"); assert.deepStrictEqual(sent.at(-1), "https://example.com/third"); - // A reloading web app stops listening; links wait for it again. yield* webLinks.setRendererReady(false); yield* webLinks.receive("https://example.com/fourth"); assert.strictEqual(sent.length, 3); @@ -81,7 +78,6 @@ describe("DesktopWebLinks", () => { return Effect.gen(function* () { const webLinks = yield* DesktopWebLinks.DesktopWebLinks; yield* webLinks.setRendererReady(true); - // The old page never says it stopped; the reload itself ends its listening. reload(); yield* webLinks.receive("https://example.com/during-reload"); assert.deepStrictEqual(sent, []); diff --git a/apps/desktop/src/app/DesktopWebLinks.ts b/apps/desktop/src/app/DesktopWebLinks.ts index 2031ada45a12..f448f184b893 100644 --- a/apps/desktop/src/app/DesktopWebLinks.ts +++ b/apps/desktop/src/app/DesktopWebLinks.ts @@ -6,29 +6,18 @@ import * as Option from "effect/Option"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; -/** An http(s) link the operating system handed to T3 Code as the default browser. */ export const isWebLink = (value: string) => { if (!URL.canParse(value)) return false; const { protocol } = new URL(value); return protocol === "http:" || protocol === "https:"; }; -/** An HTML file the operating system asked T3 Code to open, as browsers are asked to. */ export const isWebPageFile = (path: string) => /\.x?html?$/i.test(path); -/** - * Web links and HTML files macOS opens with T3 Code once it is the default - * browser, plus `t3code://` prompt and thread links on every platform. Files - * arrive as file:// URLs. They can - * arrive before the app is ready (the link that launched it) or while the web - * app reloads, so they wait until the renderer says it is listening. - */ export class DesktopWebLinks extends Context.Service< DesktopWebLinks, { - /** Queues a link and delivers it as soon as the renderer is listening. */ readonly receive: (url: string) => Effect.Effect; - /** The renderer started or stopped listening; listening flushes queued links. */ readonly setRendererReady: (ready: boolean) => Effect.Effect; } >()("@t3tools/desktop/app/DesktopWebLinks") {} @@ -36,7 +25,6 @@ export class DesktopWebLinks extends Context.Service< const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; const pending: Array = []; - /** The page that said it is listening; a reload or crash replaces it, and it must say so again. */ let listening: Electron.WebContents | null = null; let detachListening: (() => void) | null = null; @@ -65,7 +53,6 @@ const make = Effect.gen(function* () { if (listening === webContents) return; stopListening(); listening = webContents; - // Links that arrive while a new page loads wait for it to listen again. const onNavigation = ( event: Electron.Event, ) => { diff --git a/apps/desktop/src/app/chromiumLocalStorage.ts b/apps/desktop/src/app/chromiumLocalStorage.ts index d5847b814db6..98de2e91eb06 100644 --- a/apps/desktop/src/app/chromiumLocalStorage.ts +++ b/apps/desktop/src/app/chromiumLocalStorage.ts @@ -5,8 +5,6 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; /** - * Read-only decoder for a Chromium profile's `Local Storage/leveldb`. - * * 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 @@ -97,7 +95,6 @@ function crc32c(bytes: Uint8Array): number { return (crc ^ 0xffffffff) >>> 0; } -/** LevelDB stores CRCs masked so a CRC of data containing CRCs stays well distributed. */ const unmaskCrc = (masked: number) => { const rotated = (masked - 0xa282ead8) >>> 0; return ((rotated >>> 17) | (rotated << 15)) >>> 0; @@ -149,10 +146,9 @@ function decompressSnappy(input: Uint8Array): Uint8Array { } /** - * Splits a LevelDB log (write-ahead log or MANIFEST) into its records. 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. + * 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. */ function readLogRecords(bytes: Uint8Array): Uint8Array[] { const records: Uint8Array[] = []; @@ -212,7 +208,6 @@ interface LiveFiles { readonly previousLogNumber: number; } -/** Replays MANIFEST version edits to find the live table files and log. */ function readManifest(bytes: Uint8Array): LiveFiles { const tables = new Set(); let logNumber = 0; @@ -225,21 +220,21 @@ function readManifest(bytes: Uint8Array): LiveFiles { const tag = reader.varint(); required.delete(tag); switch (tag) { - case 1: // comparator + case 1: reader.lengthPrefixed(); break; case 2: logNumber = reader.varint(); break; - case 3: // next file number - case 4: // last sequence + case 3: + case 4: reader.varint(); break; - case 5: // compaction pointer + case 5: reader.varint(); reader.lengthPrefixed(); break; - case 6: // deleted file + case 6: reader.varint(); tables.delete(reader.varint()); break; @@ -265,7 +260,6 @@ function readManifest(bytes: Uint8Array): LiveFiles { interface VersionedEntry { readonly sequence: number; - /** `null` marks a deletion. */ readonly value: Uint8Array | null; } @@ -279,10 +273,8 @@ function record(entries: EntryMap, key: Uint8Array, entry: VersionedEntry) { if (!existing || existing.entry.sequence < entry.sequence) entries.set(id, { key, entry }); } -/** Applies one write-ahead log record (a WriteBatch). */ function readWriteBatch(entries: EntryMap, batch: Uint8Array) { if (batch.length < 12) fail("write batch too short"); - // The sequence is a little-endian uint64; real values stay far below 2^53. const sequence = readUint32(batch, 0) + readUint32(batch, 4) * 0x100000000; const count = readUint32(batch, 8); const reader = new Reader(batch, 12); @@ -332,7 +324,6 @@ function* blockEntries(block: Uint8Array): Generator<[Uint8Array, Uint8Array]> { } } -/** Applies every entry in one sorted table (`.ldb`/`.sst`) file. */ function readTable(entries: EntryMap, table: Uint8Array) { if (table.length < TABLE_FOOTER_SIZE) fail("table too short"); const footer = new Reader(table, table.length - TABLE_FOOTER_SIZE); @@ -376,10 +367,6 @@ export class ChromiumLocalStorageReadError extends Schema.TaggedError readManifest(manifest)).pipe(wrap); const logs = (yield* fs.readDirectory(directory).pipe(wrap)) .map((name) => /^(\d+)\.log$/.exec(name)) - // The logs LevelDB's own recovery replays. .filter((match) => { const number = Number(match?.[1]); return number >= live.logNumber || number === live.previousLogNumber; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 090f6e0edf5e..c0ef57ae993a 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -15,6 +15,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -37,6 +38,14 @@ const isDesktopBackendObservabilitySettingsReadError = Schema.is( DesktopBackendConfiguration.DesktopBackendObservabilitySettingsReadError, ); +const layerShellEnvironment = (prepared = true) => + Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { + installIntoProcess: Effect.succeed(prepared), + }); + +const layerBackendConfiguration = (prepared = true) => + DesktopBackendConfiguration.layer.pipe(Layer.provide(layerShellEnvironment(prepared))); + const layerServerExposure = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { getState: Effect.die("unexpected getState"), backendConfig: Effect.succeed({ @@ -114,6 +123,7 @@ const withHarness = ( | FileSystem.FileSystem | DesktopBackendConfiguration.DesktopBackendConfiguration >, + prepared = true, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -123,7 +133,7 @@ const withHarness = ( return yield* effect.pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration(prepared).pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), @@ -201,7 +211,7 @@ const withPackagedWslHarness = ( return yield* effect(context).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(layerServerTree), @@ -262,8 +272,6 @@ describe("DesktopBackendConfiguration", () => { second.bootstrap.desktopBootstrapSecret, first.bootstrap.desktopBootstrapSecret, ); - // The launch token is the secret's token for the current window, and - // the renderer is handed the same one. assert.equal( first.bootstrap.desktopBootstrapToken, currentDesktopBootstrapToken( @@ -293,7 +301,7 @@ describe("DesktopBackendConfiguration", () => { return yield* configuration.resolvePrimary; }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), @@ -334,12 +342,22 @@ describe("DesktopBackendConfiguration", () => { const wsl = yield* configuration.resolveWsl({ port: 5000, distro: null }); assert.equal(primary.bootstrap.shellEnvironmentPrepared, true); - // A WSL server runs on its own Linux user environment and must hydrate it. assert.isUndefined(wsl.bootstrap.shellEnvironmentPrepared); }), ), ); + it.effect("keeps server hydration enabled when desktop preparation failed", () => + withHarness( + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const primary = yield* configuration.resolvePrimary; + assert.equal(primary.bootstrap.shellEnvironmentPrepared, false); + }), + false, + ), + ); + it.effect("resolveWsl reuses the primary's bootstrap token", () => withHarness( Effect.gen(function* () { @@ -370,7 +388,7 @@ describe("DesktopBackendConfiguration", () => { return yield* configuration.resolveWsl({ port: 5000, distro: null }); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -444,8 +462,6 @@ describe("DesktopBackendConfiguration", () => { observedProbeRoots.push(root); return { ok: true, resolvedPath }; }, - // The staged runtime carries its own Node and node-pty, so it must - // not require the mounted server tree's native dependency check. ensureNodePty: () => { throw new Error("the staged runtime must not probe for node-pty"); }, @@ -692,7 +708,7 @@ describe("DesktopBackendConfiguration", () => { return yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -744,11 +760,6 @@ describe("DesktopBackendConfiguration", () => { Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - // Resolve both before any token is cached, concurrently, so the - // generate step (a yield point) can interleave. The atomic - // get-or-create must still hand both the same token; a non-atomic - // Ref would let each generate its own and break the shared-token - // invariant. const [primary, wsl] = yield* Effect.all( [configuration.resolvePrimary, configuration.resolveWsl({ port: 5000, distro: null })], { concurrency: "unbounded" }, @@ -812,15 +823,12 @@ describe("DesktopBackendConfiguration", () => { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - // No settings.json exists here: the endpoints come from the desktop - // environment, and the bootstrap carries them for a WSL child that - // lacks the variables. assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -872,13 +880,11 @@ describe("DesktopBackendConfiguration", () => { const config = yield* configuration.resolvePrimary; assert.equal(config.bootstrap.otlpLogsUrl, "http://env:4318/v1/logs"); - // Only the logs endpoint is set in env, so the other two still come - // from the settings file rather than being dropped together. assert.equal(config.bootstrap.otlpTracesUrl, "http://persisted:4318/v1/traces"); assert.equal(config.bootstrap.otlpMetricsUrl, "http://persisted:4318/v1/metrics"); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -923,7 +929,7 @@ describe("DesktopBackendConfiguration", () => { }).pipe( Effect.provide( Layer.mergeAll( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -964,11 +970,10 @@ describe("DesktopBackendConfiguration", () => { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const config = yield* configuration.resolvePrimary; assert.equal(config.captureOutput, true); - // Dev never shares the prod compile cache. assert.notInclude(config.args, "--require"); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -1010,7 +1015,7 @@ describe("DesktopBackendConfiguration", () => { assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -1048,7 +1053,6 @@ describe("DesktopBackendConfiguration", () => { T3CODE_OTLP_TRACES_URL: "http://t3.example.com:4318/v1/traces", }; const previousWslEnv = process.env.WSLENV; - // A developer's own OTLP variables would be forwarded too. const ambientOtel = Object.entries(process.env).filter( ([name]) => name.startsWith("OTEL_") || name.startsWith("T3CODE_OTLP_"), ); @@ -1069,7 +1073,6 @@ describe("DesktopBackendConfiguration", () => { config.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS, "authorization=Bearer%20token", ); - // Without a flag, WSL passes the values through untranslated. const wslEnv = (config.env.WSLENV ?? "").split(":"); assert.include(wslEnv, "OTEL_EXPORTER_OTLP_ENDPOINT"); assert.include(wslEnv, "OTEL_EXPORTER_OTLP_LOGS_HEADERS"); @@ -1077,7 +1080,7 @@ describe("DesktopBackendConfiguration", () => { assert.include(wslEnv, "T3CODE_OTLP_TRACES_URL"); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -1112,7 +1115,6 @@ describe("DesktopBackendConfiguration", () => { const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL; - // A developer's own OTEL_* variables would be forwarded too. const ambientOtel = Object.entries(process.env).filter(([name]) => name.startsWith("OTEL_")); try { for (const [name] of ambientOtel) delete process.env[name]; @@ -1128,30 +1130,21 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.executablePath, "wsl.exe"); assert.equal(config.bootstrap.port, 5050); - // Binds to 0.0.0.0 inside WSL so the backend is reachable via - // both wslhost-forwarded localhost and the distro's eth0 IP. assert.equal(config.bootstrap.host, "0.0.0.0"); assert.equal(config.bootstrap.tailscaleServeEnabled, false); assert.notProperty(config.bootstrap, "desktopTelemetryFd"); assert.notProperty(config.bootstrap, "resourceMonitorPath"); - // httpBaseUrl uses the resolved distro IP from the test stub, - // not localhost โ€” the renderer reaches the backend directly to - // avoid relying on wslhost forwarding. assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); assert.equal(config.env.OPENAI_API_KEY, "openai-key"); assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); - // The existing WSLENV is preserved byte-for-byte (note the empty - // "::" segment survives โ€” WSL ignores it, so we don't normalize - // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is - // already declared, so it isn't forwarded twice. assert.equal( config.env.WSLENV, "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", ); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -1191,15 +1184,12 @@ describe("DesktopBackendConfiguration", () => { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const config = yield* configuration.resolvePrimary; - // wsl-only is persisted but WSL is unavailable, so the primary must - // not spawn wsl.exe (which would loop on preflight failures while the - // Connections backend control is hidden). Resolve the Windows primary. assert.equal(config.executablePath, process.execPath); assert.equal(config.bootstrap.t3Home, environment.baseDir); assert.isTrue(Option.isNone(config.preflightFailure)); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge( DesktopAppSettings.layerTest({ @@ -1236,7 +1226,7 @@ describe("DesktopBackendConfiguration", () => { assert.include(failure.reason, "Removed-Distro"); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge( DesktopAppSettings.layerTest({ @@ -1277,7 +1267,7 @@ describe("DesktopBackendConfiguration", () => { assert.include(failure.reason, "timed out"); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -1312,7 +1302,7 @@ describe("DesktopBackendConfiguration", () => { assert.include(failure.reason, "missing server entry"); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -1346,7 +1336,7 @@ describe("DesktopBackendConfiguration", () => { assert.include(failure.reason, "could not be extracted"); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge( @@ -1387,7 +1377,7 @@ describe("DesktopBackendConfiguration", () => { assert.include(failure.reason, "Removed-Distro"); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -1417,7 +1407,7 @@ describe("DesktopBackendConfiguration", () => { assert.equal(label, "WSL (Ubuntu)"); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge( DesktopAppSettings.layerTest({ @@ -1466,7 +1456,7 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.bootstrap.desktopTelemetryControlFd, 5); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -1512,7 +1502,7 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.bootstrap.resourceMonitorPath, releaseMonitorPath); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), @@ -1549,14 +1539,11 @@ describe("DesktopBackendConfiguration", () => { yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - // Mirrors the resolvePrimary fall-back: the label must follow the - // backend that actually resolves, not the persisted preference, so the - // env switcher can't show "WSL" for a Windows backend. const label = yield* configuration.resolvePrimaryLabel; assert.equal(label, "Windows"); }).pipe( Effect.provide( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge( DesktopAppSettings.layerTest({ @@ -1576,22 +1563,13 @@ describe("DesktopBackendConfiguration", () => { ); it("resolvePrimaryLabel is runSync-safe against the real WSL availability probe", async () => { - // getLocalEnvironmentBootstraps is a sync IPC method: it resolves the - // primary instance's lazy label through Effect.runSync. The label chains - // to wslEnvironment.isAvailable, whose real layer probes the filesystem. - // That probe must run once at layer build and expose a resolved value, not - // a live async effect โ€” otherwise runSync throws in the handler. Build the - // real WSL layer (not the sync test stub) and resolve the label with a - // top-level runSync, exactly as the handler does. // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- This test intentionally replicates the sync IPC handler's runSync path to catch a regression to async-only resolution; it.effect would mask it. const runtime = ManagedRuntime.make( - DesktopBackendConfiguration.layer.pipe( + layerBackendConfiguration().pipe( Layer.provideMerge(layerServerExposure), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layer), - // isAvailable on win32 only touches the filesystem, never the spawner, - // so a die-stub is enough to satisfy the layer's deps. Layer.provideMerge( Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index b306975beb24..d9ff98279718 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -20,6 +20,7 @@ import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopCliShim from "../app/DesktopCliShim.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; @@ -39,19 +40,10 @@ export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedE export class DesktopBackendConfiguration extends Context.Service< DesktopBackendConfiguration, { - // Build the Windows-native primary backend's start config. Reads the - // primary's port/host/exposure from DesktopServerExposure. Can fail - // with PlatformError because bootstrap token generation now uses - // crypto.randomBytes under the hood (post Effect 4 migration). readonly resolvePrimary: Effect.Effect< DesktopBackendManager.DesktopBackendStartConfig, PlatformError.PlatformError >; - // Build a WSL backend start config for the given distro on the given - // port. The WSL backend is always loopback-only (the primary owns LAN - // exposure when the user opts in), so this takes the port directly and - // hardcodes 127.0.0.1. Distro=null means "WSL default distro" and is - // forwarded to wsl.exe with no -d flag. readonly resolveWsl: (input: { readonly port: number; readonly distro: string | null; @@ -59,14 +51,7 @@ export class DesktopBackendConfiguration extends Context.Service< DesktopBackendManager.DesktopBackendStartConfig, PlatformError.PlatformError >; - // The renderer-facing label for the primary instance, derived from the - // same decision resolvePrimary makes (including the WSL-availability - // fall-back to Windows), so the env switcher can't show "WSL" for a - // backend that actually resolved to Windows. readonly resolvePrimaryLabel: Effect.Effect; - // The bootstrap token the renderer should present right now. It rotates - // every window (derived from the secret every backend was launched with), - // so the renderer never holds one long-lived admin credential. readonly currentBootstrapToken: Effect.Effect; } >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} @@ -97,8 +82,7 @@ const DESKTOP_BACKEND_ENV_NAMES = [ ] as const; // Env vars that the WSL backend needs but Windows process.env won't forward -// across the wsl.exe boundary without WSLENV. The dev-server URL travels as -// the `--dev-url` CLI flag instead. +// across the wsl.exe boundary without WSLENV. const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", @@ -152,9 +136,6 @@ const mergeWslEnv = ( ): string | undefined => { const existing = existingWslEnv?.trim() ?? ""; - // Names already declared, so we don't forward a duplicate. We parse the - // existing value only for this membership test โ€” the string itself is - // preserved verbatim below rather than re-serialized. const seenNames = new Set( existing .split(":") @@ -249,11 +230,6 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { }; }); -// The bootstrap carries the OTLP endpoints to every backend, including a WSL -// child that lacks the variables. The T3 URLs also travel as variables in -// WSL_FORWARDED_ENV_NAMES so they outrank a forwarded OTEL endpoint. Env beats -// the persisted settings file, matching the precedence resolveServerConfig and -// DesktopObservability apply. const readBackendObservabilitySettings = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const persisted = yield* readPersistedBackendObservabilitySettings; @@ -270,9 +246,6 @@ interface SharedBootstrapInput { readonly observabilitySettings: BackendObservabilitySettings; } -// What the launch runs inside the distro. The staged runtime is the release's -// self-contained `t3` executable (Node inside); the mounted server tree is a -// script that needs the distro's own Node. type WslPreflightRuntime = | { readonly kind: "executable"; @@ -293,20 +266,13 @@ interface WslPreflightSuccess { readonly runningDistro: string; readonly windowsEntryPath: string; readonly runtime: WslPreflightRuntime; - // PATH captured from the user's login shell. The launch forwards this value - // directly without a shell so the server can spawn provider CLIs by name. readonly resolvedPath: string; - // Identifies the distro-local runtime cache selected from the packaged archive. readonly runtimeId?: string; } interface WslPreflightFailure { readonly _tag: "Failed"; readonly reason: string; - // Fatal: the WSL distro is misconfigured (no node, wrong version, missing - // build tools) and retrying won't help โ€” surface it and (wsl-only) fall back - // to Windows. Non-fatal: transient (WSL not ready yet, wslpath while it - // boots), with a bounded window for self-healing before fallback. readonly fatal: boolean; readonly retryLimit?: number; } @@ -394,8 +360,6 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f ...(result.retryLimit === undefined ? {} : { retryLimit: result.retryLimit }), }) as const; - // The mounted server tree is the fallback runtime: the Windows-side copy the - // distro reads over /mnt. Slower to launch from, but always installed. const resolveMountedAppRoot = Effect.gen(function* () { const serverTree = yield* wslServerTree.ensure; if (!serverTree.ok) { @@ -422,12 +386,6 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f : ({ ok: true, windowsEntryPath, linuxAppRoot: mountedAppRoot.value } as const); }); - // Set once a staged runtime has been ruled out by the probe, and carried - // through the mounted attempt: if the mounted tree works the cache is the - // broken part and gets invalidated, and if the mounted tree returns its own - // fatal verdict the cached reason is the more actionable one to report. - // A transient mounted failure is neither โ€” it rules nothing out, so it stays - // retryable and the staged verdict waits for an attempt that can answer. let stagedFailure: { readonly runtimeId: string; readonly reason: string } | undefined; const failedStaged = (failure: { readonly reason: string }) => ({ @@ -488,9 +446,6 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f : failedNodePty(nodePtyResult); } - // The mounted tree runs what the cache could not, so the cache is the broken - // copy: revoke its ready marker so the next launch reinstalls it instead of - // reusing a tree that has already been proven unloadable. if (stagedFailure) { yield* wslEnv.invalidateRuntime(runningDistro, stagedFailure.runtimeId); } @@ -508,10 +463,8 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f } as const; }); -// True when the given IPv4 belongs to a Windows-side network -// interface. 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 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. const isLocalHostIpv4 = (ip: string): boolean => { const interfaces = NodeOS.networkInterfaces(); for (const list of Object.values(interfaces)) { @@ -519,9 +472,7 @@ const isLocalHostIpv4 = (ip: string): boolean => { 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. Normalize to a string so a future runtime - // bump can't silently break mirrored-mode detection and leave the - // renderer pointed at the distro IP instead of loopback. + // builds report the numeric 4. const family = String(entry.family); if ((family === "IPv4" || family === "4") && entry.address === ip) return true; } @@ -549,6 +500,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv input: SharedBootstrapInput & { readonly resourceMonitorPath: Option.Option; readonly cliPath: Option.Option; + readonly shellEnvironmentPrepared: boolean; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -567,10 +519,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv host: backendExposure.bindHost, desktopBootstrapToken: input.bootstrapToken, desktopBootstrapSecret: input.bootstrapSecret, - // DesktopApp installs the shell environment into process.env before any - // backend starts, and this child extends it (extendEnv below). WSL gets - // a different environment, so its bootstrap never sets this. - shellEnvironmentPrepared: true, + // Failed Windows probes leave server hydration enabled for another attempt. + shellEnvironmentPrepared: input.shellEnvironmentPrepared, tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, tailscaleServePort: backendExposure.tailscaleServePort, desktopTelemetryFd: 4, @@ -601,10 +551,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv env: { ...backendChildEnvPatch(), ELECTRON_RUN_AS_NODE: "1", - // The server names this launcher in commands it asks a person to run. T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), }, - // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). extendEnv: true, bootstrap, bootstrapDelivery: "fd3", @@ -632,17 +580,11 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const fileSystem = yield* FileSystem.FileSystem; - // Bind to 0.0.0.0 inside WSL so the backend is reachable both via - // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1 - // -> WSL 127.0.0.1) AND via the distro's eth0 IP directly from - // Windows. 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. - // Security-wise this is acceptable for the local-only WSL backend: - // the network it exposes on is the WSL-vEthernet network, not the - // LAN; the primary owns LAN exposure when the user opts in. + // 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. const wslBindHost = "0.0.0.0"; const bootstrap = { @@ -655,22 +597,13 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl 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. The backend reads tailscaleServePort - // only when tailscaleServeEnabled is true, so the actual value here is - // inert. + // PortSchema rejects 0, so when tailscale serve is disabled we still need a + // valid number in this slot. tailscaleServeEnabled: false, tailscaleServePort: 443, - // The packaged sidecar is a Windows executable and cannot run inside the - // Linux WSL backend. Keep the field absent instead of passing an unusable - // `/mnt/.../*.exe` path; WSL resource telemetry is reported unavailable. - // See docs/internals/resource-telemetry.md. ...buildObservabilityFragment(input.observabilitySettings), }; - // The archive is the primary packaged WSL path: it installs directly into - // the distro's ext4 filesystem. The server.asar extraction service is only - // consulted lazily if the archive is unavailable or cannot be staged. const archivePath = environment.path.join(environment.resourcesPath, WSL_RUNTIME_ARCHIVE_NAME); const archiveHashPath = environment.path.join( environment.resourcesPath, @@ -700,20 +633,9 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ? null : { windowsPath: archivePath, - // The verified archive bytes are the cache identity. Release builds - // embed the release version and pnpm install metadata, so the - // archive changes on every update even when application logic does - // not. Later launches of that update still reuse this directory. runtimeId: `sha256-${archiveHash}`, sha256: archiveHash, }, - // Packaged builds run the self-contained Linux runtime and, on fallback, - // whatever Linux node-pty the mounted tree carries, so the WSL backend never - // needs a compiler, node-gyp, or network on first launch. Compiling from - // source is a dev-only convenience: a checkout has no Linux binary, and - // developers have the toolchain. In packaged builds we instead surface a - // clear diagnostic if the binary can't load (unsupported arch/distro), - // rather than silently dropping into a fragile runtime build. allowBuild: !environment.isPackaged, }); @@ -723,9 +645,6 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const runningDistro = preflight._tag === "Ready" ? preflight.runningDistro : null; const distroForConfig = runningDistro ?? input.distro; - // Resolve the selected distro's IPv4 address. In mirrored mode the distro - // reports a host interface, so use loopback instead; a failed probe also - // falls back to loopback and preserves the previous behavior. const distroIp = yield* wslEnvironment.getDistroIp(distroForConfig); const usesSharedNetworkStack = Option.match(distroIp, { onNone: () => false, @@ -747,11 +666,6 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl } } - // Build an explicit copy of process.env minus T3CODE_HOME (dev-runner - // exports the Windows-side base dir for the primary; if it leaks into - // the WSL backend the Linux side ends up sharing C:\Users\...\.t3 via - // /mnt/c, which means both backends read/write the same database and - // their env-ids collide). const parentEnvWithoutT3Home: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (key === "T3CODE_HOME") continue; @@ -781,8 +695,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl }; // Forward the dev-server URL as an explicit CLI flag so the WSL backend's - // config resolution lands in dev/ instead of userdata/. The packaged build - // leaves devServerUrl as None. + // config resolution lands in dev/ instead of userdata/. const devUrlArgs = Option.match(environment.devServerUrl, { onNone: () => [] as ReadonlyArray, onSome: (url) => ["--dev-url", url.href], @@ -805,13 +718,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // 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. Pass the - // user PATH entries captured by the preflight. Every dynamic value is a - // separate argv entry under `wsl.exe --exec`; no shell command is involved, - // so Windows cannot mangle nested quotes and stdin remains reserved for the - // bootstrap envelope. A node-script runtime additionally leads with the - // probed Node's bin dir so the server cannot pick up a different node than - // the one node-pty was probed with. + // the process PATH, so `npm install -g ...` fails with NotFound. const runtime = preflight.runtime; const launchPath = runtime.kind === "executable" @@ -842,6 +749,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const fileSystem = yield* FileSystem.FileSystem; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; @@ -852,9 +760,8 @@ export const make = Effect.gen(function* () { // 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. modifyEffect serializes the whole - // get-or-create so the first caller wins and the rest reuse its secret. + // backends deriving mismatched tokens and breaking the shared-token invariant + // the renderer relies on. const secretRef = yield* SynchronizedRef.make(Option.none()); const getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) => Option.match(current, { @@ -869,11 +776,6 @@ export const make = Effect.gen(function* () { }), ); - // Both resolvers share the same bootstrap token: the renderer holds a - // single token and uses it against whichever backend it's currently - // talking to. Observability settings get re-read each resolve so a - // hot-swap of the server-settings file is picked up on the next - // restart cycle without having to bounce the desktop process. const sharedInputs = Effect.gen(function* () { const bootstrapSecret = yield* getOrCreateBootstrapSecret; const bootstrapToken = currentDesktopBootstrapToken( @@ -892,12 +794,6 @@ export const make = Effect.gen(function* () { }); const buildWslPrimaryConfig = Effect.gen(function* () { - // wsl-only mode pipes the WSL backend through the same port the - // Windows primary would normally take. That way the renderer - // still loads from the local-only endpoint advertised by - // DesktopServerExposure, and primary-aware code paths (cookie - // auth, the env switcher's "primary" id) keep working without - // a parallel "secondary" registration. const backendExposure = yield* serverExposure.backendConfig; const persistedSettings = yield* settings.get; const shared = yield* sharedInputs; @@ -924,16 +820,18 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); - return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath, cliPath }).pipe( + const shellEnvironmentPrepared = yield* shellEnvironment.installIntoProcess; + return yield* resolvePrimaryStartConfig({ + ...shared, + resourceMonitorPath, + cliPath, + shellEnvironmentPrepared, + }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), ); }); - // Single source of truth for what the primary actually runs as. Both - // the start-config dispatch and the renderer-facing label derive from - // this, so they can't disagree โ€” e.g. the label reading "WSL" while the - // config silently fell back to Windows because WSL is unavailable. // 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). diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 760e5a24f897..8cc1a9fc6433 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -141,11 +141,6 @@ interface MakeInstanceInput { readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect; } -// Helper that constructs a primary backend instance using the factory -// directly. The factory's deps (FileSystem, ChildProcessSpawner, -// HttpClient, DesktopBackendOutputLogFactory) are provided per-test via -// a scoped layer; tests yield the returned Effect inside `Effect.scoped` -// to drive the instance's lifecycle. function makeTestInstance(input: MakeInstanceInput) { const stubLog: DesktopObservability.DesktopBackendOutputLogShape = { beginSession: () => Effect.void, @@ -759,9 +754,6 @@ describe("DesktopBackendManager", () => { ), ); - // The backend stays 503 through the first *two* readiness budgets - // and only becomes healthy (200) for the third round, i.e. it comes - // up well after the initial 50ms budget has expired. const layerHttp = layerHttpClient((request) => Effect.gen(function* () { requestCount += 1; @@ -789,18 +781,10 @@ describe("DesktopBackendManager", () => { assert.equal(readyCount, 0); assert.equal(readinessTimeoutCount, 0); - // The first 50ms readiness budget expires while the backend still - // answers 503. The child is alive and may yet become healthy, so the - // probe must start a fresh round instead of stopping permanently โ€” - // the pre-fix behavior left the app stuck on "Connecting to WSLโ€ฆ" - // forever even though the backend kept running. yield* TestClock.adjust(Duration.millis(50)); assert.equal(readinessTimeoutCount, 1); assert.equal(readyCount, 0); - // The second budget also expires (backend still 503), then the third - // round connects. The point is the probe persisted across budgets - // while the process was alive instead of giving up after the first. yield* TestClock.adjust(Duration.millis(100)); assert.equal(readinessTimeoutCount, 2); assert.equal(readyCount, 1); @@ -1296,15 +1280,12 @@ describe("DesktopBackendManager", () => { yield* instance.start; assert.deepEqual(failures, []); - // Five fatal attempts with exponential backoff (500ms, 1s, 2s, 4s) reach - // the cap, at which point the failure is surfaced exactly once. yield* TestClock.adjust(Duration.millis(500)); yield* TestClock.adjust(Duration.seconds(1)); yield* TestClock.adjust(Duration.seconds(2)); yield* TestClock.adjust(Duration.seconds(4)); assert.deepEqual(failures, ["Node.js not found"]); - // Past the cap the loop stops and nothing else is surfaced. yield* TestClock.adjust(Duration.seconds(8)); yield* TestClock.adjust(Duration.seconds(30)); assert.deepEqual(failures, ["Node.js not found"]); @@ -1394,8 +1375,7 @@ describe("DesktopBackendManager", () => { }); yield* instance.start; - // Well beyond the fatal cap's worth of time: a transient failure must - // keep retrying (self-heal) and never surface. + yield* TestClock.adjust(Duration.minutes(2)); assert.deepEqual(failures, []); }).pipe(Effect.provide(TestClock.layer())), @@ -1545,10 +1525,6 @@ describe("DesktopBackendManager", () => { it.effect("stopAllPoolInstances bounds the quit finalizer when backends hang", () => Effect.scoped( Effect.gen(function* () { - // Each backend's process-scope finalizer reports when it starts and - // when it finishes, keyed by instance name, so the test can prove - // both backends reached each milestone instead of inferring it from - // a shared flag or a clock advance. const teardownStarted = yield* Queue.unbounded(); const teardownFinished = yield* Queue.unbounded(); const allowTeardown = yield* Deferred.make(); @@ -1589,9 +1565,6 @@ describe("DesktopBackendManager", () => { unregister: () => Effect.die(new Error("unregister not implemented")), }); - // Mirror the quit path: register stopAllPoolInstances as a scope - // finalizer and let the scope close run it, rather than calling it - // as an ordinary interruptible effect. const quitFiber = yield* Effect.scoped( Effect.addFinalizer(() => DesktopApp.stopAllPoolInstances()), ).pipe(Effect.provide(layerMockPool), Effect.forkChild); @@ -1599,14 +1572,10 @@ describe("DesktopBackendManager", () => { const started = yield* Queue.takeN(teardownStarted, 2); assert.deepEqual(started.toSorted(), ["instance1", "instance2"]); - // Both backends are now hung in teardown. Advancing past the 5s - // budget must let the quit finalizer return without them. yield* TestClock.adjust(Duration.seconds(5)); yield* Fiber.join(quitFiber); assert.equal(yield* Queue.size(teardownFinished), 0); - // The timed-out closes keep running in the background and finish - // once the backends unblock. yield* Deferred.succeed(allowTeardown, undefined); const finished = yield* Queue.takeN(teardownFinished, 2); assert.deepEqual(finished.toSorted(), ["instance1", "instance2"]); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index c758b27dd282..8d2a3f1c4912 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1,28 +1,3 @@ -// Per-instance backend factory. Replaces the legacy singleton -// `DesktopBackendManager` Context.Service: each call to -// `makeBackendInstance(spec)` constructs an isolated backend lifecycle โ€” -// its own state Ref, mutex, restart loop, and active child process. The -// returned `DesktopBackendInstance` exposes start/stop/snapshot/wait -// methods that operate on that single backend. -// -// The pool layer (`DesktopBackendPool.ts`) calls this factory once per -// backend it wants to run. Today that's the Windows primary; follow-up -// commits add a second call for the WSL instance. -// -// Singleton couplings that the legacy service held inline are now -// parameterized via the spec: -// - configResolve replaces the legacy `DesktopBackendConfiguration.resolve` -// so each instance can resolve its own start config โ€” the primary wires -// `configuration.resolvePrimary`, the WSL orchestrator wires a -// `configuration.resolveWsl({ port, distro })` closure. -// - onReady / onShutdown drive UI side effects (window auto-open, -// readiness latch) only for instances that want them โ€” the primary's -// spec passes the window's handleBackendReady/handleBackendNotReady, -// other pool instances pass nothing. -// - log writes go through a per-instance writer that the factory -// pulls from `DesktopBackendOutputLogFactory.forInstance(spec.id)`, -// so each instance lands in its own rotating file. - import * as Brand from "effect/Brand"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; @@ -58,10 +33,6 @@ import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); -// After this many consecutive fatal preflight failures, stop the silent -// restart loop and surface the reason via onPreflightFailed. Transient -// failures may instead provide their own larger retryLimit when they should -// self-heal for a while but must not leave the app connecting forever. const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5; const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); @@ -101,20 +72,10 @@ export interface DesktopBackendStartConfig extends BackendProcessContext { readonly httpBaseUrl: URL; readonly captureOutput: boolean; readonly preflightFailure: Option.Option; - // Present for a WSL run after the configured/default distro has been - // resolved to the concrete distro passed to wsl.exe. readonly runningDistro?: string; - // Present only when this run launched from a staged WSL-local runtime. - // Once HTTP readiness succeeds, the manager uses it to retain this cache - // plus the newest previous cache and prune older versions. readonly wslRuntimeId?: string; } -// A preflight failure records whether it is fatal. Transient failures (WSL -// cold-starting, wslpath while the VM boots) keep retrying so the backend can -// self-heal; fatal ones (no node, wrong version, missing build tools) are -// surfaced via onPreflightFailed and stop the restart loop after -// MAX_PREFLIGHT_FAILURE_ATTEMPTS. export interface PreflightFailure { readonly reason: string; readonly fatal: boolean; @@ -226,9 +187,7 @@ export type BackendProcessError = typeof BackendProcessError.Type; interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly desktopTelemetryStream: Stream.Stream; - /** Events for the desktop's browser tabs, written to `desktopBrowserFd`. */ readonly desktopBrowserStream?: Stream.Stream; - /** Each line the backend writes to `desktopBrowserControlFd`. */ readonly onDesktopBrowserCommand?: (line: string) => Effect.Effect; readonly onDesktopTelemetryControl?: ( message: DesktopTelemetryControlMessageValue, @@ -254,11 +213,6 @@ export interface DesktopBackendSnapshot { readonly restartScheduled: boolean; } -// Opaque identifier for one backend process inside the pool. Today only -// PRIMARY_INSTANCE_ID is registered. Follow-up commits add WSL distros -// under ids derived from the distro name (e.g. "wsl:ubuntu"). Eventually -// these map 1:1 with environment ids on the frontend; keeping them -// desktop-local for now avoids leaking the contracts dependency. export type BackendInstanceId = string & Brand.Brand<"BackendInstanceId">; export const BackendInstanceId = Brand.nominal(); @@ -266,9 +220,6 @@ export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId( PRIMARY_LOCAL_ENVIRONMENT_ID, ); -// One pooled backend instance. Same lifecycle surface as the legacy -// `DesktopBackendManagerShape`; the id and label give the pool registry -// + UI something to route on. export interface DesktopBackendInstance { readonly id: BackendInstanceId; readonly label: Effect.Effect; @@ -276,25 +227,12 @@ export interface DesktopBackendInstance { readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; readonly currentConfig: Effect.Effect>; readonly snapshot: Effect.Effect; - // Polls desiredRunning + the instance's own ready flag until the - // backend reports ready, or the timeout elapses. Returns true on - // ready, false on timeout. Used by the WSL backend swap to drive its - // rollback path. readonly waitForReady: (timeout: Duration.Duration) => Effect.Effect; } -// Spec describing one backend instance to spawn. The configResolve -// effect is awaited each time the instance is (re)started so live -// settings changes are picked up on the next start cycle. onReady and -// onShutdown let the primary instance trigger UI side effects (window -// open, global readiness flag) without coupling the factory to those -// concerns; other instances pass them as undefined. export interface BackendInstanceSpec { readonly id: BackendInstanceId; readonly label: Effect.Effect; - // configResolve can now fail with PlatformError because the - // bootstrap-token closure inside DesktopBackendConfiguration uses - // crypto.randomBytes (Effect 4 beta.73 migration). 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 @@ -303,9 +241,6 @@ export interface BackendInstanceSpec { // between "fired onReady" and "currentConfig already advanced". readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; readonly onShutdown?: () => Effect.Effect; - // Fired once when a fatal or bounded preflight failure has exhausted its - // retries. Returns true when the callback changed configuration and the - // manager should resolve once more; false stops the failed instance. readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect; readonly onStateDirOwned?: () => Effect.Effect; } @@ -325,8 +260,6 @@ interface BackendManagerState { readonly config: Option.Option; readonly active: Option.Option; readonly restartAttempt: number; - // Consecutive bounded/fatal preflight failures, reset on a clean or - // unbounded-transient preflight. restartAttempt counts all restarts. readonly preflightFailureAttempt: number; readonly restartFiber: Option.Option>; readonly nextRunId: number; @@ -609,14 +542,12 @@ 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. Each round gets a fresh budget, and the forked - // loop is torn down with the run scope once the child exits. + // 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. const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() => waitForHttpReady({ executablePath: options.executablePath, @@ -668,11 +599,6 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( } satisfies BackendProcessExit; }); -// Factory for one pooled backend instance. The returned instance owns -// its own state Ref, mutex, restart loop, and active child process; -// nothing is shared between instances created from separate -// makeBackendInstance calls. The instance shuts down automatically when -// the calling scope closes (typically the application scope). export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( spec: BackendInstanceSpec, ): Effect.fn.Return< @@ -784,9 +710,6 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (Option.isSome(preflightFailure)) { const { reason, fatal, retryLimit } = preflightFailure.value; if (!fatal && retryLimit === undefined) { - // Transient (WSL cold-starting, wslpath while the VM boots). Keep - // retrying so the backend self-heals once WSL is ready. Reset a - // prior bounded/fatal streak because this is a different failure. yield* Ref.update(state, (latest) => latest.preflightFailureAttempt === 0 ? latest @@ -801,9 +724,6 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return [next, { ...latest, preflightFailureAttempt: next }] as const; }); if (attempt > attemptLimit) { - // We already surfaced and asked for the Windows fallback, yet we're - // still resolving the WSL primary โ€” the fallback didn't take (e.g. - // the settings write failed). Stop rather than loop forever. yield* logInstanceError("backend preflight still failing after fallback; stopping", { reason, attempt, @@ -816,10 +736,6 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } if (attempt === attemptLimit) { - // Fatal/bounded and out of retries. Surface the reason (onPreflightFailed, - // on the primary, shows a dialog and persists Windows mode), then - // schedule one more restart so the next resolve picks up the Windows - // primary and a window can open. yield* logInstanceError( "backend preflight failed repeatedly; surfacing and falling back", { reason, attempt }, @@ -841,8 +757,6 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* scheduleRestart(reason); return; } - // Clean preflight โ€” reset the fatal counter so a later failure gets a - // fresh allowance. yield* Ref.update(state, (latest) => latest.preflightFailureAttempt === 0 ? latest : { ...latest, preflightFailureAttempt: 0 }, ); @@ -957,7 +871,6 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const program = runBackendProcess({ ...config.value, desktopTelemetryStream: desktopTelemetryPublisher.encoded, - // Only a bootstrap that names the browser fds (the local primary) gets them. desktopBrowserStream: desktopBrowserHost.events, onDesktopBrowserCommand: desktopBrowserHost.handleCommandLine, onDesktopTelemetryControl: (message) => @@ -1193,8 +1106,6 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const waitForReady = (timeout: Duration.Duration): Effect.Effect => Effect.gen(function* () { const current = yield* Ref.get(state); - // Return false early if an external `stop()` flipped desiredRunning off - // โ€” no point polling for a backend that is being torn down. if (!current.desiredRunning) return { done: true, ready: false }; return current.ready ? { done: true, ready: true } : { done: false, ready: false }; }).pipe( diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index c5f31fc47a09..72a05e9d657d 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -124,8 +124,6 @@ describe("DesktopBackendPool", () => { assert.equal(yield* Option.getOrThrow(fetchedWsl).label, "WSL (Ubuntu)"); assert.isTrue(Option.isNone(fetchedMissing)); assert.lengthOf(all, 2); - // First instance becomes primary in layerTest so single-instance - // stubs don't have to wire an explicit primary. assert.equal(resolvedPrimary.id, DesktopBackendPool.PRIMARY_INSTANCE_ID); }).pipe( Effect.provide( @@ -158,4 +156,50 @@ describe("DesktopBackendPool", () => { }), ), ); + + it.effect("unregister stops the registered instance", () => + Effect.scoped( + Effect.gen(function* () { + const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"); + const pool = yield* DesktopBackendPool.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: [], + entryPath: "/server/bin.mjs", + cwd: "/server", + env: {}, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3774, + t3Home: "/tmp/t3", + host: "127.0.0.1", + desktopBootstrapToken: "token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + bootstrapDelivery: "fd3", + extendEnv: true, + httpBaseUrl: new URL("http://127.0.0.1:3774"), + captureOutput: false, + preflightFailure: Option.some({ reason: "WSL starting", fatal: false }), + } satisfies DesktopBackendStartConfig), + }); + yield* instance.start; + assert.equal((yield* instance.snapshot).restartScheduled, true); + + yield* pool.unregister(id); + + const stopped = yield* instance.snapshot; + assert.equal(stopped.desiredRunning, false); + assert.equal(stopped.restartScheduled, false); + assert.equal(Option.isNone(yield* pool.get(id)), true); + }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows")))), + ), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 21845205110c..a0e6d4377f2c 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -1,84 +1,3 @@ -// Pool registry for multiple backend processes. This file is the entry -// point for the concurrent-Windows+WSL-backend feature; see the design -// notes below before extending it. -// -// Current state: -// - `DesktopBackendManager.ts` exposes a per-instance factory -// (`makeBackendInstance(spec)`); the pool calls it once for the -// Windows primary at startup, and `DesktopWslBackend.reconcile` -// calls it through `pool.register` to bring up the WSL instance -// when the user enables it. -// - The primary spec wires `configResolve` to -// `DesktopBackendConfiguration.resolvePrimary` and the -// `onReady`/`onShutdown` callbacks to the window service. WSL -// instances wire `configResolve: configuration.resolveWsl(...)` -// and skip onReady/onShutdown โ€” the window only follows the primary. -// - The pool exposes `register(spec)` and `unregister(id)`. Each -// registered instance gets its own child scope, so unregister can -// stop it cleanly without tearing down the pool. The primary's id -// refuses unregister. -// - Settings: `wslBackendEnabled: boolean` + `wslDistro: string | null`. -// The legacy `wslMode: "local" | "wsl"` swap setting is migrated on -// load. IPC surface is `setWslBackendEnabled(boolean)` + -// `setWslDistro(string | null)`; both persist and then call the -// orchestrator's reconcile. No swap, no rollback, primary stays up. -// - `getLocalEnvironmentBootstraps()` (plural) returns one entry per -// pool instance currently registered with bootstrap info. The -// primary keeps the "primary" id; WSL instances are "wsl:default" -// or "wsl:". -// - `pickFolder` accepts an optional `targetEnvironmentId`. Omitting -// it gives the Windows picker โ€” what every existing caller gets, -// and what non-WSL users see. WSL targets route to the wsl helpers. -// - Web settings UX: a plain toggle for "WSL backend" plus a distro -// picker that shows up when the toggle is on. Default-off, so -// users who never opted in see the same surface as before. -// -// Renderer-side wiring (apps/web/src/environments/local/): -// - reconcileLocalSecondaryEnvironments() runs at app boot and after -// WSL settings changes. It reads getLocalEnvironmentBootstraps(), -// skips the primary (which the existing primary/ runtime owns), -// and for every other entry POSTs the shared bootstrap token to -// /api/auth/bootstrap/bearer on that backend's URL, fetches the -// descriptor, builds a SavedEnvironmentRecord marked desktopLocal, -// writes the bearer to the secret store, and opens a connection -// through the same saved-env path remote envs use. -// - The desktopLocal marker filters records out of saved-env -// persistence, so toggling WSL off or switching distros doesn't -// pollute the user's settings file. The sidebar, CommandPalette, -// env switcher, and project-id routing all read the saved-env -// registry, so the WSL backend shows up there without any -// per-surface changes. -// -// Browser validation (2026-05-17, dev:desktop with wslBackendEnabled=true, -// wslDistro="Ubuntu"): -// - Two backends listening on distinct loopback ports -// (server.log: 13773 primary, 13774 wsl). -// - Per-instance log files: server-child.log + server-child-wsl_Ubuntu.log. -// - Distinct environment ids reported by each backend's -// /.well-known/t3/environment (Windows vs Linux platform). -// - Renderer completes the bearer-token bootstrap against the WSL -// backend (POST /api/auth/bootstrap/bearer 200), obtains a -// ws-token (POST /api/auth/ws-token 200), and holds an -// ESTABLISHED WebSocket connection to both ports (netstat). -// -// Migration history (commits): -// 1. Reshape `DesktopBackendManager` into an instance factory and route -// consumers through the pool. Pool held a single instance. (a8fc7845) -// 2. Drop `DesktopState.backendReady`. The window owns its own -// readiness latch via onReady / onShutdown callbacks. (425c7d0b) -// 3. Per-instance log routing via DesktopBackendOutputLogFactory. (563820ed) -// 4. Add register/unregister to the pool. (a0eaf560) -// 5. Wire WSL through the pool: settings rename, BackendConfiguration -// split, DesktopWslBackend orchestrator, new IPC, web compat. -// (b1622191 + 31ce3add + 627c80cb) -// 6. Widen getLocalEnvironmentBootstrap to *Bootstraps (plural). (bad66041) -// 7. pickFolder takes optional targetEnvironmentId. (5d80468d) -// 8. Settings UX: toggle + distro picker, no swap dialog. (eb5a03ea) -// 9. Register WSL backend as desktop-local saved env via -// reconcileLocalSecondaryEnvironments. (1c7e7873 + c17897bd) -// 10. CommandPalette enables file-manager picker for desktop-local -// envs, routes pickFolder by env id. (38e8477a) - import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -113,9 +32,6 @@ export const PRIMARY_INSTANCE_ID = DesktopBackendManager.PRIMARY_INSTANCE_ID; export type DesktopBackendInstance = DesktopBackendManager.DesktopBackendInstance; export type BackendInstanceSpec = DesktopBackendManager.BackendInstanceSpec; -// Caller tried to register an id that's already in the pool. The pool -// refuses overwrites so two independent orchestrators racing on the -// same id surface as a typed failure instead of one silently winning. export class DesktopBackendPoolInstanceAlreadyRegisteredError extends Schema.TaggedError()( "DesktopBackendPoolInstanceAlreadyRegisteredError", { @@ -127,9 +43,6 @@ export class DesktopBackendPoolInstanceAlreadyRegisteredError extends Schema.Tag } } -// Primary instance is registered for the pool's lifetime. Unregister is -// a no-op for it today (no real callers), but if someone wires it up -// later it's a clear bug rather than something to "handle". export class DesktopBackendPoolCannotUnregisterPrimaryError extends Schema.TaggedError()( "DesktopBackendPoolCannotUnregisterPrimaryError", {}, @@ -142,40 +55,18 @@ export class DesktopBackendPoolCannotUnregisterPrimaryError extends Schema.Tagge export class DesktopBackendPool extends Context.Service< DesktopBackendPool, { - // Look up a registered instance. None when no backend with that id is - // currently registered (e.g. WSL backend disabled). readonly get: (id: BackendInstanceId) => Effect.Effect>; - // Snapshot of all currently-registered instances. Order is unspecified; - // callers that need a canonical "primary first" view should sort by id. readonly list: Effect.Effect; - // Convenience accessor for the always-registered primary instance. - // Currently equivalent to `get(PRIMARY_INSTANCE_ID)` unwrapped, but - // exposed as a typed effect so consumers don't have to handle the - // Option for the case that's guaranteed to be present. readonly primary: Effect.Effect; - // Build a fresh DesktopBackendInstance from `spec` and add it to the - // registry. The pool owns the instance's scope: unregister(id) or pool - // teardown closes it and runs the instance's auto-stop finalizer. The - // returned instance has not been started โ€” callers decide when to - // start it (and can call start more than once if a retry-after-failure - // story makes sense for them). readonly register: ( spec: BackendInstanceSpec, ) => Effect.Effect; - // Stop the named instance and remove it from the registry. Closing the - // instance's scope triggers its auto-stop finalizer; the registry is - // updated atomically with the scope close so subsequent get(id) calls - // observe the unregister before the underlying child process has fully - // exited. readonly unregister: ( id: BackendInstanceId, ) => Effect.Effect; } >()("@t3tools/desktop/backend/DesktopBackendPool") {} -// Services required by makeBackendInstance โ€” exported so caller -// orchestrators that build their own specs can confirm the layer graph -// satisfies them at compile time. export type BackendInstanceFactoryRequirements = | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner @@ -188,9 +79,6 @@ export type BackendInstanceFactoryRequirements = interface ActiveRegisteredInstance { readonly _tag: "Active"; readonly instance: DesktopBackendInstance; - // None for the primary (which lives in the pool's own layer scope and - // is never unregistered); Some for instances added via register, whose - // scope unregister closes to stop them. readonly scope: Option.Option; } @@ -218,22 +106,13 @@ export const layer = Layer.effect( const electronDialog = yield* ElectronDialog.ElectronDialog; const electronApp = yield* ElectronApp.ElectronApp; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; - // Anchor the pool's lifetime to its layer scope so registered - // instance scopes can be forked off it. 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. + // 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. const layerScope = yield* Scope.Scope; - // Capture the services needed to build any future instance from the - // pool's layer scope. register() runs `makeBackendInstance` against - // a fresh child scope but reuses these services so the instance gets - // the same FileSystem, spawner, HTTP client and log factory the - // primary instance uses. const factoryContext = yield* Effect.context(); - // A WSL preflight failure on the primary only happens in wsl-only mode. // 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. @@ -260,13 +139,9 @@ 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.`, ); - // Fully disable the WSL backend โ€” both flags, matching the "Switch to - // Windows" recovery path โ€” so the manager's next restart re-resolves the - // primary as Windows and reconcile won't register a secondary WSL backend - // against the same broken setup. 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. + // 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. yield* appSettings.applyWslWindowsFallback.pipe( Effect.catch((error) => logBackendPoolWarning( @@ -289,12 +164,8 @@ export const layer = Layer.effect( // primaries as Windows. 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. The window service only - // logs on success, so log the failure here before swallowing it โ€” - // otherwise a post-readiness window-open failure vanishes silently and - // is near-impossible to diagnose in production. + // 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. onReady: (httpBaseUrl) => desktopWindow.handleBackendReady(httpBaseUrl).pipe( Effect.catch((error) => @@ -348,12 +219,14 @@ export const layer = Layer.effect( ] as const); } return Effect.gen(function* () { - // Provide the captured factory services first, then the child scope - // last so instance finalizers are owned by the unregisterable scope. + // 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. const instanceScope = yield* Scope.fork(layerScope, "sequential"); const instance = yield* DesktopBackendManager.makeBackendInstance(spec).pipe( - Effect.provide(factoryContext), Scope.provide(instanceScope), + Effect.provide(factoryContext), ); const next = new Map(current); next.set(spec.id, { @@ -454,13 +327,6 @@ export const layer = Layer.effect( }), ); -// Test layer for unit tests that want to assert against a known pool -// composition without standing up the full manager. Each provided -// instance is registered under its own id; the first one is also -// surfaced as `primary` so callers can stub a single-instance pool. -// `register` and `unregister` are stubbed to die so tests that -// accidentally exercise pool registration fail loudly instead of -// silently noop'ing. export const layerTest = ( instances: readonly DesktopBackendInstance[], ): Layer.Layer => diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 3f0bd82d5888..900afce9b860 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -128,7 +128,6 @@ describe("DesktopLocalEnvironmentAuth", () => { ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); - // The first exchange happens a day after launch, past the launch token's windows. yield* TestClock.setTime(DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS * 2 + 1); yield* Effect.gen(function* () { const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; @@ -171,7 +170,6 @@ describe("DesktopLocalEnvironmentAuth", () => { }, ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); - // Answers the first `failures` exchanges with `failure`, then with a token. const makeExchange = (failures: number, failure: () => Response) => Effect.gen(function* () { const requestCount = yield* Ref.make(0); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index d137d502e6c5..4d95dd075cf3 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -16,10 +16,6 @@ import * as HttpClient from "effect/http/HttpClient"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; -// A loopback /oauth/token exchange can fail transiently while the backend -// settles (a 502-504, a refused or reset connection, a timeout). Retry those -// for as long as the renderer's own bootstrap does; a rejected credential or a -// server error is final. const BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT = Duration.seconds(15); const BOOTSTRAP_TRANSIENT_RETRY_INTERVAL = Duration.millis(500); const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); @@ -113,7 +109,6 @@ export const make = Effect.gen(function* () { while: isTransientBearerBootstrapError, schedule: Schedule.spaced(BOOTSTRAP_TRANSIENT_RETRY_INTERVAL), }), - // Bounds the attempts and any request still in flight at the deadline. Effect.timeout(BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT), Effect.mapError( (cause) => diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts index f2c32e1d0731..b75a686ca46b 100644 --- a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts @@ -39,7 +39,7 @@ export class DesktopNetworkInterfaces extends Context.Service< } >()("@t3tools/desktop/backend/DesktopNetworkInterfaces") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; return DesktopNetworkInterfaces.of({ diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index b4d0a0626554..5e3d25392ef8 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -40,12 +40,13 @@ const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { ], }; -function layerMockSpawner(statusJson = "{}") { +function layerMockSpawner(statusJson = "{}", onSpawn?: () => void) { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ + Effect.sync(() => { + onSpawn?.(); + return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), isRunning: Effect.succeed(false), @@ -57,8 +58,8 @@ function layerMockSpawner(statusJson = "{}") { all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, - }), - ), + }); + }), ), ); } @@ -146,6 +147,34 @@ const withHarness = ( }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopServerExposure", () => { + it.effect("uses cached Tailscale identity to exclude another VPN's pairing address", () => { + let statusReads = 0; + return withHarness( + { + ...lanNetworkInterfaces, + pvpnksintrf1: [{ address: "100.85.0.1", family: "IPv4", internal: false }], + vpn0: [{ address: "100.74.126.34", family: "IPv4", internal: false }], + }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setMode("network-accessible"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://100.74.126.34:4173/"], + ); + assert.deepEqual(yield* serverExposure.getAdvertisedEndpoints, endpoints); + assert.equal(statusReads, 1); + }), + {}, + layerMockSpawner(`{"Self":{"TailscaleIPs":["100.74.126.34"]}}`, () => { + statusReads += 1; + }), + ); + }); + it.effect("falls back to local-only without losing the requested network preference", () => withHarness( emptyNetworkInterfaces, @@ -211,6 +240,39 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("advertises the Wi-Fi or Ethernet address ahead of bridges and tunnels", () => + withHarness( + { + bridge0: [{ address: "192.168.2.1", family: "IPv4", internal: false }], + utun4: [{ address: "10.8.0.2", family: "IPv4", internal: false }], + en0: [{ address: "192.168.1.20", family: "IPv4", internal: false }], + }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const change = yield* serverExposure.setMode("network-accessible"); + assert.equal(change.state.advertisedHost, "192.168.1.20"); + }), + ), + ); + + it.effect("keeps a host bridge's LAN address ahead of later adapters", () => + withHarness( + { + vmbr0: [{ address: "192.168.1.30", family: "IPv4", internal: false }], + enx00e04c680001: [{ address: "192.168.42.10", family: "IPv4", internal: false }], + }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const change = yield* serverExposure.setMode("network-accessible"); + assert.equal(change.state.advertisedHost, "192.168.1.30"); + }), + ), + ); + it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => withHarness( emptyNetworkInterfaces, @@ -317,7 +379,6 @@ describe("DesktopServerExposure", () => { const settings = yield* DesktopAppSettings.DesktopAppSettings; return DesktopAppSettings.DesktopAppSettings.of({ ...settings, - // Hold the mode write the way a slow disk would. setServerExposureMode: (mode) => Deferred.succeed(modeWriteStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseModeWrite)), @@ -405,11 +466,8 @@ describe("DesktopServerExposure", () => { Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* serverExposure.configureFromSettings({ port: 4173 }); - // mode stays at default "local-only", tailscaleServeEnabled stays false. const endpoints = yield* serverExposure.getAdvertisedEndpoints; - // Only the loopback endpoint; no tailscale spawn means the dieOnSpawnLayer - // would have crashed the test if the gate was missing. assert.deepEqual( endpoints.map((endpoint) => endpoint.httpBaseUrl), ["http://127.0.0.1:4173/"], diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index e5bd11035f5c..b116622576b6 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -70,6 +70,18 @@ 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. + */ +const HOST_ONLY_INTERFACE = + /^(bridge\d|utun|ipsec|ppp|tun|tap|wg|docker|br-|veth|virbr|vmnet|vboxnet|VirtualBox|VMware)/; + const isHttpsEndpointUrl = (value: string): boolean => { try { return new URL(value).protocol === "https:"; @@ -87,18 +99,17 @@ const resolveLanAdvertisedHost = ( return normalizedExplicitHost; } - for (const interfaceAddresses of Object.values(networkInterfaces)) { - if (!interfaceAddresses) continue; - - for (const address of interfaceAddresses) { - if (address.internal) continue; - if (address.family !== "IPv4") continue; - if (!isUsableLanIpv4Address(address.address)) continue; - return address.address; - } - } - - return null; + const lanAddresses = Object.entries(networkInterfaces).flatMap(([name, addresses]) => + (addresses ?? []) + .filter( + (address) => + !address.internal && address.family === "IPv4" && isUsableLanIpv4Address(address.address), + ) + .map((address) => ({ name, address: address.address })), + ); + const preferred = + lanAddresses.find(({ name }) => !HOST_ONLY_INTERFACE.test(name)) ?? lanAddresses[0]; + return preferred?.address ?? null; }; const resolveDesktopServerExposure = (input: { @@ -198,9 +209,7 @@ const resolveDesktopCoreAdvertisedEndpoints = ( : "User-configured endpoint for this desktop backend.", }), ); - } catch { - // Ignore malformed user-configured endpoints without dropping valid endpoints. - } + } catch {} } return endpoints; @@ -428,9 +437,8 @@ export const make = Effect.gen(function* () { // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App // Store Tailscale CLI lives inside Tailscale's sandbox container, so each // spawn re-triggers the "Other apps" TCC prompt. - const cachedReadMagicDnsName = yield* Effect.cachedWithTTL( + const cachedReadTailscaleStatus = yield* Effect.cachedWithTTL( readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), Effect.orElseSucceed(() => null), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), ), @@ -556,7 +564,7 @@ export const make = Effect.gen(function* () { serveEnabled: state.tailscaleServeEnabled, servePort: state.tailscaleServePort, networkInterfaces: currentNetworkInterfaces, - readMagicDnsName: cachedReadMagicDnsName, + readStatus: cachedReadTailscaleStatus, }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 2c0055a0668d..1911654bdfa5 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -20,7 +20,109 @@ const layerUnusedTailscaleExternalServices = Layer.mergeAll( ), ); +const protonAndTailscaleInterfaces = { + pvpnksintrf1: [{ address: "100.85.0.1", family: "IPv4", internal: false }], + tailscale0: [{ address: "100.74.126.34", family: "IPv4", internal: false }], +}; + describe("tailscale endpoint provider", () => { + it.effect("does not advertise Proton's CGNAT address as a Tailscale pairing endpoint", () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: protonAndTailscaleInterfaces, + statusJson: `{"Self":{"TailscaleIPs":["100.74.126.34"]}}`, + }); + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://100.74.126.34:3773/"], + ); + }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), + ); + + it.effect( + "does not advertise CGNAT interfaces when Tailscale reports no assigned addresses", + () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: { pvpnksintrf1: protonAndTailscaleInterfaces.pvpnksintrf1 }, + statusJson: `{"BackendState":"NoState","Self":{"TailscaleIPs":null}}`, + }); + assert.deepEqual(endpoints, []); + }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), + ); + + it.effect("keeps native Tailscale interfaces with missing, malformed, or stale status", () => + Effect.gen(function* () { + for (const interfaceName of ["tailscale0", "Tailscale"]) { + for (const statusJson of [null, "not-json", `{"Self":{"TailscaleIPs":[]}}`]) { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: { + pvpnksintrf1: protonAndTailscaleInterfaces.pvpnksintrf1, + [interfaceName]: protonAndTailscaleInterfaces.tailscale0, + }, + statusJson, + }); + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://100.74.126.34:3773/"], + ); + } + } + }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), + ); + + it.effect( + "recognizes a macOS Tailscale tunnel by its IPv6 address when status is unavailable", + () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: { + utun3: [{ address: "100.85.0.1", family: "IPv4", internal: false }], + utun4: [ + { address: "100.74.126.34", family: "IPv4", internal: false }, + { address: "fd7a:115c:a1e0::1234", family: "IPv6", internal: false }, + ], + }, + statusJson: null, + }); + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://100.74.126.34:3773/"], + ); + }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), + ); + + it.effect("uses assigned addresses for renamed adapters without IPv6", () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: { + vpn0: [{ address: "100.74.126.34", family: "IPv4", internal: false }], + }, + statusJson: `{"Self":{"TailscaleIPs":["100.74.126.34"]}}`, + }); + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://100.74.126.34:3773/"], + ); + }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), + ); + + it.effect("does not advertise cached Tailscale addresses absent from local interfaces", () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: { pvpnksintrf1: protonAndTailscaleInterfaces.pvpnksintrf1 }, + statusJson: `{"Self":{"TailscaleIPs":["100.74.126.35"]}}`, + }); + assert.deepEqual(endpoints, []); + }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), + ); + it.effect("parses MagicDNS names from tailscale status", () => Effect.gen(function* () { const dnsName = yield* parseTailscaleMagicDnsName( @@ -49,7 +151,7 @@ describe("tailscale endpoint provider", () => { }, ], }, - statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100"]}}`, }); assert.deepEqual(endpoints, [ { @@ -96,21 +198,24 @@ describe("tailscale endpoint provider", () => { }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), ); - it.effect("uses an injected magic DNS name reader instead of spawning tailscale", () => + it.effect("uses one injected status reader for both IP and MagicDNS endpoints", () => Effect.gen(function* () { let readerCalls = 0; const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: 3773, - networkInterfaces: {}, - readMagicDnsName: Effect.sync(() => { + networkInterfaces: protonAndTailscaleInterfaces, + readStatus: Effect.sync(() => { readerCalls += 1; - return "desktop.tail.ts.net"; + return { + magicDnsName: "desktop.tail.ts.net", + tailnetIpv4Addresses: ["100.74.126.34"], + }; }), }); assert.equal(readerCalls, 1); assert.deepEqual( endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["https://desktop.tail.ts.net/"], + ["http://100.74.126.34:3773/", "https://desktop.tail.ts.net/"], ); }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), ); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 7dff0b61b372..ec3819ae98af 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -3,9 +3,10 @@ import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/co import { buildTailscaleHttpsBaseUrl, isTailscaleIpv4Address, - parseTailscaleMagicDnsName, + parseTailscaleStatus, probeTailscaleHttpsEndpoint, readTailscaleStatus, + type TailscaleStatus, } from "@t3tools/tailscale"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -26,17 +27,32 @@ const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { function resolveTailscaleIpAdvertisedEndpoints(input: { readonly port: number; readonly networkInterfaces: NetworkInterfaces; + readonly status: TailscaleStatus | null; }): readonly AdvertisedEndpoint[] { const seen = new Set(); const endpoints: AdvertisedEndpoint[] = []; - for (const interfaceAddresses of Object.values(input.networkInterfaces)) { + 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. + const isTailscaleInterface = + /^tailscale(?:\d+|ipv[46])?$/iu.test(interfaceName) || + interfaceAddresses.some( + (address) => + !address.internal && + address.family === "IPv6" && + /^fd7a:115c:a1e0:/iu.test(address.address), + ); + for (const address of interfaceAddresses) { if (address.internal) continue; if (address.family !== "IPv4") continue; if (!isTailscaleIpv4Address(address.address)) continue; + if (!isTailscaleInterface && !input.status?.tailnetIpv4Addresses.includes(address.address)) { + continue; + } if (seen.has(address.address)) continue; seen.add(address.address); @@ -105,8 +121,8 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd readonly servePort?: number; readonly networkInterfaces: NetworkInterfaces; readonly statusJson?: string | null; - readonly readMagicDnsName?: Effect.Effect< - string | null, + readonly readStatus?: Effect.Effect< + TailscaleStatus | null, never, ChildProcessSpawner.ChildProcessSpawner >; @@ -116,23 +132,17 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd never, ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient > { - const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); - const readDnsName = - input.readMagicDnsName ?? - readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), - Effect.orElseSucceed((): string | null => null), - ); - const dnsName = + const readStatus = + input.readStatus ?? readTailscaleStatus.pipe(Effect.orElseSucceed(() => null)); + const status = input.statusJson === undefined - ? yield* readDnsName + ? yield* readStatus : input.statusJson - ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( - Effect.orElseSucceed(() => null), - ) + ? yield* parseTailscaleStatus(input.statusJson).pipe(Effect.orElseSucceed(() => null)) : null; + const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints({ ...input, status }); const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ - dnsName, + dnsName: status?.magicDnsName ?? null, serveEnabled: input.serveEnabled === true, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), ...(input.probe === undefined ? {} : { probe: input.probe }), diff --git a/apps/desktop/src/boot.ts b/apps/desktop/src/boot.ts index c9eb35b24e5f..a8abdad9bbc6 100644 --- a/apps/desktop/src/boot.ts +++ b/apps/desktop/src/boot.ts @@ -1,4 +1,4 @@ -// Packaged app entry. Enables the compile cache before the main bundle loads, -// so the cache also covers main.cjs itself. +// 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/compileCache.ts b/apps/desktop/src/compileCache.ts index 20f942db7db9..da0fd66f5e58 100644 --- a/apps/desktop/src/compileCache.ts +++ b/apps/desktop/src/compileCache.ts @@ -1,19 +1,12 @@ // @effect-diagnostics nodeBuiltinImport:off -// Runs before any Effect runtime exists, so it stays on Node built-ins. import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -// Turns on Node's on-disk V8 code cache for every module loaded after this one, -// so later launches skip recompiling the large main and server bundles. -// Packaged builds only: boot.ts loads it for the main process, and the local -// backend gets it with `--require`. Dev launches main.cjs directly and skips it. -// 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. The backend inherits APPIMAGE, so this covers it too. +// 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. try { if (!process.env.APPIMAGE) { const cacheRoot = @@ -23,6 +16,4 @@ try { : NodeOS.tmpdir(); NodeModule.enableCompileCache(NodePath.join(cacheRoot, "t3code", "compile-cache")); } -} catch { - // The cache is only a speedup. Never let it stop the app from starting. -} +} catch {} diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index f75ea51552e7..0aae1148a482 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -51,6 +51,7 @@ export class ElectronApp extends Context.Service< */ readonly systemLocale: Effect.Effect; readonly whenReady: Effect.Effect; + readonly requestSingleInstanceLock: Effect.Effect; readonly quit: Effect.Effect; readonly exit: (code: number) => Effect.Effect; readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; @@ -137,6 +138,7 @@ export const make = ElectronApp.of({ catch: (cause) => new ElectronAppWhenReadyError({ isPackaged, cause }), }); }), + requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), quit: Effect.sync(() => { Electron.app.quit(); }), diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 3f0be7cc7b6d..4c9da1dd9259 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -49,7 +49,6 @@ describe("ElectronProtocol", () => { const request = (pathname: string, init?: RequestInit) => Effect.promise(() => handler!(new Request(`t3code://app${pathname}`, init))); - // SPA routes fall back to index.html, including ones containing dots. const page = yield* request("/settings/connections"); assert.equal(yield* Effect.promise(() => page.text()), "app"); assert.include(page.headers.get("content-security-policy") ?? "", "default-src 'self'"); @@ -323,6 +322,7 @@ describe("ElectronProtocol", () => { ]); assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]); assert.deepEqual(directives["frame-src"], ["'self'", "blob:", "http:", "https:"]); + assert.deepEqual(directives["object-src"], ["'self'", "blob:", "http:", "https:"]); assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 7b2bf8a59fc9..9abe17d77512 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -52,8 +52,6 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError catch: (cause) => new ElectronProtocolFetchError({ cause }), }); -// The dev renderer target can briefly refuse connections while Vite restarts: -// retry idempotent requests after 50ms, then 150ms, and keep the last failure. const fetchWithTransientRetry = (url: string, init: RequestInit) => netFetch(url, init).pipe( Effect.retry({ schedule: Schedule.exponential("50 millis", 3), times: 2 }), @@ -213,9 +208,6 @@ const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* ( return withContentSecurityPolicy(response, contentSecurityPolicy); }); -// Serves the packaged web client without a backend: files resolve within the -// asset directory, and any other path falls back to index.html so the SPA -// router handles it, except for asset-shaped misses (`/missing.js`) which 404. const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( request: Request, assetDirectory: string, @@ -276,7 +268,6 @@ export const make = Effect.gen(function* () { contentSecurityPolicy, ); } - // Reject with net.fetch's own error, as an unproxied fetch would. return runPromise( proxyRequest(request, input.targetOrigin, contentSecurityPolicy).pipe( Effect.catchTags({ diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 5504125ec202..d6832c79eb80 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -58,7 +58,6 @@ export class ElectronShell extends Context.Service< ElectronShell, { readonly openExternal: (rawUrl: unknown) => Effect.Effect; - /** Opens a known System Settings pane by identifier, not by URL. */ readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; } diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index a5e74494c12f..a7ab05303f72 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -139,7 +139,6 @@ export const make = Effect.gen(function* () { ); return windowsForegroundFocus; }; - // Tracks a capture reveal in flight. Ordinary reveals keep Electron's native path. const captureRevealWindows = new Set(); yield* Effect.addFinalizer(() => Effect.sync(() => windowsForegroundFocus?.close())); const mainWindowRef = yield* Ref.make>(Option.none()); @@ -269,8 +268,6 @@ export const make = Effect.gen(function* () { return; } - // Only a capture reveal fights another process for the foreground, which - // needs Win32 calls that load native modules. Everything else stays native. const captureReveal = platform === "win32" && captureRevealWindows.delete(window.id); const shellHostedForeground = captureReveal && (await isWindowsShellHostedForeground().catch(() => false)); diff --git a/apps/desktop/src/electron/WindowsForeground.ts b/apps/desktop/src/electron/WindowsForeground.ts index b90081d20cde..47ecd7fd71f8 100644 --- a/apps/desktop/src/electron/WindowsForeground.ts +++ b/apps/desktop/src/electron/WindowsForeground.ts @@ -14,7 +14,6 @@ export interface WindowsForegroundApi { | { readonly x: number; readonly y: number; readonly width: number; readonly height: number } | undefined; readonly getProcessImagePath: (processId: number) => string; - /** High bit of `GetAsyncKeyState`: whether the virtual key is currently down. */ readonly isKeyDown: (virtualKey: number) => boolean; readonly attachThreadInput: ( sourceThreadId: number, diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index dd645a3379f6..354acc5a8155 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -154,10 +154,6 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => export const layer = (ipcMain: DesktopIpcMain) => Layer.succeed(DesktopIpc, make(ipcMain)); -/** - * Convenience helpers for creating IPC methods - */ - export interface DesktopIpcMethodRegistration< Payload, EncodedPayload, diff --git a/apps/desktop/src/ipc/methods/localEnvironment.test.ts b/apps/desktop/src/ipc/methods/localEnvironment.test.ts index c6376554f99a..94bbf085a992 100644 --- a/apps/desktop/src/ipc/methods/localEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/localEnvironment.test.ts @@ -12,8 +12,6 @@ import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts"; -// `relaunch` declares the lifecycle runtime services as requirements even -// though the mocked relaunch never touches them. const layerUnusedLifecycleRuntime = Layer.mergeAll( DesktopShutdown.layer, DesktopState.layer, diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 94c56464f7c4..74c6f85043d7 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -256,11 +256,9 @@ export const clearCache = DesktopIpc.makeIpcMethod({ }); /** - * Partition scope for an (environment, profile) pair. - * * 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. Incognito derives a non-persistent partition. + * existed, so upgrading does not strand anyone's existing logins in an orphaned + * partition. */ export function resolvePartitionScope( environmentId: string, @@ -322,11 +320,6 @@ export const getPreviewConfig = DesktopIpc.makeIpcMethod({ }), }); -/** - * Registered separately from `methods`: these carry `BrowserImport` in their - * context and their own failure type, so they do not unify with the - * manager-backed handlers the shared loop iterates. - */ export const listBrowserImportSources = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL, payload: Schema.Void, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 33895ffffbd1..6a2bfc7f1773 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -102,21 +102,7 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ const isPrimary = instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID; const config = yield* instance.currentConfig; const snapshot = yield* instance.snapshot; - // A secondary backend (e.g. a parallel WSL backend) that hasn't produced - // a config yet (mid-registration, before its first start cycle) or that - // is retrying a *transient* preflight failure (WSL VM still booting, a - // not-yet-built linux server entry) is not listening on a port. We - // surface it as a *pending* bootstrap (null endpoints, no token) so the - // renderer can show a "Connectingโ€ฆ" indicator while it retries โ€” null - // endpoints keep the renderer from dialing the dead port, avoiding the - // needless /api/auth/bootstrap/bearer error cycles a real endpoint would - // trigger. if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) { - // Skip the primary (same-origin, no "connecting" affordance) and skip a - // secondary whose preflight failed *fatally* (no node, wrong version, - // missing build tools): it has stopped retrying, so an indefinite - // "Connectingโ€ฆ" would be misleading โ€” its error is surfaced by the - // WSL-state UI instead. const fatalPreflight = Option.isSome(config) && Option.isSome(config.value.preflightFailure) && @@ -143,11 +129,9 @@ 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. Every backend - // the desktop launches (primary, staged or mounted WSL runtime) is the - // server build bundled with this desktop, so it understands the secret. + // 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. ...(bootstrap.desktopBootstrapSecret ? { bootstrapToken: yield* configuration.currentBootstrapToken } : bootstrap.desktopBootstrapToken @@ -159,10 +143,6 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ }), }); -// Pull the distro selection out of a backend instance id like -// "wsl:ubuntu". Returns null for "wsl:default", which is the sentinel -// for "track the user's WSL default distro" and maps to the -// wslEnv-derived default at picker time. function extractWslDistroFromEnvironmentId(envId: string): string | null { if (!envId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX)) { return null; @@ -196,15 +176,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ if (!settings.localEnvironmentEnabled) { return null; } - // Three picker modes: - // - targetEnvironmentId omitted: default to the primary picker. Keeps - // the historical behavior unchanged for users who never enabled the - // WSL backend, and is what unfamiliar callers should get out of the - // box. - // - targetEnvironmentId starts with "wsl:": route to the WSL picker - // using the distro encoded in the id (or the user's selected - // wslDistro when the id is the "wsl:default" sentinel). - // - anything else (incl. PRIMARY_LOCAL_ENVIRONMENT_ID): primary picker. const targetId = options?.targetEnvironmentId; const wslDistroFromTarget = targetId !== undefined && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX) @@ -381,8 +352,6 @@ export const pasteAsText = DesktopIpc.makeIpcMethod({ }), }); -/** Theme files are a few KB; anything larger returns empty text and lets the - * renderer reject it by size without the contents ever crossing the bridge. */ const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; export const pickThemeFiles = DesktopIpc.makeIpcMethod({ @@ -394,9 +363,6 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ const electronWindow = yield* ElectronWindow.ElectronWindow; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // The VS Code extensions directory is the same dotfolder on Windows, - // macOS, and Linux; when it is missing the picker opens wherever the - // platform would by default. const extensionsDir = path.join(yield* HostProcess.HomeDirectory, ".vscode", "extensions"); const defaultPath = yield* fileSystem .exists(extensionsDir) @@ -420,10 +386,7 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ } const text = yield* fileSystem.readFileString(filePath); return { name, size, text } satisfies PickedThemeFile; - }).pipe( - // An unreadable file degrades to an entry the renderer reports. - Effect.orElseSucceed((): PickedThemeFile => ({ name, size: 0, text: "" })), - ); + }).pipe(Effect.orElseSucceed((): PickedThemeFile => ({ name, size: 0, text: "" }))); }); }), }); diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts index 37cd992bb641..3c10f04512b4 100644 --- a/apps/desktop/src/ipc/methods/wsl.ts +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -32,8 +32,6 @@ const readWslState: Effect.Effect< available, wslOnly: settings.wslOnly, distros, - // Only the dual-mode secondary records this; a wsl-only failure surfaces via - // a dialog + Windows fallback, so it stays null there. preflightError: settings.wslOnly ? null : Option.getOrNull(preflightError), }; }); @@ -70,9 +68,7 @@ export const setWslBackendEnabled = makeIpcMethod({ return state; } // Reconcile is idempotent and never fails; no need for a swap-style - // rollback when the WSL side has trouble coming up. With both - // backends running side by side, "WSL didn't start" is a transient - // state on one instance โ€” the primary stays up either way. + // rollback when the WSL side has trouble coming up. yield* wslBackend.reconcile; return yield* readWslState; }), @@ -90,7 +86,6 @@ export const setWslDistro = makeIpcMethod({ 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. - // When WSL is disabled, this only stages a preference for the next enable. if (settings.wslBackendEnabled && settings.wslOnly && change.changed) { const state = yield* readWslState; yield* lifecycle.relaunch(`wslDistro=${distro ?? "default"}`); diff --git a/apps/desktop/src/legacyLocalStorageMerge.ts b/apps/desktop/src/legacyLocalStorageMerge.ts index 5a048e2edd18..c63bf7e4668c 100644 --- a/apps/desktop/src/legacyLocalStorageMerge.ts +++ b/apps/desktop/src/legacyLocalStorageMerge.ts @@ -1,10 +1,3 @@ -/** - * Merges V1 profile localStorage items into the V2 profile. Runs in the preload - * before the app reads storage, and never overwrites what V2 already holds: a - * key V2 has written keeps V2's value, except that stashed prompts and unsent - * drafts are merged entry by entry so neither side's are lost. - */ - interface MergeStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; @@ -15,7 +8,6 @@ const COMPOSER_DRAFTS_KEY = "t3code:composer-drafts:v1"; const NOT_IMPORTED_KEYS = new Set([ // Identifies one running install to the server; two apps must not share it. "t3.backgroundActivity.clientId", - // Resumes a permission setup V1 left mid-flow; V2 would redirect to it on boot. "t3code:snap-shot-setup-resume:v1", ]); /** Matches MAX_STASH_ENTRIES in apps/web/src/promptStashStore.ts. */ @@ -40,7 +32,6 @@ function parseObject(raw: string): JsonObject | null { } } -/** V2 entries first, then V1 entries V2 does not already have, up to the stash cap. */ function mergePromptStash(current: JsonObject, legacy: JsonObject): JsonObject | null { const currentEntries = isObject(current.state) ? current.state.entries : null; const legacyEntries = isObject(legacy.state) ? legacy.state.entries : null; @@ -55,7 +46,6 @@ function mergePromptStash(current: JsonObject, legacy: JsonObject): JsonObject | }; } -/** Adds V1 drafts for threads V2 has no draft for. Shapes must share a version. */ function mergeComposerDrafts(current: JsonObject, legacy: JsonObject): JsonObject | null { if (current.version !== legacy.version) return null; if (!isObject(current.state) || !isObject(legacy.state)) return null; @@ -86,7 +76,6 @@ function mergeValue(key: string, current: string, legacy: string): string | null return merged === null ? null : JSON.stringify(merged); } -/** Returns false when a write failed, so the caller can retry on a later launch. */ export function mergeLegacyLocalStorage( storage: MergeStorage, legacyItems: Readonly>, @@ -105,7 +94,6 @@ export function mergeLegacyLocalStorage( try { storage.setItem(key, next); } catch { - // Over quota: skip this key rather than abandon the rest. complete = false; } } diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index 5827e38e406f..157175e53332 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -26,7 +26,6 @@ describe("linuxSecretStorage", () => { expect( resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "niri" } }), ).toBe(preference); - // An explicit preference also wins where auto would have stayed out of the way. expect( resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "KDE" } }), ).toBe(preference); @@ -76,9 +75,6 @@ describe("linuxSecretStorage", () => { }); it("overrides KDE sessions identified only by legacy variables", () => { - // Chromium reaches KWallet4 for some of these, such as DESKTOP_SESSION=kde with a version, and - // basic text for the rest. Either way these are the variables a previous session leaves behind, - // so they are treated as unproven and forced to a real keyring rather than a guessed wallet. expect(autoSwitch({ XDG_CURRENT_DESKTOP: "plasma" })).toBe("gnome-libsecret"); for (const session of [ "kde", @@ -107,7 +103,6 @@ describe("linuxSecretStorage", () => { expect( autoSwitch({ XDG_CURRENT_DESKTOP: "niri", DESKTOP_SESSION: "gnome", GDMSESSION: "gnome" }), ).toBe("gnome-libsecret"); - // A previous KDE session left these behind; the compositor running now is not KDE. expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri", KDE_SESSION_VERSION: "6" })).toBe( "gnome-libsecret", ); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index 3aa7a440d1e8..81f891985b9d 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -9,8 +9,7 @@ export type LinuxPasswordStoreSwitch = Exclude { const send = (action: "drag" | "finder" | "close") => ipcRenderer.send(MAC_PERMISSION_HELPER_CHANNEL, action); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ef00c4d66ed8..3056ce1ea263 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -103,9 +103,6 @@ const layerDesktopEnvironment = Layer.unwrap( }), ); -// The remote runs the exact release this app is on, from its self-contained -// archive, so it needs neither Node nor npm. Development points the remote at -// a source checkout instead so the two sides can be iterated together. const resolveDesktopSshCliRunner = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], ): RemoteT3RunnerOptions => { @@ -187,23 +184,16 @@ const layerDesktopAppActivation = DesktopAppActivation.layer.pipe( Layer.provide(layerDesktopWindow), ); -// Pool layer instantiates the backend factory once for the Windows -// primary instance and exposes it via pool.primary. Consumers go through -// the pool now; the legacy DesktopBackendManager service is gone. The -// WSL second instance gets registered later in the migration. See -// DesktopBackendPool.ts header for the full rollout plan. const layerDesktopBackend = DesktopBackendPool.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), + Layer.provideMerge(DesktopShellEnvironment.layer), Layer.provideMerge(DesktopWslEnvironment.layer), Layer.provideMerge(DesktopWslServerTree.layer), Layer.provideMerge(DesktopTelemetryPublisher.layer), Layer.provideMerge(layerDesktopWindow), ); -// WSL orchestrator hangs off the backend layer because it needs the -// pool + configuration + serverExposure; it pulls NetService and the -// foundation services through the same provideMerge chain. const layerDesktopWslBackend = DesktopWslBackend.layer.pipe( Layer.provideMerge(layerDesktopBackend), ); @@ -219,7 +209,6 @@ const layerDesktopApplication = Layer.mergeAll( DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopCliCommand.layer, - DesktopShellEnvironment.layer, layerDesktopSsh, ).pipe( Layer.provideMerge(layerDesktopSnapShot), diff --git a/apps/desktop/src/permissions/MacPermissionHelper.ts b/apps/desktop/src/permissions/MacPermissionHelper.ts index 3f395c81ffc4..dfc378cfd1c4 100644 --- a/apps/desktop/src/permissions/MacPermissionHelper.ts +++ b/apps/desktop/src/permissions/MacPermissionHelper.ts @@ -18,7 +18,6 @@ const permissionGranted = (permission: MacPermission) => { return false; }; -/** Resolve the outer app bundle, never the executable or the ASAR inside it. */ export function macAppBundlePath(executable: string): string | undefined { return /^(.+\.app)\/Contents\/MacOS\/[^/]+$/.exec(executable)?.[1]; } @@ -65,7 +64,6 @@ img { width: 32px; height: 32px; pointer-events: none; } `; } -/** Owns one temporary panel and its IPC listener. Closing it releases all resources. */ export class MacPermissionHelper { private generation = 0; private window: Electron.BrowserWindow | undefined; @@ -130,7 +128,6 @@ export class MacPermissionHelper { try { if ((await isGranted()) && !window.isDestroyed()) finish(); } catch { - // An unavailable probe is not evidence of a grant; the wizard can retry. } finally { checking = false; } diff --git a/apps/desktop/src/permissions/MacSettingsWindow.ts b/apps/desktop/src/permissions/MacSettingsWindow.ts index 02d8180e0785..f529d82b5941 100644 --- a/apps/desktop/src/permissions/MacSettingsWindow.ts +++ b/apps/desktop/src/permissions/MacSettingsWindow.ts @@ -56,7 +56,6 @@ function run() { } }`; -/** Place the panel inside Settings' content column, above its bottom edge. */ export function settingsHelperBounds(settings: NonNullable): Electron.Rectangle { const sidebarWidth = 216; const inset = 16; @@ -69,7 +68,6 @@ export function settingsHelperBounds(settings: NonNullable): Ele }; } -/** Track only metadata; this does not request Accessibility or Screen Recording. */ export function watchMacSettingsWindow( onChange: (window: SettingsWindow) => void, onUnavailable: () => void, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index efd49ffa3864..2e046b7f238c 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -40,9 +40,7 @@ try { void ipcRenderer.invoke(IpcChannels.COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL); } } -} catch { - // Best effort: the app still starts on the V2 profile's own storage. -} +} catch {} // oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. const clientPlatform = process.platform; diff --git a/apps/desktop/src/preview/AnnotationIds.test.ts b/apps/desktop/src/preview/AnnotationIds.test.ts new file mode 100644 index 000000000000..86eeccc92ab6 --- /dev/null +++ b/apps/desktop/src/preview/AnnotationIds.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vite-plus/test"; + +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"); + + const secondLoad = createAnnotationIdSource(); + secondLoad("element"); + const secondAnnotation = secondLoad("annotation"); + + expect(secondAnnotation).not.toBe(firstAnnotation); + }); + + it("never repeats an id within one page load", () => { + const nextId = createAnnotationIdSource(); + const ids = Array.from({ length: 1_000 }, () => nextId("annotation")); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/apps/desktop/src/preview/AnnotationIds.ts b/apps/desktop/src/preview/AnnotationIds.ts new file mode 100644 index 000000000000..2262febdd1c1 --- /dev/null +++ b/apps/desktop/src/preview/AnnotationIds.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ +export function createAnnotationIdSource(): (prefix: string) => string { + const pageLoad = Array.from(crypto.getRandomValues(new Uint32Array(2)), (part) => + part.toString(36), + ).join(""); + let sequence = 0; + return (prefix) => { + sequence += 1; + return `${prefix}_${pageLoad}_${sequence.toString(36)}`; + }; +} diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index edd4e981dee5..22e384834789 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -26,10 +26,6 @@ const cookie = { sameSite: "lax" as const, }; -/** - * Dies if the import reaches session work: every case here covers a request - * that must be rejected before a cookie is read or written. - */ const layerRejectedBeforeSession = Layer.succeed( BrowserSession.BrowserSession, BrowserSession.BrowserSession.of({ @@ -41,10 +37,6 @@ const layerRejectedBeforeSession = Layer.succeed( }), ); -/** - * Builds the service against a scratch home containing an installed, closed - * copy of the source browser. - */ const withImporter = Effect.fnUntraced(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" }); @@ -80,8 +72,6 @@ describe("BrowserImport.importCookies", () => { const fileSystem = yield* FileSystem.FileSystem; const { importer, home } = yield* withImporter(); - // A cookie database reachable on disk but outside the browser's - // user-data directory โ€” the payoff a traversal would be after. yield* fileSystem.makeDirectory(`${home}/secrets`, { recursive: true }); yield* fileSystem.writeFileString(`${home}/secrets/Cookies`, "not-a-db"); @@ -151,7 +141,6 @@ describe("BrowserImport.writeCookies", () => { skipped: 1, skippedDomains: ["rejected.example"], }); - // Nothing landed, so there is nothing to persist. assert.equal(flushes, 0); }), ); @@ -175,8 +164,6 @@ describe("BrowserImport.writeCookies", () => { { cookies: [cookie, cookie], undecryptable: 0, undecryptableHosts: [] }, ); - // One flush after every write, not one per cookie; the cookies are in - // the session either way, so a failed flush is not a failed import. assert.deepEqual(events, ["set", "set", "flush"]); assert.deepEqual(result, { imported: 2, skipped: 0, skippedDomains: [] }); }), diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 69fccc5b761d..e7f97eca26c0 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -1,9 +1,3 @@ -/** - * Browser import service - lists importable sources and writes their cookies - * into a T3 Code browser profile's Electron partition. - * - * @module BrowserImport - */ import type { BrowserImportInput, BrowserImportResult, @@ -44,7 +38,6 @@ export class BrowserImportFailedError extends Schema.TaggedError>; readonly importCookies: (input: { readonly input: BrowserImportInput; - /** Partition scope of the target profile, derived by the caller in main. */ readonly scope: string; readonly persistent: boolean; readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; @@ -93,11 +85,8 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; if (yield* isSourceRunning(definition, context)) return "browserRunning"; - // Safari's jar is found by `stat`, which TCC permits without Full Disk - // Access โ€” so a Safari that lists as ready may still refuse the read. Probe - // the grant here, so the wizard can open on the permission step and a - // post-grant recheck can tell granted from still-denied, rather than only - // discovering it by attempting the import. + // Safari's jar is found by `stat`, which TCC permits without Full Disk Access + // โ€” so a Safari that lists as ready may still refuse the read. if (definition.engine === "safari") { const jar = yield* resolveCookieDatabase(definition, context, "."); if (jar !== undefined && (yield* safariAccessDenied(jar))) return "needsFullDiskAccess"; @@ -105,7 +94,6 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* return undefined; }); -/** The host a constructed cookie URL points at, for naming what was skipped. */ const cookieHost = (url: string): string => { try { return new URL(url).hostname; @@ -152,9 +140,7 @@ export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( } // `set` resolves once the cookie is in memory; Chromium writes the store to // disk on its own schedule. Flush before reporting "Done", so a crash right - // after does not lose what the user was just told was imported. A failed - // flush is logged rather than surfaced: the cookies are still in the - // session and land on disk at the next scheduled write. + // after does not lose what the user was just told was imported. if (imported > 0) { yield* Effect.tryPromise(() => session.cookies.flushStore()).pipe( Effect.tapError((error) => @@ -171,8 +157,6 @@ export const make = Effect.gen(function* BrowserImportMake() { const browserSession = yield* BrowserSession.BrowserSession; const platform = yield* HostProcess.Platform; const executablePath = yield* HostProcess.ExecutablePath; - // Captured here so the service's methods stay free of a requirements - // channel: the layer is built where NodeServices is already in scope. const platformServices = yield* Effect.context< FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner >(); @@ -185,8 +169,6 @@ export const make = Effect.gen(function* BrowserImportMake() { return { id: definition.id, name: definition.name, - // Listing profiles touches the source's own files, so skip it when the - // source is unusable anyway. profiles: unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [], ...(unavailable === undefined ? {} : { unavailable }), @@ -252,16 +234,9 @@ export const make = Effect.gen(function* BrowserImportMake() { requestedProfile.directory, ).pipe(Effect.provide(platformServices)); if (databasePath === undefined) { - // A profile we listed moments ago can lose its database before the - // import runs (browser data cleanup, a profile reset). That is a read - // failure, not a platform problem. return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" }); } - // Both branches fail with a tagged error, so the union stays structurally - // identifiable and each tag is handled on its own below. The success side - // is normalized to one shape too, so the skipped tally survives either - // engine โ€” Firefox stores plaintext, so nothing there is ever unreadable. const userDataDirectory = definition.userDataDirectory(pathContext); const read: Effect.Effect< CookieReadResult, @@ -297,15 +272,10 @@ export const make = Effect.gen(function* BrowserImportMake() { Effect.fail( new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), ), - // Firefox has one failure mode โ€” its plaintext database would not open - // โ€” so its error carries no reason of its own and the user-facing one - // is supplied here. FirefoxCookieReadError: (cause) => Effect.fail( new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }), ), - // Safari's reasons are already user-facing: a TCC refusal is the Full - // Disk Access prompt, anything else is a read failure. SafariCookieReadError: (cause) => Effect.fail( new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index 0cd921267ac3..eace39003e39 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -140,7 +140,6 @@ describe("readChromiumCookieDatabase", () => { expect(partial.cookies.map((cookie) => cookie.value)).toEqual(["kept"]); expect(partial.undecryptable).toBe(1); - // A partitioned-only jar does not need its key: it is skipped separately. yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`delete from cookies where name = 'readable'`; @@ -322,8 +321,6 @@ describe("readChromiumCookieDatabase", () => { ('ev11.example', 'empty-v11', '', ${encryptChromium("v11", "empty v11 value", cbcEmpty)}, '/', 0, 1, 0, 0)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); - // The records' own keys fail, and the empty key recovers both โ€” the - // retry Chromium itself performs. const recovered = yield* readChromiumCookieDatabase( filename, { cbcV10, cbcV11, cbcEmpty }, @@ -335,8 +332,6 @@ describe("readChromiumCookieDatabase", () => { ]); expect(recovered.undecryptable).toBe(0); - // Matching Chromium: a record whose own key is missing entirely is not - // retried with the empty key. const noV11 = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcEmpty }, "linux"); expect(noV11.cookies.map(({ name }) => name)).toEqual(["empty-v10"]); expect(noV11.undecryptableHosts).toEqual(["ev11.example"]); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index d524bf0938fc..d5f7592b2606 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -1,18 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the // OSCrypt primitives Chromium uses; Effect has no equivalent. -/** - * Chromium cookie extraction. - * - * Reads a Chromium-family browser's cookie database and decrypts each record - * with the key its prefix calls for. Key acquisition โ€” and the consent it - * needs โ€” lives in `ChromiumKeys`. - * - * Records whose scheme we hold no key for are skipped rather than failing the - * whole import: a Linux database can mix `v10` and `v11`. A partial result - * reported honestly is more useful than an all-or-nothing error. - * - * @module ChromiumCookies - */ import * as NodeCrypto from "node:crypto"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -45,12 +32,7 @@ const AES_GCM_NONCE_LENGTH = 12; const AES_GCM_TAG_LENGTH = 16; const isChromiumKeyError = Schema.is(ChromiumKeyError); -/** - * Every way the read can fail: the key failures, plus the ones this module - * raises itself. - */ export const ChromiumCookieReadReason = Schema.Literals([ - // `readFailed` already comes from the key failures, so it is not repeated. ...ChromiumKeyFailure.literals, "browserRunning", ]); @@ -60,13 +42,7 @@ export class ChromiumCookieReadError extends Schema.TaggedError { if (value === 0) return "no_restriction"; @@ -113,10 +87,9 @@ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { }; /** - * Chromium timestamps count microseconds from 1601-01-01; Electron wants - * seconds from the UNIX epoch. The microsecond value overflows JavaScript's - * safe integer range and `node:sqlite` refuses to narrow it, so the division - * happens in SQL and this only ever sees seconds. + * The microsecond value overflows JavaScript's safe integer range and + * `node:sqlite` refuses to narrow it, so the division happens in SQL and this + * only ever sees seconds. */ const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600; const toUnixSeconds = (webkitSeconds: number): number | undefined => { @@ -126,7 +99,7 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => { /** * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding - * a cookie to its domain. Strip it when present. + * a cookie to its domain. */ const stripDomainBinding = ( plaintext: Buffer, @@ -176,11 +149,6 @@ const decryptGcm = ( } }; -/** - * Decrypts one stored value, choosing the scheme from its prefix. Returns null - * when no key covers that scheme โ€” including Windows' app-bound `v20`, which - * this build has no key for at all. - */ export function decryptChromiumValue( encrypted: Uint8Array, keys: ChromiumKeyMaterial, @@ -203,8 +171,7 @@ export function decryptChromiumValue( // Chromium retries a failed record with a key derived from an empty // passphrase, because some Linux clients wrote data that way - // (crbug.com/1195256). A record whose own key is missing entirely stays - // skipped, matching Chromium. + // (crbug.com/1195256). if (prefix === "v10") { if (!keys.cbcV10) return null; return ( @@ -219,18 +186,12 @@ export function decryptChromiumValue( (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null) ); } - // No recognised prefix: Chromium on macOS and Linux both treat this as - // legacy data stored in the clear and return it as-is, so it is a readable - // cookie rather than an undecryptable one. Windows is the exception โ€” its - // app-bound `v20` blobs also lack these prefixes and must not be read as - // plaintext โ€” but Windows Chromium is not importable here at all. if (platform === "darwin" || platform === "linux") { return stripDomainBinding(buffer, domain, schemaVersion)?.toString("utf8") ?? null; } return null; } -/** Reads and decodes one snapshotted Chromium cookie database. */ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiumCookieDatabase")( function* (snapshotPath: string, keys: ChromiumKeyMaterial, platform: NodeJS.Platform) { const result = yield* Effect.gen(function* () { @@ -288,8 +249,6 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu sameSite: sameSiteFromColumn(row.samesite), }); } - // Keep partial imports, but do not call a missing key a successful import - // when it prevented every otherwise importable cookie from being read. if ( cookies.length === 0 && keys.cbcV11Error !== undefined && @@ -315,7 +274,6 @@ export interface ChromiumCookieSource { readonly keychainAccount: string | undefined; readonly linuxSecretApplication: string | undefined; readonly windowsLocalStatePath?: string; - /** Supplied by the caller from `HostProcess.Platform` rather than read here. */ readonly platform: NodeJS.Platform; } diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts index ef7c8685b707..0d91ebe5a374 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -1,22 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the // OSCrypt key derivation Chromium uses; Effect has no equivalent. -/** - * Chromium cookie-encryption keys, per platform. - * - * Chromium calls this OSCrypt, and it works differently on each OS: - * - * - **macOS** keeps one key in the login keychain. Reading it prompts the - * user, which is the consent this feature is built around. - * - **Linux** may keep a key in libsecret/kwallet (`v11` records), or use a - * hardcoded `peanuts` passphrase when no keyring is available (`v10`). Both - * can appear in the same database, so both are derived up front and chosen - * per record. - * - * - **Windows** legacy Chromium stores protect a random AES key with DPAPI. - * App-Bound Encryption remains deliberately unsupported. - * - * @module ChromiumKeys - */ import * as NodeCrypto from "node:crypto"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -42,14 +25,12 @@ export const ChromiumKeyFailure = Schema.Literals([ "keychainItemMissing", "keychainUnavailable", "unsupportedPlatform", - /** The key store itself could not be read, as opposed to holding no key. */ "readFailed", ]); export type ChromiumKeyFailure = typeof ChromiumKeyFailure.Type; export class ChromiumKeyError extends Schema.TaggedError()("ChromiumKeyError", { reason: ChromiumKeyFailure, - /** Kept for the log; never surfaced to the user. */ cause: Schema.optional(Schema.Defect()), }) { override get message(): string { @@ -57,17 +38,9 @@ export class ChromiumKeyError extends Schema.TaggedError()("Ch } } -/** - * Keys to try, indexed by the record prefix they decrypt. A database can hold - * records written under more than one scheme, so a missing entry means those - * records are skipped rather than the whole import failing. - */ export interface ChromiumKeyMaterial { - /** AES-128-CBC on macOS, and the keyring-free Linux fallback. */ readonly cbcV10?: Buffer; - /** AES-128-CBC, Linux keyring-derived. */ readonly cbcV11?: Buffer; - /** Retained so an import that needs this key can report why it is missing. */ readonly cbcV11Error?: ChromiumKeyError; /** * AES-128-CBC from an empty passphrase. Some Linux clients wrote records @@ -75,7 +48,6 @@ export interface ChromiumKeyMaterial { * after a record's own key fails. */ readonly cbcEmpty?: Buffer; - /** AES-256-GCM key used by pre-App-Bound Chromium on Windows. */ readonly gcmV10?: Buffer; } @@ -83,15 +55,9 @@ const derive = (passphrase: string, iterations: number) => NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1"); /** - * Reads the macOS OSCrypt secret from the login keychain. - * - * Uses the in-process Keychain API rather than shelling out to - * `/usr/bin/security`, because macOS attributes both the consent prompt and the - * resulting ACL entry to the binary that asks. Via the CLI the prompt says - * "security" and "Always Allow" grants trust to a tool every process on the - * machine can invoke; in-process it names this app and the grant belongs to it. - * (In an unsigned dev build the name is the dev binary, not the shipped app - * identity.) + * 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, @@ -111,8 +77,6 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function try: () => new Keyring.Entry(service, account).getPassword(), catch: (cause) => { const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); - // Distinguish the causes rather than reporting "approve the prompt" for - // a failure approving cannot fix. const missing = /no (matching )?entry|not found/i.test(message); return new ChromiumKeyError({ reason: missing ? "keychainItemMissing" : "needsKeychainApproval", @@ -127,10 +91,8 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function }); /** - * The bundled helper searches Chromium's libsecret schema and application - * attribute, retaining the desktop's normal unlock prompt. Its exit codes - * distinguish a missing key, denied access, and an unavailable keyring without - * parsing localized error messages. Stdout is the unmodified secret. + * Its exit codes distinguish a missing key, denied access, and an unavailable + * keyring without parsing localized error messages. */ export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(function* ( application: string, @@ -215,7 +177,6 @@ export const decodeWindowsWrappedKey = Effect.fn("ChromiumKeys.decodeWindowsWrap return wrappedBuffer.subarray(DPAPI_PREFIX.length); }); -/** Unwraps a key with the current Windows user's DPAPI identity. */ export const unwrapWindowsDpapiKey = Effect.fn("ChromiumKeys.unwrapWindowsDpapiKey")(function* ( wrapped: Buffer, ) { @@ -273,7 +234,6 @@ export const unwrapWindowsDpapiKey = Effect.fn("ChromiumKeys.unwrapWindowsDpapiK ); }); -/** Reads and unwraps a legacy Windows Chromium key without exposing it in argv. */ export const readWindowsKey = Effect.fn("ChromiumKeys.readWindowsKey")(function* ( localStatePath: string, ) { @@ -308,8 +268,7 @@ 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`. Preserve its - // failure until the reader knows whether any cookies needed that key. + // secret, when one is reachable, additionally unlocks `v11`. const keyringSecret = request.linuxSecretApplication ? yield* readLinuxSecret(request.linuxSecretApplication).pipe( // v10 remains importable when Secret Service is absent or does not diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts index 9742cb68c364..2eb6cdbb5a17 100644 --- a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts @@ -1,9 +1,3 @@ -/** - * Shared pieces of cookie extraction: the shape both engines produce, and the - * snapshot every reader takes before touching a live database. - * - * @module CookieDatabase - */ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -11,49 +5,29 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; -/** A cookie in the shape Electron's `session.cookies.set` accepts. */ export interface ImportedCookie { readonly url: string; readonly name: string; readonly value: string; /** - * Set only for domain cookies, which the sources mark with a leading dot. - * A host-only cookie leaves this undefined: Electron treats any `domain` it - * is given as marking a domain cookie and re-adds the dot, which would widen - * the cookie to every subdomain of the host it was scoped to, and rejects - * `__Host-` cookies, which require it to be absent. + * A host-only cookie leaves this undefined: Electron treats any `domain` it is given as marking a + * domain cookie and re-adds the dot, which would widen the cookie to every subdomain of the host + * it was scoped to, and rejects `__Host-` cookies, which require it to be absent. */ readonly domain: string | undefined; readonly path: string; readonly secure: boolean; readonly httpOnly: boolean; - /** Seconds since the UNIX epoch, or undefined for a session cookie. */ readonly expirationDate: number | undefined; readonly sameSite: "unspecified" | "no_restriction" | "lax" | "strict"; } -/** - * Cookies recovered from one database and rows that could not be decrypted. - * The skipped count reaches the user instead of disappearing from a partial - * import result. - */ export interface CookieReadResult { readonly cookies: ReadonlyArray; readonly undecryptable: number; - /** Distinct hosts of the rows that could not be decrypted. */ readonly undecryptableHosts: ReadonlyArray; } -/** - * The URL and domain Electron should register a stored row under. - * - * Both engines mark a domain cookie with a leading dot on the host. Electron - * matches on a URL, so the dot comes off for that; `domain` is passed through - * only for domain cookies, because supplying it at all makes Electron treat - * the cookie as one and re-add the dot โ€” widening a host-only cookie to every - * subdomain of the host it was scoped to, and rejecting `__Host-` cookies, - * which require it to be absent. - */ export const cookieScope = ( host: string, path: string, @@ -71,18 +45,11 @@ export const cookieScope = ( }; }; -/** A host without the leading dot both engines put on a domain cookie, for display. */ export const bareHost = (host: string): string => (host.startsWith(".") ? host.slice(1) : host); /** - * Creates a transactionally consistent snapshot of a cookie database in a - * temporary directory and returns the snapshot's path. - * - * Both engines keep the file open with WAL while the browser runs, so reading - * in place can observe a torn write. Copying also guarantees we never open the - * browser's own file for writing. - * - * Scoped: the temporary directory goes away when the caller's scope closes. + * 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. */ export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(function* ( cookiePath: string, diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index 17f9ff9b9867..9f28387d4f14 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -24,7 +24,6 @@ const parseWindowsFirefoxProfiles = (ini: string, root = "C:\\Users\\user\\Firef return parseFirefoxProfiles(ini, path, root); }).pipe(Effect.provide(NodePath.layerWin32)); -/** Builds a `cookies.sqlite` with Firefox's real `moz_cookies` shape. */ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( rows: ReadonlyArray<{ host: string; @@ -130,7 +129,6 @@ describe("readFirefoxCookies", () => { name: "plain", value: "v", path: "/app", - // Firefox writes 0 for a session cookie. expiry: 0, isSecure: 0, isHttpOnly: 0, @@ -142,8 +140,6 @@ describe("readFirefoxCookies", () => { expect(cookies).toEqual([ { - // The leading dot stays on the domain but not in the URL, which is - // what Electron matches against. url: "https://github.com/", name: "session", value: "abc", @@ -158,13 +154,10 @@ describe("readFirefoxCookies", () => { url: "http://example.test/app", name: "plain", value: "v", - // Host-only in Firefox, so no `domain`: supplying one would make - // Electron widen it to every subdomain of example.test. domain: undefined, path: "/app", secure: false, httpOnly: false, - // Session cookies carry no expiry rather than one at the epoch. expirationDate: undefined, sameSite: "no_restriction", }, @@ -336,7 +329,6 @@ describe("readFirefoxCookies", () => { yield* readFirefoxCookies(file); - // The browser's own file is snapshotted, never opened for writing. const after = yield* fileSystem.stat(file); expect(after.mtime).toEqual(before.mtime); expect(after.size).toBe(before.size); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts index 1c159f682622..16ad488f5cc2 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -1,14 +1,3 @@ -/** - * Firefox cookie extraction. - * - * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key - * to fetch and no consent prompt โ€” the file is readable by anything running as - * the user. That is Mozilla's design choice, not a control being circumvented, - * which is why this path works identically on macOS, Windows, and Linux while - * the Chromium one needs a per-platform credential store. - * - * @module FirefoxCookies - */ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -16,25 +5,10 @@ import * as SqlClient from "effect/sql/SqlClient"; import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts"; -/** - * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error - * the service can tell apart, rather than one of them widening the channel to - * an anonymous shape. - * - * No `reason` field: unlike Chromium there is only one way this fails โ€” the - * plaintext database would not open โ€” and the tag already says which engine it - * was. `BrowserImport` supplies the user-facing reason when it maps the union. - */ export class FirefoxCookieReadError extends Schema.TaggedError()( "FirefoxCookieReadError", { - /** - * Which database the read was for. Firefox keeps one per profile, so - * without it a failure cannot be traced back to the profile that caused - * it. - */ cookieDatabasePath: Schema.String, - /** Always present: every construction site wraps a real failure. */ cause: Schema.Defect(), }, ) { @@ -44,13 +18,11 @@ export class FirefoxCookieReadError extends Schema.TaggedError= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; - // Only the default container. Firefox isolates cookies per container and - // per private window via `originAttributes` (`^userContextId=2`, - // `^privateBrowsingId=1`); Electron has no equivalent, so importing them - // all would collapse several identities onto one host/name/path and hand - // the profile an arbitrary container's session. + // Firefox isolates cookies per container and per private window via + // `originAttributes` (`^userContextId=2`, `^privateBrowsingId=1`); + // Electron has no equivalent, so importing them all would collapse + // several identities onto one host/name/path and hand the profile an + // arbitrary container's session. const raw = hasRawSameSite ? yield* sql` select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite diff --git a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts index b2c2d3a47e6b..b365b15720ad 100644 --- a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts +++ b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts @@ -5,7 +5,6 @@ import * as Layer from "effect/Layer"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; -/** Absolute path to the helper shipped with this desktop instance. */ export const LinuxBrowserSecretPath = Context.Reference( "@t3tools/desktop/preview/BrowserImport/LinuxBrowserSecretPath", { defaultValue: () => undefined }, diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index 587c045422bb..fa369c8edc62 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -23,11 +23,9 @@ interface FixtureCookie { readonly path: string; readonly value: string; readonly flags: number; - /** Seconds since 2001-01-01, as Safari stores them. */ readonly expiry: number; } -/** Encodes one cookie exactly as Safari lays it out. */ function encodeCookie(cookie: FixtureCookie): Buffer { const strings = [cookie.domain, cookie.name, cookie.path, cookie.value]; const headerSize = 56; @@ -58,7 +56,6 @@ function encodeCookie(cookie: FixtureCookie): Buffer { return buffer; } -/** Builds a single-page `Cookies.binarycookies` file. */ function encodeBinaryCookies(cookies: ReadonlyArray): Buffer { const encoded = cookies.map(encodeCookie); const headerSize = 12 + encoded.length * 4; @@ -90,7 +87,6 @@ describe("parseBinaryCookies", () => { name: "session", path: "/", value: "abc", - // secure | httpOnly flags: 0x1 | 0x4, expiry: 800_000_000, }, @@ -113,17 +109,13 @@ describe("parseBinaryCookies", () => { path: "/", secure: true, httpOnly: true, - // Safari counts from 2001-01-01, Electron from 1970. expirationDate: 800_000_000 + APPLE_EPOCH_OFFSET_SECONDS, - // The format predates SameSite; Lax is the safe modern default. sameSite: "lax", }, { url: "http://example.test/app", name: "plain", value: "v", - // Host-only: no leading dot in the jar, so no `domain` for Electron, - // which would otherwise re-add the dot and widen it to subdomains. domain: undefined, path: "/app", secure: false, @@ -158,15 +150,12 @@ describe("parseBinaryCookies", () => { }); it("reads cookies spread across multiple pages", () => { - // Safari pages its cookie file, and a single-page reader would silently - // return only the first slice. const first = encodeBinaryCookies([ { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, ]); const second = encodeBinaryCookies([ { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, ]); - // Splice the two single-page files into one two-page file. const firstPage = first.subarray(12); const secondPage = second.subarray(12); const header = Buffer.alloc(16); @@ -183,7 +172,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. - // Every cookie after the boundary then vanishes from a "successful" import. const first = encodeBinaryCookies([ { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, ]); @@ -195,7 +183,6 @@ describe("parseBinaryCookies", () => { const header = Buffer.alloc(16); header.write("cook", 0, "latin1"); header.writeUInt32BE(2, 4); - // Declares more bytes for page one than the file holds in total. header.writeUInt32BE(firstPage.length + secondPage.length + 32, 8); header.writeUInt32BE(secondPage.length, 12); @@ -208,8 +195,6 @@ describe("parseBinaryCookies", () => { const valid = encodeBinaryCookies([ { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, ]); - // The record's own length is what bounds its string offsets; an inflated - // one lets them read the following record's bytes as this cookie's value. const pageStart = 8 + 4; const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); const corrupt = Buffer.from(valid); @@ -242,18 +227,14 @@ describe("parseBinaryCookies", () => { const pageStart = 8 + 4; const firstRecord = valid.readUInt32LE(pageStart + 8); - // Pointing the second offset at the page's offset table would let those - // table bytes parse as a fabricated record. const intoTable = Buffer.from(valid); intoTable.writeUInt32LE(4, pageStart + 12); expect(() => parseBinaryCookies(intoTable)).toThrow(SafariCookieReadError); - // Pointing it back at the first record makes the same bytes count twice. const overlapping = Buffer.from(valid); overlapping.writeUInt32LE(firstRecord, pageStart + 12); expect(() => parseBinaryCookies(overlapping)).toThrow(SafariCookieReadError); - // And a well-formed two-record page still parses. expect(parseBinaryCookies(valid)).toHaveLength(2); }); @@ -287,9 +268,6 @@ describe("parseBinaryCookies", () => { }); it("rejects a jar whose page table stops short of its contents", () => { - // A second, undeclared page after the first would be silently dropped โ€” - // the cookies it holds vanish from the import with no error โ€” so a file - // the header does not fully describe is refused instead. const first = encodeBinaryCookies([ { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, ]); @@ -300,7 +278,6 @@ describe("parseBinaryCookies", () => { expect(() => parseBinaryCookies(Buffer.concat([first, extraPage]))).toThrow( SafariCookieReadError, ); - // A trailer that claims a property list it doesn't contain is refused too. const badLength = Buffer.alloc(4); badLength.writeUInt32BE(99, 0); expect(() => @@ -335,9 +312,7 @@ describe("readSafariCookies", () => { Effect.gen(function* () { // What Full Disk Access actually looks like: the file is there, the read // is refused with EPERM. Effect tags that `Unknown`, not - // `PermissionDenied`, so the reader has to look at the errno. Reporting - // it as a generic failure would send the user looking for a missing - // browser instead of a checkbox. + // `PermissionDenied`, so the reader has to look at the errno. const denied = PlatformError.systemError({ _tag: "Unknown", module: "FileSystem", @@ -361,8 +336,6 @@ describe("readSafariCookies", () => { const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); const jar = `${directory}/Cookies.binarycookies`; yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); - // A mode-bits refusal is EACCES: granting Full Disk Access cannot fix - // it, so it must not be routed to that grant. yield* fileSystem.chmod(jar, 0o000); const error = yield* readSafariCookies(jar).pipe(Effect.flip); @@ -396,8 +369,6 @@ describe("safariAccessDenied", () => { it.effect("reports TCC's EPERM as a missing Full Disk Access grant", () => Effect.gen(function* () { - // `stat` finds the jar without the grant, so only an open tells the - // listing whether the import would actually be allowed. assert.isTrue( yield* safariAccessDenied("/protected/Cookies.binarycookies").pipe( Effect.provide(denied(eperm)), @@ -413,28 +384,20 @@ describe("safariAccessDenied", () => { const jar = `${directory}/Cookies.binarycookies`; yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); assert.isFalse(yield* safariAccessDenied(jar)); - // Missing entirely is "not installed", not "denied". assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`)); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); }); describe("isPermissionDenied", () => { - // Shapes taken from a real `FileSystem.readFile` failure on macOS โ€” verified - // against Safari's TCC-protected jar, whose denial is EPERM, tagged - // `Unknown` rather than `PermissionDenied`. const platformError = (reasonTag: string, code: string): PlatformError.PlatformError => ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never; it("treats a TCC EPERM denial as permission denied", () => { - // The regression: EPERM is tagged `Unknown`, so checking the tag alone - // reported Safari's Full Disk Access refusal as a generic read failure. expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true); }); it("does not send an ordinary EACCES failure to the Full Disk Access grant", () => { - // A POSIX permission or ACL refusal cannot be fixed by granting Full Disk - // Access, so it stays a plain read failure; only TCC's EPERM routes there. expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(false); }); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts index 36d0417c79e8..15f8de8d3a36 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -1,24 +1,3 @@ -/** - * Safari cookie extraction. - * - * Safari does not encrypt its cookies; it stores them in a proprietary - * `Cookies.binarycookies` file inside its app container. The protection is - * TCC, not cryptography โ€” the file lives under a path only apps with Full Disk - * Access may read, so the gate is a permission the user grants in System - * Settings rather than a key to obtain. - * - * The format, big-endian throughout except the page bodies: - * - * magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page: - * u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount], - * then each cookie: - * u32le size, u32le unknown, u32le flags, u32le unknown, - * u32le urlOffset, nameOffset, pathOffset, valueOffset, - * u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated - * strings at the offsets above (relative to the cookie start). - * - * @module SafariCookies - */ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as PlatformError from "effect/PlatformError"; @@ -31,7 +10,6 @@ const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; /** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */ const COOKIE_PAGE_HEADER_SIZE = 12; -/** Through the `f64 creation` field; string bytes follow. */ const COOKIE_RECORD_HEADER_SIZE = 56; const FLAG_SECURE = 0x1; @@ -44,12 +22,7 @@ export class SafariCookieReadError extends Schema.TaggedError buffer.length) { throw new SafariCookieReadError({ reason: "readFailed" }); } @@ -112,8 +83,6 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray page.length) { throw new SafariCookieReadError({ reason: "readFailed" }); } - // Bounded by the record's own length so a string offset cannot run past - // it into the following record's bytes. const recordSize = page.readUInt32LE(cookieStart); const cookieEnd = cookieStart + recordSize; if ( @@ -133,8 +102,6 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray offset < COOKIE_RECORD_HEADER_SIZE || offset >= cookie.length, @@ -173,13 +140,8 @@ 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 @@ -210,12 +164,10 @@ export const isPermissionDenied = (error: PlatformError.PlatformError): boolean }; /** - * Whether reading the jar is refused by TCC. `stat` succeeds on the jar - * inside Safari's container even without Full Disk Access โ€” that is what lets - * the listing find it โ€” so presence alone cannot tell granted from denied. - * Opening it for read is what TCC gates: EPERM means the grant is missing. - * Anything else (including a missing jar) is not a permission answer. - */ + * `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. + * */ export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe( @@ -225,7 +177,6 @@ export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: strin ); }); -/** A missing or unreadable jar is never evidence that access was granted. */ export const safariAccessGranted = Effect.fnUntraced(function* (cookiePath: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe( @@ -241,11 +192,6 @@ export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(fu const fileSystem = yield* FileSystem.FileSystem; const contents = yield* fileSystem.readFile(cookiePath).pipe( Effect.mapError((cause) => { - // TCC denies the read even though the file exists โ€” a permission the user - // grants in System Settings rather than a missing browser. macOS never - // prompts for Full Disk Access, so there is no dialog to wait on; the - // read just fails, and it fails with EPERM, which Effect surfaces as an - // `Unknown` system error rather than `PermissionDenied` (that is EACCES). return new SafariCookieReadError({ reason: isPermissionDenied(cause) ? "needsFullDiskAccess" : "readFailed", cookieDatabasePath: cookiePath, @@ -253,8 +199,6 @@ export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(fu }); }), ); - // The parser throws on a malformed jar; catch it here so callers see a typed - // failure rather than a defect. return yield* Effect.try({ try: () => parseBinaryCookies(Buffer.from(contents)), catch: (cause) => diff --git a/apps/desktop/src/preview/BrowserImport/SafariPermission.ts b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts index 1aa72bcc0a0f..1bad2ec2b357 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariPermission.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts @@ -26,6 +26,5 @@ export const safariPermissionCheck = Effect.gen(function* () { } return false; }); - // Open and close the jar without reading cookies or attempting an import. return () => runPromise(check); }); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index 51bab02ce939..30bf5a7fd923 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -71,7 +71,6 @@ describe("Windows browser lock errors", () => { }); }); -/** A scratch home with the source's user-data directory already created. */ const withSourceHome = Effect.fnUntraced(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" }); @@ -83,7 +82,6 @@ const withSourceHome = Effect.fnUntraced(function* () { return context; }); -/** Every case here runs on darwin, where Helium always resolves a directory. */ const userDataDirectory = (context: BrowserImportPathContext) => { const root = helium.userDataDirectory(context); if (root === undefined) throw new Error("Helium has no macOS user-data directory"); @@ -98,7 +96,6 @@ const run = ( >, ) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); -/** Writes a Chromium-shaped cookie table with `count` rows. */ const writeCookieDatabase = (file: string, count: number) => Effect.sync(() => { const database = new NodeSqlite.DatabaseSync(file); @@ -360,8 +357,6 @@ describe("isSourceInstalled", () => { yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); assert.isTrue(yield* isSourceInstalled(helium, context)); - // A real install whose cookies live outside `Default` still counts: - // reporting it as absent hides the source from the menu entirely. yield* fileSystem.remove(`${root}/Default`, { recursive: true }); yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); @@ -418,8 +413,6 @@ describe("listSourceProfiles", () => { const fileSystem = yield* FileSystem.FileSystem; const paths = yield* withSourceHome(); const root = helium.userDataDirectory(paths); - // A directory named `Cookies` would list as importable and then fail - // the SQLite open, so only a regular file counts as a database. yield* fileSystem.makeDirectory(`${root}/Broken/Cookies`, { recursive: true }); yield* fileSystem.makeDirectory(`${root}/Real`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Real/Cookies`, "db"); @@ -438,8 +431,6 @@ describe("listSourceProfiles", () => { const fileSystem = yield* FileSystem.FileSystem; const context = yield* withSourceHome(); const root = userDataDirectory(context); - // Assuming `Default` would report a browser whose cookies live in - // `Profile 1` as having nothing to import, and it is then hidden. yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); @@ -463,8 +454,6 @@ describe("listSourceProfiles", () => { assert.deepEqual(yield* listSourceProfiles(helium, context), [ { directory: "Default", name: "You" }, - // Blank display name falls back to the directory rather than - // rendering an empty row. { directory: "Profile 2", name: "Profile 2" }, ]); }), @@ -591,8 +580,6 @@ Path=Profiles/wxyz.empty const fileSystem = yield* FileSystem.FileSystem; const paths = yield* withSourceHome(); const root = helium.userDataDirectory(paths); - // A folder squatting on the preferred candidate path must not shadow - // the real legacy database behind it. yield* fileSystem.makeDirectory(`${root}/Default/Network/Cookies`, { recursive: true }); yield* writeCookieDatabase(`${root}/Default/Cookies`, 2); @@ -640,7 +627,6 @@ describe("cookieDatabaseCandidatePaths", () => { yield* resolveCookieDatabase(helium, context, "Default"), context.path.join(root, "Default", "Network", "Cookies"), ); - // A fresh install with only the Network/ jar is installed, not hidden. yield* fileSystem.remove(`${root}/Default/Cookies`); assert.isTrue(yield* isSourceInstalled(helium, context)); }), @@ -812,8 +798,6 @@ describe("listSourceProfiles Firefox fallback", () => { Effect.provideService(HostProcess.Platform, "darwin"), ); const root = firefox.userDataDirectory(context)!; - // `profiles.ini` names a profile that was never launched (no cookie - // database), while the real cookies sit in an undeclared one. yield* fileSystem.makeDirectory(path.join(root, "Profiles", "stale.default"), { recursive: true, }); @@ -825,7 +809,6 @@ describe("listSourceProfiles Firefox fallback", () => { ["[Profile0]", "Name=Stale", "IsRelative=1", "Path=Profiles/stale.default"].join("\n"), ); - // Returning the empty declared list would hide the browser entirely. assert.deepEqual(yield* listSourceProfiles(firefox, context), [ { directory: path.join("Profiles", "real.default"), @@ -908,8 +891,6 @@ describe("isSourceRunning for Firefox", () => { assert.isFalse(yield* isSourceRunning(firefox, context)); - // Firefox keeps its locks per profile. A root-level lock is not one, - // and looking there was why a running Firefox read as importable. yield* fileSystem.writeFileString(`${root}/lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); @@ -934,14 +915,7 @@ describe("isSourceRunning for Firefox", () => { const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-lock-" }); const lock = `${directory}/.parentlock`; yield* fileSystem.writeFileString(lock, ""); - // A Mac without the developer tools has only Apple's shim, which - // refuses to run the script; a machine with no python at all has - // nothing. Either way the probe is unavailable, not the lock held โ€” - // treating it as held would block Firefox import on that machine for - // good. assert.isFalse(yield* posixLockIsHeld(lock, ["/nonexistent/python3"])); - // And a fake "interpreter" that exits non-zero without a verdict, as - // the shim does, is the same case. assert.isFalse(yield* posixLockIsHeld(lock, ["/usr/bin/false"])); }), ), @@ -967,8 +941,6 @@ describe("isSourceRunning for Firefox", () => { const parentLock = `${profile}/.parentlock`; yield* fileSystem.writeFileString(parentLock, ""); - // Hold the lock from a child the way Firefox does (F_SETLK, write), - // and keep it until the scope closes. const holder = yield* spawner.spawn( ChildProcess.make( "python3", @@ -984,7 +956,6 @@ describe("isSourceRunning for Firefox", () => { { stdin: "ignore" }, ), ); - // Wait for the child to confirm it holds the lock before probing. yield* holder.stdout.pipe( Stream.decodeText(), Stream.splitLines, @@ -1010,13 +981,9 @@ describe("isSourceRunning for Firefox", () => { assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.0.1:4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.1.1:+4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+4242", local, alive)); - // A crash leaves the symlink behind with a dead pid, on any local address. assert.isFalse(yield* firefoxSymlinkLockIsHeld("127.0.0.1:+9999", local, alive)); assert.isFalse(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+9999", local, alive)); - // Anything unparseable stays conservative. assert.isTrue(yield* firefoxSymlinkLockIsHeld("garbage", local, alive)); - // A foreign owner (a shared profile locked from another machine) names - // a pid we cannot probe, so it is held regardless of local liveness. assert.isTrue(yield* firefoxSymlinkLockIsHeld("10.0.0.7:+9999", local, alive)); }), ); @@ -1041,8 +1008,7 @@ describe("isSourceRunning for Firefox", () => { yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); // On Windows, Firefox creates parent.lock as a regular file that - // persists after the process exits. The file is only locked while - // Firefox is running; the old stat-based check always found it. + // persists after the process exits. yield* fileSystem.writeFileString(`${profile}/parent.lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); }), @@ -1053,9 +1019,6 @@ describe("isSourceRunning for Firefox", () => { describe("Windows user-data directories", () => { it.effect("keeps app-bound Chromium forks unsupported on win32", () => Effect.sync(() => { - // Helium retains the older DPAPI-backed store. Other Chromium forks use - // App-Bound Encryption, so omitting win32 makes `unavailableReason` - // report `unsupportedPlatform` and keeps them out of the menu. for (const source of BROWSER_IMPORT_SOURCES) { if (source.engine === "chromium" && source.id !== "helium") { assert.notInclude(source.platforms, "win32"); @@ -1071,9 +1034,6 @@ describe("listSourceProfiles hardening", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const context = yield* withSourceHome(); - // `Local State` is writable by anything running as the user, so a - // crafted key must not reach `cookieDatabasePath` and read a database - // outside the browser's user-data directory. yield* fileSystem.writeFileString( `${userDataDirectory(context)}/Local State`, `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`, diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 86baffbf1b2f..59a89eadfa73 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -1,20 +1,3 @@ -/** - * Importable browser sources. - * - * Chromium-family browsers keep cookies in an - * encrypted SQLite database whose key lives in an OS credential store; Firefox - * keeps them in plain SQLite with no key at all, so it needs no keychain and - * works the same on every platform. Safari uses binary cookie files, with - * separate WebKit data stores for named profiles. - * - * Each entry pins its own paths and credential-store coordinates rather than - * deriving them, because the forks do not agree. macOS uses service/account - * pairs, while Linux Chromium uses a custom libsecret schema keyed by an - * `application` attribute. The user-data directory also differs per fork and - * per platform. - * - * @module BrowserImportSources - */ import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -29,18 +12,11 @@ import * as SqlClient from "effect/sql/SqlClient"; export type BrowserImportEngine = "chromium" | "firefox" | "safari"; -/** - * Directory roots a definition builds its paths from. Passed in rather than - * read from `process`, so source resolution stays testable for platforms the - * host is not currently running. - */ export interface BrowserImportPathContext { readonly path: Path.Path; readonly platform: NodeJS.Platform; readonly home: string; - /** `%APPDATA%` on Windows; unused elsewhere. */ readonly appData: string | undefined; - /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */ readonly localAppData: string | undefined; } @@ -48,13 +24,10 @@ export interface BrowserImportSourceDefinition { readonly id: BrowserImportSourceId; readonly name: string; readonly engine: BrowserImportEngine; - /** Platforms the definition has paths for. */ readonly platforms: ReadonlyArray; readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; - /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */ readonly keychainService?: string; readonly keychainAccount?: string; - /** Chromium's `application` attribute in the Linux libsecret schema. */ readonly linuxSecretApplication?: string; } @@ -63,11 +36,6 @@ const macApplicationSupport = ( ...segments: ReadonlyArray ) => context.path.join(context.home, "Library", "Application Support", ...segments); -/** - * One Chromium fork. The leaves differ per fork; omitting a platform's - * segments marks the fork as unavailable there. Most Windows Chromium builds - * use App-Bound Encryption, but forks can retain the older DPAPI-backed store. - */ const chromiumSource = (input: { readonly id: BrowserImportSourceId; readonly name: string; @@ -107,8 +75,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. macOS and Linux keep working, so only the - // Windows segments are omitted. + // other process can read them. chromiumSource({ id: "chrome", name: "Chrome", @@ -154,7 +121,6 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray directory.length > 0 && directory !== "." && @@ -365,11 +309,8 @@ const CookieCountRow = Schema.Struct({ count: Schema.Number }); const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); /** - * How many importable cookies a profile holds, counted without decrypting - * anything. Firefox containers use identities Electron cannot represent, so - * its count uses the same default-container predicate as the reader. Best - * effort: a locked, missing or unexpected database yields `undefined` rather - * than failing the listing. + * Firefox containers use identities Electron cannot represent, so its count + * uses the same default-container predicate as the reader. */ const countProfileCookies = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, @@ -449,7 +390,6 @@ const listSafariProfiles = Effect.fnUntraced(function* ( name: profile.title?.trim() || profile.external_uuid, }); } - // If Safari's metadata is unavailable, recover stores that have cookies. // With readable metadata, avoid resurrecting deleted profiles left on disk. if (declared.length === 0) { const entries = yield* fileSystem.readDirectory(stores).pipe(Effect.orElseSucceed(() => [])); @@ -463,15 +403,6 @@ const listSafariProfiles = Effect.fnUntraced(function* ( return profiles; }); -/** - * Profiles the source browser knows about. - * - * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When - * that metadata is missing, unreadable or malformed, the directories that - * actually hold a cookie database are scanned instead. Assuming a single - * `Default` would report a browser whose cookies live in `Profile 1` as having - * nothing to import โ€” and it is then left out of the menu entirely. - */ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -490,12 +421,11 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( Effect.orElseSucceed(() => [] as ReadonlyArray), ); // `profiles.ini` also lists profiles the installer created but the user - // never launched, which hold no cookie database and nothing to import. - // Only keep the ones a database proves exist, like the directory scans - // below do. When none of the declared profiles has one, fall through to - // the scan rather than returning empty: `profiles.ini` can list stale or - // never-launched profiles while the cookies live in one it does not - // mention, and an empty answer here hides the browser entirely. + // 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. if (declared.length > 0) { const found = yield* Effect.forEach(declared, (profile) => Effect.forEach( @@ -509,9 +439,6 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( } } - // No usable `profiles.ini`, so fall back to scanning the directory the - // profiles actually live in, keeping only the ones a cookie database - // proves were launched. const fallbackDirectory = context.platform === "linux" ? root : context.path.join(root, "Profiles"); const scanned = yield* fileSystem @@ -546,8 +473,6 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( ); if (declared.length > 0) return yield* withCookieCounts(definition, context, declared); - // `Local State` is missing, unreadable or malformed. Scanning for directories - // that hold a cookie database finds the profiles anyway. const entries = yield* fileSystem .readDirectory(root) .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); @@ -600,11 +525,6 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf return [...profiles.values()]; }); -/** - * Whether a cookie database candidate is a regular file. Presence alone is - * not enough: a directory at the path would list as an importable profile and - * then fail the SQLite open, so anything but a file is treated as absent. - */ const databaseFileExists = Effect.fnUntraced(function* (path: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.stat(path).pipe( @@ -621,7 +541,6 @@ export const chromiumProcessIsAlive = ( ) => Effect.sync(() => { try { - // Signal 0 performs a read-only existence/permission check. signalProcess(pid, 0); return true; } catch (cause) { @@ -639,7 +558,6 @@ export const chromiumProcessIsAlive = ( const processIsAlive: ProcessLivenessProbe = (pid) => chromiumProcessIsAlive(pid); -/** Whether a Chromium `-` lock target may still name its owner. */ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( target: string, currentHost: string, @@ -703,11 +621,6 @@ export const windowsChromiumCookiesAreHeld = Effect.fnUntraced(function* ( return held.some(Boolean); }); -/** - * Whether a Firefox `lock` symlink's `:[+]` target still names a - * live owner. Firefox writes this symlink beside the profile while it runs and - * unlinks it on a clean exit, so a dangling one is either live or a crash. - */ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( target: string, localAddresses: ReadonlySet, @@ -718,9 +631,6 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( // The owner half is whatever Firefox's resolver returned for the machine's // hostname โ€” 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN // address โ€” so a pid is only meaningful when that address is one of ours. - // A shared (NFS) profile locked from another machine names a foreign - // address whose pid cannot be probed here, nor could a reused local pid - // vouch for it, so it stays conservatively held. const owner = target.slice(0, separator); if (!localAddresses.has(owner)) return true; // A `+` marks an fcntl-holding owner; the pid follows either way. @@ -732,18 +642,16 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( }); /** - * Interpreters that can run the fcntl probe, tried in order. `/usr/bin/python3` - * is named absolutely first so a Dock-launched app with launchd's bare `PATH` - * still finds it without depending on the login-shell PATH merge; Linux - * distributions carry python3 on the default path. + * `/usr/bin/python3` is named absolutely first so a Dock-launched app with + * launchd's bare `PATH` still finds it without depending on the login-shell + * PATH merge; Linux distributions carry python3 on the default path. */ const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const; /** - * The probe prints exactly one of these. Anything else means the script never - * ran โ€” most importantly Apple's `/usr/bin/python3` shim, which on a Mac - * without the Command Line Tools exits non-zero after printing an install - * prompt, without ever reaching our code. + * Anything else means the script never ran โ€” most importantly Apple's + * `/usr/bin/python3` shim, which on a Mac without the Command Line Tools exits + * non-zero after printing an install prompt, without ever reaching our code. */ const FCNTL_PROBE_SCRIPT = "import fcntl,os,sys\n" + @@ -756,21 +664,12 @@ const FCNTL_PROBE_SCRIPT = " print('free')"; /** - * Whether another process holds an fcntl write lock on `path`. - * - * Firefox's `.parentlock` is an empty file whose only signal is the kernel - * lock, and Node exposes no fcntl, so a throwaway interpreter tries a - * non-blocking `F_SETLK` and reports `EWOULDBLOCK`. The lock is never - * acquired for real: on success the child exits and the kernel drops it. - * - * The answer is trusted only when the script itself spoke. A verdict of - * `held` or `free` on stdout is the probe's own, and stands. Anything else โ€” - * no interpreter on any candidate path, or one that refused to run the script - * (Apple's shim without the developer tools) โ€” is the probe being unavailable, - * not evidence about the lock. That case falls back to "not held" rather than - * "held": reporting every profile as locked forever would block Firefox import - * outright on such machines, and the SQLite snapshot already copes with a - * live database's WAL, as it does for every other engine. + * Anything else โ€” no interpreter on any candidate path, or one that refused to + * run the script (Apple's shim without the developer tools) โ€” is the probe + * being unavailable, not evidence about the lock. That case falls back to "not + * held" rather than "held": reporting every profile as locked forever would + * block Firefox import outright on such machines, and the SQLite snapshot + * already copes with a live database's WAL, as it does for every other engine. */ export const posixLockIsHeld = Effect.fnUntraced(function* ( path: string, @@ -801,32 +700,20 @@ export const posixLockIsHeld = Effect.fnUntraced(function* ( }); /** - * Whether Firefox holds a profile. - * - * Firefox leaves two kinds of lock behind, and they mean different things. - * On Linux the `lock` symlink (target `:+`) is removed on a clean - * exit, so its presence is evidence โ€” provided the pid it names is alive. But - * `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files - * held with fcntl or a Windows handle and are *deliberately left on disk* - * after exit, as a last-used marker; treating them as proof of a running - * browser blocks every import after Firefox has been used once. On POSIX the - * fcntl lock itself is the truth, and macOS in particular writes nothing else - * (no symlink, no pid), so `.parentlock` is probed for the kernel lock. On - * Windows the held handle denies our open, which `windowsLockIsHeld` reads as `Busy`. + * 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. */ const firefoxProfileIsHeld = Effect.fnUntraced(function* ( directory: string, context: BrowserImportPathContext, - // Resolved once by the caller: it involves a DNS lookup of the hostname and - // is the same for every profile. localAddresses: ReadonlySet, ) { const fileSystem = yield* FileSystem.FileSystem; if (context.platform === "win32") { return yield* windowsLockIsHeld(context.path.join(directory, "parent.lock")); } - // Linux additionally writes the `lock` symlink; a live pid there settles it - // without spawning anything. const symlinkHeld = yield* fileSystem.readLink(context.path.join(directory, "lock")).pipe( Effect.flatMap((target) => firefoxSymlinkLockIsHeld(target, localAddresses, processIsAlive)), Effect.orElseSucceed(() => false), @@ -841,7 +728,6 @@ const firefoxProfileIsHeld = Effect.fnUntraced(function* ( return yield* posixLockIsHeld(parentLock); }); -/** Whether the browser is running, which leaves its cookie DB mid-write. */ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -853,15 +739,8 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") const fileSystem = yield* FileSystem.FileSystem; const root = definition.userDataDirectory(context); if (root === undefined) return false; - // Probe the source's own lock state rather than scanning the process table. // Safari keeps no lock and writes its jar atomically, so a running instance // is not a hazard there. - // - // Chromium exposes its lock through the cookie jar on Windows and through a - // user-data SingletonLock on POSIX. Firefox keeps its locks inside each - // profile under three names across platforms (`lock` on macOS and Linux, - // `.parentlock` beside it, `parent.lock` on Windows). Looking for Firefox's - // at the root finds nothing and reports a running browser as importable. if (definition.engine === "safari") return false; if (definition.engine !== "firefox") { if (context.platform === "win32") { @@ -876,7 +755,6 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") } const profiles = yield* listSourceProfiles(definition, context); - // Only the Linux `lock` symlink names an address, so Windows skips the lookup. const localAddresses: ReadonlySet = context.platform === "win32" ? new Set() : yield* yield* HostProcess.Addresses; const found = yield* Effect.forEach(profiles, (profile) => { @@ -889,14 +767,11 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") }); /** - * Whether the source has cookies to import. - * * Keyed off the cookie database rather than the user-data directory, because * that directory is not evidence the browser exists: installers for native * messaging hosts create an empty one for every Chromium fork they know about, * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as - * present. The database is the thing an import actually needs, so its absence - * is the honest answer either way. + * 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 diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 9799d88fdc9c..d421898732a1 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -93,11 +93,9 @@ describe("BrowserSession", () => { const replacementChar = yield* browserSessions.getPartition("p\ufffd"); assert.notStrictEqual(loneSurrogate, replacementChar); - // The escape can't be forged with a literal backslash either. const literal = yield* browserSessions.getPartition("p\\ud800"); assert.notStrictEqual(literal, loneSurrogate); - // And a well-formed scope still lands on its historical partition. assert.strictEqual( yield* browserSessions.getPartition("scope-a"), "persist:t3code-preview-f051bb2c68cb7b2fe969", @@ -109,8 +107,6 @@ describe("BrowserSession", () => { Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; - // These share the same scope string: default environment `a::b`, and - // environment `a` with nondefault profile `b`. const legacyDefault = yield* browserSessions.getPartition("a::b"); const nondefaultProfile = yield* browserSessions.getPartition("a::b", true, "profile"); @@ -123,10 +119,8 @@ describe("BrowserSession", () => { ); // 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. The mock applies - // setUserAgent calls, so this fails on any reintroduced rewrite while still - // permitting a harmless re-set of the unchanged native string. + // 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 @@ -186,7 +180,6 @@ describe("BrowserSession", () => { for (const permission of [ "clipboard-read", "clipboard-sanitized-write", - "notifications", "geolocation", "fullscreen", ]) { @@ -200,7 +193,7 @@ describe("BrowserSession", () => { // `clipboard-write` is not a real Electron permission โ€” the async write API // uses `clipboard-sanitized-write` โ€” so the stale name must not be granted, // and unrelated permissions stay denied. - for (const permission of ["clipboard-write", "midi"]) { + for (const permission of ["clipboard-write", "notifications", "midi"]) { assert.isFalse(requestAllows(permission), `request handler should deny ${permission}`); assert.isFalse( checkHandler(null, permission) as boolean, @@ -242,8 +235,6 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); - // A prompt without an owner can open behind the app window, leaving the one - // prompt slot taken and every later link silently denied. it.effect("attaches the open-externally prompt to the window hosting the preview", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; @@ -362,10 +353,6 @@ describe("BrowserSession", () => { const browserSessions = yield* BrowserSession.BrowserSession; const partition = yield* browserSessions.getPartition("scope-untouched"); - // Deriving the partition string does not create the session, and the - // clear only walks sessions it already holds. Without loading it first - // this reports success and deletes nothing โ€” which is what a user - // clearing a profile after a restart would get. assert.isUndefined(sessions.get(partition)); yield* browserSessions.clearCookies([partition]); assert.isUndefined(sessions.get(partition)); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 10e2a2575f38..3847ec332331 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -23,33 +23,28 @@ const PROFILE_PARTITION_MARKER = "profile-"; export type BrowserSessionPartitionNamespace = "profile"; -// Permissions granted to preview web content. `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`. +// `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`. const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-read", "clipboard-sanitized-write", - "notifications", "geolocation", - // The Fullscreen API. `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` 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. "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()). The app's own font - // picker runs in the main window session, which is unaffected by this list. + // 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. ]); -/** - * Schemes a preview page may never hand to the OS, even with the user's - * consent: they read local files, run script, or are web pages that belong in - * the preview itself. - */ const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet = new Set([ "about:", "blob:", @@ -64,10 +59,6 @@ const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet = new Set([ "view-source:", ]); -/** - * The URL to offer the user when a preview page navigates to a custom scheme - * such as `slack://` or `zoom://`, or `null` when it must stay denied. - */ const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => { if (!rawUrl) return null; try { @@ -167,11 +158,6 @@ export class BrowserSession extends Context.Service< } >()("@t3tools/desktop/preview/BrowserSession") {} -/** - * Restricts a clear to the given partitions. Omitting them keeps the historical - * "every partition" behaviour, which callers now only use for an explicit - * "all profiles" action โ€” a per-profile clear must never reach across profiles. - */ const selectSessions = ( sessions: ReadonlyMap, partitions: ReadonlyArray | undefined, @@ -181,14 +167,10 @@ const selectSessions = ( ); /** - * Scope bytes for the partition digest. - * * `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. - * Every well-formed scope passes through byte-for-byte unchanged, which keeps - * existing partitions (and the logins in them) where they are. */ const encodeScopeForDigest = (scope: string): Uint8Array => new TextEncoder().encode( @@ -201,8 +183,8 @@ const encodeScopeForDigest = (scope: string): Uint8Array => ); /** - * The window showing a preview guest. A `` guest has no window of its - * own, so look it up through the page that embeds it. + * A `` guest has no window of its own, so look it up through the page + * that embeds it. */ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined => { if (guest === null || guest.isDestroyed()) return undefined; @@ -279,9 +261,6 @@ export const make = Effect.gen(function* BrowserSessionMake() { ), ); const prefix = persistent ? PREVIEW_PARTITION_PREFIX : PREVIEW_EPHEMERAL_PARTITION_PREFIX; - // Legacy/default partitions are prefix + hex digest. The non-hex profile - // marker creates a disjoint namespace while leaving every legacy default - // partition byte-for-byte unchanged. return `${prefix}${namespace === "profile" ? PROFILE_PARTITION_MARKER : ""}${Hex.encode(digest).slice(0, 20)}`; }); @@ -301,8 +280,7 @@ export const make = Effect.gen(function* BrowserSessionMake() { // 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). Re-setting the unchanged native string is harmless, so it - // is the rewritten string itself that trips the check. + // (#5002). 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 a87d8af85ea0..5932db57a863 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -27,7 +27,6 @@ describe("CDP relay", () => { relay.receive(JSON.stringify({ id: 2, method: "Target.createTarget", params: {} })); await settle(); const attach = written.findIndex((message) => message["id"] === 1); - // The page is announced right after the auto-attach reply, never before it. expect(written.slice(attach, attach + 2)).toEqual([ { id: 1, result: {} }, { @@ -58,7 +57,6 @@ describe("CDP relay", () => { JSON.stringify({ id: 5, method: "Page.captureScreenshot", sessionId: "t3-preview-page" }), ); relay.receive(JSON.stringify({ id: 6, method: "DOM.enable", sessionId: "iframe-session" })); - // A slow screenshot must not hold up the reply behind it. pending[1]!(); await settle(); expect(written).toEqual([ @@ -78,7 +76,6 @@ describe("CDP relay", () => { it("resumes paused child frames, so cross-site iframes run", async () => { const send = vi.fn(async () => ({})); const relay = createCdpRelayConnection(makeTarget(send), () => {}); - // The page's own session is already running; only its children wait. relay.receive( JSON.stringify({ id: 1, @@ -181,7 +178,6 @@ describe("CDP relay downloads", () => { ); await settle(); expect(directories).toEqual(["/srv/artifacts"]); - // The tab's own debugger has no such context, so the id stays here. expect(send).toHaveBeenCalledWith( "Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: "/srv/artifacts", eventsEnabled: true }, diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 62c2410c19b8..581b692882b3 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -1,16 +1,9 @@ /** - * Presents one preview tab's debugger as a minimal CDP browser endpoint, so - * the environment server can drive a desktop `` with the same - * Playwright engine it uses for headless tabs. - * - * Only the tab's own page is reachable. Browser-level commands Playwright sends - * while connecting are answered here, and every page command goes to the tab's - * `webContents.debugger`. Electron allows one debugger client per webContents, - * so the relay shares the attach the preview manager already holds. + * Electron allows one debugger client per webContents, so the relay shares the + * attach the preview manager already holds. */ export interface CdpRelayTarget { - /** Sends a command to the tab's page, or to a child session such as an iframe. */ readonly send: ( method: string, params: Record, @@ -22,17 +15,14 @@ export interface CdpRelayTarget { readonly title: () => string; readonly userAgent: () => string; /** - * Where the server wants this tab's downloads, named by their CDP guid. The - * desktop app and the server it launched share a disk, so the server's + * The desktop app and the server it launched share a disk, so the server's * Playwright reads the file where it asked for it. */ readonly setDownloadDirectory: (directory: string | null) => void; } export interface CdpRelayConnection { - /** Feed one message from the server. */ readonly receive: (raw: string) => void; - /** Feed one debugger event from the tab. */ readonly event: (method: string, params: unknown, sessionId: string | undefined) => void; } @@ -50,12 +40,9 @@ const isCommand = (value: unknown): value is CdpCommand => typeof (value as { method?: unknown }).method === "string"; const BROWSER_CONTEXT_ID = "t3-preview"; -/** Marks an event to send right after a command's reply. */ const ANNOUNCE = Symbol("announce"); -/** The page's root session id, as Playwright sees it. */ const PAGE_SESSION_ID = "t3-preview-page"; -/** Browser-level commands that only need acknowledging for a page the desktop owns. */ const ACKNOWLEDGED = new Set([ "Target.setDiscoverTargets", "Browser.grantPermissions", @@ -95,8 +82,6 @@ export function createCdpRelayConnection( if (ACKNOWLEDGED.has(command.method)) return {}; switch (command.method) { case "Browser.setDownloadBehavior": { - // The tab's debugger reports downloads as Chromium does, so the server - // sees them as its own. Only the directory needs the desktop's help. const directory = command.params?.["downloadPath"]; const allowed = command.params?.["behavior"] !== "deny" && typeof directory === "string"; target.setDownloadDirectory(allowed ? directory : null); @@ -143,7 +128,6 @@ export function createCdpRelayConnection( case "Target.setAutoAttach": { if (attached) return {}; attached = true; - // The page is announced once, right after this reply, as Chromium does. const info = await targetInfo(); return { [ANNOUNCE]: { diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 81ea040701f0..0d281c46f860 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -16,7 +16,6 @@ const decodeCdpReply = Schema.decodeUnknownSync( ); const key = { threadId: "thread-1", tabId: "tab-1" }; -/** A tab's webContents and debugger, with the debugger's commands left pending until released. */ const makeDebuggee = () => { const emitter = new NodeEvents.EventEmitter(); const pending: Array<() => void> = []; @@ -45,7 +44,6 @@ const makeDebuggee = () => { }; }; -/** Reads `count` events from one backend's subscription. */ const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], count: number) => host.events.pipe( Stream.take(count), @@ -58,7 +56,7 @@ describe("DesktopBrowserHost", () => { Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; host.attach(key, makeDebuggee().tab); - // A restarted backend subscribes after the attach and still hears it. + expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]); expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]); }), @@ -70,7 +68,7 @@ describe("DesktopBrowserHost", () => { const debuggee = makeDebuggee(); host.attach(key, debuggee.tab); const reader = yield* takeEvents(host, 2).pipe(Effect.forkScoped); - // Wait until the reader has received the announcement. + yield* Effect.yieldNow; const command = (id: number, method: string) => host.handleCommandLine( @@ -86,7 +84,7 @@ describe("DesktopBrowserHost", () => { debuggee.release(); yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); const [, reply] = yield* Fiber.join(reader); - // Only the new connection's reply arrives; the old one's id could collide. + expect(reply).toMatchObject({ type: "cdp" }); expect(decodeCdpReply((reply as { message: string }).message).id).toBe(2); }).pipe(Effect.scoped), @@ -101,7 +99,7 @@ describe("DesktopBrowserHost", () => { const item = { setSavePath: (path: string) => void paths.push(path), } as unknown as Electron.DownloadItem; - // Before the server sets a directory, Electron keeps its own handling. + expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(false); yield* host.handleCommandLine( encodeJson({ @@ -125,7 +123,7 @@ describe("DesktopBrowserHost", () => { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); host.attach(key, debuggee.tab); - // Reading the page is not acting on it. + yield* host.handleCommandLine( encodeJson({ type: "cdp", diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index cfb1ff753c47..cef42d4f35fc 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -1,13 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - Names download files on the shared disk. /** - * The desktop end of the desktop browser channel (see `DesktopBrowserEvent` in - * contracts). The primary backend gets two file descriptors at spawn: this - * service writes events for the desktop's tabs to one and reads commands from - * the other. Each attached tab is reachable only through its `CdpRelay`. - * - * A tab is attached once its `` registers with a key the web app - * gave it. The preview manager owns the tab's single debugger session and hands - * it here; the relay shares it. + * The preview manager owns the tab's single debugger session and hands it here; + * the relay shares it. */ import { DesktopBrowserCommand, @@ -24,14 +18,13 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts"; +import { sendPageCommand } from "./GuestInput.ts"; const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent)); const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand)); const lineEncoder = new TextEncoder(); -/** CDP commands through which an agent clicks, types, or navigates the page. */ const AGENT_INPUT_COMMAND = /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/; -/** How long after an agent's input a download still counts as the agent's. */ const AGENT_DOWNLOAD_WINDOW_MS = 5_000; export interface DesktopBrowserTabKey { @@ -39,7 +32,6 @@ export interface DesktopBrowserTabKey { readonly tabId: string; } -/** A tab's debugger, as the preview manager lends it to the relay. */ export interface DesktopBrowserTabDebugger { readonly webContents: Electron.WebContents; readonly debugger: Electron.Debugger; @@ -51,11 +43,8 @@ interface AttachedTab { readonly key: DesktopBrowserTabKey; readonly debuggee: DesktopBrowserTabDebugger; relay: CdpRelayConnection | null; - /** Where the server wants this tab's downloads; null keeps Electron's own handling. */ downloadDirectory: string | null; - /** The guid CDP gave the download that is about to start. */ pendingDownloadGuid: string | null; - /** When the server last sent this page input, which only an agent does. */ agentInputAt: number; readonly onMessage: ( event: Electron.Event, @@ -68,25 +57,12 @@ interface AttachedTab { export class DesktopBrowserHost extends Context.Service< DesktopBrowserHost, { - /** - * Newline-delimited events for a backend's browser fd. Each run starts by - * announcing the tabs already attached, so a restarted backend hears them. - */ readonly events: Stream.Stream; - /** One line from the backend's browser control fd. */ readonly handleCommandLine: (line: string) => Effect.Effect; - /** Offers a server tab's `` to the server. */ readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; - /** Withdraws it: closed, swapped, crashed, or devtools needs the debugger. */ readonly detach: (key: DesktopBrowserTabKey) => void; - /** - * Whether a server tab's download came from the person at this desktop: - * the server sent the page no input just before it started. - */ readonly humanStartedDownload: (source: Electron.WebContents) => boolean; - /** Points a server tab's download at the server; false for any other download. */ readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; - /** The agent's cursor positions for attached tabs, keyed by their server tab. */ readonly pointers: Stream.Stream<{ readonly key: DesktopBrowserTabKey; readonly phase: "move" | "click"; @@ -115,7 +91,7 @@ export const make = Effect.gen(function* () { { send: (method, params, sessionId) => sessionId === undefined - ? debuggee.sendCommand(method, params) + ? sendPageCommand(webContents, debuggee, method, params) : debuggee.sendCommand(method, params, sessionId), targetId: () => debuggee @@ -128,7 +104,6 @@ export const make = Effect.gen(function* () { tab.downloadDirectory = directory; }, }, - // A released relay's late replies belong to a connection that is gone. (message) => { if (tab.relay === relay && tabs.get(keyOf(tab.key)) === tab) { emit({ type: "cdp", ...tab.key, message }); @@ -140,9 +115,8 @@ export const make = Effect.gen(function* () { }; /** - * Saves a download from a server tab where the server's Playwright expects - * it. 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. + * 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. */ const placeDownload = (source: Electron.WebContents, item: Electron.DownloadItem) => { const tab = [...tabs.values()].find( @@ -199,7 +173,6 @@ export const make = Effect.gen(function* () { return; } if (command.value.type === "release") { - // A new server connection starts with a fresh relay and fresh sessions. tab.relay = null; return; } @@ -212,7 +185,6 @@ export const make = Effect.gen(function* () { return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_DOWNLOAD_WINDOW_MS; }; - // Read when a backend starts, not when the host is built. const announceAll = Effect.suspend(() => Effect.forEach( [...tabs.values()], diff --git a/apps/desktop/src/preview/FaviconCapture.ts b/apps/desktop/src/preview/FaviconCapture.ts index 7e8ae64ff889..a2b36d182a8f 100644 --- a/apps/desktop/src/preview/FaviconCapture.ts +++ b/apps/desktop/src/preview/FaviconCapture.ts @@ -642,8 +642,6 @@ async function rasterizeFavicon( ]); const result = new Promise((resolve, reject) => { - // Electron cannot cancel isolated-world execution. This timeout ends only - // the logical attempt; renderer work may finish after a newer attempt starts. const timeout = AbortSignal.timeout(FAVICON_RASTER_TIMEOUT_MS); let settled = false; const finish = (complete: () => void) => { diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts new file mode 100644 index 000000000000..d607b217262f --- /dev/null +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -0,0 +1,114 @@ +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, +}: { + readonly focused: boolean; + readonly focusedDocument?: boolean; +}) => { + const sendCommand = vi.fn(async (method: string, params?: { expression?: string }) => + method === "Runtime.evaluate" && params?.expression?.endsWith("!== null") + ? { result: { value: focusedDocument } } + : { result: {} }, + ); + const webContents = { + focusedFrame: focused ? {} : null, + insertText: vi.fn(async () => {}), + sendInputEvent: vi.fn(), + }; + return { + webContents, + sendCommand, + send: (method: string, params: Record) => + sendPageCommand( + webContents as unknown as Electron.WebContents, + { sendCommand } as unknown as Electron.Debugger, + method, + params, + ), + }; +}; + +describe("preview guest input", () => { + it("types into an unfocused guest's own page, not the app's focused field", async () => { + const guest = makeGuest({ focused: false }); + await guest.send("Input.insertText", { text: "pairing-code" }); + expect(guest.webContents.insertText).toHaveBeenCalledWith("pairing-code"); + expect(guest.sendCommand).not.toHaveBeenCalledWith("Input.insertText", expect.anything()); + }); + + it("uses CDP input while the guest holds focus, where it already reaches the page", async () => { + const guest = makeGuest({ focused: true }); + await guest.send("Input.insertText", { text: "x" }); + await guest.send("Input.dispatchKeyEvent", { type: "keyUp", key: "a" }); + expect(guest.sendCommand.mock.calls.map(([method]) => method)).toEqual([ + "Input.insertText", + "Input.dispatchKeyEvent", + ]); + expect(guest.webContents.insertText).not.toHaveBeenCalled(); + }); + + it("refuses input when a cross-site frame holds the unfocused page's focus", async () => { + const guest = makeGuest({ focused: false, focusedDocument: false }); + await expect(guest.send("Input.insertText", { text: "secret" })).rejects.toThrow( + /cross-site frame/, + ); + expect(guest.webContents.insertText).not.toHaveBeenCalled(); + }); + + it("clears a field with Cmd+A and Delete while unfocused, never touching the clipboard", async () => { + const guest = makeGuest({ focused: false }); + await guest.send("Input.dispatchKeyEvent", { + type: "rawKeyDown", + key: "a", + modifiers: 4, + commands: ["selectAll", "paste"], + }); + await guest.send("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "Delete" }); + expect(guest.webContents.sendInputEvent.mock.calls.map(([event]) => event)).toEqual([ + { type: "rawKeyDown", keyCode: "a", modifiers: ["meta"] }, + { type: "rawKeyDown", keyCode: "Delete", modifiers: [] }, + ]); + const scripts = guest.sendCommand.mock.calls.map(([, params]) => params?.expression ?? ""); + expect(scripts.some((script) => script.includes('["selectAll"]'))).toBe(true); + expect(scripts.some((script) => script.includes("paste"))).toBe(false); + }); + + it("does not run other page commands through the guest's direct input", async () => { + const guest = makeGuest({ focused: false }); + await guest.send("Input.dispatchMouseEvent", { type: "mousePressed", x: 1, y: 1 }); + expect(guest.sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mousePressed", + x: 1, + y: 1, + }); + expect(guest.webContents.sendInputEvent).not.toHaveBeenCalled(); + }); +}); + +describe("CDP key events as Electron input", () => { + it("sends a key's text as char events after its key down", () => { + expect(toKeyboardInputEvents({ type: "keyDown", key: "Enter", text: "\r" })).toEqual([ + { type: "rawKeyDown", keyCode: "Enter", modifiers: [] }, + { type: "char", keyCode: "\r", modifiers: [] }, + ]); + expect(toKeyboardInputEvents({ type: "keyDown", key: "รฉ", text: "รฉ", modifiers: 8 })).toEqual([ + { type: "rawKeyDown", keyCode: "รฉ", modifiers: ["shift"] }, + { type: "char", keyCode: "รฉ", modifiers: ["shift"] }, + ]); + }); + + it("names arrows and function keys as Electron accelerators", () => { + expect(toKeyboardInputEvents({ type: "keyUp", key: "ArrowLeft", modifiers: 3 })).toEqual([ + { type: "keyUp", keyCode: "Left", modifiers: ["alt", "control"] }, + ]); + expect(toKeyboardInputEvents({ type: "rawKeyDown", key: "F12" })).toEqual([ + { type: "rawKeyDown", keyCode: "F12", modifiers: [] }, + ]); + expect(toKeyboardInputEvents({ type: "rawKeyDown", key: "AudioVolumeUp" })).toEqual([]); + }); +}); diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts new file mode 100644 index 000000000000..773d6b79873b --- /dev/null +++ b/apps/desktop/src/preview/GuestInput.ts @@ -0,0 +1,150 @@ +/** + * 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 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. + */ +const FOCUSED_DOCUMENT = `(() => { + let doc = document; + for (;;) { + const active = doc.activeElement; + if (active?.tagName !== "IFRAME" && active?.tagName !== "FRAME") return doc; + if (!active.contentDocument) return null; + doc = active.contentDocument; + } +})()`; + +/** + * 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. + */ +const EDITING_COMMANDS = new Set(["selectAll", "undo", "redo"]); + +const CROSS_SITE_FOCUS_MESSAGE = + "The page's focus is inside a cross-site frame, which takes typed input only while the preview has focus. Click the field first."; + +const ARROW_KEYS: Record = { + ArrowUp: "Up", + ArrowDown: "Down", + ArrowLeft: "Left", + ArrowRight: "Right", +}; +const NAMED_KEYS = new Set([ + "Backspace", + "Tab", + "Enter", + "Escape", + "Delete", + "Insert", + "Home", + "End", + "PageUp", + "PageDown", + "Shift", + "Control", + "Alt", + "Meta", + "CapsLock", + "NumLock", + "ScrollLock", + "PrintScreen", +]); +const MODIFIER_BITS = [ + [1, "alt"], + [2, "control"], + [4, "meta"], + [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. + */ +export const toKeyboardInputEvents = ( + params: Record, +): Array => { + const bits = typeof params["modifiers"] === "number" ? params["modifiers"] : 0; + const modifiers = MODIFIER_BITS.flatMap(([bit, name]) => (bits & bit ? [name] : [])); + const text = typeof params["text"] === "string" ? params["text"] : ""; + const chars = [...text].map((char) => ({ type: "char" as const, keyCode: char, modifiers })); + const keyCode = keyCodeFor(typeof params["key"] === "string" ? params["key"] : ""); + switch (params["type"]) { + case "keyDown": + return [...(keyCode ? [{ type: "rawKeyDown" as const, keyCode, modifiers }] : []), ...chars]; + case "rawKeyDown": + case "keyUp": + return keyCode ? [{ type: params["type"], keyCode, modifiers }] : []; + case "char": + return chars; + default: + return []; + } +}; + +const evaluate = async (debuggee: Electron.Debugger, expression: string) => { + const reply = (await debuggee.sendCommand("Runtime.evaluate", { + expression, + returnByValue: true, + })) as { readonly result?: { readonly value?: unknown } }; + return reply.result?.value; +}; + +/** Sends one command to the guest's page, keeping key and text input inside it. */ +export const sendPageCommand = async ( + webContents: Electron.WebContents, + debuggee: Electron.Debugger, + method: string, + params: Record, +): Promise => { + if (!FOCUS_ROUTED_INPUT.has(method) || webContents.focusedFrame) { + return debuggee.sendCommand(method, params); + } + if ((await evaluate(debuggee, `${FOCUSED_DOCUMENT} !== null`)) !== true) { + throw new Error(CROSS_SITE_FOCUS_MESSAGE); + } + switch (method) { + case "Input.insertText": + await webContents.insertText(typeof params["text"] === "string" ? params["text"] : ""); + return {}; + case "Input.dispatchKeyEvent": { + for (const event of toKeyboardInputEvents(params)) webContents.sendInputEvent(event); + const commands = Array.isArray(params["commands"]) + ? params["commands"].filter((command) => EDITING_COMMANDS.has(command)) + : []; + // Runs after the page handles the keys, so the reply also waits for them. + await evaluate( + debuggee, + `(() => { const doc = ${FOCUSED_DOCUMENT}; for (const name of ${JSON.stringify(commands)}) doc?.execCommand(name); return 0; })()`, + ); + return {}; + } + default: + throw new Error(`${method} needs the preview to have focus.`); + } +}; diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 4a3eea297c12..29011a3db7e0 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -13,5 +13,4 @@ export const RECORDING_INPUT_CHANNEL = "preview:recording-input"; export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create"; export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; -/** Renderer argument that turns on the guest passkey bridge; see Passkeys.ts. */ export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6d585a88641c..f059d672b1f2 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -329,6 +329,8 @@ 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 }; } @@ -407,7 +409,6 @@ const makeTestPreviewWebContents = ( } as unknown as TestPreviewWebContents; }; -/** Two ready tabs (41, 42) sharing one window, so they contend for the single display-media slot. */ const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service"]) => Effect.gen(function* () { const capturePage = vi.fn(async () => ({ @@ -505,6 +506,8 @@ const makeFaviconWebContents = (options?: { send: webviewSend, session: { fetch }, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + isFocused: vi.fn(() => true), + focusedFrame: {} as object | null, setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), executeJavaScriptInIsolatedWorld, @@ -642,7 +645,6 @@ describe("PreviewManager", () => { const hostWebContents = { sendInputEvent, isDestroyed: () => false }; Object.assign(preview.webContents, { hostWebContents }); fromId.mockReturnValue(preview.webContents); - getFocusedWebContents.mockReturnValue(preview.webContents as never); yield* manager.setMainWindow({ isDestroyed: () => false, once: vi.fn(), @@ -707,7 +709,6 @@ describe("PreviewManager", () => { hostWebContents: { isDestroyed: () => false, send }, }); fromId.mockReturnValue(preview.webContents); - getFocusedWebContents.mockReturnValue(preview.webContents as never); yield* manager.createTab("tab_reopen"); yield* manager.registerWebview("tab_reopen", 42); const shortcut = parseKeybindingShortcut(chord)!; @@ -736,12 +737,12 @@ describe("PreviewManager", () => { expect(preventDefault).toHaveBeenCalledTimes("isAutoRepeat" in overrides ? 1 : 0); expect(send).toHaveBeenCalledOnce(); } - getFocusedWebContents.mockReturnValue(null); + vi.mocked((preview.webContents as Electron.WebContents).isFocused).mockReturnValue(false); preventDefault.mockClear(); beforeInput({ preventDefault } as never, input as never); expect(preventDefault).not.toHaveBeenCalled(); expect(send).toHaveBeenCalledOnce(); - getFocusedWebContents.mockReturnValue(preview.webContents as never); + vi.mocked((preview.webContents as Electron.WebContents).isFocused).mockReturnValue(true); yield* manager.setForwardedShortcuts([]); beforeInput({ preventDefault } as never, input as never); expect(preventDefault).not.toHaveBeenCalled(); @@ -769,7 +770,6 @@ describe("PreviewManager", () => { hostWebContents: { isDestroyed: () => false, send }, }); fromId.mockReturnValue(preview.webContents); - getFocusedWebContents.mockReturnValue(preview.webContents as never); yield* manager.createTab("tab_alt_graph"); yield* manager.registerWebview("tab_alt_graph", 42); yield* manager.setForwardedShortcuts([ @@ -811,7 +811,6 @@ describe("PreviewManager", () => { for (const browser of [preview, popup]) { const contents = browser.webContents as Electron.WebContents; - getFocusedWebContents.mockReturnValue(browser.webContents as never); const beforeInput = browser.listeners.get("before-input-event")!; const preventDefault = vi.fn(); const input = { @@ -824,7 +823,6 @@ describe("PreviewManager", () => { }; beforeInput({ preventDefault } as never, input as never); expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false); - // Releasing Command must not disable native fallback for the pending paste. beforeInput( { preventDefault } as never, { ...input, type: "keyUp", key: "Meta", meta: false } as never, @@ -834,8 +832,7 @@ describe("PreviewManager", () => { beforeInput({ preventDefault } as never, { ...input, key: "w" } as never); expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true); - // An injected paste in an unfocused guest cannot edit the active renderer. - getFocusedWebContents.mockReturnValue(null); + vi.mocked(contents.isFocused).mockReturnValue(false); beforeInput({ preventDefault } as never, input as never); expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true); expect(preventDefault).not.toHaveBeenCalled(); @@ -844,6 +841,61 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("keeps editing and forwarded shortcuts in whichever tab holds focus", () => + withManager((manager) => + Effect.gen(function* () { + const send = vi.fn(); + const host = { isDestroyed: () => false, send }; + 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"); + yield* manager.registerWebview("tab_older", 42); + fromId.mockReturnValue(newer.webContents); + yield* manager.createTab("tab_newer"); + yield* manager.registerWebview("tab_newer", 43); + const shortcut = parseKeybindingShortcut("mod+shift+t")!; + yield* manager.setForwardedShortcuts([{ command: "view.reopenClosed", shortcut }]); + + const beforeInput = older.listeners.get("before-input-event")!; + const key = { type: "keyDown", meta: true, control: false, alt: false }; + beforeInput( + { preventDefault: vi.fn() } as never, + { ...key, key: "v", shift: false } as never, + ); + expect( + (older.webContents as Electron.WebContents).setIgnoreMenuShortcuts, + ).toHaveBeenLastCalledWith(false); + + const preventDefault = vi.fn(); + beforeInput({ preventDefault } as never, { ...key, key: "T", shift: true } as never); + 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(); + agentInput( + { preventDefault: agentPreventDefault } as never, + { ...key, key: "v", shift: false } as never, + ); + expect( + (newer.webContents as Electron.WebContents).setIgnoreMenuShortcuts, + ).toHaveBeenLastCalledWith(true); + agentInput( + { preventDefault: agentPreventDefault } as never, + { ...key, key: "T", shift: true } as never, + ); + expect(agentPreventDefault).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledOnce(); + }), + ), + ); + effectIt.effect("rejects a destroyed webview during registration", () => withManager((manager) => Effect.gen(function* () { @@ -1581,8 +1633,6 @@ describe("PreviewManager", () => { expect(states.at(-1)?.zoomFactor).toBe(1); expect(setZoomFactor).toHaveBeenCalledWith(1); - // An app zoom leaves the guest reporting the inherited level. Navigating - // must not adopt it as the preview's zoom. effectiveZoom = 0.8; url = "https://example.com/after-app-zoom"; listeners.get("did-navigate")?.(); @@ -1595,7 +1645,6 @@ describe("PreviewManager", () => { }); expect(states.at(-1)?.zoomFactor).toBe(1); - // Only the preview's own zoom controls move it. yield* manager.zoomIn("tab_zoom"); expect(setZoomFactor).toHaveBeenCalledWith(1.1); expect(states.at(-1)?.zoomFactor).toBe(1.1); @@ -1688,9 +1737,6 @@ describe("PreviewManager", () => { ), ); - // did-attach and dom-ready both re-register the guest that is already - // attached, and a guest that just inherited the app window's zoom needs its - // own back โ€” without that round trip republishing tab state. effectIt.effect("re-asserts the tab's zoom when the active guest registers again", () => withManager((manager) => Effect.gen(function* () { @@ -1840,6 +1886,8 @@ describe("PreviewManager", () => { const sendCommand = vi.fn<(method: string, params?: unknown) => Promise>( async () => undefined, ); + const detachListeners = new Set<() => void>(); + let chromiumDetaches = 0; let destroyed = false; const wc = { id, @@ -1868,19 +1916,30 @@ describe("PreviewManager", () => { setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { - isAttached: () => attach.mock.calls.length > detach.mock.calls.length, + isAttached: () => attach.mock.calls.length > detach.mock.calls.length + chromiumDetaches, attach, detach, sendCommand, - on: vi.fn(), - off: vi.fn(), + on: (event: string, listener: () => void) => { + if (event === "detach") detachListeners.add(listener); + }, + off: (event: string, listener: () => void) => { + if (event === "detach") detachListeners.delete(listener); + }, }, }; + /** Chromium dropping the debugger without a detach() call. */ + const chromiumDetach = () => { + chromiumDetaches += 1; + // Snapshot like an EventEmitter, so a listener added during emit waits for the next one. + for (const listener of Array.from(detachListeners)) listener(); + }; return { wc: wc as unknown as Electron.WebContents, attach, detach, sendCommand, + chromiumDetach, destroy: () => { destroyed = true; listeners.get("destroyed")?.(); @@ -1891,8 +1950,6 @@ describe("PreviewManager", () => { effectIt.effect("sets the opaque base as soon as a guest attaches", () => withManager((manager) => Effect.gen(function* () { - // The tab's first document can paint before the renderer registers the - // guest, so the opaque base has to be the first command on attach. const claimed = makeAttachingGuest(44); fromId.mockReturnValue(claimed.wc); yield* manager.prepareWebview(claimed.wc); @@ -1910,7 +1967,6 @@ describe("PreviewManager", () => { features: [{ name: "prefers-color-scheme", value: "dark" }], }); - // A guest destroyed before any tab claims it releases its session. const unclaimed = makeAttachingGuest(45); yield* manager.prepareWebview(unclaimed.wc); expect(unclaimed.attach).toHaveBeenCalledTimes(1); @@ -1922,6 +1978,24 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("reattaches a debugger that Chromium dropped on its own", () => + withManager((manager) => + Effect.gen(function* () { + const guest = makeAttachingGuest(48); + fromId.mockReturnValue(guest.wc); + yield* manager.createTab("tab_detached"); + yield* manager.registerWebview("tab_detached", 48); + yield* Effect.yieldNow; + expect(guest.attach).toHaveBeenCalledTimes(1); + + guest.chromiumDetach(); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + expect(guest.attach).toHaveBeenCalledTimes(2); + }), + ), + ); + effectIt.effect("skips a guest destroyed while another guest's session opens", () => withManager((manager) => Effect.gen(function* () { @@ -2069,7 +2143,6 @@ describe("PreviewManager", () => { }); const exit = yield* manager.setAudioMuted("tab_audio_fail", true).pipe(Effect.exit); - // Reporting success would draw the tab as muted while it keeps playing. expect(Exit.isFailure(exit)).toBe(true); expect(states.at(-1)?.audioMuted).toBe(false); }), @@ -2087,15 +2160,12 @@ describe("PreviewManager", () => { yield* manager.setAudioMuted("tab_audio_attach_fail", true); const replacement = makeAudioWebContents(43); - // Fails the post-attach settle, not the pre-publish apply. replacement.setAudioMuted.mockImplementationOnce(() => undefined); replacement.setAudioMuted.mockImplementationOnce(() => { throw new Error("guest went away"); }); fromId.mockReturnValue(replacement.wc); - // Reconciliation is best-effort: a guest dying mid-attach must not fail - // the registration it was attaching for. const exit = yield* manager.registerWebview("tab_audio_attach_fail", 43).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); }), @@ -2218,13 +2288,9 @@ describe("PreviewManager", () => { yield* manager.navigate("tab_audio_nav", "https://example.com/next"); yield* Effect.yieldNow; - // navigate runs before loadURL swaps the document, so the old page can - // still be playing. Dropping audibility here would lose the speaker - // with no transition left to bring it back. expect(states.at(-1)?.audioMuted).toBe(true); expect(states.at(-1)?.audible).toBe(true); - // Chromium reports the real stop once the new document takes over. guest.emitAudioState(false); yield* Effect.yieldNow; expect(states.at(-1)?.audible).toBe(false); @@ -2405,7 +2471,6 @@ describe("PreviewManager", () => { withManager((manager) => Effect.gen(function* () { const preview = makeFaviconWebContents(); - // The frame tree is still loading the iframe; the main frame is done. Object.assign(preview.webContents, { isLoading: () => true }); fromId.mockReturnValue(preview.webContents); const states: PreviewManager.PreviewTabState[] = []; @@ -2497,7 +2562,6 @@ describe("PreviewManager", () => { expect(Exit.isSuccess(retriedExit)).toBe(true); expect(capturePage).toHaveBeenCalledTimes(3); - // A persistent failure still surfaces once the retries are spent. capturePage.mockClear(); const captureCause = new Error("capture failed"); capturePage.mockRejectedValue(captureCause); @@ -2585,7 +2649,6 @@ describe("PreviewManager", () => { } as never); yield* manager.startRecording("tab_capture_throttling_1"); - // The first renderer takes its grant, freeing the arm slot for the second tab. host.displayMediaHandler()?.({ frame: host.mainFrame }, () => {}); yield* manager.startRecording("tab_capture_throttling_2"); expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); @@ -2901,7 +2964,6 @@ describe("PreviewManager", () => { const manager = yield* PreviewManager.PreviewManager; yield* manager.createTab("tab_1", { serverTab }); yield* manager.registerWebview("tab_1", 42); - // The debugger attaches in the background; the server hears it here. expect(yield* nextType).toBe("attached"); yield* manager.closeTab("tab_1"); expect(yield* nextType).toBe("detached"); @@ -2924,7 +2986,6 @@ describe("PreviewManager", () => { const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( Effect.forkChild({ startImmediately: true }), ); - // Let the rejection schedule its retry before replacing the guest. yield* TestClock.adjust(60); expect(capturePage).toHaveBeenCalledTimes(1); fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43)); @@ -2985,7 +3046,6 @@ describe("PreviewManager", () => { })); const firstSendCommand = vi.fn(async () => undefined); const secondSendCommand = vi.fn(async () => undefined); - // Both webviews live in the same window, so they share one display-media handler. const host = makeTestHostWebContents(); const makeWebContents = ( id: number, @@ -3089,8 +3149,6 @@ describe("PreviewManager", () => { ), ); - // Runs on the real clock: an earlier queueing design only settled under TestClock and stalled the - // losing start forever in the desktop app. effectIt.live("settles both starts when two tabs race for the capture stream", () => withManager((manager) => Effect.gen(function* () { @@ -3105,7 +3163,6 @@ describe("PreviewManager", () => { ); const [exitA, exitB] = exits; - // Exactly one start owns the stream; the other fails fast instead of hanging. expect(exits.filter(Exit.isSuccess)).toHaveLength(1); const loserExit = Exit.isSuccess(exitA) ? exitB : exitA; if (Exit.isSuccess(loserExit)) return; @@ -3113,7 +3170,6 @@ describe("PreviewManager", () => { _tag: "PreviewRecordingArmConflictError", }); - // The single grant goes to the tab that actually won the slot, never the other one. takeGrant(); expect(grants).toEqual([{ video: { routingId: Exit.isSuccess(exitA) ? 41 : 42 } }]); expect(host.session.setDisplayMediaRequestHandler).toHaveBeenCalledOnce(); @@ -3135,7 +3191,6 @@ describe("PreviewManager", () => { armedTabId: "tab_race_a", }); - // Nothing ever captured the armed tab, so the slot goes stale and stops blocking starts. yield* TestClock.adjust(10_000); yield* manager.startRecording("tab_race_b"); takeGrant(); @@ -3154,7 +3209,6 @@ describe("PreviewManager", () => { yield* manager.startRecording("tab_race_a"); yield* TestClock.adjust(10_000); - // The handler cannot read a clock, so the expiry fiber must have dropped the frame. takeGrant(); expect(grants).toEqual([{}]); @@ -3267,7 +3321,6 @@ describe("PreviewManager", () => { yield* manager.startRecording("tab_cursor_reload", options); for (const recording of [true, false]) { if (!recording) yield* manager.stopRecording("tab_cursor_reload"); - // A new document has lost the previous preload's cursor overlay. cursorActive = false; const restored = new Promise((resolve) => { cursorUpdated = resolve; @@ -3353,6 +3406,85 @@ describe("PreviewManager", () => { ), ); + effectIt.effect( + "releases queued STOP after stalled recording warmups and ignores late completion", + () => + withManager((manager) => + Effect.gen(function* () { + const warmupStarted = Promise.withResolvers(); + const firstWarmup = Promise.withResolvers(); + const lateWarmup = Promise.withResolvers(); + const capturePage = vi + .fn<() => Promise>() + .mockImplementationOnce(() => { + warmupStarted.resolve(); + return firstWarmup.promise; + }) + .mockImplementation(() => lateWarmup.promise); + const host = makeTestHostWebContents(); + const webContents = makeTestPreviewWebContents(capturePage, 42, host); + const windowThrottling = vi.fn(); + fromId.mockReturnValue(webContents); + yield* manager.createTab("tab_recording_stalled_warmup"); + yield* manager.registerWebview("tab_recording_stalled_warmup", 42); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: { setBackgroundThrottling: windowThrottling }, + } as never); + + const image = { + toJPEG: () => Buffer.from("late-recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + }; + try { + const start = yield* manager + .startRecording("tab_recording_stalled_warmup") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => warmupStarted.promise); + let stopped = false; + const stop = yield* manager.stopRecording("tab_recording_stalled_warmup").pipe( + Effect.andThen( + Effect.sync(() => { + stopped = true; + }), + ), + Effect.forkChild({ startImmediately: true }), + ); + expect(webContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(windowThrottling.mock.calls).toEqual([[false]]); + yield* TestClock.adjust(1_000); + expect(stopped).toBe(false); + expect(host.executeJavaScript).not.toHaveBeenCalled(); + yield* TestClock.adjust(1_000); + expect(stopped).toBe(true); + yield* Fiber.join(start); + yield* Fiber.join(stop); + expect(capturePage).toHaveBeenCalledTimes(2); + expect(host.executeJavaScript).toHaveBeenCalledOnce(); + expect(webContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(windowThrottling.mock.calls).toEqual([[false], [true]]); + expect(webviewSend).toHaveBeenLastCalledWith("preview:recording-cursor", false); + + lateWarmup.resolve(image); + yield* TestClock.adjust(0); + const streams: Array<{ video?: unknown }> = []; + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => streams.push(value)); + expect(streams).toEqual([{}]); + expect(capturePage).toHaveBeenCalledTimes(2); + expect(host.executeJavaScript).toHaveBeenCalledOnce(); + expect(webContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(windowThrottling.mock.calls).toEqual([[false], [true]]); + expect(webviewSend).toHaveBeenLastCalledWith("preview:recording-cursor", false); + } finally { + // Release stalled native calls after a failing assertion against unchanged base. + firstWarmup.resolve(image); + lateWarmup.resolve(image); + } + }), + ), + ); + effectIt.effect("continues native recording when the source warmup fails", () => withManager((manager) => Effect.gen(function* () { @@ -3371,7 +3503,6 @@ describe("PreviewManager", () => { yield* manager.startRecording("tab_recording_warmup_failure"); expect(capturePage).toHaveBeenCalledTimes(2); - // The armed tab answers exactly one display-media request, then further requests are denied. const handler = host.displayMediaHandler(); const streams: Array<{ video?: unknown }> = []; handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); @@ -3454,6 +3585,7 @@ describe("PreviewManager", () => { const jpeg = Buffer.from("shared-preview-frame"); const capturePage = vi.fn(async () => ({ toJPEG: () => jpeg, + toBitmap: () => jpeg, getSize: () => ({ width: 1280, height: 720 }), })); fromId.mockReturnValue({ @@ -3612,6 +3744,7 @@ describe("PreviewManager", () => { const jpeg = Buffer.from("shared-preview-frame"); const capturePage = vi.fn(async () => ({ toJPEG: () => jpeg, + toBitmap: () => jpeg, getSize: () => ({ width: 1280, height: 720 }), })); fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); @@ -3687,6 +3820,7 @@ describe("PreviewManager", () => { const jpeg = Buffer.from("retry-preview-frame"); const capturePage = vi.fn(async () => ({ toJPEG: () => jpeg, + toBitmap: () => jpeg, getSize: () => ({ width: 1280, height: 720 }), })); fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); @@ -3718,6 +3852,7 @@ describe("PreviewManager", () => { const jpeg = Buffer.from("reloaded-preview-frame"); const capturePage = vi.fn(async () => ({ toJPEG: () => jpeg, + toBitmap: () => jpeg, getSize: () => ({ width: 1280, height: 720 }), })); fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); @@ -3741,11 +3876,62 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("encodes a picture-in-picture frame only when the page changes", () => + withManager((manager) => + Effect.gen(function* () { + let page = "static-page"; + let pixelsReadable = true; + const toJPEG = vi.fn((pixels: string) => Buffer.from(`jpeg:${pixels}`)); + const capturePage = vi.fn(async () => { + const pixels = page; + return { + toJPEG: () => toJPEG(pixels), + toBitmap: () => (pixelsReadable ? Buffer.from(pixels) : Buffer.alloc(0)), + getSize: () => ({ width: 1280, height: 720 }), + }; + }); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + + yield* manager.createTab("tab_pip_unchanged"); + yield* manager.registerWebview("tab_pip_unchanged", 42); + yield* manager.openPictureInPicture("tab_pip_unchanged"); + yield* TestClock.adjust(100); + + expect(capturePage).toHaveBeenCalledTimes(2); + expect(toJPEG).toHaveBeenCalledOnce(); + expect(send).toHaveBeenCalledOnce(); + + page = "changed-page"; + yield* TestClock.adjust(100); + + expect(toJPEG).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenLastCalledWith( + "desktop:preview-pip-frame", + expect.objectContaining({ data: Buffer.from("jpeg:changed-page").toString("base64") }), + ); + + // A frame whose pixels cannot be read has no fingerprint and is still delivered. + pixelsReadable = false; + yield* TestClock.adjust(100); + + expect(toJPEG).toHaveBeenCalledTimes(3); + expect(send).toHaveBeenCalledTimes(3); + yield* manager.closePictureInPicture("tab_pip_unchanged"); + }), + ), + ); + effectIt.effect("drops empty frames before picture-in-picture delivery", () => withManager((manager) => Effect.gen(function* () { const validImage: TestCapturedPreviewImage = { toJPEG: () => Buffer.from("valid-preview-frame"), + toBitmap: () => Buffer.from("valid-preview-frame"), getSize: () => ({ width: 1280, height: 720 }), }; const capturePage = vi.fn(async () => validImage); @@ -3819,10 +4005,7 @@ describe("PreviewManager", () => { })); fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); const { pictureInPictureWindow: initializingWindow } = makeTestPictureInPictureWindow( - () => - new Promise(() => { - // Simulate a renderer load that never settles. - }), + () => new Promise(() => {}), ); const { pictureInPictureWindow: reopenedWindow } = makeTestPictureInPictureWindow(); browserWindowConstructor @@ -4119,7 +4302,6 @@ describe("PreviewManager", () => { on: vi.fn(), once: vi.fn(), off: vi.fn(), - // A wedged compositor leaves `capturePage` pending forever. capturePage: vi.fn(() => new Promise(() => {})), ipc: { on: vi.fn((channel: string, listener: typeof onPicked) => { @@ -4168,8 +4350,6 @@ describe("PreviewManager", () => { expect(pick.pollUnsafe()).toBeUndefined(); yield* TestClock.adjust("6 seconds"); - // The pick has to give up on the crop rather than strand the renderer, - // which would leave the composer stuck on "Capturingโ€ฆ". const result = yield* Fiber.join(pick); expect(result?.annotation.screenshot).toBeNull(); expect(result?.screenshotFailed).toBe(true); @@ -4298,18 +4478,14 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_1", 42); const firstPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); yield* Effect.yieldNow; - // The first pick submits and its crop hangs. onPicked?.({}, annotation, null, "send"); yield* Effect.yieldNow; - // A second pick on the same tab replaces the first, which resumes null. const secondPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); yield* Effect.yieldNow; expect(yield* Fiber.join(firstPick)).toBeNull(); webviewSend.mockClear(); - // The first pick's crop times out while the second pick is live. It - // must not signal the overlay, which would tear down the second pick. yield* TestClock.adjust("6 seconds"); yield* Effect.yieldNow; expect(webviewSend).not.toHaveBeenCalledWith("preview:annotation-captured"); @@ -4380,7 +4556,6 @@ describe("PreviewManager", () => { yield* Effect.yieldNow; expect(goForward).toHaveBeenCalledOnce(); - // Ignores unknown payloads and never navigates when history is exhausted. mouseNavigate?.({}, { direction: "sideways" }); canGoBack = false; mouseNavigate?.({}, { direction: "back" }); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 2d26ce591a93..56b9d960369c 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -5,6 +5,7 @@ * 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, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, @@ -105,30 +106,20 @@ export interface PreviewTabState { zoomFactor: number; pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; - /** User intent to silence this tab. Re-applied to each guest that attaches. */ audioMuted: boolean; - /** Observed from Chromium. Stays true while a muted tab keeps playing. */ audible: boolean; controller: "human" | "agent" | "none"; favicon?: DesktopPreviewFavicon; - /** Set for a tab of the desktop's own server, which drives it over the browser channel. */ serverTab?: { readonly threadId: string; readonly tabId: string }; updatedAt: string; } -/** Discrete zoom levels mirroring Chrome's preset list. */ const ZOOM_LEVELS: ReadonlyArray = [ 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, ]; const DEFAULT_ZOOM_FACTOR = 1.0; const ZOOM_EPSILON = 0.001; -/** - * A `[role]` container's innerText is its whole subtree, which turned one - * snapshot's element list into 60 KB of repeated page text. Names are labels, - * not content, so cap them where they are read. - */ -/** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */ 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; @@ -318,6 +309,33 @@ 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. + */ +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. + */ +const fingerprintCapturedFrame = (image: Electron.NativeImage, size: Electron.Size) => { + let bitmap: Buffer; + try { + bitmap = image.toBitmap(FINGERPRINT_BITMAP_OPTIONS); + } catch { + return null; + } + if (bitmap.byteLength === 0) return null; + return `${size.width}x${size.height}:${NodeZlib.crc32(bitmap)}`; +}; + const findZoomStep = (current: number): number => { const index = ZOOM_LEVELS.findIndex( (level) => Math.abs(level - current) < ZOOM_EPSILON || level > current, @@ -326,12 +344,6 @@ const findZoomStep = (current: number): number => { return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1; }; -/** - * Clamp a client-supplied zoom factor onto the discrete ladder. The setting is - * chosen from the same ladder, but it arrives over IPC from a schema that only - * guarantees a positive number, so an out-of-band value snaps to the nearest - * step rather than leaving the guest at a zoom the zoom controls can't reach. - */ const normalizeZoomFactor = (value: number | undefined): number => { if (value === undefined || !Number.isFinite(value)) return DEFAULT_ZOOM_FACTOR; let closest = ZOOM_LEVELS[0]!; @@ -370,7 +382,7 @@ interface FrameCaptureSession { readonly scope: Scope.Closeable | null; readonly consumers: ReadonlySet; readonly unthrottledWebContentsIds: ReadonlySet; - readonly lastPictureInPictureFrame: Buffer | null; + readonly lastPictureInPictureFingerprint: string | null; } interface PictureInPictureSession { @@ -380,7 +392,6 @@ interface PictureInPictureSession { readonly initializationScope: Scope.Closeable; } -/** The tab whose frame the next `getDisplayMedia()` request is allowed to capture. */ interface PendingRecording { readonly tabId: string; readonly webContents: Electron.WebContents; @@ -394,7 +405,6 @@ interface PickSession { interface BrowserControlSession { readonly webContentsId: number; - // Pins the WebContents' Debugger wrapper for the session's lifetime. // Electron's Debugger is GC-managed but registered with Chromium as a raw // DevToolsAgentHostClient pointer; collecting it while attached crashes the // browser process (electron/electron#53376). Detach must also go through @@ -418,15 +428,11 @@ type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; /** - * Protocols a preview page may open in a real popup window. - * - * `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. Those popups keep loading in the preview tab. - * + * `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. + * `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal` and not in a + * window spawned by a third-party page in the preview. */ const POPUP_PROTOCOLS = new Set(["http:", "https:"]); @@ -439,13 +445,10 @@ const isPopupUrl = (rawUrl: string): boolean => { }; /** - * Preferences for a popup a preview page opens. - * - * 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. The window keeps the opener and the guest session either way. + * 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. */ const POPUP_WINDOW_OPTIONS = { webPreferences: { @@ -456,22 +459,11 @@ const POPUP_WINDOW_OPTIONS = { } satisfies Electron.BrowserWindowConstructorOptions; /** - * Decides what a preview page's `window.open` should do. - * - * `"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. - * - * `"new-tab"` hands a `target="_blank"` link (a tab disposition) to the web - * app, which opens it as another preview tab so the page that held the link - * stays put. 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. - * - * `"deny"` is for a blank window (`""` or `about:blank`). It cannot get a real - * window (see `POPUP_PROTOCOLS`), and loading it into the preview tab would - * replace the opener with an empty page. Denying makes `window.open()` return - * `null`, which SDKs such as MSAL treat as a blocked popup and fall back from. + * `"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. */ export const previewWindowOpenAction = (details: { readonly url: string; @@ -621,10 +613,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }); const capture = Effect.gen(function* () { - // Check after the retry delay, and again before accepting its result. yield* requireCurrentGuest; const image = yield* Effect.tryPromise({ - // An abort-signal parameter makes a stalled promise interruptible. try: (_signal) => wc.capturePage(), catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), }).pipe( @@ -793,8 +783,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ...current, scope: consumer === "picture-in-picture" ? null : current.scope, consumers, - lastPictureInPictureFrame: - consumer === "picture-in-picture" ? null : current.lastPictureInPictureFrame, + lastPictureInPictureFingerprint: + consumer === "picture-in-picture" + ? null + : current.lastPictureInPictureFingerprint, }); }), ] as const; @@ -910,10 +902,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); /** - * Pushes a tab's zoom factor onto whichever guest it currently owns, reading - * both at call time. 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. + * 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. */ const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); @@ -929,10 +920,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function * 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. - * - * Failures propagate so the user-facing setter can roll its commit back. - * Reconciliation callers, where a guest going away mid-attach is expected, - * discard the error at their own call site. */ const assertTabAudioMuted = Effect.fn("PreviewManager.assertTabAudioMuted")(function* ( tabId: string, @@ -946,13 +933,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - /** - * Publishes an observed audibility value for the guest that reported it. - * Shared by the `audio-state-changed` handler and the post-attach reconcile - * so both drop values from a guest the tab no longer owns, and both skip - * unchanged values: Chromium re-emits per media element, and republishing - * would cost an IPC push per element rather than per real transition. - */ const syncTabAudible = Effect.fn("PreviewManager.syncTabAudible")(function* ( tabId: string, wc: Electron.WebContents, @@ -1035,7 +1015,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const detachControlSession = Effect.fn("PreviewManager.detachControlSession")(function* ( webContentsId: number, - /** The server tab it rendered, when the caller already took the tab out of `tabsRef`. */ closedServerTab?: PreviewTabState["serverTab"], ) { const control = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => [ @@ -1044,14 +1023,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function copy.delete(webContentsId); }), ]); - if (control) { - // The server can only drive a tab while the desktop holds its debugger. - if (closedServerTab) browserHost.detach(closedServerTab); - for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) { - if (tab.webContentsId === webContentsId && tab.serverTab) browserHost.detach(tab.serverTab); + if (control) yield* releaseControlSession(control, closedServerTab); + }); + + // Closes a session already removed from `controlSessionsRef`. The server can + // only drive a tab while the desktop holds its debugger. + const releaseControlSession = Effect.fnUntraced(function* ( + control: BrowserControlSession, + closedServerTab?: PreviewTabState["serverTab"], + ) { + if (closedServerTab) browserHost.detach(closedServerTab); + for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) { + if (tab.webContentsId === control.webContentsId && tab.serverTab) { + browserHost.detach(tab.serverTab); } - yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore); } + yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore); }); const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( @@ -1067,8 +1054,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function > => { const existing = sessions.get(wc.id); if (existing) return Effect.succeed([existing, sessions] as const); - // A guest can be destroyed while it waits for this lock, and its native - // methods throw once it is. if (wc.isDestroyed()) { return Effect.fail( new PreviewOperationError({ @@ -1097,10 +1082,8 @@ 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. Coalesce bursts behind one - // command rather than queueing a command for every logged object. + // 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. yield* Effect.forkIn( Effect.forever( Queue.take(consoleReleases).pipe( @@ -1167,13 +1150,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { runFork(handleDebuggerMessage(method, params)); }; - yield* Scope.addFinalizer( - scope, - attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => { - wcDebugger.off("message", onMessage); - if (wcDebugger.isAttached()) wcDebugger.detach(); - }).pipe(Effect.ignore), - ); const control: BrowserControlSession = { webContentsId: wc.id, debugger: wcDebugger, @@ -1181,19 +1157,34 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function scope, onMessage, }; + // Electron emits `detach` when Chromium closes the debugger session on its + // own. The cached session would then fail every command until the webview + // is replaced. + let dropped = false; + const onDetach = () => { + dropped = true; + runFork(recoverDetachedControlSession(wc, control)); + }; + // The listener comes off before detach() so a detach this manager requested + // never looks like a dropped one. A dropped session has nothing left to + // detach: `wc.debugger` is shared, so detaching here would hit a successor. + yield* Scope.addFinalizer( + scope, + attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => { + wcDebugger.off("message", onMessage); + wcDebugger.off("detach", onDetach); + if (!dropped && wcDebugger.isAttached()) wcDebugger.detach(); + }).pipe(Effect.ignore), + ); const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => { wcDebugger.on("message", onMessage); + wcDebugger.on("detach", onDetach); wcDebugger.attach("1.3"); }); - // Electron gives `` guests a transparent base background, and - // Chromium only paints a dark canvas for dark color-scheme pages over an - // opaque base. Without this, dark-scheme pages with no background of - // their own (text/plain, e.g. .md files) render white text on white. - // White matches the webview's existing white backing, so light pages look - // the same; Chromium still swaps in its dark canvas for dark-scheme pages. - // Sent first because a document that paints before it arrives keeps the - // transparent base until its next load. + // 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. yield* attemptPromise( { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id }, () => @@ -1286,7 +1277,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } // 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. Keep the failure until a new load actually starts. + // navigated anywhere. const navStatus = preserveLoadFailure && current.navStatus.kind === "LoadFailed" && @@ -1510,13 +1501,14 @@ 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. + const holdsKeyboardFocus = (contents: Electron.WebContents) => + contents.isFocused() && contents.focusedFrame !== null; const syncMenuShortcuts = (contents: Electron.WebContents, input: Electron.Input): void => { if (input.type !== "keyDown") return; - // Native editing roles must remain available after the page handles the key. - // Background automation must not edit whichever other renderer has focus. contents.setIgnoreMenuShortcuts( - !isPreviewEditingShortcut(input, hostPlatform) || - webContents.getFocusedWebContents() !== contents, + !isPreviewEditingShortcut(input, hostPlatform) || !holdsKeyboardFocus(contents), ); }; // A popup opens with Electron's default handler, so the page inside it could @@ -1537,7 +1529,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function !input.isComposing && host && !host.isDestroyed() && - webContents.getFocusedWebContents() === wc && + holdsKeyboardFocus(wc) && !( hostPlatform !== "darwin" && input.control && @@ -1597,8 +1589,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { - // Only focused native editing shortcuts may reach the application menu. - // Other preview input, including CDP keys, belongs to the page. wc.setIgnoreMenuShortcuts(true); wc.on("did-start-navigation", navigationStarted); wc.on("did-navigate", syncNavigation); @@ -1833,9 +1823,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const currentAttachment = attached.get(webContentsId); yield* keepFrameCaptureWebContentsUnthrottled(tabId, wc); if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { - // The guest we already own re-announced itself, so nothing about the tab - // changed. Only push its zoom back down โ€” Chromium may have just handed - // this guest the app window's zoom level. yield* assertTabZoom(tabId); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => { wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme); @@ -1849,7 +1836,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ? tab.webContentsId : null; if (replacedWebContentsId !== null) { - // The replaced guest can no longer redeem a display-media grant. clearPendingRecording(tabId); yield* Effect.all( [ @@ -1875,9 +1861,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function 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. - // Settled again after attach, below, the same way zoom is. + // 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. yield* attempt({ operation: "registerWebview.restoreAudioMuted", tabId, webContentsId }, () => wc.setAudioMuted(currentTab.audioMuted), ); @@ -1935,13 +1920,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function // addressed the guest this one replaced, so settle the new guest on the // committed values. yield* assertTabZoom(tabId); - // Best-effort here, unlike in setAudioMuted: a guest that dies mid-attach - // must not fail the registration it was attaching for. yield* assertTabAudioMuted(tabId).pipe(Effect.ignore); runFork(restoreControlSession(tabId, wc)); - // emitIfCurrent, not emit: audio-state-changed can land between the commit - // above and here, and republishing this snapshot would roll the UI back to - // a superseded audibility that syncTabAudible will not re-send. 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 @@ -1977,21 +1957,17 @@ 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. registerWebview reuses the session opened here. + // 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. const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( wc: Electron.WebContents, ) { yield* rendererHistory.register(wc, { surface: "preview" }); const webContentsId = wc.id; - // A guest destroyed before any tab claims it has no other cleanup path. wc.once("destroyed", () => { runFork(detachControlSession(webContentsId)); }); - // Runs detached from the attach event, so nothing may escape. registerWebview - // opens the session again if this one did not. yield* ensureControlSession(wc).pipe( Effect.catchCause((cause) => Effect.logDebug("Preview webview control session was not opened on attach.", { @@ -2022,10 +1998,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", - // Both carry across navigation. 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. + // 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. audioMuted: current?.audioMuted ?? false, audible: current?.audible ?? false, controller: current?.controller ?? "none", @@ -2039,9 +2015,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ] as const; }); - // emitIfCurrent for the same reason as update: this snapshot carries - // audibility forward, and an audio-state-changed landing in between would - // otherwise be rolled back with no follow-up transition to correct it. yield* emitIfCurrent(tabId, pending); if (pending.webContentsId == null) return; const webContentsId = pending.webContentsId; @@ -2088,8 +2061,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); return; } - // URL-bar navigation hands the page back to the human. The agent's own - // navigation shares this path; its next action marks the page again. if (wc.getURL() === url) { yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () => wc.reload(), @@ -2181,8 +2152,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationTheme = yield* Ref.get(annotationThemeRef); return yield* Effect.callback( (resume) => { - // Declared first so cleanup can check slot ownership by identity - // without a type cycle through the cancel effect it builds. const session: PickSession = { cancel: Effect.suspend(() => cancelPickSession()) }; const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { @@ -2200,10 +2169,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function : sessions, ); }); - // Every exit from this session runs through `claimSettle`, so the - // renderer's `pickElement` promise resolves exactly once. The previous - // identity check let a cancelled or replaced session return without - // resuming, which left the composer waiting forever. let settled = false; const claimSettle = (): boolean => { if (settled) return false; @@ -2256,9 +2221,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function args[2] === "send" && annotationSendEnabled.get(tabId) === true ? "send" : "attach"; const devicePixelRatio = typeof args[3] === "number" ? args[3] : 1; runFork( - // The full-page capture with retries is what the screenshot button - // uses. A single cropped `capturePage(rect)` with a fixed timeout - // dropped the crop on pages where that capture was merely slow. capturePageWithRetry( { operation: "captureAnnotationScreenshot", tabId, webContentsId: wc.id }, tabId, @@ -2271,9 +2233,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), ), Effect.withSpan("PreviewManager.captureAnnotationScreenshot"), - // The renderer cannot tell a dropped crop from a comment-only - // pick by the null alone, so a failed or timed-out capture is - // flagged on the result. Effect.match({ onFailure: (): PreviewAnnotationSubmissionResult => ({ annotation: payload, @@ -2344,12 +2303,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - /** - * Chromium hands every guest `` the embedder's zoom level, so zooming - * the app UI drags the previewed page along with it. The preview browser owns - * its own zoom factor, so re-assert it on each attached guest whenever the main - * window's zoom changes (see DesktopWindow.zoomMain). - */ const reapplyZoom = Effect.fn("PreviewManager.reapplyZoom")(function* () { const tabIds = Array.from((yield* SynchronizedRef.get(tabsRef)).keys()); yield* Effect.forEach(tabIds, assertTabZoom, { discard: true }); @@ -2388,7 +2341,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function features: [ { name: "prefers-color-scheme", - // An empty value clears the override so the page follows the OS. value: colorScheme === "system" ? "" : colorScheme, }, ], @@ -2396,10 +2348,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Re-establish the control session after a detach, restoring any - // color-scheme override the tab carries. The scheme is read after the - // session attaches so a concurrent setColorScheme is not overwritten with - // a stale snapshot. + // The scheme is read after the session attaches so a concurrent setColorScheme is not + // overwritten with a stale snapshot. const restoreControlSession = (tabId: string, wc: Electron.WebContents) => Effect.gen(function* () { const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); @@ -2428,6 +2378,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }).pipe(Effect.ignore); + // Only the session that saw the detach is replaced: by the time the event is + // handled the guest may already hold a newer one, which must stay. + const recoverDetachedControlSession = Effect.fn("PreviewManager.recoverDetachedControlSession")( + function* (wc: Electron.WebContents, detached: BrowserControlSession) { + const removed = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => + sessions.get(wc.id) === detached + ? ([ + true, + replaceMap(sessions, (copy) => { + copy.delete(wc.id); + }), + ] as const) + : ([false, sessions] as const), + ); + if (!removed) return; + yield* releaseControlSession(detached); + if (wc.isDestroyed()) return; + const tabId = yield* tabIdForWebContents(wc.id); + if (tabId === null) return; + yield* restoreControlSession(tabId, wc); + }, + (recover) => Effect.ignore(recover), + ); + const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( tabId: string, colorScheme: DesktopPreviewColorScheme, @@ -2437,9 +2411,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* new PreviewTabNotFoundError({ tabId }); } if (tab.colorScheme !== colorScheme) { - // Record the choice even when the CDP call below can't run yet (no - // webview, DevTools holding the debugger) โ€” it is re-applied on the - // next control-session (re)attach. yield* update(tabId, { colorScheme }); } // Re-read after the update: registerWebview may have swapped the guest @@ -2466,8 +2437,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* withTabLifecycleLock( tabId, Effect.gen(function* () { - // Record the intent even when no guest is attached yet โ€” it is - // re-applied by registerWebview when one arrives. const previous = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.audioMuted; const committed = previous !== undefined && previous !== audioMuted; if (committed) { @@ -2584,6 +2553,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { return; } + 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. + const fingerprint = fingerprintCapturedFrame(image, size); + const pictureInPicture = + frameSession.consumers.has("picture-in-picture") && + (fingerprint === null || frameSession.lastPictureInPictureFingerprint !== fingerprint); + if (!pictureInPicture) return; const encoded = yield* attempt( { operation: "frameCapture.encodeFrame", @@ -2592,12 +2571,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }, () => image.toJPEG(PICTURE_IN_PICTURE_JPEG_QUALITY), ); - const frameSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); - if (frameSession?.scope !== captureSession.scope) return; - const pictureInPicture = - frameSession.consumers.has("picture-in-picture") && - frameSession.lastPictureInPictureFrame?.equals(encoded) !== true; - if (!pictureInPicture) return; const receivedAt = yield* currentIso; const frame: DesktopPreviewRecordingFrame = { tabId, @@ -2662,7 +2635,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return replaceMap(sessions, (copy) => { copy.set(tabId, { ...frameSession, - lastPictureInPictureFrame: encoded, + lastPictureInPictureFingerprint: fingerprint, }); }); }); @@ -2684,8 +2657,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId: string, consumer: FrameCaptureConsumer, ) { - // Recording keeps only the activity lease. Picture-in-picture owns the - // capturePage loop and tolerates transient compositor warmup failures. const captureNextFrame = Effect.sleep(PICTURE_IN_PICTURE_FRAME_INTERVAL_MS).pipe( Effect.andThen(capturePreviewFrame(tabId)), Effect.catch((error) => @@ -2754,7 +2725,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function scope, consumers: new Set([consumer]), unthrottledWebContentsIds: new Set([wc.id]), - lastPictureInPictureFrame: null, + lastPictureInPictureFingerprint: null, }); }), ] as const; @@ -2920,7 +2891,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const current = sessions.get(tabId); if (!current?.consumers.has("picture-in-picture")) return sessions; return replaceMap(sessions, (copy) => { - copy.set(tabId, { ...current, lastPictureInPictureFrame: null }); + copy.set(tabId, { ...current, lastPictureInPictureFingerprint: null }); }); }), ); @@ -3073,17 +3044,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* Effect.failCause(initializationExit.cause); }); - /** Only drops the armed target when it still belongs to `tabId`, so tabs cannot clobber each other. */ const clearPendingRecording = (tabId: string) => { if (pendingRecording?.tabId === tabId) pendingRecording = null; }; /** - * Claims the single arm slot for `tabId`. 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. Rather than queue (which can only ever stall a start), a colliding start - * fails fast and the renderer can retry. An arm the renderer never redeemed goes stale after - * `RECORDING_ARM_GRACE_MS` so it cannot hold the slot forever. + * 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. */ const armPendingRecording = Effect.fn("PreviewManager.armPendingRecording")(function* ( tabId: string, @@ -3176,14 +3143,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* attempt({ operation: "recording.cursor", tabId, webContentsId: wc.id }, () => wc.send(RECORDING_CURSOR_CHANNEL, true, options, tab?.controller), ); - yield* attemptPromise( - { - operation: "recording.warmSource", - tabId, - webContentsId: wc.id, - }, - () => wc.capturePage().then(() => undefined), - ).pipe(Effect.retry({ times: 1 }), Effect.ignore); + const warmupContext = { operation: "recording.warmSource", tabId, webContentsId: wc.id }; + yield* Effect.tryPromise({ + // Electron cannot cancel capturePage, but a stalled warmup must release the tab lock. + try: (_signal) => wc.capturePage(), + catch: (cause) => new PreviewOperationError({ ...warmupContext, cause }), + }).pipe( + Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail(new PreviewOperationError({ ...warmupContext, cause })), + }), + Effect.retry({ times: 1 }), + Effect.ignore, + ); const currentWebContents = yield* requireWebContents(tabId); if (currentWebContents !== wc || wc.isDestroyed()) { return yield* new PreviewWebContentsNotFoundError({ @@ -3304,10 +3277,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); let pointerSequence = 0; - /** - * The server reports where an agent action is about to land on a tab it - * drives here. The live cursor and desktop recordings draw it like any agent. - */ const emitAgentPointer = Effect.fn("PreviewManager.emitAgentPointer")(function* (pointer: { readonly key: { readonly threadId: string; readonly tabId: string }; readonly phase: "move" | "click"; @@ -3343,7 +3312,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { discard: true }, ); }); - // Listening starts with the first tab of the desktop's own server. let pointersStarted = false; const listenForAgentPointers = Effect.suspend(() => { if (pointersStarted) return Effect.void; @@ -3614,8 +3582,6 @@ export class PreviewManager extends Context.Service< readonly zoomIn: (tabId: string) => Effect.Effect; readonly zoomOut: (tabId: string) => Effect.Effect; readonly resetZoom: (tabId: string) => Effect.Effect; - // Re-applies every attached guest's own zoom factor, undoing the zoom level - // Chromium inherits from the embedder when the app UI zooms. readonly reapplyZoom: () => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; readonly setColorScheme: ( @@ -3696,7 +3662,6 @@ export const make = Effect.gen(function* PreviewManagerMake() { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runForkDownload = Effect.runForkWith(yield* Effect.context()); - /** Puts a copy in Downloads under a name that never replaces a file already there. */ const copyToDownloads = Effect.fn("PreviewManager.copyToDownloads")(function* ( source: string, fileName: string, @@ -3715,8 +3680,6 @@ export const make = Effect.gen(function* PreviewManagerMake() { return; } }); - // Server tabs save downloads where the server's engine reads them. Downloads - // the person starts in a tab the server is not driving keep Electron's dialog. const placeServerDownloads = (session: Electron.Session) => { if (downloadSessions.has(session)) return; downloadSessions.add(session); @@ -3724,7 +3687,6 @@ export const make = Effect.gen(function* PreviewManagerMake() { // Decided before the server's path is set: placing it consumes the guid. const human = browserHost.humanStartedDownload(source); if (!browserHost.placeDownload(source, item) || !human) return; - // The server keeps its copy for agents; the person who clicked gets theirs. item.once("done", (_doneEvent, state) => { if (state !== "completed") return; runForkDownload( diff --git a/apps/desktop/src/preview/PasskeyAttestation.ts b/apps/desktop/src/preview/PasskeyAttestation.ts index 17927a2957da..043ccaa77206 100644 --- a/apps/desktop/src/preview/PasskeyAttestation.ts +++ b/apps/desktop/src/preview/PasskeyAttestation.ts @@ -1,8 +1,3 @@ -/** - * Reads the raw `authData` bytes out of a CBOR attestation object - * (`{ fmt, attStmt, authData }`). Returns undefined for anything malformed. - * Only the definite-length encodings authenticators emit are understood. - */ export const authenticatorDataFromAttestation = (bytes: Uint8Array) => { let offset = 0; const readHead = () => { diff --git a/apps/desktop/src/preview/PasskeyBackend.ts b/apps/desktop/src/preview/PasskeyBackend.ts index ac174d2759a4..ec4a70936031 100644 --- a/apps/desktop/src/preview/PasskeyBackend.ts +++ b/apps/desktop/src/preview/PasskeyBackend.ts @@ -1,19 +1,11 @@ import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; -/** What the shared bridge has already checked before a backend runs a ceremony. */ export interface PasskeyCeremonyContext { /** The committed origin of the frame that asked; never the page's own claim. */ readonly origin: string; - /** The window the system sheet attaches to. */ readonly nativeWindowHandle: Buffer; } -/** - * One platform's way to create and use passkeys for a preview page. The - * bridge in `Passkeys.ts` owns everything platform-neutral: which frame may - * ask, focus, one ceremony at a time, the deadline, and dropping results for - * a page that navigated away. A backend only talks to the platform. - */ export interface PasskeyBackend { readonly create: ( publicKey: PublicKeyCredentialCreationOptions, diff --git a/apps/desktop/src/preview/PasskeyBackendMac.ts b/apps/desktop/src/preview/PasskeyBackendMac.ts index a21d94df1cbe..ccdffa2a70c0 100644 --- a/apps/desktop/src/preview/PasskeyBackendMac.ts +++ b/apps/desktop/src/preview/PasskeyBackendMac.ts @@ -33,9 +33,7 @@ const toBase64Url = (value: BufferSource) => ).toString("base64url"); /** - * macOS passkeys through AuthenticationServices (`electron-webauthn`): the - * system sheet offers iCloud Keychain, password managers, phones, and security - * keys. Needs Apple's managed browser entitlement in the signed build. + * Needs Apple's managed browser entitlement in the signed build. */ export const macPasskeyBackend: PasskeyBackend = { create: async (publicKey, context): Promise => { @@ -63,8 +61,6 @@ export const macPasskeyBackend: PasskeyBackend = { const authData = authenticatorDataFromAttestation( Buffer.from(result.data.attestationObject, "base64url"), ); - // Sites verify the new credential from its authenticator data; without it - // the passkey is unusable, so say so now rather than let sign-up fail later. if (!authData) return { success: false, error: "NotAllowedError" }; return { success: true, diff --git a/apps/desktop/src/preview/PasskeyBridge.test.ts b/apps/desktop/src/preview/PasskeyBridge.test.ts index 3ca93190ae51..8cbaad7f38dc 100644 --- a/apps/desktop/src/preview/PasskeyBridge.test.ts +++ b/apps/desktop/src/preview/PasskeyBridge.test.ts @@ -106,7 +106,6 @@ describe("installPasskeyBridge", () => { challenge, rpId: "example.com", allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }], - // Unknown members the page adds must not break IPC cloning. extensions: { onHint: () => {} } as AuthenticationExtensionsClientInputs, }, })) as PublicKeyCredential; @@ -191,7 +190,6 @@ describe("installPasskeyBridge", () => { publicKey: { challenge: new Uint8Array([1]) }, }); await credentials.get({ password: true } as CredentialRequestOptions); - // Conditional create upgrades a password sign-in silently; no modal sheet. const upgrade = { mediation: "conditional", publicKey: creationOptions } as const; await credentials.create(upgrade); await credentials.create({ password: {} } as CredentialCreationOptions); diff --git a/apps/desktop/src/preview/PasskeyBridge.ts b/apps/desktop/src/preview/PasskeyBridge.ts index cea175c29633..fbb9fefa4aa5 100644 --- a/apps/desktop/src/preview/PasskeyBridge.ts +++ b/apps/desktop/src/preview/PasskeyBridge.ts @@ -2,7 +2,6 @@ import type { CreateCredentialResult, GetCredentialResult } from "electron-webau import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; -/** What the main process answers a guest passkey ceremony with. */ export type PasskeyCeremonyResult = | Extract | Extract @@ -46,9 +45,8 @@ const ceremonyError = (name: string) => ); /** - * Copies request options into plain IPC-cloneable data. Chromium ignores - * members it does not know, so functions and other uncloneable values drop out - * here instead of failing the whole ceremony. + * Chromium ignores members it does not know, so functions and other uncloneable + * values drop out here instead of failing the whole ceremony. */ const toCloneable = (value: unknown): unknown => { if (value instanceof ArrayBuffer) return value.slice(0); @@ -205,14 +203,10 @@ const abortReason = (signal: AbortSignal) => signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); /** - * Routes this page's WebAuthn ceremonies to the system passkey sheet through - * the main process, which supplies the frame's real origin. Runs in the page's - * own world (the preview preload has contextIsolation off), before any page - * script. Conditional (autofill and automatic upgrade) requests stay native: - * the system sheet is modal and must not open on its own. + * Conditional (autofill and automatic upgrade) requests stay native: the system + * sheet is modal and must not open on its own. */ export function installPasskeyBridge(invoke: InvokePasskeyCeremony) { - // Insecure contexts have no WebAuthn to bridge. if (typeof CredentialsContainer === "undefined" || typeof PublicKeyCredential === "undefined") { return; } @@ -230,7 +224,6 @@ export function installPasskeyBridge(invoke: InvokePasskeyCeremony) { ? credentialFromCreateResult(result.data) : credentialFromGetResult(result.data); }, - // No handler yet (or any more) for this guest: answer like a refusal. (error: unknown) => { throw error instanceof DOMException || error instanceof TypeError ? error diff --git a/apps/desktop/src/preview/Passkeys.test.ts b/apps/desktop/src/preview/Passkeys.test.ts index 0fe4ed8be682..c6b8720314a3 100644 --- a/apps/desktop/src/preview/Passkeys.test.ts +++ b/apps/desktop/src/preview/Passkeys.test.ts @@ -66,7 +66,6 @@ const makeGuest = (origin = "https://accounts.example.com") => { const bridgeLayer = layerFor({ t3codeWebAuthn: { browserPasskeys: true } }); -/** CBOR `{ fmt: "packed", attStmt: { alg: -7, sig }, authData }`, as authenticators encode it. */ const attestationObject = (authData: ReadonlyArray) => { const text = (value: string) => [0x60 + value.length, ...Buffer.from(value)]; const bytes = (value: ReadonlyArray) => [0x58, value.length, ...value]; @@ -93,7 +92,6 @@ describe("PreviewPasskeys", () => { getNativeWindowHandle: () => Buffer.from([1]), }); }); - // Restored here so a failing fake-timer test cannot leak fake timers into the next one. afterEach(() => { vi.useRealTimers(); }); @@ -126,7 +124,6 @@ describe("PreviewPasskeys", () => { yield* call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]) }); assert.strictEqual(webauthn.getCredential.mock.calls[1]?.[0].rpId, "accounts.example.com"); - // Only the main frame runs the preload; anything else is not the page. const forged = yield* call(PASSKEY_CREATE_CHANNEL, publicKey, { origin: "https://evil.example", }); @@ -182,7 +179,6 @@ describe("PreviewPasskeys", () => { assert.deepStrictEqual(unsupported, { success: false, error: "NotSupportedError" }); assert.strictEqual(webauthn.createCredential.mock.calls.length, 1); - // A malformed algorithm list is the page's error, not a cue to pick ES256. const malformed = yield* call(PASSKEY_CREATE_CHANNEL, { ...publicKey, pubKeyCredParams: { type: "public-key", alg: -7 }, @@ -190,7 +186,6 @@ describe("PreviewPasskeys", () => { assert.deepStrictEqual(malformed, { success: false, error: "TypeError" }); assert.strictEqual(webauthn.createCredential.mock.calls.length, 1); - // Without authenticator data the site cannot verify the passkey. webauthn.createCredential.mockResolvedValueOnce({ success: true, data: { credentialId: "AQ", attestationObject: Buffer.from([0x00]).toString("base64url") }, @@ -231,7 +226,6 @@ describe("PreviewPasskeys", () => { webauthn.getCredential.mockResolvedValueOnce(assertion("AQ")); assert.deepInclude(yield* call(PASSKEY_GET_CHANNEL, publicKey), { success: true }); - // With no allow list, any of the site's credentials will do. webauthn.getCredential.mockResolvedValueOnce(assertion("Ag")); const discoverable = yield* call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]), @@ -268,7 +262,6 @@ describe("PreviewPasskeys", () => { yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); - // The page navigated while the sheet was up; its credential goes nowhere. page.mainFrame.origin = "https://other.example"; finish({ success: true, data: { credentialId: "AQ" } }); assert.deepStrictEqual(yield* Fiber.join(first), notAllowed); diff --git a/apps/desktop/src/preview/Passkeys.ts b/apps/desktop/src/preview/Passkeys.ts index c06b4dc522b5..5456aaab6a8a 100644 --- a/apps/desktop/src/preview/Passkeys.ts +++ b/apps/desktop/src/preview/Passkeys.ts @@ -37,31 +37,13 @@ export class PreviewPasskeysConfigureError extends Schema.TaggedError; - /** Lets the user choose between several passkeys for one site. Idempotent. */ readonly installSessionHandlers: (session: Session) => void; - /** Serves a preview guest's passkey ceremonies. Returns the detach function. */ readonly attachGuest: (guest: WebContents) => () => void; } >()("@t3tools/desktop/preview/Passkeys/PreviewPasskeys") {} @@ -131,8 +113,7 @@ const isTrustworthyOrigin = (origin: string) => { }; /** - * The platform that serves preview pages' passkey ceremonies, or none. Windows - * needs none: Chromium already hands WebAuthn to Windows Hello there. + * Windows needs none: Chromium already hands WebAuthn to Windows Hello there. */ const backendFor = (platform: NodeJS.Platform): PasskeyBackend | undefined => platform === "darwin" ? macPasskeyBackend : undefined; @@ -191,7 +172,6 @@ const make = Effect.gen(function* () { // be trusted for the origin. Read the committed origin before any await: // the frame object outlives a navigation. const origin = frame.origin; - // Like Chromium, only a focused page may open the sheet, one at a time. if (!isTrustworthyOrigin(origin) || ceremonyPending || !guest.isFocused()) { return notAllowed; } @@ -224,8 +204,6 @@ const make = Effect.gen(function* () { guest.ipc.removeHandler(PASSKEY_GET_CHANNEL); }; detach(); - // Option shapes are the page's to get wrong: the native layer validates - // them and answers with a TypeError. guest.ipc.handle( PASSKEY_CREATE_CHANNEL, (event, publicKey: PublicKeyCredentialCreationOptions) => diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 7b0f900f8fec..a1df630de4e9 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -14,6 +14,7 @@ import type { PreviewAnnotationSubmission, } from "@t3tools/contracts"; +import { createAnnotationIdSource } from "./AnnotationIds.ts"; import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; import { installRecordingCursor } from "./RecordingCursor.ts"; @@ -38,7 +39,6 @@ const Z_INDEX_OVERLAY = 2147483646; const PRIMARY = "var(--t3-primary)"; const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; const MAX_MARQUEE_ELEMENTS = 20; -/** Upper bound on one element's React context lookup during submit. */ const ELEMENT_CONTEXT_TIMEOUT_MS = 5_000; const CONTENT_LAYER_Z_INDEX = 1; const CHROME_LAYER_Z_INDEX = 10; @@ -134,7 +134,6 @@ interface AnnotationSession { } let activeSession: AnnotationSession | null = null; -let idSequence = 0; let annotationTheme: DesktopPreviewAnnotationTheme | null = null; const applyAnnotationTheme = ( @@ -188,7 +187,6 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { window.addEventListener("pointerdown", reportHumanPointerInput, true); window.addEventListener("keydown", reportHumanKeyInput, true); -// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. const MOUSE_BUTTON_BACK = 3; const MOUSE_BUTTON_FORWARD = 4; @@ -222,10 +220,7 @@ window.addEventListener("mousedown", suppressNavigationButton, true); window.addEventListener("mouseup", requestNavigationForButton, true); window.addEventListener("auxclick", suppressNavigationButton, true); -const nextId = (prefix: string): string => { - idSequence += 1; - return `${prefix}_${idSequence.toString(36)}`; -}; +const nextId = createAnnotationIdSource(); const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({ x: rect.left, @@ -326,7 +321,6 @@ function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { node.style.height = `${rect.height}px`; } -/** Clamps a box to the viewport and clipping ancestors, so its border is never painted off-screen. */ function visibleElementRect(element: Element): PreviewAnnotationRect { const rect = element.getBoundingClientRect(); let left = Math.max(0, rect.left); @@ -400,10 +394,9 @@ function toStackFrame(frame: { } /** - * Resolves to `null` instead of hanging when `promise` outlives `millis`. - * `getElementContext` walks the inspected page's React internals, and some - * pages leave it pending forever. Without a bound, the whole submit chain - * stalls and the overlay sits on "Capturingโ€ฆ". + * `getElementContext` walks the inspected page's React internals, and some pages leave it + * pending forever. Without a bound, the whole submit chain stalls and the overlay sits on + * "Capturingโ€ฆ". */ function withCaptureTimeout(promise: Promise, millis: number): Promise { let timer: ReturnType | undefined; @@ -415,14 +408,12 @@ function withCaptureTimeout(promise: Promise, millis: number): Promise clearTimeout(timer)); } -/** Truncation for the DOM-only preview used when React context is unavailable. */ const HTML_PREVIEW_MAX_CHARS = 500; /** - * Describes a picked element. The React context lookup can stall or throw on - * some pages, so the element is never dropped: without context it still - * carries its tag, a short HTML preview, and its rect so the crop stays on the - * pick instead of falling back to the whole viewport. + * The React context lookup can stall or throw on some pages, so the element is never dropped: + * without context it still carries its tag, a short HTML preview, and its rect so the crop + * stays on the pick instead of falling back to the whole viewport. */ async function captureElement(element: Element): Promise { const base = { @@ -448,9 +439,7 @@ async function captureElement(element: Element): Promise { styles: context.styles ?? "", }; } - } catch { - // Fall through to the DOM-only payload. - } + } catch {} return { ...base, selector: null, @@ -1033,7 +1022,6 @@ function startAnnotation(sendEnabled: boolean): void { const getAnnotationBounds = (): PreviewAnnotationRect | null => unionRects( [ - // Follow the visible outline, so a clipped selection does not anchor the editor off-screen. ...Array.from(selected.values(), (target) => visibleElementRect(target.element)).filter( (rect) => rect.width > 0 && rect.height > 0, ), @@ -1401,7 +1389,6 @@ function startAnnotation(sendEnabled: boolean): void { pendingCapture = true; submit.disabled = true; submit.textContent = "Capturingโ€ฆ"; - // Snapshot everything the annotation will carry before the capture runs. // The element context lookup can take up to its timeout, and the user can // keep editing meanwhile; the annotation must describe what they submitted. const submittedComment = comment.value.trim(); @@ -1457,9 +1444,6 @@ function startAnnotation(sendEnabled: boolean): void { ); }) .catch(() => { - // Last resort. Main is waiting on this message, so hand it an empty - // pick rather than leaving the button stuck on "Capturingโ€ฆ" and the - // renderer's pick promise pending. teardown is a no-op once finished. teardown(true); }); }; diff --git a/apps/desktop/src/preview/PickedElementPayload.ts b/apps/desktop/src/preview/PickedElementPayload.ts index e2d596120dba..1dab0e41a7b4 100644 --- a/apps/desktop/src/preview/PickedElementPayload.ts +++ b/apps/desktop/src/preview/PickedElementPayload.ts @@ -1,15 +1,3 @@ -/** - * Strict structural validator for `PickedElementPayload` messages received - * from the in-page picker preload (`apps/desktop/src/preview/PickPreload.ts`) - * via `wc.ipc`. Lives in its own electron-free module so the validator is - * trivially unit-testable. - * - * Validation must be tight: downstream `normalizeElementContextSelection` - * calls `.trim()` on incoming strings, so a malformed payload (preload bug, - * future schema mismatch, malicious page that intercepts the preload's IPC - * channel via prototype pollution) would otherwise throw deep in the - * renderer and the chip silently never appears. - */ import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts"; function isStringOrNull(value: unknown): value is string | null { diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts index 70b0a43be43a..4524b0d017b6 100644 --- a/apps/desktop/src/preview/RecordingCursor.ts +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -37,7 +37,6 @@ export function installRecordingCursor( agentCursor.setAttribute("data-t3code-recording-agent-cursor", ""); agentCursor.style.cssText = "position:fixed;left:0;top:0;width:20px;height:20px;pointer-events:none;z-index:2147483647;display:none;filter:drop-shadow(0 1px 2px #0003);transition:transform 150ms ease-out,opacity 150ms ease-out;"; - // Match the MousePointer2 icon used by the live AgentBrowserCursor. agentCursor.innerHTML = ''; document.documentElement.append(style, cursor, agentCursor); diff --git a/apps/desktop/src/preview/RecordingInput.ts b/apps/desktop/src/preview/RecordingInput.ts index a69102121d4e..388090dfff10 100644 --- a/apps/desktop/src/preview/RecordingInput.ts +++ b/apps/desktop/src/preview/RecordingInput.ts @@ -16,7 +16,6 @@ export interface RecordingKeyPress { readonly shiftKey: boolean; } -/** Formats a single chord without duplicating a modifier pressed on its own. */ export function recordingKeyLabel(input: RecordingKeyPress, isMac: boolean): string | null { if (["Dead", "Process", "Unidentified", ""].includes(input.key)) return null; const modifiers = [ diff --git a/apps/desktop/src/preview/WebviewPreferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts index 498c1df4665b..94681b3e046f 100644 --- a/apps/desktop/src/preview/WebviewPreferences.test.ts +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -2,29 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { PREVIEW_WEBVIEW_PREFERENCES } from "./WebviewPreferences.ts"; -/** - * Mirrors Electron's webview attribute parser closely enough to catch the - * regressions we've already hit: - * - * - whitespace inside the comma-separated list silently drops keys (so - * `" sandbox=true"` becomes an unknown key and Electron falls back to - * defaults โ€” re-opening the Node-leak window we closed), - * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy - * strings and assigned to a boolean preference, which silently flips - * `contextIsolation=no` to ENABLED (then react-grab can't see the React - * DevTools hook and componentName resolution always returns null). - * - * The actual Electron parser does roughly: - * - * for (const pair of webpreferences.split(',')) { - * const [key, value] = pair.split('='); - * prefs[key] = value; // value left as a string - * } - * - * then later coerces booleans via `Boolean(value)`. Replicating that here - * keeps the test independent of Electron internals while still failing if - * we accidentally ship `"contextIsolation=no"` again. - */ function parseWebPreferences(input: string): Record { const out: Record = {}; for (const pair of input.split(",")) { @@ -71,8 +48,6 @@ describe("PREVIEW_WEBVIEW_PREFERENCES", () => { }); it("contains no whitespace (Electron's parser does not trim)", () => { - // Electron splits on `,` without trimming, so any whitespace would turn - // a key into an unknown one and silently drop the security flag. expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/); }); }); diff --git a/apps/desktop/src/preview/WebviewPreferences.ts b/apps/desktop/src/preview/WebviewPreferences.ts index 085c75232b38..4236b78293ab 100644 --- a/apps/desktop/src/preview/WebviewPreferences.ts +++ b/apps/desktop/src/preview/WebviewPreferences.ts @@ -1,42 +1,9 @@ /** - * webPreferences override applied to every preview `` element via - * its `webpreferences="..."` attribute. Single source of truth so all guest - * surfaces inherit the same security posture. - * - * Lives in its own electron-free module so the value is unit-testable - * without importing `Manager.ts` (which transitively imports - * `electron` and blows up under vitest). - * - * - `contextIsolation=false`: the picker preload needs to share `globalThis` - * with the page so react-grab/bippy can read the React DevTools hook - * (`__REACT_DEVTOOLS_GLOBAL_HOOK__`) and resolve component names. Without - * this every pick comes back with `componentName: null` even on dev React - * apps. * - `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. - * In sandboxed mode Electron still synthesizes the `electron` module for - * the preload's `import { ipcRenderer }` line, but no Node globals leak. - * - `nodeIntegration=false`: pinned for clarity (the page itself never gets - * Node access). - * - * Format notes (locked down by `WebviewPreferences.test.ts`): - * - Whitespace-free. Electron's webpreferences parser splits on `,` and - * does not trim, so a leading space would turn a key into an unknown one - * and silently drop it. - * - Values are JS-boolean strings (`true`/`false`) โ€” `yes`/`no` are not - * special-cased by the parser; `value="no"` becomes the truthy STRING - * `"no"` when assigned to a boolean webPreferences key. Most critically, - * `contextIsolation="no"` is truthy โ†’ contextIsolation stays ENABLED โ†’ - * react-grab can't see the React DevTools hook. - * - * Defense in depth: `apps/desktop/src/main.ts` also runs a - * `will-attach-webview` handler that force-sets `sandbox: true` and - * `nodeIntegration*: false` on the actual webPreferences object, gated on - * the preview partition, so even if this string is ever wrong, the - * security-critical flags can't regress on preview tabs. */ 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 a8dbc8e63f05..1c131681295f 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -35,19 +35,9 @@ export interface DesktopSettings { readonly tailscaleServePort: number; readonly updateChannel: DesktopUpdateChannel; readonly updateChannelConfiguredByUser: boolean; - // Was a "local" | "wsl" swap mode in an earlier iteration of the WSL - // integration. We now run Windows and WSL backends side by side, so the - // setting is just whether the WSL backend should be running alongside the - // primary. Persisted documents that still carry the legacy `wslMode: "wsl"` - // value are migrated to `wslBackendEnabled: true` on load. readonly wslBackendEnabled: boolean; readonly wslDistro: string | null; - // When true (and wslBackendEnabled is also true) the desktop runs only - // the WSL backend as the primary, and the Windows-side Node backend is - // not started. Designed for users who develop entirely inside WSL and - // don't want a second backend process running. Defaults to false so - // existing setups stay on the parallel-backends behavior. Changing - // this requires a desktop restart because the pool's primary spec is + // Changing this requires a desktop restart because the pool's primary spec is // chosen once at layer init. readonly wslOnly: boolean; } @@ -106,9 +96,6 @@ const DesktopSettingsDocument = Schema.Struct({ tailscaleServePort: Schema.optionalKey(Schema.Number), updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), - // Newer form of the WSL toggle. `wslMode` is still accepted on load so - // existing on-disk settings keep working; on the next persist we write the - // new boolean and the legacy key drops out. wslBackendEnabled: Schema.optionalKey(Schema.Boolean), wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), @@ -224,9 +211,6 @@ function normalizeDesktopSettingsDocument( parsed.updateChannelConfiguredByUser === true || (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); - // Newer form wins when both are present; otherwise fall back to the legacy - // `wslMode === "wsl"` signal so users coming off the swap-mode build keep - // their WSL backend enabled. const wslBackendEnabled = parsed.wslBackendEnabled === true || (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); @@ -481,6 +465,7 @@ 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; diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index d6e1a210cc0a..248fb9b6f848 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -130,7 +130,6 @@ export type DesktopSavedEnvironmentsGetSecretError = | DesktopSavedEnvironmentSecretDecodeError | DesktopSavedEnvironmentSecretProtectionError; -/** Reads the previous registry for connection-catalog migration. */ export class DesktopSavedEnvironments extends Context.Service< DesktopSavedEnvironments, { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 6efe817574a3..aee5a0bc81b5 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -98,7 +98,9 @@ function runShellEnvironment(input: { const program = Effect.gen(function* () { const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; - yield* shellEnvironment.installIntoProcess; + const prepared = yield* shellEnvironment.installIntoProcess; + assert.equal(yield* shellEnvironment.installIntoProcess, prepared); + return prepared; }).pipe( Effect.provide( DesktopShellEnvironment.layer.pipe( @@ -446,7 +448,7 @@ describe("DesktopShellEnvironment", () => { USERPROFILE: "C:\\Users\\testuser", }; - yield* runShellEnvironment({ + const prepared = yield* runShellEnvironment({ env, platform: "win32", handler: (command) => { @@ -462,6 +464,7 @@ describe("DesktopShellEnvironment", () => { }, }); + assert.isTrue(prepared); assert.equal( env.PATH, [ @@ -486,6 +489,47 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect.each([false, true])( + "leaves Windows preparation incomplete when loadProfile=%s fails", + (loadProfile) => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows\\System32" }; + let calls = 0; + const prepared = yield* runShellEnvironment({ + env, + platform: "win32", + handler: (command) => { + calls++; + if (command._tag !== "StandardCommand") return ""; + return !command.args.includes("-NoProfile") === loadProfile + ? { output: "", exitCode: 1 } + : envOutput({ PATH: "C:\\Working" }); + }, + }).pipe(Effect.provide(Logger.layer([]))); + assert.isFalse(prepared); + assert.equal(calls, 3); + assert.include(env.PATH, "C:\\Working"); + assert.include(env.PATH, "C:\\Windows\\System32"); + }), + ); + + it.effect("replaces a Path-spelled Windows PATH instead of adding a second key", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { Path: "C:\\Windows\\System32" }; + + yield* runShellEnvironment({ + env, + platform: "win32", + handler: (command) => + command._tag === "StandardCommand" && command.args.includes("-NoProfile") + ? envOutput({ PATH: "C:\\Shell\\Bin" }) + : "", + }); + + assert.deepEqual(env, { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }); + }), + ); + it.effect("prefers login-shell desktop session hints over inherited values on linux", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 2b41b6ea7db9..8fa8a60b2e62 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,4 +1,4 @@ -import { listLoginShellCandidates } from "@t3tools/shared/shell"; +import { listLoginShellCandidates, setWindowsPath } from "@t3tools/shared/shell"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -64,7 +64,8 @@ export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErr export class DesktopShellEnvironment extends Context.Service< DesktopShellEnvironment, { - readonly installIntoProcess: Effect.Effect; + // Reuses the installation result; false leaves server hydration enabled. + readonly installIntoProcess: Effect.Effect; } >()("@t3tools/desktop/shell/DesktopShellEnvironment") {} @@ -365,7 +366,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWindowsEnvironment")( function* ( config: ShellEnvironmentConfig, - ): Effect.fn.Return { + ): 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 @@ -386,7 +387,7 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin ]); if (Option.isSome(mergedPath)) { - config.env.PATH = mergedPath.value; + setWindowsPath(config.env, mergedPath.value); } if (!config.env.FNM_DIR && profile.FNM_DIR) { config.env.FNM_DIR = profile.FNM_DIR; @@ -394,6 +395,7 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin if (!config.env.FNM_MULTISHELL_PATH && profile.FNM_MULTISHELL_PATH) { config.env.FNM_MULTISHELL_PATH = profile.FNM_MULTISHELL_PATH; } + return Boolean(noProfile.PATH && profile.PATH); }, ); @@ -463,7 +465,6 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", - // The telemetry opt-out is documented as a shell variable; GUI launches never see it. "T3CODE_TELEMETRY_ENABLED", ] as const) { if (!config.env[name] && shellEnvironment[name]) { @@ -514,14 +515,18 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix const installShellEnvironment = ( config: ShellEnvironmentConfig, -): Effect.Effect => { +): Effect.Effect< + boolean, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem +> => { if (config.platform === "win32") { return installWindowsEnvironment(config); } if (config.platform === "darwin" || config.platform === "linux") { - return installPosixEnvironment(config); + return installPosixEnvironment(config).pipe(Effect.as(true)); } - return Effect.void; + return Effect.succeed(false); }; /** @public Service construction is part of the canonical Effect module API. */ @@ -530,13 +535,15 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = - installShellEnvironment({ - env: process.env, - platform: environment.platform, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), + yield* Effect.cached( + installShellEnvironment({ + env: process.env, + platform: environment.platform, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), + ), ); return DesktopShellEnvironment.of({ installIntoProcess }); diff --git a/apps/desktop/src/snapShot/ActiveWindow.ts b/apps/desktop/src/snapShot/ActiveWindow.ts index 0d564f10f855..f62669a39bf3 100644 --- a/apps/desktop/src/snapShot/ActiveWindow.ts +++ b/apps/desktop/src/snapShot/ActiveWindow.ts @@ -7,9 +7,8 @@ import * as Schema from "effect/Schema"; import { loadWindowsForegroundApi } from "../electron/WindowsForeground.ts"; /** - * The foreground window as the snapshot service needs it. `id` is the - * CGWindowNumber on macOS and the HWND on Windows, which is what the capture - * backends key on. + * `id` is the CGWindowNumber on macOS and the HWND on Windows, which is what + * the capture backends key on. */ export type ActiveWindow = { readonly platform: "macos" | "windows"; @@ -49,9 +48,6 @@ const MacActiveWindow = Schema.Struct({ }); const decodeMacActiveWindow = Schema.decodeUnknownSync(Schema.fromJsonString(MacActiveWindow)); -// The frontmost app's first on-screen, layer-0 window in front-to-back order is -// the active window. Window titles need Screen Recording, which the snapshot -// service has already requested by the time this runs. const MAC_LOOKUP_SCRIPT = ` ObjC.import("CoreGraphics"); ObjC.import("AppKit"); @@ -135,7 +131,6 @@ async function windowsActiveWindow(): Promise { }; } -/** Resolve the OS foreground window, or `undefined` when there is none. */ export function activeWindow(platform: NodeJS.Platform): Promise { if (platform === "darwin") return macActiveWindow(); if (platform === "win32") return windowsActiveWindow(); diff --git a/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts b/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts index 248a6874e48f..ea6e176b8204 100644 --- a/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts +++ b/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts @@ -156,6 +156,87 @@ it("leaves already configured files unchanged, without creating a backup", async expect(await setup.apply(preview.id, "niri")).toEqual({ backupPath: null, warning: null }); expect(await NodeFSP.readdir(directory)).toEqual(["config.kdl"]); }); +it("accepts an unchanged capture shortcut reported as a Lua callback", async () => { + const hypr = { + ...target(), + desktop: "hyprland" as const, + path: NodePath.join(directory, "bindings.lua"), + }; + const before = `${captureConfigBinding("hyprland-lua", appId, "Ctrl+Shift+2")}\n`; + await NodeFSP.writeFile(hypr.path, before); + tools.hyprlandBindings.mockResolvedValue([ + { modmask: 5, key: "2", dispatcher: "__lua", arg: "92" }, + ]); + + const preview = await setup.preview(hypr, install); + expect(preview.after).toBe(before); + expect(preview.shortcut).toBe("Ctrl+Shift+2"); + expect(await setup.apply(preview.id, "hyprland")).toEqual({ backupPath: null, warning: null }); + expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe(before); + expect((await NodeFSP.readdir(directory)).sort()).toEqual(["bindings.lua", "config.kdl"]); + expect(tools.reloadHyprland).not.toHaveBeenCalled(); + + const unchanged = await setup.preview(hypr, install); + await NodeFSP.appendFile(hypr.path, "-- edited elsewhere\n"); + await expect(setup.apply(unchanged.id, "hyprland")).rejects.toThrow("changed since"); + expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe(before + "-- edited elsewhere\n"); +}); +it.each(["preview", "apply"] as const)( + "checks unchanged Hyprland .conf shortcuts for conflicts during %s", + async (stage) => { + const hypr = { + ...target(), + desktop: "hyprland" as const, + path: NodePath.join(directory, "hyprland.conf"), + }; + const before = `${captureConfigBinding("hyprland", appId, "Ctrl+Shift+2")}\n`; + await NodeFSP.writeFile(hypr.path, before); + const ownBinding = { + modmask: 5, + key: "2", + dispatcher: "global", + arg: `${appId}:capture-window`, + }; + tools.hyprlandBindings.mockResolvedValue([ownBinding]); + const unchanged = await setup.preview(hypr, install); + expect(unchanged.after).toBe(before); + expect(await setup.apply(unchanged.id, "hyprland")).toEqual({ + backupPath: null, + warning: null, + }); + + const preview = stage === "apply" ? await setup.preview(hypr, install) : undefined; + tools.hyprlandBindings.mockResolvedValue([ + ownBinding, + { modmask: 5, key: "2", dispatcher: "exec", arg: "kitty" }, + ]); + await expect( + preview ? setup.apply(preview.id, "hyprland") : setup.preview(hypr, install), + ).rejects.toThrow("already used"); + expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe(before); + expect((await NodeFSP.readdir(directory)).sort()).toEqual(["config.kdl", "hyprland.conf"]); + expect(tools.reloadHyprland).not.toHaveBeenCalled(); + }, +); +it("rejects changing an existing capture shortcut to keys used by another Lua callback", async () => { + const hypr = { + ...target(), + desktop: "hyprland" as const, + path: NodePath.join(directory, "bindings.lua"), + }; + const before = `${captureConfigBinding("hyprland-lua", appId, "Ctrl+Alt+Y")}\n`; + await NodeFSP.writeFile(hypr.path, before); + const request = { ...install, shortcut: "Ctrl+Shift+2" }; + const occupied = [{ modmask: 5, key: "2", dispatcher: "__lua", arg: "93" }]; + tools.hyprlandBindings.mockResolvedValue(occupied); + await expect(setup.preview(hypr, request)).rejects.toThrow("already used"); + + tools.hyprlandBindings.mockResolvedValue([]); + const preview = await setup.preview(hypr, request); + tools.hyprlandBindings.mockResolvedValue(occupied); + await expect(setup.apply(preview.id, "hyprland")).rejects.toThrow("already used"); + expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe(before); +}); it("detects conflicts in included Niri configs without changing either file", async () => { await NodeFSP.appendFile(path, 'include "keys.kdl"\n'); await NodeFSP.writeFile(NodePath.join(directory, "keys.kdl"), "binds { Shift+Ctrl+2 { quit; } }"); @@ -178,7 +259,7 @@ it("rejects a conflict from another Hyprland config or dynamic Lua bind", async }; await NodeFSP.writeFile(hypr.path, "-- user bindings\n"); tools.hyprlandBindings.mockResolvedValue([ - { modmask: 5, key: "2", dispatcher: "exec", arg: "kitty" }, + { modmask: 5, key: "2", dispatcher: "__lua", arg: "92" }, ]); await expect(setup.preview(hypr, install)).rejects.toThrow("already used"); expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe("-- user bindings\n"); @@ -245,7 +326,6 @@ it("the installed Niri parser accepts the generated edit and its relative includ } throw error; } - // No compositor is launched: validate only parses the temporary fixture. await NodeFSP.appendFile(path, 'include "keys.kdl"\n'); await NodeFSP.writeFile( NodePath.join(directory, "keys.kdl"), diff --git a/apps/desktop/src/snapShot/CaptureShortcutConfig.ts b/apps/desktop/src/snapShot/CaptureShortcutConfig.ts index 8b86b416cdba..abf85da4f107 100644 --- a/apps/desktop/src/snapShot/CaptureShortcutConfig.ts +++ b/apps/desktop/src/snapShot/CaptureShortcutConfig.ts @@ -95,7 +95,13 @@ const defaultTools = { /** The renderer can apply only the exact proposal retained by the desktop process. */ export class CaptureShortcutConfig { private pending: - | { preview: DesktopCaptureConfigPreview; target: Target; files: Snapshot[]; missing: string[] } + | { + preview: DesktopCaptureConfigPreview; + target: Target; + files: Snapshot[]; + missing: string[]; + checkHyprlandKeys: boolean; + } | undefined; private applying = false; private readonly home: string; @@ -153,6 +159,10 @@ export class CaptureShortcutConfig { request.operation, request.shortcut, ); + // Lua bindings appear as opaque callbacks, including our own unchanged shortcut. + const checkHyprlandKeys = + request.operation === "install" && + (format === "hyprland" || (format === "hyprland-lua" && edit.after !== root.text)); const files = [root]; const missing: string[] = []; if (target.desktop === "niri") { @@ -195,7 +205,7 @@ export class CaptureShortcutConfig { } }; await visit(root, 0); - } else if (request.operation === "install") { + } else if (checkHyprlandKeys) { await this.checkHyprlandKeys(target.appId, edit.shortcut); } const preview = { @@ -207,7 +217,7 @@ export class CaptureShortcutConfig { shortcut: edit.shortcut, operation: request.operation, }; - this.pending = { preview, target, files, missing }; + this.pending = { preview, target, files, missing, checkHyprlandKeys }; return preview; } @@ -255,7 +265,7 @@ export class CaptureShortcutConfig { } }; await unchanged(); - if (desktop === "hyprland" && preview.operation === "install") + if (pending.checkHyprlandKeys) await this.checkHyprlandKeys(pending.target.appId, preview.shortcut); if (preview.before === preview.after) return { backupPath: null, warning: null }; await NodeFSP.writeFile(temporary, preview.after, { diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 485ed2aaa0f7..d2520ee03d2f 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -429,8 +429,6 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopSnapShot from "./DesktopSnapShot.ts"; import * as SnapShotAccessibility from "./SnapShotAccessibility.ts"; -// The accessibility reader normally runs in a worker with the real xa11y `App`. -// Tests hand it this stand-in so the mocks above drive window lookups. const accessibilityApp = { byPid: accessibilityByPidMock, list: accessibilityListMock, @@ -500,6 +498,7 @@ const layerTest = ( DesktopEnvironment.DesktopEnvironment, DesktopEnvironment.DesktopEnvironment.of({ platform, + displayName: "T3 Code (Dev)", stateDir: "/state", linuxDesktopEntryName: "com.t3tools.T3Code.desktop", appRoot: "/repo", @@ -871,6 +870,69 @@ it.effect("captures the active Windows window without enumerating desktop source ).pipe(Effect.provide(layer)); }); +it.effect.each(["darwin", "win32"] as const)( + "drops shortcut bursts during flight and accepts a fresh capture after landing on %s", + (platform) => { + const fixture = concurrentCaptureFixture(platform, true); + fixture.releaseAll(); + const flying = Promise.withResolvers(); + const heldFlights: Array<() => void> = []; + const push = heldFlights.push.bind(heldFlights); + vi.spyOn(heldFlights, "push").mockImplementation((...resolvers) => { + const count = push(...resolvers); + flying.resolve(); + return count; + }); + transitionScriptState.heldFlights = heldFlights; + return Effect.scoped( + Effect.gen(function* () { + const service = yield* DesktopSnapShot.make; + yield* service.configure(fixture.settings); + yield* Effect.promise(fixture.trigger); + const firstId = fixture.readyIds[0]!; + const landing = yield* service + .setAnimationDestination(firstId, { + frame: { x: 100, y: 400, width: 208, height: 112 }, + backgroundColor: "#fff", + borderColor: "#ccc", + borderWidth: 1, + cornerRadius: 8, + scaleFactor: 1, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => flying.promise); + const overlayCount = flashWindows.length; + + for (let repeat = 0; repeat < 20; repeat++) { + yield* TestClock.adjust("200 millis"); + yield* Effect.promise(fixture.trigger); + } + assert.equal(fixture.state.snapshots, 1); + assert.lengthOf(fixture.readyIds, 1); + assert.lengthOf(flashWindows, overlayCount); + assert.isTrue(flashWindows.every((window) => !window.destroyed)); + + 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); + assert.isTrue(fixture.second.oldOverlaysCleared); + }), + ).pipe( + Effect.provide(fixture.layer), + Effect.ensuring( + Effect.sync(() => { + for (const finish of heldFlights) finish(); + transitionScriptState.heldFlights = null; + fixture.reset(); + }), + ), + ); + }, +); + it.effect.each([ { length: 1_000, suffix: "", expectedLength: 1_000 }, { length: 1_001, suffix: "", expectedLength: 1_000 }, @@ -2205,7 +2267,9 @@ it("does not flash a dismissed overlay after its initial compositor receipt arri true, ); await readingFrame.promise; + assert.isFalse(transition.isAnimating); transition.dismiss("capture-1"); + assert.isFalse(transition.isAnimating); frame.resolve(); await begin; @@ -2294,7 +2358,9 @@ it("does not start a dismissed transition after its compositor receipt arrives", }); const landing = transition.waitForLanding("capture-1"); await readingFrame.promise; + assert.isTrue(transition.isAnimating); transition.dismiss("capture-1"); + assert.isFalse(transition.isAnimating); frame.resolve(); await landing; @@ -2426,6 +2492,7 @@ it("does not let a failed transition fail landing or capture completion", async }); await transition.waitForLanding("capture-1"); + assert.isFalse(transition.isAnimating); await transition.complete("capture-1"); assert.isTrue(flashWindows[0]?.destroyed); @@ -3895,6 +3962,10 @@ it.effect.each([false, true])( if (fails) { const error = yield* service.setup("test-mac-capture").pipe(Effect.flip); assert.equal(error.reason, "setup-failed"); + assert.equal( + error.message, + 'Could not capture a test snapshot. Capturing the window failed. macOS grants Screen Recording and Accessibility to each app separately. In System Settings, open Privacy & Security and check that "T3 Code (Dev)" is allowed for both.', + ); } else yield* service.setup("test-mac-capture"); assert.equal(macCaptureMock.mock.calls.length, 1); assert.equal(macCaptureMock.mock.calls[0]?.[0], active); @@ -3919,12 +3990,88 @@ it.effect.each([false, true])( }, ); +const macTestCaptureLayer = layerTest("darwin", { + makeTempDirectoryScoped: () => Effect.succeed("/tmp/setup-test"), + readDirectory: () => Effect.succeed([]), +}); +const macTestCaptureWindow = { + platform: "macos", + id: 42, + title: "Setup", + owner: { name: "T3 Code", processId: 123, path: "/Applications/T3 Code.app" }, + bounds: { x: 0, y: 0, width: 800, height: 600 }, +}; + +it.effect("names the failed macOS window lookup without repeating what it printed", () => { + const script = 'ObjC.import("CoreGraphics"); function run() { return ""; }'; + const stderr = "execution error: Error: Not authorized to send Apple events. (-1743)"; + activeWindowMock + .mockReset() + .mockRejectedValue( + Object.assign( + new Error(`Command failed: /usr/bin/osascript -l JavaScript -e ${script}\n${stderr}`), + { cmd: `/usr/bin/osascript -l JavaScript -e ${script}`, code: 1, killed: false }, + ), + ); + macCaptureMock.mockReset(); + return Effect.scoped( + Effect.gen(function* () { + const service = yield* DesktopSnapShot.make; + const error = yield* service.setup("test-mac-capture").pipe(Effect.flip); + assert.equal( + error.message, + 'Could not capture a test snapshot. Finding the front window failed: osascript exited with code 1. macOS grants Screen Recording and Accessibility to each app separately. In System Settings, open Privacy & Security and check that "T3 Code (Dev)" is allowed for both.', + ); + assert.include(String(error.cause), stderr); + assert.equal(macCaptureMock.mock.calls.length, 0); + }), + ).pipe(Effect.provide(macTestCaptureLayer)); +}); + +it.effect("says when macOS has no window to test capture", () => { + activeWindowMock.mockReset().mockResolvedValue(undefined); + macCaptureMock.mockReset(); + return Effect.scoped( + Effect.gen(function* () { + const service = yield* DesktopSnapShot.make; + const error = yield* service.setup("test-mac-capture").pipe(Effect.flip); + assert.equal( + error.message, + "Could not capture a test snapshot. No window is available to capture.", + ); + assert.equal(macCaptureMock.mock.calls.length, 0); + }), + ).pipe(Effect.provide(macTestCaptureLayer)); +}); + +it.effect("says when the macOS test capture command timed out", () => { + activeWindowMock.mockReset().mockResolvedValue(macTestCaptureWindow); + macCaptureMock.mockReset().mockRejectedValue( + Object.assign(new Error("Command failed: /usr/sbin/screencapture -l 42 -o -x"), { + code: null, + killed: true, + signal: "SIGTERM", + }), + ); + return Effect.scoped( + Effect.gen(function* () { + const service = yield* DesktopSnapShot.make; + const error = yield* service.setup("test-mac-capture").pipe(Effect.flip); + assert.equal( + error.message, + 'Could not capture a test snapshot. Capturing the window timed out. macOS grants Screen Recording and Accessibility to each app separately. In System Settings, open Privacy & Security and check that "T3 Code (Dev)" is allowed for both.', + ); + }), + ).pipe(Effect.provide(macTestCaptureLayer)); +}); + it.effect("rejects macOS test capture on other platforms", () => Effect.scoped( Effect.gen(function* () { const service = yield* DesktopSnapShot.make; const error = yield* service.setup("test-mac-capture").pipe(Effect.flip); assert.equal(error.reason, "unsupported-session"); + assert.equal(error.message, "Test capture requires macOS."); }), ).pipe(Effect.provide(layerTest("win32"))), ); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 7585c1c18879..539d9019bd52 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -30,6 +30,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Predicate from "effect/Predicate"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -187,7 +188,6 @@ export class DesktopSnapShot extends Context.Service< shortcut: SnapShotShortcut, ) => Effect.Effect; readonly setShortcutSuppressed: (suppressed: boolean) => Effect.Effect; - /** Capture the foreground window in place, including T3 Code itself. */ readonly capture: Effect.Effect; readonly listPending: Effect.Effect< ReadonlyArray, @@ -211,10 +211,36 @@ export class DesktopSnapShotSetupError extends Schema.TaggedError, "prefersReducedMotion" | "shouldRenderRichAnimation" @@ -770,7 +808,6 @@ export const make = Effect.gen(function* () { path.join(__dirname, "snapShot", "RegionSnapShotWorker.cjs"), ); let registeredAccelerator: string | undefined; - // False until the first applySettings; the first pass must always register. let initialized = false; let portalShortcut: PortalCaptureShortcut | undefined; let shortcutGeneration = 0; @@ -925,7 +962,6 @@ export const make = Effect.gen(function* () { const appIconDataUrl = yield* Effect.promise(() => iconDataUrl(source, active, environment.platform), ); - // Native labels are unbounded; keep a valid screenshot when its metadata is too long. const appIdentifier = boundedSnapShotString( active?.platform === "macos" ? active.owner.bundleId : linuxWindow?.appIdentifier, 255, @@ -971,8 +1007,6 @@ export const make = Effect.gen(function* () { if (!settings.snapShotEnabled) { return yield* new DesktopSnapShotError({ operation: "disabled" }); } - // Only source acquisition and the initial handoff require exclusive access. - // Each captured image can finish its own accessibility read and persistence. const prepared = yield* prepareCapture(settings).pipe( Effect.tapError((error) => (error.captureId ? discardCapture(error.captureId) : Effect.void).pipe( @@ -999,7 +1033,9 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.snapShot.capture")); const captureFromShortcut = Effect.gen(function* () { - if (shortcutSuppressed) return; + // Repeated shortcuts must not tear down a moving capture and start another + // renderer. Drop them while the flight runs, just as during pixel acquisition. + if (shortcutSuppressed || transition.isAnimating) return; shortcutVerified = true; const now = yield* Clock.currentTimeNanos; if (lastShortcutAt !== undefined && now - lastShortcutAt < SHORTCUT_COOLDOWN_NS) return; @@ -1328,27 +1364,52 @@ export const make = Effect.gen(function* () { if (action === "test-mac-capture") { if (environment.platform !== "darwin") return yield* new DesktopSnapShotSetupError({ action, reason: "unsupported-session" }); - // Exercise the real capture path during setup without attaching a snapshot - // or running capture feedback. The temporary image is discarded on failure too. yield* Effect.scoped( Effect.gen(function* () { - const directory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-snapshot-test-", - }); - yield* Effect.tryPromise(async () => { - const active = await activeWindow("darwin"); - if (!active) throw new Error("No window is available to test capture."); - await captureMacWindowSnapshot( - active, - path.join(directory, "test.png"), - snapShotThumbnailSize(active), + const directory = yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3-snapshot-test-" }) + .pipe( + Effect.mapError( + (cause) => new DesktopSnapShotSetupError({ action, reason: "setup-failed", cause }), + ), ); + const active = yield* Effect.tryPromise({ + try: () => activeWindow("darwin"), + catch: (cause) => + new DesktopSnapShotSetupError({ + action, + reason: "setup-failed", + step: "find-window", + ...macCommandFailure(cause), + appName: environment.displayName, + cause, + }), + }); + if (!active) + return yield* new DesktopSnapShotSetupError({ + action, + reason: "setup-failed", + step: "find-window", + status: "missing", + }); + yield* Effect.tryPromise({ + try: () => + captureMacWindowSnapshot( + active, + path.join(directory, "test.png"), + snapShotThumbnailSize(active), + ), + catch: (cause) => + new DesktopSnapShotSetupError({ + action, + reason: "setup-failed", + step: "capture-window", + ...macCommandFailure(cause), + appName: environment.displayName, + cause, + }), }); }), - ).pipe( - Effect.mapError( - (cause) => new DesktopSnapShotSetupError({ action, reason: "setup-failed", cause }), - ), ); return; } else if (action === "install-kde-helper" || action === "remove-kde-helper") { @@ -1540,9 +1601,6 @@ export const make = Effect.gen(function* () { ) : environment.platform === "darwin" ? Effect.gen(function* () { - // Permissions can change in System Settings at any time. Surface a - // revocation on every read, and re-register the shortcut once a - // previously missing permission is granted again. const settings = yield* Ref.get(settingsRef); const macPermissions = currentMacPermissions(); const message = settings.snapShotEnabled @@ -1564,7 +1622,6 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((state) => Effect.gen(function* () { - // Keep the session identity available even when its capability probe fails. const linuxDesktop = state.mode === "portal" ? process.env.XDG_CURRENT_DESKTOP?.toLowerCase() diff --git a/apps/desktop/src/snapShot/GlobalShiftShortcutWorker.ts b/apps/desktop/src/snapShot/GlobalShiftShortcutWorker.ts index a8c0e0f79ac4..78f41968ba63 100644 --- a/apps/desktop/src/snapShot/GlobalShiftShortcutWorker.ts +++ b/apps/desktop/src/snapShot/GlobalShiftShortcutWorker.ts @@ -1,7 +1,4 @@ // @effect-diagnostics globalTimers:off -- A plain Node child; the poll runs outside any Effect fiber. -// Windows modifier-pair listener. Runs in a forked Node-mode child so a stuck or -// crashed FFI call cannot take the main process with it. Mirrors the macOS -// poller: sample both physical keys at 20 Hz, fire on the rising edge. import { SNAP_SHOT_MODIFIERS, type SnapShotModifier } from "@t3tools/contracts"; import { loadWindowsForegroundApi } from "../electron/WindowsForeground.ts"; diff --git a/apps/desktop/src/snapShot/GnomeCaptureSetup.test.ts b/apps/desktop/src/snapShot/GnomeCaptureSetup.test.ts index 0c89f332171d..af2052c6a03a 100644 --- a/apps/desktop/src/snapShot/GnomeCaptureSetup.test.ts +++ b/apps/desktop/src/snapShot/GnomeCaptureSetup.test.ts @@ -118,12 +118,13 @@ it("refuses to replace symlinks or downgrade a newer extension", async () => { it.each([ [{}, "not-installed"], - [{ state: 2, version: 2 }, "disabled"], - [{ state: 1, version: 2 }, "enabled"], + [{ state: 2, version: 3 }, "disabled"], + [{ state: 1, version: 3 }, "enabled"], [{ state: 1, version: 1 }, "update-required"], - [{ state: 2, version: 2, enabled: false }, "extensions-disabled"], - [{ state: 3, version: 2, error: "Load failed" }, "error"], - [{ shell: "51.0" }, "unsupported"], + [{ state: 1, version: 2, shell: "51.0" }, "update-required"], + [{ state: 2, version: 3, enabled: false }, "extensions-disabled"], + [{ state: 3, version: 3, error: "Load failed" }, "error"], + [{ shell: "52.0" }, "unsupported"], ] as const)("reports GNOME setup state %j", async (options, status) => { const { setup, call } = fixture(options); expect((await setup.state()).status).toBe(status); @@ -141,7 +142,7 @@ it("requires login for local installs and updates until the new version is loade expect((await setup.state()).status).toBe("restart-required"); setup.close(); } - const { setup } = fixture({ state: 2, version: 2 }); + const { setup } = fixture({ state: 2, version: 3 }); expect((await setup.state()).status).toBe("disabled"); setup.close(); }); @@ -157,7 +158,7 @@ it("installs only on explicit action without invoking remote installation", asyn }); it("enables and disables only the capture UUID", async () => { - const { setup, call } = fixture({ state: 2, version: 2 }); + const { setup, call } = fixture({ state: 2, version: 3 }); await setup.perform("enable-extension"); await setup.perform("disable-extension"); for (const member of ["EnableExtension", "DisableExtension"]) { @@ -172,14 +173,14 @@ it("enables and disables only the capture UUID", async () => { }); it("never changes the global user-extensions preference", async () => { - const { setup, call } = fixture({ state: 2, version: 2, enabled: false }); + const { setup, call } = fixture({ state: 2, version: 3, enabled: false }); await expect(setup.perform("enable-extension")).rejects.toThrow("GNOME has disabled"); expect(call.mock.calls.every(([message]) => message.member.startsWith("Get"))).toBe(true); setup.close(); }); it("surfaces desktop rejection and disconnect as actionable failures", async () => { - const { setup, bus } = fixture({ state: 2, version: 2, accepted: false }); + const { setup, bus } = fixture({ state: 2, version: 3, accepted: false }); await expect(setup.perform("enable-extension")).rejects.toThrow("Sign out"); bus.emit("error", new Error("Session bus disconnected")); expect(await setup.state()).toEqual({ status: "error", message: "Session bus disconnected" }); diff --git a/apps/desktop/src/snapShot/GnomeCaptureSetup.ts b/apps/desktop/src/snapShot/GnomeCaptureSetup.ts index 429a9986d94c..cc4ada0d57ab 100644 --- a/apps/desktop/src/snapShot/GnomeCaptureSetup.ts +++ b/apps/desktop/src/snapShot/GnomeCaptureSetup.ts @@ -35,7 +35,6 @@ const decodeMetadata = Schema.decodeUnknownSync(Schema.fromJsonString(Metadata)) type SetupPaths = { readonly bundle: string; readonly dataHome: string }; -/** Copies only the shipped extension, offline. Replaced versions are kept for recovery. */ export async function installGnomeCaptureBundle({ bundle, dataHome }: SetupPaths) { const metadata = decodeMetadata( await NodeFSP.readFile(NodePath.join(bundle, "metadata.json"), "utf8"), @@ -86,7 +85,6 @@ export async function installGnomeCaptureBundle({ bundle, dataHome }: SetupPaths } } -/** A short-lived, read-only probe unless the user explicitly invokes an action. */ export class GnomeCaptureSetup { private readonly disconnected: Promise; private readonly paths: SetupPaths; diff --git a/apps/desktop/src/snapShot/KdeSnapShot.ts b/apps/desktop/src/snapShot/KdeSnapShot.ts index 9b8c8bb90dc0..5a90aa818fac 100644 --- a/apps/desktop/src/snapShot/KdeSnapShot.ts +++ b/apps/desktop/src/snapShot/KdeSnapShot.ts @@ -95,7 +95,6 @@ async function regularFile(path: string): Promise { return NodeFSP.readFile(path); } -/** No install on launch: writes happen only after the user chooses Install helper. */ export class KdeCaptureSetup { private readonly paths: KdeCapturePaths; constructor(paths: KdeCapturePaths) { @@ -144,7 +143,6 @@ export class KdeCaptureSetup { throw new Error( "Another desktop entry uses the capture helper's name. Rename it before continuing.", ); - // Never overwrite/follow a symlink, including the installation directory itself. const directory = NodePath.dirname(executable); const existing = await NodeFSP.lstat(directory).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; @@ -186,7 +184,6 @@ export class KdeCaptureSetup { 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. - // Non-systemd sessions use the direct refresh and atomic directory notification above. await run("systemd-run", [ "--user", "--quiet", diff --git a/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts b/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts index dca715a4a8af..1cb75134ad07 100644 --- a/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts +++ b/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts @@ -94,7 +94,7 @@ it.runIf(hasDbus)("captures through real D-Bus marshalling on a private bus", as } else if (message.member === "GetExtensionInfo") { server!.send( Message.newMethodReturn(message, "a{sv}", [ - { state: new Variant("d", shellExtensionState), version: new Variant("d", 2) }, + { state: new Variant("d", shellExtensionState), version: new Variant("d", 3) }, ]), ); } else if (message.member === "EnableExtension" || message.member === "DisableExtension") { @@ -224,7 +224,6 @@ it.runIf(hasDbus)("captures through real D-Bus marshalling on a private bus", as "--method", "com.t3tools.SnapShot.Capture", ]; - // Exercise the actual command copied to Niri's configuration, including gdbus introspection. await new Promise((resolve, reject) => { NodeChildProcess.execFile( "gdbus", diff --git a/apps/desktop/src/snapShot/LinuxSnapShot.ts b/apps/desktop/src/snapShot/LinuxSnapShot.ts index dffd2a71f36c..8603232e7e9f 100644 --- a/apps/desktop/src/snapShot/LinuxSnapShot.ts +++ b/apps/desktop/src/snapShot/LinuxSnapShot.ts @@ -172,7 +172,6 @@ export class LinuxCaptureConnection { }); if (kde !== undefined && decodeKde(kde).Version.value >= 2) return "kde"; } - // Sandboxed apps already have an identity. Host apps register before any portal calls. if (!process.env.FLATPAK_ID && !process.env.SNAP) { await this.call({ destination: PORTAL, @@ -297,7 +296,6 @@ export class LinuxCaptureConnection { }), ); } - // Closing this dedicated connection also removes the signal match. } } diff --git a/apps/desktop/src/snapShot/NativeCaptureFeedback.ts b/apps/desktop/src/snapShot/NativeCaptureFeedback.ts index a9211b89a3f7..6b5775633f0b 100644 --- a/apps/desktop/src/snapShot/NativeCaptureFeedback.ts +++ b/apps/desktop/src/snapShot/NativeCaptureFeedback.ts @@ -40,7 +40,6 @@ export async function startNativeCaptureFeedback( ready.resolve(undefined); landed.resolve(); child.stdin.end('{"command":"close"}\n'); - // Kill only our own child if the compositor fails to acknowledge cancellation. killTimer = setTimeout(() => child.kill(), 1000); killTimer.unref(); }; diff --git a/apps/desktop/src/snapShot/NiriCaptureShortcut.ts b/apps/desktop/src/snapShot/NiriCaptureShortcut.ts index bae9c2b962af..7746413cabea 100644 --- a/apps/desktop/src/snapShot/NiriCaptureShortcut.ts +++ b/apps/desktop/src/snapShot/NiriCaptureShortcut.ts @@ -6,7 +6,6 @@ import { NIRI_CAPTURE_PATH as PATH, } from "./linuxCaptureSession.ts"; -/** Niri owns the keybinding; this endpoint triggers capture without first focusing T3. */ export async function startNiriCaptureShortcut( appId: string, onCapture: () => void, diff --git a/apps/desktop/src/snapShot/NiriSnapShot.test.ts b/apps/desktop/src/snapShot/NiriSnapShot.test.ts index c25030650afd..313d335d3886 100644 --- a/apps/desktop/src/snapShot/NiriSnapShot.test.ts +++ b/apps/desktop/src/snapShot/NiriSnapShot.test.ts @@ -61,7 +61,6 @@ beforeEach(async () => { else if (typeof request !== "string" && request.Action.ScreenshotWindow) { const screenshot = request.Action.ScreenshotWindow; capturePath = screenshot.path; - // Focus may change between discovery and capture. The request must keep the original ID. focused = { ...window, id: 99, title: "Another app" }; await NodeFSP.writeFile(screenshot.path, png); for (const event of events) { diff --git a/apps/desktop/src/snapShot/NiriSnapShot.ts b/apps/desktop/src/snapShot/NiriSnapShot.ts index 87133aded548..c1384d581bf6 100644 --- a/apps/desktop/src/snapShot/NiriSnapShot.ts +++ b/apps/desktop/src/snapShot/NiriSnapShot.ts @@ -48,7 +48,6 @@ const isWindowChanged = Schema.is(WindowChanged); const isWindowClosed = Schema.is(WindowClosed); const isScreenshotCaptured = Schema.is(ScreenshotCaptured); -/** A session hint, never a probe of another compositor or a sandbox escape. */ export function niriSocketPath(env = process.env): string | undefined { if (env.FLATPAK_ID || env.SNAP) return undefined; if (!env.XDG_CURRENT_DESKTOP?.split(":").some((name) => name.toLowerCase() === "niri")) @@ -56,7 +55,6 @@ export function niriSocketPath(env = process.env): string | undefined { return env.NIRI_SOCKET && NodePath.isAbsolute(env.NIRI_SOCKET) ? env.NIRI_SOCKET : undefined; } -/** One bounded exchange or event subscription. All waiters end when the socket closes. */ class NiriConnection { private readonly socket: NodeNet.Socket; private readonly listeners = new Set<(value: unknown) => void>(); diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts index 67794846f3dc..acd9a29b3220 100644 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts +++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts @@ -90,7 +90,6 @@ it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)( ]; results = { shortcuts: new Variant("a(sa{sv})", bound) }; } - // Exercise the fast-portal race: Response is delivered before the method's handle reply. const response = Message.newSignal( handle, "org.freedesktop.portal.Request", diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts index 7487e2f582ba..ec42af46a388 100644 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts +++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts @@ -54,13 +54,11 @@ export interface PortalShortcutState { readonly shortcutActionRegistered?: boolean; readonly shortcutRegistered: boolean; readonly shortcutPending: boolean; - /** Whether retry-shortcut can reopen permissions or start a new session. */ readonly shortcutCanRetry?: boolean; readonly shortcutLabel?: string; readonly shortcutMessage: string | null; } -/** Own the portal session, including consent and release; Electron only reports submission. */ export class PortalCaptureShortcut { state: PortalShortcutState = { shortcutRegistered: false, @@ -117,15 +115,12 @@ export class PortalCaptureShortcut { try { if (this.pending) this.closeObject(this.pending.path, REQUEST); if (this.session) this.closeObject(this.session, SESSION); - } catch { - // A broken bus is already closing its sessions; local cleanup must still run. - } + } catch {} this.bus.removeListener("message", this.message); this.responses.clear(); this.bus.disconnect(); }; - /** A previously denied binding is changed by the desktop, not by bypassing its decision. */ get hasSession() { return !this.closed && Boolean(this.session); } @@ -161,7 +156,6 @@ export class PortalCaptureShortcut { this.update({ shortcutRegistered: false, shortcutPending: false, - // This failed session is closing, so retry can register a fresh one. shortcutCanRetry: !this.managedByHyprland, shortcutMessage: this.managedByHyprland ? "Couldn't connect to Hyprland shortcuts. Make sure xdg-desktop-portal-hyprland is running, then restart T3 Code." diff --git a/apps/desktop/src/snapShot/RegionSnapShot.ts b/apps/desktop/src/snapShot/RegionSnapShot.ts index 46da3f78f985..c640198524af 100644 --- a/apps/desktop/src/snapShot/RegionSnapShot.ts +++ b/apps/desktop/src/snapShot/RegionSnapShot.ts @@ -55,10 +55,6 @@ export type RegionSnapShotProcess = { readonly close: () => void; }; -/** - * One capture child. `warm` it ahead of time so the native module is loaded - * before the shortcut fires; each child serves a single capture and exits. - */ export function startRegionSnapShotProcess( workerPath: string, fork: (workerPath: string) => RegionSnapShotChild = forkRegionSnapShotChild, diff --git a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts index fcc7b56259a7..374745c96e60 100644 --- a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts +++ b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts @@ -1,5 +1,5 @@ -// Windows region capture. Runs in a forked Node-mode child so the native -// screenshot call cannot crash or stall the main process. One request per child. +// Runs in a forked Node-mode child so the native screenshot call cannot crash +// or stall the main process. import type { RegionSnapShotRequest, RegionSnapShotResult } from "./RegionSnapShot.ts"; process.once("disconnect", () => process.exit(0)); diff --git a/apps/desktop/src/snapShot/SnapShotTransition.ts b/apps/desktop/src/snapShot/SnapShotTransition.ts index 01532d0e26c4..31eae474f87c 100644 --- a/apps/desktop/src/snapShot/SnapShotTransition.ts +++ b/apps/desktop/src/snapShot/SnapShotTransition.ts @@ -17,7 +17,6 @@ type SnapShotAnimationDetails = { export type SnapShotAnimationDestination = { readonly frame: Electron.Rectangle; - /** Unit coordinates in T3's content area; GNOME supplies the real compositor origin. */ readonly relativeFrame?: Electron.Rectangle | undefined; readonly backgroundColor: string; readonly borderColor: string; @@ -53,6 +52,7 @@ type ActiveTransition = { details?: SnapShotAnimationDetails | undefined; timer?: Fiber.Fiber | undefined; flight?: Promise | undefined; + landed?: boolean | undefined; }; type SnapShotTransitionOptions = { @@ -207,6 +207,10 @@ export class SnapShotTransition { private readonly alwaysOnTopLevel: SnapShotTransitionOptions["alwaysOnTopLevel"]; private readonly showWindow: (window: Electron.BaseWindow) => void; + get isAnimating(): boolean { + return this.active?.flight !== undefined && !this.active.landed; + } + constructor(options: SnapShotTransitionOptions = {}) { this.boundOverlayToCaptureDisplays = options.boundOverlayToCaptureDisplays ?? false; this.waitForCompositorFrame = options.waitForCompositorFrame ?? false; @@ -351,7 +355,9 @@ export class SnapShotTransition { } return; } - active.flight = this.runFlight(active, destination); + active.flight = this.runFlight(active, destination).finally(() => { + active.landed = true; + }); } private async runFlight( @@ -410,7 +416,6 @@ export class SnapShotTransition { }), ); if (this.active !== active) return; - // One display failing must not cut the flight short on the others. await Promise.allSettled( prepared.map((result) => { if (result.status !== "fulfilled" || !result.value || result.value.window.isDestroyed()) { diff --git a/apps/desktop/src/snapShot/captureConfigEdit.test.ts b/apps/desktop/src/snapShot/captureConfigEdit.test.ts index ea2953ceddc3..6d57d35ee8b6 100644 --- a/apps/desktop/src/snapShot/captureConfigEdit.test.ts +++ b/apps/desktop/src/snapShot/captureConfigEdit.test.ts @@ -20,7 +20,6 @@ describe("Niri capture config edits", () => { }); it("ignores commented, nested, and string-valued binds and braces", () => { const before = `// binds { fake\n/* binds { /* nested */ } */\n/- binds { Ctrl+Shift+2 { quit; } }\nrecent-windows { binds { Alt+Tab { next-window; } }; }\nspawn-at-startup "echo" r#"binds { }"#\n"binds" {\n Mod+Q { spawn "a } \\" b"; }\n}\n`; - // A quoted brace in a valid shell argument must not close the binds section. const valid = before.replace('"a } \\" b"', '"a } b"'); const result = editCaptureConfig(valid, "niri", app, "install"); expect(result.after).toContain(` ${binding}\n}\n`); diff --git a/apps/desktop/src/snapShot/captureConfigEdit.ts b/apps/desktop/src/snapShot/captureConfigEdit.ts index c46211faa8ac..7de20719b3aa 100644 --- a/apps/desktop/src/snapShot/captureConfigEdit.ts +++ b/apps/desktop/src/snapShot/captureConfigEdit.ts @@ -188,7 +188,6 @@ export function editCaptureConfig( readKdlNodes(after); } else { // Do not append executable Lua inside a block/string, or after an early return. - // The normal Omarchy binding file is a sequence of top-level hl.bind calls. after += `${after && !after.endsWith("\n") ? newline : ""}${newline}${binding}${newline}`; } return { after, shortcut: keys }; diff --git a/apps/desktop/src/snapShot/captureConfigKdl.ts b/apps/desktop/src/snapShot/captureConfigKdl.ts index f3b3beab8c26..ca8527549174 100644 --- a/apps/desktop/src/snapShot/captureConfigKdl.ts +++ b/apps/desktop/src/snapShot/captureConfigKdl.ts @@ -2,7 +2,6 @@ import * as Schema from "effect/Schema"; const decodeString = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.String)); -/** A lossless structural reader: edits only binding nodes, never reformats a user's KDL. */ type Token = { value: string; start: number; end: number; quoted: boolean }; export type KdlNode = { name: string; diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 2cfa406d1781..0c85a0148425 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -25,7 +25,6 @@ interface AccessibilityTreeNode { const MAX_ACCESSIBILITY_TREE_NODES = 10_000; const WINDOW_BLUR_TIMEOUT_MS = 1_000; -/** Win32 virtual-key codes for the left and right key of each modifier pair. */ export const WINDOWS_MODIFIER_PAIR_VIRTUAL_KEYS: Record< SnapShotModifier, readonly [number, number] @@ -536,7 +535,6 @@ const ELECTRON_KEY_NAMES: Readonly> = { Escape: "Esc", }; -/** Two shortcuts are the same when they would register the same listener. */ export function sameSnapShotShortcut(left: SnapShotShortcut, right: SnapShotShortcut): boolean { if (isModifierPairShortcut(left) || isModifierPairShortcut(right)) { return ( diff --git a/apps/desktop/src/telemetry/DesktopRendererHistory.ts b/apps/desktop/src/telemetry/DesktopRendererHistory.ts index 1d65d5c56f8d..ed1590f63b19 100644 --- a/apps/desktop/src/telemetry/DesktopRendererHistory.ts +++ b/apps/desktop/src/telemetry/DesktopRendererHistory.ts @@ -72,7 +72,6 @@ interface TrackedRenderer { const nonnegative = (value: number): number | null => Number.isFinite(value) && value >= 0 ? Math.round(value) : null; -/** Small restart-surviving PID history; never reads URLs or page content. */ const make = Effect.fn("DesktopRendererHistory.make")(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const path = yield* Path.Path; diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 7651eb92da01..5e59973901c1 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -39,6 +39,7 @@ function layerElectronApp( name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, + requestSingleInstanceLock: Effect.succeed(true), quit: Effect.void, exit: () => Effect.void, relaunch: () => Effect.void, @@ -467,8 +468,6 @@ describe("DesktopTelemetryPublisher", () => { } as const; yield* publisher.publishUpdateReport(report); - // A subscriber that attaches after the publish (the backend spawned - // by a relaunch) still sees the latest report replayed. const decoder = new TextDecoder(); const decodeMessage = Schema.decodeUnknownEffect( Schema.fromJsonString(DesktopHostTelemetryMessage), @@ -658,7 +657,6 @@ describe("DesktopRendererHistory", () => { assert.equal(crash.rendererPidSource, "last-known"); assert.equal(crash.reason, "oom"); assert.equal(crash.exitCode, -7); - // A restarted renderer can reuse the same numeric PID. const restarted = yield* Deferred.make(); milestone = { matches: (row) => row.event === "dom-ready", written: restarted }; previewPid = 7_003; @@ -684,7 +682,6 @@ describe("DesktopRendererHistory", () => { yield* history.recordMetrics(metrics); assert.equal((yield* Deferred.await(afterDestroy)).webContentsId, 1); assert.isFalse(persisted.slice(previousCount).some((row) => row.webContentsId === 2)); - // The app quit handshake drains before the history layer closes. for (let index = 0; index < 12; index++) main.emit("dom-ready"); yield* history.shutdown; assert.equal( diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index c0369e702fa1..1155a2b15b0c 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -69,11 +69,7 @@ export class DesktopTelemetryPublisher extends Context.Service< message: DesktopTelemetryControlMessage, ) => Effect.Effect; readonly removeControlSource: (sourceId: string) => Effect.Effect; - /** Sends the report to the attached backend and replays the latest one - to backends that attach later (including the one spawned after a - relaunch). */ readonly publishUpdateReport: (report: DesktopUpdateStatusReport) => Effect.Effect; - /** Update requests received over the control channel. Single consumer. */ readonly updateRequests: Stream.Stream; readonly updateCommits: Stream.Stream; readonly updateCancellations: Stream.Stream; @@ -219,7 +215,6 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { { concurrency: "unbounded" }, ); // One Electron sampler preserves the shared CPU measurement interval. - // History follows the existing power-aware cadence, even without demand. yield* rendererHistory.recordMetrics(metrics); const polledLocked = systemIdleState === "unknown" diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 1198429a1700..1fc0a9e39be5 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -112,7 +112,6 @@ describe("DesktopRemoteUpdates", () => { yield* settle; assert.equal(harness.quitAndInstalls(), 1); - // The mirror stamped the in-run state changes with the request id. const statuses = reports .filter((report) => report.requestId === "req-1") .map((report) => report.state.status); @@ -150,8 +149,6 @@ describe("DesktopRemoteUpdates", () => { }); yield* settle; - // Install failures reduce to status "downloaded" + errorContext - // "install"; the prepared result is followed by the commit failure. const terminals = terminalReports(reports); assert.deepEqual( terminals.map((report) => report.outcome), @@ -185,7 +182,6 @@ describe("DesktopRemoteUpdates", () => { ["ready-to-install"], ); - // A local install takes the updater reservation and starts shutdown. const localInstall = yield* updates.install.pipe(Effect.forkChild); yield* Deferred.await(installStarted); yield* Queue.offer(commits, { @@ -195,8 +191,6 @@ describe("DesktopRemoteUpdates", () => { }); yield* settle; - // No "failed" marker: that install relaunches the app and the - // client proves the handoff by reconnecting on the target version. assert.deepEqual( terminalReports(reports).map((report) => report.outcome), ["ready-to-install"], @@ -271,9 +265,6 @@ describe("DesktopRemoteUpdates", () => { requestId: "req-5", }); yield* settle; - // First run failed; state is "downloaded" with a lingering - // errorContext "install". The retry succeeds and must not report - // that leftover as a fresh failure. yield* Queue.offer(requests, request("req-6")); yield* settle; yield* Queue.offer(commits, { @@ -336,9 +327,7 @@ 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. The download the - // remote flow forks in response is refused and must be retried once the - // reservation frees up, without burning a download attempt. + // before the check action releases its reservation. const releaseCheck = Deferred.makeUnsafe(); const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) }); @@ -348,7 +337,6 @@ describe("DesktopRemoteUpdates", () => { yield* settle; assert.equal(harness.checkCount(), 1); - // Fire "available" while the check reservation is still held. harness.emit("update-available", { version: "1.2.4" }); yield* settle; assert.equal(harness.downloadCount(), 0); @@ -409,14 +397,11 @@ describe("DesktopRemoteUpdates", () => { const desktopState = yield* DesktopState.DesktopState; harness.emit("update-downloaded", { version: "1.2.4" }); yield* settle; - // Another install already owns the shutdown. yield* Ref.set(desktopState.quitting, true); yield* Queue.offer(requests, request("req-9")); yield* settle; - // Report "installing" once, no "failed" after the refusal: that - // install will relaunch the app and this request rides along. assert.deepEqual( terminalReports(reports).map((report) => report.outcome), ["ready-to-install"], diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index 31e3c5af5260..716a73c199aa 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -24,7 +24,6 @@ import { const { logInfo, logError } = DesktopObservability.makeComponentLogger("desktop-remote-updates"); -/** Pause before retrying an action the updater refused for a held reservation. */ const ACTION_RETRY_DELAY = Duration.millis(250); const PREPARED_UPDATE_TTL = Duration.minutes(5); @@ -42,11 +41,6 @@ type CommitClaim = | { readonly _tag: "committing" } | { readonly _tag: "claimed"; readonly prepared: PreparedUpdate }; -/** - * Server-triggered desktop updates. Mirrors updater state, prepares downloads, - * cancels abandoned preparations, and commits installs after the remote client - * confirms that it received the preparation token. - */ export const listen: Effect.Effect< void, never, @@ -181,12 +175,11 @@ 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 }; - // The updater admits one action at a time. 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. + // 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. const retries = yield* Queue.unbounded(); const retryLater = (state: DesktopUpdateState) => Effect.sleep(ACTION_RETRY_DELAY).pipe( @@ -195,16 +188,12 @@ export const listen: Effect.Effect< Effect.forkScoped, ); - // Returns true when the run reached a terminal outcome. const step = (state: DesktopUpdateState): Effect.Effect => Effect.gen(function* () { const next = nextRemoteDesktopUpdateStep(state, attempts, disabledReason); switch (next.action) { case "wait": return false; - // Counters increment before the action so the state event the - // action produces already sees it; a refusal for a held - // reservation rolls the count back, since it was not a try. case "check": attempts = { ...attempts, checks: attempts.checks + 1 }; yield* updates.check("remote-update").pipe( @@ -315,8 +304,6 @@ export const listen: Effect.Effect< ), ); - // Sequential by construction: a second remote request queued mid-run is - // handled after the current one, when the state machine resolves it fast. yield* Stream.runForEach(publisher.updateRequests, handleRequest).pipe(Effect.forkScoped); yield* Stream.runForEach(publisher.updateCancellations, (cancellation) => diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 26ad0f1c4e70..2a0d1d453092 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -14,6 +14,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; @@ -686,7 +687,6 @@ describe("DesktopUpdates", () => { const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; assert.equal((yield* updates.getState).notAppliedVersion, "1.2.4"); - // Kept, so the next start on 1.2.3 still knows 1.2.4 did not apply. assert.isDefined(harness.previousInstallRecord()); harness.emit("update-available", { version: "1.2.4" }); @@ -762,14 +762,96 @@ describe("DesktopUpdates", () => { assert.isTrue(result.accepted); assert.isFalse(yield* Ref.get(desktopState.quitting)); assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); - // The restarted old backend must release its tunnel on a later quit. assert.equal(harness.updateRestartMarkers.size, 0); - // The app never quit, so the next start must not report an unapplied install. assert.equal(harness.installRecords.size, 0); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("keeps the disabled primary stopped when quitAndInstall fails", () => { + const harness = makeHarness({ + backendDesiredRunning: false, + quitAndInstall: Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: "latest", + isSilent: true, + isForceRunAfter: true, + cause: new Error("installer refused"), + }), + ), + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const pool = yield* DesktopBackendPool.DesktopBackendPool; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.equal(result.state.errorContext, "install"); + assert.deepEqual(harness.installSteps, ["quitAndInstall"]); + assert.isFalse((yield* (yield* pool.primary).snapshot).desiredRunning); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("restores only desired-running instances after an installer error event", () => { + const starts: string[] = []; + const stops: string[] = []; + const makeInstance = (id: DesktopBackendPool.BackendInstanceId, initiallyRunning: boolean) => { + let desiredRunning = initiallyRunning; + return { + id, + label: Effect.succeed(id), + currentConfig: Effect.succeedNone, + start: Effect.sync(() => { + starts.push(id); + desiredRunning = true; + }), + stop: () => + Effect.sync(() => { + stops.push(id); + desiredRunning = false; + }), + snapshot: Effect.sync(() => ({ + desiredRunning, + ready: false, + activePid: Option.none(), + restartAttempt: 0, + restartScheduled: false, + })), + waitForReady: () => Effect.succeed(true), + } satisfies DesktopBackendPool.DesktopBackendInstance; + }; + const primary = makeInstance(DesktopBackendPool.PRIMARY_INSTANCE_ID, false); + const wsl = makeInstance(DesktopBackendPool.BackendInstanceId("wsl:default"), true); + const harness = makeHarness({ backendInstances: [primary, wsl] }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + yield* updates.install; + assert.sameMembers(stops, [primary.id, wsl.id]); + assert.isFalse((yield* wsl.snapshot).desiredRunning); + const { changes } = yield* updates.subscribe; + const recovered = yield* Stream.runHead(changes).pipe(Effect.forkChild); + yield* flushCallbacks; + harness.emit("error", new Error("native installer refused")); + assert.equal(Option.getOrThrow(yield* Fiber.join(recovered)).errorContext, "install"); + + assert.deepEqual(starts, [wsl.id]); + assert.isFalse((yield* primary.snapshot).desiredRunning); + assert.isTrue((yield* wsl.snapshot).desiredRunning); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("holds the install reservation until failed-install recovery finishes", () => { const recoveryStarted = Deferred.makeUnsafe(); const releaseRecovery = Deferred.makeUnsafe(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 77069d3ba3fe..b2d93f407ac7 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -73,7 +73,6 @@ const UpdateInfo = Schema.Struct({ const DownloadProgressInfo = Schema.Struct({ percent: Schema.Number, }); -/** Saved in the runtime folder before an install quits the app, read on the next start. */ export const DESKTOP_UPDATE_INSTALL_RECORD_FILE = "desktop-update-install.json"; const DesktopUpdateInstallRecord = Schema.fromJsonString( Schema.Struct({ fromVersion: Schema.String, targetVersion: Schema.String }), @@ -171,12 +170,8 @@ export class DesktopUpdates extends Context.Service< DesktopUpdates, { readonly getState: Effect.Effect; - /** True while a check, download, install, or channel change holds the - updater's single action reservation. */ readonly isActionActive: Effect.Effect; - /** True only while an install owns the updater action reservation. */ readonly isInstallActive: Effect.Effect; - /** Current state plus a stream of every later state change. */ readonly subscribe: Effect.Effect< { readonly latest: DesktopUpdateState; @@ -207,7 +202,6 @@ const { } = DesktopObservability.makeComponentLogger("desktop-updater"); function redactMessage(message: string): string { - // Updater HTTP errors append response bodies and headers after the first line. return (message.split(/[\r\n]/, 1)[0] ?? "") .replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, "[redacted URL]") .replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/gi, "[redacted credential]") @@ -254,7 +248,6 @@ function summarizeCause(cause: unknown, depth: number): string { return `${errorTag(cause)}${code}${message}${nested}`; } -/** Log a bounded cause summary without stacks, response payloads, or request credentials. */ export function describeUpdateErrorCause(cause: unknown): string { try { return summarizeCause(cause, 0).slice(0, 1024); @@ -353,6 +346,9 @@ export const make = Effect.gen(function* () { const appUpdateYmlConfigRef = yield* Ref.make>(Option.none()); const activeUpdateActionRef = yield* Ref.make>(Option.none()); + const installRunningBackendsRef = yield* Ref.make< + ReadonlyArray + >([]); const finishedUpdateActions = yield* PubSub.unbounded(); const updaterConfiguredRef = yield* Ref.make(false); const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); @@ -587,9 +583,6 @@ export const make = Effect.gen(function* () { ); }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); - // Tells the primary backend that the coming stop is an update restart, so it - // keeps its managed tunnel for the backend the updated app starts. Best - // effort: without the marker the backend only re-provisions its tunnel. const updateRestartMarkerDir = environment.path.join(environment.baseDir, "runtime"); const updateRestartMarkerPath = environment.path.join( updateRestartMarkerDir, @@ -610,8 +603,6 @@ export const make = Effect.gen(function* () { .remove(updateRestartMarkerPath, { force: true }) .pipe(Effect.ignore); - // Records the version an install hands to the OS installer, so the next - // start can tell whether it applied. Best effort, like the restart marker. const installRecordPath = environment.path.join( updateRestartMarkerDir, DESKTOP_UPDATE_INSTALL_RECORD_FILE, @@ -627,10 +618,6 @@ export const make = Effect.gen(function* () { .remove(installRecordPath, { force: true }) .pipe(Effect.ignore); - // Reads the record of the last install. Returns the version it did not - // apply, when the app came back on the version it installed from. That - // record stays through later starts, until the version changes or a new - // install replaces it, so no restart forgets the failed version. const readNotAppliedInstallVersion = fileSystem.readFileString(installRecordPath).pipe( Effect.flatMap(decodeInstallRecord), Effect.option, @@ -659,6 +646,7 @@ export const make = Effect.gen(function* () { [ finishUpdateAction("install"), Ref.set(desktopState.quitting, false), + Ref.set(installRunningBackendsRef, []), removeUpdateRestartMarker, removeInstallRecord, ], @@ -679,7 +667,10 @@ export const make = Effect.gen(function* () { yield* removeUpdateRestartMarker; yield* removeInstallRecord; yield* Effect.gen(function* () { - const instances = yield* pool.list; + const runningInstances = yield* Ref.getAndSet(installRunningBackendsRef, []); + const instances = (yield* pool.list).filter((instance) => + runningInstances.includes(instance), + ); const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, { concurrency: "unbounded", discard: true, @@ -745,19 +736,23 @@ export const make = Effect.gen(function* () { } yield* Ref.set(desktopState.quitting, true); + yield* Ref.set(installRunningBackendsRef, []); return yield* 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. Stops run concurrently with the same 5s - // budget the primary had on its own. + // 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. const instances = yield* pool.list; + // stop clears desiredRunning, so retain the recovery set beforehand. + const runningInstances = yield* Effect.filter(instances, (instance) => + instance.snapshot.pipe(Effect.map((snapshot) => snapshot.desiredRunning)), + ); + yield* Ref.set(installRunningBackendsRef, runningInstances); yield* Effect.forEach( instances, (instance) => instance.stop({ timeout: Duration.seconds(5) }), diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 3cab5f15e451..ef6d217299d0 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -125,13 +125,10 @@ interface NormalizedDesktopUpdateReleaseNotes { } /** - * Turns electron-updater's release notes into the groups the popover shows. * 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. Only - * releases on the channel being followed are kept, the same test the - * updater applies to the offered version itself. + * 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. */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, diff --git a/apps/desktop/src/updates/remoteUpdateFlow.test.ts b/apps/desktop/src/updates/remoteUpdateFlow.test.ts index acc8479013fc..d7afeafab879 100644 --- a/apps/desktop/src/updates/remoteUpdateFlow.test.ts +++ b/apps/desktop/src/updates/remoteUpdateFlow.test.ts @@ -75,8 +75,6 @@ describe("nextRemoteDesktopUpdateStep", () => { ), { action: "install" }, ); - // A previous install failure keeps status "downloaded", so a remote run - // retries the install. assert.deepEqual( nextRemoteDesktopUpdateStep( makeState({ @@ -90,8 +88,6 @@ describe("nextRemoteDesktopUpdateStep", () => { ), { action: "install" }, ); - // A download survives an unrelated background updater error, so a run - // installs it instead of replaying that error. assert.deepEqual( nextRemoteDesktopUpdateStep( makeState({ @@ -105,8 +101,6 @@ describe("nextRemoteDesktopUpdateStep", () => { ), { action: "install" }, ); - // A leftover download behind a check error is not installable: fresh - // runs re-check, and post-check the error is terminal. const staleError = makeState({ status: "error", downloadedVersion: "1.2.4", @@ -172,8 +166,6 @@ describe("nextRemoteDesktopUpdateStep", () => { }); it("re-checks stale up-to-date and error states before trusting them", () => { - // These states are retained from earlier/background checks; a remote - // request must look again instead of replaying them. assert.deepEqual( nextRemoteDesktopUpdateStep(makeState({ status: "up-to-date" }), NO_ATTEMPTS, null), { action: "check" }, diff --git a/apps/desktop/src/updates/remoteUpdateFlow.ts b/apps/desktop/src/updates/remoteUpdateFlow.ts index a1523baee58c..91fb45fee3a4 100644 --- a/apps/desktop/src/updates/remoteUpdateFlow.ts +++ b/apps/desktop/src/updates/remoteUpdateFlow.ts @@ -1,10 +1,5 @@ import type { DesktopUpdateRemoteOutcome, DesktopUpdateState } from "@t3tools/contracts"; -/** - * What a server-triggered update run should do next, given the updater's - * current state. "wait" means an action (possibly started locally) is in - * flight and the run should ride along until the next state change. - */ export type RemoteDesktopUpdateStep = | { readonly action: "check" } | { readonly action: "download" } @@ -17,10 +12,10 @@ export type RemoteDesktopUpdateStep = }; /** - * How many times this run already issued each action. 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. + * 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. */ export interface RemoteDesktopUpdateAttempts { readonly checks: number; @@ -30,7 +25,6 @@ export interface RemoteDesktopUpdateAttempts { export const MAX_REMOTE_UPDATE_CHECKS = 2; export const MAX_REMOTE_UPDATE_DOWNLOADS = 3; -/** Same predicate DesktopUpdates.installDownloadedUpdate uses for admission. */ function isInstallableDesktopUpdateState(state: DesktopUpdateState): boolean { return ( state.downloadedVersion !== null && @@ -105,7 +99,6 @@ export function nextRemoteDesktopUpdateStep( reason: state.message ?? "The desktop app update failed.", }; } - // status === "idle" if (attempts.checks >= MAX_REMOTE_UPDATE_CHECKS) { return { action: "done", @@ -117,10 +110,9 @@ export function nextRemoteDesktopUpdateStep( } /** - * Normalizes an updater message for the `reason` wire field, which is a - * TrimmedNonEmptyString. 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. + * 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. */ export function normalizeRemoteUpdateReason(reason: string | undefined): string | undefined { const trimmed = reason?.trim(); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index e7f9a2f8547d..1b1a1ec1efa9 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,12 +1,10 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; -// Preview builds are the maintainers' test train, cut by hand from unreleased -// branches to exercise the release flow. 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. +// 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. const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index 48fcc07acda9..4d3852446b18 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -212,17 +212,11 @@ export function reduceDesktopUpdateStateOnInstallFailure( }; } -/** The install this app handed to the OS installer, saved right before it quits. */ export interface DesktopUpdateInstallRecord { readonly fromVersion: string; readonly targetVersion: string; } -/** - * Read on the next start: the version an install did not apply, because the - * app came back on the version it installed from. Null when the install - * applied, or when the app now runs some other version. - */ export function resolveNotAppliedInstallVersion( record: DesktopUpdateInstallRecord, appVersion: string, diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index c3701280ad55..1d8c21a42dfe 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -15,10 +15,6 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; -/** Shared DesktopUpdates test harness: a fully stubbed updater layer whose - electron-updater events are driven by hand via `emit`. Used by - DesktopUpdates.test.ts and DesktopRemoteUpdates.test.ts. */ - export const flushCallbacks = Effect.yieldNow; export interface UpdatesHarnessOptions { @@ -33,11 +29,11 @@ export interface UpdatesHarnessOptions { readonly quitAndInstall?: Effect.Effect; readonly stopBackend?: Effect.Effect; readonly startBackend?: Effect.Effect; + readonly backendDesiredRunning?: boolean; + readonly backendInstances?: ReadonlyArray; readonly env?: Record; readonly platform?: NodeJS.Platform; - /** Contents of the resources/package-type marker a Linux package ships. */ readonly packageType?: string | undefined; - /** Contents of the install record a previous run left behind. */ readonly installRecord?: string | undefined; } @@ -130,24 +126,31 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { syncAllAppearance: () => Effect.void, } satisfies ElectronWindow.ElectronWindow["Service"]); + let backendDesiredRunning = options.backendDesiredRunning ?? true; const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { id: DesktopBackendPool.PRIMARY_INSTANCE_ID, label: Effect.succeed("Windows"), start: Effect.sync(() => { + backendDesiredRunning = true; installSteps.push("startBackend"); }).pipe(Effect.andThen(options.startBackend ?? Effect.void)), - stop: () => options.stopBackend ?? Effect.void, + stop: () => + Effect.sync(() => { + backendDesiredRunning = false; + }).pipe(Effect.andThen(options.stopBackend ?? Effect.void)), currentConfig: Effect.succeedNone, - snapshot: Effect.succeed({ - desiredRunning: false, + snapshot: Effect.sync(() => ({ + desiredRunning: backendDesiredRunning, ready: false, activePid: Option.none(), restartAttempt: 0, restartScheduled: false, - }), + })), waitForReady: () => Effect.succeed(true), }; - const layerBackend = DesktopBackendPool.layerTest([stubBackendInstance]); + const layerBackend = DesktopBackendPool.layerTest( + options.backendInstances ?? [stubBackendInstance], + ); const layerEnvironment = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -210,10 +213,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) : DesktopAppSettings.layer; - // Tracks the restart markers and install records installs leave, so - // installs stay free of real disk I/O that would outrun the tests' settle loops. const updateRestartMarkers = new Set(); - // Contents of the install record, by path once an install writes one. const installRecords = new Map(); let previousInstallRecord = options.installRecord; const isInstallRecord = (path: string) => @@ -275,7 +275,6 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { installSteps, updateRestartMarkers, installRecords, - /** The record the test started with, until the app removes it. */ previousInstallRecord: () => previousInstallRecord, downloadCount: () => downloadCount, feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index f111dac25db8..fa3960a34ee5 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -32,6 +32,7 @@ const layerElectronApp = Layer.succeed(ElectronApp.ElectronApp, { name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, + requestSingleInstanceLock: Effect.succeed(true), quit: Effect.void, exit: () => Effect.void, relaunch: () => Effect.void, @@ -217,8 +218,6 @@ describe("DesktopApplicationMenu", () => { }), ); - // Chromium pastes as plain text for the accelerator on its own. Dispatching - // the action as well injects a second paste, which doubles the pasted text. it.effect("leaves the accelerator to Chromium instead of injecting a paste", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); @@ -248,9 +247,6 @@ describe("DesktopApplicationMenu", () => { }), ); - // Zoom must route through DesktopWindow.zoomMain instead of the Electron - // zoom roles: the roles zoom whichever webContents has focus, which breaks - // app zoom while an embedded preview WebContentsView holds focus. it.effect("routes View menu zoom to the main window instead of zoom roles", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); @@ -284,6 +280,37 @@ describe("DesktopApplicationMenu", () => { }), ); + it.effect("zooms from the numeric keypad without listing the commands twice", () => + Effect.gen(function* () { + for (const [accelerator, expected] of [ + ["CmdOrCtrl+numadd", "zoom-in"], + ["CmdOrCtrl+numsub", "zoom-out"], + ["CmdOrCtrl+num0", "zoom-reset"], + ] as const) { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const viewMenu = template.find((item) => item.label === "View"); + if (!viewMenu || !Array.isArray(viewMenu.submenu)) { + throw new Error("Expected View menu submenu to be an array."); + } + const item = viewMenu.submenu.find((entry) => entry.accelerator === accelerator); + assert.isDefined(item); + assert.isFalse(item.visible); + if (typeof item.click !== "function") { + throw new Error(`Expected ${accelerator} menu item to have a click handler.`); + } + + item.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(selectedAction), expected); + } + }), + ); + it.effect("reloads the main window even while a browser page has focus", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index d0363fb01a27..c51e13e58673 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -106,8 +106,10 @@ 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. const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.ensureMain; + yield* desktopWindow.activate; yield* checkForUpdatesFromMenu; }).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); @@ -144,8 +146,7 @@ export const make = Effect.gen(function* () { // 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. Only a menu click, - // which produces no keystroke for them to see, needs that round trip. + // lands a second, injected paste and doubles the text. const pasteAsTextClick = ( _item: Electron.MenuItem, _window: Electron.BaseWindow | undefined, @@ -240,8 +241,7 @@ export const make = Effect.gen(function* () { /* 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. These always target - the main window (see DesktopWindow.zoomMain). + the guest page and the app UI appears stuck. */ { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") }, { @@ -264,6 +264,26 @@ export const make = Effect.gen(function* () { click: zoomClick("in"), }, { 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. + { + label: "Actual Size", + accelerator: "CmdOrCtrl+num0", + visible: false, + click: zoomClick("reset"), + }, + { + label: "Zoom In", + accelerator: "CmdOrCtrl+numadd", + visible: false, + click: zoomClick("in"), + }, + { + label: "Zoom Out", + accelerator: "CmdOrCtrl+numsub", + visible: false, + click: zoomClick("out"), + }, { type: "separator" }, { role: "togglefullscreen" }, ], diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index c9a49b1d735b..cebdcbb77972 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -347,11 +347,6 @@ function layerTest(input: { ); } -// Builds a DesktopWindow over a fake ElectronWindow whose `create` returns the -// given outcomes in order (null => simulated open failure), and whose -// currentMainOrFirst mirrors the real fallback to the first live window (the -// splash, before any main is registered). Reveal targets are recorded so tests -// can assert what activation actually surfaced. const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | null)[]) => Effect.gen(function* () { const createdWindows = yield* Ref.make([]); @@ -798,13 +793,45 @@ describe("DesktopWindow", () => { fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), [-0.5, -1, -0.5, 0], ); - // Recorded after the window level moved, so the preview is put back at - // its own zoom on every step rather than left on the inherited one. assert.deepEqual(previewZoomReapplies, [-0.5, -1, -0.5, 0]); }).pipe(Effect.provide(layer)); }), ); + // 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(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const previewZoomReapplies: number[] = []; + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + previewZoomReapplies, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const zoomChanged = fakeWindow.webContentsListeners.get("zoom-changed"); + if (!zoomChanged) return yield* Effect.die("zoom-changed listener was not registered"); + zoomChanged({}, "in"); + zoomChanged({}, "out"); + zoomChanged({}, "out"); + + assert.deepEqual( + fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), + [0.5, 0, -0.5], + ); + assert.deepEqual(previewZoomReapplies, [0.5, 0, -0.5]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("reloads and inspects the main window even when a popup has focus", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -851,7 +878,6 @@ describe("DesktopWindow", () => { yield* desktopWindow.zoomMain(direction); const position = fakeWindow.setWindowButtonPosition.mock.lastCall?.[0]; assert.isDefined(position); - // The 14-point native buttons should share the zoomed 52px header's center. const headerCenter = 26 * fakeWindow.window.webContents.getZoomFactor(); assert.isAtMost(Math.abs(position.y + 7 - headerCenter), 0.5); assert.equal(position.x, 16); @@ -929,9 +955,6 @@ describe("DesktopWindow", () => { }), ); - // The window boots hidden with throttling disabled so first paint runs at - // full speed; the first reveal must hand it back to normal hidden-window - // throttling or a minimized window stays expensive forever. it.effect("re-enables background throttling on first reveal", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1350,6 +1373,47 @@ describe("DesktopWindow", () => { }), ); + it.effect( + "recovers killed renderers without reloading indefinitely or reviving closed windows", + () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.createMain; + const onGone = fakeWindow.webContentsListeners.get("render-process-gone"); + const onLoaded = fakeWindow.webContentsListeners.get("did-finish-load"); + if (!onGone || !onLoaded) + return yield* Effect.die("renderer lifecycle listeners missing"); + + onGone({}, { reason: "clean-exit", exitCode: 0 }); + yield* TestClock.adjust(500); + assert.equal(fakeWindow.loadURL.mock.calls.length, 1); + + for (let attempt = 0; attempt < 4; attempt += 1) { + onGone({}, { reason: "killed", exitCode: 9 }); + yield* TestClock.adjust(500); + onLoaded(); + } + assert.equal(fakeWindow.loadURL.mock.calls.length, 4); + + yield* TestClock.adjust(60_000); + onGone({}, { reason: "killed", exitCode: 9 }); + yield* TestClock.adjust(500); + assert.equal(fakeWindow.loadURL.mock.calls.length, 5); + assert.equal(yield* Ref.get(createCount), 1); + + onGone({}, { reason: "killed", exitCode: 9 }); + fakeWindow.isDestroyed.mockReturnValue(true); + yield* TestClock.adjust(500); + assert.equal(fakeWindow.loadURL.mock.calls.length, 5); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("recovers when the development renderer is temporarily unreachable", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1461,20 +1525,14 @@ describe("DesktopWindow", () => { Effect.gen(function* () { const splash = makeFakeBrowserWindow(); const main = makeFakeBrowserWindow(); - // create #1 -> splash, #2 -> fails (the pool swallows this post-readiness - // window-open error), #3 -> the real main on activate's retry. const scenario = yield* makeSplashScenario([splash.window, null, main.window]); yield* Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; - // 1. WSL-only boot shows the connecting splash. yield* desktopWindow.showConnectingSplash; assert.equal(yield* Ref.get(scenario.createCalls), 1); - // 2. Backend reports ready, but opening the real main fails. The pool - // swallows that error in production, so handleBackendReady fails - // here without a registered main window -- only the splash is open. const readyExit = yield* Effect.exit( desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")), ); @@ -1482,9 +1540,6 @@ describe("DesktopWindow", () => { assert.equal(yield* Ref.get(scenario.createCalls), 2); assert.isTrue(Option.isNone(yield* Ref.get(scenario.mainWindow))); - // 3. Activating must not mistake the splash for the main window: it - // retries the open and brings up the real main instead of leaving - // the user stranded on "Connecting to WSL". yield* desktopWindow.activate; assert.equal(yield* Ref.get(scenario.createCalls), 3); const registeredMain = yield* Ref.get(scenario.mainWindow); @@ -1499,7 +1554,6 @@ describe("DesktopWindow", () => { () => Effect.gen(function* () { const splash = makeFakeBrowserWindow(); - // Only the splash is ever created; the backend never reports ready. const scenario = yield* makeSplashScenario([splash.window]); yield* Effect.gen(function* () { @@ -1508,8 +1562,6 @@ describe("DesktopWindow", () => { yield* desktopWindow.showConnectingSplash; assert.equal(yield* Ref.get(scenario.createCalls), 1); - // Taskbar/dock activation during cold boot must bring the splash back - // rather than no-op and leave it hidden until the backend finishes. yield* desktopWindow.activate; assert.equal(yield* Ref.get(scenario.createCalls), 1); assert.deepEqual(yield* Ref.get(scenario.revealedWindows), [splash.window]); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 75393d54a1b2..1d76107425ed 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -57,21 +57,10 @@ const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; -// Renderer crash (usually V8 OOM on long sessions) recovery: reload after a -// short delay, at most MAX_ATTEMPTS times per rolling WINDOW so a renderer -// that dies on boot cannot reload-loop forever. const RENDERER_RECOVERY_RELOAD_DELAY_MS = 500; const RENDERER_RECOVERY_MAX_ATTEMPTS = 3; const RENDERER_RECOVERY_WINDOW_MS = 60_000; -const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ - -2, // ERR_FAILED - -7, // ERR_TIMED_OUT - -9, // ERR_UNEXPECTED (custom protocol handler rejected) - -102, // ERR_CONNECTION_REFUSED - -105, // ERR_NAME_NOT_RESOLVED - -106, // ERR_INTERNET_DISCONNECTED - -118, // ERR_CONNECTION_TIMED_OUT -]); +const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([-2, -7, -9, -102, -105, -106, -118]); type WindowTitleBarOptions = Pick< Electron.BrowserWindowConstructorOptions, @@ -106,21 +95,8 @@ export class DesktopWindow extends Context.Service< readonly revealOrCreateMain: Effect.Effect; readonly activate: Effect.Effect; readonly createMainIfBackendReady: Effect.Effect; - // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only - // mode), before the WSL backend that acts as the primary is ready. It is - // dismissed automatically once the real main window reveals. readonly showConnectingSplash: Effect.Effect; - // Marks the primary backend as ready so `createMainIfBackendReady` and the - // macOS "activate without windows" path may open the real main window. The - // renderer now always loads the local client URL (getDesktopUrl) and connects - // to the backend through the connection layer, so the reported httpBaseUrl is - // no longer used to point the window at the backend โ€” it is kept only for the - // readiness log and to preserve the callback contract the backend pool drives. readonly handleBackendReady: (httpBaseUrl: URL) => Effect.Effect; - // Called when the backend transitions back to "not ready" (clean stop, - // restart, crash). Clears the latch that lets `activate` auto-create a - // window so a "macOS dock click" while the backend is down doesn't - // produce a stranded window pointing at nothing. readonly handleBackendNotReady: Effect.Effect; readonly flushMainWindowBounds: Effect.Effect; readonly prepareCaptureReveal: Effect.Effect; @@ -128,22 +104,13 @@ export class DesktopWindow extends Context.Service< action: string, options?: { readonly reveal?: boolean }, ) => Effect.Effect; - /** - * Push a capture lifecycle event to the renderer. Only `started` reveals the - * window; the rest must not interrupt the app the user has switched to. - */ readonly dispatchSnapShotEvent: ( event: DesktopSnapShotEvent, ) => Effect.Effect; - // Zooms the main window's own webContents. 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. The menu routes here to always target - // the main window. + // 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. readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; - // Reload and DevTools for the main window's own webContents, for the same - // reason as zoomMain: the Electron roles act on the focused webContents, - // which is a preview guest whenever a browser page has focus. readonly runMainContentsCommand: (command: MainWindowContentsCommand) => Effect.Effect; readonly syncAppearance: Effect.Effect; } @@ -156,7 +123,7 @@ function getIconOption( iconPaths: DesktopAssets.DesktopIconPaths, platform: NodeJS.Platform, ): { icon: string } | Record { - if (platform === "darwin") return {}; // macOS uses .icns from app bundle + if (platform === "darwin") return {}; const ext = platform === "win32" ? "ico" : "png"; return Option.match(iconPaths[ext], { onNone: () => ({}), @@ -207,9 +174,6 @@ export function resolveInitialMainWindowBounds( return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; } -// A self-contained "Connecting to WSL" splash, shown immediately in wsl-only -// mode while the WSL backend (which serves the renderer) cold-boots. Inlined as -// a data URL so it needs no bundled asset and no backend โ€” pure CSS, no JS. function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { const background = getInitialWindowBackgroundColor(shouldUseDarkColors); const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280"; @@ -334,14 +298,7 @@ export const make = Effect.gen(function* () { const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronApp = yield* ElectronApp.ElectronApp; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; - // Window-side latch for the primary backend's readiness. Set by - // handleBackendReady (driven by the pool's onReady callback), cleared - // by handleBackendNotReady (driven by onShutdown). Only consumed by - // createMainIfBackendReady, which gates the post-readiness window - // open in development and the macOS "activate without windows" path. const backendReadyRef = yield* Ref.make(false); - // The transient "Connecting to WSL" splash window, tracked separately so it - // is never mistaken for the real main window. const splashWindowRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); @@ -359,9 +316,7 @@ export const make = Effect.gen(function* () { // 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. That is the failure the pool's swallowed - // post-readiness window-open error would otherwise strand the user in: - // splash up, backend ready, no main, and activation only re-reveals splash. + // open (or retry) the real main. const withoutSplash = (window: Option.Option) => Ref.get(splashWindowRef).pipe( Effect.map((splash) => @@ -374,6 +329,21 @@ export const make = Effect.gen(function* () { const currentMainWindow = electronWindow.currentMainOrFirst.pipe(Effect.flatMap(withoutSplash)); const focusedMainWindow = electronWindow.focusedMainOrFirst.pipe(Effect.flatMap(withoutSplash)); + const zoomWindow = Effect.fn("desktop.window.zoomWindow")(function* ( + window: Electron.BrowserWindow, + direction: MainWindowZoomDirection, + ) { + const webContents = window.webContents; + webContents.setZoomLevel( + 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. + yield* previewManager.reapplyZoom(); + }); + const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< Electron.BrowserWindow, DesktopWindowError @@ -419,7 +389,6 @@ export const make = Effect.gen(function* () { ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), webPreferences: { preload: environment.preloadPath, - // The window boots hidden (show: false until ready-to-show), and // 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 @@ -656,9 +625,6 @@ 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. - // Deliberate presses still flow through the renderer or native menu. - // Intercept the quit accelerator before the native menu sees it and apply - // the configured direct, hold, or double-press behavior. const quitShortcutHandler = makeQuitShortcutHandler({ platform: environment.platform, getMode: () => @@ -694,6 +660,13 @@ 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. + window.webContents.on("zoom-changed", (_event, direction) => { + if (window.isDestroyed()) return; + runFork(zoomWindow(window, direction)); + }); window.on("page-title-updated", (event) => { event.preventDefault(); @@ -805,13 +778,11 @@ export const make = Effect.gen(function* () { const recoverable = details.reason === "crashed" || details.reason === "oom" || + details.reason === "killed" || details.reason === "abnormal-exit"; - // Long sessions can OOM the renderer (V8 heap exhaustion from - // accumulated thread state). Without a reload the user is left staring - // at a dead white window while agents keep running invisibly, so - // recover by reloading โ€” the renderer rehydrates from the backend, - // which is unaffected. Recovery attempts are bounded so a renderer - // that dies immediately on boot cannot reload-loop forever. + // Recovery attempts are bounded so a renderer that dies immediately on + // boot cannot reload-loop forever. + runFork( Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; @@ -844,13 +815,9 @@ export const make = Effect.gen(function* () { revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); } bindFirstRevealTrigger(revealSubscribers, () => { - // Boot is done; hand the window back to normal hidden-window throttling - // (see the backgroundThrottling comment on the create options above). if (!window.isDestroyed()) { window.webContents.setBackgroundThrottling(true); } - // Reveal the real window, then close the connecting splash (if any) so the - // two don't overlap and there's no blank gap between them. if (persistedSettings.mainWindowMaximized) { window.maximize(); } @@ -892,8 +859,6 @@ export const make = Effect.gen(function* () { return window; }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); - // With the local environment disabled there is no backend to wait for: the - // renderer is served from bundled assets and only talks to remote environments. const waitingForBackend = Effect.gen(function* () { if (yield* Ref.get(backendReadyRef)) return false; return (yield* desktopSettings.get).localEnvironmentEnabled; @@ -907,7 +872,6 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); const showConnectingSplash = Effect.gen(function* () { - // Only when nothing is shown yet: no real window, no existing splash. const existingSplash = yield* Ref.get(splashWindowRef); if (Option.isSome(existingSplash)) return; const existingWindow = yield* electronWindow.currentMainOrFirst; @@ -946,7 +910,6 @@ export const make = Effect.gen(function* () { void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors)); yield* logWindowInfo("connecting splash shown"); }).pipe( - // The splash is best-effort UX โ€” never let it fail startup. Effect.catch((error) => logWindowWarning("failed to show connecting splash", { message: error.message }), ), @@ -993,11 +956,6 @@ export const make = Effect.gen(function* () { yield* electronWindow.reveal(existingWindow.value); return; } - // No real main window yet. While the backend is still cold-booting, - // re-reveal the connecting splash so taskbar/dock activation brings it - // back instead of doing nothing. Once the backend is ready we fall - // through to (re)create the real main -- including retrying a previously - // failed open the pool swallowed -- rather than latching onto the splash. const backendReady = yield* Ref.get(backendReadyRef); if (!backendReady) { const splash = yield* Ref.get(splashWindowRef); @@ -1040,16 +998,7 @@ export const make = Effect.gen(function* () { if (Option.isNone(window) || window.value.isDestroyed()) { return; } - const webContents = window.value.webContents; - // Same step size as the Electron zoomIn/zoomOut menu roles. - webContents.setZoomLevel( - direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), - ); - if (environment.platform === "darwin") syncMacosWindowButtons(window.value); - // 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. - yield* previewManager.reapplyZoom(); + yield* zoomWindow(window.value, direction); }), runMainContentsCommand: Effect.fn("desktop.window.runMainContentsCommand")(function* (command) { yield* Effect.annotateCurrentSpan({ command }); diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index 47f3ec5298ec..591fad185a5d 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -44,11 +44,9 @@ function makeHarness(options?: { const preventDefault = vi.fn(); const send = async (input: QuitHoldKeyInput) => { handler({ preventDefault }, input); - // Let the getMode promise settle. await Promise.resolve(); await Promise.resolve(); }; - // Simulates the OS auto-repeating the held shortcut every `intervalMs`. const holdFor = async ( durationMs: number, repeatOverrides: Partial = {}, @@ -80,7 +78,6 @@ describe("makeQuitShortcutHandler", () => { vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).not.toHaveBeenCalled(); - // The watchdog dismisses the hint once the press is clearly over. expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); @@ -128,8 +125,6 @@ describe("makeQuitShortcutHandler", () => { await harness.send(makeInput({})); await harness.holdFor(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS * 2); - // If neither keyUp reaches the handler, continued repeats must keep the - // app alive. Once they stop, the quiet period is the release signal. expect(harness.quit).not.toHaveBeenCalled(); vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); @@ -178,8 +173,6 @@ describe("makeQuitShortcutHandler", () => { await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); harness.preventDefault.mockClear(); - // Repeats without the modifier prove Q is still down, so they hold the - // quit back for as long as they keep arriving. await harness.holdFor(QUIT_HOLD_RELEASE_GRACE_MS * 2, { meta: false }); expect(harness.preventDefault).toHaveBeenCalled(); expect(harness.quit).not.toHaveBeenCalled(); @@ -199,7 +192,6 @@ describe("makeQuitShortcutHandler", () => { vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).toHaveBeenCalledTimes(1); - // A lone Cmd tap afterwards must not quit a second time. harness.quit.mockClear(); await harness.send(makeInput({ key: "Meta" })); await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); @@ -300,26 +292,21 @@ describe("makeQuitShortcutHandler", () => { ); it("discards a stale mode resolution from a superseded press", async () => { - // Press #1's mode is still pending when the user releases and - // presses again; its late resolution must not act for press #2. const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; const harness = makeHarness({ getMode: () => new Promise((resolve) => resolvers.push(resolve)), }); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); - // Outside the double-press window, so the second press starts a new hold. vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); await harness.send(makeInput({})); expect(resolvers).toHaveLength(2); - // Press #1 resolves late with "direct". It must not quit press #2. resolvers[0]?.("direct"); await Promise.resolve(); await Promise.resolve(); expect(harness.quit).not.toHaveBeenCalled(); - // Press #2 resolves to hold and completes the gesture. resolvers[1]?.("hold"); await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); await harness.send(makeInput({ type: "keyUp" })); @@ -442,10 +429,8 @@ describe("makeQuitShortcutHandler", () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.holdFor(500); - // Shift pressed mid-hold breaks the gesture... await harness.send(makeInput({ shift: true })); expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - // ...so later repeats past the threshold must not quit. await harness.holdFor(QUIT_HOLD_DURATION_MS); expect(harness.quit).not.toHaveBeenCalled(); }); @@ -454,7 +439,6 @@ describe("makeQuitShortcutHandler", () => { const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); await harness.send(makeInput({ shift: true })); - // A fresh press right after the interruption starts a new attempt. await harness.send(makeInput({})); expect(harness.quit).not.toHaveBeenCalled(); expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index f656183f1375..eb0b3141c47a 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -2,18 +2,13 @@ import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; -// The quit accelerator is intercepted in before-input-event, which runs -// before the native menu accelerator. Quitting from the application menu is -// untouched and always quits immediately. 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. Keyboards with -// auto-repeat disabled must use a double press or the application menu Quit action. -// Supporting holds without repeats requires a native physical key-state check. +// repeats stop so they cannot reach the next app. export const QUIT_HOLD_RELEASE_GRACE_MS = 600; // A slow repeat rate can exceed the fixed grace. Waiting for two observed // cadences keeps the timer behind the next repeat without slowing normal rates. @@ -45,7 +40,6 @@ export function makeQuitShortcutHandler( let holding = false; let mode: QuitConfirmationMode | undefined; let notified = false; - // Set once getMode resolves to hold; auto-repeats may only complete the hold when armed. let armed = false; let quitOnRelease = false; let heldSince = 0; @@ -83,7 +77,6 @@ export function makeQuitShortcutHandler( } }; - // Dismisses any overlay first so a cancelled quit cannot leave a stale hint. const quitNow = () => { release(); lastPressAt = 0; @@ -125,18 +118,13 @@ export function makeQuitShortcutHandler( } if (quitOnRelease) { event.preventDefault(); - // A Q keydown proves the key is still down whether or not the modifier - // is still held, so it only pushes the quiet period back. if (key === "q") quitAfterQuietPeriod(); return; } if (!modifierDown || input.alt || input.shift || key !== "q") { - // Re-pressing the platform modifier is the first half of a second full - // quit shortcut, so it must not cancel an active double-press window. if (key === modifierKey && !input.alt && !input.shift) return; - // Other keys cancel the hold and the first tap, even after release. // Keep this separate from release(), which also runs when a fresh Q // keydown follows a keyUp that macOS did not deliver. if (!input.isAutoRepeat) { @@ -161,13 +149,9 @@ export function makeQuitShortcutHandler( const now = Date.now(); const previousPressAt = lastPressAt; lastPressAt = now; - // A fresh keydown supersedes the current physical hold or the hint kept - // alive after a detected release. if (holding || notified) release(); generation += 1; - // Every mode accepts two presses. Quit before reading settings so a slow - // read cannot delay the second press. Repeats never reach this branch. if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) { quitNow(); return; @@ -196,7 +180,6 @@ export function makeQuitShortcutHandler( return; } - // A hold cannot be armed after its physical press has ended. if (!holding) return; mode = resolvedMode; @@ -204,14 +187,11 @@ export function makeQuitShortcutHandler( options.notify({ state: "down", mode: resolvedMode }); armed = true; - // No auto-repeat by then means the key was released (possibly with a - // suppressed keyUp) or repeat is disabled; either way, don't quit. watchdog = setTimeout(() => { watchdog = undefined; release(); }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); }, - // A failed settings read must never strand the quit request. () => { if (generation !== pressGeneration) return; quitNow(); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index fe131ca4f9c0..95e5e84d9134 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -1,25 +1,3 @@ -// Orchestrator that keeps the WSL pool instance in sync with the user's -// settings. `reconcile` is the single entry point โ€” bootstrap calls it -// once after the primary backend starts, and the wsl.ts IPC calls it -// after persisting a `wslBackendEnabled` or `wslDistro` change. The -// effect is idempotent and never fails: errors (WSL not available, port -// allocation failed, register failed) get logged and reconcile returns -// having left the pool in a consistent state (either the previous WSL -// instance is still running, or none is). -// -// The instance id encodes the desired distro selection โ€” `wsl:default` -// when the user picked "track the WSL default" (settings.wslDistro is -// null) and `wsl:` otherwise. Changing the distro setting -// changes the id, so reconcile unregisters the old instance before -// registering the new one. The label that the frontend env switcher -// renders is derived from the same field. -// -// Port allocation: each WSL instance gets a freshly scanned port to -// avoid colliding with the primary or with a previously-registered WSL -// instance that's still tearing down. The scan only checks loopback -// (127.0.0.1) since the WSL backend is loopback-only โ€” the primary -// owns LAN exposure when the user opts in. - import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -37,10 +15,8 @@ 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. Keeping it inline in two places risks silent -// divergence if one ever gets renamed. +// 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; @@ -48,14 +24,7 @@ const MAX_TCP_PORT = 65_535; export class DesktopWslBackend extends Context.Service< DesktopWslBackend, { - // Bring the pool in line with the current persisted WSL settings. - // Idempotent. Never fails (errors are logged); callers can chain it - // after persisting settings without an error-handling dance. readonly reconcile: Effect.Effect; - // Reason the dual-mode WSL secondary last failed preflight (no node, wrong - // version, missing build tools), or None. Read by the getWslState IPC so - // Connections settings can show it inline. None in wsl-only mode (that path - // surfaces via a dialog + Windows fallback). readonly lastPreflightError: Effect.Effect>; } >()("@t3tools/desktop/wsl/DesktopWslBackend") {} @@ -107,11 +76,6 @@ export const layer = Layer.effect( // with different distros, leaving the loser stranded. const reconcileMutex = yield* Semaphore.make(1); - // Last fatal preflight failure from the dual-mode WSL *secondary*, surfaced - // inline in Connections settings. The primary's failure is handled by the - // pool (dialog + Windows fallback) instead; here the app stays usable on - // Windows, so we record the reason rather than interrupting. Cleared on any - // reconcile state change so it reflects the current attempt. const preflightErrorRef = yield* Ref.make(Option.none()); const ownershipRefusedRef = yield* Ref.make( Option.none(), @@ -126,8 +90,6 @@ export const layer = Layer.effect( pool.unregister(id).pipe( Effect.catchTags({ DesktopBackendPoolCannotUnregisterPrimaryError: (cause) => - // Should never happen โ€” wsl: ids are not the primary id โ€” but - // log loudly if the logic ever drifts. logWslBackendWarning("refusing to unregister primary as wsl instance", { id, error: cause.message, @@ -166,9 +128,6 @@ export const layer = Layer.effect( id: targetId, label: Effect.succeed(buildLabel(input.distro)), configResolve: configuration.resolveWsl({ port: allocatedPort, distro: input.distro }), - // Dual-mode secondary: record a fatal preflight failure so Connections - // settings can show why the WSL backend never appeared. No dialog or - // fallback โ€” Windows is the primary and keeps working. onPreflightFailed: (failure) => Ref.set(preflightErrorRef, Option.some(failure.reason)).pipe(Effect.as(false)), onStateDirOwned: () => @@ -220,7 +179,6 @@ export const layer = Layer.effect( ? Option.some(resolveTargetInstanceId(settings.wslDistro)) : Option.none(); - // No-op if the desired state already matches what's registered. if (Option.isNone(targetId) && Option.isNone(existingId)) { return; } @@ -243,9 +201,6 @@ export const layer = Layer.effect( return; } - // A real state change is happening (start, stop, or distro swap). Clear - // any stale secondary preflight error so it reflects this fresh attempt; - // onPreflightFailed re-sets it only if the new secondary exhausts retries. yield* Ref.set(preflightErrorRef, Option.none()); yield* Ref.set(ownershipRefusedRef, Option.none()); @@ -255,23 +210,13 @@ 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. preWarm tolerates - // distro=null (uses the WSL default) and is bounded by its own - // timeout, so it's safe to await unconditionally here. + // Pre-warm the WSL VM before registering so the readiness probe doesn't + // race wsl.exe's first-spawn cold start. yield* wslEnvironment.preWarm(settings.wslDistro); yield* startNew({ distro: settings.wslDistro }); } }); - // Top-level safety net. Every internal step today already catches - // its own failures (port allocation, register, preWarm), so the - // inferred error type is `never` and this catch is a no-op in - // steady state. It's here to enforce the file-header contract - // ("reconcile never fails; errors are logged") if a future change - // introduces an unhandled failure path โ€” otherwise IPC callers - // like setWslBackendEnabled would surface it to the renderer as - // an opaque error. const reconcile = reconcileMutex .withPermits(1)(reconcileBody) .pipe( diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index a3cb739be0f6..0224eca440ea 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -35,8 +35,6 @@ const encoder = new TextEncoder(); const REQUIRED_SHELL_TOOLS = ["flock", "sha256sum", "tar", "mktemp"] as const; const posixShellRunner = (() => { - // Candidates rather than a platform switch: wsl.exe simply fails to spawn - // where it does not exist, which is the same answer as a shell missing flock. const candidates = [ { file: "bash", args: [] as ReadonlyArray }, { file: "wsl.exe", args: ["-e", "bash"] as ReadonlyArray }, @@ -69,7 +67,6 @@ const runShell = (script: string) => { const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; -// Polls for up to five seconds until a background holder has taken effect. const awaitHolder = (condition: string) => `i=0; until ${condition}; do i=$((i + 1)); [ "$i" -lt 500 ] || exit 1; sleep 0.01; done`; @@ -83,8 +80,6 @@ const holdRuntimeBusy = (entry: string) => awaitHolder(`grep -qF -- ${entry} /proc/[0-9]*/cmdline 2>/dev/null`), ].join("\n"); -// Holds a cache's install lock the way a concurrent install does, and likewise -// releases it when the script exits. const holdInstallLock = (lock: string) => [ `exec 6> >(exec 9> ${lock}; flock -x 9; cat >/dev/null 2>&1)`, @@ -97,8 +92,6 @@ const readField = (stdout: string, field: string) => { return line.slice(field.length + 1).trim(); }; -// Stands in for the release's self-contained `t3` executable: the install -// script only asks it for `--version`. const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n'; const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => @@ -229,8 +222,6 @@ describe("WSL runtime cache", () => { ); expect(script).toContain(`if [ "$archive_sha" != '${expected}' ]; then`); - // A warm cache exits before the hash, so reuse never pays for it, and the - // mismatch check runs before anything mutates the cache. const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); const digestChecked = script.indexOf("archive_sha=$(sha256sum"); const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); @@ -240,11 +231,6 @@ describe("WSL runtime cache", () => { expect(extracted).toBeGreaterThan(digestChecked); }); - // Invalidation revokes the ready marker without stopping the backend that - // failed the probe, so the next install can find an unready tree that a live - // process is still running out of. Deleting it there unlinks node_modules - // under that process; the pruner already refuses to touch in-use caches, and - // the install path has to refuse too. it("moves an in-use runtime aside instead of deleting it under a live backend", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", @@ -253,8 +239,6 @@ describe("WSL runtime cache", () => { ); expect(script).toContain('grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); - // No /proc means no way to tell, and guessing wrong costs a backend its - // runtime, so an unknowable answer has to count as in use. expect(script).toContain(" [ -d /proc/1 ] || return 0"); expect(script).toContain(' if runtime_in_use "$runtime_root"; then'); @@ -265,9 +249,6 @@ describe("WSL runtime cache", () => { expect(inUseChecked).toBeGreaterThan(-1); expect(inUseChecked).toBeLessThan(moved); - // In use: keep the tree and restart the sweep's clock, because renaming - // preserves the directory's mtime and a long-installed tree would otherwise - // already be past the age gate. Idle: delete it now, as before. const kept = script.indexOf('touch "$runtime_stale"'); const deleted = script.indexOf('rm -rf "$runtime_stale"'); expect(kept).toBeGreaterThan(moved); @@ -281,13 +262,8 @@ describe("WSL runtime cache", () => { "b".repeat(64), ); - // The same proof the SSH runner and CLI installers use: executable, and - // `--version` exits 0. That is what decides arch and loadability, so no - // separate native probe is needed. expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1'); - // Readiness gates the short-circuit, so a cache whose executable broke - // reinstalls from the archive instead of being reused forever. const entryCheckDefined = script.indexOf("runtime_entry_runs() {"); const readinessDefined = script.indexOf("runtime_is_ready() {"); const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); @@ -296,9 +272,6 @@ describe("WSL runtime cache", () => { expect(readinessDefined).toBeLessThan(readyShortCircuit); }); - // A swapped or half-written `t3` can still exist and even still answer - // `--version`, and launch then runs something this install never verified. - // The digest the install records is what turns that into a miss. it("re-hashes the executable against the digest the install recorded", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", @@ -310,14 +283,11 @@ describe("WSL runtime cache", () => { expect(script).toContain( ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', ); - // A runtime installed before the marker carried a digest reads as empty, - // which has to be a miss rather than a pass. expect(script).toContain(' [ -n "$recorded_entry_digest" ] &&'); expect(script).toContain( `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/.t3code-wsl-runtime-ready"`, ); - // The digest is recorded after extraction and before promotion. const extracted = script.indexOf("tar -xzf"); const digestRecorded = script.indexOf( 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")', @@ -338,8 +308,6 @@ describe("WSL runtime cache", () => { expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); - // The extracted tree is rejected before the ready marker is written, so a - // defective archive falls back to the mounted tree instead of caching. const payloadValidated = script.indexOf('runtime_entry_runs "$runtime_tmp"'); const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); @@ -370,18 +338,11 @@ describe("WSL runtime cache", () => { it("never deletes a runtime another backend is running from", () => { const script = buildWslRuntimePruneScript("1.2.3/x64"); - // The running backend's argv holds `/t3`, so - // the process itself is the lease and exiting releases it. Nothing has to be - // registered up front, which is what makes this cover backends already - // running from an older version that knows nothing about pruning. expect(script).toContain(' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); expect(script).toContain(' ! runtime_in_use "$candidate" || continue'); - // Without visible processes the retention rules cannot tell a live cache - // from an abandoned one, so the sweep is skipped rather than guessed at. expect(script).toContain("[ -d /proc/1 ] || exit 0"); - // The guard has to gate the delete, not just exist. const inUseChecked = script.indexOf('! runtime_in_use "$candidate"'); const removed = script.indexOf('rm -rf -- "$candidate"'); expect(inUseChecked).toBeGreaterThan(-1); @@ -391,31 +352,20 @@ describe("WSL runtime cache", () => { it("sweeps orphaned install scratch directories the ready-marker loops cannot see", () => { const script = buildWslRuntimePruneScript("1.2.3/x64"); - // Dot-prefixed, so `"$runtime_parent"/*` never matches them, and they carry - // no ready marker either; without this pass a killed install leaks forever. expect(script).toContain( 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do', ); - // Age guard: a scratch directory younger than this belongs to a live install. expect(script).toContain('find "$scratch" -maxdepth 0 -mmin +120'); }); it("invalidates a cache by dropping its ready marker, not the tree", () => { const script = buildWslRuntimeInvalidateScript("1.2.3/x64"); - // Readiness is a presence check, so a tree whose pty.node is present but - // unloadable stays ready forever unless the probe can revoke the marker. expect(script).toContain('rm -f "$HOME/.t3/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"'); - // Deleting the tree here would pull it out from under any backend still - // running from it; the next install moves an unready root aside instead. expect(script).not.toContain("rm -rf"); }); }); -// Reading the generated script proves what it says, not what it does. A cache -// whose entry was truncated satisfied every assertion above and still got -// reused, so these run the real script against a real archive in a throwaway -// HOME and check the outcome. describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { const fixtures: Array = []; @@ -429,8 +379,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed [ "set -eu", "work=$(mktemp -d)", - // Mirrors the release archive: one top-level versioned directory that - // holds the executable and its native addons. 'stage="$work/stage/t3-0.0.0-linux-x64"', 'mkdir -p "$stage/node_modules/node-pty/build/Release" "$work/home"', `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/t3"`, @@ -474,8 +422,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed [ `export HOME=${sh(`${fixture.work}/home`)}`, 'export NVM_DIR="$HOME/.nvm" FNM_DIR="$HOME/.fnm" VOLTA_HOME="$HOME/.volta"', - // Isolate login profiles and hide the host's Node/version managers. - // The resolver must discover the fixture's installation itself. "bash() { (", " command() {", ' case "$*" in', @@ -541,8 +487,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed it("reuses a warm cache without touching the archive", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); - // Deleting the archive is how the test tells reuse apart from a silent - // reinstall: only the warm path can succeed without it. expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0); const warm = fixture.install(); @@ -572,20 +516,12 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed const broken = fixture.install(); - // Non-zero with no runtimeRoot is what sends the backend to the mounted - // server tree. Exiting 0 here is the bug: launch would pick the zero-byte - // server, fail to become ready, and do it again on every restart. expect(broken.status).not.toBe(0); expect(parseWslRuntimeRoot(broken.stdout)).toBeNull(); }); it("extracts once when two installs race for the same cache", () => { const fixture = createFixture(); - // A tar shim counts extractions and holds the critical section open long - // enough that the second install is certain to arrive while the first is - // still inside it. One extraction is the answer either way the runs - // interleave: whoever waits for the lock re-checks readiness before - // spending an extract, so a broken lock shows up as two. const raced = runShell( [ "set -eu", @@ -626,9 +562,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed it("leaves no half-built cache when extraction fails", () => { const fixture = createFixture(); - // Truncating the archive and re-recording its digest gets the install past - // the digest gate and into a tar that dies mid-stream, which is what a full - // disk or an interrupted write looks like from inside the distro. const truncated = runShell( [ "set -eu", @@ -647,21 +580,12 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(failed.status).not.toBe(0); expect(parseWslRuntimeRoot(failed.stdout)).toBeNull(); - // A partial extract that survived under the cache name would be promoted by - // the next launch's readiness check; scratch that survived would sit there - // until the pruner's age sweep. Neither is left behind. Only directories - // are counted: the empty flock file stays on purpose, which is what keeps - // the lock from carrying stale state across a killed install. const leftovers = runShell( `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`, ); expect(leftovers.stdout.trim()).toBe(""); }); - // The archive and the identity recorded beside it can diverge โ€” a partial - // download, or a rebuilt archive dropped next to an older sidecar. Either - // gate firing means the bytes never reach the cache under a name that claims - // to describe something else. it("refuses an archive whose bytes do not match the digest recorded for it", () => { const fixture = createFixture(); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index dedf4a4a3427..7e00f1b40ad1 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -34,9 +34,6 @@ export interface EnsureWslNodePtyOptions { readonly nodeEngineRange?: string | null; } -// The packaged WSL runtime archive plus the SHA-256 identity the build recorded -// for it. The cache key derives from the same digest, and installation verifies -// the bytes before promoting the extracted tree. export interface WslRuntimeArchive { readonly windowsPath: string; readonly runtimeId: string; @@ -66,9 +63,6 @@ export type EnsureWslNodePtyResult = readonly retryLimit?: number; }; -// Outcome of asking the staged self-contained runtime to prove itself. Any -// failure sends the launch to the mounted server tree; the caller decides what -// to do with the cache. export type ProbeWslRuntimeResult = | { readonly ok: true; @@ -104,26 +98,19 @@ export class DesktopWslEnvironment extends Context.Service< distro: string | null, windowsPath: string, ) => Effect.Effect>; - // Resolves the user's Linux home dir inside the chosen distro (e.g. - // "/home/josh"). Used by the folder picker to expand `~` correctly. readonly getUserHome: (distro: string | null) => Effect.Effect>; - // Resolves the WSL distro's IPv4 address on the WSL vEthernet adapter - // (e.g. "172.x.x.x"). 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). + // 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). readonly getDistroIp: (distro: string | null) => Effect.Effect>; readonly prepareRuntime: ( distro: string | null, archive: WslRuntimeArchive, ) => Effect.Effect; readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect; - // Marks a staged runtime as unusable so the next launch reinstalls it. readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect; - // Proves a staged self-contained runtime can run (`/t3 --version`) - // and resolves the user's PATH, including version-managed Node for provider - // CLIs. Node is optional; the mounted tree still requires ensureNodePty. readonly probeRuntime: ( distro: string | null, linuxAppRoot: string, @@ -183,9 +170,6 @@ const formatWslShellTransportFailureReason = ( } }; -// Reuse the SSH remote resolver so WSL and SSH discover version-managed Node -// the same way. Passing the engine range lets the resolver fall through to -// version managers like nvm when a system node exists but is too old. const buildWslNodeEnvPreamble = ( nodeEngineRange?: string | null, ): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })} @@ -193,8 +177,7 @@ ensure_remote_node_path || true `; // wsl.exe re-escapes args before forwarding them to the Linux side, which -// mangles quotes inside `bash -lc "'; const configuration = JSON.stringify({ deviceId, ticket: "" }); diff --git a/apps/mobile/src/features/devices/device-stream-document.ts b/apps/mobile/src/features/devices/device-stream-document.ts index 961dee9affe0..07a699567b6e 100644 --- a/apps/mobile/src/features/devices/device-stream-document.ts +++ b/apps/mobile/src/features/devices/device-stream-document.ts @@ -1,5 +1,6 @@ import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access"; import type { DevicePlatform } from "@t3tools/contracts"; +import type { PlatformOSType } from "react-native"; export interface DeviceStreamConfiguration { readonly access: DeviceHubAccess; @@ -15,8 +16,20 @@ export interface DeviceStreamConfiguration { }; } +export function deviceStreamBaseUrl( + viewerPlatform: PlatformOSType, + devicePlatform: DevicePlatform, + httpBase: string, +) { + if (viewerPlatform !== "android") return "file:///"; + // Android targets stream through WebCodecs, which needs a secure context. iOS targets + // stream an MJPEG image from the environment, which a synthetic https document blocks as + // mixed content on plain-http environments even with mixedContentMode="always". + if (devicePlatform === "android") return "https://localhost/"; + return `${new URL(httpBase).origin}/`; +} + export function deviceStreamDocument(configuration: string, script: string) { - // Tickets and device names are data, including any HTML delimiter characters. const safeConfiguration = configuration.replace(/(() => diff --git a/apps/mobile/src/features/diagnostics/crash-log-model.test.ts b/apps/mobile/src/features/diagnostics/crash-log-model.test.ts index 761d9a49ed96..49e201ce77dc 100644 --- a/apps/mobile/src/features/diagnostics/crash-log-model.test.ts +++ b/apps/mobile/src/features/diagnostics/crash-log-model.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { formatStartupCrashReport, parseStartupCrashRecords } from "./crash-log-model"; -// Verbatim shape of the entry expo-updates wrote for the build 56 launch crash. const BUNDLE = "/Users/expo/workingdir/build/apps/mobile/ios/build/Build/Intermediates.noindex/ArchiveIntermediates/T3Code/BuildProductsPath/Release-iphoneos/main.jsbundle"; const FATAL = { diff --git a/apps/mobile/src/features/diagnostics/crash-log-model.ts b/apps/mobile/src/features/diagnostics/crash-log-model.ts index 4c222a1f0be9..c4982857739c 100644 --- a/apps/mobile/src/features/diagnostics/crash-log-model.ts +++ b/apps/mobile/src/features/diagnostics/crash-log-model.ts @@ -1,8 +1,3 @@ -/** - * The shape of an expo-updates log entry we care about. Mirrors - * `UpdatesLogEntry` structurally so the model needs no native module at test - * time. - */ export interface UpdatesLogEntryLike { readonly timestamp: number; readonly message: string; @@ -10,14 +5,10 @@ export interface UpdatesLogEntryLike { readonly level: string; } -/** One fatal JavaScript error that took the app down at startup. */ export interface StartupCrashRecord { readonly timestamp: number; - /** The `Description:` line, minus the "Unhandled JS Exception:" prefix. */ readonly description: string; - /** Component stack (`at Name (bundle:line:col)`), one frame per entry. */ readonly frames: ReadonlyArray; - /** Everything after the message header, verbatim, for copying. */ readonly detail: string; } @@ -25,11 +16,6 @@ const FATAL_PREFIX = "ErrorRecovery fatal exception: "; const DESCRIPTION_PREFIX = "Description: "; const UNHANDLED_PREFIX = "Unhandled JS Exception: "; -/** - * Keep only the JS runtime fatals expo-updates' ErrorRecovery wrote while - * aborting the process. Everything else in that log (update checks, asset - * loads) is noise for a "why did the app crash" question. - */ export function parseStartupCrashRecords( entries: ReadonlyArray, ): ReadonlyArray { @@ -50,15 +36,9 @@ export function parseStartupCrashRecords( .map(compactFrame); records.push({ timestamp: entry.timestamp, description, frames, detail: body.trim() }); } - // Newest first; the log appends in time order. return records.sort((left, right) => right.timestamp - left.timestamp); } -/** - * `at NewTaskFlowProvider (/Users/expo/โ€ฆ/main.jsbundle:590585:51)` reads as - * `NewTaskFlowProvider (main.jsbundle:590585:51)`. The absolute build path is - * the same on every frame and says nothing. - */ function compactFrame(line: string): string { const match = /^at (.+?) \((.*)\)$/.exec(line); if (!match) return line.slice("at ".length); @@ -67,7 +47,6 @@ function compactFrame(line: string): string { return location === "" ? name : `${name} (${file})`; } -/** The report a user pastes into an issue: every recorded crash, newest first. */ export function formatStartupCrashReport( records: ReadonlyArray, app: { readonly version: string; readonly build: string }, diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts index 24b18edd5db3..0533a3e0c77f 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts @@ -28,7 +28,6 @@ vi.mock("@shikijs/core", async (importOriginal) => { }; }); -// Exercise the native entry path without requiring an iOS or Android runtime. vi.mock("react-native-shiki-engine", async () => { const { createJavaScriptRegexEngine } = await import("@shikijs/engine-javascript"); return { isNativeEngineAvailable: () => true, createNativeEngine: createJavaScriptRegexEngine }; diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 8a5e6c4de8fb..21d7d372740c 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -124,7 +124,6 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly rowHeight: number; readonly contentWidth: number; readonly initialRowIndex?: number; - /** Disable the native vertical scroller when an excerpt grows inside a page. */ readonly scrollEnabled?: boolean; readonly bounces?: boolean; readonly refreshing?: boolean; diff --git a/apps/mobile/src/features/files/AttachmentFileScreen.tsx b/apps/mobile/src/features/files/AttachmentFileScreen.tsx index e877b165eb9f..5a92ea913b57 100644 --- a/apps/mobile/src/features/files/AttachmentFileScreen.tsx +++ b/apps/mobile/src/features/files/AttachmentFileScreen.tsx @@ -1,17 +1,22 @@ +import { Toolbar } from "../../components/ui/Toolbar"; +import * as Dialog from "../../components/ui/Dialog"; /* oxlint-disable react/no-array-index-key -- Captured table rows and columns have stable positions and may contain identical values. */ -import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { MenuAction } from "@react-native-menu/menu"; import { EnvironmentId } from "@t3tools/contracts"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Platform, ScrollView, View } from "react-native"; +import { useEffect, useRef, useState } from "react"; +import { Platform, ScrollView, View } from "react-native"; -import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; -import { AppText as Text } from "../../components/AppText"; -import { AudioFilePreview } from "../../components/AudioFilePreview"; -import { ControlPillMenu } from "../../components/ControlPill"; -import { EmptyState } from "../../components/EmptyState"; +import { + AndroidHeaderIconButton, + AndroidScreenHeader, +} from "../../components/ui/AndroidScreenHeader"; +import { AppText as Text } from "../../components/ui/AppText"; +import { AudioFilePreview } from "./AudioFilePreview"; +import { Menu } from "../../components/ui/Menu"; +import { EmptyState } from "../../components/ui/EmptyState"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { useAttachmentDocument } from "../../lib/attachmentDocument"; import { nativeViewerErrorMessage } from "../../lib/attachmentDownload"; @@ -26,10 +31,6 @@ import { SourceFileSurface } from "./SourceFileSurface"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { HtmlRenderWebView } from "../threads/HtmlRenderWebView"; -/** - * Both the thread stack and the new-task sheet stack register this screen, so a chip in a - * sent message and a chip in a draft open the same view a workspace file does. - */ export type AttachmentFileRouteParams = { readonly environmentId?: string; readonly threadId?: string; @@ -37,15 +38,12 @@ export type AttachmentFileRouteParams = { readonly name: string; readonly mimeType: string; readonly sizeBytes: string; - /** Present for a draft attachment, which may still live only on this device. */ readonly draftKey?: string; - /** Present for a page an agent published with `html_render`, which takes the app theme. */ readonly htmlRender?: "1"; }; type AttachmentFileScreenProps = StaticScreenProps; -/** Kinds this screen cannot render itself; the platform viewer is the primary presentation. */ function nativeViewerKind(kind: ReturnType["kind"]) { if (kind === "image") return "image" as const; if (kind === "pdf") return "pdf" as const; @@ -192,17 +190,15 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { attachment: localAttachment, }); const [nativeOpen, setNativeOpen] = useState(false); - // A format we cannot render goes straight to the system viewer; this screen is only the - // launch pad and, when no viewer can show it, the honest fallback. const nativeKind = nativeViewerKind(document.kind); const [nativeViewer, setNativeViewer] = useState<"pending" | "open" | "unavailable" | null>( nativeKind ? "pending" : null, ); const [nativeError, setNativeError] = useState(null); const pendingNativeError = useRef(null); - const handleBack = useCallback(() => { + const handleBack = () => { if (navigation.canGoBack()) navigation.goBack(); - }, [navigation]); + }; const { uri, resource } = document; useEffect(() => { if (nativeViewer !== "pending" || !uri) return; @@ -212,23 +208,23 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { }, [nativeViewer, uri]); // The viewer mints its own fresh URL from the resource, so a link that expired while this // screen sat in the background is never handed to Quick Look or ACTION_VIEW. - const nativeSource = useMemo(() => { + const nativeSource = ((): FilePreviewSource | null => { const kind = nativeKind ?? "document"; const base = { kind, name: params.name, mimeType: params.mimeType }; if (localAttachment) return { ...base, attachment: localAttachment }; if (environmentId) return { ...base, environmentId, resource }; return uri ? { ...base, uri } : null; - }, [environmentId, localAttachment, nativeKind, params.mimeType, params.name, resource, uri]); - const handleNativeOpenError = useCallback((error: unknown) => { + })(); + const handleNativeOpenError = (error: unknown) => { pendingNativeError.current = nativeViewerErrorMessage(error); - }, []); - const handleNativeClose = useCallback(() => { + }; + const handleNativeClose = () => { setNativeOpen(false); const message = pendingNativeError.current; pendingNativeError.current = null; if (nativeViewer === null) { // A file this screen renders itself: an explicit viewer failure is worth a word. - if (message) Alert.alert("Could not open document", message); + if (message) Dialog.alert("Could not open document", message); return; } if (message) { @@ -236,119 +232,95 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { setNativeViewer("unavailable"); return; } - // The viewer was the whole visit: return to the conversation rather than a blank screen. handleBack(); - }, [handleBack, nativeViewer]); - const removeFromDraft = useCallback(() => { + }; + const removeFromDraft = () => { if (!draftKey) return; removeComposerDraftAttachment(draftKey, params.attachmentId); handleBack(); - }, [draftKey, handleBack, params.attachmentId]); + }; const { content, renderedMode, activeMode, setRendered, share, sharing } = document; - const menuActions = useMemo( - () => - [ - renderedMode - ? ({ - id: "preview", - title: renderedMode === "table" ? "Table" : "Preview", - icon: renderedMode === "table" ? "tablecells" : "eye", - inline: true, - onPress: () => setRendered(true), - } as const) - : null, - renderedMode - ? ({ - id: "source", - title: "Source", - icon: "doc.text", - inline: true, - onPress: () => setRendered(false), - } as const) - : null, - content && activeMode === "source" - ? ({ - id: "word-wrap", - title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap", - icon: "text.alignleft", - inline: false, - onPress: () => setCodeWordBreak(!appearance.codeWordBreak), - } as const) - : null, - content - ? ({ - id: "copy", - title: content.truncated ? "Copy preview" : "Copy contents", - icon: "doc.on.doc", - inline: false, - onPress: () => copyTextWithHaptic(content.text), - } as const) - : null, - uri - ? ({ - id: "share", - title: sharing ? "Opening share sheetโ€ฆ" : "Save or share", - icon: "square.and.arrow.up", - inline: false, - onPress: () => void share(), - } as const) - : null, - uri - ? ({ - id: "open-viewer", - title: "Open in file viewer", - icon: "arrow.up.left.and.arrow.down.right", - inline: false, - onPress: () => { - setNativeViewer((current) => (current === null ? null : "open")); - setNativeOpen(true); - }, - } as const) - : null, - draftKey - ? ({ - id: "remove", - title: "Remove from draft", - icon: "trash", - inline: false, - destructive: true, - onPress: removeFromDraft, - } as const) - : null, - ].filter((action) => action !== null), - [ - appearance.codeWordBreak, - setCodeWordBreak, - content, - draftKey, - removeFromDraft, - activeMode, - renderedMode, - setRendered, - share, - sharing, - uri, - ], - ); + const menuActions = [ + renderedMode + ? ({ + id: "preview", + title: renderedMode === "table" ? "Table" : "Preview", + icon: renderedMode === "table" ? "tablecells" : "eye", + inline: true, + onPress: () => setRendered(true), + } as const) + : null, + renderedMode + ? ({ + id: "source", + title: "Source", + icon: "doc.text", + inline: true, + onPress: () => setRendered(false), + } as const) + : null, + content && activeMode === "source" + ? ({ + id: "word-wrap", + title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap", + icon: "text.alignleft", + inline: false, + onPress: () => setCodeWordBreak(!appearance.codeWordBreak), + } as const) + : null, + content + ? ({ + id: "copy", + title: content.truncated ? "Copy preview" : "Copy contents", + icon: "doc.on.doc", + inline: false, + onPress: () => copyTextWithHaptic(content.text), + } as const) + : null, + uri + ? ({ + id: "share", + title: sharing ? "Opening share sheetโ€ฆ" : "Save or share", + icon: "square.and.arrow.up", + inline: false, + onPress: () => void share(), + } as const) + : null, + uri + ? ({ + id: "open-viewer", + title: "Open in file viewer", + icon: "arrow.up.left.and.arrow.down.right", + inline: false, + onPress: () => { + setNativeViewer((current) => (current === null ? null : "open")); + setNativeOpen(true); + }, + } as const) + : null, + draftKey + ? ({ + id: "remove", + title: "Remove from draft", + icon: "trash", + inline: false, + destructive: true, + onPress: removeFromDraft, + } as const) + : null, + ].filter((action) => action !== null); const selectedAction = activeMode === "source" ? "source" : "preview"; - const androidMenuActions = useMemo( - () => - menuActions.map((action) => ({ - id: action.id, - title: action.title, - image: action.icon, - state: action.inline ? (action.id === selectedAction ? "on" : "off") : undefined, - ...("destructive" in action ? { attributes: { destructive: true } } : {}), - })), - [selectedAction, menuActions], - ); - const handleAndroidMenuAction = useCallback( - (event: { nativeEvent: { event: string } }) => { - menuActions.find(({ id }) => id === event.nativeEvent.event)?.onPress(); - }, - [menuActions], - ); + const androidMenuActions = menuActions.map((action) => ({ + id: action.id, + title: action.title, + image: action.icon, + state: action.inline ? (action.id === selectedAction ? "on" : "off") : undefined, + ...("destructive" in action ? { attributes: { destructive: true } } : {}), + })) satisfies MenuAction[]; + const handleAndroidMenuAction = (event: { nativeEvent: { event: string } }) => { + menuActions.find(({ id }) => id === event.nativeEvent.event)?.onPress(); + }; const subtitle = `${draftKey ? "Draft attachment" : "Attachment"}${sizeBytes > 0 ? ` ยท ${formatAttachmentSize(sizeBytes)}` : ""}`; return ( @@ -368,49 +340,49 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { subtitle={subtitle} onBack={handleBack} trailing={ - - + } /> ) : null} - - + + {renderedMode ? ( - + {menuActions .filter(({ inline }) => inline) .map((action) => ( - {action.title} - + ))} - + ) : null} {menuActions .filter(({ inline }) => !inline) .map((action) => ( - {action.title} - + ))} - - + + void }) { const player = useAudioPlayer({ uri: props.uri }, { updateInterval: 500 }); const status = useAudioPlayerStatus(player); const [seekError, setSeekError] = useState(false); + // Video teardown deactivates the shared iOS session, which would stop this player. + useMediaAudioSessionHold(status.playing); + const fullscreenVideoOpen = useFullscreenVideoOpen(); + useEffect(() => { + if (fullscreenVideoOpen) player.pause(); + }, [fullscreenVideoOpen, player]); const seek = (seconds: number, play = false) => { setSeekError(false); void player diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 3cf183520b61..f279c6d8cebe 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -1,11 +1,12 @@ +import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { resolveMobileMarkdownMediaSource } from "../../lib/markdownMediaSource"; import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects"; -import { useCallback, useMemo, useState } from "react"; +import { useState } from "react"; import { RefreshControl, ScrollView, View } from "react-native"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; -import { useFontFamily } from "../../lib/useFontFamily"; +import { FONT_FAMILIES } from "../../lib/fontFamilies"; import { resolveNativeMarkdownTypography } from "../../lib/appearancePreferences"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { @@ -33,10 +34,7 @@ interface MarkdownPreviewStyles { export function useMarkdownPreviewStyles(): MarkdownPreviewStyles { const { appearance } = useAppearancePreferences(); - const nativeMarkdownTypography = useMemo( - () => resolveNativeMarkdownTypography(appearance.baseFontSize), - [appearance.baseFontSize], - ); + const nativeMarkdownTypography = resolveNativeMarkdownTypography(appearance.baseFontSize); const theme = useUniwindTheme(); const body = theme["--color-md-body"]; const strong = theme["--color-md-strong"]; @@ -45,44 +43,31 @@ export function useMarkdownPreviewStyles(): MarkdownPreviewStyles { const codeBackground = theme["--color-md-code-bg"]; const codeText = theme["--color-md-code-text"]; const horizontalRule = theme["--color-md-hr"]; - const regularFontFamily = useFontFamily("regular"); - const boldFontFamily = useFontFamily("bold"); + const regularFontFamily = FONT_FAMILIES.regular; + const boldFontFamily = FONT_FAMILIES.bold; - return useMemo(() => { - return { - nativeTextStyle: { - color: body, - strongColor: strong, - mutedColor: body, - linkColor: link, - inlineCodeColor: codeText, - codeColor: codeText, - codeBackgroundColor: codeBackground, - codeBlockBackgroundColor: codeBackground, - fileTextColor: codeText, - skillTextColor: codeText, - quoteMarkerColor: blockquoteBorder, - dividerColor: horizontalRule, - fontSize: nativeMarkdownTypography.fontSize, - lineHeight: nativeMarkdownTypography.lineHeight, - headingFontSizes: nativeMarkdownTypography.headingFontSizes, - fontFamily: regularFontFamily, - headingFontFamily: boldFontFamily, - boldFontFamily, - }, - }; - }, [ - blockquoteBorder, - body, - codeBackground, - codeText, - horizontalRule, - link, - nativeMarkdownTypography, - regularFontFamily, - strong, - boldFontFamily, - ]); + return { + nativeTextStyle: { + color: body, + strongColor: strong, + mutedColor: body, + linkColor: link, + inlineCodeColor: codeText, + codeColor: codeText, + codeBackgroundColor: codeBackground, + codeBlockBackgroundColor: codeBackground, + fileTextColor: codeText, + skillTextColor: codeText, + quoteMarkerColor: blockquoteBorder, + dividerColor: horizontalRule, + fontSize: nativeMarkdownTypography.fontSize, + lineHeight: nativeMarkdownTypography.lineHeight, + headingFontSizes: nativeMarkdownTypography.headingFontSizes, + fontFamily: regularFontFamily, + headingFontFamily: boldFontFamily, + boldFontFamily, + }, + }; } export function FileMarkdownPreview(props: { @@ -91,92 +76,85 @@ export function FileMarkdownPreview(props: { readonly environmentId: EnvironmentId; readonly markdown: string; readonly relativePath: string; - /** Absent for a file opened from a project draft, which has no thread yet. */ readonly threadId: ThreadId | null; readonly onRefresh?: () => Promise | void; }) { const [isPullRefreshing, setIsPullRefreshing] = useState(false); const [expandedFile, setExpandedFile] = useState(null); const [expandedVideo, setExpandedVideo] = useState(null); - const handlePullToRefresh = useCallback(async () => { + const handlePullToRefresh = async () => { if (!props.onRefresh) { return; } setIsPullRefreshing(true); - try { - await props.onRefresh(); - } finally { - setIsPullRefreshing(false); - } - }, [props.onRefresh]); - const markdownDirectory = useMemo( - () => getBrowseDirectoryPath(resolveWorkspaceFilePath(props.cwd, props.relativePath)), - [props.cwd, props.relativePath], + // RefreshControl only needs the spinner cleared; the caller surfaces its own errors. + await settlePromise(async () => props.onRefresh?.()); + setIsPullRefreshing(false); + }; + const markdownDirectory = getBrowseDirectoryPath( + resolveWorkspaceFilePath(props.cwd, props.relativePath), ); - const renderImage = useCallback( - (image) => { - const media = resolveMobileMarkdownMediaSource(image.href, { - threadId: props.threadId ?? undefined, - workspaceRoot: markdownDirectory, - imageEmbed: true, - }); - const preview = resolveMarkdownMediaPreview(image.href, { - environmentId: props.environmentId, - threadId: props.threadId ?? undefined, - workspaceRoot: markdownDirectory, - captured: props.captured, - imageEmbed: true, - }); - if (media?.access === "direct") { - if (media.kind === "video") { - return ( - - ); - } + const renderImage: MarkdownImageRenderer = (image) => { + const media = resolveMobileMarkdownMediaSource(image.href, { + threadId: props.threadId ?? undefined, + workspaceRoot: markdownDirectory, + imageEmbed: true, + }); + const preview = resolveMarkdownMediaPreview(image.href, { + environmentId: props.environmentId, + threadId: props.threadId ?? undefined, + workspaceRoot: markdownDirectory, + captured: props.captured, + imageEmbed: true, + }); + if (media?.access === "direct") { + if (media.kind === "video") { return ( - ); } - if (props.captured || media === null || media.access === "unavailable") { - return ; - } - if (media.kind === "video") { - return preview?.kind === "video" ? ( - - ) : ( - - ); - } return ( - ); - }, - [markdownDirectory, props.environmentId, props.threadId, props.captured], - ); + } + if (props.captured || media === null || media.access === "unavailable") { + return ; + } + if (media.kind === "video") { + return preview?.kind === "video" ? ( + + ) : ( + + ); + } + return ( + + ); + }; const styles = useMarkdownPreviewStyles(); const resolveImageSource = useMarkdownImageSource({ environmentId: props.environmentId, @@ -184,34 +162,25 @@ export function FileMarkdownPreview(props: { workspaceRoot: markdownDirectory, captured: props.captured, }); - const openMedia = useCallback( - (href: string, imageEmbed = false) => { - const media = resolveMarkdownMediaPreview(href, { - environmentId: props.environmentId, - threadId: props.threadId ?? undefined, - workspaceRoot: markdownDirectory, - captured: props.captured, - imageEmbed, - }); - if (!media) return false; - if (media.kind === "video") setExpandedVideo(media.source); - else setExpandedFile(media.source); - return true; - }, - [markdownDirectory, props.environmentId, props.threadId, props.captured], - ); - const onImagePress = useCallback( - (href: string) => { - openMedia(href, true); - }, - [openMedia], - ); - const onLinkPress = useCallback( - (href: string) => { - if (!openMedia(href)) void tryOpenExternalUrl(href, "markdown-link"); - }, - [openMedia], - ); + const openMedia = (href: string, imageEmbed = false) => { + const media = resolveMarkdownMediaPreview(href, { + environmentId: props.environmentId, + threadId: props.threadId ?? undefined, + workspaceRoot: markdownDirectory, + captured: props.captured, + imageEmbed, + }); + if (!media) return false; + if (media.kind === "video") setExpandedVideo(media.source); + else setExpandedFile(media.source); + return true; + }; + const onImagePress = (href: string) => { + openMedia(href, true); + }; + const onLinkPress = (href: string) => { + if (!openMedia(href)) void tryOpenExternalUrl(href, "markdown-link"); + }; return ( { return ancestors; } -const FileTreeRow = memo(function FileTreeRow(props: { +const FileTreeRow = function FileTreeRow(props: { readonly item: VisibleFileTreeNode; readonly selected: boolean; readonly expanded: boolean; @@ -102,7 +102,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { ) : null} ); -}); +}; export function FileTreeBrowser(props: { readonly entries: ReadonlyArray; @@ -110,6 +110,7 @@ export function FileTreeBrowser(props: { readonly isPending: boolean; readonly isRefreshing: boolean; readonly searchQuery: string; + readonly searchResultPaths: ReadonlySet | null; readonly searchTruncated: boolean; readonly selectedPath: string | null; readonly loadingDirectories: ReadonlySet; @@ -133,24 +134,18 @@ export function FileTreeBrowser(props: { loadingDirectories, selectedPath: controlledSelectedPath, } = props; - const controlledSelectedPathRef = useRef(controlledSelectedPath); const pendingSelectionTimeoutRef = useRef | null>(null); - controlledSelectedPathRef.current = controlledSelectedPath; const selectedPath = pendingSelection?.selectedPathAtPress === controlledSelectedPath ? pendingSelection.path : controlledSelectedPath; - const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]); - const visibleNodes = useMemo( - () => - flattenFileTree({ - nodes: tree, - expanded: expandedPaths, - searchQuery: props.searchQuery, - }), - [expandedPaths, props.searchQuery, tree], - ); + const tree = cachedFileTree(props.entries); + const visibleNodes = flattenFileTree({ + nodes: tree, + expanded: expandedPaths, + searchResultPaths: props.searchResultPaths, + }); useEffect(() => { if (!controlledSelectedPath) { @@ -182,64 +177,45 @@ export function FileTreeBrowser(props: { [], ); - const toggleDirectory = useCallback( - (path: string, expand: boolean) => { - if (expand) onLoadDirectory(path); - setExpandedPaths((current) => { - const next = new Set(current); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } - return next; - }); - }, - [onLoadDirectory], - ); - const handleSelectFile = useCallback( - (path: string) => { - if (pendingSelectionTimeoutRef.current !== null) { - clearTimeout(pendingSelectionTimeoutRef.current); + const toggleDirectory = (path: string, expand: boolean) => { + if (expand) onLoadDirectory(path); + setExpandedPaths((current) => { + const next = new Set(current); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); } - setPendingSelection({ - path, - selectedPathAtPress: controlledSelectedPathRef.current, - }); - pendingSelectionTimeoutRef.current = setTimeout(() => { - pendingSelectionTimeoutRef.current = null; - setPendingSelection((current) => (current?.path === path ? null : current)); - }, OPTIMISTIC_SELECTION_TIMEOUT_MS); - onSelectFile(path); - }, - [onSelectFile], - ); - const renderItem = useCallback( - ({ item }: { readonly item: VisibleFileTreeNode }) => ( - - ), - [ - expandedPaths, - handleSelectFile, - onPreviewFile, - loadingDirectories, - selectedPath, - toggleDirectory, - ], + return next; + }); + }; + const handleSelectFile = (path: string) => { + if (pendingSelectionTimeoutRef.current !== null) { + clearTimeout(pendingSelectionTimeoutRef.current); + } + setPendingSelection({ + path, + selectedPathAtPress: controlledSelectedPath, + }); + pendingSelectionTimeoutRef.current = setTimeout(() => { + pendingSelectionTimeoutRef.current = null; + setPendingSelection((current) => (current?.path === path ? null : current)); + }, OPTIMISTIC_SELECTION_TIMEOUT_MS); + onSelectFile(path); + }; + const renderItem = ({ item }: { readonly item: VisibleFileTreeNode }) => ( + ); - const extraData = useMemo( - () => ({ expandedPaths, loadingDirectories, selectedPath }), - [expandedPaths, loadingDirectories, selectedPath], - ); + const extraData = { expandedPaths, loadingDirectories, selectedPath }; // UIKit owns the header inset on every supported iOS version. Keep the // list as direct screen content so automatic inset adjustment can find it. diff --git a/apps/mobile/src/features/files/MaterialFilesHeader.tsx b/apps/mobile/src/features/files/FilesHeader.tsx similarity index 84% rename from apps/mobile/src/features/files/MaterialFilesHeader.tsx rename to apps/mobile/src/features/files/FilesHeader.tsx index 17bd76b6ce3b..877a552bf0ec 100644 --- a/apps/mobile/src/features/files/MaterialFilesHeader.tsx +++ b/apps/mobile/src/features/files/FilesHeader.tsx @@ -1,13 +1,16 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { BackHandler, Keyboard, type TextInputInstance, View } from "react-native"; -import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; -import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu"; -import { MaterialSearchField } from "../../components/MaterialSearchField"; -import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout"; +import { + AndroidHeaderIconButton, + AndroidScreenHeader, +} from "../../components/ui/AndroidScreenHeader"; +import { AndroidAnchoredMenu } from "../../components/ui/AndroidAnchoredMenu"; +import { SearchField } from "../../components/ui/SearchField"; +import { useToolbarLayout } from "../../components/ui/useToolbarLayout"; /** Keep Files search in the same header row on compact and expanded layouts. */ -export function MaterialFilesHeader(props: { +export function FilesHeader(props: { readonly projectName: string; readonly searchQuery: string; readonly onSearchQueryChange: (query: string) => void; @@ -15,7 +18,7 @@ export function MaterialFilesHeader(props: { readonly onBack?: () => void; readonly leading?: ReactNode; }) { - const { paddingTop, paddingBottom } = useMaterialToolbarLayout(); + const { paddingTop, paddingBottom } = useToolbarLayout(); const searchRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); const searching = searchOpen || props.searchQuery.length > 0; @@ -37,7 +40,7 @@ export function MaterialFilesHeader(props: { return ( - {/* Keep the title/subtitle's natural height, including larger text, while searching. */} + {} - Promise | void; } type SourceHighlightStatus = "highlighting" | "ready" | "error"; -const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { +const HighlightedSourceLine = function HighlightedSourceLine(props: { readonly codeSurface: ResolvedMobileCodeSurface; readonly index: number; readonly line: string; @@ -128,20 +125,21 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { ); -}); +}; function useSourceFileModel(props: SourceFileSurfaceProps) { const { themeAppearance: theme } = useAppearancePreferences(); - const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]); + const document = prepareSourceFileDocument(props.contents); const { contents: normalizedContents, lines, rowsJson } = document; const targetIndex = props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0 ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1)) : null; - const highlightAtom = useMemo( - () => sourceHighlightAtom({ path: props.path, contents: normalizedContents, theme }), - [normalizedContents, props.path, theme], - ); + const highlightAtom = sourceHighlightAtom({ + path: props.path, + contents: normalizedContents, + theme, + }); const highlightResult = useAtomValue(highlightAtom); const tokens = AsyncResult.isSuccess(highlightResult) ? highlightResult.value : null; const status: SourceHighlightStatus = AsyncResult.isFailure(highlightResult) @@ -169,17 +167,15 @@ function SourceHighlightStatusView(props: { readonly status: SourceHighlightStat function useSourceFileRefresh(onRefresh: SourceFileSurfaceProps["onRefresh"]) { const [isPullRefreshing, setIsPullRefreshing] = useState(false); - const handlePullToRefresh = useCallback(async () => { + const handlePullToRefresh = async () => { if (!onRefresh) { return; } setIsPullRefreshing(true); - try { - await onRefresh(); - } finally { - setIsPullRefreshing(false); - } - }, [onRefresh]); + // RefreshControl only needs the spinner cleared; the caller surfaces its own errors. + await settlePromise(async () => onRefresh()); + setIsPullRefreshing(false); + }; return { isPullRefreshing, handlePullToRefresh }; } @@ -198,16 +194,12 @@ function NativeSourceFileSurface( const { width: viewportWidth } = useWindowDimensions(); const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(onRefresh); - const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); - const selectedRowIdsJson = useMemo( - () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), - [targetIndex], - ); - const themeJson = useMemo( - () => JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId, appTheme)), - [appTheme, themeAppearance, themeId], + const tokensJson = JSON.stringify(buildNativeSourceTokens(tokens)); + const selectedRowIdsJson = JSON.stringify( + targetIndex === null ? [] : [nativeSourceRowId(targetIndex)], ); - const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]); + const themeJson = JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId, appTheme)); + const styleJson = JSON.stringify(nativeSourceStyle); const contentWidth = codeWordBreak ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24) : NATIVE_SOURCE_CONTENT_WIDTH; @@ -247,10 +239,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { const foreground = useUniwindTheme()["--color-foreground"]; const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props); - const selectableTokens = useMemo( - () => (props.selectable ? boundedSelectableSourceTokens(tokens) : null), - [props.selectable, tokens], - ); + const selectableTokens = props.selectable ? boundedSelectableSourceTokens(tokens) : null; const listRef = useRef>(null); const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(props.onRefresh); const refreshControl = props.onRefresh ? ( @@ -267,25 +256,22 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { return () => cancelAnimationFrame(frame); }, [props.path, targetIndex]); - const renderLine = useCallback( - ({ item, index }: { item: string; index: number }) => ( - - ), - [codeSurface, codeWordBreak, targetIndex, tokens], + const renderLine = ({ item, index }: { item: string; index: number }) => ( + ); - // One selectable text for the whole file. On iOS `uiTextView` renders a real `UITextView`, - // which selects across every line, wraps, and lays out long documents through TextKit; on - // Android the primitive is an RN `Text`, which selects across its nested children. Either - // way "select all" takes the file rather than a line, which a `FlatList` row can never do - // because each row is its own selection scope. + // On iOS `uiTextView` renders a real `UITextView`, which selects across every line, + // wraps, and lays out long documents through TextKit; on Android the primitive is an RN + // `Text`, which selects across its nested children. Either way "select all" takes the + // file rather than a line, which a `FlatList` row can never do because each row is its + // own selection scope. const selectableBlock = props.selectable ? ( ); - // Workspace files retain their numbered, virtualized rows, with or without a line target. - // Attachments opt into one selection scope for the entire document. const usesLineList = !props.selectable; const padded = ( {padded} @@ -404,9 +387,6 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { export function SourceFileSurface(props: SourceFileSurfaceProps) { const NativeView = resolveNativeReviewDiffView(); const { codeWordBreak } = useAppearanceCodeSurface(); - // The native canvas draws source lines without text selection or wrapping. Attachments - // need one selectable text view in either wrap mode; workspace line navigation can still - // use the canvas when wrapping is disabled. const surface = NativeView && !codeWordBreak && !props.selectable ? ( diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index ca9adeaea165..a95f8ea8e997 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -2,7 +2,7 @@ import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/files import { environmentSession } from "../../state/session"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; @@ -15,9 +15,9 @@ import { } from "@t3tools/shared/filePreview"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; -import { MaterialScreenContent } from "../../components/MaterialScreenContent"; -import { AudioFilePreview } from "../../components/AudioFilePreview"; -import { EmptyState } from "../../components/EmptyState"; +import { ScreenContent } from "../../components/ui/ScreenContent"; +import { AudioFilePreview } from "./AudioFilePreview"; +import { EmptyState } from "../../components/ui/EmptyState"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { LoadingScreen } from "../../components/LoadingScreen"; import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; @@ -50,7 +50,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; -import { ScreenHeader } from "../../components/ScreenHeader"; +import { AppBar } from "../../components/ui/AppBar"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError"; import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; @@ -73,7 +73,7 @@ function FilesBrowserHeader(props: { readonly onBack: () => void; }) { return ( - inline); return ( - ; @@ -357,7 +352,6 @@ function useThreadFilesWorkspace(params: { }) { const routeEnvironmentId = firstRouteParam(params.environmentId); const routeThreadId = firstRouteParam(params.threadId); - // A project draft has no thread to resolve a workspace from, so it names one itself. const routeCwd = firstRouteParam(params.cwd); const routeProjectName = firstRouteParam(params.projectName); const { selectedThread, selectedThreadProject } = useThreadSelection(); @@ -448,7 +442,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { cwd: !canReadFiles || fileInspector.supported ? null : cwd, searchQuery, }); - const handleReturnToThread = useCallback(() => { + const handleReturnToThread = () => { if (navigation.canGoBack()) { navigation.goBack(); return; @@ -461,57 +455,48 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { }), ); } - }, [environmentId, navigation, threadId]); + }; - const handleSelectFile = useCallback( - (path: string) => { - if (environmentId === null || threadId === null) { - return; - } - const params = { - environmentId: String(environmentId), - threadId: String(threadId), - path: path.split("/").filter((segment) => segment.length > 0), - }; - const navigationAction = resolveFileSelectionNavigationAction({ - hasPersistentFileInspector: fileInspector.supported, - }); - if (navigationAction === "replace") { - navigation.dispatch(StackActions.replace("ThreadFile", params)); - return; - } - navigation.navigate("ThreadFile", params); - }, - [environmentId, fileInspector.supported, navigation, threadId], - ); - const renderInspector = useCallback( - (headerInset: number) => - environmentId !== null && cwd !== null ? ( - - ) : null, - [cwd, environmentId, handleSelectFile, projectName], - ); - const handlePreviewFile = useCallback( - (relativePath: string) => { - if (environmentId === null || cwd === null) { - return; - } - preloadWorkspaceFileContents({ - cwd, - environmentId, - relativePath, - theme: highlightTheme, - }); - }, - [cwd, environmentId, highlightTheme], - ); + const handleSelectFile = (path: string) => { + if (environmentId === null || threadId === null) { + return; + } + const params = { + environmentId: String(environmentId), + threadId: String(threadId), + path: path.split("/").filter((segment) => segment.length > 0), + }; + const navigationAction = resolveFileSelectionNavigationAction({ + hasPersistentFileInspector: fileInspector.supported, + }); + if (navigationAction === "replace") { + navigation.dispatch(StackActions.replace("ThreadFile", params)); + return; + } + navigation.navigate("ThreadFile", params); + }; + const renderInspector = (headerInset: number) => + environmentId !== null && cwd !== null ? ( + + ) : null; + const handlePreviewFile = (relativePath: string) => { + if (environmentId === null || cwd === null) { + return; + } + preloadWorkspaceFileContents({ + cwd, + environmentId, + relativePath, + theme: highlightTheme, + }); + }; useEffect(() => { if (fileInspector.supported && cwd !== null && !revealedInspectorRef.current) { revealedInspectorRef.current = true; @@ -563,7 +548,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { onRefresh={entriesQuery.refresh} onBack={handleReturnToThread} /> - + - + ); @@ -632,49 +618,42 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { environmentId, relativePath: assetPreviewPath, threadId, - // A project draft names its workspace root explicitly: there is no thread to resolve one. draftCwd: threadId === null ? cwd : null, }); const assetPreviewUri = assetPreview._tag === "Success" ? assetPreview.url : null; - const mediaSource = useMemo( - () => - environmentId !== null && - relativePath !== null && - assetPreview.resource !== null && - "path" in assetPreview.resource && - typeof assetPreview.resource.path === "string" && - (isImageFile || isVideoFile) - ? { - reference: mediaFileReference(assetPreview.resource.path, cwd), - name: basename(relativePath), - mimeType: - mediaMimeTypeFromExtension(relativePath.slice(relativePath.lastIndexOf("."))) ?? - "application/octet-stream", - environmentId, - ...(threadId === null ? {} : { threadId }), - resource: assetPreview.resource, - } - : undefined, - [assetPreview.resource, cwd, environmentId, isImageFile, isVideoFile, relativePath, threadId], - ); + const mediaSource: MediaActionsSource | undefined = + environmentId !== null && + relativePath !== null && + assetPreview.resource !== null && + "path" in assetPreview.resource && + typeof assetPreview.resource.path === "string" && + (isImageFile || isVideoFile) + ? { + reference: mediaFileReference(assetPreview.resource.path, cwd), + name: basename(relativePath), + mimeType: + mediaMimeTypeFromExtension(relativePath.slice(relativePath.lastIndexOf("."))) ?? + "application/octet-stream", + environmentId, + ...(threadId === null ? {} : { threadId }), + resource: assetPreview.resource, + } + : undefined; const mediaActions = useMediaActions(mediaSource); - const videoSource = useMemo( - () => - environmentId !== null && - relativePath !== null && - (assetPreview.resource?._tag === "media-file" || - assetPreview.resource?._tag === "draft-workspace-file") - ? { - type: "media", - environmentId, - resource: assetPreview.resource, - name: basename(relativePath), - mimeType: videoMimeType({ name: relativePath, mimeType: "" }) ?? "video/mp4", - actionsSource: mediaSource, - } - : null, - [assetPreview.resource, environmentId, relativePath, mediaSource], - ); + const videoSource: MediaVideoPreviewSource | null = + environmentId !== null && + relativePath !== null && + (assetPreview.resource?._tag === "media-file" || + assetPreview.resource?._tag === "draft-workspace-file") + ? { + type: "media", + environmentId, + resource: assetPreview.resource, + name: basename(relativePath), + mimeType: videoMimeType({ name: relativePath, mimeType: "" }) ?? "video/mp4", + actionsSource: mediaSource, + } + : null; const previewUri = assetPreviewUri === null || previewRevision === 0 ? assetPreviewUri @@ -713,64 +692,50 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ); const fileData = fileQuery.data as ProjectReadFileResult | null; - const handleSelectFile = useCallback( - (path: string) => { - const segments = path.split("/").filter(Boolean); - // A draft has no thread. `ThreadFile` would stringify null and then wait forever for a - // thread to resolve, so a draft stays on its own route and carries its workspace along. - if (threadId === null) { - navigation.dispatch( - StackActions.push("NewTaskFile", { - environmentId: String(environmentId), - ...(cwd === null ? {} : { cwd }), - projectName, - path: segments, - }), - ); - return; - } - navigation.navigate("ThreadFile", { - environmentId: String(environmentId), - threadId: String(threadId), - path: segments, - }); - }, - [cwd, environmentId, navigation, projectName, threadId], - ); - const renderInspector = useCallback( - (headerInset: number) => - fileInspector.supported && environmentId !== null && cwd !== null ? ( - - ) : undefined, - [cwd, environmentId, fileInspector.supported, handleSelectFile, projectName, relativePath], - ); - // The workspace inspector column spans the full window height. On iOS the - // pane brings its own nested native header; elsewhere it pads itself below - // the top inset. + const handleSelectFile = (path: string) => { + const segments = path.split("/").filter(Boolean); + // A draft has no thread. `ThreadFile` would stringify null and then wait forever for a + // thread to resolve, so a draft stays on its own route and carries its workspace along. + if (threadId === null) { + navigation.dispatch( + StackActions.push("NewTaskFile", { + environmentId: String(environmentId), + ...(cwd === null ? {} : { cwd }), + projectName, + path: segments, + }), + ); + return; + } + navigation.navigate("ThreadFile", { + environmentId: String(environmentId), + threadId: String(threadId), + path: segments, + }); + }; + const renderInspector = (headerInset: number) => + fileInspector.supported && environmentId !== null && cwd !== null ? ( + + ) : undefined; const safeAreaInsets = useSafeAreaInsets(); const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top; - // Hand the file navigator to the workspace so it renders beside the - // navigator, outside this screen's native header. - const renderWorkspaceInspector = useCallback( - () => ( - renderInspector(inspectorHeaderInset)} - /> - ), - [cwd, inspectorHeaderInset, renderInspector, threadId], + const renderWorkspaceInspector = () => ( + renderInspector(inspectorHeaderInset)} + /> ); useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); - const fileMenuActions = useMemo(() => { + const fileMenuActions = (() => { if (relativePath === null) return []; const canToggleMode = canPreview && !isImageFile && !isVideoFile && !isAudioFile; return [ @@ -792,7 +757,6 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => setModeOverride({ path: relativePath, mode: "source" }), } as const) : null, - // Only the source body wraps; a rendered preview lays itself out. resolvedActiveMode === "source" ? ({ id: "word-wrap", @@ -822,8 +786,6 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => copyTextWithHaptic(relativePath), } as const, ]), - // Selecting a long file by hand is painful on a phone, so copying the whole thing is - // the action most readers actually want. The attachment screen already offers it. fileData?.contents != null ? ({ id: "copy-contents", @@ -870,26 +832,9 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { } as const) : null, ].filter((action) => action !== null); - }, [ - appearance.codeWordBreak, - setCodeWordBreak, - assetPreviewUri, - assetPreview.refresh, - previewUri, - canPreview, - isAudioFile, - isBrowserFile, - isImageFile, - isVideoFile, - relativePath, - resolvedActiveMode, - mediaSource, - mediaActions.actions, - fileData?.contents, - fileData?.truncated, - ]); - - const handleReturnToThread = useCallback(() => { + })(); + + const handleReturnToThread = () => { if (environmentId !== null && threadId !== null) { navigation.dispatch( StackActions.replace("Thread", { @@ -898,18 +843,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { }), ); } - }, [environmentId, navigation, threadId]); - const handleBack = useCallback(() => { + }; + const handleBack = () => { if (navigation.canGoBack()) { navigation.goBack(); return; } handleReturnToThread(); - }, [handleReturnToThread, navigation]); + }; - // A file opened from a project draft has no thread, and needs none: the thread only supplies - // the workspace to read from and the target to navigate back to, both of which a draft names - // for itself. Wait only for what this file actually cannot render without. if (environmentId === null || (threadId !== null && selectedThread === null)) { return ; } @@ -949,7 +891,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onReturnToThread={handleReturnToThread} actions={fileMenuActions} /> - + fileQuery.refresh()} /> - + setFullScreenPreview(null)} diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index b2e92969567f..7729347c9b05 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -2,9 +2,9 @@ import { useId, useMemo, useState } from "react"; import { Image, Pressable, View } from "react-native"; import { FilePreviewLoading } from "./FilePreviewFeedback"; -import { EmptyState } from "../../components/EmptyState"; +import { EmptyState } from "../../components/ui/EmptyState"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; -import { PresentationSource } from "../../components/NativePresentation"; +import { PresentationSource } from "../../components/ui/NativePresentation"; import { useMediaActions } from "../../state/mediaActions"; import { type MediaActionsSource } from "../../lib/mediaActionsSource"; import { MediaActionsMenu } from "../../components/MediaActionsMenu"; diff --git a/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx b/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx index a1ceebfbc2cb..79df6d05c700 100644 --- a/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx +++ b/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx @@ -2,18 +2,13 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback } from "react"; import { View } from "react-native"; -import { EmptyState } from "../../components/EmptyState"; +import { EmptyState } from "../../components/ui/EmptyState"; import { environmentCatalog } from "../../connection/catalog"; import type { AssetUrlFailureReason } from "../../state/asset-url-state"; import { useAtomCommand } from "../../state/use-atom-command"; import { useEnvironmentPresentation } from "../../state/presentation"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; -/** - * Terminal state for a preview whose signed asset URL will not arrive. A dead - * environment reuses the same notice the terminal and review sheets show, so the - * user gets one recognizable way back online. - */ export function WorkspaceFilePreviewError(props: { readonly environmentId: EnvironmentId | null; readonly reason: AssetUrlFailureReason; diff --git a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx index 1034696ba636..72b31f061e96 100644 --- a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx @@ -3,7 +3,6 @@ import { View } from "react-native"; import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; -/** Uses the signed progressive URL directly; choosing a file never preloads its video bytes as text. */ export function WorkspaceFileVideoPreview(props: { readonly name: string; readonly thumbnailKey: string; diff --git a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx index bc0c90da6212..432f2eb36bdc 100644 --- a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx @@ -1,57 +1,107 @@ -import { useState } from "react"; -import { View } from "react-native"; +import { useEffect, useState } from "react"; +import { BackHandler, Platform, View } from "react-native"; import { WebView } from "react-native-webview"; +import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"; -import { AppActivityIndicator } from "../../components/AppActivityIndicator"; -import { AppText as Text } from "../../components/AppText"; +import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; +import { AppText as Text } from "../../components/ui/AppText"; import { FilePreviewLoading } from "./FilePreviewFeedback"; -import { LoadingStrip } from "../../components/LoadingStrip"; +import { LoadingStrip } from "../../components/ui/LoadingStrip"; +import { PdfPreview } from "../../components/PdfPreview"; +import { ControlPill } from "../../components/ui/ControlPill"; +import { isPdfPreviewUri } from "../../lib/pdfPreview"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) { + if (props.uri === null) { + return ; + } + return ; +} + +function DocumentWebPreview(props: { readonly uri: string }) { + const insets = useSafeAreaInsets(); + const columnMetrics = useNativeColumnLayoutMetrics(); + const pdfTopInset = Platform.OS === "ios" ? (columnMetrics?.safeArea.top ?? insets.top) : 0; const [loadProgress, setLoadProgress] = useState(0); const [loadError, setLoadError] = useState(null); + const [linkedPdf, setLinkedPdf] = useState(null); - if (props.uri === null) { - return ; + useEffect(() => { + if (linkedPdf === null) return; + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + setLinkedPdf(null); + return true; + }); + return () => subscription.remove(); + }, [linkedPdf]); + + if (isPdfPreviewUri(props.uri)) { + return ( + + + + ); } return ( - {loadProgress > 0 && loadProgress < 1 ? : null} - {loadError ? ( - - Preview failed - {loadError} + {linkedPdf ? ( + + + + setLinkedPdf(null)} + /> + ) : null} - { - setLoadProgress(event.nativeEvent.progress); - }} - onLoadStart={() => { - setLoadProgress(0.05); - setLoadError(null); - }} - onLoadEnd={() => { - setLoadProgress(0); - }} - onError={(event) => { - setLoadProgress(0); - setLoadError(event.nativeEvent.description || "The file could not be rendered."); - }} - renderLoading={() => ( - - + + {loadProgress > 0 && loadProgress < 1 ? : null} + {loadError ? ( + + Preview failed + {loadError} - )} - style={{ flex: 1, backgroundColor: "transparent" }} - /> + ) : null} + { + if (request.isTopFrame !== false && isPdfPreviewUri(request.url)) { + setLinkedPdf(request.url); + return false; + } + return true; + }} + startInLoadingState + onLoadProgress={(event) => { + setLoadProgress(event.nativeEvent.progress); + }} + onLoadStart={() => { + setLoadProgress(0.05); + setLoadError(null); + }} + onLoadEnd={() => { + setLoadProgress(0); + }} + onError={(event) => { + setLoadProgress(0); + setLoadError(event.nativeEvent.description || "The file could not be rendered."); + }} + renderLoading={() => ( + + + + )} + style={{ flex: 1, backgroundColor: "transparent" }} + /> + ); } diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts index cdc8999d1b73..507c5c6a45d5 100644 --- a/apps/mobile/src/features/files/filePath.test.ts +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -53,7 +53,6 @@ describe("fileHeaderSubtitle", () => { }); it("shows only the directory for a host file outside the workspace", () => { - // It is not under the project, so naming the project there would be a lie. expect(fileHeaderSubtitle("t3code", "/tmp/report.md")).toBe("/tmp"); }); diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index 24bfaabd708e..654a668cb2c3 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -13,12 +13,10 @@ function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); } -/** A file route holding an absolute path shows a host file outside the workspace. */ export function isAbsolutePath(value: string): boolean { return value.startsWith("/") || isWindowsAbsolutePath(value); } -/** Route segments that `normalizeRoutePath` joins back into the same path, root included. */ export function fileRoutePathSegments(path: string): string[] { const segments = path.split("/").filter((segment) => segment.length > 0); return path.startsWith("/") ? ["", ...segments] : segments; @@ -92,7 +90,6 @@ export function resolveWorkspaceRelativeFilePath( } const relativePath = normalizedTarget.slice(normalizedRoot.length + 1); - // `/repo/../x` starts with the root but escapes it. if (relativePath.split("/").includes("..")) { return null; } @@ -127,10 +124,6 @@ export function fileBreadcrumbs(projectName: string, relativePath: string): File ]; } -/** - * The location line under a file's name: `project ยท parent/dir`. A host file outside the - * workspace is not under the project, so it shows its directory alone. - */ export function fileHeaderSubtitle(projectName: string, relativePath: string): string { const parentDir = relativePath.slice( 0, diff --git a/apps/mobile/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts index edab2ba687b8..eb97254b8a30 100644 --- a/apps/mobile/src/features/files/fileTree.test.ts +++ b/apps/mobile/src/features/files/fileTree.test.ts @@ -51,46 +51,31 @@ describe("mobile file tree helpers", () => { flattenFileTree({ nodes: tree, expanded: new Set(), - searchQuery: "app", + searchResultPaths: new Set(["src/components/App.tsx"]), }).map((item) => item.node.path), ).toEqual(["src", "src/components", "src/components/App.tsx"]); }); - it("supports fuzzy, whitespace-separated path queries", () => { - const tree = buildFileTree([ - { - kind: "file", - path: "docs/internals/workspace-layout.md", - }, - { - kind: "file", - path: ".repos/alchemy-effect/examples/aws-lambda/src/JobNotifications.ts", - }, - { kind: "directory", path: "apps/web/src/components/chat" }, - { kind: "file", path: "apps/web/src/components/chat/ChatHeader.test.ts" }, - { kind: "file", path: "apps/web/src/components/chat/ChatHeader.tsx" }, - { kind: "file", path: "apps/web/src/components/chat/Composer.tsx" }, - ]); - - const expectedPaths = [ - "apps", - "apps/web", - "apps/web/src", - "apps/web/src/components", - "apps/web/src/components/chat", - "apps/web/src/components/chat/ChatHeader.test.ts", - "apps/web/src/components/chat/ChatHeader.tsx", - ]; + it("excludes cached children of matched directories", () => { + expect( + flattenFileTree({ + nodes: buildFileTree(entries), + expanded: new Set(["src", "src/components"]), + searchResultPaths: new Set(["src/components"]), + }).map((item) => item.node.path), + ).toEqual(["src", "src/components"]); + }); - for (const searchQuery of ["chat hea", "cht hdr"]) { - expect( - flattenFileTree({ - nodes: tree, - expanded: new Set(), - searchQuery, - }).map((item) => item.node.path), - ).toEqual(expectedPaths); - } + it("keeps an empty search empty and restores expansion when search clears", () => { + const tree = buildFileTree(entries); + expect( + flattenFileTree({ nodes: tree, expanded: new Set(["src"]), searchResultPaths: new Set() }), + ).toEqual([]); + expect( + flattenFileTree({ nodes: tree, expanded: new Set(), searchResultPaths: null }).map( + (item) => item.node.path, + ), + ).toEqual(["src", "package.json", "README.md"]); }); it("expands top-level directories by default", () => { diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts index 9b945d1a5f5b..17b49d44ce41 100644 --- a/apps/mobile/src/features/files/fileTree.ts +++ b/apps/mobile/src/features/files/fileTree.ts @@ -1,5 +1,4 @@ import type { ProjectEntry } from "@t3tools/contracts"; -import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; export interface FileTreeNode { readonly path: string; @@ -7,8 +6,6 @@ export interface FileTreeNode { readonly kind: ProjectEntry["kind"]; readonly ignored?: boolean; readonly children: ReadonlyArray; - readonly searchSegments: ReadonlyArray; - readonly searchWords: ReadonlyArray; } export interface VisibleFileTreeNode { @@ -37,43 +34,13 @@ function createMutableNode( }; } -function splitSearchWords(value: string): ReadonlyArray { - return value - .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .split(/[^A-Za-z0-9]+/) - .filter(Boolean) - .map((word) => word.toLowerCase()); -} - -function buildNodeSearchTerms(path: string): { - readonly segments: ReadonlyArray; - readonly words: ReadonlyArray; -} { - const segments: string[] = []; - const words: string[] = []; - - for (const segment of path.split("/")) { - if (!segment) { - continue; - } - segments.push(segment.toLowerCase()); - words.push(...splitSearchWords(segment)); - } - - return { segments, words }; -} - function freezeNode(node: MutableFileTreeNode): FileTreeNode { - const searchTerms = buildNodeSearchTerms(node.path); return { path: node.path, name: node.name, kind: node.kind, ...(node.ignored ? { ignored: true } : {}), children: [...node.children.values()].sort(compareNodes).map(freezeNode), - searchSegments: searchTerms.segments, - searchWords: searchTerms.words, }; } @@ -133,44 +100,21 @@ export function defaultExpandedTreePaths(nodes: ReadonlyArray): Re return expanded; } -function valueMatchesSearchToken(value: string, token: string, fuzzy: boolean): boolean { - return ( - scoreQueryMatch({ - value, - query: token, - exactBase: 0, - prefixBase: 2, - boundaryBase: 4, - includesBase: 6, - ...(fuzzy ? { fuzzyBase: 100 } : {}), - boundaryMarkers: ["/", "-", "_", "."], - }) !== null - ); -} - -function nodeMatchesSearch(node: FileTreeNode, tokens: ReadonlyArray): boolean { - return tokens.every( - (token) => - node.searchSegments.some((segment) => valueMatchesSearchToken(segment, token, false)) || - node.searchWords.some((word) => valueMatchesSearchToken(word, token, true)), - ); -} - function flattenNode( output: VisibleFileTreeNode[], node: FileTreeNode, depth: number, expanded: ReadonlySet, - searchTokens: ReadonlyArray, + searchResultPaths: ReadonlySet | null, ): boolean { - const isSearching = searchTokens.length > 0; - const matches = isSearching && nodeMatchesSearch(node, searchTokens); + const isSearching = searchResultPaths !== null; + const matches = searchResultPaths?.has(node.path) ?? false; let descendantMatches = false; const childOutput: VisibleFileTreeNode[] = []; if (node.kind === "directory" && (expanded.has(node.path) || isSearching)) { for (const child of node.children) { - if (flattenNode(childOutput, child, depth + 1, expanded, searchTokens)) { + if (flattenNode(childOutput, child, depth + 1, expanded, searchResultPaths)) { descendantMatches = true; } } @@ -189,13 +133,12 @@ function flattenNode( export function flattenFileTree(input: { readonly nodes: ReadonlyArray; readonly expanded: ReadonlySet; - readonly searchQuery?: string; + // Null restores normal directory expansion; an empty set is a search with no results. + readonly searchResultPaths?: ReadonlySet | null; }): ReadonlyArray { const output: VisibleFileTreeNode[] = []; - const normalizedSearch = normalizeSearchQuery(input.searchQuery ?? ""); - const searchTokens = normalizedSearch.split(/[\s/\\._-]+/).filter(Boolean); for (const node of input.nodes) { - flattenNode(output, node, 0, input.expanded, searchTokens); + flattenNode(output, node, 0, input.expanded, input.searchResultPaths ?? null); } return output; } diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 9ee450edfed2..901024a27827 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -1,8 +1,8 @@ import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { environmentSession } from "../../state/session"; import type { EnvironmentId } from "@t3tools/contracts"; -import { SymbolView } from "../../components/AppSymbol"; -import { MaterialScreenContent } from "../../components/MaterialScreenContent"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { ScreenContent } from "../../components/ui/ScreenContent"; import { useCallback, useMemo, useState, type ComponentProps } from "react"; import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; import { @@ -13,8 +13,8 @@ import { SearchBar, } from "react-native-screens"; -import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; -import { MaterialFilesHeader } from "./MaterialFilesHeader"; +import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; +import { FilesHeader } from "./FilesHeader"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useEnvironmentQuery } from "../../state/query"; @@ -99,6 +99,7 @@ export function ThreadFileNavigatorPane(props: { isPending={fileAccess.isPending || entriesQuery.isPending} isRefreshing={entriesQuery.isRefreshing} searchQuery={searchQuery} + searchResultPaths={entriesQuery.searchResultPaths} searchTruncated={entriesQuery.searchTruncated} selectedPath={props.selectedPath} onPreviewFile={handlePreviewFile} @@ -172,7 +173,7 @@ export function ThreadFileNavigatorPane(props: { style={{ paddingTop: Platform.OS === "android" ? 0 : props.headerInset }} > {Platform.OS === "android" ? ( - ) : null} - {fileTree} + {fileTree} ); } diff --git a/apps/mobile/src/features/files/useFileTreeEntries.ts b/apps/mobile/src/features/files/useFileTreeEntries.ts index 1553736dcdbd..3c728d48ad09 100644 --- a/apps/mobile/src/features/files/useFileTreeEntries.ts +++ b/apps/mobile/src/features/files/useFileTreeEntries.ts @@ -1,7 +1,7 @@ import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; +import { useEffect, useReducer, useRef, useState } from "react"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; @@ -33,17 +33,14 @@ export function useFileTreeEntries(input: { const [, render] = useReducer((value: number) => value + 1, 0); const [entriesRevision, entriesChanged] = useReducer((value: number) => value + 1, 0); const refreshVersion = useRef(0); - const directories = useMemo( - () => ({ - cwd, - environmentId, - entries: new Map>(), - requested: new Set(), - pending: new Map(), - errors: new Map(), - }), - [cwd, environmentId], - ); + const directories = { + cwd, + environmentId, + entries: new Map>(), + requested: new Set(), + pending: new Map(), + errors: new Map(), + }; const [refreshingDirectories, setRefreshingDirectories] = useState( null, ); @@ -55,54 +52,51 @@ export function useFileTreeEntries(input: { }, [directories], ); - const loadDirectory = useCallback( - (directoryPath: string, refresh = false) => { - if ( - cwd === null || - environmentId === null || - (!refresh && directories.entries.has(directoryPath)) || - directories.pending.has(directoryPath) - ) { - return; + const loadDirectory = (directoryPath: string, refresh = false) => { + if ( + cwd === null || + environmentId === null || + (!refresh && directories.entries.has(directoryPath)) || + directories.pending.has(directoryPath) + ) { + return; + } + const controller = new AbortController(); + directories.requested.add(directoryPath); + directories.pending.set(directoryPath, controller); + directories.errors.delete(directoryPath); + render(); + const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); + appAtomRegistry.refresh(atom); + return executeAtomQuery(appAtomRegistry, atom, { + signal: controller.signal, + reportFailure: false, + reportDefect: false, + }).then((result) => { + if (controller.signal.aborted) return; + directories.pending.delete(directoryPath); + if (result._tag === "Success") { + directories.entries.set( + directoryPath, + result.value.entries.filter( + (entry) => + entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, + ), + ); + entriesChanged(); + } else { + const error = Cause.squash(result.cause); + directories.errors.set( + directoryPath, + error instanceof Error ? error.message : "Files unavailable", + ); } - const controller = new AbortController(); - directories.requested.add(directoryPath); - directories.pending.set(directoryPath, controller); - directories.errors.delete(directoryPath); render(); - const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); - appAtomRegistry.refresh(atom); - return executeAtomQuery(appAtomRegistry, atom, { - signal: controller.signal, - reportFailure: false, - reportDefect: false, - }).then((result) => { - if (controller.signal.aborted) return; - directories.pending.delete(directoryPath); - if (result._tag === "Success") { - directories.entries.set( - directoryPath, - result.value.entries.filter( - (entry) => - entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, - ), - ); - entriesChanged(); - } else { - const error = Cause.squash(result.cause); - directories.errors.set( - directoryPath, - error instanceof Error ? error.message : "Files unavailable", - ); - } - render(); - }); - }, - [cwd, directories, environmentId], - ); + }); + }; const { refresh: refreshRoot, data: rootData } = root; const { refresh: refreshSearch, data: searchData } = search; - const snapshot = useMemo(() => { + const snapshot = (() => { const merged = new Map(); if (searching) { for (const entry of searchData?.entries ?? []) merged.set(entry.path, entry); @@ -118,10 +112,17 @@ export function useFileTreeEntries(input: { } }; visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); - return { revision: entriesRevision, entries: [...merged.values()], reachableDirectories }; - }, [directories, entriesRevision, rootData, searchData, searching]); + return { + revision: entriesRevision, + entries: [...merged.values()], + reachableDirectories, + searchResultPaths: searching + ? new Set((searchData?.entries ?? []).map((entry) => entry.path)) + : null, + }; + })(); - const refresh = useCallback(() => { + const refresh = () => { refreshRoot(); if (searching) refreshSearch(); const paths = new Set( @@ -164,20 +165,11 @@ export function useFileTreeEntries(input: { return Promise.all(work).finally(() => { if (version === refreshVersion.current) setRefreshingDirectories(null); }); - }, [ - cwd, - debouncedQuery, - environmentId, - directories, - loadDirectory, - refreshRoot, - refreshSearch, - searching, - snapshot.reachableDirectories, - ]); + }; return { entries: snapshot.entries, + searchResultPaths: snapshot.searchResultPaths, isRefreshing: refreshingDirectories === directories, error: root.error ?? diff --git a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts index 6d59caf2a287..f109ac0042a8 100644 --- a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts +++ b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts @@ -14,7 +14,6 @@ export function useWorkspaceFileAssetUrlState(props: { readonly environmentId: EnvironmentId | null; readonly relativePath: string | null; readonly threadId: ThreadId | null; - /** A draft's workspace root, used only when there is no thread to resolve one from. */ readonly draftCwd?: string | null; }) { const absolutePath = useMemo( @@ -25,8 +24,6 @@ export function useWorkspaceFileAssetUrlState(props: { [props.cwd, props.relativePath], ); - // Video and audio stream from an exact-file URL, and so does anything outside - // the workspace, where no workspace-scoped URL can exist. const relativePath = props.relativePath; const draftCwd = props.draftCwd ?? null; const resource = useMemo(() => { @@ -43,7 +40,6 @@ export function useWorkspaceFileAssetUrlState(props: { path: absolutePath, }; } - // A project draft has no thread, so it names its workspace root explicitly. if (draftCwd === null) return null; return { _tag: "draft-workspace-file", cwd: draftCwd, path: relativePath }; }, [absolutePath, relativePath, props.threadId, draftCwd]); diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index 423c76d91ea3..d8f4a218d6b8 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -6,11 +6,11 @@ import { type NativeSyntheticEvent, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton"; +import { NewThreadButton } from "../../components/NewThreadButton"; import type { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared"; import { useWorkspaceState } from "../../state/workspace"; -import { MaterialFabScrollContext } from "./MaterialFabScrollContext"; -import { updateMaterialFabScroll } from "./material-fab-scroll"; +import { FabScrollContext } from "./FabScrollContext"; +import { updateFabScroll } from "./fab-scroll"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; export function AndroidHomeFabLayout(props: ComponentProps) { @@ -23,7 +23,7 @@ export function AndroidHomeFabLayout(props: ComponentProps) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; - const next = updateMaterialFabScroll( + const next = updateFabScroll( scrollState.current, contentOffset.y, contentSize.height - layoutMeasurement.height, @@ -35,9 +35,9 @@ export function AndroidHomeFabLayout(props: ComponentProps setLayoutWidth(event.nativeEvent.layout.width)}> - {props.children} + {props.children} {!props.disabled && state.hasConnections && layoutWidth !== null ? ( - void; readonly children: ReactNode; diff --git a/apps/mobile/src/features/home/MaterialFabScrollContext.ts b/apps/mobile/src/features/home/FabScrollContext.ts similarity index 60% rename from apps/mobile/src/features/home/MaterialFabScrollContext.ts rename to apps/mobile/src/features/home/FabScrollContext.ts index de99c9ee7d74..286f52b4ac24 100644 --- a/apps/mobile/src/features/home/MaterialFabScrollContext.ts +++ b/apps/mobile/src/features/home/FabScrollContext.ts @@ -1,10 +1,10 @@ import { createContext, useContext } from "react"; import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"; -export const MaterialFabScrollContext = createContext< +export const FabScrollContext = createContext< ((event: NativeSyntheticEvent) => void) | undefined >(undefined); -export function useMaterialFabScroll() { - return useContext(MaterialFabScrollContext); +export function useFabScroll() { + return useContext(FabScrollContext); } diff --git a/apps/mobile/src/features/home/HomeHeader.android.tsx b/apps/mobile/src/features/home/HomeHeader.android.tsx index 0c15bcc09ce7..d6eb9c203566 100644 --- a/apps/mobile/src/features/home/HomeHeader.android.tsx +++ b/apps/mobile/src/features/home/HomeHeader.android.tsx @@ -1,7 +1,7 @@ import type { MenuAction } from "@react-native-menu/menu"; import { useCallback, useMemo } from "react"; import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; +import { ThreadListToolbar } from "./ThreadListToolbar"; import type { HomeHeaderProps } from "./HomeHeader.types"; export type { HomeHeaderEnvironment } from "./HomeHeader.types"; @@ -11,9 +11,6 @@ function checkedMenuState(checked: boolean) { } export function HomeHeader(props: HomeHeaderProps) { - // The list uses a fixed creation order and ignores sort/group options, so - // the filter menu only carries the filters and the "customized" icon state - // keys off those alone. const hasCustomListOptions = props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; const menuActions = useMemo( @@ -95,7 +92,7 @@ export function HomeHeader(props: HomeHeaderProps) { return ( <> - >(); const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); + const deviceType = useDeviceType(); const primaryColumn = use(NativePrimaryColumnContext); const sidebarHeader = Platform.OS === "ios" && primaryColumn !== null && - (Platform.isPad || !usesNativeMailSearchToolbar); - const showsCompactCompose = Platform.OS === "ios" && Platform.isPad && !layout.usesSplitView; + (deviceType === "pad" || !usesNativeMailSearchToolbar); + const showsCompactCompose = deviceType === "pad" && !layout.usesSplitView; const searchBarRef = useRef(null); const focusAfterReveal = useRef(false); useEffect( @@ -40,9 +43,6 @@ export function HomeHeader(props: HomeHeaderProps) { [navigation], ); const iconColor = useUniwindTheme()["--color-icon"]; - // The list uses a fixed creation order and ignores sort/group options, so - // the filter menu only carries the filters and the "customized" icon state - // keys off those alone. const hasCustomListOptions = props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; const focusSearch = useCallback(() => { @@ -66,8 +66,6 @@ export function HomeHeader(props: HomeHeaderProps) { sidebarHeader @@ -104,7 +102,7 @@ export function HomeHeader(props: HomeHeaderProps) { }), ], // The keys below are set per-branch (not `undefined`) so a later - // reapply cannot clobber options owned by NativeHeaderToolbar. + // reapply cannot clobber options owned by Toolbar. ...(sidebarHeader ? { headerSearchBarOptions: { @@ -146,8 +144,6 @@ export function HomeHeader(props: HomeHeaderProps) { ], } : { - // Pre-Liquid-Glass iOS: standard pull-down search in the nav - // bar; create + sort live in the plain bottom toolbar below. headerSearchBarOptions: { ref: searchBarRef, autoCapitalize: "none" as const, @@ -165,8 +161,8 @@ export function HomeHeader(props: HomeHeaderProps) { /> {sidebarHeader || usesNativeMailSearchToolbar ? null : ( - - + - - Environment - + Environment + props.onEnvironmentChange(null)} subtitle="Show threads from every environment" > - All environments - + All environments + {props.environments.map((environment) => ( - props.onEnvironmentChange(environment.environmentId)} > - {environment.label} - + {environment.label} + ))} - + {props.projects.length > 0 ? ( - - Project - + Project + props.onProjectChange(null)} subtitle="Show threads from every project" > - All projects - + All projects + {props.projects.map((project) => ( - props.onProjectChange(project.key)} > - {project.label} - + {project.label} + ))} - + ) : null} - - - + + - + )} ); diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 8ce82e951820..e15b861b341a 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -1,3 +1,4 @@ +import { Toolbar } from "../../components/ui/Toolbar"; import { use } from "react"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import * as Arr from "effect/Array"; @@ -9,16 +10,16 @@ import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; -import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects } from "../../state/entities"; import { environmentThreadShells } from "../../state/threads"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; -import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; +import { useDeviceType, useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; @@ -32,12 +33,11 @@ import { useThreadListActions } from "./useThreadListActions"; import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; -/* โ”€โ”€โ”€ Route screen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ - export function HomeRouteScreen() { const { width: windowWidth } = useWindowDimensions(); const nativePrimaryColumn = use(NativePrimaryColumnContext); const columnMetrics = useNativeColumnLayoutMetrics(); + const deviceType = useDeviceType(); const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; const { layout, panes, primarySidebarSearchQuery, setPrimarySidebarSearchQuery } = useAdaptiveWorkspaceLayout(); @@ -148,15 +148,12 @@ export function HomeRouteScreen() { })), [listOptions.projectGroupingMode, projects, selectedEnvironmentId], ); - // A saved project stays selected while its environment is offline or filtered out. const selectedProjectKey = projectFilterOptions.some( (project) => project.key === resolvedProjectKey, ) ? resolvedProjectKey : null; - // In split layouts the persistent sidebar IS the thread list โ€” Home becomes - // an empty detail pane so selecting a thread never transitions layouts. if (layout.usesSplitView && !nativePrimaryColumn) { return ( <> @@ -168,7 +165,6 @@ export function HomeRouteScreen() { title: "", headerTitle: "", unstable_headerLeftItems: () => [], - // Options survive layout changes; search and settings now belong to the sidebar. headerSearchBarOptions: undefined, unstable_headerRightItems: () => [], unstable_headerToolbarItems: () => [], @@ -176,13 +172,13 @@ export function HomeRouteScreen() { } /> {Platform.OS === "ios" ? ( - - + navigation.navigate("NewTaskSheet", { screen: "NewTask" })} /> - + ) : null} {Platform.OS === "android" ? : null} <> {/* Restore the header after leaving split view; screen options are - shallow-merged. The brand slot also doubles as the connection - status surface while an environment reconnects. */} + shallow-merged. */} {workspace !== null && Platform.OS === "ios" ? null : ( navigation.navigate("SettingsSheet", { screen: "SettingsContent", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index cd0e1c120fc0..e90fc1672bc7 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,5 +1,5 @@ -import { AppActivityIndicator } from "../../components/AppActivityIndicator"; -import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; +import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; +import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import type { ThreadMoveDestination } from "../threads/threadOrder"; import { computeThreadMoveAvailability } from "../threads/threadOrder"; import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; @@ -14,10 +14,11 @@ import { import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; -import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; +import { use, useEffect, useRef, useState, type ComponentProps } from "react"; import { Platform, ScrollView, + useWindowDimensions, View, type ScrollViewProps, type GestureResponderEvent, @@ -30,8 +31,12 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cn } from "../../lib/cn"; -import { EmptyState } from "../../components/EmptyState"; -import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; +import { + ANDROID_READING_MAX_WIDTH, + deriveCenteredContentHorizontalPadding, +} from "../../lib/layout"; +import { EmptyState } from "../../components/ui/EmptyState"; +import { FloatingActionButton } from "../../components/ui/FloatingActionButton"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; @@ -58,6 +63,8 @@ import { ThreadListV2WorkingShelfHeader, } from "../threads/thread-list-v2-items"; import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance"; +import { useOpenThreadPullRequests } from "../threads/use-open-thread-pull-requests"; +import type { ThreadPrPresentation } from "../../state/thread-pr-presentation"; import { buildThreadListV2Items, getThreadListV2OrderedSection, @@ -77,12 +84,10 @@ import { } from "./homeThreadList"; import { createSwipeRowActivation } from "./swipe-row-activation"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; -import { useMaterialFabScroll } from "./MaterialFabScrollContext"; +import { useFabScroll } from "./FabScrollContext"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { ThreadListFilters } from "./ThreadListFilters"; -/* โ”€โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ - interface HomeScreenProps { readonly projects: ReadonlyArray; readonly threads: ReadonlyArray; @@ -106,7 +111,6 @@ interface HomeScreenProps { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; - /** Resolves true iff the settle was dispatched and succeeded. */ readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; readonly onSnoozeThread: ( thread: EnvironmentThreadShell, @@ -132,33 +136,11 @@ interface HomeScreenProps { readonly onNewThreadInProject: (project: EnvironmentProject) => void; } -/* โ”€โ”€โ”€ Layout constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ - -// v2 rows are mixed-height: settled slim rows run ~60dp, single-line cards -// measured ~74dp on device (252px on the Pixel 10 Pro screenshot), two-line -// cards ~94dp. The estimate seeds the recycler's initial container count, -// `ceil((scrollLength + 2 * INITIAL_DRAW_DISTANCE) / estimate)` with the -// initial draw distance capped at 50, so an estimate at or below the average -// row height starts the pool at or above the item count for the short lists -// that LegendList otherwise keeps pooling to exactly its item count โ€” that is -// what stopped the dev-mode "no unused container available" warning on the -// seeded short-list device passes. It is a mitigation, not an elimination: -// after first layout the full drawDistance applies, and a sudden expansion -// past the pooled headroom (~25+ items appearing at once) still creates a -// container on demand with the dev-only warning one pass ahead of the -// measured-height pool expansion. The old tallest-card estimate (~92) fired -// that warning on every ordinary shelf expand, so the average wins. const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; // Rows away from the viewport are cheap dormant frames (see // swipe-row-activation), so render further ahead: a fast fling then reaches // rows that are already built instead of rows still being rebuilt. const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; -/** - * Top spacing between the list and the Android custom header. The Android - * header is rendered in-flow above this screen and - * already consumes the top safe-area inset, so the list only needs breathing - * room here. - */ function deriveEmptyState(props: { readonly catalogState: WorkspaceState; @@ -243,7 +225,6 @@ function HomeScrollView(props: ComponentProps) { style={{ flex: 1 }} scrollEdgeEffects={{ ...nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version), - // Retain the existing sidebar fade on iOS 26; iOS 27 uses the native bar material. top: Number.parseInt(String(Platform.Version), 10) >= 27 ? "automatic" : "soft", }} > @@ -268,8 +249,6 @@ function renderHomeScrollView(props: ScrollViewProps) { return ; } -/* โ”€โ”€โ”€ Main screen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ - export function HomeScreen(props: HomeScreenProps) { const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const workspace = useWorkspaceTabs(); @@ -285,7 +264,6 @@ export function HomeScreen(props: HomeScreenProps) { const fullSwipeWidth = primaryColumn && columnMetrics ? columnMetrics.width - 20 : undefined; const queuedThreadKeys = useQueuedThreadKeys(); const openSwipeableRef = useRef(null); - // Let trackpad scrolling coexist with each row's horizontal swipe gesture. const homeScrollGesture = useNativeGesture(); const insets = useSafeAreaInsets(); const { fabClearance } = useAndroidControlSizing(); @@ -293,29 +271,36 @@ export function HomeScreen(props: HomeScreenProps) { const contentSideInsets = usesNativeWorkspaceColumns ? (columnMetrics ?? screenMetrics)?.safeArea : undefined; - // UIKit's column safe area already includes its bottom toolbar. + // Android lists that span a wide window (foldables in portrait) keep the + // reading width, so rows stay clear of the edges and the camera cutout. + const { width: windowWidth } = useWindowDimensions(); + const androidListGutter = + Platform.OS === "android" + ? deriveCenteredContentHorizontalPadding({ + viewportWidth: windowWidth, + maxContentWidth: ANDROID_READING_MAX_WIDTH, + minimumPadding: 0, + }) + : 0; const iosBottomClearance = Math.max( columnMetrics?.safeArea.bottom ?? insets.bottom, 24, workspace?.bottomClearance ?? 0, ); - const searchEnvironmentIds = useMemo( - () => - props.selectedEnvironmentId === null - ? props.environments - .filter((environment) => environment.connectionState === "connected") - .map((environment) => environment.environmentId) - : props.environments.some( - (environment) => - environment.environmentId === props.selectedEnvironmentId && - environment.connectionState === "connected", - ) - ? [props.selectedEnvironmentId] - : [], - [props.environments, props.selectedEnvironmentId], - ); + const searchEnvironmentIds = + props.selectedEnvironmentId === null + ? props.environments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId) + : props.environments.some( + (environment) => + environment.environmentId === props.selectedEnvironmentId && + environment.connectionState === "connected", + ) + ? [props.selectedEnvironmentId] + : []; const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); - const threadSearchMatchByKey = useMemo(() => { + const threadSearchMatchByKey = (() => { const matches = new Map(); for (const match of threadSearch.matches) { if (match.source === "user" || match.source === "assistant") { @@ -323,28 +308,33 @@ export function HomeScreen(props: HomeScreenProps) { } } return matches; - }, [threadSearch.matches]); - const matchedThreadKeys = useMemo( - () => new Set(threadSearch.matches.map(threadSearchMatchKey)), - [threadSearch.matches], - ); - const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { + })(); + const matchedThreadKeys = new Set(threadSearch.matches.map(threadSearchMatchKey)); + const handleSwipeableWillOpen = (methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { openSwipeableRef.current?.close(); openSwipeableRef.current = methods; } - }, []); + }; - const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { + const handleSwipeableClose = (methods: SwipeableMethods) => { if (openSwipeableRef.current === methods) { openSwipeableRef.current = null; } - }, []); + }; - const handleScrollBeginDrag = useCallback(() => { + const handleScrollBeginDrag = () => { + openSwipeableRef.current?.close(); + }; + const openThreadPullRequests = useOpenThreadPullRequests(); + const handleOpenThreadPullRequests = ( + thread: EnvironmentThreadShell, + pr: ThreadPrPresentation, + ) => { openSwipeableRef.current?.close(); - }, []); - const onMaterialFabScroll = useMaterialFabScroll(); + openThreadPullRequests(thread, pr); + }; + const onFabScroll = useFabScroll(); const listRef = useRef(null); const searchMode = workspace?.state.tabs.threads.searchMode; const restoredOffset = workspace?.state.tabs.threads.scrollOffset; @@ -355,170 +345,97 @@ export function HomeScreen(props: HomeScreenProps) { animated: false, }); }, [searchMode, insets.top, restoredOffset]); - const swipeRowActivation = useMemo(() => createSwipeRowActivation(), []); - const activateVisibleRows = useCallback( - (rows: ReadonlyArray) => { - const state = listRef.current?.getState(); - if (state === undefined || !(state.end >= 0)) return; - swipeRowActivation.activate( - rows.slice(Math.max(0, state.start - 2), state.end + 3).map((row) => row.key), - ); - }, - [swipeRowActivation], - ); + const swipeRowActivation = createSwipeRowActivation(); + const activateVisibleRows = (rows: ReadonlyArray) => { + const state = listRef.current?.getState(); + if (state === undefined || !(state.end >= 0)) return; + swipeRowActivation.activate( + rows.slice(Math.max(0, state.start - 2), state.end + 3).map((row) => row.key), + ); + }; // Status-bar, accessibility and programmatic scrolls never arm the scroll // gate, so every scroll also activates the visible rows once it settles. const activationTimerRef = useRef | undefined>(undefined); useEffect(() => () => clearTimeout(activationTimerRef.current), []); - const handleListScroll = useCallback( - (event: NativeSyntheticEvent) => { - onMaterialFabScroll?.(event); - if (workspace) - workspace.recordScrollOffset( - "threads", - Math.max(0, event.nativeEvent.contentOffset.y + (Platform.OS === "ios" ? insets.top : 0)), - ); - clearTimeout(activationTimerRef.current); - activationTimerRef.current = setTimeout( - () => activateVisibleRows(listRef.current?.getState().data ?? []), - 200, - ); - }, - [activateVisibleRows, onMaterialFabScroll, workspace, insets.top], - ); - const trackListTouches = useCallback( - (event: GestureResponderEvent, started: boolean) => { - const { changedTouches, touches } = event.nativeEvent; - swipeRowActivation.trackTouches( - started ? changedTouches.map((touch) => String(touch.identifier)) : [], - touches.map((touch) => String(touch.identifier)), + const handleListScroll = (event: NativeSyntheticEvent) => { + onFabScroll?.(event); + if (workspace) + workspace.recordScrollOffset( + "threads", + Math.max(0, event.nativeEvent.contentOffset.y + (Platform.OS === "ios" ? insets.top : 0)), ); - }, - [swipeRowActivation], - ); + clearTimeout(activationTimerRef.current); + activationTimerRef.current = setTimeout( + () => activateVisibleRows(listRef.current?.getState().data ?? []), + 200, + ); + }; + const trackListTouches = (event: GestureResponderEvent, started: boolean) => { + const { changedTouches, touches } = event.nativeEvent; + swipeRowActivation.trackTouches( + started ? changedTouches.map((touch) => String(touch.identifier)) : [], + touches.map((touch) => String(touch.identifier)), + ); + }; const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ onScroll: handleListScroll, onScrollBeginDrag: handleScrollBeginDrag, }); - const projectScopes = useMemo( - () => - buildHomeProjectScopes({ - projects: props.projects, - environmentId: props.selectedEnvironmentId, - projectGroupingMode: props.projectGroupingMode, - }), - [props.projectGroupingMode, props.projects, props.selectedEnvironmentId], - ); + const projectScopes = buildHomeProjectScopes({ + projects: props.projects, + environmentId: props.selectedEnvironmentId, + projectGroupingMode: props.projectGroupingMode, + }); const hasSearchQuery = props.searchQuery.trim().length > 0; - const projectByKey = useMemo(() => { + const projectByKey = (() => { const map = new Map(); for (const project of props.projects) { map.set(scopedProjectKey(project.environmentId, project.id), project); } return map; - }, [props.projects]); + })(); const v2ProjectScopeKey = props.selectedProjectKey; - const v2ScopedProjectGroup = useMemo( - () => findHomeProjectScope(projectScopes, v2ProjectScopeKey), - [v2ProjectScopeKey, projectScopes], - ); - const v2ProjectTitleByProjectKey = useMemo( - () => - new Map( - projectScopes.flatMap((scope) => - scope.projectRefs.map( - (projectRef) => - [ - scopedProjectKey(projectRef.environmentId, projectRef.projectId), - scope.title, - ] as const, - ), - ), + const v2ScopedProjectGroup = findHomeProjectScope(projectScopes, v2ProjectScopeKey); + const v2ProjectTitleByProjectKey = new Map( + projectScopes.flatMap((scope) => + scope.projectRefs.map( + (projectRef) => + [scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.title] as const, ), - [projectScopes], + ), ); - const v2ScopedProjectKeys = useMemo( - () => - v2ScopedProjectGroup === null - ? null - : new Set( - v2ScopedProjectGroup.projectRefs.map((projectRef) => - scopedProjectKey(projectRef.environmentId, projectRef.projectId), - ), + const v2ScopedProjectKeys = + v2ScopedProjectGroup === null + ? null + : new Set( + v2ScopedProjectGroup.projectRefs.map((projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId), ), - [v2ScopedProjectGroup], - ); - // Thread List v2 (beta): one flat list in creation order, no grouping. - // Settled threads collapse into a recency tail below the card block. - // Settled threads stay in the live shell stream (settled โ‰  archived), so - // the partition works directly off live shells โ€” no snapshot merging or - // optimistic holds. + ); const handleSettleThread = props.onSettleThread; - const handleSnoozeThread = useCallback( - (thread: EnvironmentThreadShell, snoozedUntil: string) => { - void props.onSnoozeThread(thread, snoozedUntil); - }, - [props.onSnoozeThread], - ); - const handleUnsnoozeThread = useCallback( - (thread: EnvironmentThreadShell) => { - void props.onUnsnoozeThread(thread); - }, - [props.onUnsnoozeThread], - ); - const handlePinThread = useCallback( - (thread: EnvironmentThreadShell) => { - void props.onPinThread(thread); - }, - [props.onPinThread], - ); - const handleMoveThread = useCallback( - (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { - void props.onMoveThread(thread, direction); - }, - [props.onMoveThread], - ); - const handleUnpinThread = useCallback( - (thread: EnvironmentThreadShell) => { - void props.onUnpinThread(thread); - }, - [props.onUnpinThread], - ); - const handleSetThreadAutoSettle = useCallback( - (thread: EnvironmentThreadShell, enabled: boolean) => { - void props.onSetThreadAutoSettle(thread, enabled); - }, - [props.onSetThreadAutoSettle], - ); - const handleRegenerateThreadTitle = useCallback( - (thread: EnvironmentThreadShell) => { - void props.onRegenerateThreadTitle(thread); - }, - [props.onRegenerateThreadTitle], - ); - const handleRenameThread = useCallback( - (thread: EnvironmentThreadShell) => props.onRenameThread(thread), - [props.onRenameThread], - ); + const handleSnoozeThread = props.onSnoozeThread; + const handleUnsnoozeThread = props.onUnsnoozeThread; + const handlePinThread = props.onPinThread; + const handleMoveThread = props.onMoveThread; + const handleUnpinThread = props.onUnpinThread; + const handleSetThreadAutoSettle = props.onSetThreadAutoSettle; + const handleRegenerateThreadTitle = props.onRegenerateThreadTitle; + const handleRenameThread = props.onRenameThread; const handleDeleteThread = props.onDeleteThread; const handleUnsettleThread = props.onUnsettleThread; - // The settled tail renders in pages; expansion resets when the filter - // context changes so environment/search flips never inherit a deep page. const [settledVisibleCount, setSettledVisibleCount] = useState( THREAD_LIST_V2_SETTLED_INITIAL_COUNT, ); const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${v2ProjectScopeKey ?? "all"}:${props.searchQuery.trim()}`; - const lastSettledResetKeyRef = useRef(settledResetKey); - if (lastSettledResetKeyRef.current !== settledResetKey) { - lastSettledResetKeyRef.current = settledResetKey; + const [lastSettledResetKey, setLastSettledResetKey] = useState(settledResetKey); + if (lastSettledResetKey !== settledResetKey) { + setLastSettledResetKey(settledResetKey); setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT); } - const showMoreSettled = useCallback( - () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), - [], - ); + const showMoreSettled = () => + setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT); const { loaded: shelfPreferencesLoaded, settledShelfExpanded, @@ -529,22 +446,17 @@ export function HomeScreen(props: HomeScreenProps) { toggleSnoozedShelf, toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); - // The queued-start and snooze helpers need a clock while the list stays open. - const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); - // Snooze wake times are second-precise; a counter bumped exactly at the - // next wake boundary re-runs the partition with a fresh clock so a woken - // thread reappears immediately instead of on the next minute tick. + // The list's clock is state: the compiler caches anything computed from it on + // its other inputs, so a render-time `new Date()` would stop moving. The minute + // interval and the snooze-wake timer below both advance it. + const [now, setNow] = useState(() => new Date().toISOString()); + const nowMinute = now.slice(0, 16); const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - useFocusEffect( - useCallback(() => { - // Refresh immediately on enable or focus because the previous value can be hours old. - setNowMinute(new Date().toISOString().slice(0, 16)); - const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); - return () => clearInterval(id); - }, []), - ); - // Threads on servers without the settlement capability never classify as - // settled (the user could neither un-settle nor pin them). + useFocusEffect(() => { + setNow(new Date().toISOString()); + const id = setInterval(() => setNow(new Date().toISOString()), 60_000); + return () => clearInterval(id); + }); const listEnvironments = useAtomValue(threadListEnvironmentsAtom); const { providersByEnvironmentId, @@ -562,7 +474,7 @@ export function HomeScreen(props: HomeScreenProps) { // Up/down menu availability for every card, computed once per section per // rebuild (see computeThreadMoveAvailability): per-thread planner calls made // list construction quadratic, and this list rebuilds on every minute tick. - const threadMoveAvailability = useMemo(() => { + const threadMoveAvailability = (() => { const sectionAvailability = (section: "pinned" | "active") => computeThreadMoveAvailability({ allThreads: props.threads, @@ -574,33 +486,19 @@ export function HomeScreen(props: HomeScreenProps) { threads: props.threads, section, pendingOrder, - now: new Date().toISOString(), + now, settlementEnvironmentIds, snoozeEnvironmentIds, queuedThreadKeys, }), }); - // The Working beta orders the inbox by time, so only pins can move. return new Map([ ...sectionAvailability("pinned"), ...(workingShelfEnabled ? [] : sectionAvailability("active")), ]); - }, [ - workingShelfEnabled, - pinReorderEnvironmentIds, - activeReorderEnvironmentIds, - props.threads, - pendingOrder, - queuedThreadKeys, - settlementEnvironmentIds, - snoozeEnvironmentIds, - nowMinute, - snoozeWakeTick, - ]); - const threadListV2Layout = useMemo(() => { + })(); + const threadListV2Layout = (() => { threadListInboxReturns.observe(workingShelfEnabled ? props.threads : null); - // Settled threads are live shells; archived threads keep their original - // "hidden from lists" meaning. return buildThreadListV2Items({ pendingOrder, threads: props.threads.filter((thread) => thread.archivedAt === null), @@ -612,7 +510,7 @@ export function HomeScreen(props: HomeScreenProps) { snoozeEnvironmentIds, queuedThreadKeys, settledLimit: settledVisibleCount, - now: new Date().toISOString(), + now, workingShelfEnabled, workingShelfExpanded, inboxReturnAt: threadListInboxReturns.returnedAt, @@ -620,303 +518,197 @@ export function HomeScreen(props: HomeScreenProps) { settledShelfExpanded, selectedThreadKey, }); - }, [ - workingShelfEnabled, - workingShelfExpanded, - pendingOrder, - selectedThreadKey, - queuedThreadKeys, - nowMinute, - snoozeWakeTick, - snoozedShelfExpanded, - settledShelfExpanded, - settledVisibleCount, - settlementEnvironmentIds, - snoozeEnvironmentIds, - props.searchQuery, - props.selectedEnvironmentId, - props.threads, - matchedThreadKeys, - v2ScopedProjectGroup, - ]); - // Re-partition the moment the earliest snooze expires (clamped to the - // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). + })(); const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; useEffect(() => { if (nextSnoozeWakeAt === null) return; const wakeAtMs = Date.parse(nextSnoozeWakeAt); if (Number.isNaN(wakeAtMs)) return; const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); - const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); + const id = setTimeout(() => { + setNow(new Date().toISOString()); + bumpSnoozeWakeTick((tick) => tick + 1); + }, delayMs); return () => clearTimeout(id); // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout // range) the boundary string is identical and the chain would die. }, [nextSnoozeWakeAt, snoozeWakeTick]); - // Queued tasks are not thread shells, so the v2 partition never sees them; - // they are spliced in below the active block and stay visible and deletable - // while their environment is offline. Same environment scope and search - // filter as the list itself. const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); - const v2PendingTasks = useMemo( - () => - props.pendingTasks.filter( - (pendingTask) => - (props.selectedEnvironmentId === null || - pendingTask.environmentId === props.selectedEnvironmentId) && - (v2ScopedProjectKeys === null || - v2ScopedProjectKeys.has( - scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), - )) && - (v2SearchQuery.length === 0 || - pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), - ), - [props.pendingTasks, props.selectedEnvironmentId, v2ScopedProjectKeys, v2SearchQuery], - ); - const threadListV2Items = useMemo( - () => - buildThreadListV2ListItems({ - items: threadListV2Layout.items, - pendingTasks: v2PendingTasks, - workingCount: threadListV2Layout.workingCount, - workingShelfExpanded, - workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex, - snoozedCount: threadListV2Layout.snoozedCount, - snoozedShelfExpanded, - snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, - settledCount: threadListV2Layout.settledCount, - settledShelfExpanded, - settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, - snoozeLabelNow: `${nowMinute}:00.000Z`, - snoozeEnvironmentIds, - queuedThreadKeys, - moveAvailability: threadMoveAvailability, - shelfPreferencesLoading: !shelfPreferencesLoaded, - }), - [ - nowMinute, - queuedThreadKeys, - threadMoveAvailability, - settledShelfExpanded, - shelfPreferencesLoaded, - snoozedShelfExpanded, - snoozeEnvironmentIds, - threadListV2Layout, - v2PendingTasks, - workingShelfExpanded, - ], + const v2PendingTasks = props.pendingTasks.filter( + (pendingTask) => + (props.selectedEnvironmentId === null || + pendingTask.environmentId === props.selectedEnvironmentId) && + (v2ScopedProjectKeys === null || + v2ScopedProjectKeys.has( + scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), + )) && + (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), ); + const threadListV2Items = buildThreadListV2ListItems({ + items: threadListV2Layout.items, + pendingTasks: v2PendingTasks, + workingCount: threadListV2Layout.workingCount, + workingShelfExpanded, + workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex, + snoozedCount: threadListV2Layout.snoozedCount, + snoozedShelfExpanded, + snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, + settledCount: threadListV2Layout.settledCount, + settledShelfExpanded, + settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, + snoozeLabelNow: `${nowMinute}:00.000Z`, + snoozeEnvironmentIds, + queuedThreadKeys, + moveAvailability: threadMoveAvailability, + shelfPreferencesLoading: !shelfPreferencesLoaded, + }); useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); useEffect(() => { if (swipeEnabled) activateVisibleRows(threadListV2Items); }, [activateVisibleRows, swipeEnabled, threadListV2Items]); - const renderV2Item = useCallback( - ({ item }: { readonly item: ThreadListV2ListItem }) => { - if (item.type === "v2-pending") { - const pendingScopeKey = scopedProjectKey( - item.pendingTask.environmentId, - item.pendingTask.projectId, - ); - return ( - 1 - ? (props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? - null) - : null - } - environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} - showPendingDivider={item.showPendingDivider} - showTrailingDivider={item.showTrailingDivider} - onSelectPendingTask={props.onSelectPendingTask} - onDeletePendingTask={props.onDeletePendingTask} - /> - ); - } - if (item.type === "v2-working-shelf") { - return ( - - ); - } - if (item.type === "v2-snoozed-shelf") { - return ( - - ); - } - if (item.type === "v2-settled-shelf") { - return ( - - ); - } - const thread = item.item.thread; + const renderV2Item = ({ item }: { readonly item: ThreadListV2ListItem }) => { + if (item.type === "v2-pending") { + const pendingScopeKey = scopedProjectKey( + item.pendingTask.environmentId, + item.pendingTask.projectId, + ); return ( - 1 - ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) + ? (props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? + null) : null } - environmentMachine={machineByEnvironmentId.get(thread.environmentId)} - searchMatch={threadSearchMatchByKey.get( - threadSearchMatchKey({ - environmentId: thread.environmentId, - threadId: thread.id, - }), - )} - searchQuery={props.searchQuery} - onSelectThread={props.onSelectThread} - onDeleteThread={handleDeleteThread} - onArchiveThread={props.onArchiveThread} - onRenameThread={handleRenameThread} - onRegenerateThreadTitle={handleRegenerateThreadTitle} - titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} - settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} - onSettleThread={handleSettleThread} - snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} - pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} - autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} - reorderSupported={ - item.item.pinned - ? pinReorderEnvironmentIds.has(thread.environmentId) - : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId) - } - canMoveUp={item.canMoveUp} - canMoveDown={item.canMoveDown} - onSnoozeThread={handleSnoozeThread} - onUnsnoozeThread={handleUnsnoozeThread} - onUnsettleThread={handleUnsettleThread} - onPinThread={handlePinThread} - onUnpinThread={handleUnpinThread} - onSetThreadAutoSettle={handleSetThreadAutoSettle} - onMoveThread={handleMoveThread} - onSwipeableClose={handleSwipeableClose} - onSwipeableWillOpen={handleSwipeableWillOpen} - simultaneousSwipeGesture={homeScrollGesture} - activationKey={item.key} + environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} + showPendingDivider={item.showPendingDivider} + showTrailingDivider={item.showTrailingDivider} + onSelectPendingTask={props.onSelectPendingTask} + onDeletePendingTask={props.onDeletePendingTask} /> ); - }, - [ - handleDeleteThread, - activeReorderEnvironmentIds, - handleMoveThread, - handlePinThread, - handleRegenerateThreadTitle, - handleRenameThread, - handleSettleThread, - handleSnoozeThread, - handleUnpinThread, - handleUnsnoozeThread, - handleSwipeableClose, - handleSwipeableWillOpen, - handleUnsettleThread, - handleSetThreadAutoSettle, - homeScrollGesture, - autoSettleOptOutEnvironmentIds, - pinningEnvironmentIds, - autoSettleOptOutEnvironmentIds, - machineByEnvironmentId, - pinReorderEnvironmentIds, - projectByKey, - props.onArchiveThread, - props.onDeletePendingTask, - props.onSelectPendingTask, - props.onSelectThread, - primaryColumn, - selectedThreadKey, - fullSwipeWidth, - props.onNewThreadOnBranch, - props.savedConnectionsById, - resolveProviderInstance, - providersByEnvironmentId, - settlementEnvironmentIds, - snoozeEnvironmentIds, - threadSearchMatchByKey, - titleRegenerationEnvironmentIds, - toggleSettledShelf, - toggleSnoozedShelf, - toggleWorkingShelf, - v2ProjectTitleByProjectKey, - props.searchQuery, - workingShelfEnabled, - ], - ); - const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); + } + if (item.type === "v2-working-shelf") { + return ( + + ); + } + if (item.type === "v2-snoozed-shelf") { + return ( + + ); + } + if (item.type === "v2-settled-shelf") { + return ( + + ); + } + const thread = item.item.thread; + return ( + 1 + ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) + : null + } + environmentMachine={machineByEnvironmentId.get(thread.environmentId)} + searchMatch={threadSearchMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + )} + searchQuery={props.searchQuery} + onSelectThread={props.onSelectThread} + onOpenThreadPullRequests={handleOpenThreadPullRequests} + onDeleteThread={handleDeleteThread} + onArchiveThread={props.onArchiveThread} + onRenameThread={handleRenameThread} + onRegenerateThreadTitle={handleRegenerateThreadTitle} + titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} + settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} + onSettleThread={handleSettleThread} + snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} + pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} + reorderSupported={ + item.item.pinned + ? pinReorderEnvironmentIds.has(thread.environmentId) + : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId) + } + canMoveUp={item.canMoveUp} + canMoveDown={item.canMoveDown} + onSnoozeThread={handleSnoozeThread} + onUnsnoozeThread={handleUnsnoozeThread} + onUnsettleThread={handleUnsettleThread} + onPinThread={handlePinThread} + onUnpinThread={handleUnpinThread} + onSetThreadAutoSettle={handleSetThreadAutoSettle} + onMoveThread={handleMoveThread} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + simultaneousSwipeGesture={homeScrollGesture} + activationKey={item.key} + /> + ); + }; + const v2KeyExtractor = (item: ThreadListV2ListItem) => item.key; // FlatList/LegendList treat a changed extraData identity as "re-render every // visible row", so an inline object literal would invalidate all rows on // every HomeScreen render โ€” and the minute clock must stay out of it for // the same reason: the clock text is precomputed per item instead. - const v2ExtraData = useMemo( - () => ({ - projectByKey, - projectTitleByProjectKey: v2ProjectTitleByProjectKey, - listEnvironments, - savedConnectionsById: props.savedConnectionsById, - searchQuery: props.searchQuery, - selectedThreadKey, - threadSearchMatchByKey, - // Rows read it for their reorder menu items. - workingShelfEnabled, - }), - [ - projectByKey, - props.searchQuery, - selectedThreadKey, - props.savedConnectionsById, - listEnvironments, - threadSearchMatchByKey, - v2ProjectTitleByProjectKey, - workingShelfEnabled, - ], - ); + const v2ExtraData = { + projectByKey, + projectTitleByProjectKey: v2ProjectTitleByProjectKey, + listEnvironments, + savedConnectionsById: props.savedConnectionsById, + searchQuery: props.searchQuery, + selectedThreadKey, + threadSearchMatchByKey, + workingShelfEnabled, + }; - /* Empty states */ - // The signal must ignore the search/environment filters: an active query - // that matches nothing needs the in-list "No results" state, not the - // full-page "No threads yet". Settled threads are unarchived live shells, - // so the archived-at check already covers the settled shelf. const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; const selectedEnvironmentLabel = @@ -924,9 +716,6 @@ export function HomeScreen(props: HomeScreenProps) { ? null : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? "this environment"); - // Connection state surfaces in the header title slot - // (WorkspaceConnectionTitle) โ€” nothing renders inside the list, so - // reconnects never shift the rows. const emptyState = deriveEmptyState({ catalogState: props.catalogState, projectCount: props.projects.length, @@ -956,7 +745,7 @@ export function HomeScreen(props: HomeScreenProps) { onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} action={ Platform.OS === "android" && !props.catalogState.hasReadyEnvironment ? ( - ); - // Use the v2 project scope for its empty state. Snoozed threads need no - // special empty state: their shelf header is a list row even while collapsed. const v2ListEmpty = hasSearchQuery && threadSearch.isPending ? undefined : hasSearchQuery ? ( - {/* Shared with the iPad sidebar: cells are reused across data - rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated - shell update) from re-rendering untouched rows. */} void; readonly filterActions: MenuAction[]; readonly filterCustomized: boolean; - readonly onFilterAction: NonNullable["onPressAction"]>; + readonly onFilterAction: NonNullable["onPressAction"]>; readonly onOpenSettings: () => void; readonly onOpenEnvironments: () => void; readonly sidebar?: boolean; @@ -44,9 +46,10 @@ export function MaterialThreadListToolbar(props: { const listFocused = homeFocused && (workspace === null || workspace.state.selectedTab === "threads"); const { fabSize } = useAndroidControlSizing(); - const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout(); + const { height: toolbarHeight, ...headerPadding } = useToolbarLayout(); const { state } = useWorkspaceState(); const { onRequestVisibility, onSearchQueryChange } = props; + const showPullRequests = useWorkspaceListNavigation("pullRequests"); const searchRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); const searching = searchOpen || props.searchQuery.length > 0; @@ -86,7 +89,7 @@ export function MaterialThreadListToolbar(props: { ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle"; const searchField = ( - ) : ( <> - {/* Match the visible inset of the trailing 48dp icon button. */} } + brand={} /> + {props.sidebar && workspace ? ( + + ) : null} - {/* Keep the filter above the New thread FAB at every text size. */} {state.hasConnections ? ( {(open) => ( - + )} diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx index 1e461e3f0d7a..f1b9bfd5b926 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -1,11 +1,11 @@ import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { Animated, Platform, Pressable, View } from "react-native"; -import { AppActivityIndicator } from "../../components/AppActivityIndicator"; -import { SymbolView } from "../../components/AppSymbol"; -import { AppText as Text } from "../../components/AppText"; -import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; +import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { AppText as Text } from "../../components/ui/AppText"; +import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import { brandTitleOffset, CompactBrandTitle, @@ -17,18 +17,9 @@ import { type WorkspaceConnectionStatusPresentation, } from "./workspace-connection-status"; -/** - * Delay before a connection interruption surfaces in the title slot. Sub-second - * blips (the common reconnect case) resolve without any UI at all. - */ const STATUS_SHOW_DELAY_MS = 800; const FADE_IN_MS = 250; -/** - * Connection status presentation, debounced for display: null until the - * workspace has been in a non-connected state for STATUS_SHOW_DELAY_MS, - * then live-updating until the workspace reconnects (null again immediately). - */ function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | null { const { state } = useWorkspaceState(); const presentation = workspaceConnectionStatusPresentation(state); @@ -48,17 +39,17 @@ function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | n } /** - * One-shot entrance fade for the status label. Deliberately JS-driven: this can - * mount inside a native header item (RNSScreenStackHeaderSubview), where - * native-driver animated nodes blank the re-hosted view entirely. The JS driver - * updates opacity through the ordinary style path, which those subviews handle. + * Deliberately JS-driven: this can mount inside a native header item + * (RNSScreenStackHeaderSubview), where native-driver animated nodes blank the + * re-hosted view entirely. The JS driver updates opacity through the ordinary + * style path, which those subviews handle. */ function StatusFadeIn(props: { readonly children: ReactNode; readonly grow?: boolean; readonly maxWidth?: number; }) { - const opacity = useRef(new Animated.Value(0)).current; + const [opacity] = useState(() => new Animated.Value(0)); useEffect(() => { const animation = Animated.timing(opacity, { @@ -82,27 +73,12 @@ function StatusFadeIn(props: { ); } -/** - * Renders the brand/title slot of a thread-list surface, swapping the brand - * for the workspace connection status while an environment is unavailable. - * - * Both states occupy the same slot, so connection changes never shift the - * layout below. While connected the brand renders untouched โ€” no wrapper โ€” - * keeping the native header item on the exact element tree that predates the - * status swap. Replaces the old WorkspaceConnectionStatus pill, which inserted - * a row above the thread list. - */ export function WorkspaceConnectionTitle(props: { - /** Content shown while connected (brand lockup or a screen title). */ readonly brand: ReactNode; - /** Opens environment settings. Status is not pressable when omitted. */ readonly onPress?: () => void; - /** Fill the available row width (in-flow headers) instead of hugging content (native title slots). */ readonly grow?: boolean; readonly size?: "navbar" | "pageTitle"; - /** Horizontal correction so the status aligns with the brand in native title slots. */ readonly statusOffset?: number; - /** Space available beside the native header actions. */ readonly maxWidth?: number; }) { const status = useDelayedConnectionStatus(); @@ -159,19 +135,12 @@ export function WorkspaceConnectionTitle(props: { ); } -/** - * getCompactBrandHeaderOptions with the brand slot upgraded to the - * connection-status swap. Screens with an environment-settings callback apply - * this over the static brand options at mount. - */ export function getConnectionAwareBrandHeaderOptions(opts: { readonly headerWidth: number; readonly trailingItemCount?: number; readonly onOpenEnvironments: () => void; readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"]; }): NativeStackNavigationOptions { - // Leave room for bar margins, title spacing and the 44-point native actions. - // Long status labels must not push Settings into UIKit's overflow menu. const maxWidth = Math.max(0, opts.headerWidth - 64 - 44 * (opts.trailingItemCount ?? 1)); return { diff --git a/apps/mobile/src/features/home/fab-scroll.test.ts b/apps/mobile/src/features/home/fab-scroll.test.ts new file mode 100644 index 000000000000..838489c9f383 --- /dev/null +++ b/apps/mobile/src/features/home/fab-scroll.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { updateFabScroll, type FabScrollState } from "./fab-scroll"; + +describe("Material FAB scroll direction", () => { + const initial: FabScrollState = { anchor: 0, expanded: true }; + + it("shrinks scrolling down and expands scrolling up without returning to the top", () => { + const collapsed = updateFabScroll(initial, 80, 500); + expect(collapsed.expanded).toBe(false); + const lower = updateFabScroll(collapsed, 180, 500); + expect(lower.expanded).toBe(false); + expect(updateFabScroll(lower, 160, 500).expanded).toBe(true); + }); + + it("accumulates travel but ignores jitter around a direction change", () => { + let state = updateFabScroll(initial, 10, 500); + expect(state.expanded).toBe(true); + state = updateFabScroll(state, 14, 500); + expect(state.expanded).toBe(false); + state = updateFabScroll(state, 100, 500); + state = updateFabScroll(state, 95, 500); + state = updateFabScroll(state, 98, 500); + expect(state.expanded).toBe(false); + expect(updateFabScroll(state, 88, 500).expanded).toBe(true); + }); + + it("does not expand from bottom bounce or collapse from top bounce", () => { + const bottom = updateFabScroll(initial, 500, 500); + const bounce = updateFabScroll(bottom, 560, 500); + expect(updateFabScroll(bounce, 500, 500).expanded).toBe(false); + expect(updateFabScroll(initial, -50, 500)).toEqual(initial); + }); + + it("expands near the top and when filtering leaves a non-scrollable list", () => { + const collapsed = updateFabScroll(initial, 80, 500); + expect(updateFabScroll(collapsed, 5, 500).expanded).toBe(true); + expect(updateFabScroll(collapsed, 80, -20)).toEqual(initial); + }); +}); diff --git a/apps/mobile/src/features/home/material-fab-scroll.ts b/apps/mobile/src/features/home/fab-scroll.ts similarity index 78% rename from apps/mobile/src/features/home/material-fab-scroll.ts rename to apps/mobile/src/features/home/fab-scroll.ts index b89946803982..2361fdf3f047 100644 --- a/apps/mobile/src/features/home/material-fab-scroll.ts +++ b/apps/mobile/src/features/home/fab-scroll.ts @@ -1,14 +1,14 @@ -export interface MaterialFabScrollState { +export interface FabScrollState { readonly anchor: number; readonly expanded: boolean; } /** Ignore small direction changes and overscroll; always show the label at the top. */ -export function updateMaterialFabScroll( - state: MaterialFabScrollState, +export function updateFabScroll( + state: FabScrollState, offset: number, maxOffset: number, -): MaterialFabScrollState { +): FabScrollState { const y = Math.max(0, Math.min(offset, Math.max(0, maxOffset))); if (y <= 8) return { anchor: y, expanded: true }; const anchor = state.expanded ? Math.min(state.anchor, y) : Math.max(state.anchor, y); diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts index c49d9c786ba9..86cda3680629 100644 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -46,7 +46,6 @@ describe("home list options across layout changes", () => { createElement( HomeListOptionsProvider, { projectGroupingMode: "repository", savedFilters }, - // Replace the list while keeping the workspace provider mounted. createElement(ThreadList, { key: layout }), ), ), @@ -55,7 +54,6 @@ describe("home list options across layout changes", () => { } beforeEach(() => { - // The hook probe renders no DOM, but ReactDOM needs an event target. const document = { nodeType: 9, addEventListener() {}, @@ -110,7 +108,6 @@ describe("home list options across layout changes", () => { }); } } - // Make the next selection (or clear it) from the opposite layout. await showLayout(otherLayout); } }, diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 422ea23c17d6..9ee8cadf6760 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -4,10 +4,8 @@ import { useAtomSet } from "@effect/atom-react"; import { createContext, createElement, - useCallback, useContext, useEffect, - useMemo, useRef, useState, type PropsWithChildren, @@ -48,10 +46,6 @@ interface HomeListOptionsContextValue { const HomeListOptionsContext = createContext(null); -/** - * Keeps list preferences stable while the app moves between compact and split shells. - * Filters start from the saved preference, and changes are saved for the next launch. - */ export function HomeListOptionsProvider({ children, projectGroupingMode, @@ -73,10 +67,7 @@ export function HomeListOptionsProvider({ saved.current = { environmentId, projectKey }; savePreferences({ threadListFilters: saved.current }); }, [environmentId, projectKey, savePreferences]); - const value = useMemo( - () => ({ options, setOptions, projectGroupingMode }), - [options, projectGroupingMode], - ); + const value = { options, setOptions, projectGroupingMode }; return createElement(HomeListOptionsContext, { value }, children); } @@ -99,18 +90,15 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { + const setSelectedEnvironmentId = (value: EnvironmentId | null) => { setOptions((current) => ({ ...current, selectedEnvironmentId: value })); - }, []); - const setSelectedProjectKey = useCallback( - (value: string | null) => { - setOptions((current) => ({ ...current, selectedProjectKey: value })); - }, - [setOptions], - ); - const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => { + }; + const setSelectedProjectKey = (value: string | null) => { + setOptions((current) => ({ ...current, selectedProjectKey: value })); + }; + const setProjectSortOrder = (value: HomeProjectSortOrder) => { setOptions((current) => ({ ...current, projectSortOrder: value })); - }, []); + }; return { options: resolvedOptions, setSelectedEnvironmentId, diff --git a/apps/mobile/src/features/home/home-route-visibility.test.tsx b/apps/mobile/src/features/home/home-route-visibility.test.tsx index f1c670119ce1..ac3493d5fcd9 100644 --- a/apps/mobile/src/features/home/home-route-visibility.test.tsx +++ b/apps/mobile/src/features/home/home-route-visibility.test.tsx @@ -40,7 +40,6 @@ import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visi const shellsAtom = Atom.make(0).pipe(Atom.keepAlive); let registry: AtomRegistry.AtomRegistry; let root: Root | null = null; -// Value of every committed render, the work a shell update costs on device. let renders: Array<{ readonly visible: boolean; readonly value: number }> = []; function Probe() { diff --git a/apps/mobile/src/features/home/home-route-visibility.ts b/apps/mobile/src/features/home/home-route-visibility.ts index 1d5f5c7aab99..b66a539f50f5 100644 --- a/apps/mobile/src/features/home/home-route-visibility.ts +++ b/apps/mobile/src/features/home/home-route-visibility.ts @@ -5,14 +5,9 @@ import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalSt import type { Atom } from "effect/reactivity"; /** - * Whether the Home route can be seen. Home stays mounted under a pushed - * Thread, and native-stack never freezes the route directly below the focused - * one on Fabric, so Home has to stop its own work. - * - * Driven by the native appear/disappear events rather than focus. A form sheet - * over Home blurs it but leaves it on screen, and the presenting screen gets no - * disappear event for it. Home becomes visible as soon as a back swipe or pop - * starts revealing it, so the list is fresh during the transition. + * Home stays mounted under a pushed Thread, and native-stack never freezes the + * route directly below the focused one on Fabric, so Home has to stop its own + * work. */ export function useHomeRouteVisible(): boolean { const navigation = @@ -38,11 +33,6 @@ export function useHomeRouteVisible(): boolean { return visible; } -/** - * Like `useAtomValue`, but unsubscribed while `visible` is false: the last - * visible value is held and atom changes do not re-render. Resubscribing reads - * the current value in the same render that turns visibility back on. - */ export function useAtomValueWhileVisible(atom: Atom.Atom, visible: boolean): A { const registry = useContext(RegistryContext); const held = useRef<{ readonly value: A } | null>(null); diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 8e0f3e81a85c..c8ea261a5d73 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -363,8 +363,6 @@ describe("home project scopes", () => { it("prefers an exact scope key over a project ID that collides with it", () => { const environmentId = EnvironmentId.make("environment-local"); - // project.create accepts caller-supplied IDs, so one project's scoped ref - // key can equal another project's normalized workspace-root scope key. const collidingProject = makeProject({ environmentId, id: ProjectId.make("/workspaces/target"), diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 9119e39cdf36..82eab6b384b9 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -66,8 +66,6 @@ export function buildHomeProjectScopes(input: { }); } -// Prefers an exact scope key so a caller-supplied project ID that equals another -// project's normalized workspace root cannot select the wrong group. export function findHomeProjectScope( scopes: ReadonlyArray, key: string | null, diff --git a/apps/mobile/src/features/home/material-fab-scroll.test.ts b/apps/mobile/src/features/home/material-fab-scroll.test.ts deleted file mode 100644 index c68613ed2999..000000000000 --- a/apps/mobile/src/features/home/material-fab-scroll.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { updateMaterialFabScroll, type MaterialFabScrollState } from "./material-fab-scroll"; - -describe("Material FAB scroll direction", () => { - const initial: MaterialFabScrollState = { anchor: 0, expanded: true }; - - it("shrinks scrolling down and expands scrolling up without returning to the top", () => { - const collapsed = updateMaterialFabScroll(initial, 80, 500); - expect(collapsed.expanded).toBe(false); - const lower = updateMaterialFabScroll(collapsed, 180, 500); - expect(lower.expanded).toBe(false); - expect(updateMaterialFabScroll(lower, 160, 500).expanded).toBe(true); - }); - - it("accumulates travel but ignores jitter around a direction change", () => { - let state = updateMaterialFabScroll(initial, 10, 500); - expect(state.expanded).toBe(true); - state = updateMaterialFabScroll(state, 14, 500); - expect(state.expanded).toBe(false); - state = updateMaterialFabScroll(state, 100, 500); - state = updateMaterialFabScroll(state, 95, 500); - state = updateMaterialFabScroll(state, 98, 500); - expect(state.expanded).toBe(false); - expect(updateMaterialFabScroll(state, 88, 500).expanded).toBe(true); - }); - - it("does not expand from bottom bounce or collapse from top bounce", () => { - const bottom = updateMaterialFabScroll(initial, 500, 500); - const bounce = updateMaterialFabScroll(bottom, 560, 500); - expect(updateMaterialFabScroll(bounce, 500, 500).expanded).toBe(false); - expect(updateMaterialFabScroll(initial, -50, 500)).toEqual(initial); - }); - - it("expands near the top and when filtering leaves a non-scrollable list", () => { - const collapsed = updateMaterialFabScroll(initial, 80, 500); - expect(updateMaterialFabScroll(collapsed, 5, 500).expanded).toBe(true); - expect(updateMaterialFabScroll(collapsed, 80, -20)).toEqual(initial); - }); -}); diff --git a/apps/mobile/src/features/home/swipe-row-activation.test.ts b/apps/mobile/src/features/home/swipe-row-activation.test.ts index 43f8dcf0ca7e..e8ac62d2682c 100644 --- a/apps/mobile/src/features/home/swipe-row-activation.test.ts +++ b/apps/mobile/src/features/home/swipe-row-activation.test.ts @@ -42,7 +42,6 @@ describe("createSwipeRowActivation", () => { activation.trackTouches(["1"], ["1", "2"]); activation.activate(["a"]); - // The list finger lifts while finger 2 stays on another control. activation.trackTouches([], ["2"]); expect(activation.isActive("a")).toBe(true); }); diff --git a/apps/mobile/src/features/home/swipe-row-activation.ts b/apps/mobile/src/features/home/swipe-row-activation.ts index d56d12cba56f..1d9422946617 100644 --- a/apps/mobile/src/features/home/swipe-row-activation.ts +++ b/apps/mobile/src/features/home/swipe-row-activation.ts @@ -4,8 +4,7 @@ import { createContext, use, useSyncExternalStore } from "react"; * Full swipe rows (pan gesture, animated actions, hidden action buttons) only * exist around the viewport. Every other Home row renders a dormant frame that * paints the same content with a fraction of the native views, so a row the - * list rebuilds while scrolling is cheap. The scroll gate already disables - * swipes while the list moves, so rows are activated once it rests. + * list rebuilds while scrolling is cheap. */ export function createSwipeRowActivation() { let activeKeys = new Set(); @@ -30,11 +29,6 @@ export function createSwipeRowActivation() { if (listTouches.size > 0) pendingKeys = keys; else apply(keys); }, - /** - * `started` are touches that just began on the list; `onScreen` is every - * finger still down anywhere. A finger on another control never holds - * changes, and one whose end event went missing is dropped here. - */ trackTouches(started: ReadonlyArray, onScreen: ReadonlyArray) { for (const id of started) listTouches.add(id); for (const id of listTouches) if (!onScreen.includes(id)) listTouches.delete(id); @@ -52,7 +46,6 @@ export const SwipeRowActivationContext = createContext () => {}; -/** Rows outside an activation provider (e.g. the iPad sidebar) stay live. */ export function useSwipeRowDormant(key: string | undefined): boolean { const activation = use(SwipeRowActivationContext); return useSyncExternalStore( diff --git a/apps/mobile/src/features/home/thread-dismissal.ts b/apps/mobile/src/features/home/thread-dismissal.ts index 1e1561570d7a..56f79782e0b3 100644 --- a/apps/mobile/src/features/home/thread-dismissal.ts +++ b/apps/mobile/src/features/home/thread-dismissal.ts @@ -5,7 +5,6 @@ interface ThreadDismissal { const rows = new Map ThreadDismissal>>(); -/** A thread can be visible in Home and the navigation sidebar at once. */ export function registerThreadDismissal(key: string, dismiss: () => ThreadDismissal) { const registrations = rows.get(key) ?? new Set(); registrations.add(dismiss); @@ -16,7 +15,6 @@ export function registerThreadDismissal(key: string, dismiss: () => ThreadDismis }; } -/** Finish the exit before mutating the list; failed commands put the rows back. */ export async function withThreadDismissal( key: string, action: () => Promise, diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 1d61cd3a0af2..dad92ab65be5 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -1,5 +1,5 @@ -import { SymbolView } from "../../components/AppSymbol"; -import { ControlPillMenu } from "../../components/ControlPill"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { Menu } from "../../components/ui/Menu"; import type { MenuAction } from "@react-native-menu/menu"; import * as Haptics from "expo-haptics"; import { @@ -39,11 +39,10 @@ import Animated, { withTiming, } from "react-native-reanimated"; -import { AppText as Text } from "../../components/AppText"; +import { AppText as Text } from "../../components/ui/AppText"; import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; import { registerThreadDismissal } from "./thread-dismissal"; -// Wide enough for the longest action label ("Unarchive"). const ACTION_ITEM_WIDTH = 58; const ACTION_CIRCLE_SIZE = 36; const ACTION_ICON_SIZE = 15; @@ -64,7 +63,7 @@ interface ThreadSwipeAction { readonly label: string; readonly menu?: { readonly actions: MenuAction[]; - readonly onPressAction: NonNullable["onPressAction"]>; + readonly onPressAction: NonNullable["onPressAction"]>; readonly title?: string; }; readonly onPress: () => void; @@ -78,7 +77,6 @@ function swipeActionsWidth(hasSecondaryAction: boolean) { return hasSecondaryAction ? THREAD_SWIPE_ACTIONS_WIDTH : ACTION_ITEM_WIDTH; } -/** `undefined` keeps the v1 Delete default; `null` means one action only. */ function resolveSecondaryAction(input: { readonly close: () => void; readonly onDelete: () => void; @@ -123,8 +121,7 @@ function resolveSecondaryAction(input: { * Delivers the scroll gate to swipeables via context so that flipping it does * NOT re-render whole rows: putting the flag in list extraData/renderItem deps * re-rendered every visible row (hooks, subscriptions and all) exactly at - * scroll start โ€” peak frame pressure. As a context value only the - * ThreadSwipeable consumers re-render. + * scroll start โ€” peak frame pressure. */ const SwipeableScrollGateContext = createContext(true); @@ -143,15 +140,11 @@ export function SwipeableScrollGateProvider(props: { } /** - * Gates row swipes on list scroll activity, mirroring UIKit's own swipe - * actions (`!isDragging && !isDecelerating`). failOffsetY on the swipe pan - * covers the first pan of a scroll, but trackpad scroll sessions spawn fresh - * gesture sessions (momentum catch, direction changes) whose reset - * translation can re-activate a swipe mid-scroll โ€” so while the list has - * moved vertically during an active drag/momentum phase, row swipes are - * disabled entirely. - * - * Spread the returned handlers onto the list and pass `swipeEnabled` to rows. + * failOffsetY on the swipe pan covers the first pan of a scroll, but trackpad + * scroll sessions spawn fresh gesture sessions (momentum catch, direction + * changes) whose reset translation can re-activate a swipe mid-scroll โ€” so + * while the list has moved vertically during an active drag/momentum phase, row + * swipes are disabled entirely. */ export function useSwipeableScrollGate(options?: { readonly onScroll?: (event: NativeSyntheticEvent) => void; @@ -190,9 +183,6 @@ export function useSwipeableScrollGate(options?: { ); const onScroll = useCallback( (event: NativeSyntheticEvent) => { - // Only vertical movement during a user drag arms the gate โ€” a purely - // horizontal row swipe never moves contentOffset.y, and inset-driven - // offset changes at mount happen outside a drag. if ( draggingRef.current && !gateActiveRef.current && @@ -207,8 +197,6 @@ export function useSwipeableScrollGate(options?: { const onScrollEndDrag = useCallback(() => { draggingRef.current = false; clearSettle(); - // If momentum follows, onMomentumScrollBegin cancels this and the gate - // stays armed until the deceleration finishes. settleTimerRef.current = setTimeout(() => update(false), 160); }, [clearSettle, update]); const onMomentumScrollBegin = useCallback(() => { @@ -233,17 +221,14 @@ export function useSwipeableScrollGate(options?: { interface ThreadSwipeableProps { readonly backgroundColor: ColorValue; readonly children: (close: () => void) => ReactNode; - /** Uses action visuals that fit inside compact 44pt rows. The press target - * still spans the row's full height and width. */ readonly compactActions?: boolean; readonly containerStyle?: StyleProp; - /** Disables NEW swipe activations (e.g. while the list scrolls). */ readonly enabled?: boolean; readonly enableTrackpadSwipe?: boolean; /** - * What a full swipe commits. Omitted keeps the v1 Delete behavior only when - * the built-in Delete secondary action is in use; custom or absent - * secondary actions default to the advertised primary action. + * Omitted keeps the v1 Delete behavior only when the built-in Delete + * secondary action is in use; custom or absent secondary actions default to + * the advertised primary action. */ readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeWidth: number; @@ -258,13 +243,7 @@ interface ThreadSwipeableProps { * unadvertised Delete. */ readonly secondaryAction?: ThreadSwipeAction | null; - /** - * Identity of the content being wrapped. When a recycled list reuses this - * component for a different item, the swipeable snaps back to closed so an - * open/mid-drag state can't leak onto another row. - */ readonly resetKey?: string; - /** Paints the row without swipe machinery; see swipe-row-activation. */ readonly dormant?: boolean; readonly simultaneousWith?: ComponentProps["simultaneousWith"]; readonly threadTitle: string; @@ -274,7 +253,6 @@ const closeDormant = () => {}; export function ThreadSwipeable(props: ThreadSwipeableProps) { if (props.dormant) { - // Mirrors ReanimatedSwipeable's container and children views. return ( { if (!finished) return; @@ -685,14 +661,14 @@ function SwipeActionButton(props: { {props.menu === undefined ? ( button ) : ( - {button} - + )} ); diff --git a/apps/mobile/src/features/home/threadArchive.ts b/apps/mobile/src/features/home/threadArchive.ts index 5f0e0c2d6d27..1917e8012332 100644 --- a/apps/mobile/src/features/home/threadArchive.ts +++ b/apps/mobile/src/features/home/threadArchive.ts @@ -3,10 +3,6 @@ import { type ThreadRuntimeSummary, } from "@t3tools/client-runtime/state/models"; -/** - * Archiving may discard queued work, but it must not detach a provider while - * that provider is still executing a turn. - */ export function threadCanArchive(runtime: ThreadRuntimeSummary | null | undefined): boolean { return threadRuntimeCanArchive(runtime); } diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts index dcf127ccae20..20fba857a716 100644 --- a/apps/mobile/src/features/home/usePendingTaskListActions.ts +++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts @@ -1,6 +1,6 @@ +import * as Dialog from "../../components/ui/Dialog"; import { useNavigation } from "@react-navigation/native"; import { useCallback } from "react"; -import { Alert } from "react-native"; import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal"; import { clearComposerDraftContent } from "../../state/use-composer-drafts"; @@ -31,14 +31,12 @@ export function usePendingTaskListActions(): { const confirmDeletePendingTask = useCallback((pendingTask: PendingNewTask) => { if (pendingTask.kind === "draft") { - Alert.alert("Discard draft?", `โ€œ${pendingTask.title}โ€ will be removed.`, [ + Dialog.alert("Discard draft?", `โ€œ${pendingTask.title}โ€ will be removed.`, [ { text: "Cancel", style: "cancel" }, { text: "Discard", style: "destructive", onPress: () => { - // Same reset a submit performs: the next task in this project - // re-resolves project defaults instead of inheriting the pick. clearComposerDraftContent(pendingTask.draftKey, { clearModelSelection: true, clearWorkspaceSelection: true, @@ -48,7 +46,7 @@ export function usePendingTaskListActions(): { ]); return; } - Alert.alert( + Dialog.alert( "Delete pending task?", `โ€œ${pendingTask.title}โ€ has not been sent yet and will be removed from the outbox.`, [ @@ -63,7 +61,7 @@ export function usePendingTaskListActions(): { void removeThreadOutboxMessage(pendingTask.message) .then(() => releaseEditingQueuedMessage(pendingTask.message.messageId)) .catch((error) => { - Alert.alert( + Dialog.alert( "Could not delete pending task", error instanceof Error ? error.message : "The pending task could not be removed.", ); diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 4fffe5f36c10..93b3ef3cb4a5 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -43,8 +43,10 @@ vi.mock("expo-haptics", () => ({ impactAsync: async () => {}, ImpactFeedbackStyle: { Light: "light" }, })); -vi.mock("../../components/ConfirmDialogHost", () => ({ - showConfirmDialog: (dialog: { onConfirm: () => void }) => state.dialogs.push(dialog), +vi.mock("../../components/ui/Dialog", () => ({ + alert: (title: string, _message: string, buttons?: { text: string; onPress?: () => void }[]) => + state.alerts.push({ title, buttons }), + confirm: (dialog: { onConfirm: () => void }) => state.dialogs.push(dialog), })); vi.mock("../archive/useArchivedThreadSnapshots", () => ({ refreshArchivedThreadsForEnvironment: () => {}, @@ -89,10 +91,7 @@ vi.mock("../../state/atom-registry", () => ({ vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command, })); -// Stubbed at the direct dependency: the real outbox pulls the Expo file-system -// storage into a test that only reads which threads are queued. vi.mock("../../state/use-thread-outbox", () => ({ queuedThreadKeysAtom: "queued-thread-keys" })); -// The real hold lives in a module-level atom that would leak between cases. vi.mock("../../state/thread-order", () => ({ threadDropBusyAtom: "thread-drop-busy", getPendingThreadOrder: () => state.pendingOrder, @@ -224,8 +223,7 @@ describe("thread list operation permissions", () => { }, ); - it.each(["ios", "android"])("does not show a forbidden %s delete confirmation", (os) => { - vi.stubEnv("EXPO_OS", os); + it("does not show a forbidden delete confirmation", () => { useThreadListActions().confirmDeleteThread(makeThread({ environmentId: otherEnvironmentId })); expect(state.dialogs).toEqual([]); @@ -233,23 +231,16 @@ describe("thread list operation permissions", () => { expect(state.requests).toEqual([]); }); - it.each(["ios", "android"])( - "rechecks permission after a retained %s delete confirmation", - async (os) => { - vi.stubEnv("EXPO_OS", os); - useThreadListActions().confirmDeleteThread(makeThread()); - const confirm = - os === "ios" - ? state.alerts[0]?.buttons?.find((button) => button.text === "Delete")?.onPress - : state.dialogs[0]?.onConfirm; - expect(confirm).toBeTypeOf("function"); - state.scopes.get(primaryEnvironmentId)!.clear(); + it("rechecks permission after a retained delete confirmation", async () => { + useThreadListActions().confirmDeleteThread(makeThread()); + const confirm = state.dialogs[0]?.onConfirm; + expect(confirm).toBeTypeOf("function"); + state.scopes.get(primaryEnvironmentId)!.clear(); - await confirm!(); + await confirm!(); - expect(state.requests).toEqual([]); - }, - ); + expect(state.requests).toEqual([]); + }); it("unarchives with only task permission and blocks a later revoked callback", async () => { const actions = useArchivedThreadListActions(() => {}); diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 8e5db2faf99d..4ca0fc56140c 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -4,11 +4,10 @@ import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/threa import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; -import { useCallback, useRef } from "react"; -import { Alert, Platform } from "react-native"; +import { useRef } from "react"; import { withThreadDismissal } from "./thread-dismissal"; -import { showConfirmDialog, showTextInputDialog } from "../../components/ConfirmDialogHost"; +import * as Dialog from "../../components/ui/Dialog"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; @@ -112,11 +111,10 @@ function actionFailureTitle(action: ThreadListAction): string { function checkThreadOperationPermission(thread: EnvironmentThreadShell, title: string): boolean { if (readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return true; - Alert.alert(title, "This connection cannot change threads."); + Dialog.alert(title, "This connection cannot change threads."); return false; } -/** Resolves to true iff the action was dispatched and succeeded. */ function useThreadActionExecutor( onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, ) { @@ -127,121 +125,92 @@ function useThreadActionExecutor( const unsettleMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); const inFlightThreadKeys = useRef(new Set()); - const executeAction = useCallback( - async (action: ThreadListAction, thread: EnvironmentThreadShell) => { - if (!checkThreadOperationPermission(thread, actionFailureTitle(action))) return false; - const key = scopedThreadKey(thread.environmentId, thread.id); - if (inFlightThreadKeys.current.has(key)) { + const executeAction = async (action: ThreadListAction, thread: EnvironmentThreadShell) => { + if (!checkThreadOperationPermission(thread, actionFailureTitle(action))) return false; + const key = scopedThreadKey(thread.environmentId, thread.id); + if (inFlightThreadKeys.current.has(key)) { + return false; + } + + inFlightThreadKeys.current.add(key); + selectionHaptic(); + const succeeded = await (async () => { + if ( + (action === "settle" || action === "unsettle") && + !environmentSupportsSettlement(thread.environmentId) + ) { + Dialog.alert( + actionFailureTitle(action), + "This environment's server does not support settling yet. Update the server to use Settle.", + ); return false; } - - inFlightThreadKeys.current.add(key); - selectionHaptic(); - try { - if ( - (action === "settle" || action === "unsettle") && - !environmentSupportsSettlement(thread.environmentId) - ) { - Alert.alert( - actionFailureTitle(action), - "This environment's server does not support settling yet. Update the server to use Settle.", - ); - return false; - } - // Archive keeps its original, narrower guard: never interrupt a - // thread mid-turn. - if (action === "archive" && !threadCanArchive(thread.runtime)) { - Alert.alert( - actionFailureTitle(action), - "This thread is working. Interrupt it first, then try again.", - ); - return false; - } - const result = await withThreadDismissal( - key, - async () => - action === "unsettle" - ? // reason "user" pins the thread active: auto-settle stays - // suppressed until real activity clears the pin server-side. - await unsettleMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, reason: "user" }, - }) - : await ( - action === "settle" - ? settleMutation - : action === "archive" - ? archiveMutation - : action === "unarchive" - ? unarchiveMutation - : deleteMutation - )({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }), - (result) => result._tag === "Success", + if (action === "archive" && !threadCanArchive(thread.runtime)) { + Dialog.alert( + actionFailureTitle(action), + "This thread is working. Interrupt it first, then try again.", ); - if (result._tag === "Failure") { - Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); - return false; - } - // Settled threads stay in the live shell stream; only the archive - // lifecycle still feeds the archived-snapshot surface. - if (action === "archive" || action === "unarchive" || action === "delete") { - refreshArchivedThreadsForEnvironment(thread.environmentId); - } - onCompleted?.(action, thread); - return true; - } finally { - inFlightThreadKeys.current.delete(key); + return false; } - }, - [ - archiveMutation, - deleteMutation, - onCompleted, - settleMutation, - unarchiveMutation, - unsettleMutation, - ], - ); + const result = await withThreadDismissal( + key, + async () => + action === "unsettle" + ? // reason "user" pins the thread active: auto-settle stays + // suppressed until real activity clears the pin server-side. + await unsettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }) + : await ( + action === "settle" + ? settleMutation + : action === "archive" + ? archiveMutation + : action === "unarchive" + ? unarchiveMutation + : deleteMutation + )({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }), + (result) => result._tag === "Success", + ); + if (result._tag === "Failure") { + Dialog.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); + return false; + } + if (action === "archive" || action === "unarchive" || action === "delete") { + refreshArchivedThreadsForEnvironment(thread.environmentId); + } + onCompleted?.(action, thread); + return true; + })(); + inFlightThreadKeys.current.delete(key); + return succeeded; + }; return executeAction; } -function useConfirmDeleteThread( +// A plain function, not a hook: a `use*` function that calls no hooks is skipped by +// the compiler, so the delete handler it returned was a new closure every render. +function confirmThreadDeletion( executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, + thread: EnvironmentThreadShell, ) { - return useCallback( - (thread: EnvironmentThreadShell) => { - if (!checkThreadOperationPermission(thread, actionFailureTitle("delete"))) return; - const title = "Delete thread?"; - const message = `โ€œ${thread.title}โ€ will be permanently deleted, including its terminal history.`; - if (process.env.EXPO_OS === "ios") { - Alert.alert(title, message, [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - style: "destructive", - onPress: () => { - void executeAction("delete", thread); - }, - }, - ]); - return; - } - showConfirmDialog({ - title, - message, - confirmText: "Delete", - destructive: true, - onConfirm: () => { - void executeAction("delete", thread); - }, - }); + if (!checkThreadOperationPermission(thread, actionFailureTitle("delete"))) return; + const title = "Delete thread?"; + const message = `โ€œ${thread.title}โ€ will be permanently deleted, including its terminal history.`; + Dialog.confirm({ + title, + message, + confirmText: "Delete", + destructive: true, + onConfirm: () => { + void executeAction("delete", thread); }, - [executeAction], - ); + }); } export function useThreadListActions(): { @@ -253,7 +222,6 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; - /** Sets per-thread automatic settlement on or off. */ readonly setThreadAutoSettle: ( thread: EnvironmentThreadShell, enabled: boolean, @@ -279,306 +247,265 @@ export function useThreadListActions(): { const snoozeInFlightThreadKeys = useRef(new Set()); const titleRegenerationInFlightThreadKeys = useRef(new Set()); - const archiveThread = useCallback( - (thread: EnvironmentThreadShell) => { - void executeAction("archive", thread); - }, - [executeAction], - ); - const settleThread = useCallback( - async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, - [executeAction], - ); - const snoozeThread = useCallback( - async (thread: EnvironmentThreadShell, snoozedUntil: string) => { - if (!checkThreadOperationPermission(thread, "Could not snooze thread")) return false; - const key = scopedThreadKey(thread.environmentId, thread.id); - if (snoozeInFlightThreadKeys.current.has(key)) { - return false; - } - snoozeInFlightThreadKeys.current.add(key); - try { - if (!environmentSupportsSnooze(thread.environmentId)) { - Alert.alert( - "Could not snooze thread", - "This environment's server does not support snoozing yet. Update the server to use Snooze.", - ); - return false; - } - if (!canSnooze(thread, { now: new Date().toISOString() })) { - Alert.alert( - "Could not snooze thread", - thread.hasPendingApprovals || - thread.hasPendingUserInput || - thread.pendingSubagentRequest != null - ? "This thread is waiting on you. Respond to the pending request before snoozing it." - : "This thread is still starting a turn. Try again once it's running.", - ); - return false; - } - - selectionHaptic(); - const result = await withThreadDismissal( - key, - () => - snoozeMutation({ - environmentId: thread.environmentId, - input: { - threadId: thread.id, - snoozedUntil, - }, - }), - (result) => result._tag === "Success", + const archiveThread = (thread: EnvironmentThreadShell) => { + void executeAction("archive", thread); + }; + const settleThread = async (thread: EnvironmentThreadShell) => + (await executeAction("settle", thread)) === true; + const snoozeThread = async (thread: EnvironmentThreadShell, snoozedUntil: string) => { + if (!checkThreadOperationPermission(thread, "Could not snooze thread")) return false; + const key = scopedThreadKey(thread.environmentId, thread.id); + if (snoozeInFlightThreadKeys.current.has(key)) { + return false; + } + snoozeInFlightThreadKeys.current.add(key); + const succeeded = await (async () => { + if (!environmentSupportsSnooze(thread.environmentId)) { + Dialog.alert( + "Could not snooze thread", + "This environment's server does not support snoozing yet. Update the server to use Snooze.", ); - if (result._tag === "Failure") { - const error = Cause.squash(result.cause); - Alert.alert( - "Could not snooze thread", - error instanceof Error && error.message.trim().length > 0 - ? error.message - : "The thread could not be snoozed.", - ); - return false; - } - return true; - } finally { - snoozeInFlightThreadKeys.current.delete(key); - } - }, - [snoozeMutation], - ); - const unsnoozeThread = useCallback( - async (thread: EnvironmentThreadShell) => { - if (!checkThreadOperationPermission(thread, "Could not wake thread")) return false; - const key = scopedThreadKey(thread.environmentId, thread.id); - if (snoozeInFlightThreadKeys.current.has(key)) { return false; } - snoozeInFlightThreadKeys.current.add(key); - try { - if (!environmentSupportsSnooze(thread.environmentId)) { - Alert.alert( - "Could not wake thread", - "This environment's server does not support snoozing yet. Update the server to wake this thread.", - ); - return false; - } - - selectionHaptic(); - const result = await withThreadDismissal( - key, - () => - unsnoozeMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, reason: "user" }, - }), - (result) => result._tag === "Success", - ); - if (result._tag === "Failure") { - const error = Cause.squash(result.cause); - Alert.alert( - "Could not wake thread", - error instanceof Error && error.message.trim().length > 0 - ? error.message - : "The thread could not be woken.", - ); - return false; - } - return true; - } finally { - snoozeInFlightThreadKeys.current.delete(key); - } - }, - [unsnoozeMutation], - ); - const unsettleThread = useCallback( - async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, - [executeAction], - ); - const pinThread = useCallback( - async (thread: EnvironmentThreadShell) => { - if (!checkThreadOperationPermission(thread, "Could not pin thread")) return false; - if (!environmentSupportsPinning(thread.environmentId)) { - Alert.alert( - "Could not pin thread", - "This environment's server does not support pinning yet. Update the server to use Pin.", + if (!canSnooze(thread, { now: new Date().toISOString() })) { + Dialog.alert( + "Could not snooze thread", + thread.hasPendingApprovals || + thread.hasPendingUserInput || + thread.pendingSubagentRequest != null + ? "This thread is waiting on you. Respond to the pending request before snoozing it." + : "This thread is still starting a turn. Try again once it's running.", ); return false; } + selectionHaptic(); - // Same placement as web: a fresh pin takes the top of the arranged - // run. Servers that predate reordering get the bare pin (keyless). - let orderKey: string | undefined; - if (environmentSupportsPinReorder(thread.environmentId)) { - const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); - let firstKey: string | null = null; - for (const shell of shells) { - if (shell.pinnedAt == null || shell.pinOrderKey == null) continue; - if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey; - } - orderKey = pinOrderKeyBetween(null, firstKey) ?? undefined; - } - const result = await pinMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) }, - }); + const result = await withThreadDismissal( + key, + () => + snoozeMutation({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + snoozedUntil, + }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { const error = Cause.squash(result.cause); - Alert.alert( - "Could not pin thread", + Dialog.alert( + "Could not snooze thread", error instanceof Error && error.message.trim().length > 0 ? error.message - : "The thread could not be pinned.", + : "The thread could not be snoozed.", ); return false; } return true; - }, - [pinMutation], - ); - const unpinThread = useCallback( - async (thread: EnvironmentThreadShell) => { - if (!checkThreadOperationPermission(thread, "Could not unpin thread")) return false; - if (!environmentSupportsPinning(thread.environmentId)) { - Alert.alert( - "Could not unpin thread", - "This environment's server does not support pinning yet. Update the server to use Pin.", + })(); + snoozeInFlightThreadKeys.current.delete(key); + return succeeded; + }; + const unsnoozeThread = async (thread: EnvironmentThreadShell) => { + if (!checkThreadOperationPermission(thread, "Could not wake thread")) return false; + const key = scopedThreadKey(thread.environmentId, thread.id); + if (snoozeInFlightThreadKeys.current.has(key)) { + return false; + } + snoozeInFlightThreadKeys.current.add(key); + const succeeded = await (async () => { + if (!environmentSupportsSnooze(thread.environmentId)) { + Dialog.alert( + "Could not wake thread", + "This environment's server does not support snoozing yet. Update the server to wake this thread.", ); return false; } + selectionHaptic(); - const result = await unpinMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + const result = await withThreadDismissal( + key, + () => + unsnoozeMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { const error = Cause.squash(result.cause); - Alert.alert( - "Could not unpin thread", + Dialog.alert( + "Could not wake thread", error instanceof Error && error.message.trim().length > 0 ? error.message - : "The thread could not be unpinned.", + : "The thread could not be woken.", ); return false; } return true; - }, - [unpinMutation], - ); - const setThreadAutoSettle = useCallback( - async (thread: EnvironmentThreadShell, enabled: boolean) => { - if (!environmentSupportsAutoSettleOptOut(thread.environmentId)) { - Alert.alert( - "Could not update auto-settle", - "This environment's server does not support turning auto-settle off per thread yet. Update the server to use it.", - ); - return false; + })(); + snoozeInFlightThreadKeys.current.delete(key); + return succeeded; + }; + const unsettleThread = async (thread: EnvironmentThreadShell) => + (await executeAction("unsettle", thread)) === true; + const pinThread = async (thread: EnvironmentThreadShell) => { + if (!checkThreadOperationPermission(thread, "Could not pin thread")) return false; + if (!environmentSupportsPinning(thread.environmentId)) { + Dialog.alert( + "Could not pin thread", + "This environment's server does not support pinning yet. Update the server to use Pin.", + ); + return false; + } + selectionHaptic(); + // Same placement as web: a fresh pin takes the top of the arranged + // run. Servers that predate reordering get the bare pin (keyless). + let orderKey: string | undefined; + if (environmentSupportsPinReorder(thread.environmentId)) { + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + let firstKey: string | null = null; + for (const shell of shells) { + if (shell.pinnedAt == null || shell.pinOrderKey == null) continue; + if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey; } - selectionHaptic(); - const result = await setAutoSettleMutation({ + orderKey = pinOrderKeyBetween(null, firstKey) ?? undefined; + } + const result = await pinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Dialog.alert( + "Could not pin thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be pinned.", + ); + return false; + } + return true; + }; + const unpinThread = async (thread: EnvironmentThreadShell) => { + if (!checkThreadOperationPermission(thread, "Could not unpin thread")) return false; + if (!environmentSupportsPinning(thread.environmentId)) { + Dialog.alert( + "Could not unpin thread", + "This environment's server does not support pinning yet. Update the server to use Pin.", + ); + return false; + } + selectionHaptic(); + const result = await unpinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Dialog.alert( + "Could not unpin thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be unpinned.", + ); + return false; + } + return true; + }; + const setThreadAutoSettle = async (thread: EnvironmentThreadShell, enabled: boolean) => { + if (!environmentSupportsAutoSettleOptOut(thread.environmentId)) { + Dialog.alert( + "Could not update auto-settle", + "This environment's server does not support turning auto-settle off per thread yet. Update the server to use it.", + ); + return false; + } + selectionHaptic(); + const result = await setAutoSettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, enabled }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Dialog.alert( + "Could not update auto-settle", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The auto-settle setting could not be changed.", + ); + return false; + } + return true; + }; + const regenerateThreadTitle = async (thread: EnvironmentThreadShell) => { + if (!checkThreadOperationPermission(thread, "Could not regenerate title")) return false; + const key = scopedThreadKey(thread.environmentId, thread.id); + if (thread.titleRegeneration != null || titleRegenerationInFlightThreadKeys.current.has(key)) { + return false; + } + if (!environmentSupportsTitleRegeneration(thread.environmentId)) { + Dialog.alert( + "Could not regenerate title", + "This environment's server does not support title regeneration yet. Update the server to regenerate thread titles.", + ); + return false; + } + + titleRegenerationInFlightThreadKeys.current.add(key); + selectionHaptic(); + const succeeded = await (async () => { + const result = await updateThreadMetadata({ environmentId: thread.environmentId, - input: { threadId: thread.id, enabled }, + input: { threadId: thread.id, regenerateTitle: true }, }); if (result._tag === "Failure") { const error = Cause.squash(result.cause); - Alert.alert( - "Could not update auto-settle", + Dialog.alert( + "Could not regenerate title", error instanceof Error && error.message.trim().length > 0 ? error.message - : "The auto-settle setting could not be changed.", + : "The thread title could not be regenerated.", ); return false; } return true; - }, - [setAutoSettleMutation], - ); - const regenerateThreadTitle = useCallback( - async (thread: EnvironmentThreadShell) => { - if (!checkThreadOperationPermission(thread, "Could not regenerate title")) return false; - const key = scopedThreadKey(thread.environmentId, thread.id); - if ( - thread.titleRegeneration != null || - titleRegenerationInFlightThreadKeys.current.has(key) - ) { - return false; - } - if (!environmentSupportsTitleRegeneration(thread.environmentId)) { - Alert.alert( - "Could not regenerate title", - "This environment's server does not support title regeneration yet. Update the server to regenerate thread titles.", - ); - return false; + })(); + titleRegenerationInFlightThreadKeys.current.delete(key); + return succeeded; + }; + const renameThread = (thread: EnvironmentThreadShell) => { + const commit = (title: string) => { + const resolution = resolveThreadTitleRename({ title, originalTitle: thread.title }); + if (resolution.action === "reject-empty") { + Dialog.alert("Could not rename thread", "Thread title cannot be empty."); + return; } - - titleRegenerationInFlightThreadKeys.current.add(key); + if (resolution.action === "noop") return; selectionHaptic(); - try { - const result = await updateThreadMetadata({ - environmentId: thread.environmentId, - input: { threadId: thread.id, regenerateTitle: true }, - }); - if (result._tag === "Failure") { - const error = Cause.squash(result.cause); - Alert.alert( - "Could not regenerate title", - error instanceof Error && error.message.trim().length > 0 - ? error.message - : "The thread title could not be regenerated.", - ); - return false; - } - return true; - } finally { - titleRegenerationInFlightThreadKeys.current.delete(key); - } - }, - [updateThreadMetadata], - ); - const renameThread = useCallback( - (thread: EnvironmentThreadShell) => { - const commit = (title: string) => { - const resolution = resolveThreadTitleRename({ title, originalTitle: thread.title }); - if (resolution.action === "reject-empty") { - Alert.alert("Could not rename thread", "Thread title cannot be empty."); - return; - } - if (resolution.action === "noop") return; - selectionHaptic(); - void updateThreadMetadata({ - environmentId: thread.environmentId, - input: { threadId: thread.id, title: resolution.title }, - }).then((result) => { - if (result._tag === "Success") return; - const error = Cause.squash(result.cause); - Alert.alert( - "Could not rename thread", - error instanceof Error && error.message.trim().length > 0 - ? error.message - : "The thread could not be renamed.", - ); - }); - }; - - if (Platform.OS === "ios") { - Alert.prompt( - "Rename thread", - undefined, - (title) => commit(title ?? ""), - "plain-text", - thread.title, + void updateThreadMetadata({ + environmentId: thread.environmentId, + input: { threadId: thread.id, title: resolution.title }, + }).then((result) => { + if (result._tag === "Success") return; + const error = Cause.squash(result.cause); + Dialog.alert( + "Could not rename thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be renamed.", ); - return; - } - showTextInputDialog({ - title: "Rename thread", - initialValue: thread.title, - confirmText: "Rename", - onConfirm: commit, }); - }, - [updateThreadMetadata], - ); + }; + + Dialog.prompt({ + title: "Rename thread", + initialValue: thread.title, + confirmText: "Rename", + onConfirm: commit, + }); + }; // Plan against the complete section so filtering does not change a move. const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, { @@ -587,173 +514,157 @@ export function useThreadListActions(): { const reorderActiveMutation = useAtomCommand(threadEnvironment.reorderActive, { reportFailure: false, }); - const moveThread = useCallback( - async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { - if (!checkThreadOperationPermission(thread, "Could not move thread")) return false; - if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false; - const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); - const current = shells.find( - (row) => row.id === thread.id && row.environmentId === thread.environmentId, + const moveThread = async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { + if (!checkThreadOperationPermission(thread, "Could not move thread")) return false; + if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false; + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const current = shells.find( + (row) => row.id === thread.id && row.environmentId === thread.environmentId, + ); + if (!current || current.archivedAt !== null) return false; + thread = current; + const section = + typeof direction === "object" && direction.section !== undefined + ? direction.section + : thread.pinnedAt != null + ? "pinned" + : "active"; + if (section === "settled") { + if (!environmentSupportsSettlement(thread.environmentId)) return false; + appAtomRegistry.set(threadDropBusyAtom, true); + const settled = await settleThread(thread); + appAtomRegistry.set(threadDropBusyAtom, false); + return settled; + } + const configs = appAtomRegistry.get(environmentServerConfigsAtom); + const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { + const capabilities = configs.get(environmentId)?.environment.capabilities; + return section === "pinned" + ? capabilities?.threadPinReorder === true + : capabilities?.threadActiveReorder === true; + }; + if (!supportsReorder(thread.environmentId)) { + Dialog.alert( + "Could not move thread", + "This environment's server does not support reordering these threads. Update the server to arrange them.", ); - if (!current || current.archivedAt !== null) return false; - thread = current; - const section = - typeof direction === "object" && direction.section !== undefined - ? direction.section - : thread.pinnedAt != null - ? "pinned" - : "active"; - if (section === "settled") { - if (!environmentSupportsSettlement(thread.environmentId)) return false; - appAtomRegistry.set(threadDropBusyAtom, true); - try { - return await settleThread(thread); - } finally { - appAtomRegistry.set(threadDropBusyAtom, false); - } - } - const configs = appAtomRegistry.get(environmentServerConfigsAtom); - const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { - const capabilities = configs.get(environmentId)?.environment.capabilities; - return section === "pinned" - ? capabilities?.threadPinReorder === true - : capabilities?.threadActiveReorder === true; - }; - if (!supportsReorder(thread.environmentId)) { - Alert.alert( - "Could not move thread", - "This environment's server does not support reordering these threads. Update the server to arrange them.", - ); - return false; - } - const ordered = getThreadListV2OrderedSection({ - threads: shells, - section, - now: new Date().toISOString(), - queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), - settlementEnvironmentIds: new Set( - [...configs].flatMap(([id, config]) => - config.environment.capabilities.threadSettlement === true ? [id] : [], - ), + return false; + } + const ordered = getThreadListV2OrderedSection({ + threads: shells, + section, + now: new Date().toISOString(), + queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), + settlementEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSettlement === true ? [id] : [], ), - snoozeEnvironmentIds: new Set( - [...configs].flatMap(([id, config]) => - config.environment.capabilities.threadSnooze === true ? [id] : [], - ), + ), + snoozeEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSnooze === true ? [id] : [], ), - }); - const assignments = createThreadMovePlanner({ - allThreads: shells, - ordered, - section, - reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)), - })(scopedThreadKey(thread.environmentId, thread.id), direction); - if (assignments === null) return false; - const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString()); - const crossSection = !ordered.some( - (row) => row.id === thread.id && row.environmentId === thread.environmentId, - ); - if ( - crossSection && - (((section === "pinned" || thread.pinnedAt != null) && - !environmentSupportsPinning(thread.environmentId)) || - (thread.settledOverride === "settled" && - !environmentSupportsSettlement(thread.environmentId)) || - (effectiveSnoozed(thread, { now: new Date().toISOString() }) && - !environmentSupportsSnooze(thread.environmentId))) - ) - return false; - const shellByKey = new Map( - shells.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), - ); - for (const assignment of assignments) { - const target = shellByKey.get(assignment.id); - if (target && !checkThreadOperationPermission(target, "Could not move thread")) - return false; - } - selectionHaptic(); - appAtomRegistry.set(threadDropBusyAtom, true); - const pending = crossSection - ? null - : beginPendingThreadOrder( - createPendingThreadOrder({ - section, - ordered, - movedId: scopedThreadKey(thread.environmentId, thread.id), - direction, - assignments, - }), - ); - let succeeded = false; - const reorder = section === "pinned" ? reorderPinnedMutation : reorderActiveMutation; - try { - if (crossSection) { - if (section === "pinned") { - const orderKey = assignments.find( - ({ id }) => id === scopedThreadKey(thread.environmentId, thread.id), - )?.orderKey; - const result = await pinMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, ...(orderKey === undefined ? {} : { orderKey }) }, - }); - if (result._tag === "Failure") { - Alert.alert("Could not pin thread", String(Cause.squash(result.cause))); - return false; - } - } else { - if (lifecycle.unpin && !(await unpinThread(thread))) return false; - if (lifecycle.unsettle && !(await unsettleThread(thread))) return false; - if (lifecycle.unsnooze && !(await unsnoozeThread(thread))) return false; - } - } - for (const assignment of assignments) { - if ( - crossSection && - section === "pinned" && - thread.pinnedAt == null && - assignment.id === scopedThreadKey(thread.environmentId, thread.id) - ) - continue; - if (pending !== null && !pending.isPending()) return false; - const target = shellByKey.get(assignment.id); - if (target === undefined) continue; - if (!checkThreadOperationPermission(target, "Could not move thread")) return false; - const result = await reorder({ - environmentId: target.environmentId, - input: { threadId: target.id, orderKey: assignment.orderKey }, + ), + }); + const assignments = createThreadMovePlanner({ + allThreads: shells, + ordered, + section, + reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)), + })(scopedThreadKey(thread.environmentId, thread.id), direction); + if (assignments === null) return false; + const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString()); + const crossSection = !ordered.some( + (row) => row.id === thread.id && row.environmentId === thread.environmentId, + ); + if ( + crossSection && + (((section === "pinned" || thread.pinnedAt != null) && + !environmentSupportsPinning(thread.environmentId)) || + (thread.settledOverride === "settled" && + !environmentSupportsSettlement(thread.environmentId)) || + (effectiveSnoozed(thread, { now: new Date().toISOString() }) && + !environmentSupportsSnooze(thread.environmentId))) + ) + return false; + const shellByKey = new Map( + shells.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), + ); + for (const assignment of assignments) { + const target = shellByKey.get(assignment.id); + if (target && !checkThreadOperationPermission(target, "Could not move thread")) return false; + } + selectionHaptic(); + appAtomRegistry.set(threadDropBusyAtom, true); + const pending = crossSection + ? null + : beginPendingThreadOrder( + createPendingThreadOrder({ + section, + ordered, + movedId: scopedThreadKey(thread.environmentId, thread.id), + direction, + assignments, + }), + ); + const reorder = section === "pinned" ? reorderPinnedMutation : reorderActiveMutation; + const moved = await (async () => { + if (crossSection) { + if (section === "pinned") { + const orderKey = assignments.find( + ({ id }) => id === scopedThreadKey(thread.environmentId, thread.id), + )?.orderKey; + const result = await pinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, ...(orderKey === undefined ? {} : { orderKey }) }, }); if (result._tag === "Failure") { - const error = Cause.squash(result.cause); - Alert.alert( - "Could not move thread", - error instanceof Error && error.message.trim().length > 0 - ? error.message - : "The thread could not be moved.", - ); - // Keep confirmed keys when a later environment rejects its write. + Dialog.alert("Could not pin thread", String(Cause.squash(result.cause))); return false; } + } else { + if (lifecycle.unpin && !(await unpinThread(thread))) return false; + if (lifecycle.unsettle && !(await unsettleThread(thread))) return false; + if (lifecycle.unsnooze && !(await unsnoozeThread(thread))) return false; } - succeeded = true; - pending?.complete(); - return true; - } finally { - if (!succeeded) pending?.cancel(); - appAtomRegistry.set(threadDropBusyAtom, false); } - }, - [ - settleThread, - reorderActiveMutation, - reorderPinnedMutation, - pinMutation, - unpinThread, - unsettleThread, - unsnoozeThread, - ], - ); + for (const assignment of assignments) { + if ( + crossSection && + section === "pinned" && + thread.pinnedAt == null && + assignment.id === scopedThreadKey(thread.environmentId, thread.id) + ) + continue; + if (pending !== null && !pending.isPending()) return false; + const target = shellByKey.get(assignment.id); + if (target === undefined) continue; + if (!checkThreadOperationPermission(target, "Could not move thread")) return false; + const result = await reorder({ + environmentId: target.environmentId, + input: { threadId: target.id, orderKey: assignment.orderKey }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Dialog.alert( + "Could not move thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be moved.", + ); + return false; + } + } + pending?.complete(); + return true; + })(); + if (!moved) pending?.cancel(); + appAtomRegistry.set(threadDropBusyAtom, false); + return moved; + }; - const confirmDeleteThread = useConfirmDeleteThread(executeAction); + const confirmDeleteThread = (thread: EnvironmentThreadShell) => + confirmThreadDeletion(executeAction, thread); return { archiveThread, @@ -777,20 +688,15 @@ export function useArchivedThreadListActions( readonly unarchiveThread: (thread: EnvironmentThreadShell) => void; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; } { - const handleCompleted = useCallback( - (_action: ThreadListAction, thread: EnvironmentThreadShell) => { - onCompleted(thread); - }, - [onCompleted], - ); + const handleCompleted = (_action: ThreadListAction, thread: EnvironmentThreadShell) => { + onCompleted(thread); + }; const executeAction = useThreadActionExecutor(handleCompleted); - const unarchiveThread = useCallback( - (thread: EnvironmentThreadShell) => { - void executeAction("unarchive", thread); - }, - [executeAction], - ); - const confirmDeleteThread = useConfirmDeleteThread(executeAction); + const unarchiveThread = (thread: EnvironmentThreadShell) => { + void executeAction("unarchive", thread); + }; + const confirmDeleteThread = (thread: EnvironmentThreadShell) => + confirmThreadDeletion(executeAction, thread); return { unarchiveThread, confirmDeleteThread }; } diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index d45a46adf933..88ce6a3bde1b 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -2,7 +2,6 @@ import type { WorkspaceState } from "../../state/workspaceModel"; export interface WorkspaceConnectionStatusPresentation { readonly label: string; - /** True while actively working (connecting/syncing) โ€” render a spinner. False for offline/error/idle states โ€” render a wifi-slash icon. */ readonly showsProgress: boolean; } @@ -31,7 +30,6 @@ function workspaceConnectionStatusLabel(state: WorkspaceState): string { return "Not connected"; } -/** Header-title presentation of the connection state, or null while connected. */ export function workspaceConnectionStatusPresentation( state: WorkspaceState, ): WorkspaceConnectionStatusPresentation | null { diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 5c5126865e55..d264c0765bcf 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -17,10 +17,10 @@ import { import { GestureHandlerRootView } from "react-native-gesture-handler"; -import { GlassSurface } from "../../components/GlassSurface"; -import { RowPressable } from "../../components/RowPressable"; -import { AppText as Text } from "../../components/AppText"; -import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; +import { GlassSurface } from "../../components/ui/GlassSurface"; +import { RowPressable } from "../../components/ui/RowPressable"; +import { AppText as Text } from "../../components/ui/AppText"; +import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; import { cn } from "../../lib/cn"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; @@ -38,6 +38,7 @@ import { } from "./commandPaletteItems"; import { parseActiveThreadPath, type HardwareKeyboardCommand } from "./hardwareKeyboardCommands"; import { threadJumpIndex } from "./threadKeyboardShortcuts"; +import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; const PALETTE_COMMANDS: ReadonlyArray = [ "commandPalette", @@ -59,7 +60,7 @@ const ACTION_ICONS: Record = { archive: "archivebox", files: "doc.text", terminal: "terminal", - review: "arrow.triangle.pull", + review: PullRequestGlyph.pullRequest, copyThreadReference: "link", }; diff --git a/apps/mobile/src/features/keyboard/commandPaletteItems.ts b/apps/mobile/src/features/keyboard/commandPaletteItems.ts index 6a06fd4e3387..2e7c2399ae49 100644 --- a/apps/mobile/src/features/keyboard/commandPaletteItems.ts +++ b/apps/mobile/src/features/keyboard/commandPaletteItems.ts @@ -7,7 +7,6 @@ export interface CommandPaletteItem { readonly run: () => void; } -/** `>` narrows to actions, matching the desktop palette. Stable ties retain recent-thread order. */ export function filterCommandPaletteItems( items: ReadonlyArray, query: string, diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts index 679790f59cf7..cabe2949788e 100644 --- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts @@ -23,10 +23,6 @@ const handlers = new Map>(); const registrationListeners = new Set<() => void>(); let registrationVersion = 0; -/** - * Registers a context-specific hardware-keyboard action. The most recently mounted handler gets - * the first chance to consume the command, allowing focused screens to override app defaults. - */ export function useHardwareKeyboardCommand( command: HardwareKeyboardCommand | ReadonlyArray, handler: CommandHandler, @@ -93,10 +89,6 @@ export function parseActiveThreadPath(pathname: string): { } } -/** - * The machine after `currentId` in display order, wrapping around. Starts at - * the first machine when `currentId` is not listed. - */ export function nextEnvironmentId( environments: ReadonlyArray, currentId: string | null, diff --git a/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts index 45b6ccb13d17..3143d164c178 100644 --- a/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts +++ b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts @@ -14,7 +14,6 @@ export function threadJumpIndex(command: HardwareKeyboardCommand) { return THREAD_JUMP_KEYBINDING_COMMANDS.findIndex((candidate) => candidate === command); } -/** Uses the rendered list so filters and shelves keep their order. */ export function threadJumpTarget( items: ReadonlyArray, command: HardwareKeyboardCommand, diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index b2981f63e255..82bd286cbdbd 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -15,9 +15,8 @@ import { import { createContext, use, - useCallback, useEffect, - useMemo, + useEffectEvent, useRef, useState, type ReactNode, @@ -34,8 +33,11 @@ import { AsyncResult } from "effect/reactivity"; import { deriveFileInspectorPaneLayout, + alignListPaneToHinge, deriveLayout, + type VerticalHinge, deriveWorkspacePaneLayout, + isPrimarySidebarPinned, type FileInspectorPaneLayout, type Layout, type WorkspaceAuxiliaryPaneRole, @@ -57,15 +59,11 @@ import { useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; -import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; -import { - AndroidWorkspaceNavigation, - ANDROID_WORKSPACE_RAIL_WIDTH, -} from "../workspace/AndroidWorkspaceNavigation"; import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar"; import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; +import { useOpenThreadPullRequests } from "../threads/use-open-thread-pull-requests"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; @@ -76,6 +74,8 @@ import { } from "../../native/v5-workspace-context"; import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; +import { AndroidHingeObserver } from "../../native/AndroidHingeObserver"; +import { WindowHingeContext } from "../../native/window-hinge-context"; interface AdaptiveWorkspaceContextValue { readonly layout: Layout; @@ -84,14 +84,6 @@ interface AdaptiveWorkspaceContextValue { readonly primarySidebarSearchQuery: string; readonly selectThread: (thread: EnvironmentThreadShell) => void; readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; - /** - * Route screens hand their inspector pane content to the workspace so it - * renders BESIDE the navigator (outside the native stack header) instead of - * inside the route. Returns a deactivate callback: the pane animates closed - * (content kept mounted for the exit transition) unless a newer - * registration already took over โ€” stale deactivates never clobber it. - * Prefer useRegisterWorkspaceInspector over calling this directly. - */ readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; readonly setPrimarySidebarSearchQuery: (query: string) => void; readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void; @@ -133,35 +125,25 @@ export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) { const { activateAuxiliaryPaneRole } = useAdaptiveWorkspaceLayout(); - useFocusEffect( - useCallback(() => activateAuxiliaryPaneRole(role), [activateAuxiliaryPaneRole, role]), - ); + useFocusEffect(() => activateAuxiliaryPaneRole(role)); } /** - * Register this screen's inspector pane content with the workspace column. - * * The column renders BESIDE the navigator โ€” outside any screen โ€” so the * registering screen's navigation and route contexts are captured here and - * re-provided around the portal content. Without them, useNavigation/useRoute - * inside the pane (e.g. GitOverviewSheet via useThreadSelection) throw - * "Couldn't find a route object". + * re-provided around the portal content. * * Registration is FOCUS-scoped, driven by navigation events rather than the - * screen's own render cycle: react-native-screens freezes blurred screens, so - * a cleanup that depends on the blurred subtree re-rendering never runs and - * would leak the pane into the next route. Blur deactivates the pane (it - * animates closed, or is replaced seamlessly when the next route registers in - * the same commit); focus re-registers it. + * screen's own render cycle: react-native-screens freezes blurred screens, so a + * cleanup that depends on the blurred subtree re-rendering never runs and would + * leak the pane into the next route. */ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefined) { const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout(); - // Raw context values (not the useNavigation/useRoute wrappers) so the - // portal re-provides exactly what this screen sees. const navigation = use(NavigationContext); const route = use(NavigationRouteContext); - const wrappedRender = useMemo(() => { + const wrappedRender = (() => { if (render === undefined) { return undefined; } @@ -170,43 +152,36 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi {render()} ); - }, [navigation, render, route]); + })(); - const wrappedRenderRef = useRef(wrappedRender); - wrappedRenderRef.current = wrappedRender; const focusedRef = useRef(false); const deactivateRef = useRef<(() => void) | null>(null); - const syncRegistration = useCallback(() => { - if (!focusedRef.current || wrappedRenderRef.current === undefined) { + // An effect event reads this render's pane without making the focus + // subscription re-run (and deactivate) every time the pane closure changes. + const syncRegistration = useEffectEvent(() => { + if (!focusedRef.current || wrappedRender === undefined) { deactivateRef.current?.(); return; } - deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current); - }, [registerWorkspaceInspector]); + deactivateRef.current = registerWorkspaceInspector(wrappedRender); + }); - // Focus lifecycle. Blur/focus events fire even when the blurred subtree is - // frozen (events are navigation-driven, renders are not). - useFocusEffect( - useCallback(() => { - focusedRef.current = true; + useFocusEffect(() => { + focusedRef.current = true; + syncRegistration(); + return () => { + focusedRef.current = false; syncRegistration(); - return () => { - focusedRef.current = false; - syncRegistration(); - }; - }, [syncRegistration]), - ); + }; + }); - // Content changes while focused re-register in place. useEffect(() => { if (focusedRef.current) { syncRegistration(); } - }, [syncRegistration, wrappedRender]); + }, [wrappedRender]); - // Unmount: hand the pane back (owner-guarded, so a route that already - // took over is unaffected). useEffect( () => () => { deactivateRef.current?.(); @@ -257,12 +232,10 @@ function AdaptiveWorkspaceLayoutContent( const windowDimensions = useWindowDimensions(); const nativeMetrics = useNativeLayoutMetrics(); const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions; - const { scale: androidControlScale } = useAndroidControlSizing(); const pathname = props.pathname; const navigation = useNavigation(); const activeRoleOwner = useRef(null); const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); - const showPrimarySidebar = pathname === "/" || primarySidebarPreferredVisible; const [supplementaryPanePreferredVisible, setSupplementaryPanePreferredVisible] = useState(true); const [supplementaryPanePreferredWidth, setSupplementaryPanePreferredWidth] = useState< number | null @@ -274,36 +247,30 @@ function AdaptiveWorkspaceLayoutContent( const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = useState(null); - const baseLayout = useMemo( - () => deriveLayout({ width: viewportWidth, height, nativeMetrics }), - [height, viewportWidth, nativeMetrics], - ); - const layout = baseLayout; - // The rail consumes real space before either pane; inspectors and the detail - // navigator must use the remaining viewport rather than clipping behind it. - const shouldRenderNavigationRail = Platform.OS === "android" && layout.usesSplitView; - const navigationRailWidth = - shouldRenderNavigationRail && showPrimarySidebar - ? ANDROID_WORKSPACE_RAIL_WIDTH * Math.max(1, androidControlScale) - : 0; - const width = viewportWidth - navigationRailWidth; - // In split layouts the sidebar IS the thread list โ€” it renders on every - // route, including Home (which shows an empty-detail pane instead of the - // compact list). + const baseLayout = deriveLayout({ width: viewportWidth, height, nativeMetrics }); + const primarySidebarPinned = isPrimarySidebarPinned({ + pathname, + nativeWorkspace, + usesSplitView: baseLayout.usesSplitView, + }); + const showPrimarySidebar = primarySidebarPinned || primarySidebarPreferredVisible; + const [hinge, setHinge] = useState(null); + const [windowHinge, setWindowHinge] = useState(null); + const handleHingeChange = (next: VerticalHinge | null, nextWindow: VerticalHinge | null) => { + setHinge(next); + setWindowHinge(nextWindow); + }; + const layout = alignListPaneToHinge({ layout: baseLayout, hinge, viewportWidth }); const shouldRenderPrimarySidebar = layout.usesSplitView; - const fileInspector = useMemo( - () => - deriveFileInspectorPaneLayout({ - layout, - viewportWidth: width, - preferredWidth: fileInspectorPreferredWidth ?? undefined, - reservedLeadingWidth: - shouldRenderPrimarySidebar && showPrimarySidebar - ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) - : 0, - }), - [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width], - ); + const fileInspector = deriveFileInspectorPaneLayout({ + layout, + viewportWidth, + preferredWidth: fileInspectorPreferredWidth ?? undefined, + reservedLeadingWidth: + shouldRenderPrimarySidebar && showPrimarySidebar + ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) + : 0, + }); const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole = focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary"); const auxiliaryPanePreferredVisible = @@ -314,29 +281,18 @@ function AdaptiveWorkspaceLayoutContent( auxiliaryPaneRole === "inspector" ? fileInspectorPreferredWidth : supplementaryPanePreferredWidth; - const panes = useMemo( - () => - deriveWorkspacePaneLayout({ - layout, - viewportWidth: width, - primarySidebarPreferredVisible: showPrimarySidebar, - auxiliaryPanePreferredVisible, - auxiliaryPaneRole, - auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined, - }), - [ - auxiliaryPanePreferredVisible, - auxiliaryPaneRole, - auxiliaryPanePreferredWidth, - layout, - showPrimarySidebar, - width, - ], - ); + const panes = deriveWorkspacePaneLayout({ + layout, + viewportWidth, + primarySidebarPreferredVisible: showPrimarySidebar, + auxiliaryPanePreferredVisible, + auxiliaryPaneRole, + auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined, + }); const activeThread = parseActiveThreadPath(pathname); const environmentId = activeThread?.environmentId ?? null; const threadId = activeThread?.threadId ?? null; - const selectedThreadKey = useMemo(() => { + const selectedThreadKey = (() => { if (environmentId === null || threadId === null) { return null; } @@ -345,17 +301,13 @@ function AdaptiveWorkspaceLayoutContent( } catch { return null; } - }, [environmentId, threadId]); - // Wrapped in an object: bare functions in useState would be treated as - // lazy initializers/updaters. `active: false` keeps the outgoing route's - // content mounted so the pane can animate closed (or be replaced - // seamlessly by the next route's registration in the same commit). + })(); const [workspaceInspector, setWorkspaceInspector] = useState<{ readonly render: () => ReactNode; readonly active: boolean; } | null>(null); const workspaceInspectorOwner = useRef(null); - const registerWorkspaceInspector = useCallback((render: () => ReactNode) => { + const registerWorkspaceInspector = (render: () => ReactNode) => { const owner = Symbol("workspace-inspector"); workspaceInspectorOwner.current = owner; setWorkspaceInspector({ render, active: true }); @@ -368,12 +320,11 @@ function AdaptiveWorkspaceLayoutContent( } setWorkspaceInspector((current) => (current === null ? null : { ...current, active: false })); }; - }, []); - // Once the close animation settles, drop the stale content entirely. - const handleWorkspaceInspectorClosed = useCallback(() => { + }; + const handleWorkspaceInspectorClosed = () => { setWorkspaceInspector((current) => (current !== null && !current.active ? null : current)); - }, []); - const activateAuxiliaryPaneRole = useCallback((role: WorkspaceAuxiliaryPaneRole) => { + }; + const activateAuxiliaryPaneRole = (role: WorkspaceAuxiliaryPaneRole) => { const owner = Symbol(role); activeRoleOwner.current = owner; setFocusedAuxiliaryPaneRole(role); @@ -385,9 +336,9 @@ function AdaptiveWorkspaceLayoutContent( activeRoleOwner.current = null; setFocusedAuxiliaryPaneRole(null); }; - }, []); - const togglePrimarySidebar = useCallback(() => { - if (pathname === "/") { + }; + const togglePrimarySidebar = () => { + if (primarySidebarPinned) { return; } if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { @@ -396,23 +347,20 @@ function AdaptiveWorkspaceLayoutContent( return; } setPrimarySidebarPreferredVisible((current) => !current); - }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible, pathname]); - const revealPrimarySidebar = useCallback(() => { + }; + const revealPrimarySidebar = () => { if (panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); } setPrimarySidebarPreferredVisible(true); - }, [panes.primarySidebarSuppressedByAuxiliary]); - const handleToggleSidebarCommand = useCallback(() => { + }; + const handleToggleSidebarCommand = () => { togglePrimarySidebar(); return true; - }, [togglePrimarySidebar]); - const sidebarCommands = useMemo( - () => (pathname === "/" ? [] : (["toggleSidebar"] as const)), - [pathname], - ); + }; + const sidebarCommands = primarySidebarPinned ? [] : (["toggleSidebar"] as const); useHardwareKeyboardCommand(sidebarCommands, handleToggleSidebarCommand); - const showAuxiliaryPane = useCallback((role: WorkspaceAuxiliaryPaneRole) => { + const showAuxiliaryPane = (role: WorkspaceAuxiliaryPaneRole) => { if (role === "inspector") { setFocusedAuxiliaryPaneRole("inspector"); setFileInspectorPreferredVisible(true); @@ -420,8 +368,8 @@ function AdaptiveWorkspaceLayoutContent( } setFocusedAuxiliaryPaneRole("supplementary"); setSupplementaryPanePreferredVisible(true); - }, []); - const handleOpenFilesCommand = useCallback(() => { + }; + const handleOpenFilesCommand = () => { const activeThread = parseActiveThreadPath(pathname); if (!layout.usesSplitView || !fileInspector.supported || activeThread === null) { return false; @@ -432,73 +380,66 @@ function AdaptiveWorkspaceLayoutContent( } navigation.navigate("ThreadFiles", activeThread); return true; - }, [fileInspector.supported, layout.usesSplitView, pathname, navigation, showAuxiliaryPane]); + }; useHardwareKeyboardCommand("files", handleOpenFilesCommand); - const toggleAuxiliaryPane = useCallback(() => { + const toggleAuxiliaryPane = () => { if (auxiliaryPaneRole === "inspector") { setFileInspectorPreferredVisible((current) => !current); return; } setSupplementaryPanePreferredVisible((current) => !current); - }, [auxiliaryPaneRole]); - const setAuxiliaryPaneWidth = useCallback( - (nextWidth: number) => { - if (auxiliaryPaneRole === "inspector") { - setFileInspectorPreferredWidth(nextWidth); - return; - } - setSupplementaryPanePreferredWidth(nextWidth); - }, - [auxiliaryPaneRole], - ); - const handleOpenSettings = useCallback(() => { + }; + const setAuxiliaryPaneWidth = (nextWidth: number) => { + if (auxiliaryPaneRole === "inspector") { + setFileInspectorPreferredWidth(nextWidth); + return; + } + setSupplementaryPanePreferredWidth(nextWidth); + }; + const handleOpenSettings = () => { navigation.navigate("SettingsSheet", { screen: "SettingsContent", params: { screen: "Settings" }, }); - }, [navigation]); + }; - const handleStartNewTask = useCallback(() => { + const handleStartNewTask = () => { navigation.navigate("NewTaskSheet", { screen: "NewTask" }); - }, [navigation]); + }; // Minted here (root stack navigation) so the sidebar pane stays free of // navigation hooks โ€” on iOS it renders inside an independent nav tree. - const handleOpenEnvironmentSettings = useCallback(() => { + const handleOpenEnvironmentSettings = () => { navigation.navigate("SettingsSheet", { screen: "SettingsContent", params: { screen: "SettingsEnvironments" }, }); - }, [navigation]); + }; - const handleNewThreadOnBranch = useCallback( - (thread: EnvironmentThreadShell) => { - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { - environmentId: String(thread.environmentId), - projectId: String(thread.projectId), - branch: thread.branch, - worktreePath: thread.worktreePath, - }, - }); - }, - [navigation], - ); + const handleOpenThreadPullRequests = useOpenThreadPullRequests(); - const handleNewThreadInProject = useCallback( - (project: EnvironmentProject) => { - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { - environmentId: String(project.environmentId), - projectId: String(project.id), - title: project.title, - }, - }); - }, - [navigation], - ); + const handleNewThreadOnBranch = (thread: EnvironmentThreadShell) => { + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(thread.environmentId), + projectId: String(thread.projectId), + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + }); + }; + + const handleNewThreadInProject = (project: EnvironmentProject) => { + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(project.environmentId), + projectId: String(project.id), + title: project.title, + }, + }); + }; const inspectorColumnTargetWidth = workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible @@ -512,47 +453,75 @@ function AdaptiveWorkspaceLayoutContent( target: contentTargetWidth, sidebarVisible: panes.primarySidebarVisible, width: contentTargetWidth, + listPaneTarget: layout.listPaneWidth, + listPaneFloor: layout.listPaneWidth, + listPaneCeiling: layout.listPaneWidth, revision: 0, }); if ( Platform.OS === "android" && (contentSizing.viewportWidth !== viewportWidth || contentSizing.target !== contentTargetWidth || - contentSizing.sidebarVisible !== panes.primarySidebarVisible) + contentSizing.sidebarVisible !== panes.primarySidebarVisible || + contentSizing.listPaneTarget !== layout.listPaneWidth) ) { + const sameViewport = contentSizing.viewportWidth === viewportWidth; const openingSidebar = - Platform.OS === "android" && - contentSizing.viewportWidth === viewportWidth && - !contentSizing.sidebarVisible && + sameViewport && !contentSizing.sidebarVisible && panes.primarySidebarVisible; + // A book-posture hinge resizes the visible list pane instead of revealing it. + const resizingSidebar = + sameViewport && + contentSizing.sidebarVisible && panes.primarySidebarVisible && + contentSizing.listPaneTarget !== layout.listPaneWidth; + const keepWide = + (openingSidebar || resizingSidebar) && contentSizing.width !== null && contentTargetWidth !== null; setContentSizing({ viewportWidth, target: contentTargetWidth, sidebarVisible: panes.primarySidebarVisible, - // Clip the wide navigator during expansion, then reflow once at the end. - // Narrowing it first exposes an empty strip before the sidebar arrives. - width: openingSidebar + // Clip the wide navigator while the sidebar opens or widens, then reflow once at the + // end. Narrowing it first exposes an empty strip before the sidebar arrives. + width: keepWide ? Math.max(contentSizing.width ?? 0, contentTargetWidth ?? 0) : contentTargetWidth, + listPaneTarget: layout.listPaneWidth, + // While a hinge resizes the list, its width follows the pane edge within this span. A + // retarget mid-animation widens the span, since the edge is still somewhere inside it. + listPaneFloor: resizingSidebar + ? Math.min(contentSizing.listPaneFloor ?? 0, layout.listPaneWidth ?? 0) + : layout.listPaneWidth, + listPaneCeiling: resizingSidebar + ? Math.max(contentSizing.listPaneCeiling ?? 0, layout.listPaneWidth ?? 0) + : layout.listPaneWidth, revision: contentSizing.revision + 1, }); } - const settleContentWidth = useCallback((revision: number) => { + const settleContentWidth = (revision: number) => { setContentSizing((current) => - current.revision !== revision || current.width === current.target + current.revision !== revision || + (current.width === current.target && + current.listPaneFloor === current.listPaneTarget && + current.listPaneCeiling === current.listPaneTarget) ? current - : { ...current, width: current.target }, + : { + ...current, + width: current.target, + listPaneFloor: current.listPaneTarget, + listPaneCeiling: current.listPaneTarget, + }, ); - }, []); + }; + const listPaneFloor = contentSizing.listPaneFloor ?? 0; + const listPaneCeiling = contentSizing.listPaneCeiling ?? 0; const contentSettledWidth = Platform.OS === "android" ? contentSizing.width : contentTargetWidth; const contentRevision = contentSizing.revision; const renderedSidebarWidth = useSharedValue( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, ); - const renderedRailWidth = useSharedValue(navigationRailWidth); useEffect(() => { if (nativeWorkspace) return; const targetWidth = panes.primarySidebarVisible @@ -561,127 +530,95 @@ function AdaptiveWorkspaceLayoutContent( renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING, (finished) => { if (finished && Platform.OS === "android") runOnJS(settleContentWidth)(contentRevision); }); - renderedRailWidth.set(withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING)); }, [ nativeWorkspace, - navigationRailWidth, layout.listPaneWidth, layout.listPaneGap, panes.primarySidebarVisible, renderedSidebarWidth, - renderedRailWidth, settleContentWidth, contentRevision, ]); - const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value })); + // Showing or hiding the sidebar clips a fixed-width list. A hinge resize instead tracks the + // animated edge, so the header actions slide with it rather than jumping or being covered. + const listPaneGap = layout.listPaneGap ?? 0; + const listPaneAnimatedStyle = useAnimatedStyle(() => ({ + width: Math.min( + listPaneCeiling, + Math.max(listPaneFloor, renderedSidebarWidth.value - listPaneGap), + ), + })); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ // Fading a UIKit glass ancestor can leave remounted header effects missing. - // The clipped width already reveals and hides the iOS sidebar. opacity: Platform.OS === "ios" ? 1 : Math.min(1, renderedSidebarWidth.value / 80), width: renderedSidebarWidth.value, })); - // Freeze the content pane at its SETTLED width while the side panes - // animate. The navigator (native header + markdown feed) lays out ONCE per - // pane toggle instead of re-measuring on every animation frame โ€” the - // animating columns merely clip/reveal it over a matching background. - // Continuously re-wrapping the chat feed was the main source of dropped - // frames during sidebar/inspector transitions. + // The navigator (native header + markdown feed) lays out ONCE per pane toggle + // instead of re-measuring on every animation frame โ€” the animating columns + // merely clip/reveal it over a matching background. const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); const renderedContentWidth = useDerivedValue(() => - Math.max( - 0, - viewportWidth - - renderedRailWidth.value - - renderedSidebarWidth.value - - renderedInspectorWidth.value, - ), + Math.max(0, viewportWidth - renderedSidebarWidth.value - renderedInspectorWidth.value), ); const selectAndroidWorkspace = useAndroidWorkspaceSelection(); - const handleSelectThread = useCallback( - (thread: EnvironmentThreadShell) => { - const params = { - environmentId: String(thread.environmentId), - threadId: String(thread.id), - }; - if (Platform.OS === "android") { - // Selecting a sidebar row enters its base chat, including from that chat's nested screens. - setFileInspectorPreferredVisible(false); - selectAndroidWorkspace({ name: "Thread", params }); - return; - } - const navigationAction = resolveThreadSelectionNavigationAction({ - usesSplitView: layout.usesSplitView, - pathname, - }); - const overlayState = resolveThreadSelectionOverlayState({ - state: navigation.getState(), - workspaceRouteKey: props.workspaceRouteKey, - action: navigationAction, - params, - }); - if (overlayState !== null) { - setFileInspectorPreferredVisible(false); - navigation.dispatch(CommonActions.reset(overlayState)); - return; - } - if (navigationAction === "set-params") { - const nextThreadKey = scopedThreadKey(thread.environmentId, thread.id); - if (nextThreadKey === selectedThreadKey) { - return; - } - setFileInspectorPreferredVisible(false); - navigation.navigate("Thread", params); - return; - } - if (navigationAction === "replace") { - setFileInspectorPreferredVisible(false); - navigation.dispatch(StackActions.replace("Thread", params)); + const handleSelectThread = (thread: EnvironmentThreadShell) => { + const params = { + environmentId: String(thread.environmentId), + threadId: String(thread.id), + }; + if (Platform.OS === "android") { + setFileInspectorPreferredVisible(false); + selectAndroidWorkspace({ name: "Thread", params }); + return; + } + const navigationAction = resolveThreadSelectionNavigationAction({ + usesSplitView: layout.usesSplitView, + pathname, + }); + const overlayState = resolveThreadSelectionOverlayState({ + state: navigation.getState(), + workspaceRouteKey: props.workspaceRouteKey, + action: navigationAction, + params, + }); + if (overlayState !== null) { + setFileInspectorPreferredVisible(false); + navigation.dispatch(CommonActions.reset(overlayState)); + return; + } + if (navigationAction === "set-params") { + const nextThreadKey = scopedThreadKey(thread.environmentId, thread.id); + if (nextThreadKey === selectedThreadKey) { return; } + setFileInspectorPreferredVisible(false); navigation.navigate("Thread", params); - }, - [ - layout.usesSplitView, - pathname, - navigation, - selectedThreadKey, - props.workspaceRouteKey, - selectAndroidWorkspace, - ], - ); + return; + } + if (navigationAction === "replace") { + setFileInspectorPreferredVisible(false); + navigation.dispatch(StackActions.replace("Thread", params)); + return; + } + navigation.navigate("Thread", params); + }; - const contextValue = useMemo( - () => ({ - layout, - panes, - fileInspector, - primarySidebarSearchQuery, - selectThread: handleSelectThread, - activateAuxiliaryPaneRole, - registerWorkspaceInspector, - setPrimarySidebarSearchQuery, - showAuxiliaryPane, - toggleAuxiliaryPane, - togglePrimarySidebar, - setAuxiliaryPaneWidth, - }), - [ - activateAuxiliaryPaneRole, - fileInspector, - handleSelectThread, - layout, - panes, - primarySidebarSearchQuery, - registerWorkspaceInspector, - showAuxiliaryPane, - setPrimarySidebarSearchQuery, - setAuxiliaryPaneWidth, - toggleAuxiliaryPane, - togglePrimarySidebar, - ], - ); + const contextValue = { + layout, + panes, + fileInspector, + primarySidebarSearchQuery, + selectThread: handleSelectThread, + activateAuxiliaryPaneRole, + registerWorkspaceInspector, + setPrimarySidebarSearchQuery, + showAuxiliaryPane, + toggleAuxiliaryPane, + togglePrimarySidebar, + setAuxiliaryPaneWidth, + }; if (nativeWorkspace) { return ( @@ -705,102 +642,95 @@ function AdaptiveWorkspaceLayoutContent( savedFilters={props.savedThreadListFilters} > - - {shouldRenderNavigationRail ? ( - 0 ? "auto" : "no-hide-descendants"} - pointerEvents={navigationRailWidth > 0 ? "auto" : "none"} - style={railAnimatedStyle} - > - - - ) : null} - {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( - - - ( - - )} - > - - - - - - - - - ) : null} + + + {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( + + + ( + + )} + > + + + + + + + + + ) : null} - - {props.children} - + + {props.children} + + + - - + ); diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx index d5037c6c64d8..12d42a8647a9 100644 --- a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx +++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx @@ -1,10 +1,10 @@ -import { SymbolView } from "../../components/AppSymbol"; +import { SymbolView } from "../../components/ui/AppSymbol"; import { Platform, Pressable, View } from "react-native"; -import { AppText as Text } from "../../components/AppText"; -import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton"; -import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; -import { EmptyState } from "../../components/EmptyState"; +import { AppText as Text } from "../../components/ui/AppText"; +import { NewThreadButton } from "../../components/NewThreadButton"; +import { FloatingActionButton } from "../../components/ui/FloatingActionButton"; +import { EmptyState } from "../../components/ui/EmptyState"; export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => void; @@ -25,7 +25,7 @@ export function WorkspaceEmptyDetail(props: { detail="Add an environment to load projects and start coding sessions." variant="plain" action={ - {props.onStartNewTask ? ( Platform.OS === "android" ? ( - + ) : ( { - // In compact size classes UIKit exposes one column. Selecting a thread - // changes the visible column without rebuilding either navigation stack. - // The initial column is a native prop, so it is applied before the host - // attaches. Commands only handle later navigation changes. if (layout.usesSplitView || shownCompactColumn.current === compactColumn) return; shownCompactColumn.current = compactColumn; hostRef.current?.show(compactColumn); @@ -187,7 +182,9 @@ function WorkspaceColumns( preferredSplitBehavior="tile" topColumnForCollapsing={compactColumn} preferredDisplayMode={ - panes.primarySidebarVisible || !activeDetailKey ? "oneBesideSecondary" : "secondaryOnly" + panes.primarySidebarVisible || (!layout.usesSplitView && !activeDetailKey) + ? "oneBesideSecondary" + : "secondaryOnly" } displayModeButtonVisibility="never" presentsWithGesture @@ -224,24 +221,6 @@ function WorkspaceColumns( options={{ headerShown: true, title: "", - unstable_headerRightItems: () => - Platform.OS === "ios" && - !Platform.isPad && - layout.usesSplitView && - !panes.primarySidebarVisible - ? [ - { - type: "button", - axisBehavior: "verticalPreferred", - accessibilityLabel: "Search threads", - label: "Search threads", - icon: { type: "sfSymbol", name: "magnifyingglass" }, - onPress: () => { - dispatchHardwareKeyboardCommand("focusSearch"); - }, - }, - ] - : [], unstable_headerLeftItems: () => [ { type: "button", @@ -275,7 +254,6 @@ function WorkspaceColumns( - {/* Inspector content supplies its own navigation header. */}