diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index b2f5443e431a..66be80cd1231 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -31,11 +31,21 @@ when inspecting or seeding SQLite. Stop the test server before fixture writes. From the checkout being tested on the selected device host, run: ```bash -node scripts/mobile-native-client.ts ensure +vp run mobile:install ``` -This reuses a matching native client or builds and installs one. Authorized -mobile verification includes that build step unless the user prohibits it. +This installs the dev client for the checkout's native fingerprint: from the +device, the local cache, or the shared cache in seconds, building once only +when no machine has that fingerprint yet (5-15 minutes, logged to the file it +names). The device id may be omitted when exactly one device is booted. It +prints one JSON line; on failure it names the next command. Authorized mobile +verification includes that step unless the user prohibits it. + +For a device you opened in an agent-device session elsewhere (a device pool, +cloud, or remote host), run `vp run mobile:install --session ` +from the directory you opened the session in. It installs the cached build into +that session (`--abi arm64-v8a` for an ARM Android device; `--metro ` also +opens it against Metro) and never builds. Start `vp run dev:client` from `apps/mobile`, or reuse a healthy Metro belonging to this checkout. Open its printed development-client URL with AgentDevice diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e2af83cc5af..d993972f9dc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -423,7 +423,18 @@ jobs: env: T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json - run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} + run: >- + vp run test:cached -- --shard ${{ matrix.shard }}/${{ strategy.job-total }} + --uncached apps/server/integration/transferBudgetV2.integration.test.ts + --verify-hits 1 --report "${{ runner.temp }}/test-cache-${{ matrix.shard }}.json" apps/server + + - name: Upload test cache measurements + if: always() && steps.changes.outputs.test_server != 'false' + uses: actions/upload-artifact@v7 + with: + name: test-cache-${{ matrix.shard }} + path: ${{ runner.temp }}/test-cache-${{ matrix.shard }}.json + if-no-files-found: error # integration/transferBudgetV2.integration.test.ts writes the report; exactly one shard # produces these files. Gating the upload on their presence keeps a diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml index 3def8478bbdc..4b3f8952d503 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -82,7 +82,8 @@ jobs: --archive=tgz \ --yes \ --token "$VERCEL_TOKEN" \ - --scope "$vercel_scope" + --scope "$vercel_scope" \ + --build-env "VITE_RELEASE_CHANNEL=preview" )" printf '%s\n' "$deployment_output" # Vercel can print lockfile warnings to stdout before the deployment URL. diff --git a/.gitignore b/.gitignore index 42543c9c3801..709cdbf6e271 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ apps/mobile/.generated/ apps/mobile/.android-cache/ /.generated/ artifacts/app-store/screenshots/ +artifacts/mobile-e2e/ .github/pr-assets/ native/**/target/ node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 00ed1601e2f9..6c46daceef9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ The most common defect in this repo is a change that works on the path you teste An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: -- The first `vp run dev` in a worktree does this for you when its database is missing. `--no-seed` or `T3CODE_DEV_SEED=0` skips it. +- The first `vp run dev` in a worktree does this for you when its database is missing and the source is at most 500 MiB. Larger sources warn and dev starts with an empty database. `--no-seed` or `T3CODE_DEV_SEED=0` skips seeding. Manual migration still accepts larger sources and may use substantial disk space before pruning. - Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. - Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing. - Bring `secrets` and `settings.json` only if the flow under test needs them. @@ -134,12 +134,12 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. -For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure ` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow. +For authorized mobile verification, a missing or outdated native client is an install step, not a blocker. Run `vp run mobile:install [device-id]` on the device host before starting Metro; it targets the only booted simulator or connected Android device when the id is omitted. It installs the dev client for this checkout's native fingerprint from the shared cache in seconds and builds only when no machine has built it yet, so prefer it over `expo run:*`, `xcodebuild`, or `gradlew`, which rebuild from scratch. Its stdout is one JSON line; build output goes to the log it names. See `test-t3-mobile` for the full workflow. ## Pull requests - Never make a PR unless the developer explicitly asks you to do so. -- If `origin` is git.cafe, follow [GitCafe mirror](docs/operations/development.md#gitcafe-mirror-optional): use `cafe` for existing PRs, issues, comments and labels, but push and open PRs on GitHub, where CI runs. +- If `origin` is git.cafe, follow [GitCafe mirror](docs/operations/development.md#gitcafe-mirror-optional): push to `origin`, open and track PRs with `cafe` and their git.cafe URLs, and keep `gh` for merging. - Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. - UI changes need before/after images. Motion or timing needs a short video. @@ -204,6 +204,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ - Inferred types over annotations. `any` is the enemy. - Default to no comment. Write one only for a constraint, an external quirk, or an invariant the code can't show, in one line where you can and four at most. Don't restate names or signatures, narrate steps, record history, or explain the fix you just made; the PR carries that. `t3code/no-low-value-comments` enforces the length cap and the mechanical cases. - Mock UI from the app itself. A requested HTML mock, mockup, or gallery of a T3 Code change is built from the web app's real components with `vp run render-mock`, never hand-written CSS that imitates them. See the `mock-t3-feature` skill. +- React effects are for synchronizing with something outside React. Derive in render and act in handlers instead of syncing state, but don't hide real lifecycle work in refs or stores to dodge one. The `t3code/react-effect-budget` lint rule holds each file to its count in `scripts/effect-budget.json`; a new effect states its reason with `// oxlint-disable-next-line t3code/react-effect-budget -- `. - 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/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 8d16dbd3d315..24506f71acd4 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -440,7 +440,10 @@ export const make = Effect.gen(function* () { webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request); }); }), - complete: (response) => Effect.sync(() => broker.complete(response)), + // The IPC handler owns the activation-completion span. + complete: Effect.fnUntraced(function* (response) { + return yield* Effect.sync(() => broker.complete(response)); + }), }); }); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 1258d29aaef3..f6465b3f7a34 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -28,8 +28,6 @@ vi.mock("@clerk/electron", () => ({ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); - -import * as Option from "effect/Option"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -298,7 +296,7 @@ it.effect( }), } as unknown as ElectronApp.ElectronApp["Service"]; const electronWindow = { - currentMainOrFirst: Effect.succeed(Option.some(window)), + currentMainOrFirst: Effect.succeedSome(window), reveal: () => Effect.sync(() => revealed.resolve()), } as unknown as ElectronWindow.ElectronWindow["Service"]; return Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index db56aa3c83b0..3cdfdb6cfa38 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -100,9 +100,15 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const command = yield* commandIn({ home }); const link = path.join(home, ".local", "bin", "t3"); - expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); + expect(yield* command.state).toEqual({ + supported: true, + installedPath: null, + onPath: false, + launcherPath: null, + }); const installed = yield* command.install; expect(installed.installedPath).toBe(link); + expect(installed.launcherPath).toBe(path.join(home, ".t3", "bin", "t3")); expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); expect((yield* command.install).installedPath).toBe(link); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 95d844e60212..2ae070d63826 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -150,18 +150,26 @@ export const make = Effect.gen(function* () { const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () { if (!environment.isPackaged) { - return { supported: false, installedPath: null, onPath: false } as const; + return { supported: false, installedPath: null, onPath: false, launcherPath: null } as const; } + const launcherPath = (yield* exists(launcher)) ? launcher : null; const installed = yield* installedAt; const shadowedBy = yield* foreignFirstOnPath; const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {}; if (Option.isNone(installed)) { - return { supported: true, installedPath: null, onPath: false, ...shadow }; + return { supported: true, installedPath: null, onPath: false, launcherPath, ...shadow }; } 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 }))); + return { supported: true, installedPath: installed.value, onPath, launcherPath, ...shadow }; + }).pipe( + Effect.orElseSucceed(() => ({ + supported: false, + installedPath: null, + onPath: false, + launcherPath: null, + })), + ); const ensureLauncher = DesktopCliShim.install.pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 6ddb31cb6eaa..ab8ea0b8e9d6 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -342,4 +342,36 @@ describe("DesktopObservability", () => { Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), ); }); + + it.effect("describes the Node runtime and the desktop mode under semantic or t3code keys", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const layerEnvironment = layerEnvironmentFor(baseDir); + yield* writeObservabilitySettings(layerEnvironment, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop resource under test").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), + ), + ); + + const body = requests.map((request) => request.body).join("\n"); + assert.include(body, '"key":"process.runtime.name","value":{"stringValue":"nodejs"}'); + assert.include( + body, + `"key":"process.runtime.version","value":{"stringValue":"${process.versions.node}"}`, + ); + assert.include(body, '"key":"t3code.desktop.mode","value":{"stringValue":"development"}'); + assert.notInclude(body, "service.mode"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), + ); + }); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 6baf2c4117f9..3cd3936a59bf 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -2,11 +2,14 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { makeLocalFileTracer, makeTraceSink, + nodeProcessRuntimeAttributes, + processServiceInstanceId, type SignalExport, } from "@t3tools/shared/observability"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SharedObservability from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import { releaseChannelOf, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import { parsePersistedServerObservabilitySettings, type PersistedServerObservabilitySettings, @@ -456,90 +459,91 @@ const makeBackendOutputLogShape = ( ): Effect.Effect => Option.match(sink, { onNone: () => Effect.succeed(DesktopBackendOutputLogNoop), - onSome: (logFile) => - Effect.gen(function* () { - const sessionRef = yield* Ref.make(Option.none()); - const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")( - function* (session: BackendOutputSession, details: string) { - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process failure output start", - level: "ERROR", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - phase: "START", - details: session.startDetails, - }, - }); - for (const output of session.chunks) { - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process output", - level: output.streamName === "stderr" ? "ERROR" : "INFO", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - stream: output.streamName, - text: textDecoder.decode(output.chunk.subarray(output.offset)), - }, - }); - } - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process failure output end", - level: "ERROR", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - phase: "END", - details: sanitizeLogValue(details), - }, - }); + // The enclosing operation owns the span for this inline handler. + onSome: Effect.fnUntraced(function* (logFile) { + const sessionRef = yield* Ref.make(Option.none()); + const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")(function* ( + session: BackendOutputSession, + details: string, + ) { + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process failure output start", + level: "ERROR", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + phase: "START", + details: session.startDetails, }, - ); - return { - beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ - details, - }) { - const runId = yield* currentDesktopRunId; - yield* Ref.set( - sessionRef, - Option.some({ - runId, - startDetails: sanitizeLogValue(details), - chunks: [], - byteLength: 0, - }), - ); - }), - writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { - if (environment.isDevelopment) { - yield* writeDevelopmentConsoleOutput(streamName, chunk); - } - yield* Ref.update( - sessionRef, - Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), - ); - }), - persistFailureSnapshot: Effect.fn( - "desktop.observability.backendOutput.persistFailureSnapshot", - )(function* ({ details }) { - const session = yield* Ref.get(sessionRef); - if (Option.isSome(session)) { - yield* writeFailure(session.value, details); - } - }), - persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")( - function* ({ details }) { - const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); - if (Option.isNone(session)) return; - yield* writeFailure(session.value, details); + }); + for (const output of session.chunks) { + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process output", + level: output.streamName === "stderr" ? "ERROR" : "INFO", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + stream: output.streamName, + text: textDecoder.decode(output.chunk.subarray(output.offset)), }, - ), - discardSession: Ref.set(sessionRef, Option.none()), - } satisfies DesktopBackendOutputLogShape; - }), + }); + } + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process failure output end", + level: "ERROR", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + phase: "END", + details: sanitizeLogValue(details), + }, + }); + }); + return { + beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ + details, + }) { + const runId = yield* currentDesktopRunId; + yield* Ref.set( + sessionRef, + Option.some({ + runId, + startDetails: sanitizeLogValue(details), + chunks: [], + byteLength: 0, + }), + ); + }), + writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { + if (environment.isDevelopment) { + yield* writeDevelopmentConsoleOutput(streamName, chunk); + } + yield* Ref.update( + sessionRef, + Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), + ); + }), + persistFailureSnapshot: Effect.fn( + "desktop.observability.backendOutput.persistFailureSnapshot", + )(function* ({ details }) { + const session = yield* Ref.get(sessionRef); + if (Option.isSome(session)) { + yield* writeFailure(session.value, details); + } + }), + persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")(function* ({ + details, + }) { + const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); + if (Option.isNone(session)) return; + yield* writeFailure(session.value, details); + }), + discardSession: Ref.set(sessionRef, Option.none()), + } satisfies DesktopBackendOutputLogShape; + }), }); const layerBackendOutputLogFactory = Layer.effect( @@ -586,7 +590,9 @@ const layerBackendOutputLogFactory = Layer.effect( }); return DesktopBackendOutputLogFactory.of({ - forInstance: (id) => makeForId(id), + forInstance: Effect.fn("DesktopBackendOutputLogFactory.forInstance")(function (id) { + return makeForId(id); + }), }); }), ); @@ -595,15 +601,24 @@ const layerTelemetry = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; + // Unpackaged runs launch a script, so Electron reports its own version. + const serviceVersion = environment.isPackaged + ? environment.appVersion + : desktopPackageJson.version; const resource = { serviceName: "t3code-desktop", - // Unpackaged runs launch a script, so Electron reports its own version. - serviceVersion: environment.isPackaged ? environment.appVersion : desktopPackageJson.version, + serviceVersion, attributes: { "service.namespace": "t3code", "service.runtime": "desktop", - "service.mode": environment.isDevelopment ? "development" : "packaged", + "t3code.desktop.mode": environment.isDevelopment ? "development" : "packaged", + "service.instance.id": processServiceInstanceId(endpoints.resourceAttributes), + ...releaseResourceAttributes( + environment.isDevelopment ? "dev" : releaseChannelOf(serviceVersion), + endpoints.resourceAttributes, + ), "host.name": endpoints.resourceAttributes["host.name"] || (yield* HostProcess.Hostname), + ...nodeProcessRuntimeAttributes(), }, }; diff --git a/apps/desktop/src/app/DesktopWebLinks.test.ts b/apps/desktop/src/app/DesktopWebLinks.test.ts index 9e87cfb7a9c0..9b5bc89148de 100644 --- a/apps/desktop/src/app/DesktopWebLinks.test.ts +++ b/apps/desktop/src/app/DesktopWebLinks.test.ts @@ -3,7 +3,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeEvents from "node:events"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; @@ -26,7 +25,7 @@ const makeWindow = () => { Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - currentMainOrFirst: Effect.succeed(Option.some(window)), + currentMainOrFirst: Effect.succeedSome(window), reveal: (target: Electron.BrowserWindow) => Effect.sync(() => void revealed.push(target)), } as unknown as ElectronWindow.ElectronWindow["Service"]), ), diff --git a/apps/desktop/src/app/DesktopWebLinks.ts b/apps/desktop/src/app/DesktopWebLinks.ts index f448f184b893..ce63a710f7f7 100644 --- a/apps/desktop/src/app/DesktopWebLinks.ts +++ b/apps/desktop/src/app/DesktopWebLinks.ts @@ -69,16 +69,15 @@ const make = Effect.gen(function* () { }); return DesktopWebLinks.of({ - receive: (url) => - Effect.suspend(() => { + receive: Effect.fn("DesktopWebLinks.receive")(function (url) { + return Effect.suspend(() => { pending.push(url); return flush; - }).pipe(Effect.withSpan("DesktopWebLinks.receive")), - setRendererReady: (ready) => - (ready ? startListening : Effect.sync(stopListening)).pipe( - Effect.andThen(flush), - Effect.withSpan("DesktopWebLinks.setRendererReady"), - ), + }); + }), + setRendererReady: Effect.fn("DesktopWebLinks.setRendererReady")(function (ready) { + return ready ? startListening : Effect.sync(stopListening); + }, Effect.andThen(flush)), }); }); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 8d70ab4c4791..7464d8fbe9e2 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -678,7 +678,7 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("resolveWsl carries the kill switch into the distro", () => + it.effect("resolveWsl carries the kill switch and resource attributes into the distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -688,10 +688,12 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousDisabled = process.env.OTEL_SDK_DISABLED; const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED; + const previousResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES; try { delete process.env.WSLENV; process.env.OTEL_SDK_DISABLED = "true"; process.env.T3CODE_TELEMETRY_ENABLED = "false"; + process.env.OTEL_RESOURCE_ATTRIBUTES = "deployment.environment.name=staging"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -701,6 +703,8 @@ describe("DesktopBackendConfiguration", () => { assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); + assert.equal(config.env.OTEL_RESOURCE_ATTRIBUTES, "deployment.environment.name=staging"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_RESOURCE_ATTRIBUTES"); }).pipe( Effect.provide( layerBackendConfiguration().pipe( @@ -722,6 +726,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OTEL_SDK_DISABLED", previousDisabled); restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); + restoreEnv("OTEL_RESOURCE_ATTRIBUTES", previousResourceAttributes); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 06b4c6b59816..ca69f5d02589 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -110,6 +110,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_TRACES_EXPORTER", "OTEL_METRICS_EXPORTER", "OTEL_LOGS_EXPORTER", + "OTEL_RESOURCE_ATTRIBUTES", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 007f7369365a..8b5e8045ed37 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1043,58 +1043,58 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); yield* Option.match(active, { onNone: () => Effect.void, - onSome: (run) => - Effect.gen(function* () { - const closed = yield* closeRun(run, parentScope, options); - if (!closed) { - return; - } - const cleanup = yield* mutex.withPermits(1)( - Ref.modify( - state, - ( - latest, - ): readonly [ - { - readonly needsCleanup: boolean; - readonly shouldStart: boolean; - }, - BackendManagerState, - ] => { - const current = Option.getOrUndefined(latest.active); - if (current?.id !== run.id) { - return [ - { - needsCleanup: false, - shouldStart: - latest.desiredRunning && - Option.isNone(latest.active) && - Option.isNone(latest.restartFiber), - }, - latest, - ]; - } + // The enclosing operation owns the span for this inline handler. + onSome: Effect.fnUntraced(function* (run) { + const closed = yield* closeRun(run, parentScope, options); + if (!closed) { + return; + } + const cleanup = yield* mutex.withPermits(1)( + Ref.modify( + state, + ( + latest, + ): readonly [ + { + readonly needsCleanup: boolean; + readonly shouldStart: boolean; + }, + BackendManagerState, + ] => { + const current = Option.getOrUndefined(latest.active); + if (current?.id !== run.id) { return [ { - needsCleanup: true, - shouldStart: latest.desiredRunning, - }, - { - ...latest, - active: Option.none(), + needsCleanup: false, + shouldStart: + latest.desiredRunning && + Option.isNone(latest.active) && + Option.isNone(latest.restartFiber), }, + latest, ]; - }, - ), - ); - if (cleanup.needsCleanup) { - yield* desktopTelemetryPublisher.removeControlSource(spec.id); - yield* backendOutputLog.discardSession; - } - if (cleanup.shouldStart) { - yield* start; - } - }), + } + return [ + { + needsCleanup: true, + shouldStart: latest.desiredRunning, + }, + { + ...latest, + active: Option.none(), + }, + ]; + }, + ), + ); + if (cleanup.needsCleanup) { + yield* desktopTelemetryPublisher.removeControlSource(spec.id); + yield* backendOutputLog.discardSession; + } + if (cleanup.shouldStart) { + yield* start; + } + }), }); }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 7b2f68519076..4292b92a3d0e 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -291,13 +291,14 @@ export const layer = Layer.effect( }); return DesktopBackendPool.of({ - get: (id) => - SynchronizedRef.get(instancesRef).pipe( + get: Effect.fn("DesktopBackendPool.get")(function (id) { + return SynchronizedRef.get(instancesRef).pipe( Effect.map((instances) => { const entry = instances.get(id); return entry?._tag === "Active" ? Option.some(entry.instance) : Option.none(); }), - ), + ); + }), list: SynchronizedRef.get(instancesRef).pipe( Effect.map((instances) => Array.from(instances.values()).flatMap((entry) => @@ -326,11 +327,17 @@ export const layerTest = ( ); const primary = instances[0]!; return DesktopBackendPool.of({ - get: (id) => Effect.succeed(Option.fromNullishOr(byId.get(id))), + get: Effect.fn("DesktopBackendPool.get")(function (id) { + return Effect.succeed(Option.fromNullishOr(byId.get(id))); + }), list: Effect.succeed(Array.from(byId.values())), primary: Effect.succeed(primary), - register: () => Effect.die("DesktopBackendPool.layerTest does not support register"), - unregister: () => Effect.die("DesktopBackendPool.layerTest does not support unregister"), + register: Effect.fn("DesktopBackendPool.register")(function () { + return Effect.die("DesktopBackendPool.layerTest does not support register"); + }), + unregister: Effect.fn("DesktopBackendPool.unregister")(function () { + return Effect.die("DesktopBackendPool.layerTest does not support unregister"); + }), }); }), ); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 7190c70aedc2..04f26b1bced8 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -135,59 +135,73 @@ export const make = ElectronApp.of({ quit: Effect.sync(() => { Electron.app.quit(); }), - exit: (code) => - Effect.sync(() => { + exit: Effect.fn("ElectronApp.exit")(function (code) { + return Effect.sync(() => { Electron.app.exit(code); - }), - relaunch: (options) => - Effect.sync(() => { + }); + }), + relaunch: Effect.fn("ElectronApp.relaunch")(function (options) { + return Effect.sync(() => { Electron.app.relaunch(options); - }), - setPath: (name, path) => - Effect.sync(() => { + }); + }), + setPath: Effect.fn("ElectronApp.setPath")(function (name, path) { + return Effect.sync(() => { Electron.app.setPath(name, path); - }), - setName: (name) => - Effect.sync(() => { + }); + }), + setName: Effect.fn("ElectronApp.setName")(function (name) { + return Effect.sync(() => { Electron.app.setName(name); - }), - setAboutPanelOptions: (options) => - Effect.sync(() => { + }); + }), + setAboutPanelOptions: Effect.fn("ElectronApp.setAboutPanelOptions")(function (options) { + return Effect.sync(() => { Electron.app.setAboutPanelOptions(options); - }), - setAppUserModelId: (id) => - Effect.sync(() => { + }); + }), + setAppUserModelId: Effect.fn("ElectronApp.setAppUserModelId")(function (id) { + return Effect.sync(() => { Electron.app.setAppUserModelId(id); - }), + }); + }), getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), - setAsDefaultProtocolClient: (protocol, path, args) => - Effect.sync(() => { - if (path === undefined) { - return Electron.app.setAsDefaultProtocolClient(protocol); - } - return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); - }), - setDesktopName: (desktopName) => - Effect.sync(() => { + setAsDefaultProtocolClient: Effect.fn("ElectronApp.setAsDefaultProtocolClient")( + function (protocol, path, args) { + return Effect.sync(() => { + if (path === undefined) { + return Electron.app.setAsDefaultProtocolClient(protocol); + } + return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); + }); + }, + ), + setDesktopName: Effect.fn("ElectronApp.setDesktopName")(function (desktopName) { + return Effect.sync(() => { const linuxApp = Electron.app as Electron.App & { setDesktopName?: (desktopName: string) => void; }; linuxApp.setDesktopName?.(desktopName); - }), - setDockIcon: (iconPath) => - Effect.sync(() => { + }); + }), + setDockIcon: Effect.fn("ElectronApp.setDockIcon")(function (iconPath) { + return Effect.sync(() => { Electron.app.dock?.setIcon(iconPath); - }), - appendCommandLineSwitch: (switchName, value) => - Effect.sync(() => { - if (value === undefined) { - Electron.app.commandLine.appendSwitch(switchName); - return; - } - Electron.app.commandLine.appendSwitch(switchName, value); - }), - onBeforeQuitForUpdate: (listener) => - Effect.acquireRelease( + }); + }), + appendCommandLineSwitch: Effect.fn("ElectronApp.appendCommandLineSwitch")( + function (switchName, value) { + return Effect.sync(() => { + if (value === undefined) { + Electron.app.commandLine.appendSwitch(switchName); + return; + } + Electron.app.commandLine.appendSwitch(switchName, value); + }); + }, + ), + onBeforeQuitForUpdate: Effect.fn("ElectronApp.onBeforeQuitForUpdate")(function (listener) { + return Effect.acquireRelease( Effect.sync(() => { Electron.autoUpdater.on("before-quit-for-update", listener); }), @@ -195,11 +209,13 @@ export const make = ElectronApp.of({ Effect.sync(() => { Electron.autoUpdater.removeListener("before-quit-for-update", listener); }), - ).pipe(Effect.asVoid), - removeCommandLineSwitch: (switchName) => - Effect.sync(() => { + ).pipe(Effect.asVoid); + }), + removeCommandLineSwitch: Effect.fn("ElectronApp.removeCommandLineSwitch")(function (switchName) { + return Effect.sync(() => { Electron.app.commandLine.removeSwitch(switchName); - }), + }); + }), on: addScopedAppListener, }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index d2a72ba88fd6..debe29f6f001 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -212,8 +212,8 @@ export const make = ElectronDialog.of({ }); return result.canceled ? Option.none() : Option.fromNullishOr(result.filePath || undefined); }), - showMessageBox: (options, owner) => - Effect.tryPromise({ + showMessageBox: Effect.fn("ElectronDialog.showMessageBox")(function (options, owner) { + return Effect.tryPromise({ try: () => owner === undefined ? Electron.dialog.showMessageBox(options) @@ -227,9 +227,10 @@ export const make = ElectronDialog.of({ buttonCount: options.buttons?.length ?? 0, cause, }), - }), - showErrorBox: (title, content) => - Effect.try({ + }); + }), + showErrorBox: Effect.fn("ElectronDialog.showErrorBox")(function (title, content) { + return Effect.try({ try: () => Electron.dialog.showErrorBox(title, content), catch: (cause) => new ElectronDialogShowErrorBoxError({ @@ -237,7 +238,8 @@ export const make = ElectronDialog.of({ contentLength: content.length, cause, }), - }).pipe(Effect.orDie), + }).pipe(Effect.orDie); + }), }); export const layer = Layer.succeed(ElectronDialog, make); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 60b7eaef4c38..d6e6f73e670c 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -189,10 +189,11 @@ export const make = Effect.gen(function* () { }; return ElectronMenu.of({ - sendActionToFirstResponder: (action) => + sendActionToFirstResponder: Effect.fn("ElectronMenu.sendActionToFirstResponder")((action) => Effect.sync(() => Electron.Menu.sendActionToFirstResponder(action)), - setApplicationMenu: (template) => - Effect.try({ + ), + setApplicationMenu: Effect.fn("ElectronMenu.setApplicationMenu")(function (template) { + return Effect.try({ try: () => { Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); }, @@ -204,9 +205,10 @@ export const make = Effect.gen(function* () { itemCount: template.length, cause, }), - }).pipe(Effect.orDie), - popupTemplate: (input) => - input.template.length === 0 + }).pipe(Effect.orDie); + }), + popupTemplate: Effect.fn("ElectronMenu.popupTemplate")(function (input) { + return input.template.length === 0 ? Effect.void : Effect.try({ try: () => @@ -222,9 +224,10 @@ export const make = Effect.gen(function* () { itemCount: input.template.length, cause, }), - }).pipe(Effect.orDie), - showContextMenu: (input) => - Effect.callback>((resume) => { + }).pipe(Effect.orDie); + }), + showContextMenu: Effect.fn("ElectronMenu.showContextMenu")(function (input) { + return Effect.callback>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); if (normalizedItems.length === 0) { resume(Effect.succeedNone); @@ -276,7 +279,8 @@ export const make = Effect.gen(function* () { ), ); } - }), + }); + }), }); }); diff --git a/apps/desktop/src/electron/ElectronPowerMonitor.ts b/apps/desktop/src/electron/ElectronPowerMonitor.ts index 41c36acf2250..5a702b61a811 100644 --- a/apps/desktop/src/electron/ElectronPowerMonitor.ts +++ b/apps/desktop/src/electron/ElectronPowerMonitor.ts @@ -79,8 +79,11 @@ const onSpeedLimitChange: ElectronPowerMonitor["Service"]["onSpeedLimitChange"] export const make = ElectronPowerMonitor.of({ isOnBatteryPower: Effect.sync(() => Electron.powerMonitor.isOnBatteryPower()), getSystemIdleTime: Effect.sync(() => Electron.powerMonitor.getSystemIdleTime()), - getSystemIdleState: (idleThresholdSeconds) => - Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)), + getSystemIdleState: Effect.fn("ElectronPowerMonitor.getSystemIdleState")( + function (idleThresholdSeconds) { + return Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)); + }, + ), getCurrentThermalState: Effect.sync(() => Electron.powerMonitor.getCurrentThermalState()), onSimpleEvent, onThermalStateChange, diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 0395d20e9bd3..9725fd41e131 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -74,16 +74,18 @@ export const make = Effect.gen(function* () { try: () => Electron.safeStorage.isEncryptionAvailable(), catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), }), - encryptString: (value) => - Effect.try({ + encryptString: Effect.fn("ElectronSafeStorage.encryptString")(function (value) { + return Effect.try({ try: () => Electron.safeStorage.encryptString(value), catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), - }), - decryptString: (value) => - Effect.try({ + }); + }), + decryptString: Effect.fn("ElectronSafeStorage.decryptString")(function (value) { + return Effect.try({ try: () => Electron.safeStorage.decryptString(Buffer.from(value)), catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), - }), + }); + }), selectedStorageBackend: Effect.sync(() => { if (platform !== "linux") { return Option.none(); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 9b93a378bab3..da824a246dce 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -68,8 +68,8 @@ export class ElectronShell extends Context.Service< /** @public */ export const make = ElectronShell.of({ - openExternal: (rawUrl) => - Option.match(parseSafeExternalUrl(rawUrl), { + openExternal: Effect.fn("ElectronShell.openExternal")(function (rawUrl) { + return Option.match(parseSafeExternalUrl(rawUrl), { onNone: () => Effect.succeed(false), onSome: (externalUrl) => Effect.promise(() => @@ -78,17 +78,22 @@ export const make = ElectronShell.of({ () => false, ), ), - }), - openSystemSettings: (pane) => - Effect.promise(() => + }); + }), + openSystemSettings: Effect.fn("ElectronShell.openSystemSettings")(function (pane) { + return Effect.promise(() => Electron.shell.openExternal(MAC_PERMISSION_SETTINGS_URLS[pane]).then( () => true, () => false, ), - ), - copyText: (text) => - Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)), - showItemInFolder: (path) => Effect.sync(() => Electron.shell.showItemInFolder(path)), + ); + }), + copyText: Effect.fn("ElectronShell.copyText")(function (text) { + return Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)); + }), + showItemInFolder: Effect.fn("ElectronShell.showItemInFolder")(function (path) { + return Effect.sync(() => Electron.shell.showItemInFolder(path)); + }), }); export const layer = Layer.succeed(ElectronShell, make); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index a84eedda13b4..ab0673b817ae 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -31,15 +31,16 @@ export class ElectronTheme extends Context.Service< /** @public */ export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), - setSource: (theme) => - Effect.try({ + setSource: Effect.fn("ElectronTheme.setSource")(function (theme) { + return Effect.try({ try: () => { Electron.nativeTheme.themeSource = theme; }, catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }), - }), - onUpdated: (listener) => - Effect.acquireRelease( + }); + }), + onUpdated: Effect.fn("ElectronTheme.onUpdated")(function (listener) { + return Effect.acquireRelease( Effect.suspend(() => { Electron.nativeTheme.on("updated", listener); return Effect.void; @@ -49,7 +50,8 @@ export const make = ElectronTheme.of({ Electron.nativeTheme.removeListener("updated", listener); return Effect.void; }), - ), + ); + }), }); export const layer = Layer.succeed(ElectronTheme, make); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 397044932607..c71b42040db2 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -82,47 +82,57 @@ export class ElectronUpdater extends Context.Service< /** @public */ export const make = ElectronUpdater.of({ - setFeedURL: (options) => - Effect.suspend(() => { + setFeedURL: Effect.fn("ElectronUpdater.setFeedURL")(function (options) { + return Effect.suspend(() => { autoUpdater.setFeedURL(options); return Effect.void; - }), - setAutoDownload: (value) => - Effect.suspend(() => { + }); + }), + setAutoDownload: Effect.fn("ElectronUpdater.setAutoDownload")(function (value) { + return Effect.suspend(() => { autoUpdater.autoDownload = value; return Effect.void; - }), - setAutoInstallOnAppQuit: (value) => - Effect.suspend(() => { + }); + }), + setAutoInstallOnAppQuit: Effect.fn("ElectronUpdater.setAutoInstallOnAppQuit")(function (value) { + return Effect.suspend(() => { autoUpdater.autoInstallOnAppQuit = value; return Effect.void; - }), - setChannel: (channel) => - Effect.suspend(() => { + }); + }), + setChannel: Effect.fn("ElectronUpdater.setChannel")(function (channel) { + return Effect.suspend(() => { autoUpdater.channel = channel; return Effect.void; - }), - setAllowPrerelease: (value) => - Effect.suspend(() => { + }); + }), + setAllowPrerelease: Effect.fn("ElectronUpdater.setAllowPrerelease")(function (value) { + return Effect.suspend(() => { autoUpdater.allowPrerelease = value; return Effect.void; - }), + }); + }), allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), - setAllowDowngrade: (value) => - Effect.suspend(() => { + setAllowDowngrade: Effect.fn("ElectronUpdater.setAllowDowngrade")(function (value) { + return Effect.suspend(() => { autoUpdater.allowDowngrade = value; return Effect.void; - }), - setFullChangelog: (value) => - Effect.suspend(() => { + }); + }), + setFullChangelog: Effect.fn("ElectronUpdater.setFullChangelog")(function (value) { + return Effect.suspend(() => { autoUpdater.fullChangelog = value; return Effect.void; - }), - setDisableDifferentialDownload: (value) => - Effect.suspend(() => { - autoUpdater.disableDifferentialDownload = value; - return Effect.void; - }), + }); + }), + setDisableDifferentialDownload: Effect.fn("ElectronUpdater.setDisableDifferentialDownload")( + function (value) { + return Effect.suspend(() => { + autoUpdater.disableDifferentialDownload = value; + return Effect.void; + }); + }, + ), checkForUpdates: Effect.suspend(() => { const channel = autoUpdater.channel; return Effect.tryPromise({ @@ -137,8 +147,11 @@ export const make = ElectronUpdater.of({ catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), }).pipe(Effect.asVoid); }), - quitAndInstall: ({ isSilent, isForceRunAfter }) => - Effect.suspend(() => { + quitAndInstall: Effect.fn("ElectronUpdater.quitAndInstall")(function ({ + isSilent, + isForceRunAfter, + }) { + return Effect.suspend(() => { const channel = autoUpdater.channel; return Effect.try({ try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), @@ -150,8 +163,9 @@ export const make = ElectronUpdater.of({ cause, }), }); - }), - on: (eventName, listener) => { + }); + }), + on: Effect.fn("ElectronUpdater.on")(function (eventName, listener) { const eventTarget = autoUpdater as unknown as { on: (eventName: string, listener: (...args: Array) => void) => void; removeListener: (eventName: string, listener: (...args: Array) => void) => void; @@ -166,7 +180,7 @@ export const make = ElectronUpdater.of({ eventTarget.removeListener(eventName, untypedListener); }), ).pipe(Effect.asVoid); - }, + }), }); export const layer = Layer.succeed(ElectronUpdater, make); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 4163d4a42970..bc5cd9b984eb 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -222,7 +222,7 @@ export const make = Effect.gen(function* () { }); return ElectronWindow.of({ - create: (options) => { + create: Effect.fn("ElectronWindow.create")(function (options) { const webPreferences = options.webPreferences; const diagnosticOptions = { title: options.title ?? null, @@ -250,13 +250,15 @@ export const make = Effect.gen(function* () { try: () => new Electron.BrowserWindow(options), catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }), }); - }, + }), main: liveMain, currentMainOrFirst, focusedMainOrFirst, - setMain: (window) => Ref.set(mainWindowRef, Option.some(window)), - clearMain: (window) => - Ref.update(mainWindowRef, (current) => { + setMain: Effect.fn("ElectronWindow.setMain")(function (window) { + return Ref.set(mainWindowRef, Option.some(window)); + }), + clearMain: Effect.fn("ElectronWindow.clearMain")(function (window) { + return Ref.update(mainWindowRef, (current) => { if (Option.isNone(current)) { return current; } @@ -264,9 +266,10 @@ export const make = Effect.gen(function* () { return current; } return Option.none(); - }), - prepareReveal: (window) => - Effect.promise(async () => { + }); + }), + prepareReveal: Effect.fn("ElectronWindow.prepareReveal")(function (window) { + return Effect.promise(async () => { if (platform !== "win32" || window.isDestroyed()) { return false; } @@ -274,9 +277,10 @@ export const make = Effect.gen(function* () { return ensureWindowsForegroundFocus() .prepare(windowsForegroundFocusTarget(window)) .catch(() => false); - }), - reveal: (window) => - Effect.tryPromise({ + }); + }), + reveal: Effect.fn("ElectronWindow.reveal")(function (window) { + return Effect.tryPromise({ try: async () => { if (window.isDestroyed()) { return; @@ -337,7 +341,8 @@ export const make = Effect.gen(function* () { channel: null, cause, }), - }).pipe(Effect.orDie), + }).pipe(Effect.orDie); + }), sendAll: /* Window broadcasts run for streamed updates; avoid a span per broadcast. */ Effect.fnUntraced(function* (channel, ...args) { diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 1df67deef2a1..ae24953b5c21 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -312,7 +312,22 @@ describe("pickProjectFavicon", () => { filters: [ { name: "Images", - extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp", "icns"], + extensions: [ + "avif", + "bmp", + "gif", + "ico", + "heic", + "heif", + "jpeg", + "jpg", + "png", + "svg", + "tif", + "tiff", + "webp", + "icns", + ], }, ], }, diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index d0444d42e502..898d94221b8d 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -318,6 +318,237 @@ describe("cookieDatabaseCandidatePaths", () => { const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; +const zen = BROWSER_IMPORT_SOURCES.find((source) => source.id === "zen")!; + +describe("Zen", () => { + it.effect("resolves Zen's native user-data roots on every supported platform", () => + run( + Effect.gen(function* () { + const darwinContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: "/Users/zen-user" }), + Effect.provideService(HostProcess.Platform, "darwin"), + ); + const windowsContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { + HOME: "C:\\Users\\zen-user", + APPDATA: "C:\\Users\\zen-user\\AppData\\Roaming", + }), + Effect.provideService(HostProcess.Platform, "win32"), + ); + const linuxContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: "/home/zen-user" }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + + assert.equal(zen.name, "Zen"); + assert.equal(zen.engine, "firefox"); + assert.deepEqual([...zen.platforms], ["darwin", "win32", "linux"]); + assert.equal( + zen.userDataDirectory(darwinContext), + darwinContext.path.join("/Users/zen-user", "Library", "Application Support", "zen"), + ); + assert.equal( + zen.userDataDirectory(windowsContext), + windowsContext.path.join("C:\\Users\\zen-user\\AppData\\Roaming", "zen"), + ); + assert.equal( + zen.userDataDirectory(linuxContext), + linuxContext.path.join("/home/zen-user", ".config", "zen"), + ); + assert.deepEqual(zen.alternateLinuxRoots?.(linuxContext), [ + linuxContext.path.join("/home/zen-user", ".zen"), + linuxContext.path.join("/home/zen-user", ".var", "app", "app.zen_browser.zen", "zen"), + ]); + }), + ), + ); + + it.effect("discovers Zen profiles through its Firefox-compatible metadata", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "darwin"), + ); + const root = zen.userDataDirectory(context)!; + yield* fileSystem.makeDirectory(root, { recursive: true }); + const profile = context.path.join(root, "Profiles", "zen.default"); + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 2, 1); + yield* fileSystem.makeDirectory(context.path.join(root, "Profiles", "empty.default"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + "Name=Zen", + "IsRelative=1", + "Path=Profiles/zen.default", + "ZenAvatarPath=chrome://browser/content/zen-avatars/avatar-55.svg", + "Default=1", + "", + "[Profile1]", + "Name=Empty", + "IsRelative=1", + "Path=Profiles/empty.default", + "", + ].join("\n"), + ); + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { + directory: context.path.join("Profiles", "zen.default"), + name: "Zen", + cookieCount: 2, + }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.isFalse(yield* isSourceRunning(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, context.path.join("Profiles", "zen.default")), + context.path.join(profile, "cookies.sqlite"), + ); + }), + ), + ); + + it.effect("discovers Zen profiles in both current and legacy Linux roots", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-linux-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const currentRoot = context.path.join(home, ".config", "zen"); + const legacyRoot = context.path.join(home, ".zen"); + const currentProfile = context.path.join(currentRoot, "current.default"); + const legacyProfile = context.path.join(legacyRoot, "legacy.default"); + + for (const [root, profile, name] of [ + [currentRoot, currentProfile, "Current"], + [legacyRoot, legacyProfile, "Legacy"], + ] as const) { + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 1, 0); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + `Name=${name}`, + "IsRelative=1", + `Path=${context.path.basename(profile)}`, + ].join("\n"), + ); + } + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: "current.default", name: "Current", cookieCount: 1 }, + { directory: legacyProfile, name: "Legacy", cookieCount: 1 }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, legacyProfile), + context.path.join(legacyProfile, "cookies.sqlite"), + ); + + yield* fileSystem.writeFileString( + context.path.join(currentRoot, "profiles.ini"), + `[Profile0]\nName=Shared\nIsRelative=0\nPath=${legacyProfile}\n`, + ); + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: legacyProfile, name: "Shared", cookieCount: 1 }, + ]); + + yield* fileSystem.remove(currentRoot, { recursive: true }); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: legacyProfile, name: "Legacy", cookieCount: 1 }, + ]); + }), + ), + ); + + it.effect("discovers a Flatpak-only Zen installation and resolves its cookies", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-flatpak-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const root = context.path.join(home, ".var", "app", "app.zen_browser.zen", "zen"); + const profile = context.path.join(root, "flatpak.default"); + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 2, 1); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + "[Profile0]\nName=Flatpak\nIsRelative=1\nPath=flatpak.default\n", + ); + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: profile, name: "Flatpak", cookieCount: 2 }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, profile), + context.path.join(profile, "cookies.sqlite"), + ); + }), + ), + ); + + it.effect("does not mistake Firefox Snap installs for Zen profiles", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-snap-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const zenRoot = context.path.join(home, ".config", "zen"); + const snapRoot = context.path.join( + home, + "snap", + "firefox", + "common", + ".mozilla", + "firefox", + ); + const zenProfile = context.path.join(zenRoot, "zen.default"); + const snapProfile = context.path.join(snapRoot, "snap.default"); + for (const root of [zenRoot, snapRoot]) { + const profile = root === zenRoot ? zenProfile : snapProfile; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 1, 0); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + "Name=Personal", + "IsRelative=1", + `Path=${profile === zenProfile ? "zen.default" : "snap.default"}`, + ].join("\n"), + ); + } + + const profiles = yield* listSourceProfiles(zen, context); + assert.deepEqual( + profiles.map((profile) => profile.directory), + ["zen.default"], + ); + assert.isTrue(yield* isSourceInstalled(zen, context)); + }), + ), + ); +}); + describe("Firefox Snap profiles", () => { it.effect.skipIf(!symlinksSupported)( "finds Snap profiles with or without profiles.ini and checks their locks", diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 0bf3df235f94..14c4bca77b7e 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -26,6 +26,7 @@ export interface BrowserImportSourceDefinition { readonly engine: BrowserImportEngine; readonly platforms: ReadonlyArray; readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; + readonly alternateLinuxRoots?: (context: BrowserImportPathContext) => ReadonlyArray; readonly keychainService?: string; readonly keychainAccount?: string; readonly linuxSecretApplication?: string; @@ -169,6 +170,26 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray [ + context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), + ], + }, + { + id: "zen", + name: "Zen", + engine: "firefox", + platforms: ["darwin", "win32", "linux"], + userDataDirectory: (context) => { + if (context.platform === "darwin") return macApplicationSupport(context, "zen"); + if (context.platform === "win32") { + return context.appData ? context.path.join(context.appData, "zen") : undefined; + } + return context.path.join(context.home, ".config", "zen"); + }, + alternateLinuxRoots: (context) => [ + context.path.join(context.home, ".zen"), + context.path.join(context.home, ".var", "app", "app.zen_browser.zen", "zen"), + ], }, ]; @@ -470,7 +491,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( ); }); -/** Keep Snap profile paths absolute so reads and locks distinguish native and Snap installations. */ +/** Keep alternate-root profile paths absolute so cookie reads and locks use the correct installation. */ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -481,10 +502,7 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf const root = definition.userDataDirectory(context); if (root === undefined) return []; - const roots = [ - root, - context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), - ]; + const roots = [root, ...(definition.alternateLinuxRoots?.(context) ?? [])]; const profiles = new Map(); for (const directory of roots) { const found = yield* listSourceProfilesInDirectory( diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index ec314d1f9d47..1c1f9dc078ca 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -65,8 +65,8 @@ describe("CDP relay", () => { ]); pending[0]!(); await settle(); - expect(send).toHaveBeenNthCalledWith(1, "Page.captureScreenshot", {}, undefined); - expect(send).toHaveBeenNthCalledWith(2, "DOM.enable", {}, "iframe-session"); + expect(send).toHaveBeenNthCalledWith(1, "Page.captureScreenshot", {}, undefined, undefined); + expect(send).toHaveBeenNthCalledWith(2, "DOM.enable", {}, "iframe-session", undefined); expect(written.at(-1)).toEqual({ id: 5, result: { method: "Page.captureScreenshot" }, @@ -92,7 +92,30 @@ describe("CDP relay", () => { }), ); await settle(); - expect(send.mock.calls).toEqual([["Runtime.runIfWaitingForDebugger", {}, "iframe-session"]]); + expect(send.mock.calls).toEqual([ + ["Runtime.runIfWaitingForDebugger", {}, "iframe-session", undefined], + ]); + }); + + // Playwright enables focus emulation on every page it connects to, and Chromium then + // counts the guest as captured: a preview the panel hides keeps its timers unthrottled. + it("acknowledges Playwright's focus emulation without forcing it onto the guest", async () => { + const send = vi.fn(async () => ({})); + const written: Array> = []; + const relay = createCdpRelayConnection(makeTarget(send), (raw) => + written.push(JSON.parse(raw)), + ); + relay.receive( + JSON.stringify({ + id: 1, + method: "Emulation.setFocusEmulationEnabled", + params: { enabled: true }, + sessionId: "t3-preview-page", + }), + ); + await settle(); + expect(written).toEqual([{ id: 1, result: {}, sessionId: "t3-preview-page" }]); + expect(send).not.toHaveBeenCalled(); }); it("routes tab events to the page session once the page is announced", async () => { @@ -147,7 +170,7 @@ describe("CDP relay extra sessions", () => { expect(written.find((m) => m["id"] === 4)).toHaveProperty("error"); relay.receive(JSON.stringify({ id: 5, method: "Page.startScreencast", sessionId: extra })); await settle(); - expect(send).toHaveBeenLastCalledWith("Page.startScreencast", {}, undefined); + expect(send).toHaveBeenLastCalledWith("Page.startScreencast", {}, undefined, extra); written.length = 0; relay.event("Page.screencastFrame", { data: "x" }, undefined); expect(written.map((m) => m["sessionId"])).toEqual(["t3-preview-page", extra]); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 82264cc4b870..948bc361f5d9 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -5,7 +5,10 @@ export interface CdpRelayTarget { method: string, params: Record, sessionId: string | undefined, + /** The extra session that sent it, such as a viewer's; the main connection has none. */ + owner?: string, ) => Promise; + readonly sessionDetached?: (owner: string) => void; /** The page's own CDP target id; Playwright keys the main frame by it. */ readonly targetId: () => Promise; readonly url: () => string; @@ -111,8 +114,10 @@ export function createCdpRelayConnection( } case "Target.detachFromTarget": { const sessionId = command.params?.["sessionId"]; - if (typeof sessionId === "string" && sessionId !== PAGE_SESSION_ID) + if (typeof sessionId === "string" && sessionId !== PAGE_SESSION_ID) { sessions.delete(sessionId); + target.sessionDetached?.(sessionId); + } return {}; } case "Target.setAutoAttach": { @@ -133,11 +138,15 @@ export function createCdpRelayConnection( const pageCommand = (command: CdpCommand): Promise => { const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId; + const owner = + child === undefined && command.sessionId !== PAGE_SESSION_ID ? command.sessionId : undefined; // Child frames/workers pause on auto-attach until resumed; only the main page can skip this command. if (command.method === "Runtime.runIfWaitingForDebugger" && child === undefined) { return Promise.resolve({}); } - return target.send(command.method, command.params ?? {}, child); + // Playwright asks for this on every page; the host emulates focus only while it is in use. + if (command.method === "Emulation.setFocusEmulationEnabled") return Promise.resolve({}); + return target.send(command.method, command.params ?? {}, child, owner); }; return { diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 50039c4e4b04..08fefd8d733f 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -6,6 +6,7 @@ import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as NodeEvents from "node:events"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; @@ -30,18 +31,39 @@ const command = ( }), ); -const makeDebuggee = () => { +const makeDebuggee = ( + options: { + readonly deferFocus?: boolean; + readonly refuse?: (method: string, sessionId: string | undefined) => boolean; + } = {}, +) => { const emitter = new NodeEvents.EventEmitter(); const pending: Array<() => void> = []; + const focusChanges: Array<() => void> = []; const sent: Array = []; const sendWaiters: Array<() => void> = []; + const focusEmulation: Array = []; const debuggee = Object.assign(emitter, { - sendCommand: (method: string, params?: { id?: number }) => - new Promise((resolve) => { + sendCommand: ( + method: string, + params?: { id?: number; enabled?: boolean }, + sessionId?: string, + ) => + new Promise((resolve, reject) => { + if (options.refuse?.(method, sessionId)) { + reject(new Error("Refused")); + return; + } if (method === "Target.getTargetInfo") { resolve({ targetInfo: { targetId: "GUEST" } }); return; } + if (method === "Emulation.setFocusEmulationEnabled") { + focusEmulation.push(params?.enabled === true); + if (options.deferFocus) focusChanges.push(() => resolve({})); + else resolve({}); + return; + } sent.push(params?.id); pending.push(() => resolve({ method })); sendWaiters.shift()?.(); @@ -61,12 +83,18 @@ const makeDebuggee = () => { navigate: () => webContents.emit("did-navigate"), /** Ids of the commands the debugger received, in order. */ sent, + focusEmulation, nextSend: () => new Promise((resolve) => sendWaiters.push(resolve)), release: () => pending.splice(0).forEach((resolve) => resolve()), releaseFirst: () => pending.shift()?.(), + settleFocus: () => focusChanges.splice(0).forEach((resolve) => resolve()), }; }; +/** Commands wait behind a focus emulation change; it settles within a macrotask. */ +// oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- a zero-delay macrotask boundary, not a wait for time +const settle = Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], count: number) => host.events.pipe( Stream.take(count), @@ -93,6 +121,18 @@ const watchReplies = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => return replies; }); +/** Whether the client should paint the guest, each time that changes. */ +const watchPainting = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => + Effect.gen(function* () { + const painting: Array = []; + yield* host.painting.pipe( + Stream.runForEach(({ active }) => Effect.sync(() => painting.push(active))), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + return painting; + }); + describe("DesktopBrowserHost", () => { it.effect("announces tabs already attached to a backend that starts later", () => Effect.gen(function* () { @@ -123,6 +163,7 @@ describe("DesktopBrowserHost", () => { yield* command(1, "Page.captureScreenshot"); yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* command(2, "DOM.enable"); + yield* settle; debuggee.release(); const [, reply] = yield* Fiber.join(reader); @@ -141,6 +182,7 @@ describe("DesktopBrowserHost", () => { yield* command(host, 1, "Page.captureScreenshot"); yield* command(host, 2, "Page.captureScreenshot"); yield* command(host, 3, "DOM.enable"); + yield* settle; expect(debuggee.sent).toEqual([1, 3]); debuggee.releaseFirst(); @@ -159,6 +201,7 @@ describe("DesktopBrowserHost", () => { yield* command(host, 1, "Page.captureScreenshot"); yield* command(host, 2, "Page.captureScreenshot"); yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; debuggee.navigate(); expect(debuggee.sent).toEqual([1, 2]); @@ -181,6 +224,7 @@ describe("DesktopBrowserHost", () => { yield* command(host, 1, "Page.captureScreenshot"); yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* command(host, 3, "Page.captureScreenshot"); expect(debuggee.sent).toEqual([1]); @@ -192,6 +236,682 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("emulates focus while the server drives a guest, then lets a hidden one throttle", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "DOM.enable"); + yield* settle; + expect(debuggee.focusEmulation).toEqual([true]); + expect(debuggee.sent).toEqual([1]); + debuggee.release(); + yield* settle; + + yield* TestClock.adjust("4 seconds"); + yield* command(host, 2, "Input.dispatchMouseEvent"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("4 seconds"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* TestClock.adjust("10 seconds"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; + expect(debuggee.focusEmulation).toEqual([true, false, true]); + expect(debuggee.sent).toEqual([1, 2, 3]); + }), + ); + + it.effect("keeps a guest focused while a command it is running is still pending", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + + // An evaluation awaiting the page's next frame, which a throttled guest never paints. + yield* command(host, 1, "Runtime.evaluate"); + yield* settle; + expect(debuggee.sent).toEqual([1]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(1); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle once Chromium can no longer answer its pending command", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Runtime.evaluate"); + yield* settle; + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + // A command the old document never answers. + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + expect(debuggee.sent).toEqual([1, 2]); + debuggee.navigate(); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true, false]); + }), + ); + + it.effect("keeps a guest focused for the screenshot a navigation lets start", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + debuggee.navigate(); + expect(debuggee.sent).toEqual([1, 2]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + }), + ); + + it.effect("never sends a screenshot a navigation superseded while focus was changing", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ deferFocus: true }); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + debuggee.navigate(); + debuggee.settleFocus(); + yield* settle; + // Two captures in flight would undo the one-at-a-time guard. + expect(debuggee.sent).toEqual([2]); + }), + ); + + it.effect("keeps a guest focused when its turn starts after the agent's first command", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + yield* command(host, 1, "Input.dispatchKeyEvent"); + yield* settle; + yield* turn(true); + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }), + ); + + it.effect("lets a guest throttle through a turn that only sent it housekeeping", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: true }), + ); + + // Playwright re-enabling a domain after a reload is not the agent using the tab. + yield* command(host, 1, "Runtime.enable"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + yield* command(host, 2, "Runtime.evaluate"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true]); + }), + ); + + it.effect("keeps a guest the agent used focused through its turn's pauses", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + yield* turn(true); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([]); + + yield* command(host, 1, "Input.dispatchKeyEvent"); + yield* settle; + debuggee.release(); + yield* settle; + // The model thinks between steps; a focus-sensitive menu must not blur meanwhile. + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }), + ); + + it.effect( + "ends a command's hold with the turn that sent it, even if Chromium never answers", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: true }), + ); + + // A screenshot of a hidden guest the server already gave up on. + yield* command(host, 1, "Page.captureScreenshot"); + yield* settle; + expect(debuggee.sent).toEqual([1]); + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: false }), + ); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }), + ); + + it.effect("ends the agent's own session's holds with its turn, but not a viewer's stream", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + // The server's own CDP session, evaluating a promise that never settles. + yield* turn(true); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Runtime.evaluate", sessionId: "t3-preview-page-1" }); + yield* settle; + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + // A viewer streaming the guest keeps it focused through a turn's end. + yield* turn(true); + yield* raw({ id: 3, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(3); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true]); + }).pipe(Effect.scoped), + ); + + it.effect("paints for a screenshot queued behind one its viewer abandoned mid-capture", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* settle; + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(3); + expect(painting).toEqual([true, false]); + + // The agent's screenshot waits its turn; painting lets the abandoned one finish first. + yield* command(host, 4, "Page.captureScreenshot"); + yield* settle; + expect(painting).toEqual([true, false, true]); + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(2); + yield* settle; + expect(debuggee.sent).toEqual([undefined, 4]); + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(4); + yield* settle; + expect(painting).toEqual([true, false, true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("stops painting for a queued screenshot once its session detaches", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + // The agent's screenshot is abandoned at turn end; a viewer's waits behind it, then leaves. + yield* turn(true); + yield* command(host, 1, "Page.captureScreenshot"); + yield* settle; + yield* turn(false); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(2); + yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* settle; + expect(painting).toEqual([true, false, true]); + yield* raw({ + id: 4, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(4); + expect(painting).toEqual([true, false, true, false]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("never sends a screenshot whose session detached while focus was changing", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ deferFocus: true }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(3); + debuggee.settleFocus(); + yield* settle; + expect(debuggee.sent).toEqual([]); + }).pipe(Effect.scoped), + ); + + it.effect("stops painting for a screenshot still queued when the agent's turn ends", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const painting = yield* watchPainting(host); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + yield* turn(true); + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + yield* turn(false); + yield* settle; + expect(painting).toEqual([true, false]); + + // The first answers late; the second still goes in turn, but holds nothing. + debuggee.releaseFirst(); + yield* settle; + expect(debuggee.sent).toEqual([1, 2]); + expect(painting).toEqual([true, false]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle when Chromium refuses a viewer's screencast", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ refuse: (method) => method === "Page.startScreencast" }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + expect(yield* Queue.take(replies)).toBe(2); + yield* settle; + expect(painting).toEqual([true, false]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle after a person's input through a viewer that then closed", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: true }), + ); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 3, method: "Input.dispatchMouseEvent", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* raw({ + id: 4, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a page's screencast when a child session's screencast is refused", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ + refuse: (method, sessionId) => method === "Page.startScreencast" && sessionId !== undefined, + }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* command(host, 1, "Page.startScreencast"); + yield* settle; + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "child-frame" }); + expect(yield* Queue.take(replies)).toBe(2); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + }).pipe(Effect.scoped), + ); + + it.effect("ignores a screencast that arrives on a session after it detached", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ + id: 2, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(2); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle once the session waiting on its command detaches", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + // A viewer's own CDP session, closed while its still of a hidden guest is pending. + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("never sends a screenshot queued by a session that has since detached", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* command(host, 2, "Page.captureScreenshot"); + yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* raw({ + id: 4, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* settle; + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(4); + // The dropped screenshot's error reply leaves as the first frees its turn. + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([3, 2]); + yield* settle; + expect(debuggee.sent).toEqual([2]); + + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a streamed guest focused until its screencast ends", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.startScreencast"); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* command(host, 2, "Page.stopScreencast"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + // A viewer whose connection went away without stopping its screencast. + yield* command(host, 3, "Page.startScreencast"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true, false]); + }), + ); + + it.effect("keeps a guest focused while any of its screencasts runs", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + // Two viewers, or a viewer and a recording, each on its own session. + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* raw({ + id: 6, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-2" }, + }); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("paints a hidden guest only while the screenshot it is taking is pending", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const painting = yield* watchPainting(host); + + yield* command(host, 1, "DOM.enable"); + yield* settle; + expect(painting).toEqual([]); + + yield* command(host, 2, "Page.captureScreenshot"); + yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; + expect(painting).toEqual([true]); + + // The first answers once the guest paints; the queued one then takes its turn. + debuggee.release(); + yield* settle; + expect(debuggee.sent).toEqual([1, 2, 3]); + debuggee.release(); + yield* settle; + expect(painting).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("stops painting for a screenshot Chromium never answers", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const painting = yield* watchPainting(host); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + // The server gave up on it, and the agent's turn ends. + yield* turn(true); + yield* command(host, 1, "Page.captureScreenshot"); + yield* settle; + yield* turn(false); + yield* settle; + expect(painting).toEqual([true, false]); + + // The page loads a new document, which also frees the screenshot turn. + debuggee.navigate(); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + debuggee.navigate(); + yield* settle; + expect(painting).toEqual([true, false, true, false]); + + // The server releases the relay. + yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* settle; + expect(painting).toEqual([true, false, true, false, true, false]); + + // The guest detaches; a replacement starts with no screenshot pending. + const replacement = makeDebuggee(); + host.attach(key, replacement.tab); + yield* command(host, 4, "Page.captureScreenshot"); + yield* settle; + host.detach(key); + yield* settle; + expect(painting).toEqual([true, false, true, false, true, false, true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("paints a streamed guest until its last screencast ends", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* settle; + expect(painting).toEqual([true]); + + yield* raw({ + id: 6, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-2" }, + }); + yield* settle; + expect(painting).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + it.effect("saves a server tab's download under its CDP guid where the server asked", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 849ae658a821..30f252e3a677 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -22,7 +22,10 @@ const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBr const lineEncoder = new TextEncoder(); const AGENT_INPUT_COMMAND = /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/; +/** The same commands as above, by method, for the relay's sends. */ +const AGENT_INPUT_METHOD = /^(?:Input\.|Page\.navigate$|Runtime\.(?:evaluate|callFunctionOn)$)/; const AGENT_DOWNLOAD_WINDOW_MS = 5_000; +const FOCUS_IDLE = "5 seconds"; export interface DesktopBrowserTabKey { readonly threadId: string; @@ -41,10 +44,33 @@ interface AttachedTab { readonly debuggee: DesktopBrowserTabDebugger; relay: CdpRelayConnection | null; screenshotTurn: object | null; - readonly waitingScreenshots: Array<() => void>; + /** Each holds focus and painting while `held` and whoever queued it still waits on it. */ + readonly waitingScreenshots: Array<{ + readonly start: () => void; + readonly live: () => boolean; + held: boolean; + }>; downloadDirectory: string | null; pendingDownloadGuid: string | null; agentInputAt: number; + focusEmulated: boolean; + focusChange: Promise | null; + focusUses: number; + /** Each pending command, by the extra session waiting on it; the main connection's have none. */ + readonly focusPending: Map< + object, + { readonly owner: string | undefined; readonly paints: boolean } + >; + /** Whether the client keeps the guest painting, as published on `painting`. */ + painting: boolean; + /** + * Driven by the agent since its thread's last turn ended or the guest last throttled. A + * turn's start can reach us after its first command, so this holds focus once it does. + */ + agentUsed: boolean; + focusReleasing: boolean; + /** The sessions streaming the guest; one stopping leaves the others' screencasts running. */ + readonly screencasts: Set; readonly onMessage: ( event: Electron.Event, method: string, @@ -69,6 +95,11 @@ export class DesktopBrowserHost extends Context.Service< readonly x: number; readonly y: number; }>; + /** Whether a guest needs its frames painted while the panel hides it. */ + readonly painting: Stream.Stream<{ + readonly key: DesktopBrowserTabKey; + readonly active: boolean; + }>; } >()("@t3tools/desktop/preview/DesktopBrowserHost") {} @@ -80,44 +111,192 @@ export const make = Effect.gen(function* () { readonly x: number; readonly y: number; }>(16); + const painting = yield* PubSub.unbounded<{ + readonly key: DesktopBrowserTabKey; + readonly active: boolean; + }>(); const runFork = Effect.runForkWith(yield* Effect.context()); const tabs = new Map(); + /** Threads whose agent turn runs, by the server's account. */ + const agentTurns = new Set(); const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); const nextScreenshot = (tab: AttachedTab) => { tab.screenshotTurn = null; - tab.waitingScreenshots.shift()?.(); + tab.waitingScreenshots.shift()?.start(); + releaseFocusWhenIdle(tab); + syncPainting(tab); }; /** * Chromium below 157 crashes the whole app when a guest's renderer exits with a * screenshot copy in flight (crbug.com/567321049). Overlapping screenshots make that * far likelier, and one the server gave up on stays pending in Chromium, so each guest - * gets one at a time. One queued by a relay released since then is dropped unsent. + * gets one at a time. One whose relay or session has since gone is dropped unsent. */ const takeScreenshot = ( tab: AttachedTab, relay: CdpRelayConnection, - capture: () => Promise, + ownerLive: () => boolean, + /** Sends only while `current` holds; holds focus and painting only while `held`. */ + capture: (current: () => boolean, held: boolean) => Promise, ) => new Promise((resolve, reject) => { - const start = () => { - if (tab.relay !== relay) { - reject(new Error("The preview connection that asked for this screenshot closed.")); - nextScreenshot(tab); + const live = () => tab.relay === relay && ownerLive(); + const waiting = { + start: () => { + if (!live()) { + reject(new Error("The preview connection that asked for this screenshot closed.")); + nextScreenshot(tab); + return; + } + const turn = {}; + tab.screenshotTurn = turn; + capture(() => tab.screenshotTurn === turn && live(), waiting.held) + .finally(() => { + if (tab.screenshotTurn === turn) nextScreenshot(tab); + }) + .then(resolve, reject); + }, + live, + held: true, + }; + if (tab.screenshotTurn === null) waiting.start(); + else { + // The one ahead may be abandoned mid-capture; it needs frames to finish and free its turn. + tab.waitingScreenshots.push(waiting); + setFocusEmulation(tab, true); + syncPainting(tab); + } + }); + + /** + * Chromium counts a focus-emulated guest as captured, so a guest the panel hides would + * never throttle. Without it, a hidden guest paints no frames and focus() fires no focus + * events, so the guest is emulated focused while the server drives it or streams it. + */ + const setFocusEmulation = (tab: AttachedTab, enabled: boolean) => { + if (tab.focusEmulated === enabled) return; + tab.focusEmulated = enabled; + // A guest that refuses it, or a debugger already gone, behaves as unfocused. + const change: Promise = tab.debuggee.debugger + .sendCommand("Emulation.setFocusEmulationEnabled", { enabled }) + .then( + () => undefined, + () => undefined, + ) + .finally(() => { + if (tab.focusChange === change) tab.focusChange = null; + }); + tab.focusChange = change; + }; + + const waitingHolds = (tab: AttachedTab) => + tab.waitingScreenshots.some((waiting) => waiting.held && waiting.live()); + + const releaseFocusWhenIdle = (tab: AttachedTab) => { + if (tab.focusReleasing) return; + tab.focusReleasing = true; + runFork( + Effect.gen(function* () { + let uses: number; + do { + uses = tab.focusUses; + yield* Effect.sleep(FOCUS_IDLE); + } while (uses !== tab.focusUses); + tab.focusReleasing = false; + // The last pending command to settle starts another idle wait. + if (tab.focusPending.size > 0 || tab.screencasts.size > 0 || waitingHolds(tab)) { return; } - const turn = {}; - tab.screenshotTurn = turn; - capture() - .finally(() => { - if (tab.screenshotTurn === turn) nextScreenshot(tab); - }) - .then(resolve, reject); - }; - if (tab.screenshotTurn === null) start(); - else tab.waitingScreenshots.push(start); + // The agent pauses to think between steps; its tab stays focused until the turn ends. + if (tab.agentUsed && agentTurns.has(tab.key.threadId)) return; + tab.agentUsed = false; + if (tabs.get(keyOf(tab.key)) === tab) setFocusEmulation(tab, false); + }), + ); + }; + + /** + * Electron composites no offscreen guest, so its screenshot or screencast waits for a frame + * that never comes. The client paints the guest behind the app while one is pending; the + * capture then finishes on its own, and the guest goes back offscreen once none is. + */ + const syncPainting = (tab: AttachedTab) => { + const active = + tabs.get(keyOf(tab.key)) === tab && + (tab.screencasts.size > 0 || + waitingHolds(tab) || + [...tab.focusPending.values()].some((hold) => hold.paints)); + if (tab.painting === active) return; + tab.painting = active; + runFork(PubSub.publish(painting, { key: tab.key, active })); + }; + + /** A command may wait on the page's next frame, so the guest stays focused until it settles. */ + const holdFocus = ( + tab: AttachedTab, + method: string, + owner: string | undefined, + work: () => Promise, + ) => { + const pending = {}; + tab.focusPending.set(pending, { owner, paints: method === "Page.captureScreenshot" }); + tab.focusUses += 1; + setFocusEmulation(tab, true); + releaseFocusWhenIdle(tab); + syncPainting(tab); + return work().finally(() => { + if (!tab.focusPending.delete(pending)) return; + tab.focusUses += 1; + releaseFocusWhenIdle(tab); + syncPainting(tab); }); + }; + + /** + * Chromium can leave a command unanswered, so the screenshot guard's release points + * also end every hold: a released relay has no one waiting, and a new document answers + * nothing pending from the old one. A released relay's screencast has no viewer to stop it. + */ + const dropFocusHolds = (tab: AttachedTab) => { + tab.focusPending.clear(); + releaseFocusWhenIdle(tab); + syncPainting(tab); + }; + + const dropRelay = (tab: AttachedTab) => { + tab.relay = null; + tab.screencasts.clear(); + tab.agentUsed = false; + dropFocusHolds(tab); + }; + + /** A detached session, such as a closed viewer, waits on nothing it sent. */ + const dropSessionHolds = (tab: AttachedTab, owner: string) => { + tab.screencasts.delete(owner); + for (const [pending, hold] of tab.focusPending) { + if (hold.owner === owner) tab.focusPending.delete(pending); + } + releaseFocusWhenIdle(tab); + syncPainting(tab); + }; + + /** + * A command the server stopped waiting on, such as a timed-out screenshot or evaluation, + * may never settle; the turn's end ends every hold. The agent also sends on its own extra + * session, so this cannot spare other sessions; a viewer's screencast still holds. + */ + const endAgentTurn = (threadId: string) => { + agentTurns.delete(threadId); + for (const tab of tabs.values()) { + if (tab.key.threadId !== threadId) continue; + tab.agentUsed = false; + // Still sent in turn, so the queue keeps moving, but no longer kept painting. + for (const waiting of tab.waitingScreenshots) waiting.held = false; + dropFocusHolds(tab); + } + }; const relayFor = (tab: AttachedTab) => { if (tab.relay) return tab.relay; @@ -126,12 +305,57 @@ export const make = Effect.gen(function* () { sessionId === undefined ? sendPageCommand(webContents, debuggee, method, params) : debuggee.sendCommand(method, params, sessionId); + const detachedOwners = new Set(); + /** Viewers and recordings stream; the agent's own sessions never do. */ + const streamingOwners = new Set(); const relay: CdpRelayConnection = createCdpRelayConnection( { - send: (method, params, sessionId) => - method === "Page.captureScreenshot" - ? takeScreenshot(tab, relay, () => send(method, params, sessionId)) - : send(method, params, sessionId), + send: (method, params, sessionId, owner) => { + // A child session's screencast is not the page's, such as a late one on a detached session. + if (sessionId === undefined && method === "Page.startScreencast") { + tab.screencasts.add(owner); + if (owner !== undefined) streamingOwners.add(owner); + } else if (sessionId === undefined && method === "Page.stopScreencast") { + tab.screencasts.delete(owner); + } + // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's. + if ( + AGENT_INPUT_METHOD.test(method) && + (owner === undefined || !streamingOwners.has(owner)) + ) { + tab.agentUsed = true; + } + // Waits for focus emulation; insertText and other non-CDP input would not queue behind it. + const forward = (current?: () => boolean) => { + const change = tab.focusChange; + const run = () => + current === undefined || current() + ? send(method, params, sessionId) + : Promise.reject(new Error("The page this screenshot was for has moved on.")); + return change === null ? run() : change.then(run); + }; + return method === "Page.captureScreenshot" + ? takeScreenshot( + tab, + relay, + () => owner === undefined || !detachedOwners.has(owner), + (current, held) => + held ? holdFocus(tab, method, owner, () => forward(current)) : forward(current), + ) + : method === "Page.startScreencast" + ? holdFocus(tab, method, owner, forward).catch((cause: unknown) => { + // A viewer can outlive a refused stream; nothing is streaming for it. + if (sessionId === undefined && tab.relay === relay) tab.screencasts.delete(owner); + releaseFocusWhenIdle(tab); + syncPainting(tab); + throw cause; + }) + : holdFocus(tab, method, owner, forward); + }, + sessionDetached: (owner) => { + detachedOwners.add(owner); + dropSessionHolds(tab, owner); + }, targetId: () => debuggee .sendCommand("Target.getTargetInfo") @@ -172,6 +396,7 @@ export const make = Effect.gen(function* () { tab.relay = null; tab.debuggee.debugger.off("message", tab.onMessage); tab.debuggee.webContents.off("did-navigate", tab.onNewDocument); + syncPainting(tab); emit({ type: "detached", ...key }); }; @@ -188,6 +413,14 @@ export const make = Effect.gen(function* () { downloadDirectory: null, pendingDownloadGuid: null, agentInputAt: Number.NEGATIVE_INFINITY, + focusEmulated: false, + focusChange: null, + focusUses: 0, + focusPending: new Map(), + painting: false, + agentUsed: false, + focusReleasing: false, + screencasts: new Set(), onMessage: (_event, method, params, sessionId) => { if (method === "Browser.downloadWillBegin") { const guid = (params as { guid?: unknown } | undefined)?.guid; @@ -198,6 +431,8 @@ export const make = Effect.gen(function* () { // A screenshot pending when the page loads a new document, a reload included, can // stay unanswered. A crashed page lands here once it reloads, well after its renderer died. onNewDocument: () => { + // Before the next screenshot starts, so its own hold survives. + dropFocusHolds(tab); if (tab.screenshotTurn !== null) nextScreenshot(tab); }, }; @@ -211,6 +446,11 @@ export const make = Effect.gen(function* () { Effect.sync(() => { const command = decodeCommand(line); if (Option.isNone(command)) return; + if (command.value.type === "turn") { + if (command.value.active) agentTurns.add(command.value.threadId); + else endAgentTurn(command.value.threadId); + return; + } const tab = tabs.get(keyOf(command.value)); if (!tab) return; if (command.value.type === "pointer") { @@ -219,7 +459,7 @@ export const make = Effect.gen(function* () { return; } if (command.value.type === "release") { - tab.relay = null; + dropRelay(tab); return; } if (AGENT_INPUT_COMMAND.test(command.value.message)) tab.agentInputAt = performance.now(); @@ -231,19 +471,22 @@ export const make = Effect.gen(function* () { return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_DOWNLOAD_WINDOW_MS; }; - const announceAll = Effect.suspend(() => - Effect.forEach( + const announceAll = Effect.suspend(() => { + // A new backend: the old one's turns ended without telling us. + agentTurns.clear(); + return Effect.forEach( [...tabs.values()], (tab) => { - tab.relay = null; + dropRelay(tab); return PubSub.publish(outbox, { type: "attached", ...tab.key }); }, { discard: true }, - ), - ); + ); + }); return DesktopBrowserHost.of({ pointers: Stream.fromPubSub(pointers), + painting: Stream.fromPubSub(painting), // Subscribes before announcing, so no attach falls between the two. events: Stream.unwrap( Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 85fa6115477a..f19079e46bce 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -147,6 +147,7 @@ const { vi.mock("electron", () => ({ BrowserWindow: browserWindowConstructor, + // oxlint-disable-next-line typescript/no-extraneous-class -- This constructor-only mock implements the browser ClipboardItem API. ClipboardItem: class { constructor(data: Record) { clipboardItemConstructor(data); @@ -1712,6 +1713,65 @@ describe("PreviewManager", () => { }).pipe(Effect.provide(managerLayer()), Effect.scoped), ); + effectIt.effect( + "asks the client to paint a navigated server tab while the agent captures it", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.DesktopBrowserHost; + const serverTab = { threadId: "thread-1", tabId: "server-tab-1" }; + const contents = Object.assign( + makeTestPreviewWebContents(async () => { + throw new Error("unused"); + }, 42), + { + isDevToolsOpened: () => false, + getUserAgent: () => "Electron", + loadURL: vi.fn(async () => undefined), + }, + ); + const screenshot = Promise.withResolvers(); + contents.debugger.sendCommand = vi.fn((method: string) => + method === "Page.captureScreenshot" ? screenshot.promise : Promise.resolve(undefined), + ) as unknown as typeof contents.debugger.sendCommand; + fromId.mockReturnValue(contents); + const manager = yield* PreviewManager.PreviewManager; + const capturing = yield* Queue.unbounded(); + yield* manager.subscribeStateChanges((_tabId, state) => + Queue.offer(capturing, state.capturing).pipe(Effect.asVoid), + ); + const attached = yield* Deferred.make(); + yield* host.events.pipe( + Stream.runForEach((line) => + new TextDecoder().decode(line).includes('"type":"attached"') + ? Deferred.succeed(attached, undefined) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* manager.createTab("tab_1", { serverTab }); + yield* manager.registerWebview("tab_1", 42); + yield* Deferred.await(attached); + // Navigating rebuilds the tab's state, which must keep the server tab it answers for. + yield* manager.navigate("tab_1", "https://example.com/next"); + yield* Queue.clear(capturing); + + yield* host.handleCommandLine( + JSON.stringify({ + type: "cdp", + ...serverTab, + message: JSON.stringify({ + id: 1, + method: "Page.captureScreenshot", + sessionId: "t3-preview-page", + }), + }), + ); + expect(yield* Queue.take(capturing)).toBe(true); + screenshot.resolve({ data: "" }); + expect(yield* Queue.take(capturing)).toBe(false); + }).pipe(Effect.provide(managerLayer()), Effect.scoped), + ); + effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index b9e0192ca8a7..d0221e9be0e6 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -102,6 +102,7 @@ export interface PreviewTabState { audioMuted: boolean; audible: boolean; controller: "human" | "agent" | "none"; + capturing: boolean; favicon?: DesktopPreviewFavicon; serverTab?: { readonly threadId: string; readonly tabId: string }; updatedAt: string; @@ -1652,6 +1653,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function audioMuted: false, audible: false, controller: "none", + capturing: false, updatedAt, }; return [ @@ -1725,6 +1727,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function audioMuted: false, audible: false, controller: "none", + capturing: false, updatedAt, }; yield* emit(tabId, closed); @@ -1946,7 +1949,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function audioMuted: current?.audioMuted ?? false, audible: current?.audible ?? false, controller: current?.controller ?? "none", + capturing: current?.capturing ?? false, ...(current?.favicon ? { favicon: current.favicon } : {}), + ...(current?.serverTab ? { serverTab: current.serverTab } : {}), updatedAt, }; return [ @@ -2299,7 +2304,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } if (afterAttach.serverTab) { - yield* listenForAgentPointers; + yield* listenToBrowserHost; browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger }); } if (afterAttach.colorScheme !== "system") { @@ -3239,14 +3244,33 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { discard: true }, ); }); - let pointersStarted = false; - const listenForAgentPointers = Effect.suspend(() => { - if (pointersStarted) return Effect.void; - pointersStarted = true; - return browserHost.pointers.pipe( - Stream.runForEach(emitAgentPointer), - Effect.forkIn(parentScope), - Effect.asVoid, + const updateServerTabCapturing = Effect.fn("PreviewManager.updateServerTabCapturing")( + function* (painting: { + readonly key: { readonly threadId: string; readonly tabId: string }; + readonly active: boolean; + }) { + const tab = [...(yield* SynchronizedRef.get(tabsRef)).values()].find( + (candidate) => + candidate.serverTab?.threadId === painting.key.threadId && + candidate.serverTab.tabId === painting.key.tabId, + ); + if (tab && tab.capturing !== painting.active) { + yield* update(tab.tabId, { capturing: painting.active }); + } + }, + ); + let browserHostStarted = false; + // Subscribes before the attach that follows, so no event falls between the two. + const listenToBrowserHost = Effect.suspend(() => { + if (browserHostStarted) return Effect.void; + browserHostStarted = true; + const listen = Effect.forkIn(parentScope, { startImmediately: true }); + return Effect.all( + [ + browserHost.pointers.pipe(Stream.runForEach(emitAgentPointer), listen), + browserHost.painting.pipe(Stream.runForEach(updateServerTabCapturing), listen), + ], + { discard: true }, ); }); diff --git a/apps/desktop/src/quickMessage/DesktopQuickMessage.test.ts b/apps/desktop/src/quickMessage/DesktopQuickMessage.test.ts index 760520959b8b..2366bdbdb73b 100644 --- a/apps/desktop/src/quickMessage/DesktopQuickMessage.test.ts +++ b/apps/desktop/src/quickMessage/DesktopQuickMessage.test.ts @@ -147,7 +147,7 @@ const layerTest = (main: { webContents: FakeWebContents } | null, revealed: Arra Layer.succeed( DesktopClientSettings.DesktopClientSettings, DesktopClientSettings.DesktopClientSettings.of({ - get: Effect.succeed(Option.some(enabled)), + get: Effect.succeedSome(enabled), set: () => Effect.void, }), ), diff --git a/apps/desktop/src/quickMessage/DesktopQuickMessage.ts b/apps/desktop/src/quickMessage/DesktopQuickMessage.ts index a54f36eca627..43e8ab7bd03f 100644 --- a/apps/desktop/src/quickMessage/DesktopQuickMessage.ts +++ b/apps/desktop/src/quickMessage/DesktopQuickMessage.ts @@ -63,8 +63,9 @@ export class DesktopQuickMessage extends Context.Service< } >()("@t3tools/desktop/quickMessage/DesktopQuickMessage") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { + const context = yield* Effect.context(); const environment = yield* DesktopEnvironment.DesktopEnvironment; const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronWindow = yield* ElectronWindow.ElectronWindow; @@ -160,7 +161,7 @@ export const make = Effect.gen(function* () { if (!isMac) { // Elsewhere the app quits once its last window closes, and a hidden panel would keep it // running after the main window is gone. - const main = Effect.runSync(electronWindow.main); + const main = Effect.runSyncWith(context)(electronWindow.main); if (Option.isSome(main)) main.value.once("closed", destroyPanel); } void window @@ -300,7 +301,9 @@ export const make = Effect.gen(function* () { else applyShortcut(); }); }), - isPanel: (webContentsId) => Effect.sync(() => livePanel()?.webContents.id === webContentsId), + isPanel: Effect.fn("DesktopQuickMessage.isPanel")((webContentsId) => + Effect.sync(() => livePanel()?.webContents.id === webContentsId), + ), setPanelReady: Effect.sync(() => { const window = livePanel(); if (window === null) return; diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 832a77457714..0135225f90c4 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -90,6 +91,37 @@ function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { } describe("DesktopSettings", () => { + it.effect("records one bounds operation span with its input attributes", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + yield* withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setMainWindowBounds({ x: 10, y: 20, width: 900, height: 600 }, true); + }), + ).pipe(Effect.provideService(Tracer.Tracer, tracer)); + const operationSpans = spans.filter( + (span) => span.name === "desktop.settings.setMainWindowBounds", + ); + assert.lengthOf(operationSpans, 1); + assert.deepEqual(Object.fromEntries(operationSpans[0]!.attributes), { + x: 10, + y: 20, + width: 900, + height: 600, + isMaximized: true, + }); + assert.equal(operationSpans[0]!.status._tag, "Ended"); + }), + ); + it.effect( "persists disabling and re-enabling local execution without clearing backend settings", () => diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 433e8127a509..5767057b4a35 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -521,48 +521,56 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), - setMainWindowBounds: (bounds, isMaximized) => - persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( - Effect.withSpan("desktop.settings.setMainWindowBounds", { - attributes: { - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - isMaximized, - }, - }), - ), - setServerExposureMode: (mode) => - persist((settings) => setServerExposureMode(settings, mode)).pipe( - Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), - ), - setTailscaleServe: (input) => - persist((settings) => setTailscaleServe(settings, input)).pipe( - Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), - ), - setUpdateChannel: (channel) => - persist((settings) => setUpdateChannel(settings, channel)).pipe( - Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), - ), - setWslBackendEnabled: (enabled) => - persist((settings) => setWslBackendEnabled(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setWslBackendEnabled", { attributes: { enabled } }), - ), - setWslDistro: (distro) => - persist((settings) => setWslDistro(settings, distro)).pipe( - Effect.withSpan("desktop.settings.setWslDistro", { - attributes: { distro: distro ?? null }, - }), - ), - setWslOnly: (enabled) => - persist((settings) => setWslOnly(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), - ), - setLocalEnvironmentEnabled: (enabled) => - persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }), - ), + setMainWindowBounds: Effect.fn("desktop.settings.setMainWindowBounds")( + function* (bounds, isMaximized) { + yield* Effect.annotateCurrentSpan({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + isMaximized, + }); + + return yield* persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)); + }, + ), + setServerExposureMode: Effect.fn("desktop.settings.setServerExposureMode")(function* (mode) { + yield* Effect.annotateCurrentSpan({ mode }); + + return yield* persist((settings) => setServerExposureMode(settings, mode)); + }), + setTailscaleServe: Effect.fn("desktop.settings.setTailscaleServe")(function* (input) { + yield* Effect.annotateCurrentSpan(input); + + return yield* persist((settings) => setTailscaleServe(settings, input)); + }), + setUpdateChannel: Effect.fn("desktop.settings.setUpdateChannel")(function* (channel) { + yield* Effect.annotateCurrentSpan({ channel }); + + return yield* persist((settings) => setUpdateChannel(settings, channel)); + }), + setWslBackendEnabled: Effect.fn("desktop.settings.setWslBackendEnabled")(function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setWslBackendEnabled(settings, enabled)); + }), + setWslDistro: Effect.fn("desktop.settings.setWslDistro")(function* (distro) { + yield* Effect.annotateCurrentSpan({ distro: distro ?? null }); + + return yield* persist((settings) => setWslDistro(settings, distro)); + }), + setWslOnly: Effect.fn("desktop.settings.setWslOnly")(function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setWslOnly(settings, enabled)); + }), + setLocalEnvironmentEnabled: Effect.fn("desktop.settings.setLocalEnvironmentEnabled")( + function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setLocalEnvironmentEnabled(settings, enabled)); + }, + ), applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( Effect.withSpan("desktop.settings.applyWslWindowsFallback"), ), @@ -594,18 +602,38 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), - setMainWindowBounds: (bounds, isMaximized) => - update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), - setServerExposureMode: (mode) => - update((settings) => setServerExposureMode(settings, mode)), - setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), - setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), - setWslBackendEnabled: (enabled) => - update((settings) => setWslBackendEnabled(settings, enabled)), - setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), - setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), - setLocalEnvironmentEnabled: (enabled) => - update((settings) => setLocalEnvironmentEnabled(settings, enabled)), + setMainWindowBounds: Effect.fn("DesktopAppSettings.setMainWindowBounds")( + function (bounds, isMaximized) { + return update((settings) => setMainWindowBounds(settings, bounds, isMaximized)); + }, + ), + setServerExposureMode: Effect.fn("DesktopAppSettings.setServerExposureMode")( + function (mode) { + return update((settings) => setServerExposureMode(settings, mode)); + }, + ), + setTailscaleServe: Effect.fn("DesktopAppSettings.setTailscaleServe")(function (input) { + return update((settings) => setTailscaleServe(settings, input)); + }), + setUpdateChannel: Effect.fn("DesktopAppSettings.setUpdateChannel")(function (channel) { + return update((settings) => setUpdateChannel(settings, channel)); + }), + setWslBackendEnabled: Effect.fn("DesktopAppSettings.setWslBackendEnabled")( + function (enabled) { + return update((settings) => setWslBackendEnabled(settings, enabled)); + }, + ), + setWslDistro: Effect.fn("DesktopAppSettings.setWslDistro")(function (distro) { + return update((settings) => setWslDistro(settings, distro)); + }), + setWslOnly: Effect.fn("DesktopAppSettings.setWslOnly")(function (enabled) { + return update((settings) => setWslOnly(settings, enabled)); + }), + setLocalEnvironmentEnabled: Effect.fn("DesktopAppSettings.setLocalEnvironmentEnabled")( + function (enabled) { + return update((settings) => setLocalEnvironmentEnabled(settings, enabled)); + }, + ), applyWslWindowsFallback: update(applyWslWindowsFallback), applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 7c65c35cf043..0836db667822 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -194,8 +194,8 @@ export const make = Effect.gen(function* () { get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( Effect.withSpan("desktop.clientSettings.get"), ), - set: (settings) => - crypto.randomUUIDv4().pipe( + set: Effect.fn("desktop.clientSettings.set")(function (settings) { + return crypto.randomUUIDv4().pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), Effect.mapError( (cause) => @@ -214,8 +214,8 @@ export const make = Effect.gen(function* () { suffix, }), ), - Effect.withSpan("desktop.clientSettings.set"), - ), + ); + }), }); }); @@ -228,7 +228,9 @@ export const layerTest = (initialSettings: Option.Option = Optio const settingsRef = yield* Ref.make(initialSettings); return DesktopClientSettings.of({ get: Ref.get(settingsRef), - set: (settings) => Ref.set(settingsRef, Option.some(settings)), + set: Effect.fn("DesktopClientSettings.set")(function (settings) { + return Ref.set(settingsRef, Option.some(settings)); + }), }); }), ); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index dc3aa093f9cf..8532dee0038a 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -1048,9 +1048,15 @@ it.effect("matches Windows accessibility windows on a scaled display", () => { ); }); -it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( - "persists %s text without inventing AT-SPI screen coordinates", - (backend) => { +it.effect.each([ + { backend: "gnome", screenshot: "PNG" }, + { backend: "niri", screenshot: "PNG" }, + { backend: "kde", screenshot: "PNG" }, + { backend: "hyprland", screenshot: "PNG" }, + { backend: "gnome", screenshot: "invalid" }, +] as const)( + "persists $backend accessibility text with a $screenshot screenshot", + ({ backend, screenshot }) => { vi.stubEnv("XDG_SESSION_TYPE", "wayland"); flashWindows.length = 0; const window = { @@ -1067,7 +1073,16 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( ...(backend === "niri" ? { accessibilityBoundsReliable: false } : {}), ...(backend === "kde" ? { clientBounds: { x: 479, y: 371, width: 700, height: 520 } } : {}), }; - linuxCaptureMock.mockResolvedValueOnce({ png: Buffer.from([1, 2, 3]), window }); + // The captured pixels differ from the compositor's logical window bounds. + const imageSize = screenshot === "PNG" ? { width: 1400, height: 1040 } : window.bounds; + const png = + screenshot === "PNG" + ? Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAABXgAAAQQAQAAAABwoA4ZAAAAyElEQVR4nO3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgYyx4AAepc/c0AAAAASUVORK5CYII=", + "base64", + ) + : Buffer.from([1, 2, 3]); + linuxCaptureMock.mockResolvedValueOnce({ png, window }); activeWindowMock.mockClear(); getSourcesMock.mockClear(); accessibilityByPidMock.mockReset().mockResolvedValue({ @@ -1107,7 +1122,7 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( assert.deepInclude(saved.source.accessibility, { format: "element-tree", coordinateSpace: "captured-image", - imageSize: { width: 700, height: window.bounds.height }, + imageSize: { width: imageSize.width, height: imageSize.height }, truncated: false, }); assert.deepInclude( @@ -1117,7 +1132,7 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( { role: "window", name: "Editor", - bounds: { x: 0, y: 0, width: 700, height: window.bounds.height }, + bounds: { x: 0, y: 0, width: imageSize.width, height: imageSize.height }, state: { active: true }, }, ); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index e2f1c6e82a36..30edacb073c3 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -90,6 +90,7 @@ import { snapShotShortcutSystemConflict, } from "./snapShot.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { readImageDimensions } from "@t3tools/shared/imageDimensions"; const MAX_CAPTURE_WIDTH = 2_560; const MAX_CAPTURE_HEIGHT = 1_600; @@ -413,10 +414,10 @@ async function requestMacSnapShotPermissions( } function snapShotImageSize(png: Buffer, fallback: Electron.Rectangle): Electron.Size { - try { - const size = Electron.nativeImage.createFromBuffer(png).getSize(); - if (size.width > 0 && size.height > 0) return size; - } catch {} + // Capture backends already validate or encode the PNG. Reading its header + // avoids decoding every pixel again just to size accessibility coordinates. + const size = readImageDimensions(png); + if (size) return size; return { width: Math.max(1, Math.round(fallback.width)), height: Math.max(1, Math.round(fallback.height)), @@ -1717,24 +1718,28 @@ export const make = Effect.gen(function* () { ), ), ), - setAnimationDestination: (id, destination) => - Effect.promise(async () => { - if (linuxFeedback?.id === id && destination.relativeFrame) { - await linuxFeedback.feedback - .animateTo(destination.relativeFrame) - .catch(() => closeLinuxFeedback(id)); - return; - } - transition.animateTo(id, destination); - await transition.waitForLanding(id); - }), - dismissAnimation: (id) => - Effect.sync(() => { + setAnimationDestination: Effect.fn("DesktopSnapShot.setAnimationDestination")( + function (id, destination) { + return Effect.promise(async () => { + if (linuxFeedback?.id === id && destination.relativeFrame) { + await linuxFeedback.feedback + .animateTo(destination.relativeFrame) + .catch(() => closeLinuxFeedback(id)); + return; + } + transition.animateTo(id, destination); + await transition.waitForLanding(id); + }); + }, + ), + dismissAnimation: Effect.fn("DesktopSnapShot.dismissAnimation")(function (id) { + return Effect.sync(() => { closeLinuxFeedback(id); transition.dismiss(id); - }), - acknowledge: (id) => - Effect.promise(async () => { + }); + }), + acknowledge: Effect.fn("DesktopSnapShot.acknowledge")(function (id) { + return Effect.promise(async () => { await completeLinuxFeedback(id); await transition.complete(id); }).pipe( @@ -1750,7 +1755,8 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new DesktopSnapShotError({ operation: "acknowledge", captureId: id, cause }), ), - ), + ); + }), }); }); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index eac148ae4d0f..f6c9ee43d758 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -133,32 +133,26 @@ export const make = Effect.gen(function* () { const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts)); return DesktopSshEnvironment.of({ - discoverHosts: (input) => - discoverDesktopSshHostsEffect(input).pipe( - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.discoverHosts"), - ), - resolveHost: (alias) => - resolveSshTarget(alias.trim()).pipe( - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.resolveHost"), - ), - ensureEnvironment: (target, ensureOptions) => - manager - .ensureEnvironment(target, ensureOptions) - .pipe( - Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.ensureEnvironment"), - ), - disconnectEnvironment: (target) => - manager - .disconnectEnvironment(target) - .pipe( - Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.disconnectEnvironment"), - ), + discoverHosts: Effect.fn("desktop.ssh.discoverHosts")(function* (input) { + return yield* discoverDesktopSshHostsEffect(input); + }, Effect.provide(runtimeContext)), + resolveHost: Effect.fn("desktop.ssh.resolveHost")(function* (alias) { + return yield* resolveSshTarget(alias.trim()); + }, Effect.provide(runtimeContext)), + ensureEnvironment: Effect.fn("desktop.ssh.ensureEnvironment")( + function* (target, ensureOptions) { + return yield* manager.ensureEnvironment(target, ensureOptions); + }, + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + ), + disconnectEnvironment: Effect.fn("desktop.ssh.disconnectEnvironment")( + function* (target) { + return yield* manager.disconnectEnvironment(target); + }, + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + ), }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 5265d65523ba..3d6556ffae9a 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1159,7 +1159,9 @@ export const make = Effect.gen(function* () { install: installWithExpectedVersion().pipe( Effect.map(({ accepted, completed, state }) => ({ accepted, completed, state })), ), - installPrepared: (expectedVersion) => installWithExpectedVersion(expectedVersion), + installPrepared: Effect.fn("DesktopUpdates.installPrepared")(function (expectedVersion) { + return installWithExpectedVersion(expectedVersion); + }), }); }); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 5017793d0735..36939729682b 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -1070,33 +1070,57 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { isAvailable: Effect.succeed(stub.isAvailable ?? false), listDistros: probeDistros.pipe(Effect.orElseSucceed(() => [])), probeDistros, - preWarm: () => Effect.void, - windowsToWslPath: (distro, windowsPath) => - Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()), - getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none()), - getDistroIp: (distro) => Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none()), - prepareRuntime: (distro, archive) => - Effect.succeed( + preWarm: Effect.fn("DesktopWslEnvironment.preWarm")(function () { + return Effect.void; + }), + windowsToWslPath: Effect.fn("DesktopWslEnvironment.windowsToWslPath")( + function (distro, windowsPath) { + return Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()); + }, + ), + getUserHome: Effect.fn("DesktopWslEnvironment.getUserHome")(function (distro) { + return Effect.succeed(stub.getUserHome?.(distro) ?? Option.none()); + }), + getDistroIp: Effect.fn("DesktopWslEnvironment.getDistroIp")(function (distro) { + return Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none()); + }), + prepareRuntime: Effect.fn("DesktopWslEnvironment.prepareRuntime")(function (distro, archive) { + return Effect.succeed( stub.prepareRuntime?.(distro, archive) ?? { ok: false, reason: "prepareRuntime stub not configured", }, - ), - pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void, - invalidateRuntime: (distro, runtimeId) => - stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void, - probeRuntime: (distro, linuxAppRoot) => - Effect.succeed( - stub.probeRuntime?.(distro, linuxAppRoot) ?? { ok: true, resolvedPath: "/usr/bin:/bin" }, - ), - ensureNodePty: (distro, linuxAppRoot, options) => - Effect.succeed( - stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { - ok: false, - reason: "ensureNodePty stub not configured", - fatal: true, - }, - ), + ); + }), + pruneRuntimes: Effect.fn("DesktopWslEnvironment.pruneRuntimes")(function (distro, runtimeId) { + return stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void; + }), + invalidateRuntime: Effect.fn("DesktopWslEnvironment.invalidateRuntime")( + function (distro, runtimeId) { + return stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void; + }, + ), + probeRuntime: Effect.fn("DesktopWslEnvironment.probeRuntime")( + function (distro, linuxAppRoot) { + return Effect.succeed( + stub.probeRuntime?.(distro, linuxAppRoot) ?? { + ok: true, + resolvedPath: "/usr/bin:/bin", + }, + ); + }, + ), + ensureNodePty: Effect.fn("DesktopWslEnvironment.ensureNodePty")( + function (distro, linuxAppRoot, options) { + return Effect.succeed( + stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { + ok: false, + reason: "ensureNodePty stub not configured", + fatal: true, + }, + ); + }, + ), }), ); }; @@ -1151,31 +1175,29 @@ export const layer = Layer.effect( Effect.withSpan("desktop.wsl.listDistros"), ), probeDistros, - preWarm: (distro) => - provideSpawner(preWarmImpl(distro)).pipe(Effect.withSpan("desktop.wsl.preWarm")), + preWarm: Effect.fn("desktop.wsl.preWarm")(function (distro) { + return provideSpawner(preWarmImpl(distro)); + }), windowsToWslPath, getUserHome, getDistroIp, - prepareRuntime: (distro, archive) => - provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)).pipe( - Effect.withSpan("desktop.wsl.prepareRuntime"), - ), - pruneRuntimes: (distro, runtimeId) => - provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)).pipe( - Effect.withSpan("desktop.wsl.pruneRuntimes"), - ), - invalidateRuntime: (distro, runtimeId) => - provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe( - Effect.withSpan("desktop.wsl.invalidateRuntime"), - ), - probeRuntime: (distro, linuxAppRoot) => - provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)).pipe( - Effect.withSpan("desktop.wsl.probeRuntime"), - ), - ensureNodePty: (distro, linuxAppRoot, options) => - provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe( - Effect.withSpan("desktop.wsl.ensureNodePty"), - ), + prepareRuntime: Effect.fn("desktop.wsl.prepareRuntime")(function (distro, archive) { + return provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)); + }), + pruneRuntimes: Effect.fn("desktop.wsl.pruneRuntimes")(function (distro, runtimeId) { + return provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)); + }), + invalidateRuntime: Effect.fn("desktop.wsl.invalidateRuntime")(function (distro, runtimeId) { + return provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)); + }), + probeRuntime: Effect.fn("desktop.wsl.probeRuntime")(function (distro, linuxAppRoot) { + return provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)); + }), + ensureNodePty: Effect.fn("desktop.wsl.ensureNodePty")( + function (distro, linuxAppRoot, options) { + return provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)); + }, + ), }); }), ); diff --git a/apps/mobile/README.md b/apps/mobile/README.md index c2dc0659e0cd..18b4ab3aad8b 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -22,20 +22,33 @@ repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See ## Development -For simulator/emulator development, select and boot a device, then ensure its native client matches -this checkout before starting Metro: +For simulator, emulator, or Android phone development, boot or connect a device, then install the +native client that matches this checkout before starting Metro: ```bash -node ../../scripts/mobile-native-client.ts ensure ios -# Or: node ../../scripts/mobile-native-client.ts ensure android +vp run mobile:install ios [simulator-udid] +# Or: vp run mobile:install android [emulator-or-phone-serial] vp run dev:client ``` -The helper compares a local Expo fingerprint and the installed binary with its last successful -build record. It builds and installs missing, stale, or unverified clients and reuses matching ones. -Use `check` instead of `ensure` for a read-only decision: exit 0 means compatible, 2 means a build is -needed, and 1 means an operational error. Run it on the simulator host; no EAS login is required. -An externally installed client is unverified until the helper builds it once. +The device argument can be omitted when exactly one device is booted or connected. The helper +compares this checkout's Expo fingerprint and the installed binary with its last install record, +and reuses a matching client. Otherwise it installs the build for that fingerprint from +`~/.cache/t3code/native-clients/artifacts`, then the shared task cache, and builds only when +neither has it. One iOS simulator build serves every iPhone and iPad simulator, and one APK per +ABI (x86_64 or arm64-v8a) serves every emulator and phone of that ABI. Build output goes to the +log file it prints (`T3_NATIVE_CLIENT_VERBOSE=1` streams it instead). Builds are shared back when +your task-cache login (`bash infra/task-cache/install.sh`) can write. `T3_NATIVE_CLIENT_CACHE=read` +skips the upload and `off` skips the shared cache. Physical iPhones still use `vp run ios:dev`. + +`--session ` installs into an agent-device session opened elsewhere, such as a device pool +or a cloud simulator, instead of a local device: `vp run mobile:install android --session pool-1 +--metro http://:8081`. It uses only the cached build (x86_64 by default; `--abi` +for ARM) and fails with the next step when no machine has built this fingerprint yet. + +For a read-only decision, run `node ../../scripts/mobile-native-client.ts check +[device]`: exit 0 means compatible, 2 means an install is needed, and 1 means an operational error. +An externally installed client is unverified until the helper installs it once. Start Metro for an already verified dev client: diff --git a/apps/mobile/deps/README.md b/apps/mobile/deps/README.md index f9af134ff22d..46b0539e5c97 100644 --- a/apps/mobile/deps/README.md +++ b/apps/mobile/deps/README.md @@ -1,3 +1,3 @@ -The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers and signed image sources (`renderers`, `resolveImageSource`) are upstream. The fork carries only changes with an open upstream PR: the `onLinksChange` link list that link pills and iOS link menus read ([#1088](https://github.com/software-mansion/enriched-markdown/pull/1088)), Android inline image alignment ([#1089](https://github.com/software-mansion/enriched-markdown/pull/1089)), clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), and development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)). +The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers and signed image sources (`renderers`, `resolveImageSource`) are upstream. The fork carries native streaming memory bounds (an 8 MiB measurement-key budget and coalesced pending renders), plus changes with an open upstream PR: the `onLinksChange` link list that link pills and iOS link menus read ([#1088](https://github.com/software-mansion/enriched-markdown/pull/1088)), Android inline image alignment ([#1089](https://github.com/software-mansion/enriched-markdown/pull/1089)), clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), and development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)). To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Keep the package version aligned with Julius's `fork/t3-mobile` branch. Rename the archive to include the first eight characters of the source commit, such as `react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz`, so each build has a distinct dependency path without version bumps. Update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes. diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz new file mode 100644 index 000000000000..3cf77c4adf97 Binary files /dev/null and b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz differ diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz deleted file mode 100644 index 1dc70ba39f32..000000000000 Binary files a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.43.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.43.tgz deleted file mode 100644 index 9406a8d7fcff..000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.43.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz new file mode 100644 index 000000000000..9346f85dd023 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz differ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 15ae4a15ba4a..f694fc8d6ef2 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -18,6 +18,13 @@ "image": "macos-tahoe-26.6-xcode-27.1" } }, + "development-simulator": { + "extends": "development", + "ios": { + "simulator": true, + "image": "macos-tahoe-26.6-xcode-27.1" + } + }, "preview": { "corepack": true, "env": { diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index d78fa318051e..a5b8f7a44e3a 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -10,6 +10,7 @@ public final class T3NativeControlsModule: Module { private var filePresentation: T3NativeFilePresentation? public func definition() -> ModuleDefinition { + Events("videoPlaybackChange") Constants { if #available(iOS 26.0, *) { return [ @@ -247,10 +248,16 @@ public final class T3NativeControlsModule: Module { userInfo: [NSLocalizedDescriptionKey: "The video preview is no longer available."] ) } - let presentation = T3NativeVideoPresentation(identifier: identifier, url: url, title: title) { [weak self] error in - self?.videoPresentation = nil - if let error { promise.reject(error) } else { promise.resolve(nil) } - } + let presentation = T3NativeVideoPresentation( + identifier: identifier, url: url, title: title, + onPlaybackChange: { [weak self] isPlaying in + self?.sendEvent("videoPlaybackChange", ["identifier": identifier, "isPlaying": isPlaying]) + }, + completion: { [weak self] error in + self?.videoPresentation = nil + if let error { promise.reject(error) } else { promise.resolve(nil) } + } + ) videoPresentation = presentation presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier) } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift index 388e12cb668c..fa0fdcafa7c7 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift @@ -7,6 +7,7 @@ final class T3NativeVideoPresentation: NSObject, AVPlayerViewControllerDelegate, private let controller = AVPlayerViewController() private let completion: (Error?) -> Void private var itemObservation: NSKeyValueObservation? + private var playbackObservation: NSKeyValueObservation? private var backgroundObserver: NSObjectProtocol? private var playbackError: Error? private var presented = false @@ -27,7 +28,8 @@ final class T3NativeVideoPresentation: NSObject, AVPlayerViewControllerDelegate, private weak var fullScreenController: UIViewController? private var embedded = false - init(identifier: String, url: URL, title: String, completion: @escaping (Error?) -> Void) { + init(identifier: String, url: URL, title: String, + onPlaybackChange: @escaping (Bool) -> Void, completion: @escaping (Error?) -> Void) { self.identifier = identifier self.completion = completion super.init() @@ -38,6 +40,12 @@ final class T3NativeVideoPresentation: NSObject, AVPlayerViewControllerDelegate, metadata.value = title as NSString item.externalMetadata = [metadata] controller.player = AVPlayer(playerItem: item) + playbackObservation = controller.player?.observe(\.timeControlStatus, options: [.new]) { player, _ in + // Waiting keeps the playback request alive through buffering and seeks. + if player.timeControlStatus != .waitingToPlayAtSpecifiedRate { + onPlaybackChange(player.timeControlStatus == .playing) + } + } controller.delegate = self controller.overrideUserInterfaceStyle = .dark controller.allowsPictureInPicturePlayback = false @@ -148,6 +156,7 @@ final class T3NativeVideoPresentation: NSObject, AVPlayerViewControllerDelegate, controller.removeFromParent() } itemObservation = nil + playbackObservation = nil controller.player = nil if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } backgroundObserver = nil diff --git a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift index 642a87f14e85..cb3c9041efcc 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift @@ -2,16 +2,18 @@ import ExpoModulesCore import UIKit /// Wraps a screen's header scroll view. Where a column pads its content away from the iPhone Duo's -/// vertical bar, this widens the scroll view under the bar. UIKit draws -/// the header's scroll-edge effect inside the scroll view, so the blur reaches the bar like in the -/// system apps while the content stays in the safe area. Everything happens in UIKit layout, so -/// folds move the blur and the content together. +/// vertical bar, this widens the scroll view under the bar, like the system apps' lists. UIKit +/// draws the header's scroll-edge effect inside the scroll view, so the blur reaches the bar while +/// the content stays in the safe area. Everything happens in UIKit layout, so folds move the blur +/// and the content together. final class T3ScrollEdgeBleedView: ExpoView { // Fabric recycles scroll views, so everything changed here is put back before this view leaves // the window, which unmounting does before any recycling. private var scrollView: UIScrollView? private var container: UIView? - private var bleed: CGFloat = 0 + /// React Native's content view, which every row lives in. + private var content: UIView? + private var bleed = UIEdgeInsets.zero /// Whether the scroll view currently carries this view's changes. private var applied = false /// React Native's clipping, saved while bleeding. React Native only rewrites it when the @@ -55,55 +57,72 @@ final class T3ScrollEdgeBleedView: ExpoView { detach() scrollView = found container = parent + // React Native adds one plain UIView to its scroll view and mounts every child into it. + content = found.subviews.first { type(of: $0) == UIView.self } } bleed = verticalBarBleed() + // Without the content view to shift back, bleeding left would move the rows under the bar. + if content == nil { bleed.left = 0 } apply() } - /// How far the vertical bar's safe area reaches past this view's right edge, when this view sits - /// flush against the padding its column added for it. Nothing else is bled into: not a sidebar - /// column overlapping this one, not a notch, and not a bar on the left, where widening would - /// move the content and need a horizontal offset React Native keeps rewriting. - private func verticalBarBleed() -> CGFloat { - guard verticalBarIsOnTheRight() else { return 0 } + /// How far the vertical bar's safe area reaches past this view's edge, when this view sits flush + /// against the padding its column added for it. Nothing else is bled into: not a sidebar column + /// overlapping this one, and not a notch. + private func verticalBarBleed() -> UIEdgeInsets { + guard let side = verticalBarSide() else { return .zero } var ancestor = superview while let view = ancestor { - let inset = view.safeAreaInsets.right + let inset = side == .left ? view.safeAreaInsets.left : view.safeAreaInsets.right if inset > 0 { let frame = convert(bounds, to: view) - return abs(view.bounds.maxX - inset - frame.maxX) < 0.5 ? inset : 0 + let flush = side == .left + ? abs(frame.minX - (view.bounds.minX + inset)) < 0.5 + : abs(view.bounds.maxX - inset - frame.maxX) < 0.5 + guard flush else { return .zero } + return side == .left + ? UIEdgeInsets(top: 0, left: inset, bottom: 0, right: 0) + : UIEdgeInsets(top: 0, left: 0, bottom: 0, right: inset) } ancestor = view.superview } - return 0 + return .zero } - private func verticalBarIsOnTheRight() -> Bool { + private enum Side { case left, right } + + private func verticalBarSide() -> Side? { // The vertical-bar API ships in the iOS 27.1 SDK (UIKit 9127.0.85); see T3LayoutMetricsView. #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *) { let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft switch traitCollection.verticalBarEdge { - case .leading: return rtl - case .trailing: return !rtl - default: return false + case .leading: return rtl ? .right : .left + case .trailing: return rtl ? .left : .right + default: return nil } } #endif - return false + return nil } - /// Widening to the right leaves the content where React Native laid it out, so only the frame, - /// the container's clipping and the scroll indicator change; contentInset and contentOffset are - /// never touched. Nothing at all is written unless this view is bleeding or undoing its bleed. + /// Widening to the left would move the rows with it. Instead of a horizontal content offset, + /// which React Native rewrites on its own layout passes, the content view's bounds origin shifts + /// them back: React Native only ever sets that view's frame, which leaves its bounds origin. + /// Nothing at all is written unless this view is bleeding or undoing its bleed. private func apply() { guard let scrollView, let container else { return } - guard bleed > 0 || applied else { return } + let bleeding = bleed != .zero + guard bleeding || applied else { return } // React Native sizes the scroll view's component view; the UIScrollView inside follows it by // autoresizing, so the widened frame survives React Native's own layout updates. - let frame = container.bounds.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: -bleed)) + let frame = container.bounds.inset( + by: UIEdgeInsets(top: 0, left: -bleed.left, bottom: 0, right: -bleed.right)) if scrollView.frame != frame { scrollView.frame = frame } - if bleed == 0 { + if let content, content.bounds.origin.x != -bleed.left { + content.bounds.origin.x = -bleed.left + } + if !bleeding { if let clipped = containerClipped { containerClipped = nil container.layer.masksToBounds = clipped @@ -115,19 +134,20 @@ final class T3ScrollEdgeBleedView: ExpoView { container.layer.masksToBounds = false } var indicators = scrollView.verticalScrollIndicatorInsets - if indicators.right != bleed { - indicators.right = bleed + if indicators.right != bleed.right { + indicators.right = bleed.right scrollView.verticalScrollIndicatorInsets = indicators } - applied = bleed > 0 + applied = bleeding } private func detach() { guard scrollView != nil else { return } - bleed = 0 + bleed = .zero apply() scrollView = nil container = nil + content = nil } /// The scroll view sits a fixed few levels down (marker, React Native's scroll view component); diff --git a/apps/mobile/modules/t3-source-text/src/SourceText.tsx b/apps/mobile/modules/t3-source-text/src/SourceText.tsx index aebac31d42c3..70bbb4fd48a6 100644 --- a/apps/mobile/modules/t3-source-text/src/SourceText.tsx +++ b/apps/mobile/modules/t3-source-text/src/SourceText.tsx @@ -28,18 +28,23 @@ export function SourceText({ style, children, nativeTextRef, ...props }: SourceT ); } - const runs = React.Children.toArray(children).map((child, index) => { - if (React.isValidElement(child)) return child; - if (typeof child !== "string" && typeof child !== "number") return null; - return ( - - ); - }); + const runs = React.Children.toArray(children).map( + ( + child, + index, + ) => /* oxlint-disable react/no-array-index-key -- Native text runs are stateless positions within the supplied text. */ { + if (React.isValidElement(child)) return child; + if (typeof child !== "string" && typeof child !== "number") return null; + return ( + + ); + } /* oxlint-enable react/no-array-index-key */, + ); if (isAncestor) { return {runs}; } diff --git a/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec b/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec index 2d40f9776973..a4123a555036 100644 --- a/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec +++ b/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec @@ -14,6 +14,10 @@ Pod::Spec.new do |s| s.source = { :path => '.' } s.source_files = 'ios/**/*.{h,m,mm,swift}' s.vendored_frameworks = 'Vendor/libghostty/GhosttyKit.xcframework' + # libghostty has no x86_64 simulator slice, so a simulator build for every + # architecture (EAS's generic destination) fails to resolve GhosttyKit. + s.pod_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + s.user_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } s.frameworks = 'IOSurface', 'Metal', 'MetalKit', 'QuartzCore', 'UIKit' s.libraries = 'c++', 'z' s.swift_version = '5.9' diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index cf99f02c028d..b8c92f2d9332 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt @@ -11,9 +11,14 @@ class T3TerminalModule : Module() { // logs so a stale native binary is distinguishable from a broken key pipeline. Constants( "hardwareKeyRevision" to 2, + "outputRevision" to 1, ) View(T3TerminalView::class) { + AsyncFunction("writeOutput") { view: T3TerminalView, data: String, reset: Boolean -> + view.writeOutput(data, reset) + } + Prop("terminalKey") { view: T3TerminalView, terminalKey: String -> view.terminalKey = terminalKey } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index ab7d306e5960..f4de25460d5c 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -55,6 +55,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } private var terminalHandle = 0L private var fedBuffer = "" + private var streamedOutput: TerminalOutputBuffer? = null private var cols = 0 private var rows = 0 private var clearingInput = false @@ -361,6 +362,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex paletteColors, ) fedBuffer = "" + streamedOutput?.let { output -> feedOutput(output.data) } } private fun recreateTerminal() { @@ -380,7 +382,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } private fun feedPendingBuffer() { - if (terminalHandle == 0L || initialBuffer == fedBuffer) return + if (streamedOutput != null || terminalHandle == 0L || initialBuffer == fedBuffer) return if (!initialBuffer.startsWith(fedBuffer)) { recreateTerminal() if (terminalHandle == 0L) return @@ -399,6 +401,29 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex renderSnapshot() } + fun writeOutput(data: String, reset: Boolean) { + if (isCleanedUp) return + val output = streamedOutput ?: TerminalOutputBuffer() + val bytes = data.toByteArray(Charsets.UTF_8) + output.write(bytes, reset) + streamedOutput = output + if (reset) { + recreateTerminal() + } else if (terminalHandle != 0L) { + feedOutput(bytes) + renderSnapshot() + } + } + + private fun feedOutput(data: ByteArray) { + if (terminalHandle == 0L || data.isEmpty()) return + emitResponse(GhosttyBridge.nativeFeed(terminalHandle, data)) + if (terminalCanvas.hasActiveSelection()) { + GhosttyBridge.nativeClearSelection(terminalHandle) + terminalCanvas.resetSelectionState() + } + } + private fun renderSnapshot() { if (terminalHandle == 0L) return TerminalFrame.decode( diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalOutputBuffer.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalOutputBuffer.kt new file mode 100644 index 000000000000..fde3393b911e --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalOutputBuffer.kt @@ -0,0 +1,71 @@ +package expo.modules.t3terminal + +import java.util.ArrayDeque + +private const val REPLAY_LIMIT = 512 * 1024 +private const val REPLAY_CHUNK_SIZE = 16 * 1024 + +// Keep only replay history for intentional terminal recreation. +internal class TerminalOutputBuffer { + private val chunks = ArrayDeque() + private var firstOffset = 0 + private var tailSize = 0 + private var size = 0 + + // Only terminal recreation needs contiguous history; appends reuse the retained chunks. + val data: ByteArray + get() { + val result = ByteArray(size) + var destination = 0 + var start = firstOffset + for (chunk in chunks) { + val end = if (chunk === chunks.last) tailSize else chunk.size + chunk.copyInto(result, destination, start, end) + destination += end - start + start = 0 + } + return result + } + + fun write(incoming: ByteArray, reset: Boolean) { + if (reset || incoming.size >= REPLAY_LIMIT) clear() + var start = maxOf(0, incoming.size - REPLAY_LIMIT) + while (start < incoming.size && incoming[start].toInt() and 0xC0 == 0x80) start += 1 + while (start < incoming.size) { + if (chunks.isEmpty() || tailSize == REPLAY_CHUNK_SIZE) { + chunks.addLast(ByteArray(REPLAY_CHUNK_SIZE)) + tailSize = 0 + } + val count = minOf(incoming.size - start, REPLAY_CHUNK_SIZE - tailSize) + incoming.copyInto(chunks.last, tailSize, start, start + count) + start += count + tailSize += count + size += count + } + discardPrefix(maxOf(0, size - REPLAY_LIMIT)) + while (size > 0 && chunks.first[firstOffset].toInt() and 0xC0 == 0x80) discardPrefix(1) + } + + private fun discardPrefix(count: Int) { + var remaining = count + while (remaining > 0) { + val end = if (chunks.size == 1) tailSize else chunks.first.size + val discarded = minOf(remaining, end - firstOffset) + firstOffset += discarded + remaining -= discarded + size -= discarded + if (firstOffset == end) { + chunks.removeFirst() + firstOffset = 0 + if (chunks.isEmpty()) tailSize = 0 + } + } + } + + private fun clear() { + chunks.clear() + firstOffset = 0 + tailSize = 0 + size = 0 + } +} diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index a3ac8f622c50..2f7445265886 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -8,9 +8,14 @@ public class T3TerminalModule: Module { // logs so a stale native binary is distinguishable from a broken key pipeline. Constants([ "hardwareKeyRevision": 3, + "outputRevision": 1, ]) View(T3TerminalView.self) { + AsyncFunction("writeOutput") { (view: T3TerminalView, data: String, reset: Bool) in + view.writeOutput(data, reset: reset) + } + Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in view.terminalKey = terminalKey } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index f407080371ec..364880aaeb39 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -196,6 +196,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { private var lastContentScale: CGFloat = 0 private var lastReportedGrid: (cols: Int, rows: Int)? private var lastAppliedBuffer = "" + private var streamedOutput: TerminalOutputBuffer? private var pendingVerticalScrollPoints: CGFloat = 0 private var app: ghostty_app_t? private var surface: ghostty_surface_t? @@ -526,7 +527,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme) setupWriteCallback() resizeSurface() - feedBuffer(initialBuffer) + if let streamedOutput { + feedData(streamedOutput.data) + } else { + feedBuffer(initialBuffer) + } } private func resetSurface() { @@ -557,6 +562,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } private func applyRemoteBuffer(_ buffer: String) { + guard streamedOutput == nil else { return } guard surface != nil else { createSurfaceIfPossible() return @@ -579,6 +585,20 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { createSurfaceIfPossible() } + func writeOutput(_ data: String, reset: Bool) { + let output = streamedOutput ?? TerminalOutputBuffer() + let bytes = Data(data.utf8) + output.write(bytes, reset: reset) + streamedOutput = output + if reset { + refreshSurface() + } else if surface == nil { + createSurfaceIfPossible() + } else { + feedData(bytes) + } + } + private func feedBuffer(_ buffer: String) { guard !buffer.isEmpty else { return } feedData(Data(buffer.utf8)) diff --git a/apps/mobile/modules/t3-terminal/ios/TerminalOutputBuffer.swift b/apps/mobile/modules/t3-terminal/ios/TerminalOutputBuffer.swift new file mode 100644 index 000000000000..b25c18cd0b65 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/ios/TerminalOutputBuffer.swift @@ -0,0 +1,28 @@ +import Foundation + +// Keep only replay history for intentional surface recreation (font/theme changes). +final class TerminalOutputBuffer { + private var storage = Data() + private var retainedStart = 0 + private let limit = 512 * 1024 + + var data: Data { storage.subdata(in: retainedStart..= limit { + storage = incoming.subdata(in: max(0, incoming.count - limit)..= limit { + storage = storage.subdata(in: retainedStart.. shouldHandleAppLink(url, hasCloudPublicConfig()), getInitialURL: async () => (await Linking.getInitialURL()) ?? (await launchWorkspaceURL()), getActionFromState: ( state: Parameters[0], @@ -70,18 +76,28 @@ const appLinking = { (workspaceDetailLinkAction(state) as ReturnType) ?? getActionFromState(state, options), getStateFromPath: (path: string, options: Parameters[1]) => - restoreSettingsLinkState( - restoreDraftLinkState( - getStateFromPath(githubPullRequestAppPath(path) ?? path, options), - path, + takeScheduledTaskNewLink( + restoreSettingsLinkState( + restoreDraftLinkState( + getStateFromPath(githubPullRequestAppPath(path) ?? path, options), + path, + ), + settingsLinkPage(), ), - settingsLinkPage(), ), }; const Navigation = createStaticNavigation(RootStack); const navigationRef = createNavigationContainerRef(); +declare const module: { readonly hot?: FoundationHotModule } | undefined; + +const disposePermissionUpdateNotice = installPermissionUpdateNotice(); +disposeOnFoundationReplace( + typeof module === "undefined" ? undefined : module.hot, + disposePermissionUpdateNotice, +); + function SplashScreenCoordinator() { const { isReady } = useAppearancePreferences(); @@ -124,7 +140,6 @@ function AppContent() { <> - diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 64b20d1ea760..e1196fff3a30 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -65,7 +65,10 @@ import { ThreadPullRequestsSheet } from "./features/threads/ThreadPullRequestsSh import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet"; import { ThreadBackgroundWorkSheet } from "./features/threads/ThreadBackgroundWorkSheet"; import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl"; -import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; +import { + FullscreenThreadRouteScreen, + ThreadRouteScreen, +} from "./features/threads/ThreadRouteScreen"; import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen"; import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; @@ -819,6 +822,13 @@ const RootStackConfig = createWorkspaceStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/terminal`, options: SOLID_HEADER_OPTIONS, }), + ThreadInspectorFullscreen: createNativeStackScreen({ + screen: FullscreenThreadRouteScreen, + options: { + ...THREAD_HEADER_OPTIONS, + presentation: "fullScreenModal", + }, + }), ThreadDevicePreview: createNativeStackScreen({ screen: DevicePreviewRouteScreen, linking: `${THREAD_LINKING_PREFIX}/devices`, @@ -1073,6 +1083,7 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig const nativeMetrics = useNativeLayoutMetrics(); const usesSplitView = deriveLayout({ width, height, nativeMetrics }).usesSplitView; + /* oxlint-disable react/no-unstable-nested-components -- React Navigation invokes screenLayout as a render callback. */ return ( } @@ -1083,6 +1094,8 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig } /> ); + + /* oxlint-enable react/no-unstable-nested-components */ }); type RootStackType = typeof RootStack; diff --git a/apps/mobile/src/components/CheckoutPickerSheet.tsx b/apps/mobile/src/components/CheckoutPickerSheet.tsx index 27ca8bb07975..8c1974968e67 100644 --- a/apps/mobile/src/components/CheckoutPickerSheet.tsx +++ b/apps/mobile/src/components/CheckoutPickerSheet.tsx @@ -9,7 +9,7 @@ import { looksLikeCheckoutPath, rankCheckoutCandidates, } from "@t3tools/shared/checkouts"; -import { useMemo, useState, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { Alert, FlatList, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -56,32 +56,26 @@ function CheckoutPickerSheet(props: OpenedCheckoutPicker & { readonly onClose: ( const threads = useThreadShells(); const config = useEnvironmentServerConfig(props.environmentId); const [query, setQuery] = useState(""); - const candidates = useMemo( - () => - checkoutCandidates({ - currentEnvironmentId: props.environmentId, - projects, - worktrees: threads.map((thread) => ({ - environmentId: thread.environmentId, - worktreePath: thread.worktreePath, - })), - environmentLabel: (id) => - id === props.environmentId - ? (config?.environment.label ?? "This environment") - : "Another environment", - }).filter((candidate) => candidate.selectable), - [config?.environment.label, projects, props.environmentId, threads], - ); - const ranked = useMemo( - () => rankCheckoutCandidates(candidates, looksLikeCheckoutPath(query) ? "" : query, new Map()), - [candidates, query], + const candidates = checkoutCandidates({ + currentEnvironmentId: props.environmentId, + projects, + worktrees: threads.map((thread) => ({ + environmentId: thread.environmentId, + worktreePath: thread.worktreePath, + })), + environmentLabel: (id) => + id === props.environmentId + ? (config?.environment.label ?? "This environment") + : "Another environment", + }).filter((candidate) => candidate.selectable); + const ranked = rankCheckoutCandidates( + candidates, + looksLikeCheckoutPath(query) ? "" : query, + new Map(), ); const pasted = looksLikeCheckoutPath(query) ? checkoutPathKey(query.trim()) : null; - const paths = useMemo(() => { - const listed = ranked.slice(0, 20).map((candidate) => candidate.path); - if (pasted !== null && !listed.includes(pasted)) listed.unshift(pasted); - return listed; - }, [pasted, ranked]); + const paths = ranked.slice(0, 20).map((candidate) => candidate.path); + if (pasted !== null && !paths.includes(pasted)) paths.unshift(pasted); const settled = useDebouncedValue(paths.join("\n"), 200); const inspection = useEnvironmentQuery( settled.length === 0 @@ -91,23 +85,21 @@ function CheckoutPickerSheet(props: OpenedCheckoutPicker & { readonly onClose: ( input: { paths: settled.split("\n") }, }), ); - const rows = useMemo(() => { - const listed = ranked.slice(0, 20); - if (pasted === null || listed.some((candidate) => checkoutPathKey(candidate.path) === pasted)) { - return listed; - } - return [ - { - environmentId: props.environmentId, - environmentLabel: "", - selectable: true, - path: pasted, - name: checkoutDisplayName(pasted), - kind: "project" as const, - }, - ...listed, - ]; - }, [pasted, props.environmentId, ranked]); + const listed = ranked.slice(0, 20); + const rows = + pasted === null || listed.some((candidate) => checkoutPathKey(candidate.path) === pasted) + ? listed + : [ + { + environmentId: props.environmentId, + environmentLabel: "", + selectable: true, + path: pasted, + name: checkoutDisplayName(pasted), + kind: "project" as const, + }, + ...listed, + ]; const attach = (path: string, name: string) => { const entry = inspection.data?.entries.find( diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 558f905372bc..0db664658048 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -64,6 +64,7 @@ const THREAD_TAB_SUMMARY_ROLE_LABELS = { error: "Error", } as const satisfies Record; +/* oxlint-disable react/no-array-index-key -- Captured summaries are immutable text snapshots without entry IDs. */ function ThreadTabSummaryFields(props: { summary: string }) { const chats = (() => parseThreadTabSummary(props.summary))(); if (chats === null) return ; @@ -148,6 +149,8 @@ function ThreadTabSummaryFields(props: { summary: string }) { }); } +/* oxlint-enable react/no-array-index-key */ + function ContextField(props: { label: string; value: string | null | undefined; code?: boolean }) { if (!props.value) return null; return ( @@ -228,7 +231,7 @@ export function ComposerContextSheet(props: { : undefined; if (record && !("payload" in record) && (record.kind === "image" || record.kind === "file")) { const mimeType = videoMimeType(record) ?? record.mimeType; - const previewKind = filePreviewKind(record); + const previewKind = filePreviewKind(record, { richDocuments: false }); const resource = { _tag: "attachment" as const, attachmentId: record.attachmentId, @@ -385,18 +388,23 @@ export function ComposerContextSheet(props: { - {record.elements?.map((element, index) => ( - - - - - - - - ))} + {record.elements?.map( + (element, index) => ( + /* oxlint-disable react/no-array-index-key -- Captured DOM elements are a fixed snapshot without persistent element IDs. */ + + + + + + + ) /* oxlint-enable react/no-array-index-key */, + )} ) : null} {record.kind === "element" ? ( diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 8dfbb5587b62..d1bb8e4ed778 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -79,6 +79,7 @@ export function ComposerEditor({ () => () => { importRef.current?.abort(); }, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Switching drafts aborts an import owned by the previous draft. [draftKey], ); const pasteContext = async ( diff --git a/apps/mobile/src/components/DiscordMessagePickerSheet.tsx b/apps/mobile/src/components/DiscordMessagePickerSheet.tsx index 6d68136b2655..7fd67533fba2 100644 --- a/apps/mobile/src/components/DiscordMessagePickerSheet.tsx +++ b/apps/mobile/src/components/DiscordMessagePickerSheet.tsx @@ -158,49 +158,49 @@ function DiscordMessageSearch( props.onClose(); }; + const attachSelected = async (message: DiscordMessageSummary) => { + if (props.mode === "link") { + await link(props.threadId, message); + return; + } + const fetched = await getThread({ + environmentId: props.environmentId, + input: { + guildId: message.guildId, + channelId: message.channelId, + messageId: message.messageId, + url: message.url, + scope: "thread", + }, + }); + if (fetched._tag === "Failure") { + Alert.alert( + "Could not attach Discord message", + discordErrorMessage(squashAtomCommandFailure(fetched), "Try again."), + ); + return; + } + const record = discordThreadContextRecord(fetched.value); + if ( + !insertComposerDraftContext( + props.draftKey, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + }, + props.insertion, + ) + ) { + Alert.alert("Too many context items", "Remove some context from the draft and try again."); + return; + } + props.onClose(); + }; + const attach = async (message: DiscordMessageSummary) => { if (attaching) return; setAttaching(message.url); - try { - if (props.mode === "link") { - await link(props.threadId, message); - return; - } - const fetched = await getThread({ - environmentId: props.environmentId, - input: { - guildId: message.guildId, - channelId: message.channelId, - messageId: message.messageId, - url: message.url, - scope: "thread", - }, - }); - if (fetched._tag === "Failure") { - Alert.alert( - "Could not attach Discord message", - discordErrorMessage(squashAtomCommandFailure(fetched), "Try again."), - ); - return; - } - const record = discordThreadContextRecord(fetched.value); - if ( - !insertComposerDraftContext( - props.draftKey, - { - text: formatComposerContextReference(record), - context: { version: 1, records: [record] }, - }, - props.insertion, - ) - ) { - Alert.alert("Too many context items", "Remove some context from the draft and try again."); - return; - } - props.onClose(); - } finally { - setAttaching(null); - } + await attachSelected(message).finally(() => setAttaching(null)); }; return ( diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index fe2ebdfdfb25..77116c880012 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -12,6 +12,11 @@ import { import { Keyboard } from "react-native"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { + downloadAndShareAttachment, + nativeViewerErrorMessage, + shareLocalAttachment, +} from "../lib/attachmentDownload"; import { useRefreshAssetUrl } from "../state/assets"; import { filePreviewGallery, filePreviewKey, filePreviewName } from "../lib/filePreviewGallery"; import { FilePreviewGallery } from "./FilePreviewGallery"; @@ -122,6 +127,32 @@ function ResolvedFilePreview(props: { [items, resolved], ); const ready = selected && gallery.length === items.length; + const onOpenError = (error: unknown) => { + if (props.onOpenError) return props.onOpenError(error); + Dialog.alert("Could not open preview", nativeViewerErrorMessage(error), [ + { text: "Cancel", style: "cancel" }, + ...(selected?.kind === "document" + ? [ + { + text: "Save or share", + onPress: () => { + const input = { + attachment: { + name: filePreviewName(selected), + mimeType: selected.mimeType ?? "application/octet-stream", + }, + signal: new AbortController().signal, + }; + const sharing = /^(file|content):/.test(selected.uri) + ? shareLocalAttachment({ ...input, uri: selected.uri }) + : downloadAndShareAttachment({ ...input, url: selected.uri }); + void sharing.catch(() => Dialog.alert("Could not share file", "Please try again.")); + }, + }, + ] + : []), + ]); + }; return ( <> {items.map((item) => ( @@ -137,7 +168,7 @@ function ResolvedFilePreview(props: { ) : null} diff --git a/apps/mobile/src/components/GitHubIssuePickerSheet.tsx b/apps/mobile/src/components/GitHubIssuePickerSheet.tsx index b0d0a95a59df..2bbda6a15cdc 100644 --- a/apps/mobile/src/components/GitHubIssuePickerSheet.tsx +++ b/apps/mobile/src/components/GitHubIssuePickerSheet.tsx @@ -208,54 +208,54 @@ function GitHubIssuePickerSheet(props: OpenedGitHubIssuePicker & { readonly onCl await attach(issue); }; - const attach = async (issue: GitHubIssueSummary) => { - setAttaching(issue.url); - try { - if (props.mode === "link") { - const linked = await linkThread({ - environmentId: props.environmentId, - input: { threadId: props.threadId, url: issue.url }, - }); - if (linked._tag === "Failure") { - Alert.alert( - "Could not link issue", - gitHubIssueErrorMessage(squashAtomCommandFailure(linked), "Try again."), - ); - return; - } - props.onClose(); - return; - } - const fetched = await getIssue({ + const attachSelected = async (issue: GitHubIssueSummary) => { + if (props.mode === "link") { + const linked = await linkThread({ environmentId: props.environmentId, - input: { url: issue.url }, + input: { threadId: props.threadId, url: issue.url }, }); - if (fetched._tag === "Failure") { + if (linked._tag === "Failure") { Alert.alert( - "Could not attach issue", - gitHubIssueErrorMessage(squashAtomCommandFailure(fetched), "Try again."), + "Could not link issue", + gitHubIssueErrorMessage(squashAtomCommandFailure(linked), "Try again."), ); return; } - const record = gitHubIssueContextRecord(fetched.value); - if ( - !insertComposerDraftContext( - props.draftKey, - { - text: formatComposerContextReference(record), - context: { version: 1, records: [record] }, - }, - props.insertion, - ) - ) { - Alert.alert("Too many context items", "Remove some context from the draft and try again."); - return; - } - if (startFrom) startFromIssueUrls.set(props.draftKey, issue.url); props.onClose(); - } finally { - setAttaching(null); + return; } + const fetched = await getIssue({ + environmentId: props.environmentId, + input: { url: issue.url }, + }); + if (fetched._tag === "Failure") { + Alert.alert( + "Could not attach issue", + gitHubIssueErrorMessage(squashAtomCommandFailure(fetched), "Try again."), + ); + return; + } + const record = gitHubIssueContextRecord(fetched.value); + if ( + !insertComposerDraftContext( + props.draftKey, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + }, + props.insertion, + ) + ) { + Alert.alert("Too many context items", "Remove some context from the draft and try again."); + return; + } + if (startFrom) startFromIssueUrls.set(props.draftKey, issue.url); + props.onClose(); + }; + + const attach = async (issue: GitHubIssueSummary) => { + setAttaching(issue.url); + await attachSelected(issue).finally(() => setAttaching(null)); }; return ( diff --git a/apps/mobile/src/components/GoogleFilePickerSheet.tsx b/apps/mobile/src/components/GoogleFilePickerSheet.tsx index 0b0ff021ca8a..c2d976788f36 100644 --- a/apps/mobile/src/components/GoogleFilePickerSheet.tsx +++ b/apps/mobile/src/components/GoogleFilePickerSheet.tsx @@ -149,43 +149,43 @@ function GoogleFileSearch(props: OpenedGoogleFilePicker & { readonly onClose: () props.onClose(); }; + const attachSelected = async (page: GoogleFileSummary) => { + if (props.mode === "link") { + await link(props.threadId, page); + return; + } + const fetched = await getFile({ + environmentId: props.environmentId, + input: { id: page.url }, + }); + if (fetched._tag === "Failure") { + Alert.alert( + "Could not attach Google file or event", + googleErrorMessage(squashAtomCommandFailure(fetched), "Try again."), + ); + return; + } + const record = googleFileContextRecord(fetched.value); + if ( + !insertComposerDraftContext( + props.draftKey, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + }, + props.insertion, + ) + ) { + Alert.alert("Too many context items", "Remove some context from the draft and try again."); + return; + } + props.onClose(); + }; + const attach = async (page: GoogleFileSummary) => { if (attaching) return; setAttaching(page.url); - try { - if (props.mode === "link") { - await link(props.threadId, page); - return; - } - const fetched = await getFile({ - environmentId: props.environmentId, - input: { id: page.url }, - }); - if (fetched._tag === "Failure") { - Alert.alert( - "Could not attach Google file or event", - googleErrorMessage(squashAtomCommandFailure(fetched), "Try again."), - ); - return; - } - const record = googleFileContextRecord(fetched.value); - if ( - !insertComposerDraftContext( - props.draftKey, - { - text: formatComposerContextReference(record), - context: { version: 1, records: [record] }, - }, - props.insertion, - ) - ) { - Alert.alert("Too many context items", "Remove some context from the draft and try again."); - return; - } - props.onClose(); - } finally { - setAttaching(null); - } + await attachSelected(page).finally(() => setAttaching(null)); }; return ( diff --git a/apps/mobile/src/components/GranolaNotePickerSheet.tsx b/apps/mobile/src/components/GranolaNotePickerSheet.tsx index b280aba76f1f..a86891342653 100644 --- a/apps/mobile/src/components/GranolaNotePickerSheet.tsx +++ b/apps/mobile/src/components/GranolaNotePickerSheet.tsx @@ -173,43 +173,43 @@ function GranolaNoteSearch(props: OpenedGranolaNotePicker & { readonly onClose: props.onClose(); }; + const attachSelected = async (note: GranolaNoteSummary) => { + if (props.mode === "link") { + await link(props.threadId, note); + return; + } + const fetched = await getNote({ + environmentId: props.environmentId, + input: { id: note.id }, + }); + if (fetched._tag === "Failure") { + Alert.alert( + "Could not attach Granola note", + granolaErrorMessage(squashAtomCommandFailure(fetched), "Try again."), + ); + return; + } + const record = granolaNoteContextRecord(fetched.value); + if ( + !insertComposerDraftContext( + props.draftKey, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + }, + props.insertion, + ) + ) { + Alert.alert("Too many context items", "Remove some context from the draft and try again."); + return; + } + props.onClose(); + }; + const attach = async (note: GranolaNoteSummary) => { if (attaching) return; setAttaching(note.id); - try { - if (props.mode === "link") { - await link(props.threadId, note); - return; - } - const fetched = await getNote({ - environmentId: props.environmentId, - input: { id: note.id }, - }); - if (fetched._tag === "Failure") { - Alert.alert( - "Could not attach Granola note", - granolaErrorMessage(squashAtomCommandFailure(fetched), "Try again."), - ); - return; - } - const record = granolaNoteContextRecord(fetched.value); - if ( - !insertComposerDraftContext( - props.draftKey, - { - text: formatComposerContextReference(record), - context: { version: 1, records: [record] }, - }, - props.insertion, - ) - ) { - Alert.alert("Too many context items", "Remove some context from the draft and try again."); - return; - } - props.onClose(); - } finally { - setAttaching(null); - } + await attachSelected(note).finally(() => setAttaching(null)); }; return ( diff --git a/apps/mobile/src/components/LinearIssuePickerSheet.tsx b/apps/mobile/src/components/LinearIssuePickerSheet.tsx index 6030a4e6cea3..c2299a9f8a0d 100644 --- a/apps/mobile/src/components/LinearIssuePickerSheet.tsx +++ b/apps/mobile/src/components/LinearIssuePickerSheet.tsx @@ -234,54 +234,54 @@ function LinearIssueSearch(props: OpenedLinearIssuePicker & { readonly onClose: await attach(issue); }; - const attach = async (issue: LinearIssueSummary) => { - setAttaching(issue.id); - try { - if (props.mode === "link") { - const linked = await linkThread({ - environmentId: props.environmentId, - input: { threadId: props.threadId, issueId: issue.id }, - }); - if (linked._tag === "Failure") { - Alert.alert( - "Could not link issue", - linearErrorMessage(squashAtomCommandFailure(linked), "Try again."), - ); - return; - } - props.onClose(); - return; - } - const fetched = await getIssue({ + const attachSelected = async (issue: LinearIssueSummary) => { + if (props.mode === "link") { + const linked = await linkThread({ environmentId: props.environmentId, - input: { id: issue.id }, + input: { threadId: props.threadId, issueId: issue.id }, }); - if (fetched._tag === "Failure") { + if (linked._tag === "Failure") { Alert.alert( - "Could not attach issue", - linearErrorMessage(squashAtomCommandFailure(fetched), "Try again."), + "Could not link issue", + linearErrorMessage(squashAtomCommandFailure(linked), "Try again."), ); return; } - const record = linearIssueContextRecord(fetched.value); - if ( - !insertComposerDraftContext( - props.draftKey, - { - text: formatComposerContextReference(record), - context: { version: 1, records: [record] }, - }, - props.insertion, - ) - ) { - Alert.alert("Too many context items", "Remove some context from the draft and try again."); - return; - } - if (startFrom) startFromIssueIds.set(props.draftKey, issue.id); props.onClose(); - } finally { - setAttaching(null); + return; } + const fetched = await getIssue({ + environmentId: props.environmentId, + input: { id: issue.id }, + }); + if (fetched._tag === "Failure") { + Alert.alert( + "Could not attach issue", + linearErrorMessage(squashAtomCommandFailure(fetched), "Try again."), + ); + return; + } + const record = linearIssueContextRecord(fetched.value); + if ( + !insertComposerDraftContext( + props.draftKey, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + }, + props.insertion, + ) + ) { + Alert.alert("Too many context items", "Remove some context from the draft and try again."); + return; + } + if (startFrom) startFromIssueIds.set(props.draftKey, issue.id); + props.onClose(); + }; + + const attach = async (issue: LinearIssueSummary) => { + setAttaching(issue.id); + await attachSelected(issue).finally(() => setAttaching(null)); }; return ( diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index 709fcb968508..ea39cac15baf 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -1,8 +1,7 @@ -import { useIsFocused } from "@react-navigation/native"; import { useEvent } from "expo"; import { useVideoPlayer, VideoView } from "expo-video"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; -import { AppState, Pressable, View } from "react-native"; +import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { Pressable, View } from "react-native"; import { AppActivityIndicator } from "./ui/AppActivityIndicator"; import { AppText } from "./ui/AppText"; @@ -10,20 +9,18 @@ import { SymbolView } from "./ui/AppSymbol"; import { VideoThumbnailImage } from "./VideoThumbnailImage"; import { useMediaActions } from "../state/mediaActions"; import { type MediaActionsSource } from "../lib/mediaActionsSource"; -import { useFullscreenVideoOpen, useMediaAudioSessionHold } from "../lib/videoAudioSession"; +import { useMediaVideoPlayback } from "../lib/useMediaVideoPlayback"; import { MediaActionsMenu } from "./MediaActionsMenu"; +import { createAudioPlaybackSession } from "../lib/audioSession"; function LoadedMediaVideo(props: { readonly uri: string; readonly resolvePlaybackUri?: () => Promise; readonly playRequested: boolean; readonly paused: boolean; + readonly visible: boolean; + readonly onPlaybackStart?: () => void; }) { - const focused = useIsFocused(); - const fullscreenVideoOpen = useFullscreenVideoOpen(); - const paused = props.paused || fullscreenVideoOpen; - const active = useRef(focused && AppState.currentState === "active"); - const fullscreen = useRef(false); const [attempt, setAttempt] = useState(0); // Expo's Android player also reports completed playback as idle. const [loadState, setLoadState] = useState<"pending" | "complete" | "error">("pending"); @@ -31,31 +28,55 @@ function LoadedMediaVideo(props: { player.staysActiveInBackground = false; player.bufferOptions = { preferredForwardBufferDuration: 5 }; }); + const playback = useMemo(() => createAudioPlaybackSession(player, true), [player]); + const { active, fullscreen, createPlayRequest, onFullscreenEnter, onFullscreenExit } = + useMediaVideoPlayback(player, props, playback); + const previousPlaybackRequested = useRef(false); + const onPlaybackStart = useEffectEvent(() => props.onPlaybackStart?.()); + useEffect(() => { + const subscription = player.addListener("playingChange", ({ isPlaying }) => { + if (isPlaying && !active.current && !fullscreen.current) playback.pause(); + else playback.playingChanged(isPlaying); + }); + const requested = player.addListener("playbackRequestedChange", ({ isPlaybackRequested }) => { + const wasPlaybackRequested = previousPlaybackRequested.current; + previousPlaybackRequested.current = isPlaybackRequested; + if (!isPlaybackRequested) { + if (playback.requested) playback.pause(); + } else if (!active.current && !fullscreen.current) playback.pause(); + else { + if (!wasPlaybackRequested) onPlaybackStart(); + playback.playingChanged(true); + } + }); + const ended = player.addListener("playToEnd", () => playback.pause()); + const failed = player.addListener("statusChange", ({ status }) => { + if (status === "error") playback.pause(); + }); + return () => { + subscription.remove(); + requested.remove(); + ended.remove(); + failed.remove(); + playback.pause(); + previousPlaybackRequested.current = false; + }; + }, [active, fullscreen, player, playback]); const { status } = useEvent(player, "statusChange", { status: player.status }); - const { isPlaying } = useEvent(player, "playingChange", { isPlaying: player.playing }); - useMediaAudioSessionHold(isPlaying); const loadSource = useEffectEvent(async (signal: AbortSignal) => { + const playIfUninterrupted = createPlayRequest(); const uri = props.resolvePlaybackUri ? await props.resolvePlaybackUri() : props.uri; if (signal.aborted) return; if (uri === null) throw new Error("Video unavailable"); - player.pause(); + playback.pause(); + previousPlaybackRequested.current = false; await player.replaceAsync({ uri, contentType: "progressive" }); - if (!signal.aborted && props.playRequested && active.current) player.play(); + if (!signal.aborted && props.playRequested) await playIfUninterrupted(); }); - useEffect(() => { - active.current = focused && !paused && AppState.currentState === "active"; - if (!focused || paused || (!active.current && !fullscreen.current)) player.pause(); - // RN reports both leaving the app and Android fullscreen as background; native handling distinguishes them. - const subscription = AppState.addEventListener("change", (state) => { - active.current = focused && !paused && state === "active"; - if (state === "inactive" || (state === "background" && !fullscreen.current)) player.pause(); - }); - return () => subscription.remove(); - }, [focused, player, paused]); - useEffect(() => { const controller = new AbortController(); + // oxlint-disable-next-line react/set-state-in-effect -- Starting a native media load publishes pending before its async result. setLoadState("pending"); // A renewed signature is used on Retry, not as a reason to reset the native player. void loadSource(controller.signal).then( @@ -67,6 +88,7 @@ function LoadedMediaVideo(props: { }, ); return () => controller.abort(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A play request or Retry (attempt) reloads the source into the same player. }, [player, props.playRequested, attempt]); return ( @@ -78,12 +100,8 @@ function LoadedMediaVideo(props: { nativeControls contentFit="contain" fullscreenOptions={{ enable: true }} - onFullscreenEnter={() => { - fullscreen.current = true; - }} - onFullscreenExit={() => { - fullscreen.current = false; - }} + onFullscreenEnter={onFullscreenEnter} + onFullscreenExit={onFullscreenExit} allowsPictureInPicture={false} /> {loadState === "error" || (loadState === "complete" && status === "error") ? ( @@ -113,10 +131,12 @@ interface MediaVideoPlayerProps { readonly name: string; readonly thumbnailKey: string; readonly thumbnailVisible?: boolean; + readonly visible?: boolean; readonly unavailable?: boolean; readonly expanded?: boolean; readonly autoPlay?: boolean; readonly paused?: boolean; + readonly onPlaybackStart?: () => void; readonly actionsSource?: MediaActionsSource; } @@ -138,6 +158,8 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { resolvePlaybackUri={props.resolvePlaybackUri} playRequested={!props.expanded || props.autoPlay === true} paused={(props.paused ?? false) || mediaActions.sharing} + visible={props.visible ?? true} + onPlaybackStart={props.onPlaybackStart} /> ) : ( @@ -150,7 +172,10 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { accessibilityState={{ disabled: props.uri === null || props.unavailable === true }} // Stays pressable so the long-press menu still opens on a failed or unsigned tile. onPress={() => { - if (props.uri !== null && !props.unavailable) setPlaybackUri(props.uri); + if (props.uri !== null && !props.unavailable) { + props.onPlaybackStart?.(); + setPlaybackUri(props.uri); + } }} className="flex-1 items-center justify-center px-4" > diff --git a/apps/mobile/src/components/NotionPagePickerSheet.tsx b/apps/mobile/src/components/NotionPagePickerSheet.tsx index e58c88ee3c11..5518b88b46f9 100644 --- a/apps/mobile/src/components/NotionPagePickerSheet.tsx +++ b/apps/mobile/src/components/NotionPagePickerSheet.tsx @@ -148,43 +148,43 @@ function NotionPageSearch(props: OpenedNotionPagePicker & { readonly onClose: () props.onClose(); }; + const attachSelected = async (page: NotionPageSummary) => { + if (props.mode === "link") { + await link(props.threadId, page); + return; + } + const fetched = await getPage({ + environmentId: props.environmentId, + input: { id: page.id }, + }); + if (fetched._tag === "Failure") { + Alert.alert( + "Could not attach Notion page", + notionErrorMessage(squashAtomCommandFailure(fetched), "Try again."), + ); + return; + } + const record = notionPageContextRecord(fetched.value); + if ( + !insertComposerDraftContext( + props.draftKey, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + }, + props.insertion, + ) + ) { + Alert.alert("Too many context items", "Remove some context from the draft and try again."); + return; + } + props.onClose(); + }; + const attach = async (page: NotionPageSummary) => { if (attaching) return; setAttaching(page.url); - try { - if (props.mode === "link") { - await link(props.threadId, page); - return; - } - const fetched = await getPage({ - environmentId: props.environmentId, - input: { id: page.id }, - }); - if (fetched._tag === "Failure") { - Alert.alert( - "Could not attach Notion page", - notionErrorMessage(squashAtomCommandFailure(fetched), "Try again."), - ); - return; - } - const record = notionPageContextRecord(fetched.value); - if ( - !insertComposerDraftContext( - props.draftKey, - { - text: formatComposerContextReference(record), - context: { version: 1, records: [record] }, - }, - props.insertion, - ) - ) { - Alert.alert("Too many context items", "Remove some context from the draft and try again."); - return; - } - props.onClose(); - } finally { - setAttaching(null); - } + await attachSelected(page).finally(() => setAttaching(null)); }; return ( diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index fa223ffa4786..156c2b823535 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -171,6 +171,7 @@ function ProjectFaviconImage(props: { if (faviconRequest === null) return; const endRequest = beginProjectFaviconRequest(faviconRequest); + // oxlint-disable-next-line react/set-state-in-effect -- Publishes the favicon request registered with the external loader until its cleanup runs. setActiveFaviconRequest(faviconRequest); return endRequest; }, [faviconRequest]); diff --git a/apps/mobile/src/components/SlackMessagePickerSheet.tsx b/apps/mobile/src/components/SlackMessagePickerSheet.tsx index ab00864a6c02..19686e6bceca 100644 --- a/apps/mobile/src/components/SlackMessagePickerSheet.tsx +++ b/apps/mobile/src/components/SlackMessagePickerSheet.tsx @@ -170,49 +170,49 @@ function SlackMessageSearch(props: OpenedSlackMessagePicker & { readonly onClose props.onClose(); }; + const attachSelected = async (message: SlackMessageSummary) => { + if (props.mode === "link") { + await link(props.threadId, message); + return; + } + const fetched = await getThread({ + environmentId: props.environmentId, + input: { + channelId: message.channelId, + ts: message.ts, + threadTs: message.threadTs ?? undefined, + url: message.url, + scope: "thread", + }, + }); + if (fetched._tag === "Failure") { + Alert.alert( + "Could not attach Slack message", + slackErrorMessage(squashAtomCommandFailure(fetched), "Try again."), + ); + return; + } + const record = slackThreadContextRecord(fetched.value); + if ( + !insertComposerDraftContext( + props.draftKey, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + }, + props.insertion, + ) + ) { + Alert.alert("Too many context items", "Remove some context from the draft and try again."); + return; + } + props.onClose(); + }; + const attach = async (message: SlackMessageSummary) => { if (attaching) return; setAttaching(message.url); - try { - if (props.mode === "link") { - await link(props.threadId, message); - return; - } - const fetched = await getThread({ - environmentId: props.environmentId, - input: { - channelId: message.channelId, - ts: message.ts, - threadTs: message.threadTs ?? undefined, - url: message.url, - scope: "thread", - }, - }); - if (fetched._tag === "Failure") { - Alert.alert( - "Could not attach Slack message", - slackErrorMessage(squashAtomCommandFailure(fetched), "Try again."), - ); - return; - } - const record = slackThreadContextRecord(fetched.value); - if ( - !insertComposerDraftContext( - props.draftKey, - { - text: formatComposerContextReference(record), - context: { version: 1, records: [record] }, - }, - props.insertion, - ) - ) { - Alert.alert("Too many context items", "Remove some context from the draft and try again."); - return; - } - props.onClose(); - } finally { - setAttaching(null); - } + await attachSelected(message).finally(() => setAttaching(null)); }; return ( diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx index 2c95bdca396d..0eec525abba3 100644 --- a/apps/mobile/src/components/VideoPreviewModal.ios.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -11,6 +11,12 @@ import { fullscreenVideoClosed, fullscreenVideoOpened } from "../lib/videoAudioS import { mediaVideoPreviewUri, type VideoPreviewSource } from "../lib/videoPreviewSource"; import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; import { usePreparedConnection } from "../state/session"; +import { + acquireAudioSession, + activateAudioSession, + isAudioSessionOwner, + releaseAudioSession, +} from "../lib/audioSession"; export type { VideoPreviewSource } from "../lib/videoPreviewSource"; @@ -22,6 +28,10 @@ const NativeControls = requireNativeModule<{ identifier: string, ): Promise; dismissVideo(identifier: string): Promise; + addListener( + event: "videoPlaybackChange", + listener: (event: { identifier: string; isPlaying: boolean }) => void, + ): { remove(): void }; }>("T3NativeControls"); function NativeVideoPreview(props: { @@ -60,6 +70,7 @@ function NativeVideoPreview(props: { return () => fullscreenVideoClosed(); }, []); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- The native player latches the first resolved URL to keep its playback session. if (playbackUrl === null && resolvedUrl !== null) setPlaybackUrl(resolvedUrl); }, [playbackUrl, resolvedUrl]); useEffect(() => { @@ -71,6 +82,29 @@ function NativeVideoPreview(props: { useEffect(() => { if (localAttachment === null && playbackUrl === null) return; const controller = new AbortController(); + let token: ReturnType | null = null; + const release = () => { + if (token) void releaseAudioSession(token).catch(() => {}); + token = null; + }; + const acquire = () => { + const next = acquireAudioSession("playback", () => { + release(); + void NativeControls.dismissVideo(identifier).catch(() => {}); + }); + if (!isAudioSessionOwner(next)) { + void NativeControls.dismissVideo(identifier).catch(() => {}); + return null; + } + token = next; + return next; + }; + const subscription = NativeControls.addListener("videoPlaybackChange", (event) => { + if (event.identifier !== identifier || controller.signal.aborted) return; + if (event.isPlaying) { + if (!token) acquire(); + } else release(); + }); let ready = false; void (async () => { const file = @@ -82,6 +116,13 @@ function NativeVideoPreview(props: { file?.dispose(); return; } + const current = acquire(); + if (!current || !(await activateAudioSession(current, true)) || controller.signal.aborted) { + release(); + file?.dispose(); + if (!controller.signal.aborted) onRequestClose(); + return; + } ready = true; const presented = await settlePromise(() => NativeControls.presentVideo( @@ -93,9 +134,11 @@ function NativeVideoPreview(props: { ); // Native completion follows dismissal, so local playback keeps its file lease. file?.dispose(); + release(); if (AsyncResult.isFailure(presented)) throw squashAtomCommandFailure(presented); if (!controller.signal.aborted) onRequestClose(); })().catch((error: unknown) => { + release(); if (controller.signal.aborted) return; // AVKit gives no retry, so re-mint now; the cached URL may simply have expired. if (ready) void refreshAssetUrl(); @@ -111,6 +154,8 @@ function NativeVideoPreview(props: { }); return () => { controller.abort(); + subscription.remove(); + release(); void NativeControls.dismissVideo(identifier).catch(() => undefined); }; }, [localAttachment, name, source.sourceIdentifier, playbackUrl, identifier]); diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index 7feae6d93006..9b2b64c68c4b 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -64,6 +64,7 @@ function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState { ); const { id: attachmentId, fileUri } = attachment; useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- A new attachment clears the previous attachment's result before loading. setUri(null); setError(null); const controller = new AbortController(); @@ -87,6 +88,7 @@ function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState { () => undefined, ); }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A different attachment restarts the load even when it resolves to the same cached file URI. }, [attachmentId, fileUri]); const mimeType = videoMimeType(attachment) ?? attachment.mimeType; return { @@ -208,9 +210,10 @@ export function VideoPreviewModal(props: { }) { const isFocused = useIsFocused(); const hasSource = props.source !== null; + const { onRequestClose } = props; useEffect(() => { - if (!isFocused && hasSource) props.onRequestClose(); - }, [isFocused, hasSource, props.onRequestClose]); + if (!isFocused && hasSource) onRequestClose(); + }, [isFocused, hasSource, onRequestClose]); const { source } = props; if (source === null || !isFocused) return null; return source.type === "local" ? ( diff --git a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx index cd45040a7088..a1f6a02cc32e 100644 --- a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx @@ -1,11 +1,12 @@ import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu"; import type { ReactNode } from "react"; -import { use, useCallback, useEffect, useRef, useState } from "react"; +import { use, useRef, useState } from "react"; import type { StyleProp, ViewInstance, ViewStyle } from "react-native"; -import { BackHandler, Pressable, ScrollView, View } from "react-native"; +import { Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; +import { useHardwareBack } from "../../lib/backHandler"; import { deriveMenuHorizontalPlacement } from "../../lib/menu-placement"; import { WindowHingeContext } from "../../native/window-hinge-context"; import { OverlayPortal } from "./OverlayPortal"; @@ -47,7 +48,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const [rootHeight, setRootHeight] = useState(null); const [overlay, setOverlay] = useState(null); const windowHinge = use(WindowHingeContext); - const anchorRef = useRef(null); + // State, not a ref: `open` reaches render-prop children, and the compiler + // bails out of the whole component when a ref might be read during render. + const [anchorNode, setAnchorNode] = useState(null); const overlayRef = useRef(null); const keyboardVisible = useKeyboardState((state) => state.isVisible); @@ -60,7 +63,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { }; const open = () => { - anchorRef.current?.measureInWindow((x, y, width, height) => { + anchorNode?.measureInWindow((x, y, width, height) => { setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible }); }); }; @@ -72,21 +75,16 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { }); }; - const submenuDepth = path.length; - useEffect(() => { - if (anchor === null || !anchor.keyboardWasVisible) { - return; + // Only the in-window (keyboard) presentation needs this; the native popup + // handles back itself. + useHardwareBack(anchor?.keyboardWasVisible === true, () => { + if (path.length > 0) { + setPath((current) => current.slice(0, -1)); + } else { + close(); } - const subscription = BackHandler.addEventListener("hardwareBackPress", () => { - if (submenuDepth > 0) { - setPath((current) => current.slice(0, -1)); - } else { - close(); - } - return true; - }); - return () => subscription.remove(); - }, [anchor, close, submenuDepth]); + return true; + }); const parent = path[path.length - 1] ?? null; const levelActions = (parent?.subactions ?? props.actions).filter( @@ -141,12 +139,17 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { return ( <> {typeof props.children === "function" ? ( - + {props.children(open)} ) : ( { - if (!searching || search?.mode === "inline") return; - const subscription = BackHandler.addEventListener("hardwareBackPress", () => { - closeSearch(); - return true; - }); - return () => subscription.remove(); - }, [closeSearch, searching, search?.mode]); + useHardwareBack(searching && search?.mode !== "inline", () => { + closeSearch(); + return true; + }); const menuView = props.menus?.map((menu) => ( [] as Array<() => void>); + vi.mock("@expo/ui/jetpack-compose", () => { const Slot = (props: { readonly children?: ReactNode }) =>
{props.children}
; - const Button = (props: { readonly children?: ReactNode; readonly onClick?: () => void }) => ( - - ); + const Button = (props: { readonly children?: ReactNode; readonly onClick?: () => void }) => { + if (props.onClick) renderedClicks.push(props.onClick); + return ; + }; return { AlertDialog: Object.assign(Slot, { Title: Slot, @@ -51,6 +56,7 @@ function mountHost() { afterEach(() => { for (const root of roots.splice(0)) act(() => root.unmount()); + renderedClicks.length = 0; document.body.innerHTML = ""; }); @@ -65,3 +71,34 @@ it("keeps presenting dialogs after an older root's Host unmounts", () => { expect(current.container.textContent).toContain("Saved"); }); + +it("presents a dialog requested before any Host mounted", () => { + Dialog.alert("Could not import shared content", undefined, [{ text: "Dismiss" }]); + + const { container } = mountHost(); + + expect(container.textContent).toContain("Could not import shared content"); +}); + +it("keeps a dialog raised while the activity is replaced", () => { + const previous = mountHost(); + act(() => Dialog.alert("Shared content imported", undefined, [{ text: "OK" }])); + const current = mountHost(); + roots.splice(roots.indexOf(previous.root), 1); + act(() => previous.root.unmount()); + + expect(current.container.textContent).toContain("Shared content imported"); +}); + +it("ignores a late action from a dialog that a newer one replaced", () => { + const { container } = mountHost(); + const olderAction = vi.fn(); + act(() => Dialog.alert("Older", undefined, [{ text: "OK", onPress: olderAction }])); + const olderClick = renderedClicks.at(-1)!; + act(() => Dialog.alert("Newer", undefined, [{ text: "OK" }])); + + act(() => olderClick()); + + expect(olderAction).toHaveBeenCalledOnce(); + expect(container.textContent).toContain("Newer"); +}); diff --git a/apps/mobile/src/components/ui/Dialog.android.tsx b/apps/mobile/src/components/ui/Dialog.android.tsx index 273ad8b022c8..af05275b7f27 100644 --- a/apps/mobile/src/components/ui/Dialog.android.tsx +++ b/apps/mobile/src/components/ui/Dialog.android.tsx @@ -14,7 +14,7 @@ import { import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useSyncExternalStore } from "react"; import { themeColorWithContrast } from "../../lib/mobileTheme"; import type { Alert, AlertButton } from "react-native"; import type { ConfirmDialogRequest, TextInputDialogRequest } from "./Dialog.types"; @@ -33,58 +33,76 @@ type DialogRequest = | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } | { readonly kind: "text-input"; readonly request: TextInputDialogRequest }; -let presentRequest: ((request: DialogRequest) => void) | null = null; +type PresentedDialog = DialogRequest & { readonly id: number }; + +// App services raise dialogs before the root mounts, so the request lives in +// the module and the Host renders whatever is current. One dialog shows at a +// time: a new request replaces the presented one. +let presented: PresentedDialog | null = null; +let nextPresentationId = 0; +const listeners = new Set<() => void>(); + +function setPresented(next: PresentedDialog | null) { + presented = next; + for (const listener of listeners) listener(); +} + +function submitRequest(dialog: DialogRequest) { + setPresented({ ...dialog, id: ++nextPresentationId }); +} + +/** Closes the dialog only if it is still the one the action came from. */ +function dismiss(id: number) { + if (presented?.id === id) setPresented(null); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getPresented() { + return presented; +} export function alert(...[title, message, buttons, options]: Parameters): void { const actions = buttons?.length ? buttons.slice(0, 3) : [{ text: "OK" }]; const normalized = actions.map((action, index) => index === actions.length - 1 ? { ...action, text: action.text || "OK" } : action, ); - presentRequest?.({ + submitRequest({ kind: "alert", request: { title, message, buttons: normalized, options }, }); } export function confirm(request: ConfirmDialogRequest): void { - presentRequest?.({ kind: "confirm", request }); + submitRequest({ kind: "confirm", request }); } export function prompt(request: TextInputDialogRequest): void { - presentRequest?.({ kind: "text-input", request }); + submitRequest({ kind: "text-input", request }); } export function Host() { - const [presented, setPresented] = useState<(DialogRequest & { readonly id: number }) | null>( - null, - ); - const [inputValue, setInputValue] = useState(""); - const nextPresentationId = useRef(0); - useEffect(() => { - const present = (request: DialogRequest) => { - setInputValue(request.kind === "text-input" ? request.request.initialValue : ""); - setPresented({ ...request, id: ++nextPresentationId.current }); - }; - presentRequest = present; - return () => { - // When Android replaces the activity, the new root's Host registers before the old root - // unmounts. Clearing unconditionally would leave the live Host without dialogs. - if (presentRequest === present) presentRequest = null; - }; - }, []); + const presented = useSyncExternalStore(subscribe, getPresented, getPresented); const handleCancel = () => { - setPresented(null); - if (presented?.kind === "alert") presented.request.options?.onDismiss?.(); - else presented?.request.onCancel?.(); + if (!presented) return; + dismiss(presented.id); + if (presented.kind === "alert") presented.request.options?.onDismiss?.(); + else presented.request.onCancel?.(); }; const handleConfirm = (nativeInputValue?: string) => { - setPresented(null); - if (presented?.kind === "confirm") { + if (!presented) return; + dismiss(presented.id); + if (presented.kind === "confirm") { presented.request.onConfirm(); - } else if (presented?.kind === "text-input") { - presented.request.onConfirm(nativeInputValue ?? inputValue); + } else if (presented.kind === "text-input") { + presented.request.onConfirm(nativeInputValue ?? presented.request.initialValue); } }; @@ -100,7 +118,7 @@ export function Host() { alertActions={presented.request.buttons ?? [{ text: "OK" }]} dismissable={presented.request.options?.cancelable ?? false} onAlertAction={(action) => { - setPresented(null); + dismiss(presented.id); action.onPress?.(); }} onCancel={handleCancel} @@ -117,7 +135,6 @@ export function Host() { presented.kind === "text-input" ? presented.request.initialValue : undefined } inputPlaceholder={presented.kind === "text-input" ? presented.request.placeholder : undefined} - onInputChange={setInputValue} onCancel={handleCancel} onConfirm={handleConfirm} /> @@ -209,7 +226,6 @@ function DialogPresentation(props: DialogPresentationProps) { singleLine value={inputState} selection={inputSelection} - onValueChange={props.onInputChange} textStyle={inputTypography} keyboardOptions={{ imeAction: "done" }} keyboardActions={{ @@ -264,7 +280,6 @@ interface DialogPresentationProps { >; readonly inputInitialValue?: string; readonly inputPlaceholder?: string; - readonly onInputChange?: (value: string) => void; readonly onCancel: () => void; readonly onConfirm: (inputValue?: string) => void; } diff --git a/apps/mobile/src/components/ui/GlassSurface.tsx b/apps/mobile/src/components/ui/GlassSurface.tsx index 05bf0532e460..8af080fc19cc 100644 --- a/apps/mobile/src/components/ui/GlassSurface.tsx +++ b/apps/mobile/src/components/ui/GlassSurface.tsx @@ -25,7 +25,8 @@ interface GlassSurfaceProps extends ViewProps { readonly ref?: Ref; readonly children: ReactNode; readonly glassEffectStyle?: "clear" | "regular" | "none"; - readonly tintColor?: ColorValue; + // null leaves UIKit's material untinted instead of applying the theme tint. + readonly tintColor?: ColorValue | null; readonly tintColorClassName?: string; readonly chrome?: "default" | "none"; readonly fallbackColor?: ColorValue; @@ -71,7 +72,7 @@ export function GlassSurface({ className, )} glassEffectStyle={glassEffectStyle} - tintColor={tintColor === undefined ? undefined : String(tintColor)} + tintColor={tintColor == null ? undefined : String(tintColor)} tintColorClassName={ tintColorClassName ?? (tintColor === undefined ? "accent-glass-tint" : undefined) } diff --git a/apps/mobile/src/components/ui/Menu.tsx b/apps/mobile/src/components/ui/Menu.tsx index 54fb0bec95f0..17757fe84b3d 100644 --- a/apps/mobile/src/components/ui/Menu.tsx +++ b/apps/mobile/src/components/ui/Menu.tsx @@ -51,6 +51,7 @@ export function Menu(props: MenuProps) { let children = menuProps.children; if (props.shouldOpenOnLongPress && isValidElement(children)) { const child = children as ReactElement>; + /* oxlint-disable react/refs -- Cloning the trigger installs event callbacks; the refs are accessed when those callbacks run. */ children = cloneElement(child, { onTouchStart: (event) => { // onPressIn can repeat within one gesture when a finger leaves and re-enters the row. @@ -74,6 +75,7 @@ export function Menu(props: MenuProps) { child.props.onPress?.(event); }, }); + /* oxlint-enable react/refs */ menuProps.onMenuInteractionStart = () => { menuPress.current.isPreparing = true; props.onMenuInteractionStart?.(); diff --git a/apps/mobile/src/components/ui/OverlayPortal.tsx b/apps/mobile/src/components/ui/OverlayPortal.tsx index a7939cfa3075..a4f07f46024f 100644 --- a/apps/mobile/src/components/ui/OverlayPortal.tsx +++ b/apps/mobile/src/components/ui/OverlayPortal.tsx @@ -1,9 +1,8 @@ -import { type ReactNode, useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState, useSyncExternalStore } from "react"; import { View } from "react-native"; // RN Modals move native window focus and close the keyboard. Keep composer menus in-window. type Entries = ReadonlyMap; -type Listener = (entries: Entries) => void; let nextKey = 0; @@ -11,24 +10,41 @@ function allocateKey() { return nextKey++; } const entries = new Map(); -const listeners = new Set(); +const listeners = new Set<() => void>(); +let snapshot: Entries = new Map(); function emit() { - const snapshot = new Map(entries); + snapshot = new Map(entries); for (const listener of listeners) { - listener(snapshot); + listener(); } } +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot() { + return snapshot; +} + export function OverlayPortal(props: { readonly children: ReactNode }) { const [key] = useState(allocateKey); - // Re-project every render so open menus reflect content and theme changes. + // RN has no createPortal, so projecting into the out-of-tree host has to + // happen after commit. No dependency array: re-project after every render so + // the host always shows the current content (menus re-render while open — + // drill-in, theme). useEffect(() => { entries.set(key, props.children); emit(); }); + // Removal is unmount-only: deleting on every re-projection would move the + // entry to the end of the map and restack it above overlays opened later. useEffect( () => () => { entries.delete(key); @@ -41,14 +57,7 @@ export function OverlayPortal(props: { readonly children: ReactNode }) { } export function OverlayPortalHost() { - const [current, setCurrent] = useState(() => new Map()); - - useEffect(() => { - listeners.add(setCurrent); - return () => { - listeners.delete(setCurrent); - }; - }, []); + const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); if (current.size === 0) { return null; diff --git a/apps/mobile/src/components/ui/SliderRow.android.tsx b/apps/mobile/src/components/ui/SliderRow.android.tsx index ad774efa2126..d9ab444f2cbf 100644 --- a/apps/mobile/src/components/ui/SliderRow.android.tsx +++ b/apps/mobile/src/components/ui/SliderRow.android.tsx @@ -1,7 +1,7 @@ import { Slider } from "@expo/ui/jetpack-compose"; import { fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; import * as Haptics from "expo-haptics"; -import { useEffect, useRef } from "react"; +import { useRef } from "react"; import { View } from "react-native"; import { AppText as Text } from "./AppText"; @@ -12,11 +12,8 @@ import type { SliderRowProps } from "./SliderRow.types"; export function SliderRow(props: SliderRowProps) { const { colors } = useControlTheme(); - const draft = useRef(props.value); - const dragging = useRef(false); - useEffect(() => { - if (!dragging.current) draft.current = props.value; - }, [props.value]); + // The value of the drag in progress; null between drags. + const draft = useRef(null); const commit = (value: number) => { if (props.disabled) return; const next = Math.min( @@ -66,12 +63,12 @@ export function SliderRow(props: SliderRowProps) { inactiveTrackColor: colors["--color-secondary"], }} onValueChange={(value) => { - dragging.current = true; draft.current = value; }} onValueChangeFinished={() => { - dragging.current = false; - commit(draft.current); + const value = draft.current; + draft.current = null; + if (value !== null) commit(value); }} /> diff --git a/apps/mobile/src/components/ui/SliderRow.tsx b/apps/mobile/src/components/ui/SliderRow.tsx index fcb837129da9..4e993363df06 100644 --- a/apps/mobile/src/components/ui/SliderRow.tsx +++ b/apps/mobile/src/components/ui/SliderRow.tsx @@ -6,7 +6,7 @@ import { tint, } from "@expo/ui/swift-ui/modifiers"; import * as Haptics from "expo-haptics"; -import { useEffect, useRef } from "react"; +import { useRef } from "react"; import { View } from "react-native"; import { AppText } from "./AppText"; @@ -19,11 +19,9 @@ export type { SliderRowProps } from "./SliderRow.types"; export function SliderRow(props: SliderRowProps) { const { colors } = useControlTheme(); - const draft = useRef(props.value); + // The value of the drag in progress; null until the drag moves. + const draft = useRef(null); const editing = useRef(false); - useEffect(() => { - if (!editing.current) draft.current = props.value; - }, [props.value]); const commit = (value: number) => { if (props.disabled) return; const next = Math.min( @@ -60,13 +58,15 @@ export function SliderRow(props: SliderRowProps) { accessibilityValue(props.valueLabel), ]} onValueChange={(value) => { - draft.current = value; // Accessibility adjustments do not begin a touch editing session. - if (!editing.current) commit(value); + if (editing.current) draft.current = value; + else commit(value); }} onEditingChanged={(isEditing) => { editing.current = isEditing; - if (!isEditing) commit(draft.current); + const value = draft.current; + draft.current = null; + if (!isEditing && value !== null) commit(value); }} /> diff --git a/apps/mobile/src/components/ui/Toast.tsx b/apps/mobile/src/components/ui/Toast.tsx index 2daa0873b65f..4426cee5552a 100644 --- a/apps/mobile/src/components/ui/Toast.tsx +++ b/apps/mobile/src/components/ui/Toast.tsx @@ -1,36 +1,71 @@ -import { MOBILE_SHAPE } from "../../lib/designTokens"; -import { useEffect } from "react"; -import { AccessibilityInfo, Platform, View, useWindowDimensions } from "react-native"; +import { MOBILE_MOTION, MOBILE_SHAPE } from "../../lib/designTokens"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { AccessibilityInfo, Platform, StyleSheet, View, useWindowDimensions } from "react-native"; +import { GestureDetector, usePanGesture } from "react-native-gesture-handler"; +import Animated, { + cancelAnimation, + Easing, + FadeOut, + ReduceMotion, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, + type EntryAnimationsValues, +} from "react-native-reanimated"; import { AppText } from "./AppText"; import { Button } from "./Button"; import { GlassSurface } from "./GlassSurface"; +import { useControlTheme } from "./useControlTheme"; import { IconButton } from "./IconButton"; import { AppActivityIndicator } from "./AppActivityIndicator"; import { SymbolView } from "./AppSymbol"; -export interface ToastProps { +export type ToastProps = { readonly message: string; readonly description?: string; + readonly singleLine?: boolean; readonly tone?: "default" | "success" | "danger"; readonly busy?: boolean; readonly action?: { readonly label: string; readonly onPress: () => void }; - readonly onDismiss?: () => void; -} +} & ( + | { readonly dismissMode: "swipe"; readonly onDismiss: () => void } + | { readonly dismissMode?: "button"; readonly onDismiss?: () => void } +); export function Toast(props: ToastProps) { + return props.dismissMode === "swipe" ? ( + + ) : ( + + ); +} + +function ToastContent(props: ToastProps & { readonly glassReady?: boolean }) { const { fontScale } = useWindowDimensions(); - // A one-line message keeps its action on the same row. Descriptions and - // accessibility text sizes need the full width, so the action moves below. - const inlineAction = !props.description && fontScale < 1.5; + const { colorScheme } = useControlTheme(); + const borderRadius = Platform.OS === "ios" ? 31 : MOBILE_SHAPE.toast; + // Explicit single-line notices reserve the action's width and truncate the text. + const inlineAction = props.singleLine || (!props.description && fontScale < 1.5); + const message = + props.singleLine && props.description + ? `${props.message}: ${props.description}` + : props.message; const action = props.action ? (