diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 06fe9867b15ae..6fde6fff30984 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -21,6 +21,19 @@ Test with meaningful project and thread data. Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) only when inspecting or seeding SQLite. Stop the test server before direct fixture writes. +## Drive agent turns for free + +Do not spend a paid model to see a turn render. `vp run dev` registers the +`Replay (dev)` provider, which replays recorded turns with simulated streaming +through the real adapter and orchestration. Its models are scenarios: +`showcase` covers thinking, a plan, tool calls, an approval, and markdown. +Press `Ctrl+Alt+Shift+R` for a picker (GNOME takes that key for screen +recording), call `window.t3dev.replay("showcase")` +with `preview_evaluate`, or run +`node apps/server/src/bin.ts thread start --base-dir .t3 --provider devReplay --model showcase "go"`. +When the test needs real provider behavior, use that provider's cheapest +model, such as Haiku for Claude or Luna for Codex. + ## Use the Browser panel Call `preview_status`, then `preview_open` if the Browser panel is diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index f22a4010df94f..0b7d0182e6df7 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -27,7 +27,7 @@ export default defineConfig((ctx) => ({ instructions: "Hold changed code to the rules in docs/internals/web-ui.md.", }, { - path: "**/*.test.{ts,tsx}", + path: "**/*.test.*", instructions: "Hold changed tests to the rules in docs/internals/testing.md. Request tests only as those rules allow.", }, diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 13ca38fed4ad6..78a0156d1eaa1 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -11,8 +11,8 @@ provided alongside this playbook. Everything machine-specific lives there, not h ## 1. Ask what went wrong -Your first message to the user: ask them to describe what went wrong, in their own -words. Ask them to paste screenshots directly into this session if they have any. +Unless the user already described it, your first message asks what went wrong, in +their own words. Ask them to paste screenshots directly into this session if they have any. Ask follow-up questions when the description is vague. Good repro steps are the most valuable thing you can extract from this conversation. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc3b79002cb7e..ca294dd4a701a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,16 +225,6 @@ jobs: !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false - # Blacksmith boots GitHub's Ubuntu runner image (gcc is usually present), - # but ACP process-tree live tests compile a small pthread fixture with `cc` - # and soft-skip when it is missing. Install build-essential so that path - # always runs in CI instead of silently no-oping. - - name: Install C toolchain for process-tree fixtures - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends build-essential - command -v cc - - uses: ./.github/actions/setup-apt-mirrors # The desktop tests compile the browser secret helper. The install runs in diff --git a/.gitignore b/.gitignore index b33f3469c5345..b6b793e020ef1 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ apps/mobile/.android-cache/ /.generated/ artifacts/app-store/screenshots/ artifacts/mobile-e2e/ +artifacts/mobile-behavior/ .github/pr-assets/ native/**/target/ node_modules/ diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index f7cef3929ab10..f005f899595f5 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -1,7 +1,7 @@ --- title: Effect Service Conventions -model: gpt-6-sol -effort: max +model: gpt-6-1-sol +reasoning: low input: incremental tools: - browse_code diff --git a/.macroscope/check-run-agents/test-signal.md b/.macroscope/check-run-agents/test-signal.md new file mode 100644 index 0000000000000..5c5319aafb739 --- /dev/null +++ b/.macroscope/check-run-agents/test-signal.md @@ -0,0 +1,27 @@ +--- +title: Test Signal +model: gpt-6-1-terra +reasoning: low +input: incremental +tools: + - browse_code + - modify_pr +# An explicit include overrides ignore.md, which skips test files for every other check. +include: + - "**/*.test.*" +exclude: + - ".repos/**" +requires: + - Check +maxBudgetPerRun: 1 +maxBudgetPerPR: 5 +conclusion: neutral +--- + +# Test signal review + +Review only the tests this pull request adds or grows. Tests it moves, renames, or deletes are not findings. The rules are in `docs/internals/testing.md` under `## What to test`; read that section first and hold new tests to it. + +Post each finding as a brief inline comment on the new test with one fix. Ask only for fewer or smaller tests, never for more coverage. + +When there are no findings, make the entire final response exactly `All clear` on one line with nothing else. diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index ab37120ff344f..047dafbdd4be1 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -1,7 +1,7 @@ --- title: UI Consistency -model: gpt-6-sol -effort: max +model: gpt-6-1-sol +reasoning: low input: incremental tools: - browse_code diff --git a/AGENTS.md b/AGENTS.md index 1dbd367a6c1bb..e8eb6fd49b883 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,11 +102,13 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - While you iterate, run `vp test run `. For big files, add `-t ""`. - Use `vp run typecheck`, not `npx tsc`; it caches per package. Both run tsc-rs (`typescript` is the `@tsc-rs/typescript` drop-in). - Run `node -e` snippets from `apps/server` or the owning package. `effect` does not resolve from the repo root. -- Test meaningful logic or observable behavior. Do not render components to static markup to assert props or attributes, or add tests that merely assert callback wiring or mirror the implementation. +- Test behavior that can break, such as orchestration and event-sourcing, provider protocol translation, auth and permission guards, persistence and migrations, external-format parsing, race fixes, and the logic a bug fix changed. Leave out tests for what types already guarantee, trivial logic, copy text, smoke runs, mock-call mirrors, callback wiring, and props rendered to static markup. +- Extend before you add. When an existing test or table covers the behavior, add a row or an assertion there. Write a new `it` only when no case fits. +- Test at one layer: the lowest one that fails on `main`. Do not repeat the case in a wrapper test, at each call site, or in web and mobile copies of the same logic. - [Testing](docs/internals/testing.md) carries these same test rules, plus review guidance, for review bots. Change both together. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. -- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in the module's existing test file), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. +- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. - The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely: - `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures. - No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker. @@ -135,6 +137,8 @@ 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. +- Real agent turns cost money. To watch a turn stream through the pipeline and UI, use the free `Replay (dev)` provider that `vp run dev` registers; [test-t3-app](.agents/skills/test-t3-app/SKILL.md) shows how to start one. Use a real provider only when the change depends on that provider's behavior, and then pick its cheapest model, such as Haiku for Claude or Luna for Codex. + 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. ## Evals @@ -199,7 +203,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 -- `. +- 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/no-unreasoned-state-sync` lint rule reports every effect; one that stays states its reason with `// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- `. - 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/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index a244ff4b3fa7a..34b7286c335ed 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -156,6 +156,7 @@ describe("DesktopClerk", () => { `setPath:userData:${userData}`, "requestSingleInstanceLock", "on:second-instance", + "on:open-url", "createClerkBridge", ]); }, @@ -200,7 +201,7 @@ describe("DesktopClerk", () => { }, ); - it.effect("delivers queued startup links and later links once in arrival order", () => { + it.effect("delivers each queued startup link once, before later links", () => { storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); const listeners = new Map void>(); @@ -208,6 +209,7 @@ describe("DesktopClerk", () => { const first = "t3code://new?prompt=first"; const second = "t3code://threads/env-1/thread-1"; const later = "t3code://new?prompt=later"; + const launchPair = "t3code://pair?pairingUrl=https%3A%2F%2Fvps.example.com%2F%23token%3Dabc"; const delivered = Promise.withResolvers(); const electronApp = { on: (name: string, listener: (...args: unknown[]) => void) => @@ -220,13 +222,14 @@ describe("DesktopClerk", () => { const clerk = yield* DesktopClerk.DesktopClerk; listeners.get("second-instance")?.({}, ["t3", first]); listeners.get("second-instance")?.({}, ["t3", second]); + listeners.get("open-url")?.({ preventDefault: () => undefined }, launchPair); assert.deepEqual(received, []); yield* clerk.configure; listeners.get("second-instance")!({}, ["t3", later]); yield* Effect.promise(() => delivered.promise); - assert.deepEqual(received, [first, second, later]); + assert.deepEqual(received, [launchPair, first, second, later]); }).pipe( Effect.scoped, Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), @@ -425,6 +428,7 @@ it.effect("hands web and app links to the renderer and leaves other links alone" }), } as unknown as ElectronApp.ElectronApp["Service"]; const received: Array = []; + const pairLink = "t3code://pair?pairingUrl=https%3A%2F%2Fvps.example.com%2F%23token%3Dabc"; yield* Effect.gen(function* () { const clerk = yield* DesktopClerk.DesktopClerk; yield* clerk.configure; @@ -437,6 +441,8 @@ it.effect("hands web and app links to the renderer and leaves other links alone" assert.strictEqual(open("http://localhost:3000/"), 1); assert.strictEqual(open("t3code://new?prompt=hi"), 1); assert.strictEqual(open("t3code://threads/env-1/thread-1"), 1); + assert.strictEqual(open(pairLink), 1); + assert.strictEqual(open("t3code://pair"), 0); assert.strictEqual(open("mailto:hello@example.com"), 0); assert.strictEqual(open("t3code://somewhere-else"), 0); yield* Effect.yieldNow; @@ -445,6 +451,7 @@ it.effect("hands web and app links to the renderer and leaves other links alone" "http://localhost:3000/", "t3code://new?prompt=hi", "t3code://threads/env-1/thread-1", + pairLink, ]); }).pipe( Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 3e0d753d1bfba..992e1fe056937 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -13,6 +13,7 @@ import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCal import * as ElectronShell from "../electron/ElectronShell.ts"; import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; +import { readPairingAppLink } from "@t3tools/shared/remote"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -118,6 +119,15 @@ export const make = Effect.gen(function* () { yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { handleSecondInstance(argv); }); + // macOS hands a launch link to open-url, which can fire before configure runs. + const pendingOpenUrls: Array = []; + let handleOpenUrl = (url: string) => { + pendingOpenUrls.push(url); + return true; + }; + yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { + if (handleOpenUrl(url)) event.preventDefault(); + }); yield* Effect.acquireRelease( Effect.try({ @@ -194,7 +204,8 @@ export const make = Effect.gen(function* () { return true; }; const openAppLink = (value: string | undefined) => { - if (!value || promptLinkAppPath(value) === null) return false; + if (!value || (promptLinkAppPath(value) === null && readPairingAppLink(value) === null)) + return false; void runPromise(webLinks.receive(value)); return true; }; @@ -205,15 +216,12 @@ export const make = Effect.gen(function* () { void runPromise(webLinks.receive(url)); return true; }; - yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { - if ( - startProviderAuthHandoff(url) || - resumeProviderAuth(url) || - openWebLink(url) || - openAppLink(url) - ) - event.preventDefault(); - }); + handleOpenUrl = (url) => + startProviderAuthHandoff(url) || + resumeProviderAuth(url) || + openWebLink(url) || + openAppLink(url); + for (const url of pendingOpenUrls.splice(0)) handleOpenUrl(url); yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => { if (!DesktopWebLinks.isWebPageFile(path)) return; event.preventDefault(); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 0b446f8ada300..8b4fc23a99335 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -100,7 +100,7 @@ const withStore = ( }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopConnectionCatalogStore", () => { - it.effect("persists, reads, and clears an encrypted connection catalog", () => + it.effect("persists and reads an encrypted connection catalog", () => withStore( Effect.gen(function* () { const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; @@ -108,9 +108,6 @@ describe("DesktopConnectionCatalogStore", () => { assert.isTrue(yield* store.set(catalog)); assert.deepStrictEqual(yield* store.get, Option.some(catalog)); - - yield* store.clear; - assert.deepStrictEqual(yield* store.get, Option.none()); }), ), ); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index a9020c36d1c89..ceb3f293550bf 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -163,7 +163,6 @@ export class DesktopConnectionCatalogStore extends Context.Service< boolean, DesktopConnectionCatalogStoreWriteError | DesktopConnectionCatalogStoreProtectionError >; - readonly clear: Effect.Effect; } >()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {} @@ -503,15 +502,6 @@ export const make = Effect.gen(function* () { yield* writeCatalog(catalog); return true; }), - clear: fileSystem.remove(catalogPath, { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("Could not clear the desktop connection catalog.", { - catalogPath, - error, - }), - ), - Effect.withSpan("desktop.connectionCatalogStore.clear"), - ), }); }); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index ca69f5d025899..492903139547b 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -74,7 +74,6 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_NO_BROWSER", "T3CODE_HOST", "T3CODE_DESKTOP_WS_URL", - "T3CODE_DESKTOP_LAN_ACCESS", "T3CODE_DESKTOP_LAN_HOST", "T3CODE_DESKTOP_HTTPS_ENDPOINTS", "T3CODE_TAILSCALE_SERVE", @@ -86,6 +85,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. "T3CODE_OTEL_SDK_DISABLED", "OTEL_SDK_DISABLED", diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 2d67aa56e6ccd..35333ac3d33a4 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -4,11 +4,7 @@ import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./metho import * as DesktopIpc from "./DesktopIpc.ts"; import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; -import { - clearConnectionCatalog, - getConnectionCatalog, - setConnectionCatalog, -} from "./methods/connectionCatalog.ts"; +import { getConnectionCatalog, setConnectionCatalog } from "./methods/connectionCatalog.ts"; import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled, @@ -26,7 +22,6 @@ import { ensureSshEnvironment, fetchSshEnvironmentDescriptor, fetchSshSessionState, - issueSshWebSocketTicket, resolveSshHost, resolveSshPasswordPrompt, } from "./methods/sshEnvironment.ts"; @@ -120,7 +115,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(dismissSnapShotAnimation); yield* ipc.handle(acknowledgeSnapShot); yield* ipc.handle(setConnectionCatalog); - yield* ipc.handle(clearConnectionCatalog); yield* ipc.handle(discoverSshHosts); yield* ipc.handle(resolveSshHost); @@ -129,7 +123,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(fetchSshEnvironmentDescriptor); yield* ipc.handle(bootstrapSshBearerSession); yield* ipc.handle(fetchSshSessionState); - yield* ipc.handle(issueSshWebSocketTicket); yield* ipc.handle(resolveSshPasswordPrompt); yield* ipc.handle(getServerExposureState); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 9b11a2283a273..bba4f6449b8c1 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -55,7 +55,6 @@ export const DISMISS_SNAP_SHOT_ANIMATION_CHANNEL = "desktop:dismiss-snap-shot-an export const ACKNOWLEDGE_SNAP_SHOT_CHANNEL = "desktop:acknowledge-snap-shot"; export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; -export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; export const RESOLVE_SSH_HOST_CHANNEL = "desktop:resolve-ssh-host"; export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; @@ -63,7 +62,6 @@ export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-enviro export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; -export const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; @@ -110,7 +108,6 @@ export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-fra export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; -export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; diff --git a/apps/desktop/src/ipc/methods/connectionCatalog.ts b/apps/desktop/src/ipc/methods/connectionCatalog.ts index 4e51496a6379a..0f913d0f300a3 100644 --- a/apps/desktop/src/ipc/methods/connectionCatalog.ts +++ b/apps/desktop/src/ipc/methods/connectionCatalog.ts @@ -25,13 +25,3 @@ export const setConnectionCatalog = DesktopIpc.makeIpcMethod({ return yield* store.set(catalog); }), }); - -export const clearConnectionCatalog = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL, - payload: Schema.Void, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.connectionCatalog.clear")(function* () { - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - yield* store.clear; - }), -}); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index c6c2cd8fbdf9a..20b7bb4877d59 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -47,9 +47,6 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribeStateChanges((tabId, state) => electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state), ); - yield* manager.subscribeRecordingFrames((frame) => - electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), - ); yield* manager.subscribeRecordingInputs((event) => electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, event), ); diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 099b97ce7bf31..15e04f71282b1 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -1,7 +1,6 @@ import { bootstrapRemoteBearerSession, fetchRemoteSessionState, - issueRemoteWebSocketTicket, RemoteEnvironmentAuthUndeclaredStatusError, type RemoteEnvironmentAuthError, } from "@t3tools/client-runtime/authorization"; @@ -24,7 +23,6 @@ import { EnvironmentScopeRequiredError, AuthAccessTokenResult, AuthSessionState, - AuthWebSocketTicketResult, } from "@t3tools/contracts"; import { SshHttpBridgeError } from "@t3tools/ssh/errors"; import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel"; @@ -40,8 +38,7 @@ import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts. type DesktopSshEnvironmentRequestOperation = | "fetch-environment-descriptor" | "bootstrap-bearer-session" - | "fetch-session-state" - | "issue-websocket-ticket"; + | "fetch-session-state"; type DesktopSshEnvironmentRequestCause = RemoteEnvironmentAuthError | SshHttpBridgeError; @@ -203,23 +200,6 @@ export const fetchSshSessionState = DesktopIpc.makeIpcMethod({ }), }); -export const issueSshWebSocketTicket = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, - payload: DesktopSshBearerRequestInputSchema, - result: AuthWebSocketTicketResult, - handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketTicket")(function* ({ - httpBaseUrl, - bearerToken, - }) { - return yield* withLoopbackSshApi("issue-websocket-ticket", (resolvedHttpBaseUrl) => - issueRemoteWebSocketTicket({ - httpBaseUrl: resolvedHttpBaseUrl, - bearerToken, - }), - )(httpBaseUrl); - }), -}); - export const resolveSshPasswordPrompt = DesktopIpc.makeIpcMethod({ channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, payload: DesktopSshPasswordPromptResolutionInputSchema, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4d57b30e2139a..a3e1e711cb2d3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -3,7 +3,6 @@ import type { DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, - DesktopPreviewRecordingFrame, DesktopPreviewTabState, DesktopSnapShotEvent, } from "@t3tools/contracts"; @@ -139,7 +138,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), setConnectionCatalog: (catalog) => ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), - clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), resolveSshHost: (alias) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_HOST_CHANNEL, alias), ensureSshEnvironment: async (target, options) => @@ -160,8 +158,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { }), fetchSshSessionState: (httpBaseUrl, bearerToken) => ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), - issueSshWebSocketTicket: (httpBaseUrl, bearerToken) => - ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), onSshPasswordPrompt: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { if (typeof request !== "object" || request === null) return; @@ -398,15 +394,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { mimeType, data, }), - onFrame: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { - if (typeof frame !== "object" || frame === null) return; - listener(frame as DesktopPreviewRecordingFrame); - }; - ipcRenderer.on(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); - return () => - ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); - }, }, onStateChange: (listener) => { const wrappedListener = ( diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index d9b797dd0451f..22af4a829e049 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -168,12 +168,7 @@ describe("BrowserSession", () => { return granted; }; - for (const permission of [ - "clipboard-read", - "clipboard-sanitized-write", - "geolocation", - "fullscreen", - ]) { + for (const permission of ["clipboard-sanitized-write", "geolocation", "fullscreen"]) { assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); assert.isTrue( checkHandler(null, permission) as boolean, @@ -226,6 +221,55 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("asks once per origin before a page reads the clipboard", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const browserSession = sessions.get(partition); + assert.isDefined(browserSession); + const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0]; + const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0]; + assert.isFunction(requestHandler); + assert.isFunction(checkHandler); + + const readClipboard = (requestingUrl: string) => + Effect.callback((resume) => { + requestHandler( + null, + "clipboard-read", + (granted: boolean) => resume(Effect.succeed(granted)), + { requestingUrl, isMainFrame: true }, + ); + }); + const checkClipboard = (origin: string): boolean => + checkHandler(null, "clipboard-read", origin, {}) as boolean; + + showMessageBox.mockReset(); + showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1); + + assert.isFalse(checkClipboard("https://allowed.test")); + assert.isTrue(yield* readClipboard("https://allowed.test/page")); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + assert.strictEqual( + showMessageBox.mock.calls[0]?.[0].message, + "https://allowed.test wants to see text and images copied to the clipboard", + ); + assert.deepStrictEqual(showMessageBox.mock.calls[0]?.[0].buttons, ["Allow", "Block"]); + // Enter must not accept a prompt the page raised while the user was typing. + assert.strictEqual(showMessageBox.mock.calls[0]?.[0].defaultId, 1); + + assert.isTrue(yield* readClipboard("https://allowed.test/other")); + assert.isTrue(checkClipboard("https://allowed.test")); + + assert.isFalse(yield* readClipboard("https://blocked.test/")); + assert.isFalse(yield* readClipboard("https://blocked.test/again")); + assert.isFalse(checkClipboard("https://blocked.test")); + assert.isTrue(checkClipboard("https://allowed.test")); + assert.strictEqual(showMessageBox.mock.calls.length, 2); + }).pipe(Effect.provide(layer)), + ); + it.effect("clears a partition whose session has not been opened yet", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 0f900d521b739..2ea7efdb88ee0 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -20,7 +20,6 @@ export type BrowserSessionPartitionNamespace = "profile"; // Electron's clipboard API uses clipboard-sanitized-write and checks both permission handlers. const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ - "clipboard-read", "clipboard-sanitized-write", "geolocation", // DesktopWindow confines HTML fullscreen to the guest's webview. @@ -55,6 +54,19 @@ const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => const MAX_PROMPT_URL_LENGTH = 300; +const truncateForPrompt = (text: string): string => + text.length > MAX_PROMPT_URL_LENGTH ? `${text.slice(0, MAX_PROMPT_URL_LENGTH)}…` : text; + +const permissionOrigin = (rawUrl: string | undefined): string | null => { + if (!rawUrl) return null; + try { + const { origin } = new URL(rawUrl); + return origin === "null" ? null : origin; + } catch { + return null; + } +}; + export class BrowserSessionPartitionDerivationError extends Schema.TaggedError()( "BrowserSessionPartitionDerivationError", { @@ -175,47 +187,70 @@ export const make = Effect.gen(function* BrowserSessionMake() { const electronDialog = yield* ElectronDialog.ElectronDialog; const runFork = Effect.runForkWith(yield* Effect.context()); // One prompt at a time, so a page cannot stack dialogs by looping a deep link. - let externalPromptOpen = false; - /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ - const confirmOpenExternal = ( + let permissionPromptOpen = false; + /** Settles with `undefined` when the user gave no answer: another prompt was open or the dialog failed. */ + const promptPermission = ( requester: WebContents | null, - rawUrl: string | undefined, - callback: (granted: boolean) => void, + prompt: { + readonly buttons: readonly [allow: string, deny: string]; + readonly message: string; + readonly detail?: string; + }, + callback: (answer: boolean | undefined) => void, ): void => { - const url = externalProtocolPromptUrl(rawUrl); - if (url === null || externalPromptOpen) { - callback(false); + if (permissionPromptOpen) { + callback(undefined); return; } - externalPromptOpen = true; - const settle = (granted: boolean) => + permissionPromptOpen = true; + const settle = (answer: boolean | undefined) => Effect.sync(() => { - externalPromptOpen = false; - callback(granted); + permissionPromptOpen = false; + callback(answer); }); - const shownUrl = - url.length > MAX_PROMPT_URL_LENGTH ? `${url.slice(0, MAX_PROMPT_URL_LENGTH)}…` : url; runFork( electronDialog .showMessageBox( { type: "question", - buttons: ["Open", "Cancel"], - defaultId: 0, + buttons: [...prompt.buttons], + // Default to deny so an Enter typed into the page cannot accept a prompt it raised. + defaultId: 1, cancelId: 1, - message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, - detail: `A page in the browser wants to open another application:\n\n${shownUrl}`, + message: prompt.message, + ...(prompt.detail === undefined ? {} : { detail: prompt.detail }), }, previewHostWindow(requester), ) .pipe( - Effect.map(({ response }) => response === 0), - Effect.orElseSucceed(() => false), + Effect.map(({ response }): boolean | undefined => response === 0), + Effect.orElseSucceed(() => undefined), Effect.flatMap(settle), - Effect.onInterrupt(() => settle(false)), + Effect.onInterrupt(() => settle(undefined)), ), ); }; + /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ + const confirmOpenExternal = ( + requester: WebContents | null, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const url = externalProtocolPromptUrl(rawUrl); + if (url === null) { + callback(false); + return; + } + promptPermission( + requester, + { + buttons: ["Open", "Cancel"], + message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, + detail: `A page in the browser wants to open another application:\n\n${truncateForPrompt(url)}`, + }, + (answer) => callback(answer === true), + ); + }; const sessionsRef = yield* SynchronizedRef.make>(new Map()); const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( @@ -248,6 +283,35 @@ export const make = Effect.gen(function* BrowserSessionMake() { return Effect.try({ try: () => { const browserSession = session.fromPartition(partition); + // Like Chrome, clipboard reads ask once per origin; both answers last for the session. + const clipboardReadDecisions = new Map(); + const confirmClipboardRead = ( + requester: WebContents, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const origin = permissionOrigin(rawUrl); + if (origin === null) { + callback(false); + return; + } + const remembered = clipboardReadDecisions.get(origin); + if (remembered !== undefined) { + callback(remembered); + return; + } + promptPermission( + requester, + { + buttons: ["Allow", "Block"], + message: `${truncateForPrompt(origin)} wants to see text and images copied to the clipboard`, + }, + (answer) => { + if (answer !== undefined) clipboardReadDecisions.set(origin, answer); + callback(answer === true); + }, + ); + }; // Rewriting Electron's native User-Agent makes Turnstile fail integrity checks with error 600010. browserSession.setPermissionRequestHandler( (webContents, permission, callback, details) => { @@ -259,11 +323,21 @@ export const make = Effect.gen(function* BrowserSessionMake() { ); return; } + if (permission === "clipboard-read") { + confirmClipboardRead( + webContents, + "requestingUrl" in details ? details.requestingUrl : undefined, + callback, + ); + return; + } callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); }, ); - browserSession.setPermissionCheckHandler((_webContents, permission) => - ALLOWED_PREVIEW_PERMISSIONS.has(permission), + browserSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => + permission === "clipboard-read" + ? clipboardReadDecisions.get(permissionOrigin(requestingOrigin) ?? "") === true + : ALLOWED_PREVIEW_PERMISSIONS.has(permission), ); const next = new Map(sessions); next.set(partition, browserSession); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index d0221e9be0e6e..036c0d4afd932 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -359,7 +359,6 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { }; type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect; -type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect; type PreviewInputSignal = | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } @@ -536,9 +535,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const recordingInputListenersRef = yield* Ref.make>( new Set(), ); - const recordingFrameListenersRef = yield* Ref.make>( - new Set(), - ); const pickSessionsRef = yield* Ref.make>(new Map()); const controlSessionsRef = yield* SynchronizedRef.make< ReadonlyMap @@ -801,12 +797,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: - | "state-change" - | "recording-frame" - | "recording-input" - | "pointer-event" - | "open-link", + eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link", tabId: string, delivery: () => Effect.Effect, ) => @@ -1077,35 +1068,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }), ).pipe(Effect.ignore); } - const tabId = yield* tabIdForWebContents(wc.id); - const metadata = - typeof params["metadata"] === "object" && params["metadata"] !== null - ? (params["metadata"] as Record) - : {}; - if (tabId && typeof params["data"] === "string") { - const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get( - tabId, - ); - if (captureSession?.consumers.has("recording")) { - const receivedAt = yield* currentIso; - const listeners = yield* Ref.get(recordingFrameListenersRef); - const frame: DesktopPreviewRecordingFrame = { - tabId, - data: params["data"], - width: - typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, - height: - typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, - receivedAt, - }; - yield* Effect.forEach( - listeners, - (listener) => - deliverEvent("recording-frame", frame.tabId, () => listener(frame)), - { discard: true }, - ); - } - } } if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") { yield* Queue.offer(consoleReleases, undefined); @@ -1505,7 +1467,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); if (forwarded && host) { event.preventDefault(); - if (!input.isAutoRepeat) host.send(MENU_ACTION_CHANNEL, forwarded.command); + if (!input.isAutoRepeat) { + if ( + forwarded.command === "workspace.focusNext" || + forwarded.command === "workspace.focusPrevious" + ) { + // DOM focus in the renderer cannot by itself release the guest's native keyboard focus. + host.focus(); + } + host.send(MENU_ACTION_CHANNEL, forwarded.command); + } return; } if (isPreviewRefreshShortcut(input)) { @@ -2517,78 +2488,66 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function height: size.height, receivedAt, }; - const deliveries: Array> = []; - if (pictureInPicture) { - const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( - tabId, - )?.window; - if (pictureInPictureWindow && !pictureInPictureWindow.isDestroyed()) { - deliveries.push( - Effect.gen(function* () { - const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get( - tabId, - ); - const aspectRatio = frame.width / frame.height; - if ( - previousAspectRatio === undefined || - Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON - ) { - yield* attempt( - { - operation: "pictureInPicture.setAspectRatio", - tabId, - webContentsId: wc.id, - }, - () => { - const contentSize = fitPictureInPictureContentSize( - pictureInPictureWindow.getContentSize(), - aspectRatio, - ); - pictureInPictureWindow.setAspectRatio(0); - pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); - pictureInPictureWindow.setAspectRatio(aspectRatio); - }, - ); - yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => - replaceMap(aspectRatios, (copy) => { - copy.set(tabId, aspectRatio); - }), - ); - } - yield* attempt( - { - operation: "pictureInPicture.deliverFrame", - tabId, - webContentsId: wc.id, - }, - () => { - pictureInPictureWindow.webContents.send( - PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, - frame, - ); - }, + const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( + tabId, + )?.window; + if (!pictureInPictureWindow || pictureInPictureWindow.isDestroyed()) return; + yield* Effect.gen(function* () { + const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get(tabId); + const aspectRatio = frame.width / frame.height; + if ( + previousAspectRatio === undefined || + Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON + ) { + yield* attempt( + { + operation: "pictureInPicture.setAspectRatio", + tabId, + webContentsId: wc.id, + }, + () => { + const contentSize = fitPictureInPictureContentSize( + pictureInPictureWindow.getContentSize(), + aspectRatio, ); - yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { - if (sessions.get(tabId) !== frameSession) return sessions; - return replaceMap(sessions, (copy) => { - copy.set(tabId, { - ...frameSession, - lastPictureInPictureFingerprint: fingerprint, - }); - }); - }); - }).pipe( - Effect.catch((error) => - Effect.logWarning("Picture-in-picture frame delivery failed.", { - tabId, - error, - }), - ), - ), + pictureInPictureWindow.setAspectRatio(0); + pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); + pictureInPictureWindow.setAspectRatio(aspectRatio); + }, + ); + yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => + replaceMap(aspectRatios, (copy) => { + copy.set(tabId, aspectRatio); + }), ); } - } - yield* Effect.all(deliveries, { concurrency: 2, discard: true }); + yield* attempt( + { + operation: "pictureInPicture.deliverFrame", + tabId, + webContentsId: wc.id, + }, + () => { + pictureInPictureWindow.webContents.send(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, frame); + }, + ); + yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { + if (sessions.get(tabId) !== frameSession) return sessions; + return replaceMap(sessions, (copy) => { + copy.set(tabId, { + ...frameSession, + lastPictureInPictureFingerprint: fingerprint, + }); + }); + }); + }).pipe( + Effect.catch((error) => + Effect.logWarning("Picture-in-picture frame delivery failed.", { + tabId, + error, + }), + ), + ); }); const startFrameCapture = Effect.fn("PreviewManager.startFrameCapture")(function* ( @@ -3306,7 +3265,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Ref.set(listenersRef, new Set()), Ref.set(pointerEventListenersRef, new Set()), Ref.set(openLinkListenersRef, new Set()), - Ref.set(recordingFrameListenersRef, new Set()), Ref.set(recordingInputListenersRef, new Set()), ], { discard: true }, @@ -3354,8 +3312,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener), subscribeRecordingInputs: (listener: RecordingInputListener) => subscribe(recordingInputListenersRef, listener), - subscribeRecordingFrames: (listener: RecordingFrameListener) => - subscribe(recordingFrameListenersRef, listener), subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), zoomIn: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "in")), zoomOut: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "out")), @@ -3597,9 +3553,6 @@ export class PreviewManager extends Context.Service< readonly subscribeRecordingInputs: ( listener: RecordingInputListener, ) => Effect.Effect; - readonly subscribeRecordingFrames: ( - listener: RecordingFrameListener, - ) => Effect.Effect; } >()("@t3tools/desktop/preview/Manager/PreviewManager") {} @@ -3733,7 +3686,6 @@ export const make = Effect.gen(function* PreviewManagerMake() { subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, subscribeOpenLinks: operations.subscribeOpenLinks, - subscribeRecordingFrames: operations.subscribeRecordingFrames, subscribeRecordingInputs: operations.subscribeRecordingInputs, }); }).pipe(Effect.withSpan("PreviewManager.make")); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 51c3d31daae85..9f76003c19c8d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -35,7 +35,6 @@ const clientSettings: ClientSettings = { confirmThreadUnpin: false, contextWindowMeterEnabled: false, composerCollapseOnScroll: true, - dismissedProviderUpdateNotificationKeys: [], diffFilesCollapsed: true, diffIgnoreWhitespace: true, diffLayout: "stacked", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index c80fe9b87cd6a..25c37ecefc051 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -95,6 +95,7 @@ const LOGIN_SHELL_ENV_NAMES = [ "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", "__MISE_DIFF", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; @@ -450,6 +451,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", ] as const) { if (!config.env[name] && shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 30edacb073c36..76daee09c2aeb 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -605,7 +605,7 @@ function createSnapShotFlashWindow(bounds: Electron.Rectangle): Electron.BaseWin return window; } -export class SnapShotFlash { +class SnapShotFlash { private flashWindow: Electron.BaseWindow | undefined; private animationTimer: ReturnType | undefined; private closeTimer: ReturnType | undefined; diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index 26ae8fbf9ebe9..7c63a8b9aadef 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -8,8 +8,6 @@ import { accessibleWindowText, compactAccessibilityTree, findAccessibleWindow, - findCaptureSource, - hideAndWaitForBlur, isWaylandSession, } from "./snapShot.ts"; @@ -450,68 +448,6 @@ describe("findAccessibleWindow", () => { }); }); -describe("hideAndWaitForBlur", () => { - it("waits for a delayed blur after hiding the window", async () => { - let blur: (() => void) | undefined; - let settled = false; - const hidden = hideAndWaitForBlur({ - hide: () => undefined, - once: (_event, listener) => { - blur = listener; - }, - removeListener: () => undefined, - }).then(() => { - settled = true; - }); - - await Promise.resolve(); - expect(settled).toBe(false); - blur?.(); - await hidden; - expect(settled).toBe(true); - }); -}); - -describe("findCaptureSource", () => { - const sources = [ - { id: "window:42:0", name: "Terminal" }, - { id: "window:84:0", name: "Editor" }, - ]; - - it("matches the native window id before its title", () => { - expect( - findCaptureSource(sources, { - id: 84, - title: "Changed title", - }), - ).toEqual(sources[1]); - }); - - it("falls back to a unique title match", () => { - expect( - findCaptureSource(sources, { - id: 100, - title: "Terminal", - }), - ).toEqual(sources[0]); - }); - - it("does not guess when a title is ambiguous", () => { - expect( - findCaptureSource( - [ - { id: "window:42:0", name: "Editor" }, - { id: "window:84:0", name: "Editor" }, - ], - { - id: 100, - title: "Editor", - }, - ), - ).toBeUndefined(); - }); -}); - describe("isWaylandSession", () => { effectIt.effect( "falls back to a live runtime directory socket when session variables are stripped", diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 28bd301c557e6..872f99e1d6a72 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -23,7 +23,6 @@ interface AccessibilityTreeNode { } const MAX_ACCESSIBILITY_TREE_NODES = 10_000; -const WINDOW_BLUR_TIMEOUT_MS = 1_000; export const WINDOWS_MODIFIER_PAIR_VIRTUAL_KEYS: Record< SnapShotModifier, @@ -438,25 +437,6 @@ export async function accessibleWindowElementTree( return { root: compacted.root, truncated: completedTruncated }; } -export function hideAndWaitForBlur(window: { - readonly hide: () => void; - readonly once: (event: "blur", listener: () => void) => unknown; - readonly removeListener: (event: "blur", listener: () => void) => unknown; -}): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - window.removeListener("blur", onBlur); - reject(new Error("Timed out waiting for T3 Code to lose focus.")); - }, WINDOW_BLUR_TIMEOUT_MS); - const onBlur = () => { - clearTimeout(timeout); - resolve(); - }; - window.once("blur", onBlur); - window.hide(); - }); -} - type WindowBounds = { readonly x: number; readonly y: number; @@ -591,30 +571,6 @@ export function toElectronAccelerator(shortcut: SnapShotKeyChord): string { return parts.join("+"); } -interface CaptureSourceLike { - readonly id: string; - readonly name: string; -} - -interface ActiveWindowLike { - readonly id: number; - readonly title: string; -} - -export function findCaptureSource( - sources: readonly T[], - activeWindow: ActiveWindowLike, -): T | undefined { - const idPrefix = `window:${activeWindow.id}:`; - const idMatch = sources.find((source) => source.id.startsWith(idPrefix)); - if (idMatch) return idMatch; - - const title = activeWindow.title.trim(); - if (!title) return undefined; - const titleMatches = sources.filter((source) => source.name.trim() === title); - return titleMatches.length === 1 ? titleMatches[0] : undefined; -} - export function isWaylandSession( platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 3d6556ffae9a3..aeda73f2bfad3 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -156,8 +156,6 @@ export class DesktopUpdateUnexpectedActionError extends Schema.TaggedError; readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; - readonly configure: Effect.Effect; + readonly configure: Effect.Effect; readonly setChannel: ( channel: DesktopUpdateChannel, ) => Effect.Effect; diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 36939729682ba..ab52ca07a0d59 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -437,10 +437,8 @@ cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 // it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at // launch (which, in wsl-only mode, would just fail to launch with no fallback). // -// The sentinel must be a package the CLI bundle leaves external. It used to be -// "effect", back when the bundle externalized its runtime deps and the whole -// node_modules tree was unpacked. The bundle now inlines its JS dependencies, -// so "effect" no longer exists on disk and only the native packages do. +// The sentinel must be a package the CLI bundle leaves external. The bundle +// inlines its JS dependencies, so only the native packages exist on disk. // Resolving node-pty is what actually validates the unpacked tree. try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); } const fs = require("node:fs"); diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp deleted file mode 100644 index a05a5738a6cd7..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp deleted file mode 100644 index 1a16b037cf0b0..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp deleted file mode 100644 index dd72a9110265c..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp deleted file mode 100644 index 3aa1c274507b9..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp deleted file mode 100644 index e2510c829bb15..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp deleted file mode 100644 index f3ba3edb51909..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp deleted file mode 100644 index 8971323e7af55..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp and /dev/null differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp deleted file mode 100644 index aa535e600a573..0000000000000 Binary files a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp and /dev/null differ diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 980da9c83c33a..6ea260eae4d9e 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -425,7 +425,7 @@ const config: ExpoConfig = { ["expo-sensors", { motionPermission: false }], // Mods run in reverse order; expo-widgets must create and wipe the target before assets are added. ...(!isIosPersonalTeamBuild ? ["./plugins/withWidgetLogoAsset.cjs", widgetsPlugin] : []), - "./plugins/withAndroidCleartextTraffic.cjs", + "./plugins/withAndroidNetworkSecurityConfig.cjs", "./plugins/withAndroidGradleHeap.cjs", "./plugins/withAndroidInputBackground.cjs", "./plugins/withAndroidModernPopupMenu.cjs", diff --git a/apps/mobile/deps/README.md b/apps/mobile/deps/README.md index 46b0539e5c97b..939ac2d9147ed 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 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)). +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)), development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)), and `md4cFlags.preserveWindowsPaths`, which keeps backslashes in Windows drive paths in prose ([#1105](https://github.com/software-mansion/enriched-markdown/pull/1105), after [md4c#5](https://github.com/software-mansion-labs/md4c/pull/5)). 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-29ddd424.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz new file mode 100644 index 0000000000000..06ac560ff2eba Binary files /dev/null and b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz differ 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 deleted file mode 100644 index 3cf77c4adf971..0000000000000 Binary files a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz and /dev/null differ diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 169d5cc4048ba..9367ade408e95 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,5 +1,6 @@ -// Must stay first: the render counter wraps the DevTools hook before any React commit. +// Must stay first: the render counter and behavior probe wrap the DevTools hook before any React commit. import "./src/features/development/render-counter-entry"; +import "./src/features/development/behavior-probe-entry"; // Next, so errors thrown while the rest of the app loads are recorded too. import "./src/features/observability/observe"; import { registerRootComponent } from "expo"; diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt index 32969b1b71537..736f7cd3019f0 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt @@ -320,11 +320,14 @@ class T3WorkspaceNavigationView( ) } } + // Other tabs keep the button's slot so the destinations below never move between tabs. if (selectedTab == "threads") { newThreadButton(fraction) { expanded = false onNewThread(emptyMap()) } + } else { + Spacer(Modifier.padding(top = 12.dp).height(FAB_SIZE)) } Spacer(Modifier.weight(1f)) tabs.forEach { tab -> diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift index 2d92537bf4312..549331a56bef8 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift @@ -53,7 +53,7 @@ public final class T3KeyboardCommandsView: ExpoView { input: "c", modifiers: [.command, .shift], action: #selector(copyThreadReference), - title: "Copy PR Link or Thread ID" + title: "Copy PR Link or Thread" ), enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"), enabledCommand("cycleHost", input: "h", modifiers: [.command, .shift], action: #selector(cycleHost), title: "Next Machine"), diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 8426ddb565a40..8cb7ad17e0d0b 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -1423,13 +1423,24 @@ private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float { return (top + bottom) / 2f - (metrics.ascent + metrics.descent) / 2f } +// A single-line preview never shows this many characters, so longer text is cut before measuring. +private const val ELLIPSIZE_MAX_CHARS = 500 + private fun ellipsize(value: String, paint: Paint, width: Float): String { - if (paint.measureText(value) <= width) return value + val truncated = value.length > ELLIPSIZE_MAX_CHARS + val capEnd = surrogateSafeEnd(value, ELLIPSIZE_MAX_CHARS) + val text = if (truncated) value.substring(0, capEnd) else value + if (!truncated && paint.measureText(text) <= width) return value val suffix = "..." val available = max(0f, width - paint.measureText(suffix)) - var end = value.length - while (end > 0 && paint.measureText(value, 0, end) > available) end -= 1 - return value.substring(0, end) + suffix + val end = surrogateSafeEnd(text, paint.breakText(text, true, available, null)) + return text.substring(0, end) + suffix +} + +private fun surrogateSafeEnd(value: String, end: Int): Int { + val splitsPair = end in 1 until value.length && + Character.isSurrogatePair(value[end - 1], value[end]) + return if (splitsPair) end - 1 else end } private fun indexFileHeaders(rows: List): IntArray { 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 b8c92f2d9332f..703bcdf361b49 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 @@ -66,8 +66,16 @@ class T3TerminalModule : Module() { Prop("captureRequest") { view: T3TerminalView, request: Double -> view.captureRequest = request } + + Prop("sharedGrid") { view: T3TerminalView, sharedGrid: List -> + view.sharedGrid = sharedGrid + } Events("onInput", "onResize", "onCapture") + OnViewDidUpdateProps { view: T3TerminalView -> + view.applyPendingBuffer() + } + OnViewDestroys { view: T3TerminalView -> view.cleanup() } 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 f4de25460d5c6..1deb3a6170350 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 @@ -58,6 +58,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex private var streamedOutput: TerminalOutputBuffer? = null private var cols = 0 private var rows = 0 + private var measuredCols = 0 + private var measuredRows = 0 private var clearingInput = false private var isCleanedUp = false private var backgroundColorValue = Color.parseColor("#24292E") @@ -74,12 +76,16 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex recreateTerminal() } + // Expo sets a batch's props in no fixed order, so the buffer waits for the whole batch: + // output fed before the same batch's sharedGrid lands would be drawn, and clipped, at the + // previous grid. var initialBuffer: String = "" set(value) { if (field == value) return field = value - feedPendingBuffer() + hasPendingBuffer = true } + private var hasPendingBuffer = false var fontSize: Float = 10f set(value) { @@ -91,6 +97,17 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex var appearanceScheme: String = "dark" + // The PTY grid shared by every attached client, or empty before the server reports one. + // The terminal draws this grid, clamped to the view's own fit (the server's grid lags a + // local resize, and read-only observers never constrain it), so its wrapping and cursor + // match the PTY; the view's own fit still goes out through onResize. + var sharedGrid: List = emptyList() + set(value) { + if (field == value) return + field = value + applyGrid() + } + var themeConfig: String = "" set(value) { field = value @@ -327,6 +344,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex val nextRows = (terminalCanvas.usableHeight() / terminalCanvas.cellHeightPx) .toInt() .coerceIn(2, 200) + if (nextCols != measuredCols || nextRows != measuredRows) { + measuredCols = nextCols + measuredRows = nextRows + onResize(mapOf("cols" to measuredCols, "rows" to measuredRows)) + } + applyGrid() + } + + private fun applyGrid() { + if (measuredCols <= 0 || measuredRows <= 0 || isCleanedUp) return + val shared = sharedGrid.takeIf { it.size == 2 && it[0] > 0 && it[1] > 0 } + val nextCols = shared?.get(0)?.coerceAtMost(measuredCols) ?: measuredCols + val nextRows = shared?.get(1)?.coerceAtMost(measuredRows) ?: measuredRows if (nextCols == cols && nextRows == rows && terminalHandle != 0L) return cols = nextCols rows = nextRows @@ -343,8 +373,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex ) } emitResponse(response) - onResize(mapOf("cols" to cols, "rows" to rows)) - feedPendingBuffer() + if (!hasPendingBuffer) feedPendingBuffer() renderSnapshot() } @@ -369,7 +398,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (terminalHandle == 0L) return destroyTerminal() createTerminal() - feedPendingBuffer() + if (!hasPendingBuffer) feedPendingBuffer() renderSnapshot() } @@ -381,6 +410,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex terminalCanvas.resetSelectionState() } + fun applyPendingBuffer() { + if (!hasPendingBuffer) return + hasPendingBuffer = false + feedPendingBuffer() + } + private fun feedPendingBuffer() { if (streamedOutput != null || terminalHandle == 0L || initialBuffer == fedBuffer) return if (!initialBuffer.startsWith(fedBuffer)) { diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index 2f7445265886c..d68e46a5416b1 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -63,7 +63,15 @@ public class T3TerminalModule: Module { Prop("captureRequest") { (view: T3TerminalView, request: Double) in view.captureRequest = request } + + Prop("sharedGrid") { (view: T3TerminalView, sharedGrid: [Int]) in + view.sharedGrid = sharedGrid + } Events("onInput", "onResize", "onCapture") + + OnViewDidUpdateProps { (view: T3TerminalView) in + view.applyPendingBuffer() + } } } } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 364880aaeb39b..0ae146bb1241a 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -243,9 +243,23 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + // Expo sets a batch's props in no fixed order, so the buffer waits for the whole batch: + // output fed before the same batch's sharedGrid lands would be drawn, and clipped, at the + // previous grid. var initialBuffer: String = "" { didSet { - applyRemoteBuffer(initialBuffer) + hasPendingBuffer = true + } + } + private var hasPendingBuffer = false + + // The PTY grid shared by every attached client, or empty before the server reports one. + // The surface draws this grid so its wrapping and cursor match the PTY; the view's own + // fit still goes out through onResize. + var sharedGrid: [Int] = [] { + didSet { + guard oldValue != sharedGrid else { return } + resizeSurface() } } @@ -529,7 +543,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { resizeSurface() if let streamedOutput { feedData(streamedOutput.data) - } else { + } else if !hasPendingBuffer { feedBuffer(initialBuffer) } } @@ -559,6 +573,19 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } surface = nil app = nil + // Ghostty adds its IOSurface layer to the viewport on iOS but never removes it on + // free, leaving the last frame on screen under the next surface's layer. The + // viewport has no subviews, so every sublayer belongs to a freed surface. + CATransaction.begin() + CATransaction.setDisableActions(true) + terminalViewport.layer.sublayers?.forEach { $0.removeFromSuperlayer() } + CATransaction.commit() + } + + func applyPendingBuffer() { + guard hasPendingBuffer else { return } + hasPendingBuffer = false + applyRemoteBuffer(initialBuffer) } private func applyRemoteBuffer(_ buffer: String) { @@ -647,10 +674,25 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { terminalViewport.contentScaleFactor = scale ghostty_surface_set_content_scale(surface, Double(scale), Double(scale)) ghostty_surface_set_size(surface, width, height) + let fitted = ghostty_surface_size(surface) + let fittedCols = max(1, Int(fitted.columns)) + let fittedRows = max(1, Int(fitted.rows)) + // Trimming whole cells keeps ghostty's padding, and the surface can only shrink + // within the view, so a stale larger grid falls back to the fitted one. + var surfaceWidth = width + var surfaceHeight = height + if sharedGrid.count == 2, sharedGrid[0] > 0, sharedGrid[1] > 0 { + surfaceWidth -= UInt32(max(fittedCols - sharedGrid[0], 0)) * fitted.cell_width_px + surfaceHeight -= UInt32(max(fittedRows - sharedGrid[1], 0)) * fitted.cell_height_px + if surfaceWidth != width || surfaceHeight != height { + ghostty_surface_set_size(surface, surfaceWidth, surfaceHeight) + } + } ghostty_surface_set_occlusion(surface, window != nil) - configureIOSurfaceLayers() + configureIOSurfaceLayers( + size: CGSize(width: CGFloat(surfaceWidth) / scale, height: CGFloat(surfaceHeight) / scale)) redrawSurface() - emitGhosttyResize() + emitResize(cols: fittedCols, rows: fittedRows) } private func redrawSurface() { @@ -658,19 +700,6 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_surface_refresh(surface) ghostty_surface_draw(surface) markIOSurfaceLayersForDisplay() - emitGhosttyResize() - } - - private func emitGhosttyResize() { - guard let surface else { - emitEstimatedResize() - return - } - - let size = ghostty_surface_size(surface) - let cols = max(1, Int(size.columns)) - let rows = max(1, Int(size.rows)) - emitResize(cols: cols, rows: rows) } private func emitEstimatedResize() { @@ -721,8 +750,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_app_keyboard_changed(app) } - private func configureIOSurfaceLayers() { - let targetBounds = CGRect(origin: .zero, size: terminalViewport.bounds.size) + private func configureIOSurfaceLayers(size: CGSize) { + let targetBounds = CGRect(origin: .zero, size: size) CATransaction.begin() CATransaction.setDisableActions(true) terminalViewport.layer.sublayers?.forEach { sublayer in diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 42d81c8adada0..f5c1c6521812e 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -121,7 +121,7 @@ "react-dom": "catalog:", "react-native": "0.88.0", "react-native-blob-util": "0.25.1", - "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz", + "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz", "react-native-gesture-handler": "~3.3.0", "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "1.22.8", diff --git a/apps/mobile/plugins/withAndroidCleartextTraffic.cjs b/apps/mobile/plugins/withAndroidCleartextTraffic.cjs deleted file mode 100644 index f612f751e9151..0000000000000 --- a/apps/mobile/plugins/withAndroidCleartextTraffic.cjs +++ /dev/null @@ -1,18 +0,0 @@ -const { withAndroidManifest } = require("expo/config-plugins"); - -module.exports = function withAndroidCleartextTraffic(config) { - return withAndroidManifest(config, (nextConfig) => { - const application = nextConfig.modResults.manifest.application?.[0]; - - if (application == null) { - throw new Error( - "AndroidManifest.xml is missing the application element required for cleartext traffic configuration.", - ); - } - - application.$ ??= {}; - application.$["android:usesCleartextTraffic"] = "true"; - - return nextConfig; - }); -}; diff --git a/apps/mobile/plugins/withAndroidNetworkSecurityConfig.cjs b/apps/mobile/plugins/withAndroidNetworkSecurityConfig.cjs new file mode 100644 index 0000000000000..90fea189e8d01 --- /dev/null +++ b/apps/mobile/plugins/withAndroidNetworkSecurityConfig.cjs @@ -0,0 +1,56 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { withAndroidManifest, withDangerousMod } = require("expo/config-plugins"); + +// Environments behind a private CA installed in the user store must work like they do in Chrome. +// Android ignores usesCleartextTraffic once a network security config exists, so permit it here. +const NETWORK_SECURITY_CONFIG = ` + + + + + + + + +`; + +function withNetworkSecurityConfigManifest(config) { + return withAndroidManifest(config, (nextConfig) => { + const application = nextConfig.modResults.manifest.application?.[0]; + + if (application == null) { + throw new Error( + "AndroidManifest.xml is missing the application element required for network security configuration.", + ); + } + + application.$ ??= {}; + application.$["android:networkSecurityConfig"] = "@xml/network_security_config"; + + return nextConfig; + }); +} + +function withNetworkSecurityConfigResource(config) { + return withDangerousMod(config, [ + "android", + async (config) => { + const xmlDir = path.join( + config.modRequest.platformProjectRoot, + "app", + "src", + "main", + "res", + "xml", + ); + fs.mkdirSync(xmlDir, { recursive: true }); + fs.writeFileSync(path.join(xmlDir, "network_security_config.xml"), NETWORK_SECURITY_CONFIG); + return config; + }, + ]); +} + +module.exports = function withAndroidNetworkSecurityConfig(config) { + return withNetworkSecurityConfigResource(withNetworkSecurityConfigManifest(config)); +}; diff --git a/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift b/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift deleted file mode 100644 index db279d0c62e75..0000000000000 --- a/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation - -enum NWInterface { - enum InterfaceType { - case other, wifi, cellular, loopback, wiredEthernet - } -} - -struct NWPath { - enum Status { - case satisfied, unsatisfied - } - - // A reference inside the value models the native path's retained storage. - final class Storage { - let type: NWInterface.InterfaceType - init(_ type: NWInterface.InterfaceType) { self.type = type } - } - - let status: Status - let storage: Storage - - init(_ type: NWInterface.InterfaceType, status: Status = .satisfied) { - self.status = status - storage = Storage(type) - } - - func usesInterfaceType(_ type: NWInterface.InterfaceType) -> Bool { - storage.type == type - } -} - -final class NWPathMonitor { - static var scenario = "first" - static let deliveries = DispatchGroup() - var pathUpdateHandler: ((NWPath) -> Void)? - - func start(queue: DispatchQueue) { - guard let handler = pathUpdateHandler else { fatalError("Missing path handler") } - if Self.scenario == "offline" { - handler(NWPath(.wifi, status: .unsatisfied)) - } else if Self.scenario == "first" { - handler(NWPath(.wifi)) - handler(NWPath(.cellular)) - } else { - queue.async(group: Self.deliveries) { - handler(NWPath(.wifi)) - DispatchQueue.concurrentPerform(iterations: 128) { index in - handler(NWPath(index.isMultiple(of: 2) ? .cellular : .wiredEthernet)) - } - } - } - } - - // Already queued callbacks can run after cancellation. - func cancel() {} -} - -@main -enum NetworkModuleRegression { - static func main() { - NWPathMonitor.scenario = CommandLine.arguments[1] - let module = NetworkModule() - if NWPathMonitor.scenario == "concurrent" { - DispatchQueue.concurrentPerform(iterations: 64) { _ in - let state = module.getNetworkStateAsync() - precondition(state["type"] as? String == "WIFI", "The first path must survive late callbacks") - precondition(state["isConnected"] as? Bool == true) - } - NWPathMonitor.deliveries.wait() - } else { - let state = module.getNetworkStateAsync() - let connected = NWPathMonitor.scenario != "offline" - precondition(state["type"] as? String == (connected ? "WIFI" : "NONE")) - precondition(state["isConnected"] as? Bool == connected) - precondition(state["isInternetReachable"] as? Bool == connected) - } - print("passed") - } -} diff --git a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift deleted file mode 100644 index 6d13972d5742e..0000000000000 --- a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift +++ /dev/null @@ -1,189 +0,0 @@ -import Foundation - -// The registry runs on macOS without starting a simulator. Only the OS-facing -// types are replaced; the tests compile the dependency's actual Swift source. -public enum UIBackgroundFetchResult { - case noData -} - -public struct UNNotificationPresentationOptions: OptionSet { - public let rawValue: Int - public init(rawValue: Int) { self.rawValue = rawValue } -} - -public final class UNNotification: NSObject {} - -public final class UNNotificationResponse: NSObject { - let identifier: String - init(_ identifier: String) { self.identifier = identifier } -} - -public protocol UNUserNotificationCenterDelegate: AnyObject {} - -public final class UNUserNotificationCenter: NSObject { - private static let instance = UNUserNotificationCenter() - public weak var delegate: UNUserNotificationCenterDelegate? - public static func current() -> UNUserNotificationCenter { instance } -} - -private final class TestDelegate: NotificationDelegate { - private let lock = NSLock() - private var recordedEvents: [String] = [] - var onEvent: ((String) -> Void)? - var onResponse: ((UNNotificationResponse) -> Bool)? - - var events: [String] { lock.withLock { recordedEvents } } - - private func record(_ event: String) { - lock.withLock { recordedEvents.append(event) } - onEvent?(event) - } - - func didRegister(_ deviceToken: String) { record("registered") } - func didFailRegistration(_ error: Error) { record("failed") } - func openSettings(_ notification: UNNotification?) { record("settings") } - - func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool { - record("present") - return false - } - - func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool { - record("background") - return false - } - - func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool { - record(response.identifier) - return onResponse?(response) ?? false - } -} - -private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws { - if !condition() { - throw NSError(domain: "NotificationCenterManagerRegression", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) - } -} - -private func deliver(_ identifier: String) { - NotificationCenterManager.shared.userNotificationCenter( - UNUserNotificationCenter.current(), - didReceive: UNNotificationResponse(identifier), - withCompletionHandler: {} - ) -} - -private func reentrantCallbacks() throws { - let manager = NotificationCenterManager.shared - let center = UNUserNotificationCenter.current() - let callbacks: [(String, () -> Void)] = [ - ("registered", { manager.didRegister("token") }), - ("failed", { manager.didFailRegistration(NSError(domain: "test", code: 1)) }), - ("settings", { manager.userNotificationCenter(center, openSettingsFor: nil) }), - ("present", { manager.userNotificationCenter(center, willPresent: UNNotification(), withCompletionHandler: { _ in }) }), - ("background", { manager.didReceive([:], completionHandler: { _ in }) }) - ] - - for (event, callback) in callbacks { - let original = TestDelegate() - let replacement = TestDelegate() - original.onEvent = { [weak original] _ in - guard let original else { return } - manager.removeDelegate(original) - manager.addDelegate(replacement) - } - manager.addDelegate(original) - callback() - try require(original.events == [event], "The original delegate must receive the callback once") - try require(replacement.events.isEmpty, "New delegates must not join an in-flight callback snapshot") - callback() - try require(replacement.events == [event], "Reentrant registration must take effect for the next callback") - manager.removeDelegate(replacement) - } -} - -private func registrationDuringDelivery() throws { - let manager = NotificationCenterManager.shared - let original = TestDelegate() - let receiver = TestDelegate() - receiver.onResponse = { _ in true } - original.onResponse = { _ in - manager.addDelegate(receiver) - return false - } - manager.addDelegate(original) - deliver("handoff") - try require(receiver.events == ["handoff"], "A delegate registering during delivery must not miss the pending response") - manager.removeDelegate(original) - manager.removeDelegate(receiver) -} - -private func responseDuringReplay() throws { - let manager = NotificationCenterManager.shared - deliver("first") - let original = TestDelegate() - original.onResponse = { response in - if response.identifier == "first" { - deliver("second") - return true - } - return false - } - manager.addDelegate(original) - let receiver = TestDelegate() - receiver.onResponse = { _ in true } - manager.addDelegate(receiver) - try require(receiver.events == ["second"], "Finishing a replay must retain responses received during callbacks") - manager.removeDelegate(original) - manager.removeDelegate(receiver) -} - -private func concurrentRegistrations() throws { - let manager = NotificationCenterManager.shared - let delegates = (0..<512).map { _ in - let delegate = TestDelegate() - delegate.onResponse = { _ in true } - return delegate - } - DispatchQueue.concurrentPerform(iterations: delegates.count) { index in - manager.addDelegate(delegates[index]) - if index.isMultiple(of: 16) { - manager.didRegister("during-add") - deliver("during-add") - } - } - let before = delegates.map { $0.events.count } - manager.didRegister("after-add") - for (index, delegate) in delegates.enumerated() { - try require(delegate.events.count == before[index] + 1, "Concurrent registration must retain every delegate exactly once") - } - DispatchQueue.concurrentPerform(iterations: delegates.count) { index in - manager.removeDelegate(delegates[index]) - if index.isMultiple(of: 16) { - manager.didRegister("during-remove") - deliver("during-remove") - } - } - let removed = delegates.map { $0.events.count } - manager.didRegister("after-remove") - try require(delegates.map { $0.events.count } == removed, "Removed delegates must not receive new callbacks") -} - -@main -private enum RegressionTests { - static func main() { - do { - switch CommandLine.arguments.last { - case "reentrant": try reentrantCallbacks() - case "handoff": try registrationDuringDelivery() - case "pending": try responseDuringReplay() - case "concurrent": try concurrentRegistrations() - default: throw NSError(domain: "NotificationCenterManagerRegression", code: 2) - } - print("passed") - } catch { - FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) - exit(1) - } - } -} diff --git a/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m b/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m deleted file mode 100644 index 7124178383d49..0000000000000 --- a/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m +++ /dev/null @@ -1,55 +0,0 @@ -@interface TestPermissionRequester : NSObject -@end - -@implementation TestPermissionRequester -+ (NSString *)permissionType { return NSStringFromClass(self); } -- (NSDictionary *)getPermissions { return @{ @"status": @(EXPermissionStatusGranted) }; } -- (void)requestPermissionsWithResolver:(EXPromiseResolveBlock)resolve rejecter:(EXPromiseRejectBlock)reject { - resolve([self getPermissions]); -} -@end - -typedef struct { - __unsafe_unretained EXPermissionsService *service; - __unsafe_unretained NSArray *requesters; - size_t offset; -} Worker; - -static void *exerciseRegistry(void *context) { - Worker *worker = context; - for (size_t index = 0; index < 250; index++) { - @autoreleasepool { - id requester = worker->requesters[(index + worker->offset) % worker->requesters.count]; - [worker->service registerRequesters:@[requester]]; - id resolved = [worker->service getPermissionRequesterForType:[[requester class] permissionType]]; - assert(resolved == requester); - NSDictionary *permissions = [worker->service getPermissionUsingRequesterClass:[requester class]]; - assert([permissions[@"status"] isEqualToString:@"granted"]); - } - } - return NULL; -} - -int main(void) { - @autoreleasepool { - EXPermissionsService *service = [EXPermissionsService new]; - NSMutableArray *requesters = [NSMutableArray new]; - for (int i = 0; i < 32; i++) { - NSString *name = [NSString stringWithFormat:@"TestPermission%d", i]; - Class cls = objc_allocateClassPair([TestPermissionRequester class], name.UTF8String, 0); - objc_registerClassPair(cls); - [requesters addObject:[cls new]]; - } - Worker workers[8]; - pthread_t threads[8]; - for (size_t index = 0; index < 8; index++) { - workers[index] = (Worker){ service, requesters, index }; - assert(pthread_create(&threads[index], NULL, exerciseRegistry, &workers[index]) == 0); - } - for (size_t index = 0; index < 8; index++) { - assert(pthread_join(threads[index], NULL) == 0); - } - puts("passed"); - } - return 0; -} diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 13fd2a7f43789..5798c1adb060d 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -195,6 +195,7 @@ disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.ho function SplashScreenCoordinator() { const { isReady } = useAppearancePreferences(); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hides the native splash screen once appearance preferences have loaded useEffect(() => { if (isReady) void SplashScreen.hide(); }, [isReady]); diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index 272d3be266dd7..7a251a2e57c16 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -10,6 +10,8 @@ import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics"; import { resolveMobileStageLabel } from "../lib/mobileBranding"; import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; +const BRAND_MAX_FONT_SCALE = 1.4; + export function brandTitleOffset(): number { if (Platform.OS !== "ios") return 0; return Platform.isPad && !NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? IPAD_HOME_TITLE_OFFSET : 0; @@ -49,8 +51,10 @@ export function CompactBrandTitle( ]} > + {/* A logo beside a fixed-size wordmark: it may grow with Dynamic Type, not crowd the bar. */} @@ -67,6 +71,7 @@ export function CompactBrandTitle( > @@ -89,7 +94,7 @@ export function CompactBrandTitle( ); } -export function renderCompactBrandTitle() { +function renderCompactBrandTitle() { return ; } diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 0098605414bd2..59c0a74e4525f 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -27,6 +27,7 @@ import { startComposerDraftOperation } from "../state/composerDraftOperations"; import { appAtomRegistry } from "../state/atom-registry"; import { mobilePreferencesAtom } from "../state/preferences"; import { useOpenLinkedThread } from "../features/threads/useOpenLinkedThread"; +import { useLinkedEnvironments } from "../state/peerLinks"; import { ComposerContextSheet } from "./ComposerContextSheet"; import { AppText as Text } from "./ui/AppText"; import { @@ -61,6 +62,7 @@ export function ComposerEditor({ ? preferencesResult.value.composerEnterBehavior : undefined; const contextHistory = createComposerDraftContextHistory(); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- releases the attachment file leases held for native text undo when the history is replaced or unmounts useEffect(() => () => contextHistory.dispose(), [contextHistory]); const changeText = (text: string) => { const restored = contextHistory.restore( @@ -76,6 +78,7 @@ export function ComposerEditor({ const [selected, setSelected] = useState<{ source: string; start: number; end: number } | null>( null, ); + const linkedEnvironments = useLinkedEnvironments(environmentId ?? null); const contextImports = useAtomValue(composerContextImportsAtom); const importing = draftKey ? contextImports[draftKey] === true : false; const pasteContext = async ( @@ -100,6 +103,7 @@ export function ComposerEditor({ signal, retained.context?.records.length ?? 0, environmentId, + linkedEnvironments, ); if (signal.aborted) return; if (!result) { diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index f2b92eaf89260..8963a4039d5f3 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -1,5 +1,5 @@ import type { ComponentProps, ReactNode } from "react"; -import { useCallback, useMemo, useState } from "react"; +import { useState } from "react"; import { Pressable, ScrollView, @@ -154,33 +154,31 @@ export function ComposerToolbarScroller(props: { viewportWidth: 0, }); - const scrollEdges = useMemo(() => { - const maxOffset = Math.max(0, metrics.contentWidth - metrics.viewportWidth); - return { - showLeftFade: metrics.offsetX > COMPOSER_TOOLBAR_SCROLL_EPSILON, - showRightFade: metrics.offsetX < maxOffset - COMPOSER_TOOLBAR_SCROLL_EPSILON, - }; - }, [metrics]); + const maxOffset = Math.max(0, metrics.contentWidth - metrics.viewportWidth); + const scrollEdges = { + showLeftFade: metrics.offsetX > COMPOSER_TOOLBAR_SCROLL_EPSILON, + showRightFade: metrics.offsetX < maxOffset - COMPOSER_TOOLBAR_SCROLL_EPSILON, + }; - const handleLayout = useCallback((event: LayoutChangeEvent) => { + const handleLayout = (event: LayoutChangeEvent) => { const viewportWidth = event.nativeEvent.layout.width; setMetrics((current) => current.viewportWidth === viewportWidth ? current : { ...current, viewportWidth }, ); - }, []); + }; - const handleContentSizeChange = useCallback((contentWidth: number) => { + const handleContentSizeChange = (contentWidth: number) => { setMetrics((current) => current.contentWidth === contentWidth ? current : { ...current, contentWidth }, ); - }, []); + }; - const handleScroll = useCallback((event: NativeSyntheticEvent) => { + const handleScroll = (event: NativeSyntheticEvent) => { const offsetX = event.nativeEvent.contentOffset.x; setMetrics((current) => Math.abs(current.offsetX - offsetX) < 1 ? current : { ...current, offsetX }, ); - }, []); + }; return ( diff --git a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx index 901a032845f9a..f4dce3c978081 100644 --- a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx +++ b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx @@ -11,7 +11,7 @@ export const ENVIRONMENT_MACHINE_SYMBOLS = { "mac-studio": "macstudio", } as const satisfies Record; -export const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { +const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { server: "Server", cloud: "Cloud VM", linux: "Linux/WSL", diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index ed5c6e56ee891..45f09779f13be 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -34,6 +34,7 @@ function NativeFilePreview(props: { else Dialog.alert("Could not open preview", "The file could not be loaded. Please try again."); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- presents the native file preview and dismisses it on cleanup useEffect(() => { let canceled = false; const presentation = diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index b0cf1fc5c3ab0..486cd9e236b8e 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -55,6 +55,7 @@ function DocumentPreview(props: { "A compatible viewer must be installed. Check your connection and try again.", ); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the attachment in an external viewer and aborts the open on cleanup useEffect(() => { const controller = new AbortController(); const input = { diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index 8079379fd4895..849bacd8ac41b 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -86,6 +86,7 @@ function ResolvedFilePreview(props: { props.onRequestClose(); }); // Resolves once per presentation and holds local file leases until it closes. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resolves preview files and holds their local file leases until the preview closes useEffect(() => { if (items.every((item) => "uri" in item)) return; const controller = new AbortController(); diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index ea39cac15baff..c6dc31ca00ad2 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -1,6 +1,6 @@ import { useEvent } from "expo"; import { useVideoPlayer, VideoView } from "expo-video"; -import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { AppActivityIndicator } from "./ui/AppActivityIndicator"; @@ -28,11 +28,12 @@ function LoadedMediaVideo(props: { player.staysActiveInBackground = false; player.bufferOptions = { preferredForwardBufferDuration: 5 }; }); - const playback = useMemo(() => createAudioPlaybackSession(player, true), [player]); + const playback = createAudioPlaybackSession(player, true); const { active, fullscreen, createPlayRequest, onFullscreenEnter, onFullscreenExit } = useMediaVideoPlayback(player, props, playback); const previousPlaybackRequested = useRef(false); const onPlaybackStart = useEffectEvent(() => props.onPlaybackStart?.()); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to native video player events to keep the shared audio session in step useEffect(() => { const subscription = player.addListener("playingChange", ({ isPlaying }) => { if (isPlaying && !active.current && !fullscreen.current) playback.pause(); @@ -74,6 +75,7 @@ function LoadedMediaVideo(props: { if (!signal.aborted && props.playRequested) await playIfUninterrupted(); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads the video source into the native player and aborts the load on cleanup 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. diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index b450e1d293596..1aa33197321c7 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,7 +1,7 @@ import { SymbolView } from "./ui/AppSymbol"; import { AppText } from "./ui/AppText"; import { Image } from "expo-image"; -import { memo, useLayoutEffect, useMemo, useState } from "react"; +import { memo, useLayoutEffect, useState } from "react"; import { View } from "react-native"; import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts"; import { @@ -52,28 +52,14 @@ export const ProjectFavicon = memo(function ProjectFavicon(props: { faviconPath: props.faviconPath, }), ); - const renderableFaviconUrl = useMemo( - () => (isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl), - [faviconUrl], - ); + const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; // Key remote images by revision so token rotation reuses the cache while changed icons reload. - const cacheKey = useMemo( - () => - renderableFaviconUrl && props.workspaceRoot - ? renderableFaviconUrl.startsWith("data:") - ? getProjectFaviconResourceKey( - props.environmentId, - props.workspaceRoot, - props.faviconPath, - ) - : getProjectFaviconCacheKey( - props.environmentId, - props.workspaceRoot, - renderableFaviconUrl, - ) - : null, - [renderableFaviconUrl, props.environmentId, props.workspaceRoot, props.faviconPath], - ); + const cacheKey = + renderableFaviconUrl && props.workspaceRoot + ? renderableFaviconUrl.startsWith("data:") + ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) + : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + : null; if (symbol !== null) { return ( @@ -159,12 +145,10 @@ function ProjectFaviconImage(props: { readonly projectTitle: string; readonly size: number; }) { - const faviconRequest = useMemo( - () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl), - [props.cacheKey, props.faviconUrl], - ); + const faviconRequest = createProjectFaviconRequest(props.cacheKey, props.faviconUrl); // Registers with the module-level loader before the Image's native onLoad/onError can fire, // so stale loads for a superseded URL under the same cacheKey are ignored. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- marks this favicon URL as the current request in the module registry before the native Image reports load or error useLayoutEffect(() => { if (faviconRequest === null) return; return beginProjectFaviconRequest(faviconRequest); diff --git a/apps/mobile/src/components/ScrollComposeButton.android.tsx b/apps/mobile/src/components/ScrollComposeButton.android.tsx index 12906a2bb4ac6..e9e57373e19c0 100644 --- a/apps/mobile/src/components/ScrollComposeButton.android.tsx +++ b/apps/mobile/src/components/ScrollComposeButton.android.tsx @@ -8,7 +8,7 @@ import { onSizeChanged, size, } from "@expo/ui/jetpack-compose/modifiers"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"; import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; @@ -28,9 +28,9 @@ export function ScrollComposeButton(props: { const scale = fabSize / nativeSize; const nativeIconSize = Math.round(iconSize / scale); const [buttonWidth, setButtonWidth] = useState(nativeSize); - const rememberWidth = useCallback(({ width }: { width: number }) => { + const rememberWidth = ({ width }: { width: number }) => { setButtonWidth((previous) => Math.max(previous, width)); - }, []); + }; return ( { const controller = new AbortController(); let token: ReturnType | null = null; diff --git a/apps/mobile/src/components/ui/AppBar.android.tsx b/apps/mobile/src/components/ui/AppBar.android.tsx index d5df66d22d6a6..d156b7ef064b7 100644 --- a/apps/mobile/src/components/ui/AppBar.android.tsx +++ b/apps/mobile/src/components/ui/AppBar.android.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { Keyboard, Pressable, TextInput, type TextInputInstance, View } from "react-native"; import { useHardwareBack } from "../../lib/backHandler"; import { useToolbarLayout } from "./useToolbarLayout"; @@ -23,11 +23,11 @@ export function AppBar(props: AppBarProps) { const [searchOpen, setSearchOpen] = useState(false); const searching = search !== undefined && (searchOpen || search.value.length > 0); const onSearchChange = search?.onChangeText; - const closeSearch = useCallback(() => { + const closeSearch = () => { onSearchChange?.(""); setSearchOpen(false); Keyboard.dismiss(); - }, [onSearchChange]); + }; useHardwareBack(searching && search?.mode !== "inline", () => { closeSearch(); return true; diff --git a/apps/mobile/src/components/ui/NativeThemeHost.android.tsx b/apps/mobile/src/components/ui/NativeThemeHost.android.tsx index ad31d4c9c49b2..ad6998029fd29 100644 --- a/apps/mobile/src/components/ui/NativeThemeHost.android.tsx +++ b/apps/mobile/src/components/ui/NativeThemeHost.android.tsx @@ -1,5 +1,5 @@ import { Host } from "@expo/ui/jetpack-compose"; -import { useMemo, type ComponentProps } from "react"; +import { type ComponentProps } from "react"; import { createNativeThemeColors } from "../../lib/nativeTheme"; import { useControlTheme } from "./useControlTheme"; @@ -7,9 +7,6 @@ export function NativeThemeHost( props: Omit, "colorScheme" | "seedColor" | "colorOverrides">, ) { const { colors, colorScheme, systemColorsActive } = useControlTheme(); - const colorOverrides = useMemo( - () => (systemColorsActive ? undefined : createNativeThemeColors(colors)), - [colors, systemColorsActive], - ); + const colorOverrides = systemColorsActive ? undefined : createNativeThemeColors(colors); return ; } diff --git a/apps/mobile/src/components/ui/Notice.tsx b/apps/mobile/src/components/ui/Notice.tsx index 44ca6c8cf7fe7..90a31f7054363 100644 --- a/apps/mobile/src/components/ui/Notice.tsx +++ b/apps/mobile/src/components/ui/Notice.tsx @@ -16,6 +16,7 @@ export function Notice(props: { readonly dismissLabel?: string; }) { const danger = props.tone === "danger"; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- announces the message to VoiceOver through AccessibilityInfo useEffect(() => { // Live regions reach TalkBack; VoiceOver needs an explicit announcement. if (Platform.OS === "ios") AccessibilityInfo.announceForAccessibility(props.message); diff --git a/apps/mobile/src/components/ui/OverlayPortal.tsx b/apps/mobile/src/components/ui/OverlayPortal.tsx index a4f07f46024f1..b5cd51e6c9058 100644 --- a/apps/mobile/src/components/ui/OverlayPortal.tsx +++ b/apps/mobile/src/components/ui/OverlayPortal.tsx @@ -38,6 +38,7 @@ export function OverlayPortal(props: { readonly children: ReactNode }) { // 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). + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- projects children into the out-of-tree overlay host store after every commit useEffect(() => { entries.set(key, props.children); emit(); @@ -45,6 +46,7 @@ export function OverlayPortal(props: { readonly children: ReactNode }) { // 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. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- removes this entry from the overlay host store on unmount useEffect( () => () => { entries.delete(key); diff --git a/apps/mobile/src/components/ui/Toast.tsx b/apps/mobile/src/components/ui/Toast.tsx index 4426cee5552af..2483ac9fa1285 100644 --- a/apps/mobile/src/components/ui/Toast.tsx +++ b/apps/mobile/src/components/ui/Toast.tsx @@ -1,5 +1,5 @@ import { MOBILE_MOTION, MOBILE_SHAPE } from "../../lib/designTokens"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; import { AccessibilityInfo, Platform, StyleSheet, View, useWindowDimensions } from "react-native"; import { GestureDetector, usePanGesture } from "react-native-gesture-handler"; import Animated, { @@ -55,6 +55,7 @@ function ToastContent(props: ToastProps & { readonly glassReady?: boolean }) { const action = props.action ? ( + ), + TextInput: (props: { + readonly accessibilityLabel?: string; + readonly value: string; + readonly onChangeText: (value: string) => void; + }) => ( + props.onChangeText(event.target.value)} + /> + ), + }; +}); +vi.mock("@legendapp/list/react-native", () => ({ + LegendList: (props: { + readonly data: ZipTreeRow[]; + readonly ListHeaderComponent: ReactNode; + readonly ListEmptyComponent: ReactNode; + readonly keyExtractor: (row: ZipTreeRow) => string; + readonly renderItem: (input: { item: ZipTreeRow }) => ReactNode; + }) => ( +
+ {props.ListHeaderComponent} + {props.data.length === 0 ? props.ListEmptyComponent : null} + {props.data.map((item) => ( +
{props.renderItem({ item })}
+ ))} +
+ ), +})); +vi.mock("expo-file-system", () => ({ + Directory: class { + create() {} + }, + Paths: { document: { uri: "file:///documents" } }, + File: class { + constructor(private readonly uri: string) {} + get exists() { + return false; + } + get size() { + return files.get(this.uri)?.byteLength ?? null; + } + async arrayBuffer() { + const bytes = files.get(this.uri); + if (!bytes) throw new Error(`Missing ZIP fixture: ${this.uri}`); + return Uint8Array.from(bytes).buffer; + } + }, +})); +vi.mock("expo/fetch", () => ({ fetch: vi.fn() })); +vi.mock("expo-crypto", () => ({ randomUUID: vi.fn() })); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ canGoBack: () => false }), + usePreventRemove: (enabled: boolean, remove: () => void) => { + navigation.remove = enabled ? remove : null; + }, +})); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})); +vi.mock("../../native/NativeScrollViewMarker", () => ({ NativeScrollView: () => null })); +vi.mock("../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../components/ui/Toolbar", () => { + const Slot = (props: { readonly children?: ReactNode }) =>
{props.children}
; + return { + Toolbar: Object.assign(Slot, { + Button: () => null, + Menu: Slot, + MenuAction: (props: { + readonly children?: ReactNode; + readonly disabled?: boolean; + readonly onPress?: () => void; + }) => ( + + ), + }), + }; +}); +vi.mock("../../components/ui/AndroidScreenHeader", () => ({ AndroidScreenHeader: () => null })); +vi.mock("../../components/ui/Menu", () => ({ Menu: () => null })); +vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("../../components/ui/Progress", () => ({ Progress: () => null })); +vi.mock("../../components/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); +vi.mock("../../components/FilePreviewModal", () => ({ FilePreviewModal: () => null })); +vi.mock("../../lib/copyTextWithHaptic", () => ({})); +vi.mock("./SourceFileSurface", () => ({ + SourceFileSurface: (props: { readonly contents: string }) =>
{props.contents}
, +})); +vi.mock("./AudioFilePreview", () => ({ AudioFilePreview: () => null })); +vi.mock("./FileMarkdownPreview", () => ({ FileMarkdownPreview: () => null })); +vi.mock("./WorkspaceFileWebPreview", () => ({ WorkspaceFileWebPreview: () => null })); +vi.mock("../threads/HtmlRenderWebView", () => ({ HtmlRenderWebView: () => null })); +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ + appearance: { codeWordBreak: false }, + themeVariables: { "--color-icon": "#000000" }, + setCodeWordBreak: () => {}, + }), +})); + +import { ZipArchiveView } from "./ZipArchiveView"; +import { AttachmentFileScreen } from "./AttachmentFileScreen"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { composerDraftsAtom } from "../../state/use-composer-drafts"; + +function setup() { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.useFakeTimers({ toFake: ["setImmediate", "clearImmediate"] }); + files.clear(); + navigation.remove = null; + server.capabilities = { repositoryIdentity: false }; + server.refresh.mockClear(); + appAtomRegistry.set(composerDraftsAtom, {}); + files.set( + "file:///first.zip", + Uint8Array.from( + atob( + "UEsDBBQAAAAAAAAAIVyzyFShFgAAABYAAAAJAAAAZmlyc3QudHh0Rmlyc3QgYXJjaGl2ZSBjb250ZW50c1BLAQIUAxQAAAAAAAAAIVyzyFShFgAAABYAAAAJAAAAAAAAAAAAAACAAQAAAABmaXJzdC50eHRQSwUGAAAAAAEAAQA3AAAAPQAAAAAA", + ), + (character) => character.charCodeAt(0), + ), + ); + + const container = document.createElement("div"); + const root = createRoot(container); + onTestFinished(() => { + act(() => root.unmount()); + files.clear(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + const props: ComponentProps = { + name: "archive.zip", + subtitle: "ZIP", + tintColor: "#000000", + sizeBytes: 0, + uri: "file:///first.zip", + isLocal: true, + refresh: async () => null, + environmentId: null, + draftKey: null, + maxAttachmentBytes: undefined, + onSave: () => {}, + onRemove: null, + }; + return { + container, + root, + props, + render: async (input = props) => { + await act(async () => root.render()); + }, + settle: async (assert: () => void) => { + await act(async () => vi.runAllTimersAsync()); + assert(); + }, + }; +} + +it("opens a ZIP chat attachment as a browsable archive on the attachment file screen", async () => { + const screen = setup(); + server.capabilities = { + repositoryIdentity: false, + attachmentUploads: true, + fileAttachments: { maxUploadBytes: 1024 }, + }; + const { fetch } = await import("expo/fetch"); + vi.mocked(fetch, { partial: true }).mockImplementation(async () => ({ + ok: true, + body: new Response(Uint8Array.from(files.get("file:///first.zip")!)).body, + })); + await act(async () => + screen.root.render( + , + ), + ); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + const add = Array.from(screen.container.querySelectorAll("button")).find( + (button) => button.textContent === "Add to chat", + ); + expect(add?.disabled).toBe(false); +}); + +it("keeps the ZIP filter and archive open when native Back closes a selected entry", async () => { + const screen = setup(); + await screen.render(); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + const filter = screen.container.querySelector( + '[aria-label="Filter files in ZIP"]', + )!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call( + filter, + "first", + ); + filter.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + + expect(navigation.remove).not.toBeNull(); + await act(async () => navigation.remove!()); + + expect(screen.container.querySelector("pre")).toBeNull(); + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(); + expect( + screen.container.querySelector('[aria-label="Filter files in ZIP"]')?.value, + ).toBe("first"); +}); + +it("explains why an encrypted ZIP cannot be browsed", async () => { + const screen = setup(); + const bytes = files.get("file:///first.zip")!; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const directory = view.getUint32(bytes.length - 6, true); + view.setUint16(directory + 8, view.getUint16(directory + 8, true) | 1, true); + await screen.render(); + await screen.settle(() => expect(screen.container.textContent).toContain("Encrypted zip")); + expect(screen.container.querySelector('[aria-label="first.txt"]')).toBeNull(); +}); + +it("keeps the archive and selected entry when the parent re-renders with a new refresh", async () => { + const screen = setup(); + await screen.render(); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + files.delete("file:///first.zip"); + + await screen.render({ ...screen.props, refresh: async () => null }); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + expect(navigation.remove).not.toBeNull(); +}); diff --git a/apps/mobile/src/features/files/ZipArchiveView.tsx b/apps/mobile/src/features/files/ZipArchiveView.tsx new file mode 100644 index 0000000000000..bb9931d4e3c00 --- /dev/null +++ b/apps/mobile/src/features/files/ZipArchiveView.tsx @@ -0,0 +1,870 @@ +import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { + ARCHIVE_ENTRY_MAX_BYTES, + ARCHIVE_MAX_BYTES, + boundedZip, +} from "@t3tools/client-runtime/zip/bounded-zip"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview"; +import { + decodeFilePreviewText, + filePreviewKind, + hostPreviewMimeTypeFromExtension, +} from "@t3tools/shared/filePreview"; +import { LegendList } from "@legendapp/list/react-native"; +import { fetch } from "expo/fetch"; +import { File } from "expo-file-system"; +import { useNavigation, usePreventRemove } from "@react-navigation/native"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { Keyboard, Platform, Pressable, ScrollView, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { PierreEntryIcon } from "../../components/PierreEntryIcon"; +import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { AppText as Text } from "../../components/ui/AppText"; +import { Button } from "../../components/ui/Button"; +import { EmptyState } from "../../components/ui/EmptyState"; +import { Progress } from "../../components/ui/Progress"; +import { Toolbar } from "../../components/ui/Toolbar"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import * as Dialog from "../../components/ui/Dialog"; +import { FilePreviewModal } from "../../components/FilePreviewModal"; +import { + nativeViewerErrorMessage, + shareGeneratedAttachment, + writeGeneratedAttachmentFile, +} from "../../lib/attachmentDownload"; +import { attachmentDocumentPresentation } from "../../lib/attachmentDocumentPresentation"; +import { + createComposerFileAttachment, + removePersistedComposerAttachmentFile, +} from "../../lib/composerImages"; +import { appendComposerDraftAttachments } from "../../state/use-composer-drafts"; +import { AttachmentDocumentBody, nativeViewerKind } from "./AttachmentDocumentBody"; +import { FilePreviewLoading } from "./FilePreviewFeedback"; +import { + buildZipTree, + flattenZipTree, + zipDownloadTimeLeft, + zipFolderLabel, + type ZipEntry, + type ZipTreeRow, +} from "./zipTree"; + +export function isZipFile(file: { readonly name: string; readonly mimeType: string }) { + const mime = file.mimeType.split(";", 1)[0]?.trim().toLowerCase(); + return ( + mime === "application/zip" || + mime === "application/x-zip-compressed" || + file.name.toLowerCase().endsWith(".zip") + ); +} + +interface ZipArchive { + readonly entries: ReadonlyArray; + readonly fileCount: number; +} + +interface ZipFailure { + readonly title: string; + readonly detail: string; + readonly retry: boolean; +} + +const SAVE_ZIP_HINT = "Save the ZIP to open it in another app."; +const TOO_LARGE: ZipFailure = { + title: "Too large to browse here", + detail: `Browsing works up to ${formatAttachmentSize(ARCHIVE_MAX_BYTES)}. ${SAVE_ZIP_HINT}`, + retry: false, +}; +const CANCELLED: ZipFailure = { + title: "Download cancelled", + detail: "The ZIP wasn't downloaded. Try again to list its files.", + retry: true, +}; +const NETWORK: ZipFailure = { + title: "Couldn't download this ZIP", + detail: "Check the connection to this environment and try again.", + retry: true, +}; + +class ZipFailureError extends Error { + constructor(readonly failure: ZipFailure) { + super(failure.title); + } +} + +function errorMessage(cause: unknown, fallback: string) { + return cause instanceof Error ? cause.message : fallback; +} + +async function readArchiveBody( + body: ReadableStream, + onProgress: (receivedBytes: number) => void, +) { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + chunks.push(value); + onProgress(size); + } + } finally { + void reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes.buffer; +} + +async function openArchive(bytes: ArrayBuffer): Promise { + let files: Awaited>; + try { + files = await boundedZip(bytes); + } catch (cause) { + throw new ZipFailureError({ + title: "Couldn't browse this ZIP", + detail: `${errorMessage(cause, "It may be incomplete or damaged.")} ${SAVE_ZIP_HINT}`, + retry: false, + }); + } + const entries = [...files].map(([path, file]): ZipEntry => { + const name = path.split("/").findLast(Boolean) ?? path; + const dot = name.lastIndexOf("."); + return { + path, + name, + isDirectory: file.directory, + sizeBytes: file.bytes.byteLength, + mimeType: + (dot > 0 ? hostPreviewMimeTypeFromExtension(name.slice(dot).toLowerCase()) : null) ?? + "application/octet-stream", + bytes: file.bytes, + }; + }); + return { entries, fileCount: entries.filter((entry) => !entry.isDirectory).length }; +} + +type ArchiveState = + | { readonly status: "loading"; readonly receivedBytes: number; readonly timeLeft: string | null } + | { readonly status: "ready"; readonly archive: ZipArchive } + | { readonly status: "failed"; readonly failure: ZipFailure }; + +function useZipArchive(input: { + readonly uri: string | null; + readonly isLocal: boolean; + readonly sizeBytes: number; + readonly refresh: () => Promise; +}) { + const [state, setState] = useState({ + status: "loading", + receivedBytes: 0, + timeLeft: null, + }); + const [revision, setRevision] = useState(0); + const controller = useRef(null); + const { uri, isLocal, sizeBytes } = input; + // The parent mints a new refresh callback on every render; it must not reload the archive. + const refresh = useEffectEvent(input.refresh); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Load archive bytes and cancel the stream on cleanup. + useEffect(() => { + if (!uri) return; + const abort = new AbortController(); + controller.current = abort; + const startedAt = Date.now(); + // oxlint-disable-next-line react/set-state-in-effect -- A new archive load resets its progress. + setState({ status: "loading", receivedBytes: 0, timeLeft: null }); + void (async () => { + if (sizeBytes > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + if (isLocal) { + const file = new File(uri); + if ((file.size ?? 0) > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + return openArchive(await file.arrayBuffer()); + } + const url = (await refresh()) ?? uri; + const response = await fetch(url, { signal: abort.signal }); + if (!response.ok || !response.body) throw new ZipFailureError(NETWORK); + const bytes = await readArchiveBody(response.body, (receivedBytes) => + setState({ + status: "loading", + receivedBytes, + timeLeft: zipDownloadTimeLeft({ + receivedBytes, + totalBytes: sizeBytes, + elapsedMs: Date.now() - startedAt, + }), + }), + ); + abort.signal.throwIfAborted(); + return openArchive(bytes); + })() + .then((archive) => { + if (!abort.signal.aborted) setState({ status: "ready", archive }); + }) + .catch((cause: unknown) => { + if (!abort.signal.aborted) + setState({ + status: "failed", + failure: cause instanceof ZipFailureError ? cause.failure : NETWORK, + }); + }); + return () => abort.abort(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reload the archive. + }, [uri, isLocal, sizeBytes, revision]); + return { + state, + retry: () => setRevision((value) => value + 1), + cancel: () => { + controller.current?.abort(); + setState({ status: "failed", failure: CANCELLED }); + }, + }; +} + +function ZipTreeRowView(props: { + readonly row: ZipTreeRow; + readonly collapsed: boolean; + readonly onToggle: (path: string) => void; + readonly onOpen: (entry: ZipEntry) => void; +}) { + const { node, depth } = props.row; + const entry = node.entry; + const folder = entry ? null : zipFolderLabel(node.label); + return ( + (entry ? props.onOpen(entry) : props.onToggle(node.path))} + className="mx-2 h-11 flex-row items-center gap-1.5 rounded-lg pr-2.5 active:bg-subtle" + style={{ paddingLeft: 8 + depth * 18 }} + > + + {entry ? null : ( + + )} + + + + + + {folder ? ( + <> + {folder.prefix} + {folder.name} + + ) : ( + node.label + )} + + + {entry + ? formatAttachmentSize(entry.sizeBytes) + : props.collapsed + ? `${node.fileCount} ${node.fileCount === 1 ? "file" : "files"}` + : ""} + + + ); +} + +function ZipArchiveHeader(props: { + readonly name: string; + readonly subtitle: string; + readonly tintColor: string; + readonly onSave: () => void; + readonly onRemove: (() => void) | null; +}) { + const navigation = useNavigation(); + const isAndroid = Platform.OS === "android"; + return ( + <> + + {isAndroid ? ( + { + if (navigation.canGoBack()) navigation.goBack(); + }} + actions={[ + { accessibilityLabel: "Save ZIP", icon: "square.and.arrow.up", onPress: props.onSave }, + ...(props.onRemove + ? [ + { + accessibilityLabel: "Remove from draft", + icon: "trash" as const, + onPress: props.onRemove, + destructive: true, + overflowOnly: true, + }, + ] + : []), + ]} + /> + ) : ( + + {props.onRemove ? ( + + + Save ZIP + + + Remove from draft + + + ) : ( + + )} + + )} + + ); +} + +function ZipArchiveProgress(props: { + readonly receivedBytes: number; + readonly totalBytes: number; + readonly timeLeft: string | null; + readonly onCancel: () => void; +}) { + const { timeLeft } = props; + return ( + + + {formatAttachmentSize(props.receivedBytes)} + {props.totalBytes > 0 ? ( + + {` of ${formatAttachmentSize(props.totalBytes)}`} + + ) : null} + + 0 ? props.receivedBytes / props.totalBytes : undefined} + label="ZIP download progress" + /> + + {`Downloading the ZIP so its files can be listed.${timeLeft ? ` ${timeLeft} on this connection.` : ""}`} + +