diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index b2f5443e431a1..66be80cd12312 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 5f6c75a0b77e5..1fdf70ae2df2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -420,7 +420,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/.gitignore b/.gitignore index 26b8965ecab8b..b33f3469c5345 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 b3ce2abb2c10d..16533186b6601 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,13 +134,13 @@ 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. - PRs target this fork (`TrogonStack/t3code`), never `pingdotgg/t3code`, unless the developer asks for the change to go upstream. -- 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. - UI changes need before/after images. Motion or timing needs a short video. @@ -195,6 +195,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ options, and pass combinators as additional function-builder arguments. - 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. +- 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 05d5a3254a84f..340cb23136441 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -382,7 +382,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 1e572412d4693..a244ff4b3fa7a 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"; @@ -297,7 +295,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 db56aa3c83b03..3cdfdb6cfa383 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 95d844e602124..2ae070d638263 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 7e7ab63a253c2..ab8ea0b8e9d6e 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -281,51 +281,6 @@ describe("DesktopObservability", () => { ), ); - it.effect("keeps operator resource attributes and its own service name on exports", () => { - 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, true, { - T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", - }); - - yield* Effect.scoped( - Effect.logInfo("desktop service name").pipe( - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - assert.lengthOf(requests, 1); - const body = requests[0]?.body ?? ""; - assert.include(body, '"stringValue":"t3code-desktop"'); - assert.include(body, '"key":"deployment.environment.name","value":{"stringValue":"staging"}'); - assert.include(body, '"key":"t3code.release.channel","value":{"stringValue":"dev"}'); - assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); - assert.include(body, '"key":"process.runtime.name","value":{"stringValue":"nodejs"}'); - assert.notInclude(body, "renamed"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - layerCollector(requests), - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - OTEL_SERVICE_NAME: "renamed", - OTEL_RESOURCE_ATTRIBUTES: - "service.name=renamed,service.namespace=renamed,deployment.environment.name=staging", - }, - }), - ), - ), - ), - ); - }); - it.effect("stops every export when the OpenTelemetry SDK is disabled", () => { const requests: Array = []; return Effect.gen(function* () { @@ -359,20 +314,20 @@ describe("DesktopObservability", () => { ); }); - it.effect("reports the app's own release identity for a non-development run", () => { + it.effect("reports the app's own version for an unpackaged desktop run", () => { 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, false); + const layerEnvironment = layerEnvironmentFor(baseDir); yield* writeObservabilitySettings(layerEnvironment, { otlpLogsUrl: "https://settings.example.com/v1/logs", }); yield* Effect.scoped( - Effect.logInfo("desktop identity under test").pipe( + Effect.logInfo("desktop resource under test").pipe( Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), ), ); @@ -380,16 +335,15 @@ describe("DesktopObservability", () => { const body = requests.map((request) => request.body).join("\n"); assert.include( body, - '"key":"deployment.environment.name","value":{"stringValue":"production"}', + `"key":"service.version","value":{"stringValue":"${desktopPackageJson.version}"}`, ); - assert.include(body, '"key":"t3code.release.channel","value":{"stringValue":"stable"}'); }).pipe( Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), ); }); - it.effect("reports the app's own version for an unpackaged desktop run", () => { + 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; @@ -408,10 +362,13 @@ describe("DesktopObservability", () => { ); const body = requests.map((request) => request.body).join("\n"); + assert.include(body, '"key":"process.runtime.name","value":{"stringValue":"nodejs"}'); assert.include( body, - `"key":"service.version","value":{"stringValue":"${desktopPackageJson.version}"}`, + `"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 cb5271ca98bc8..3cd3936a59bfc 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -2,12 +2,14 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { makeLocalFileTracer, makeTraceSink, - nodeProcessResourceAttributes, + 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, @@ -457,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( @@ -587,7 +590,9 @@ const layerBackendOutputLogFactory = Layer.effect( }); return DesktopBackendOutputLogFactory.of({ - forInstance: (id) => makeForId(id), + forInstance: Effect.fn("DesktopBackendOutputLogFactory.forInstance")(function (id) { + return makeForId(id); + }), }); }), ); @@ -605,12 +610,15 @@ const layerTelemetry = Layer.unwrap( serviceVersion, attributes: { "service.namespace": "t3code", - ...nodeProcessResourceAttributes({ - version: serviceVersion, - operatorAttributes: endpoints.resourceAttributes, - isDevelopment: environment.isDevelopment, - }), + "service.runtime": "desktop", + "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 9e87cfb7a9c02..9b5bc89148dea 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 f448f184b893f..ce63a710f7f7e 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/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 007f7369365a2..8b5e8045ed371 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 7b2f685190761..4292b92a3d0e4 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 7190c70aedc21..04f26b1bced84 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 8f0df22016169..4f9ab12eddc39 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -166,8 +166,8 @@ export const make = ElectronDialog.of({ }); return result.canceled ? [] : result.filePaths; }), - showMessageBox: (options, owner) => - Effect.tryPromise({ + showMessageBox: Effect.fn("ElectronDialog.showMessageBox")(function (options, owner) { + return Effect.tryPromise({ try: () => owner === undefined ? Electron.dialog.showMessageBox(options) @@ -181,9 +181,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({ @@ -191,7 +192,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 a58eb0c743fb0..d4338b45e280b 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -187,8 +187,8 @@ export const make = Effect.gen(function* () { }; return ElectronMenu.of({ - setApplicationMenu: (template) => - Effect.try({ + setApplicationMenu: Effect.fn("ElectronMenu.setApplicationMenu")(function (template) { + return Effect.try({ try: () => { Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); }, @@ -200,9 +200,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: () => @@ -218,9 +219,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); @@ -272,7 +274,8 @@ export const make = Effect.gen(function* () { ), ); } - }), + }); + }), }); }); diff --git a/apps/desktop/src/electron/ElectronPowerMonitor.ts b/apps/desktop/src/electron/ElectronPowerMonitor.ts index 41c36acf2250a..5a702b61a8111 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 0395d20e9bd38..9725fd41e1318 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 a72963c2e258a..e32dba36a6dfa 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -63,8 +63,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(() => @@ -73,16 +73,19 @@ 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)), + ); + }), + copyText: Effect.fn("ElectronShell.copyText")(function (text) { + return Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)); + }), }); export const layer = Layer.succeed(ElectronShell, make); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index a84eedda13b4e..ab0673b817ae2 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 397044932607b..c71b42040db29 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 60908ee90dc89..35303ed935aef 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -207,7 +207,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, @@ -235,13 +235,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; } @@ -249,9 +251,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; } @@ -259,9 +262,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; @@ -322,7 +326,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 b5fe707f4caee..ed821165b40f7 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -20,9 +20,13 @@ vi.mock("electron", () => ({ import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, getRelayTelemetryEnabled, + pickProjectFavicon, probeRemoteEditors, } from "./window.ts"; @@ -192,6 +196,78 @@ describe("getRelayTelemetryEnabled", () => { ); }); +describe("pickProjectFavicon", () => { + const pickerLayer = ( + pickFiles: () => Effect.Effect>, + settings?: DesktopAppSettings.DesktopSettings, + ) => + Layer.mergeAll( + Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeedNone, + }), + DesktopAppSettings.layerTest(settings), + ); + + it.effect("opens a single-image picker from the project directory", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon + .handler("/project") + .pipe(Effect.provide(pickerLayer(pickFiles))); + + assert.strictEqual(result, "/pictures/icon.png"); + assert.deepEqual(pickFiles.mock.calls, [ + [ + { + owner: Option.none(), + defaultPath: Option.some("/project"), + multiple: false, + filters: [ + { + name: "Images", + extensions: [ + "avif", + "bmp", + "gif", + "ico", + "heic", + "heif", + "jpeg", + "jpg", + "png", + "svg", + "tif", + "tiff", + "webp", + "icns", + ], + }, + ], + }, + ], + ]); + }), + ); + + it.effect("does not open a picker while the local environment is off", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon.handler("/project").pipe( + Effect.provide( + pickerLayer(pickFiles, { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }), + ), + ); + + assert.strictEqual(result, null); + assert.strictEqual(pickFiles.mock.calls.length, 0); + }), + ); +}); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "finds remote editors installed without PATH launchers", () => diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index ec314d1f9d478..1c1f9dc078ca0 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 82264cc4b870b..948bc361f5d92 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 50039c4e4b04e..08fefd8d733f5 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 849ae658a8219..30f252e3a677e 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 85fa6115477a0..f19079e46bce4 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 b9e0192ca8a7d..d0221e9be0e6e 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/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 832a774577149..0135225f90c48 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 433e8127a5098..5767057b4a35d 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 7c65c35cf0439..0836db6678226 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.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 9958fad3705ae..30edacb073c36 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -1718,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( @@ -1751,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 eac148ae4d0f3..f6c9ee43d7580 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 5265d65523ba2..3d6556ffae9a3 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 5017793d07357..36939729682ba 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 b3b0556f6b285..1b033082d58f9 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 f9af134ff22de..46b0539e5c97b 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 0000000000000..3cf77c4adf971 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 1dc70ba39f327..0000000000000 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 9406a8d7fcfff..0000000000000 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.45.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz new file mode 100644 index 0000000000000..2e076b177f3f8 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz differ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 15ae4a15ba4af..f694fc8d6ef26 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 d78fa318051e2..a5b8f7a44e3a6 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 388e12cb668c6..fa0fdcafa7c7c 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-source-text/src/SourceText.tsx b/apps/mobile/modules/t3-source-text/src/SourceText.tsx index aebac31d42c35..70bbb4fd48a64 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 2d40f97769732..a4123a555036a 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 cf99f02c028dc..b8c92f2d9332f 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 ab7d306e5960f..f4de25460d5c6 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 0000000000000..fde3393b911e4 --- /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 a3ac8f622c509..2f7445265886c 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 f407080371ec0..364880aaeb39b 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 0000000000000..b25c18cd0b659 --- /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], @@ -68,15 +74,25 @@ const appLinking = { (workspaceDetailLinkAction(state) as ReturnType) ?? getActionFromState(state, options), getStateFromPath: (path: string, options: Parameters[1]) => - restoreSettingsLinkState( - restoreDraftLinkState(getStateFromPath(path, options), path), - settingsLinkPage(), + takeScheduledTaskNewLink( + restoreSettingsLinkState( + restoreDraftLinkState(getStateFromPath(path, options), path), + 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(); @@ -119,7 +135,6 @@ function AppContent() { <> - diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 8a07f8b7a0867..83d075f34893e 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"; @@ -813,6 +816,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`, @@ -1067,6 +1077,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 ( } @@ -1077,6 +1088,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/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 598e3cf10d175..20c7485ef15d6 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -111,7 +111,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, @@ -268,18 +268,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 8dfbb5587b621..0098605414bd2 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -3,7 +3,7 @@ import { ComposerContextId } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import type { EnvironmentId } from "@t3tools/contracts"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; @@ -11,6 +11,7 @@ import { ComposerEditor as NativeComposerEditor } from "../native/T3ComposerEdit import type { ComposerEditorProps as NativeComposerEditorProps } from "../native/T3ComposerEditor"; import { appendComposerDraftAttachments, + composerContextImportsAtom, createComposerDraftContextHistory, getComposerDraftAfterSelection, getComposerDraftSnapshot, @@ -22,6 +23,8 @@ import { useComposerDraft, } from "../state/use-composer-drafts"; import { importComposerContextClipboard } from "../lib/composerContextClipboard"; +import { startComposerDraftOperation } from "../state/composerDraftOperations"; +import { appAtomRegistry } from "../state/atom-registry"; import { mobilePreferencesAtom } from "../state/preferences"; import { useOpenLinkedThread } from "../features/threads/useOpenLinkedThread"; import { ComposerContextSheet } from "./ComposerContextSheet"; @@ -73,32 +76,32 @@ export function ComposerEditor({ const [selected, setSelected] = useState<{ source: string; start: number; end: number } | null>( null, ); - const importRef = useRef(null); - const [importing, setImporting] = useState(false); - useEffect( - () => () => { - importRef.current?.abort(); - }, - [draftKey], - ); + const contextImports = useAtomValue(composerContextImportsAtom); + const importing = draftKey ? contextImports[draftKey] === true : false; const pasteContext = async ( clipboard: Parameters>[0], ) => { - if (!draftKey || importRef.current || props.readOnly || props.editable === false) return; + if ( + !draftKey || + appAtomRegistry.get(composerContextImportsAtom)[draftKey] || + props.readOnly || + props.editable === false + ) + return; const insertion = { text: clipboard.value, ...clipboard.selection }; - const controller = new AbortController(); - importRef.current = controller; - setImporting(true); + const operation = startComposerDraftOperation(draftKey); + const { signal } = operation; setComposerContextImporting(draftKey, true); const settled = await settlePromise(async () => { const retained = getComposerDraftAfterSelection(draftKey, insertion); const result = await importComposerContextClipboard( clipboard, retained.attachments.length, - controller.signal, + signal, retained.context?.records.length ?? 0, environmentId, ); + if (signal.aborted) return; if (!result) { insertComposerDraftText(draftKey, clipboard.text, insertion); return; @@ -125,10 +128,9 @@ export function ComposerEditor({ [...warnings, "Those references are marked unavailable."].join("\n\n"), ); }); + operation.finish(); setComposerContextImporting(draftKey, false); - importRef.current = null; - setImporting(false); - if (AsyncResult.isFailure(settled) && !controller.signal.aborted) { + if (AsyncResult.isFailure(settled) && !signal.aborted) { const error = squashAtomCommandFailure(settled); Dialog.alert( "Could not paste context", diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index fe2ebdfdfb25e..77116c8800126 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/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index 709fcb9685088..ea39cac15baff 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/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index ab43945dd73ea..3e0f09741266f 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -168,6 +168,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/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx index 2c95bdca396d2..0eec525abba3d 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 7feae6d930061..9b2b64c68c4bc 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 5149beb96de52..a1f6a02cc32eb 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,46 +48,43 @@ 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); const keyboardHeight = useKeyboardState((state) => state.height); - const close = useCallback(() => { + const close = () => { setAnchor(null); setPath([]); setOverlay(null); setRootHeight(null); - }, []); + }; - const open = useCallback(() => { - anchorRef.current?.measureInWindow((x, y, width, height) => { + const open = () => { + anchorNode?.measureInWindow((x, y, width, height) => { setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible }); }); - }, [keyboardVisible]); + }; - const measureOverlay = useCallback(() => { + const measureOverlay = () => { overlayRef.current?.measureInWindow((x, y, width, height) => { setOverlay({ x, y, width, height }); setRootHeight(height); }); - }, []); + }; - 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( @@ -125,31 +123,33 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480); const placeable = local !== null && rootHeight !== null; - const onPressItem = useCallback( - (action: MenuAction) => { - if ((action.subactions?.length ?? 0) > 0) { - setPath((current) => [...current, action]); - return; - } - close(); - if (action.id !== undefined) { - props.onPressAction?.({ - nativeEvent: { event: action.id }, - } as Parameters>[0]); - } - }, - [close, props.onPressAction], - ); + const onPressItem = (action: MenuAction) => { + if ((action.subactions?.length ?? 0) > 0) { + setPath((current) => [...current, action]); + return; + } + close(); + if (action.id !== undefined) { + props.onPressAction?.({ + nativeEvent: { event: action.id }, + } as Parameters>[0]); + } + }; 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 bc93254c0f35d..af05275b7f270 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,63 +33,78 @@ 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 = useCallback(() => { - setPresented(null); - if (presented?.kind === "alert") presented.request.options?.onDismiss?.(); - else presented?.request.onCancel?.(); - }, [presented]); + const handleCancel = () => { + if (!presented) return; + dismiss(presented.id); + if (presented.kind === "alert") presented.request.options?.onDismiss?.(); + else presented.request.onCancel?.(); + }; - const handleConfirm = useCallback( - (nativeInputValue?: string) => { - setPresented(null); - if (presented?.kind === "confirm") { - presented.request.onConfirm(); - } else if (presented?.kind === "text-input") { - presented.request.onConfirm(nativeInputValue ?? inputValue); - } - }, - [inputValue, presented], - ); + const handleConfirm = (nativeInputValue?: string) => { + if (!presented) return; + dismiss(presented.id); + if (presented.kind === "confirm") { + presented.request.onConfirm(); + } else if (presented.kind === "text-input") { + presented.request.onConfirm(nativeInputValue ?? presented.request.initialValue); + } + }; if (presented?.kind === "alert") { return ( @@ -103,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} @@ -120,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} /> @@ -212,7 +226,6 @@ function DialogPresentation(props: DialogPresentationProps) { singleLine value={inputState} selection={inputSelection} - onValueChange={props.onInputChange} textStyle={inputTypography} keyboardOptions={{ imeAction: "done" }} keyboardActions={{ @@ -267,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 05bf0532e460e..8af080fc19cc7 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 54fb0bec95f01..17757fe84b3d6 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 a7939cfa3075a..a4f07f46024f1 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 ad774efa21261..d9ab444f2cbf1 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 fcb837129da9f..4e993363df06a 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 2daa0873b65f4..4426cee5552af 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 ? (