From d15682613119f1d936fbe6f4c57200c71f3cb22d Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 1 Sep 2026 23:49:03 -0400 Subject: [PATCH] refactor(mobile): delete the mobile-web user-gesture window The hybrid WebView required a recent native-observed touch before a bridge capability could run. A scroll armed the window, so it gated nothing an attacker on the first-party page could not already reach, and no peer hybrid framework (Capacitor, Cordova, RN WebView) gates bridge calls this way. Removes the gesture module, its React authority hook, the shared requirement and its census, and all 20 gate sites: native.alert, clipboard write, external link open, terminal text-scale and custom-key updates, dictation start and model management, account select and reset-credit consume, agent-history resume, terminal clipboard paste and image attach, navigation reconnect, removeHost and terminal-settings route, and both workspace-creation writes. Each operation now just runs. The AppState foreground reporting the gesture hook also carried moves to use-mobile-web-app-foreground-authority. permission_required stays in the bridge error enum: it parses inbound responses, so narrowing it would break a new page paired with an older shell. Drops the G3 gesture-window e2e probe and renames its runner to run-hosted-ios-webview-app-bound-probe.mjs, which keeps the app-bound probe. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../mobile-hybrid-webview-architecture.md | 9 +- ...hybrid-webview-implementation-checklist.md | 6 + ...-mobile-hybrid-webview-parity-inventory.md | 6 + ...bile-hybrid-webview-single-pr-migration.md | 6 + ...ile-hybrid-webview-simplification-audit.md | 13 +- mobile/app/hybrid.tsx | 16 +- .../hosted-ios-native-alert-journey.mjs | 6 - .../hosted-ios-user-gesture-window-probe.mjs | 226 ------------------ ...un-hosted-ios-webview-app-bound-probe.mjs} | 48 ++-- .../MobileWebHybridShellPresentation.tsx | 4 +- .../hosted-ios-native-alert-journey.test.ts | 4 - .../mobile-web-account-capability.ts | 4 +- .../mobile-web-account-operations.test.ts | 35 +-- .../mobile-web-account-operations.ts | 4 - .../mobile-web-account-roundtrip.test.ts | 4 +- .../mobile-web-agent-history-operations.ts | 5 - ...mobile-web-agent-history-roundtrip.test.ts | 29 ++- .../mobile-web-capability-broker-race.test.ts | 4 +- .../mobile-web-capability-execution-arms.ts | 20 +- ...le-web-mutation-authorization-race.test.ts | 3 +- ...e-web-native-capability-operations.test.ts | 88 ++----- ...mobile-web-native-capability-operations.ts | 8 - .../mobile-web-native-chat-capability.ts | 4 +- ...e-web-native-chat-image-operations.test.ts | 31 +-- ...mobile-web-native-chat-image-operations.ts | 3 - .../mobile-web-native-chat-operations.test.ts | 3 +- .../mobile-web-native-chat-operations.ts | 1 - .../mobile-web-native-roundtrip.test.ts | 8 +- .../mobile-web-native-route-handoff.test.ts | 3 +- .../mobile-web-navigation-operations.test.ts | 41 +--- .../mobile-web-navigation-operations.ts | 8 - .../mobile-web-navigation-roundtrip.test.ts | 6 +- .../mobile-web-speech-operations.test.ts | 40 +--- .../mobile-web-speech-operations.ts | 6 - .../mobile-web-speech-roundtrip.test.ts | 17 +- .../mobile-web-terminal-streams.test.ts | 18 +- .../mobile-web/mobile-web-terminal-streams.ts | 7 +- .../mobile-web-user-gesture-census.test.ts | 166 ------------- ...mobile-web-user-gesture-operations.test.ts | 185 -------------- .../mobile-web-user-gesture-requirement.ts | 26 -- .../mobile-web-user-gesture.test.ts | 77 ------ .../src/mobile-web/mobile-web-user-gesture.ts | 15 -- ...rkspace-creation-create-operations.test.ts | 26 +- ...eb-workspace-creation-create-operations.ts | 4 - ...ace-creation-provider-revalidation.test.ts | 3 +- ...e-web-workspace-creation-roundtrip.test.ts | 6 +- .../mobile-web-workspace-operations.ts | 1 - ...obile-web-app-foreground-authority.test.ts | 17 ++ ...use-mobile-web-app-foreground-authority.ts | 17 ++ .../use-mobile-web-navigation-authority.ts | 12 +- .../use-mobile-web-user-gesture-authority.ts | 46 ---- 51 files changed, 192 insertions(+), 1153 deletions(-) delete mode 100644 mobile/scripts/hosted-ios-user-gesture-window-probe.mjs rename mobile/scripts/{run-hosted-ios-webview-gesture-and-app-bound-probes.mjs => run-hosted-ios-webview-app-bound-probe.mjs} (82%) delete mode 100644 mobile/src/mobile-web/mobile-web-user-gesture-census.test.ts delete mode 100644 mobile/src/mobile-web/mobile-web-user-gesture-operations.test.ts delete mode 100644 mobile/src/mobile-web/mobile-web-user-gesture-requirement.ts delete mode 100644 mobile/src/mobile-web/mobile-web-user-gesture.test.ts delete mode 100644 mobile/src/mobile-web/mobile-web-user-gesture.ts create mode 100644 mobile/src/mobile-web/use-mobile-web-app-foreground-authority.test.ts create mode 100644 mobile/src/mobile-web/use-mobile-web-app-foreground-authority.ts delete mode 100644 mobile/src/mobile-web/use-mobile-web-user-gesture-authority.ts diff --git a/docs/reference/mobile-hybrid-webview-architecture.md b/docs/reference/mobile-hybrid-webview-architecture.md index 6af8d0cf8c5..1c2731d61a7 100644 --- a/docs/reference/mobile-hybrid-webview-architecture.md +++ b/docs/reference/mobile-hybrid-webview-architecture.md @@ -2,7 +2,7 @@ - **Status:** Implemented as the sole workspace route in the dedicated release candidate; production promotion is not approved -- **Last updated:** September 1, 2026 +- **Last updated:** September 2, 2026 - **Migration design:** [`plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md`](./plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md) - **Active remaining work:** @@ -61,7 +61,7 @@ acceptance. | Native mobile shell | Pairing and host selection; secure credential storage; authenticated encrypted transport; QR scanning; notifications and deep links; package verification, cache, private origin, and recovery; clipboard, haptics, audio, camera and file/photo pickers; native settings, onboarding, privacy, About, and diagnostics | | Desktop-served React Native Web application | Workspace list and creation; sessions and terminal presentation; files, previews, diffs, source control, reviews, tasks, accounts, browser presentation, Agent History, and native-chat presentation | | Desktop runtime | Builds and ships the matching web package; serves its manifest and chunks through authenticated RPC; reauthorizes every workspace mutation; enforces host, workspace, provider, path, and resource limits | -| Typed native bridge | Connects the unprivileged page to explicitly granted Desktop operations and gesture-gated native capabilities; carries connection and route state without exposing transport credentials | +| Typed native bridge | Connects the unprivileged page to explicitly granted Desktop operations and native capabilities; carries connection and route state without exposing transport credentials | The page never receives the raw RPC client, pairing credential, host endpoint, private key, cache path, or unrestricted native module access. Native-owned @@ -169,7 +169,10 @@ external-link authority. every operation against the current connection and opaque workspace scope. - Clipboard reads, pickers, external links, haptics, dictation, and related native actions require the relevant grant; privacy-sensitive actions also - require a recent native-observed user gesture or system permission. + require the system permission the platform asks for. The shell's own + recent-user-gesture window was removed on 2026-09-02: a scroll armed it, so it + gated nothing on a first-party page, and peer hybrid frameworks do not gate + bridge calls on gestures. - Host switch, reconnect, process loss, cancellation, and package replacement invalidate stale authority. diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md index 2d67d5ec0bc..71709ce51a4 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md @@ -7,6 +7,12 @@ - **Ownership:** [parity inventory](2026-07-22-mobile-hybrid-webview-parity-inventory.md) - **Operations:** [rollback runbook](../mobile-hybrid-webview-rollback.md) +> **2026-09-02 — the recent-user-gesture window described below was removed.** The +> shell no longer requires a recent native touch before a bridge capability runs: a +> scroll armed the window, so it gated nothing on a first-party page, and peer hybrid +> frameworks do not gate bridge calls this way. Gesture statements here describe the +> plan as written, not the shipped shell. + This index replaces the execution-era checklist. Completed rows mean the named implementation and recorded candidate evidence exist. They do not close physical-device, store, signed-release, production cloud Relay, cross-version, diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md index 89bdc8a6d39..c5c5a250cd8 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md @@ -8,6 +8,12 @@ - **Checklist:** [`2026-07-22-mobile-hybrid-webview-implementation-checklist.md`](./2026-07-22-mobile-hybrid-webview-implementation-checklist.md) +> **2026-09-02 — the recent-user-gesture window described below was removed.** The +> shell no longer requires a recent native touch before a bridge capability runs: a +> scroll armed the window, so it gated nothing on a first-party page, and peer hybrid +> frameworks do not gate bridge calls this way. Gesture statements here describe the +> plan as written, not the shipped shell. + ## Purpose This inventory prevents a visually successful WebView cutover from silently diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md index d765a505572..8491965ceda 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md @@ -10,6 +10,12 @@ [completed-work evidence](2026-07-22-mobile-hybrid-webview-implementation-checklist.md), [open gates](2026-07-27-mobile-hybrid-webview-remaining-work.md) +> **2026-09-02 — the recent-user-gesture window described below was removed.** The +> shell no longer requires a recent native touch before a bridge capability runs: a +> scroll armed the window, so it gated nothing on a first-party page, and peer hybrid +> frameworks do not gate bridge calls this way. Gesture statements here describe the +> plan as written, not the shipped shell. + ## Context The original mobile application duplicated fast-changing Desktop workspace diff --git a/docs/reference/plans/2026-08-23-mobile-hybrid-webview-simplification-audit.md b/docs/reference/plans/2026-08-23-mobile-hybrid-webview-simplification-audit.md index bd0d593aa28..9c6194e74dd 100644 --- a/docs/reference/plans/2026-08-23-mobile-hybrid-webview-simplification-audit.md +++ b/docs/reference/plans/2026-08-23-mobile-hybrid-webview-simplification-audit.md @@ -40,7 +40,7 @@ The non-negotiable constraints were: - Preserve the existing React Native presentation and behavior. - Keep page authority opaque and the private origin network/storage isolated. - Preserve exact named operations, per-operation schemas/bounds, - reauthorization, gesture mediation, result correlation, and cleanup. + reauthorization, result correlation, and cleanup. - Preserve native, folder-workspace, WSL, SSH, Relay, provider-neutral, and mixed-version ownership even where their final matrices remain open. - Remove tests only when equivalent or stronger coverage remains. @@ -104,7 +104,7 @@ commit is independently releasable. trust boundaries even when both use the same page transport envelope. - Package delivery RPC is separate from page capability dispatch. - Terminal streaming, native-chat subscriptions, ordinary request/response, - and gesture-bound native actions have different lifecycle rules. + and native device actions have different lifecycle rules. - iOS and Android origin/cache implementations may share contracts and corpora, but their platform enforcement remains explicit. - Desktop package rollback and native-shell/store correction are independent @@ -120,7 +120,7 @@ does not, by itself, preserve: - pre-allocation collection, string, binary, and envelope limits; - opaque host/build/shell/workspace/stream authority binding; - destructive mutation reauthorization after asynchronous host resolution; -- foreground, route, permission, and recent-gesture mediation; +- foreground, route, and permission mediation; - request/result identity and delayed-response correlation; - subscription ownership, cancellation, invalid-event retirement, and reconnect resnapshot; @@ -141,7 +141,7 @@ dispatch tables, compatibility declarations, and invariant tests. It must not generate or hide authorization decisions. Each domain retains an explicit adapter that resolves opaque authority, checks the current execution host and provider/workspace context, reauthorizes mutations, mediates native -gesture/permission state, applies result-specific bounds, and owns cleanup. +permission state, applies result-specific bounds, and owns cleanup. Generated output must remain reviewable, deterministic, exact-v2 compatible, and covered by the existing malformed/lifecycle corpora. Prototype this on Tasks, Files, and Session before any broad conversion. @@ -221,8 +221,9 @@ merge subject rather than SHA, because the branch is still rebased. rebuilds the capability broker through a `viewEpoch` remount. - Android installs a document-start script denying `fetch`, `XMLHttpRequest`, `WebSocket`, and `serviceWorker`, matching iOS. -- `native.alert` is gated on a gesture witness that does not spend the gesture. - The gesture requirement now lives in one module with its own census. +- The `native.alert` gesture gate this group added was deleted on 2026-09-02, + along with the whole recent-user-gesture window: a scroll armed it, so it + gated nothing on a first-party page. The rest of this group stands. `Merge mr-p1-ci: run native shell tests in CI and fix the Android module build` diff --git a/mobile/app/hybrid.tsx b/mobile/app/hybrid.tsx index 853168f286c..3e0a348cf66 100644 --- a/mobile/app/hybrid.tsx +++ b/mobile/app/hybrid.tsx @@ -23,7 +23,7 @@ import { useMobileWebColdResumeRoute } from '../src/mobile-web/use-mobile-web-co import { mobileWebBridgeConnectionState } from '../src/mobile-web/mobile-web-bridge-connection-state' import { MobileWebOneShotResponseDrop } from '../src/mobile-web/mobile-web-one-shot-response-drop' import { useMobileWebE2eHostSelection } from '../src/mobile-web/mobile-web-e2e-host-selection' -import { useMobileWebUserGestureAuthority as useGestureAuthority } from '../src/mobile-web/use-mobile-web-user-gesture-authority' +import { useMobileWebAppForegroundAuthority } from '../src/mobile-web/use-mobile-web-app-foreground-authority' import { useMobileWebHostCatalog } from '../src/mobile-web/use-mobile-web-host-catalog' import { mobileWebDiagnosticsStore } from '../src/mobile-web/mobile-web-diagnostics-store' import { useMobileWebBridgeRuntimeRef } from '../src/mobile-web/use-mobile-web-bridge-runtime-ref' @@ -53,11 +53,7 @@ export default function HybridScreen() { const brokerRef = useRef(null) const postInitRef = useRef<() => Promise>(() => Promise.resolve()) const nativeRouteHandoffRef = useRef(new MobileWebNativeRouteHandoff()) - const recentWebGestureAtRef = useRef(null) - const { consumeRecentUserGesture, hasRecentUserGesture } = useGestureAuthority( - recentWebGestureAtRef, - brokerRef - ) + useMobileWebAppForegroundAuthority(brokerRef) const responseDropRef = useRef( new MobileWebOneShotResponseDrop(process.env.EXPO_PUBLIC_ORCA_E2E_MOBILE_WEB_DROP_RESPONSE_ONCE) ) @@ -149,7 +145,6 @@ export default function HybridScreen() { // A view-epoch bump replaces the document, so every page-scoped grant retires with it. useEffect(() => { initializedSessionRef.current = undefined - recentWebGestureAtRef.current = null nativeRouteHandoffRef.current.clear() setPageReadySessionId(undefined) healthDeadlineRef.current.clear() @@ -181,9 +176,7 @@ export default function HybridScreen() { router, clearColdResumeRoute: coldResumeRoute.clearRoute, closeHostClient, - forceReconnectHost, - consumeRecentUserGesture, - hasRecentUserGesture + forceReconnectHost }) const createBroker = useCallback( (page: MobileWebBrokerPageIdentity) => { @@ -386,9 +379,6 @@ export default function HybridScreen() { onRecoveryFailure={() => showWarning('The workspace interface recovery action could not be completed.') } - onTouch={() => { - recentWebGestureAtRef.current = Date.now() - }} onBridgeMessage={(message) => void handleBridgeMessage(message)} onPageLoaded={() => { hardwareBackHandoff.resetPage() diff --git a/mobile/scripts/hosted-ios-native-alert-journey.mjs b/mobile/scripts/hosted-ios-native-alert-journey.mjs index b40d3ff0d4c..bf4c476b934 100644 --- a/mobile/scripts/hosted-ios-native-alert-journey.mjs +++ b/mobile/scripts/hosted-ios-native-alert-journey.mjs @@ -1,7 +1,6 @@ import { randomBytes } from 'node:crypto' import { tapHostedIosAccessibilityControl, - tapHostedIosPoint, waitForHostedIosAccessibilityLabel, waitForHostedIosAccessibilityLabelToDisappear } from './hosted-ios-emulator-accessibility.mjs' @@ -14,8 +13,6 @@ import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activatio const ALERT_PROBE_PROPERTY = '__orcaE2eNativeAlertProbe' const ALERT_TITLE = 'Hosted native Alert probe' -// Normalized viewport coordinates inside the session body, away from chrome and controls. -const ALERT_GESTURE_POINT = { x: 0.5, y: 0.6 } export async function verifyHostedIosNativeAlertJourney( { discoveryUrl, emulator, expectedWorkspace, timeoutMs, workspaceDocument }, @@ -29,7 +26,6 @@ export async function verifyHostedIosNativeAlertJourney( const tapControl = operations.tapControl ?? tapHostedIosAccessibilityControl const waitForLabelToDisappear = operations.waitForLabelToDisappear ?? waitForHostedIosAccessibilityLabelToDisappear - const tapPoint = operations.tapPoint ?? tapHostedIosPoint await installNativeAlertProbe(workspaceDocument, evaluate) await activateWorkspace(workspaceDocument, expectedWorkspace, activateControl, timeoutMs, () => @@ -42,8 +38,6 @@ export async function verifyHostedIosNativeAlertJourney( timeoutMs }) const requestId = randomBytes(16).toString('base64url') - // native.alert is gesture-gated; a native tap on the page arms the shell's gesture window. - await tapPoint(emulator, ALERT_GESTURE_POINT) await postNativeAlertProbe(sessionDocument, requestId, evaluate) const title = await waitForLabel(emulator, ALERT_TITLE, timeoutMs) const button = await tapControl(emulator, 'Keep editing', timeoutMs) diff --git a/mobile/scripts/hosted-ios-user-gesture-window-probe.mjs b/mobile/scripts/hosted-ios-user-gesture-window-probe.mjs deleted file mode 100644 index 11378b2d3bb..00000000000 --- a/mobile/scripts/hosted-ios-user-gesture-window-probe.mjs +++ /dev/null @@ -1,226 +0,0 @@ -import { randomBytes } from 'node:crypto' -import { - runHostedIosEmulatorCommand, - tapHostedIosPoint -} from './hosted-ios-emulator-accessibility.mjs' -import { - activateHostedWebViewControl, - evaluateHostedDocumentWithRetry, - waitForVisibleHostedWebView -} from './hosted-webview-cdp-session.mjs' -import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs' - -const GESTURE_PROBE_PROPERTY = '__orcaE2eGestureWindowProbe' -// MOBILE_WEB_USER_GESTURE_MAX_AGE_MS is 5000; wait past it so no case inherits the previous one. -const GESTURE_QUIESCENCE_MS = 6_500 -const PAGE_TAP_POINT = { x: 0.5, y: 0.6 } -const SCROLL_FRAME_COUNT = 24 -const SCROLL_FROM_Y = 0.72 -const SCROLL_TO_Y = 0.42 - -export async function probeHostedIosUserGestureWindow( - { discoveryUrl, emulator, expectedWorkspace, timeoutMs, workspaceDocument }, - operations = {} -) { - const evaluate = operations.evaluate ?? evaluateHostedDocumentWithRetry - const waitForDocument = operations.waitForDocument ?? waitForVisibleHostedWebView - const activateWorkspace = operations.activateWorkspace ?? activateHostedWorkspaceRow - const activateControl = operations.activateControl ?? activateHostedWebViewControl - const tapPoint = operations.tapPoint ?? tapHostedIosPoint - const runCommand = operations.runCommand ?? runHostedIosEmulatorCommand - - await installGestureProbe(workspaceDocument, evaluate) - await activateWorkspace(workspaceDocument, expectedWorkspace, activateControl, timeoutMs, () => - waitForDocument({ discoveryUrl, expectedText: expectedWorkspace, timeoutMs }) - ) - const sessionDocument = await waitForDocument({ - discoveryUrl, - expectedText: 'Mobile Emulator', - expectedHrefIncludes: '/session/', - timeoutMs - }) - const geometry = await readViewportGeometry(sessionDocument, evaluate) - const insetPoint = { x: 0.5, y: geometry.viewportTopRatio / 2 } - - const cases = [] - const record = async (name, action) => { - cases.push({ name, ...(await runGestureCase(sessionDocument, evaluate, timeoutMs, action)) }) - } - - // Control: no native touch at all, so the window must be closed. - await record('no-gesture', quiesce) - // Control: a page-originated DOM touch/click cannot reach the React Native touch responder. - await record('page-dispatched-touch', async () => { - await quiesce() - await dispatchPageTouch(sessionDocument, evaluate) - }) - // G3a: a native tap that lands on the WKWebView child. - await record('native-tap-on-webview', async () => { - await quiesce() - await tapPoint(emulator, PAGE_TAP_POINT) - }) - // The same window must not survive the operation that spent it. - await record('replay-without-new-gesture', () => Promise.resolve()) - // G3b: a pan with no tap, i.e. a scroll. - await record('native-scroll-on-webview', async () => { - await quiesce() - await scrollHostedIosPoint(emulator, runCommand) - }) - // G3c: a native tap on the shell chrome above the WebView. A zero-height strip means the hosted - // state leaves no native pixels to tap, which is itself the answer. - if (geometry.viewportTop >= 2) { - await record('native-tap-outside-webview', async () => { - await quiesce() - await tapPoint(emulator, insetPoint) - }) - } else { - cases.push({ name: 'native-tap-outside-webview', skipped: 'no native strip above the WebView' }) - } - // The window must expire on its own. - await record('native-tap-then-expiry', async () => { - await quiesce() - await tapPoint(emulator, PAGE_TAP_POINT) - await quiesce() - }) - - return { cases, geometry, insetPoint, sessionDocument } -} - -async function runGestureCase(document, evaluate, timeoutMs, action) { - await action() - const requestId = randomBytes(16).toString('base64url') - await postClipboardWriteProbe(document, requestId, evaluate) - const response = await waitForProbeResponse(document, requestId, timeoutMs, evaluate) - return { - error: response?.error?.code ?? null, - granted: response?.status === 'success', - status: response?.status ?? 'missing' - } -} - -async function installGestureProbe(document, evaluate) { - const expression = `(() => { - const key = ${JSON.stringify(GESTURE_PROBE_PROPERTY)}; - if (globalThis[key]) return JSON.stringify({ started: true }); - const native = globalThis.OrcaNative; - if (!native || typeof native.postMessage !== 'function') { - return JSON.stringify({ started: false }); - } - const state = globalThis[key] = { context: null, responses: Object.create(null) }; - addEventListener('message', (event) => { - try { - const message = typeof event.data === 'string' ? JSON.parse(event.data) : null; - if (message?.type === 'response' && typeof message.requestId === 'string') { - state.responses[message.requestId] = message; - } - } catch {} - }); - globalThis.OrcaNative = Object.freeze({ - postMessage(value) { - try { - const message = JSON.parse(value); - if (message?.shellSessionId && message?.buildId && Number.isInteger(message.version)) { - state.context = { - version: message.version, - shellSessionId: message.shellSessionId, - buildId: message.buildId - }; - } - } catch {} - native.postMessage(value); - } - }); - return JSON.stringify({ started: true }); - })()` - const result = JSON.parse(await evaluate(document, expression)) - if (result?.started !== true) { - throw new Error('Gesture window probe could not observe the hosted bridge') - } -} - -// clipboardWrite is the cheapest gesture-gated mutation: it consumes the window and answers with a -// success or a permission_required error without presenting any UI. -async function postClipboardWriteProbe(document, requestId, evaluate) { - const expression = `(() => { - const state = globalThis[${JSON.stringify(GESTURE_PROBE_PROPERTY)}]; - if (!state?.context) return JSON.stringify({ posted: false }); - globalThis.OrcaNative.postMessage(JSON.stringify({ - ...state.context, - type: 'request', - mode: 'once', - requestId: ${JSON.stringify(requestId)}, - capability: 'native', - operation: 'clipboardWrite', - payload: { text: 'orca-gesture-window-probe' } - })); - return JSON.stringify({ posted: true }); - })()` - const result = JSON.parse(await evaluate(document, expression)) - if (result?.posted !== true) { - throw new Error('Gesture window probe did not capture an active bridge context') - } -} - -async function waitForProbeResponse(document, requestId, timeoutMs, evaluate) { - const deadline = Date.now() + timeoutMs - const expression = `JSON.stringify(globalThis[${JSON.stringify( - GESTURE_PROBE_PROPERTY - )}]?.responses?.[${JSON.stringify(requestId)}] ?? null)` - while (Date.now() < deadline) { - const result = JSON.parse(await evaluate(document, expression)) - if (result) { - return result - } - await delay(100) - } - throw new Error('Gesture window probe response did not return to the hosted page') -} - -// The WebView is bottom-anchored, so screen.height - innerHeight is the native strip above it. -async function readViewportGeometry(document, evaluate) { - const expression = `JSON.stringify({ - innerHeight: Number(innerHeight), - screenHeight: Number(screen.height), - screenWidth: Number(screen.width) - })` - const geometry = JSON.parse(await evaluate(document, expression)) - const viewportTop = Math.max(0, geometry.screenHeight - geometry.innerHeight) - return { ...geometry, viewportTop, viewportTopRatio: viewportTop / geometry.screenHeight } -} - -async function dispatchPageTouch(document, evaluate) { - const expression = `(() => { - const target = document.elementFromPoint(innerWidth / 2, innerHeight * 0.6) ?? document.body; - if (!target) return JSON.stringify({ dispatched: false }); - for (const type of ['pointerdown', 'mousedown', 'touchstart', 'click']) { - target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true })); - } - return JSON.stringify({ dispatched: true }); - })()` - const result = JSON.parse(await evaluate(document, expression)) - if (result?.dispatched !== true) { - throw new Error('Gesture window probe could not dispatch a page touch') - } -} - -async function scrollHostedIosPoint(emulator, runCommand) { - const frames = [{ type: 'begin', x: PAGE_TAP_POINT.x, y: SCROLL_FROM_Y }] - for (let index = 1; index <= SCROLL_FRAME_COUNT; index++) { - const ratio = index / SCROLL_FRAME_COUNT - frames.push({ - type: 'move', - x: PAGE_TAP_POINT.x, - y: SCROLL_FROM_Y + (SCROLL_TO_Y - SCROLL_FROM_Y) * ratio - }) - } - frames.push({ type: 'end', x: PAGE_TAP_POINT.x, y: SCROLL_TO_Y }) - await runCommand(emulator, ['gesture', JSON.stringify(frames)]) -} - -function quiesce() { - return delay(GESTURE_QUIESCENCE_MS) -} - -function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} diff --git a/mobile/scripts/run-hosted-ios-webview-gesture-and-app-bound-probes.mjs b/mobile/scripts/run-hosted-ios-webview-app-bound-probe.mjs similarity index 82% rename from mobile/scripts/run-hosted-ios-webview-gesture-and-app-bound-probes.mjs rename to mobile/scripts/run-hosted-ios-webview-app-bound-probe.mjs index 11cad04abfc..5d26e6c9537 100644 --- a/mobile/scripts/run-hosted-ios-webview-gesture-and-app-bound-probes.mjs +++ b/mobile/scripts/run-hosted-ios-webview-app-bound-probe.mjs @@ -20,9 +20,12 @@ import { bootHostedIosSimulator, resolveHostedIosSimulatorUdid } from './hosted-ios-simulator-device.mjs' -import { probeHostedIosUserGestureWindow } from './hosted-ios-user-gesture-window-probe.mjs' -import { waitForVisibleHostedWebView } from './hosted-webview-cdp-session.mjs' +import { + activateHostedWebViewControl, + waitForVisibleHostedWebView +} from './hosted-webview-cdp-session.mjs' import { resolveHostedWebViewRuntimeDirectory } from './hosted-webview-runtime-directory.mjs' +import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs' const worktree = path.resolve(import.meta.dirname, '../..') const options = parseOptions(process.argv.slice(2)) @@ -41,7 +44,6 @@ const orcaSelection = resolveEmulatorOrcaCli({ function parseOptions(args) { const parsed = { device: 'iPhone 17 Pro', - gestureOnly: false, reuseNativeInstall: false, skipNativeBuild: false, timeoutMs: 180_000 @@ -55,8 +57,6 @@ function parseOptions(args) { parsed.skipNativeBuild = true } else if (args[index] === '--reuse-native-install') { parsed.reuseNativeInstall = true - } else if (args[index] === '--gesture-only') { - parsed.gestureOnly = true } else { throw new Error(`Unknown argument: ${args[index]}`) } @@ -66,7 +66,7 @@ function parseOptions(args) { async function main() { if (process.platform !== 'darwin') { - throw new Error('Hosted iOS WebView probes require macOS and Xcode.') + throw new Error('The hosted iOS app-bound navigation probe requires macOS and Xcode.') } mkdirSync(runtimeDirectory, { recursive: true, mode: 0o700 }) const deviceUdid = await resolveHostedIosSimulatorUdid(options.device) @@ -107,23 +107,31 @@ async function main() { expectedText: expectedWorkspace, timeoutMs: options.timeoutMs }) - const gesture = await probeHostedIosUserGestureWindow({ - discoveryUrl, - emulator, + await activateHostedWorkspaceRow( + workspaceDocument, expectedWorkspace, - timeoutMs: options.timeoutMs, - workspaceDocument - }) - const appBound = options.gestureOnly - ? null - : await probeHostedIosAppBoundNavigation({ - deviceUdid, - emulator, - sessionDocument: gesture.sessionDocument, + activateHostedWebViewControl, + options.timeoutMs, + () => + waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: expectedWorkspace, timeoutMs: options.timeoutMs }) - const { sessionDocument: _session, ...gestureEvidence } = gesture - console.log(JSON.stringify({ appBound, gesture: gestureEvidence, nativeAppPath }, null, 2)) + ) + const sessionDocument = await waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: 'Mobile Emulator', + expectedHrefIncludes: '/session/', + timeoutMs: options.timeoutMs + }) + const appBound = await probeHostedIosAppBoundNavigation({ + deviceUdid, + emulator, + sessionDocument, + timeoutMs: options.timeoutMs + }) + console.log(JSON.stringify({ appBound, nativeAppPath }, null, 2)) } finally { inspector?.stop() await stopHostedChildProcess(launcher) diff --git a/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx b/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx index f859a47dc63..183ad149ad5 100644 --- a/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx +++ b/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx @@ -33,7 +33,6 @@ type MobileWebHybridShellPresentationProps = { onUsePrevious: () => void | Promise onClearCache: () => void | Promise onRecoveryFailure: () => void - onTouch: () => void onBridgeMessage: (message: string) => void onPageLoaded: () => void onNavigationBlocked: () => void @@ -55,7 +54,6 @@ export function MobileWebHybridShellPresentation({ onUsePrevious, onClearCache, onRecoveryFailure, - onTouch, onBridgeMessage, onPageLoaded, onNavigationBlocked, @@ -102,7 +100,7 @@ export function MobileWebHybridShellPresentation({ ) : null} {presentationState === 'hosted-interface' && session ? ( - + {packageLoading && packageProgress ? ( ) : null} diff --git a/mobile/src/mobile-web/hosted-ios-native-alert-journey.test.ts b/mobile/src/mobile-web/hosted-ios-native-alert-journey.test.ts index 6c0e8356466..dea4e8ff4e0 100644 --- a/mobile/src/mobile-web/hosted-ios-native-alert-journey.test.ts +++ b/mobile/src/mobile-web/hosted-ios-native-alert-journey.test.ts @@ -22,7 +22,6 @@ describe('hosted iOS native Alert journey', () => { const waitForLabel = vi.fn().mockResolvedValue({ frame: { x: 0, y: 0, width: 1, height: 1 } }) const tapControl = vi.fn().mockResolvedValue({ x: 0.5, y: 0.5 }) const waitForLabelToDisappear = vi.fn().mockResolvedValue(undefined) - const tapPoint = vi.fn().mockResolvedValue(undefined) const result = await verifyHostedIosNativeAlertJourney( { @@ -37,15 +36,12 @@ describe('hosted iOS native Alert journey', () => { activateWorkspace, evaluate, tapControl, - tapPoint, waitForDocument, waitForLabel, waitForLabelToDisappear } ) - expect(tapPoint).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, { x: 0.5, y: 0.6 }) - expect(tapPoint.mock.invocationCallOrder[0]).toBeLessThan(evaluate.mock.invocationCallOrder[1]!) expect(tapControl).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, 'Keep editing', 30_000) expect(activateControl).toHaveBeenCalledWith(sessionDocument, { kind: 'label', diff --git a/mobile/src/mobile-web/mobile-web-account-capability.ts b/mobile/src/mobile-web/mobile-web-account-capability.ts index 6bc4816fde9..a3e96e772f4 100644 --- a/mobile/src/mobile-web/mobile-web-account-capability.ts +++ b/mobile/src/mobile-web/mobile-web-account-capability.ts @@ -1,4 +1,3 @@ -import { mobileWebUserGestureConsumer } from './mobile-web-user-gesture-requirement' import { MobileWebAccountSubscribePayloadSchema } from '../../../src/shared/mobile-web/account-operation-contract' import { executeMobileWebAccountOperation } from './mobile-web-account-operations' import type { MobileWebCapabilityExecutionDependencies } from './mobile-web-capability-execution-dependencies' @@ -21,8 +20,7 @@ export async function executeMobileWebAccountCapability( operation: request.operation, payload: request.payload, client: args.connectedClient(), - nativeAuthority: args.nativeAuthority, - consumeRecentUserGesture: mobileWebUserGestureConsumer(args.navigationAuthority) + nativeAuthority: args.nativeAuthority }) } throw new Error('unsupported_account_request') diff --git a/mobile/src/mobile-web/mobile-web-account-operations.test.ts b/mobile/src/mobile-web/mobile-web-account-operations.test.ts index 3ae2a1d465c..d3aac626ee4 100644 --- a/mobile/src/mobile-web/mobile-web-account-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-account-operations.test.ts @@ -7,7 +7,7 @@ import { } from './mobile-web-bridge-roundtrip-fixture' describe('mobile web account operations', () => { - it('sanitizes snapshots and requires a native-observed gesture to switch accounts', async () => { + it('sanitizes snapshots and switches accounts through the host', async () => { const harness = createHarness() await harness.broker.handle(request('A', 'snapshot', {})) @@ -22,13 +22,6 @@ describe('mobile web account operations', () => { accountId: 'claude-1' }) expect(successPayload(harness.messages, 'B')).toBeNull() - - harness.consumeRecentUserGesture.mockReturnValue(false) - await harness.broker.handle(request('C', 'select', { provider: 'codex', accountId: 'codex-1' })) - expect(errorCode(harness.messages, 'C')).toBe('permission_required') - expect(harness.sendRequest).not.toHaveBeenCalledWith('accounts.selectCodex', { - accountId: 'codex-1' - }) }) it('forwards bounded snapshot events and retires the host stream on client replacement', async () => { @@ -49,7 +42,7 @@ describe('mobile web account operations', () => { expect(harness.hostUnsubscribe).toHaveBeenCalledOnce() }) - it('keeps reset identity and idempotency in the native shell behind a recent gesture', async () => { + it('keeps reset identity and idempotency in the native shell', async () => { const harness = createHarness() const expectedScope = { target: { runtime: 'host' as const, wslDistro: null }, @@ -61,12 +54,6 @@ describe('mobile web account operations', () => { await harness.broker.handle(request('F', 'resetCreditCapability', {})) expect(successPayload(harness.messages, 'F')).toBe(true) - harness.consumeRecentUserGesture.mockReturnValue(false) - await harness.broker.handle(request('G', 'consumeResetCredit', { expectedScope })) - expect(errorCode(harness.messages, 'G')).toBe('permission_required') - expect(harness.codexResetCreditConsume).not.toHaveBeenCalled() - - harness.consumeRecentUserGesture.mockReturnValue(true) await harness.broker.handle(request('H', 'consumeResetCredit', { expectedScope })) expect(harness.codexResetCreditConsume).toHaveBeenCalledWith(expect.anything(), expectedScope) expect(successPayload(harness.messages, 'H')).toMatchObject({ @@ -81,7 +68,6 @@ describe('mobile web account operations', () => { function createHarness() { let subscriptionListener: ((event: unknown) => void) | null = null const hostUnsubscribe = vi.fn() - const consumeRecentUserGesture = vi.fn(() => true) const codexResetCreditCapability = vi.fn(async () => true) const codexResetCreditConsume = vi.fn(async (_client, expectedScope) => ({ outcome: 'reset' as const, @@ -108,9 +94,7 @@ function createHarness() { navigationAuthority: { route: vi.fn(), reconnect: vi.fn(), - removeHost: vi.fn(), - consumeRecentUserGesture, - hasRecentUserGesture: () => true + removeHost: vi.fn() } }) return { @@ -118,7 +102,6 @@ function createHarness() { messages, sendRequest, hostUnsubscribe, - consumeRecentUserGesture, codexResetCreditConsume, get subscriptionListener() { return subscriptionListener @@ -256,15 +239,3 @@ function successPayload(messages: readonly MobileWebBridgeShellMessage[], id: st ? message.payload : undefined } - -function errorCode(messages: readonly MobileWebBridgeShellMessage[], id: string): string | null { - const message = messages.find( - (candidate) => - candidate.type === 'response' && - candidate.requestId === id.repeat(22) && - candidate.status === 'error' - ) - return message && message.type === 'response' && message.status === 'error' - ? message.error.code - : null -} diff --git a/mobile/src/mobile-web/mobile-web-account-operations.ts b/mobile/src/mobile-web/mobile-web-account-operations.ts index cc549141779..ee16ea7bb3c 100644 --- a/mobile/src/mobile-web/mobile-web-account-operations.ts +++ b/mobile/src/mobile-web/mobile-web-account-operations.ts @@ -9,7 +9,6 @@ import { } from '../../../src/shared/mobile-web/account-operation-contract' import type { RpcClient } from '../transport/rpc-client' import { MobileWebBrokerError } from './mobile-web-broker-error' -import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement' import { mobileWebAccountsSnapshot } from './mobile-web-account-presentation' import type { MobileWebNativeCapabilityAuthority } from './mobile-web-native-capability-authority' @@ -18,7 +17,6 @@ export async function executeMobileWebAccountOperation(args: { payload: unknown client: RpcClient nativeAuthority: MobileWebNativeCapabilityAuthority - consumeRecentUserGesture: () => boolean }): Promise { if (args.operation === 'snapshot') { MobileWebAccountSnapshotPayloadSchema.parse(args.payload) @@ -28,7 +26,6 @@ export async function executeMobileWebAccountOperation(args: { } if (args.operation === 'select') { const payload = MobileWebAccountSelectPayloadSchema.parse(args.payload) - requireRecentUserGesture(args.consumeRecentUserGesture) const method = payload.provider === 'claude' ? 'accounts.selectClaude' @@ -51,7 +48,6 @@ export async function executeMobileWebAccountOperation(args: { } if (args.operation === 'consumeResetCredit') { const payload = MobileWebAccountConsumeResetPayloadSchema.parse(args.payload) - requireRecentUserGesture(args.consumeRecentUserGesture) const consume = args.nativeAuthority.codexResetCreditConsume if (!consume) { throw new MobileWebBrokerError('unsupported_capability') diff --git a/mobile/src/mobile-web/mobile-web-account-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-account-roundtrip.test.ts index 87830f395a7..f1a6de96bb5 100644 --- a/mobile/src/mobile-web/mobile-web-account-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-account-roundtrip.test.ts @@ -27,9 +27,7 @@ it('round trips typed account reads, selection, and snapshots through the produc navigationAuthority: { route: vi.fn(), reconnect: vi.fn(), - removeHost: vi.fn(), - consumeRecentUserGesture: () => true, - hasRecentUserGesture: () => true + removeHost: vi.fn() } }) diff --git a/mobile/src/mobile-web/mobile-web-agent-history-operations.ts b/mobile/src/mobile-web/mobile-web-agent-history-operations.ts index 50f9f3148c0..0e6458926a7 100644 --- a/mobile/src/mobile-web/mobile-web-agent-history-operations.ts +++ b/mobile/src/mobile-web/mobile-web-agent-history-operations.ts @@ -4,10 +4,6 @@ import { MobileWebAgentHistoryResumeResultSchema } from '../../../src/shared/mobile-web/agent-history-operation-contract' import { MobileWebBrokerError } from './mobile-web-broker-error' -import { - mobileWebUserGestureConsumer, - requireRecentUserGesture -} from './mobile-web-user-gesture-requirement' import type { MobileWebCapabilityExecutionDependencies } from './mobile-web-capability-execution-dependencies' import { mobileWebAgentHistoryPreview } from './mobile-web-agent-history-presentation' @@ -31,7 +27,6 @@ export async function executeMobileWebAgentHistoryOperation( } if (request.operation === 'resume') { const payload = MobileWebAgentHistoryResumePayloadSchema.parse(request.payload) - requireRecentUserGesture(mobileWebUserGestureConsumer(args.navigationAuthority)) const result = await args.agentHistoryResume.resume({ payload, client: args.connectedClient(), diff --git a/mobile/src/mobile-web/mobile-web-agent-history-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-agent-history-roundtrip.test.ts index 290cd76ec96..98c8e10e0b5 100644 --- a/mobile/src/mobile-web/mobile-web-agent-history-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-agent-history-roundtrip.test.ts @@ -2,7 +2,7 @@ import { expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture' -it('round trips opaque agent history and gesture-gates resume through the production bridge', async () => { +it('round trips opaque agent history and resume through the production bridge', async () => { const sendRequest = vi.fn(async (method: string) => { if (method === 'status.get') { return { @@ -13,6 +13,16 @@ it('round trips opaque agent history and gesture-gates resume through the produc if (method === 'worktree.ps') { return { ok: true, result: { worktrees: [worktree()] } } } + if (method === 'repo.list') { + return { ok: true, result: { repos: [] } } + } + if ( + method === 'folderWorkspace.list' || + method === 'projectGroup.list' || + method === 'settings.get' + ) { + return { ok: true, result: {} } + } if (method === 'aiVault.listSessions') { return { ok: true, @@ -35,9 +45,7 @@ it('round trips opaque agent history and gesture-gates resume through the produc navigationAuthority: { route: vi.fn(), reconnect: vi.fn(), - removeHost: vi.fn(), - consumeRecentUserGesture: () => false, - hasRecentUserGesture: () => true + removeHost: vi.fn() }, randomBytes: (length) => new Uint8Array(length).fill(5) }) @@ -65,13 +73,12 @@ it('round trips opaque agent history and gesture-gates resume through the produc await expect(client.agentHistory.preview(row.handle)).resolves.toEqual({ messages: [{ role: 'assistant', text: 'safe preview' }] }) - await expect( - client.agentHistory.resume({ - workspaceId: route.workspaceId, - sessionHandle: row.handle - }) - ).rejects.toMatchObject({ code: 'permission_required' }) - expect(sendRequest).not.toHaveBeenCalledWith('repo.list', expect.anything(), expect.anything()) + const resume = await client.agentHistory.resume({ + workspaceId: route.workspaceId, + sessionHandle: row.handle + }) + expect(JSON.stringify(resume)).not.toContain('/Users/ada') + expect(JSON.stringify(resume)).not.toContain('provider-secret') }) function agentHistoryGrant(operation: 'snapshot' | 'preview' | 'resume') { diff --git a/mobile/src/mobile-web/mobile-web-capability-broker-race.test.ts b/mobile/src/mobile-web/mobile-web-capability-broker-race.test.ts index e4f485f96d3..58785ec8283 100644 --- a/mobile/src/mobile-web/mobile-web-capability-broker-race.test.ts +++ b/mobile/src/mobile-web/mobile-web-capability-broker-race.test.ts @@ -134,9 +134,7 @@ async function createPrimedHarness(alert?: MobileWebNativeCapabilityAuthority['a navigationAuthority: { route: vi.fn(), reconnect: vi.fn(), - removeHost: vi.fn(), - consumeRecentUserGesture: () => true, - hasRecentUserGesture: () => true + removeHost: vi.fn() } }) sendRequest.mockResolvedValueOnce({ diff --git a/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts b/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts index d3e9cf2613e..55d48cc737f 100644 --- a/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts +++ b/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts @@ -8,10 +8,6 @@ import { MobileWebSourceControlSubscribePayloadSchema } from '../../../src/share import { MobileWebSpeechSubscribePayloadSchema } from '../../../src/shared/mobile-web/speech-operation-contract' import { executeMobileWebAccountCapability } from './mobile-web-account-capability' import { executeMobileWebAgentHistoryOperation } from './mobile-web-agent-history-operations' -import { - mobileWebUserGestureConsumer, - mobileWebUserGestureWitness -} from './mobile-web-user-gesture-requirement' import { MobileWebBrokerError } from './mobile-web-broker-error' import { executeMobileWebBrowserOperation } from './mobile-web-browser-operations' import type { MobileWebCapabilityExecutionDependencies } from './mobile-web-capability-execution-dependencies' @@ -50,9 +46,7 @@ async function executeNative(args: Deps, request: OnceRequest): Promise payload: request.payload, authority: args.nativeAuthority, browserAuthority: args.browserAuthority, - workspaceAuthority: args.workspaceAuthority, - consumeRecentUserGesture: mobileWebUserGestureConsumer(args.navigationAuthority), - hasRecentUserGesture: mobileWebUserGestureWitness(args.navigationAuthority) + workspaceAuthority: args.workspaceAuthority }) } @@ -85,8 +79,7 @@ async function executeWorkspace(args: Deps, request: OnceRequest): Promise { - return args.terminalStreams.handle( - request.payload, - args.connectedClient(), - mobileWebUserGestureConsumer(args.navigationAuthority) - ) + return args.terminalStreams.handle(request.payload, args.connectedClient()) } async function executeFile(args: Deps, request: OnceRequest): Promise { @@ -195,8 +184,7 @@ async function executeSpeech(args: Deps, request: OnceRequest): Promise operation: request.operation, payload: request.payload, client: args.connectedClient(), - authority: args.speechAuthority, - consumeRecentUserGesture: mobileWebUserGestureConsumer(args.navigationAuthority) + authority: args.speechAuthority }) } diff --git a/mobile/src/mobile-web/mobile-web-mutation-authorization-race.test.ts b/mobile/src/mobile-web/mobile-web-mutation-authorization-race.test.ts index c5b10d0ff86..25b19fc1280 100644 --- a/mobile/src/mobile-web/mobile-web-mutation-authorization-race.test.ts +++ b/mobile/src/mobile-web/mobile-web-mutation-authorization-race.test.ts @@ -95,8 +95,7 @@ describe('mobile web mutation authorization races', () => { workspaceAuthority: workspace.authority, nativeChatAuthority: chat, nativeAuthority: { sessionChatPendingWrite }, - terminalClientId: 'mobile-client', - consumeRecentUserGesture: () => false + terminalClientId: 'mobile-client' }) const rejection = expect(pending).rejects.toMatchObject({ code: 'not_found' }) diff --git a/mobile/src/mobile-web/mobile-web-native-capability-operations.test.ts b/mobile/src/mobile-web/mobile-web-native-capability-operations.test.ts index 8ad431875a7..a2c3d4dec32 100644 --- a/mobile/src/mobile-web/mobile-web-native-capability-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-capability-operations.test.ts @@ -5,26 +5,23 @@ import { MobileWebBrowserAuthority } from './mobile-web-browser-authority' import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' describe('mobile web native capability operations', () => { - it('reads bounded shell-owned terminal preferences without a gesture', async () => { + it('reads bounded shell-owned terminal preferences', async () => { const harness = createHarness() await expect( executeMobileWebNativeCapabilityOperation({ operation: 'terminalPreferences', payload: {}, - authority: harness.authority, - consumeRecentUserGesture: harness.consumeRecentUserGesture, - hasRecentUserGesture: harness.hasRecentUserGesture + authority: harness.authority }) ).resolves.toEqual({ textScale: 1.25, autocompleteEnabled: true, linkOpenMode: 'phone-browser' }) - expect(harness.consumeRecentUserGesture).not.toHaveBeenCalled() }) - it('reads and gesture-gates bounded shell-owned terminal shortcuts', async () => { + it('reads and updates bounded shell-owned terminal shortcuts', async () => { const harness = createHarness() const customKeys = [{ id: 'custom-1', label: 'Build', bytes: 'pnpm build\r', enter: false }] @@ -32,83 +29,58 @@ describe('mobile web native capability operations', () => { executeMobileWebNativeCapabilityOperation({ operation: 'terminalAccessoryPreferences', payload: {}, - authority: harness.authority, - consumeRecentUserGesture: harness.consumeRecentUserGesture, - hasRecentUserGesture: harness.hasRecentUserGesture + authority: harness.authority }) ).resolves.toEqual({ customKeys, orderedBuiltInIds: ['escape', 'tab'], visibleBuiltInIds: ['escape'] }) - expect(harness.consumeRecentUserGesture).not.toHaveBeenCalled() await expect( executeMobileWebNativeCapabilityOperation({ operation: 'terminalCustomKeysUpdate', payload: { customKeys }, - authority: harness.authority, - consumeRecentUserGesture: harness.consumeRecentUserGesture, - hasRecentUserGesture: harness.hasRecentUserGesture + authority: harness.authority }) ).resolves.toBeNull() expect(harness.terminalCustomKeysUpdate).toHaveBeenCalledWith(customKeys) - expect(harness.consumeRecentUserGesture).toHaveBeenCalledOnce() }) - it('probes clipboard types without reading content or consuming a gesture', async () => { - const harness = createHarness(false) + it('probes clipboard types without reading content', async () => { + const harness = createHarness() await expect( executeMobileWebNativeCapabilityOperation({ operation: 'clipboardAvailability', payload: {}, - authority: harness.authority, - consumeRecentUserGesture: harness.consumeRecentUserGesture, - hasRecentUserGesture: harness.hasRecentUserGesture + authority: harness.authority }) ).resolves.toEqual({ hasText: true, hasImage: false }) expect(harness.clipboardAvailability).toHaveBeenCalledOnce() - expect(harness.consumeRecentUserGesture).not.toHaveBeenCalled() }) - it('requires and consumes a recent native gesture for clipboard writes', async () => { - const harness = createHarness(false) + it('writes only the bounded text the clipboard payload carries', async () => { + const harness = createHarness() await expect( executeMobileWebNativeCapabilityOperation({ operation: 'clipboardWrite', payload: { text: 'selected text' }, - authority: harness.authority, - consumeRecentUserGesture: harness.consumeRecentUserGesture, - hasRecentUserGesture: harness.hasRecentUserGesture - }) - ).rejects.toMatchObject({ code: 'permission_required' }) - expect(harness.clipboardWrite).not.toHaveBeenCalled() - - harness.consumeRecentUserGesture.mockReturnValue(true) - await expect( - executeMobileWebNativeCapabilityOperation({ - operation: 'clipboardWrite', - payload: { text: 'selected text' }, - authority: harness.authority, - consumeRecentUserGesture: harness.consumeRecentUserGesture, - hasRecentUserGesture: harness.hasRecentUserGesture + authority: harness.authority }) ).resolves.toEqual({ confirmation: 'in-app' }) expect(harness.clipboardWrite).toHaveBeenCalledWith('selected text') }) - it('allows only validated web URLs and gesture-bound text-scale updates', async () => { + it('allows only validated web URLs, and bounded text-scale updates', async () => { const harness = createHarness() await expect( executeMobileWebNativeCapabilityOperation({ operation: 'openExternal', payload: { url: 'javascript:alert(1)' }, - authority: harness.authority, - consumeRecentUserGesture: harness.consumeRecentUserGesture, - hasRecentUserGesture: harness.hasRecentUserGesture + authority: harness.authority }) ).rejects.toThrow() expect(harness.openExternal).not.toHaveBeenCalled() @@ -116,39 +88,32 @@ describe('mobile web native capability operations', () => { await executeMobileWebNativeCapabilityOperation({ operation: 'openExternal', payload: { url: 'https://example.com/path' }, - authority: harness.authority, - consumeRecentUserGesture: harness.consumeRecentUserGesture, - hasRecentUserGesture: harness.hasRecentUserGesture + authority: harness.authority }) await executeMobileWebNativeCapabilityOperation({ operation: 'terminalTextScaleUpdate', payload: { textScale: 1.5 }, - authority: harness.authority, - consumeRecentUserGesture: harness.consumeRecentUserGesture, - hasRecentUserGesture: harness.hasRecentUserGesture + authority: harness.authority }) expect(harness.openExternal).toHaveBeenCalledWith('https://example.com/path') expect(harness.terminalTextScaleUpdate).toHaveBeenCalledWith(1.5) }) - it('keeps bounded haptics independent from privileged gesture consumption', async () => { - const harness = createHarness(false) + it('runs bounded haptics through the shell authority', async () => { + const harness = createHarness() await executeMobileWebNativeCapabilityOperation({ operation: 'hapticFeedback', payload: { kind: 'edge-bump' }, - authority: harness.authority, - consumeRecentUserGesture: harness.consumeRecentUserGesture, - hasRecentUserGesture: harness.hasRecentUserGesture + authority: harness.authority }) expect(harness.hapticFeedback).toHaveBeenCalledWith('edge-bump') - expect(harness.consumeRecentUserGesture).not.toHaveBeenCalled() }) it('resolves opaque workspace authority before reading or writing a shell draft', async () => { - const harness = createHarness(false) + const harness = createHarness() const workspaceAuthority = new MobileWebWorkspaceAuthority((length) => new Uint8Array(length)) const browserAuthority = new MobileWebBrowserAuthority((length) => new Uint8Array(length)) const workspaceId = workspaceAuthority.registerWorkspace('host-workspace', 'host-repo') @@ -163,9 +128,7 @@ describe('mobile web native capability operations', () => { payload: { workspaceId, tabId: 'host-tab' }, authority: harness.authority, browserAuthority, - workspaceAuthority, - consumeRecentUserGesture: harness.consumeRecentUserGesture, - hasRecentUserGesture: harness.hasRecentUserGesture + workspaceAuthority }) ).resolves.toEqual({ text: 'saved draft' }) await expect( @@ -174,19 +137,16 @@ describe('mobile web native capability operations', () => { payload: { workspaceId, tabId: 'host-tab', text: 'next draft' }, authority: harness.authority, browserAuthority, - workspaceAuthority, - consumeRecentUserGesture: harness.consumeRecentUserGesture, - hasRecentUserGesture: harness.hasRecentUserGesture + workspaceAuthority }) ).resolves.toBeNull() expect(sessionChatDraftRead).toHaveBeenCalledWith('host-workspace', 'host-tab') expect(sessionChatDraftWrite).toHaveBeenCalledWith('host-workspace', 'host-tab', 'next draft') - expect(harness.consumeRecentUserGesture).not.toHaveBeenCalled() }) }) -function createHarness(hasRecentGesture = true) { +function createHarness() { const hapticFeedback = vi.fn() const clipboardAvailability = vi.fn().mockResolvedValue({ hasText: true, hasImage: false }) const clipboardWrite = vi.fn().mockResolvedValue({ confirmation: 'in-app' as const }) @@ -203,8 +163,6 @@ function createHarness(hasRecentGesture = true) { }) const terminalCustomKeysUpdate = vi.fn().mockResolvedValue(undefined) const terminalTextScaleUpdate = vi.fn().mockResolvedValue(undefined) - const consumeRecentUserGesture = vi.fn(() => hasRecentGesture) - const hasRecentUserGesture = vi.fn(() => hasRecentGesture) const authority: MobileWebNativeCapabilityAuthority = { hapticFeedback, clipboardAvailability, @@ -219,8 +177,6 @@ function createHarness(hasRecentGesture = true) { authority, clipboardAvailability, clipboardWrite, - consumeRecentUserGesture, - hasRecentUserGesture, hapticFeedback, openExternal, terminalCustomKeysUpdate, diff --git a/mobile/src/mobile-web/mobile-web-native-capability-operations.ts b/mobile/src/mobile-web/mobile-web-native-capability-operations.ts index ad249378cd6..abbc21cb7a9 100644 --- a/mobile/src/mobile-web/mobile-web-native-capability-operations.ts +++ b/mobile/src/mobile-web/mobile-web-native-capability-operations.ts @@ -19,7 +19,6 @@ import { MobileWebTerminalTextScaleUpdatePayloadSchema } from '../../../src/shared/mobile-web/native-operation-contract' import { MobileWebBrokerError } from './mobile-web-broker-error' -import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement' import type { MobileWebNativeCapabilityAuthority } from './mobile-web-native-capability-authority' import type { MobileWebBrowserAuthority } from './mobile-web-browser-authority' import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' @@ -30,12 +29,9 @@ export async function executeMobileWebNativeCapabilityOperation(args: { authority: MobileWebNativeCapabilityAuthority browserAuthority?: MobileWebBrowserAuthority workspaceAuthority?: MobileWebWorkspaceAuthority - consumeRecentUserGesture: () => boolean - hasRecentUserGesture: () => boolean }): Promise { if (args.operation === 'alert') { const payload = MobileWebNativeAlertPayloadSchema.parse(args.payload) - requireRecentUserGesture(args.hasRecentUserGesture) if (!args.authority.alert) { throw new MobileWebBrokerError('unavailable') } @@ -103,26 +99,22 @@ export async function executeMobileWebNativeCapabilityOperation(args: { } if (args.operation === 'clipboardWrite') { const payload = MobileWebClipboardWritePayloadSchema.parse(args.payload) - requireRecentUserGesture(args.consumeRecentUserGesture) return MobileWebClipboardWriteResultSchema.parse( await args.authority.clipboardWrite(payload.text) ) } if (args.operation === 'openExternal') { const payload = MobileWebOpenExternalPayloadSchema.parse(args.payload) - requireRecentUserGesture(args.consumeRecentUserGesture) await args.authority.openExternal(payload.url) return null } if (args.operation === 'terminalTextScaleUpdate') { const payload = MobileWebTerminalTextScaleUpdatePayloadSchema.parse(args.payload) - requireRecentUserGesture(args.consumeRecentUserGesture) await args.authority.terminalTextScaleUpdate(payload.textScale) return null } if (args.operation === 'terminalCustomKeysUpdate') { const payload = MobileWebTerminalCustomKeysUpdatePayloadSchema.parse(args.payload) - requireRecentUserGesture(args.consumeRecentUserGesture) if (!args.authority.terminalCustomKeysUpdate) { throw new MobileWebBrokerError('unavailable') } diff --git a/mobile/src/mobile-web/mobile-web-native-chat-capability.ts b/mobile/src/mobile-web/mobile-web-native-chat-capability.ts index 5cbc138461b..060de50cd0f 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-capability.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-capability.ts @@ -1,4 +1,3 @@ -import { mobileWebUserGestureConsumer } from './mobile-web-user-gesture-requirement' import type { MobileWebBridgePageMessage } from '../../../src/shared/mobile-web/bridge-contract' import type { MobileWebCapabilityExecutionDependencies } from './mobile-web-capability-execution-dependencies' import { executeMobileWebNativeChatOperation } from './mobile-web-native-chat-operations' @@ -17,8 +16,7 @@ export async function executeMobileWebNativeChatCapability( terminalClientId: args.terminalClientId, workspaceAuthority: args.workspaceAuthority, nativeChatAuthority: args.nativeChatAuthority, - nativeAuthority: args.nativeAuthority, - consumeRecentUserGesture: mobileWebUserGestureConsumer(args.navigationAuthority) + nativeAuthority: args.nativeAuthority }) } if (request.operation === 'subscribe') { diff --git a/mobile/src/mobile-web/mobile-web-native-chat-image-operations.test.ts b/mobile/src/mobile-web/mobile-web-native-chat-image-operations.test.ts index c1c0b102797..509b900dcf2 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-image-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-image-operations.test.ts @@ -19,25 +19,6 @@ const BINDING = { } describe('mobile web native-chat image operations', () => { - it('denies image picking before resolving host authority without a recent gesture', async () => { - const context = operationContext() - const sendRequest = vi.fn() - - await expect( - executeMobileWebNativeChatOperation({ - ...operationArgs(context, sendRequest, false), - operation: 'attachImage', - payload: { - workspaceId: context.pageWorkspaceId, - sessionId: context.pageSessionId, - source: 'library' - } - }) - ).rejects.toMatchObject({ code: 'permission_required' }) - expect(sendRequest).not.toHaveBeenCalled() - expect(prepareMobileWebNativeChatImageAttachment).not.toHaveBeenCalled() - }) - it('returns an opaque scoped reference instead of the uploaded host path', async () => { const context = operationContext() const sendRequest = vi @@ -50,7 +31,7 @@ describe('mobile web native-chat image operations', () => { }) const result = await executeMobileWebNativeChatOperation({ - ...operationArgs(context, sendRequest, true), + ...operationArgs(context, sendRequest), operation: 'attachImage', payload: { workspaceId: context.pageWorkspaceId, @@ -91,7 +72,7 @@ describe('mobile web native-chat image operations', () => { await expect( executeMobileWebNativeChatOperation({ - ...operationArgs(context, sendRequest, false), + ...operationArgs(context, sendRequest), operation: 'prepareCommit', payload: { workspaceId: context.pageWorkspaceId, @@ -125,7 +106,7 @@ describe('mobile web native-chat image operations', () => { await expect( executeMobileWebNativeChatOperation({ - ...operationArgs(context, sendRequest, false), + ...operationArgs(context, sendRequest), operation: 'sendMessage', payload: { workspaceId: context.pageWorkspaceId, @@ -156,16 +137,14 @@ function operationContext() { function operationArgs( context: ReturnType, - sendRequest: RpcClient['sendRequest'], - gesture: boolean + sendRequest: RpcClient['sendRequest'] ) { return { client: { sendRequest } as unknown as RpcClient, terminalClientId: 'mobile-device', workspaceAuthority: context.workspaceAuthority, nativeChatAuthority: context.nativeChatAuthority, - nativeAuthority: {}, - consumeRecentUserGesture: () => gesture + nativeAuthority: {} } } diff --git a/mobile/src/mobile-web/mobile-web-native-chat-image-operations.ts b/mobile/src/mobile-web/mobile-web-native-chat-image-operations.ts index 48d704679ab..896b15cf7d0 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-image-operations.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-image-operations.ts @@ -8,7 +8,6 @@ import { import { pasteMobileNativeChatImagePaths } from '../session/mobile-native-chat-image-send' import type { RpcClient } from '../transport/rpc-client' import { MobileWebBrokerError } from './mobile-web-broker-error' -import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement' import { assertCurrentMobileWebNativeChatPageBinding, resolveFreshMobileWebNativeChatPageBinding @@ -30,11 +29,9 @@ export async function executeMobileWebNativeChatImageOperation(args: { terminalClientId: string workspaceAuthority: MobileWebWorkspaceAuthority nativeChatAuthority: MobileWebNativeChatAuthority - consumeRecentUserGesture: () => boolean }): Promise { if (args.operation === 'attachImage') { const payload = MobileWebNativeChatAttachImagePayloadSchema.parse(args.payload) - requireRecentUserGesture(args.consumeRecentUserGesture) const binding = await resolveFreshMobileWebNativeChatPageBinding( args, payload.workspaceId, diff --git a/mobile/src/mobile-web/mobile-web-native-chat-operations.test.ts b/mobile/src/mobile-web/mobile-web-native-chat-operations.test.ts index 92ce870a420..6e71b71e556 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-operations.test.ts @@ -14,8 +14,7 @@ const binding = { transcriptPath: '/private/transcript.jsonl' } const OPERATION_RUNTIME = { - terminalClientId: 'mobile-device', - consumeRecentUserGesture: () => false + terminalClientId: 'mobile-device' } describe('mobile web native chat operations', () => { diff --git a/mobile/src/mobile-web/mobile-web-native-chat-operations.ts b/mobile/src/mobile-web/mobile-web-native-chat-operations.ts index 89dfb813c04..cd3a7a2c54f 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-operations.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-operations.ts @@ -44,7 +44,6 @@ export async function executeMobileWebNativeChatOperation(args: { MobileWebNativeCapabilityAuthority, 'sessionChatPendingRead' | 'sessionChatPendingWrite' > - consumeRecentUserGesture: () => boolean }): Promise { if (isMobileWebNativeChatImageOperation(args.operation)) { return executeMobileWebNativeChatImageOperation(args) diff --git a/mobile/src/mobile-web/mobile-web-native-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-native-roundtrip.test.ts index 2e93487cb66..3ad9ad626f0 100644 --- a/mobile/src/mobile-web/mobile-web-native-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-roundtrip.test.ts @@ -3,7 +3,7 @@ import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-round import { MOBILE_WEB_PRODUCTION_NATIVE_GRANTS } from './mobile-web-production-native-grants' describe('mobile web native capability round trip', () => { - it('keeps device effects in the shell behind typed grants and gestures', async () => { + it('keeps device effects in the shell behind typed grants', async () => { const alert = vi.fn().mockResolvedValue({ kind: 'button' as const, buttonIndex: 1 }) const hapticFeedback = vi.fn() const clipboardWrite = vi.fn().mockResolvedValue({ confirmation: 'in-app' as const }) @@ -20,7 +20,6 @@ describe('mobile web native capability round trip', () => { }) const terminalCustomKeysUpdate = vi.fn().mockResolvedValue(undefined) const terminalTextScaleUpdate = vi.fn().mockResolvedValue(undefined) - const consumeRecentUserGesture = vi.fn(() => true) let requestIndex = 0 const { client } = createMobileWebBridgeRoundtripFixture({ grants: [...MOBILE_WEB_PRODUCTION_NATIVE_GRANTS], @@ -39,9 +38,7 @@ describe('mobile web native capability round trip', () => { navigationAuthority: { route: vi.fn(), reconnect: vi.fn(), - removeHost: vi.fn(), - consumeRecentUserGesture, - hasRecentUserGesture: () => true + removeHost: vi.fn() } }) @@ -96,6 +93,5 @@ describe('mobile web native capability round trip', () => { expect(terminalCustomKeysUpdate).toHaveBeenCalledWith([ { id: 'custom-2', label: 'Test', bytes: 'pnpm test\r', enter: false } ]) - expect(consumeRecentUserGesture).toHaveBeenCalledTimes(4) }) }) diff --git a/mobile/src/mobile-web/mobile-web-native-route-handoff.test.ts b/mobile/src/mobile-web/mobile-web-native-route-handoff.test.ts index 151125b0a2b..4bbb3f6eeb8 100644 --- a/mobile/src/mobile-web/mobile-web-native-route-handoff.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-route-handoff.test.ts @@ -27,8 +27,7 @@ describe('mobile web native route handoff', () => { } }, reconnect: vi.fn(), - removeHost: vi.fn(), - consumeRecentUserGesture: () => true + removeHost: vi.fn() }, terminalClientId: 'native-only-device', randomBytes: (length) => new Uint8Array(length), diff --git a/mobile/src/mobile-web/mobile-web-navigation-operations.test.ts b/mobile/src/mobile-web/mobile-web-navigation-operations.test.ts index 69d6b177f59..e0ca1e62665 100644 --- a/mobile/src/mobile-web/mobile-web-navigation-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-navigation-operations.test.ts @@ -25,8 +25,8 @@ describe('mobile web navigation operations', () => { ).rejects.toBeTruthy() }) - it('requires a recent native-observed gesture for reconnect and removal', async () => { - const authority = navigationAuthority(false) + it('reconnects and removes the host through the native authority', async () => { + const authority = navigationAuthority() await expect( executeMobileWebNavigationOperation({ @@ -35,42 +35,22 @@ describe('mobile web navigation operations', () => { payload: {}, authority }) - ).rejects.toMatchObject({ code: 'permission_required' }) - await expect( - executeMobileWebNavigationOperation({ - requestId: 'D'.repeat(22), - operation: 'removeHost', - payload: { confirmation: 'remove-paired-host' }, - authority - }) - ).rejects.toMatchObject({ code: 'permission_required' }) - expect(authority.reconnect).not.toHaveBeenCalled() - expect(authority.removeHost).not.toHaveBeenCalled() + ).resolves.toBeNull() + expect(authority.reconnect).toHaveBeenCalledWith() }) - it('requires a recent native-observed gesture before opening native settings', async () => { - const deniedAuthority = navigationAuthority(false) + it('routes to native settings through the shell authority', async () => { + const authority = navigationAuthority() - await expect( - executeMobileWebNavigationOperation({ - requestId: 'E'.repeat(22), - operation: 'route', - payload: { destination: 'terminalSettings' }, - authority: deniedAuthority - }) - ).rejects.toMatchObject({ code: 'permission_required' }) - expect(deniedAuthority.route).not.toHaveBeenCalled() - - const allowedAuthority = navigationAuthority() await expect( executeMobileWebNavigationOperation({ requestId: 'F'.repeat(22), operation: 'route', payload: { destination: 'terminalSettings' }, - authority: allowedAuthority + authority }) ).resolves.toBeNull() - expect(allowedAuthority.route).toHaveBeenCalledWith('terminalSettings', 'F'.repeat(22)) + expect(authority.route).toHaveBeenCalledWith('terminalSettings', 'F'.repeat(22)) }) it('removes the native-selected host without accepting page identity', async () => { @@ -97,11 +77,10 @@ describe('mobile web navigation operations', () => { }) }) -function navigationAuthority(hasRecentUserGesture = true) { +function navigationAuthority() { return { route: vi.fn(), reconnect: vi.fn(), - removeHost: vi.fn(), - consumeRecentUserGesture: vi.fn(() => hasRecentUserGesture) + removeHost: vi.fn() } } diff --git a/mobile/src/mobile-web/mobile-web-navigation-operations.ts b/mobile/src/mobile-web/mobile-web-navigation-operations.ts index 917658d9354..65d2aabd16e 100644 --- a/mobile/src/mobile-web/mobile-web-navigation-operations.ts +++ b/mobile/src/mobile-web/mobile-web-navigation-operations.ts @@ -4,7 +4,6 @@ import { MobileWebNavigationRoutePayloadSchema } from '../../../src/shared/mobile-web/navigation-operation-contract' import { MobileWebBrokerError } from './mobile-web-broker-error' -import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement' export type MobileWebNavigationAuthority = { route( @@ -13,8 +12,6 @@ export type MobileWebNavigationAuthority = { ): void | Promise reconnect(): void | Promise removeHost(): void | Promise - consumeRecentUserGesture(): boolean - hasRecentUserGesture(): boolean } export async function executeMobileWebNavigationOperation(args: { @@ -26,23 +23,18 @@ export async function executeMobileWebNavigationOperation(args: { if (args.operation === 'route') { const payload = MobileWebNavigationRoutePayloadSchema.parse(args.payload) const authority = requireAuthority(args.authority) - if (payload.destination === 'terminalSettings') { - requireRecentUserGesture(() => authority.consumeRecentUserGesture()) - } await authority.route(payload.destination, args.requestId) return null } if (args.operation === 'reconnect') { MobileWebNavigationReconnectPayloadSchema.parse(args.payload) const authority = requireAuthority(args.authority) - requireRecentUserGesture(() => authority.consumeRecentUserGesture()) await authority.reconnect() return null } if (args.operation === 'removeHost') { MobileWebNavigationRemoveHostPayloadSchema.parse(args.payload) const authority = requireAuthority(args.authority) - requireRecentUserGesture(() => authority.consumeRecentUserGesture()) await authority.removeHost() return null } diff --git a/mobile/src/mobile-web/mobile-web-navigation-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-navigation-roundtrip.test.ts index 68147b896c1..d1c83a10c5c 100644 --- a/mobile/src/mobile-web/mobile-web-navigation-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-navigation-roundtrip.test.ts @@ -7,7 +7,6 @@ describe('mobile web navigation round trip', () => { const route = vi.fn() const reconnect = vi.fn() const removeHost = vi.fn() - const consumeRecentUserGesture = vi.fn(() => true) let requestIndex = 0 const { client } = createMobileWebBridgeRoundtripFixture({ grants: [...MOBILE_WEB_PRODUCTION_NAVIGATION_GRANTS], @@ -16,9 +15,7 @@ describe('mobile web navigation round trip', () => { navigationAuthority: { route, reconnect, - removeHost, - consumeRecentUserGesture, - hasRecentUserGesture: () => true + removeHost } }) @@ -33,6 +30,5 @@ describe('mobile web navigation round trip', () => { expect(route).toHaveBeenCalledWith('terminalSettings', 'S'.repeat(22)) expect(reconnect).toHaveBeenCalledWith() expect(removeHost).toHaveBeenCalledWith() - expect(consumeRecentUserGesture).toHaveBeenCalledTimes(3) }) }) diff --git a/mobile/src/mobile-web/mobile-web-speech-operations.test.ts b/mobile/src/mobile-web/mobile-web-speech-operations.test.ts index 81afcbe629d..597ae9be68e 100644 --- a/mobile/src/mobile-web/mobile-web-speech-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-speech-operations.test.ts @@ -1,49 +1,30 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse } from '../transport/types' -import type { MobileWebBrokerError } from './mobile-web-broker-error' import type { MobileWebSpeechAuthority } from './mobile-web-speech-authority' import { executeMobileWebSpeechOperation } from './mobile-web-speech-operations' describe('executeMobileWebSpeechOperation', () => { - it('parses bounded setup metadata without requiring a user gesture', async () => { + it('parses bounded setup metadata', async () => { const harness = createHarness() harness.sendRequest.mockResolvedValue(success(setup())) - await expect(harness.execute('setup', {}, () => false)).resolves.toEqual(setup()) + await expect(harness.execute('setup', {})).resolves.toEqual(setup()) expect(harness.sendRequest).toHaveBeenCalledWith('speech.models.list', null) }) - it.each(['downloadModel', 'deleteModel', 'configure', 'start'])( - 'requires a recent native-observed gesture for %s', - async (operation) => { - const harness = createHarness() + it('rejects a payload the operation contract does not accept', async () => { + const harness = createHarness() - await expect( - harness.execute( - operation, - operation === 'configure' - ? { enabled: true } - : operation === 'start' - ? {} - : { modelId: 'model-1' }, - () => false - ) - ).rejects.toMatchObject>({ - code: 'permission_required' - }) - expect(harness.sendRequest).not.toHaveBeenCalled() - expect(harness.authority.start).not.toHaveBeenCalled() - } - ) + await expect(harness.execute('configure', { dictationMode: 'shout' })).rejects.toBeTruthy() + expect(harness.sendRequest).not.toHaveBeenCalled() + }) it('returns only parsed setup state after deleting a model', async () => { const harness = createHarness() harness.sendRequest.mockResolvedValue(success(setup())) - await expect( - harness.execute('deleteModel', { modelId: 'model-1' }, () => true) - ).resolves.toEqual(setup()) + await expect(harness.execute('deleteModel', { modelId: 'model-1' })).resolves.toEqual(setup()) expect(harness.sendRequest).toHaveBeenCalledWith('speech.models.delete', { modelId: 'model-1' }) @@ -61,13 +42,12 @@ function createHarness() { return { sendRequest, authority, - execute: (operation: string, payload: unknown, consumeRecentUserGesture: () => boolean) => + execute: (operation: string, payload: unknown) => executeMobileWebSpeechOperation({ operation, payload, client, - authority, - consumeRecentUserGesture + authority }) } } diff --git a/mobile/src/mobile-web/mobile-web-speech-operations.ts b/mobile/src/mobile-web/mobile-web-speech-operations.ts index db80954b1a8..c0cf9be0005 100644 --- a/mobile/src/mobile-web/mobile-web-speech-operations.ts +++ b/mobile/src/mobile-web/mobile-web-speech-operations.ts @@ -7,7 +7,6 @@ import { } from '../../../src/shared/mobile-web/speech-operation-contract' import type { RpcClient } from '../transport/rpc-client' import { MobileWebBrokerError } from './mobile-web-broker-error' -import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement' import type { MobileWebSpeechAuthority } from './mobile-web-speech-authority' import { configureMobileWebSpeech, @@ -21,29 +20,24 @@ export async function executeMobileWebSpeechOperation(args: { payload: unknown client: RpcClient authority: MobileWebSpeechAuthority - consumeRecentUserGesture: () => boolean }): Promise { if (args.operation === 'setup') { return loadMobileWebSpeechSetup(args.client, args.payload) } if (args.operation === 'downloadModel') { MobileWebSpeechModelActionPayloadSchema.parse(args.payload) - requireRecentUserGesture(args.consumeRecentUserGesture) return downloadMobileWebSpeechModel(args.client, args.payload) } if (args.operation === 'deleteModel') { MobileWebSpeechModelActionPayloadSchema.parse(args.payload) - requireRecentUserGesture(args.consumeRecentUserGesture) return deleteMobileWebSpeechModel(args.client, args.payload) } if (args.operation === 'configure') { MobileWebSpeechConfigurePayloadSchema.parse(args.payload) - requireRecentUserGesture(args.consumeRecentUserGesture) return configureMobileWebSpeech(args.client, args.payload) } if (args.operation === 'start') { MobileWebSpeechStartPayloadSchema.parse(args.payload) - requireRecentUserGesture(args.consumeRecentUserGesture) return args.authority.start(args.client) } if (args.operation === 'stop') { diff --git a/mobile/src/mobile-web/mobile-web-speech-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-speech-roundtrip.test.ts index 165b269d92a..751c797c59f 100644 --- a/mobile/src/mobile-web/mobile-web-speech-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-speech-roundtrip.test.ts @@ -44,19 +44,12 @@ describe('mobile web speech broker', () => { }) }) - it('rejects speech configuration without a recent native-observed gesture', async () => { + it('rejects a speech payload the operation contract does not accept', async () => { const harness = createHarness() - await harness.broker.handle( - request('A', 'once', 'configure', { - dictationMode: 'hold' - }) - ) + await harness.broker.handle(request('A', 'once', 'configure', { dictationMode: 'shout' })) - expect(harness.messages.at(-1)).toMatchObject({ - status: 'error', - error: { code: 'permission_required' } - }) + expect(harness.messages.at(-1)).toMatchObject({ status: 'error' }) expect(harness.sendRequest).not.toHaveBeenCalled() }) }) @@ -69,9 +62,7 @@ function createHarness() { navigationAuthority: { route: vi.fn(), reconnect: vi.fn(), - removeHost: vi.fn(), - consumeRecentUserGesture: vi.fn(() => false), - hasRecentUserGesture: () => true + removeHost: vi.fn() }, now: () => 1000 }) diff --git a/mobile/src/mobile-web/mobile-web-terminal-streams.test.ts b/mobile/src/mobile-web/mobile-web-terminal-streams.test.ts index 31f4d45c34d..d5b7409f455 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-streams.test.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-streams.test.ts @@ -297,7 +297,7 @@ describe('MobileWebTerminalStreams', () => { expect(decodeTerminalStreamText(harness.sentFrames.at(-1)!.payload)).toBe('\x1b[0n') }) - it('gesture-gates shell-owned device input and returns status without native paths', async () => { + it('keeps shell-owned device input native and returns status without native paths', async () => { const harness = createHarness() await harness.streams.start({ requestId: 'request-device-input', @@ -316,19 +316,6 @@ describe('MobileWebTerminalStreams', () => { payload: '\u001b[200~/native/secret/image.png\u001b[201~' }) - expect(() => - harness.streams.handle( - { - operation: 'clipboardPaste', - streamId: SUBSCRIPTION_ID, - sequence: 0, - bracketedPaste: true - }, - harness.client, - () => false - ) - ).toThrow('permission_required') - await expect( harness.streams.handle( { @@ -337,8 +324,7 @@ describe('MobileWebTerminalStreams', () => { sequence: 0, bracketedPaste: true }, - harness.client, - () => true + harness.client ) ).resolves.toEqual({ status: 'accepted' }) expect(decodeTerminalStreamText(harness.sentFrames.at(-1)!.payload)).toContain( diff --git a/mobile/src/mobile-web/mobile-web-terminal-streams.ts b/mobile/src/mobile-web/mobile-web-terminal-streams.ts index 883cc6fed16..589b54388ec 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-streams.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-streams.ts @@ -9,7 +9,6 @@ import { type TerminalStreamFrame } from '../transport/terminal-stream-protocol' import { MobileWebBrokerError } from './mobile-web-broker-error' -import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement' import { runMobileWebTerminalAction } from './mobile-web-terminal-actions' import { acknowledgeMobileWebTerminalOutput, @@ -114,8 +113,7 @@ export class MobileWebTerminalStreams { handle( payload: unknown, - client: RpcClient, - consumeRecentUserGesture: () => boolean = () => false + client: RpcClient ): null | Promise { const request = MobileWebTerminalRequestSchema.parse(payload) if (request.operation === 'subscribe') { @@ -142,9 +140,6 @@ export class MobileWebTerminalStreams { request }).then(() => null) } - if (request.operation === 'clipboardPaste' || request.operation === 'attachImage') { - requireRecentUserGesture(consumeRecentUserGesture) - } return handleMobileWebTerminalStreamRequest({ client, record, diff --git a/mobile/src/mobile-web/mobile-web-user-gesture-census.test.ts b/mobile/src/mobile-web/mobile-web-user-gesture-census.test.ts deleted file mode 100644 index 40e200253f1..00000000000 --- a/mobile/src/mobile-web/mobile-web-user-gesture-census.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { describe, expect, it, vi } from 'vitest' -import { MobileWebBrokerError } from './mobile-web-broker-error' -import { - mobileWebUserGestureConsumer, - mobileWebUserGestureWitness, - requireRecentUserGesture -} from './mobile-web-user-gesture-requirement' - -const DIRECTORY = import.meta.dirname + '/' - -// Every operation whose gate must survive a refactor. A gate added without a row here fails, and a -// row whose gate is deleted fails. -// Which predicate each file passes: 'consume' spends the gesture, 'witness' only observes it. -// alert is the one witness: it precedes the consuming action a confirm dialog exists to confirm. -const GESTURE_PREDICATES: Record = { - 'mobile-web-account-operations.ts': ['consume', 'consume'], - 'mobile-web-agent-history-operations.ts': ['consume'], - 'mobile-web-native-capability-operations.ts': [ - 'witness', - 'consume', - 'consume', - 'consume', - 'consume' - ], - 'mobile-web-native-chat-image-operations.ts': ['consume'], - 'mobile-web-navigation-operations.ts': ['consume', 'consume', 'consume'], - 'mobile-web-speech-operations.ts': ['consume', 'consume', 'consume', 'consume'], - 'mobile-web-terminal-streams.ts': ['consume'], - 'mobile-web-workspace-creation-create-operations.ts': ['consume', 'consume'] -} - -const GESTURE_GATED_DISCRIMINATORS: Record = { - 'mobile-web-account-operations.ts': ['consumeResetCredit', 'select'], - 'mobile-web-agent-history-operations.ts': ['resume'], - 'mobile-web-native-capability-operations.ts': [ - 'alert', - 'clipboardWrite', - 'openExternal', - 'terminalCustomKeysUpdate', - 'terminalTextScaleUpdate' - ], - 'mobile-web-native-chat-image-operations.ts': ['attachImage'], - 'mobile-web-navigation-operations.ts': ['reconnect', 'removeHost', 'terminalSettings'], - 'mobile-web-speech-operations.ts': ['configure', 'deleteModel', 'downloadModel', 'start'], - 'mobile-web-terminal-streams.ts': ['attachImage', 'clipboardPaste'], - 'mobile-web-workspace-creation-create-operations.ts': [ - 'creationCreateBlank', - 'creationCreateFromSource' - ] -} - -describe('mobile web user gesture requirement census', () => { - it('routes every gesture gate through the one shared requirement', () => { - const gated = readdirSync(DIRECTORY).filter( - (name) => - name.endsWith('.ts') && - !name.endsWith('.test.ts') && - name !== 'mobile-web-user-gesture-requirement.ts' && - readFileSync(DIRECTORY + name, 'utf8').includes('requireRecentUserGesture(') - ) - - expect(gated.toSorted()).toEqual(Object.keys(GESTURE_GATED_DISCRIMINATORS).toSorted()) - for (const name of gated) { - const source = readFileSync(DIRECTORY + name, 'utf8') - expect([name, guardedDiscriminators(source)]).toEqual([ - name, - GESTURE_GATED_DISCRIMINATORS[name] - ]) - expect([name, gesturePredicates(source)]).toEqual([name, GESTURE_PREDICATES[name]]) - } - }) - - it('leaves no open-coded permission_required gesture check behind', () => { - for (const name of readdirSync(DIRECTORY).filter( - (file) => file.endsWith('.ts') && !file.endsWith('.test.ts') - )) { - const source = readFileSync(DIRECTORY + name, 'utf8') - expect([ - name, - /consumeRecentUserGesture\(\)\s*\)?\s*\{[\s\S]{0,80}permission_required/.test(source) - ]).toEqual([name, false]) - } - }) - - it('denies when the gesture is absent, stale, or unplumbed', () => { - expect(() => requireRecentUserGesture(() => false)).toThrow(MobileWebBrokerError) - expect(() => requireRecentUserGesture(undefined)).toThrow( - expect.objectContaining({ code: 'permission_required' }) - ) - expect(() => requireRecentUserGesture(() => true)).not.toThrow() - }) - - it('denies through the consumer when no navigation authority is plumbed', () => { - expect(mobileWebUserGestureConsumer(undefined)()).toBe(false) - expect( - mobileWebUserGestureConsumer({ - route: () => {}, - reconnect: () => {}, - removeHost: () => {}, - consumeRecentUserGesture: () => true - })() - ).toBe(true) - }) - - it('witnesses a gesture without spending it', () => { - const consumeRecentUserGesture = vi.fn(() => true) - const authority = { - route: () => {}, - reconnect: () => {}, - removeHost: () => {}, - consumeRecentUserGesture, - hasRecentUserGesture: () => true - } - - expect(mobileWebUserGestureWitness(authority)()).toBe(true) - expect(mobileWebUserGestureWitness(undefined)()).toBe(false) - expect(consumeRecentUserGesture).not.toHaveBeenCalled() - }) - - it('spends the gesture exactly once so a replayed request is denied', () => { - let gestures = 1 - const consume = mobileWebUserGestureConsumer({ - route: () => {}, - reconnect: () => {}, - removeHost: () => {}, - consumeRecentUserGesture: () => gestures-- > 0 - }) - - expect(() => requireRecentUserGesture(consume)).not.toThrow() - expect(() => requireRecentUserGesture(consume)).toThrow( - expect.objectContaining({ code: 'permission_required' }) - ) - }) -}) - -// The discriminators of the `if` condition guarding each requireRecentUserGesture call. -function guardedDiscriminators(source: string): string[] { - const names = new Set() - for (const call of source.matchAll(/requireRecentUserGesture\(/g)) { - const guardStart = source.lastIndexOf('if (', call.index) - expect(guardStart).toBeGreaterThan(-1) - for (const match of source - .slice(guardStart, call.index) - .matchAll(/=== '([A-Za-z][A-Za-z0-9.-]*)'/g)) { - names.add(match[1]!) - } - } - return [...names].toSorted() -} - -// In call order: the argument each requireRecentUserGesture call receives, classified by whether -// it spends the gesture (consumeRecentUserGesture / mobileWebUserGestureConsumer) or witnesses it. -function gesturePredicates(source: string): string[] { - return [...source.matchAll(/requireRecentUserGesture\(([^)]*\)?[^)]*)\)/g)].map( - ([, argument]) => { - if (/hasRecentUserGesture|mobileWebUserGestureWitness/.test(argument)) { - return 'witness' - } - if (/consumeRecentUserGesture|mobileWebUserGestureConsumer/.test(argument)) { - return 'consume' - } - throw new Error(`unclassified gesture predicate: ${argument}`) - } - ) -} diff --git a/mobile/src/mobile-web/mobile-web-user-gesture-operations.test.ts b/mobile/src/mobile-web/mobile-web-user-gesture-operations.test.ts deleted file mode 100644 index ad775ef4db2..00000000000 --- a/mobile/src/mobile-web/mobile-web-user-gesture-operations.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RpcClient } from '../transport/rpc-client' -import { executeMobileWebAccountOperation } from './mobile-web-account-operations' -import { executeMobileWebNativeCapabilityOperation } from './mobile-web-native-capability-operations' -import { executeMobileWebNavigationOperation } from './mobile-web-navigation-operations' -import { executeMobileWebSpeechOperation } from './mobile-web-speech-operations' -import type { MobileWebNativeCapabilityAuthority } from './mobile-web-native-capability-authority' -import type { MobileWebSpeechAuthority } from './mobile-web-speech-authority' - -type GatedCase = { - name: string - run: (consumeRecentUserGesture: () => boolean) => Promise -} - -const CASES: GatedCase[] = [ - nativeCase('clipboardWrite', { text: 'copied' }), - nativeCase('openExternal', { url: 'https://example.invalid/docs' }), - nativeCase('terminalTextScaleUpdate', { textScale: 1 }), - nativeCase('terminalCustomKeysUpdate', { customKeys: [] }), - speechCase('downloadModel', { modelId: 'tiny' }), - speechCase('deleteModel', { modelId: 'tiny' }), - speechCase('configure', { enabled: true }), - speechCase('start', {}), - accountCase('select', { provider: 'claude', accountId: null }), - accountCase('consumeResetCredit', { - expectedScope: { - target: { runtime: 'host', wslDistro: null }, - accountId: 'account-1', - accountRevision: 1, - offerRevision: 'v1:offer' - } - }), - navigationCase('route', { destination: 'terminalSettings' }), - navigationCase('reconnect', {}), - navigationCase('removeHost', { confirmation: 'remove-paired-host' }) -] - -describe.each(CASES)('gesture-gated $name', ({ run }) => { - it('denies the operation without a recent user gesture', async () => { - await expect(run(() => false)).rejects.toMatchObject({ code: 'permission_required' }) - }) - - it('lets the operation past the gate with a recent user gesture', async () => { - expect(await failureCode(run(() => true))).not.toBe('permission_required') - }) -}) - -describe('gesture-gated native alert', () => { - const ALERT = { - title: 'Discard changes?', - buttons: [{ text: 'Stay', style: 'cancel' as const }] - } - - it('denies an OS alert the page raises without a recent user gesture', async () => { - await expect(runAlert(() => false)).rejects.toMatchObject({ code: 'permission_required' }) - }) - - it('witnesses the gesture without spending it, so the confirmed action still has one', async () => { - const consumeRecentUserGesture = vi.fn(() => true) - - await expect(runAlert(() => true, consumeRecentUserGesture)).resolves.toEqual({ - kind: 'dismissed' - }) - expect(consumeRecentUserGesture).not.toHaveBeenCalled() - }) - - function runAlert( - hasRecentUserGesture: () => boolean, - consumeRecentUserGesture: () => boolean = () => true - ): Promise { - return executeMobileWebNativeCapabilityOperation({ - operation: 'alert', - payload: ALERT, - authority: { - alert: async () => ({ kind: 'dismissed' }), - hapticFeedback: () => {} - } as unknown as MobileWebNativeCapabilityAuthority, - consumeRecentUserGesture, - hasRecentUserGesture - }) - } -}) - -describe('gesture-gated operations reached through other executors', () => { - // These four gates share the same requirement but need host-side dependency graphs to reach, so - // their denial is proven by the suites named here; the census pins that the gates still exist. - it.each([ - ['resume', 'mobile-web-agent-history-roundtrip.test.ts'], - ['attachImage', 'mobile-web-native-chat-image-operations.test.ts'], - ['creationCreateBlank', 'mobile-web-workspace-creation-create-operations.test.ts'], - ['clipboardPaste', 'mobile-web-terminal-streams.test.ts'] - ])('keeps a permission_required assertion for %s in %s', async (operation, file) => { - const source = await import('node:fs').then((fs) => - fs.readFileSync(new URL(`./${file}`, import.meta.url), 'utf8') - ) - - expect(source).toContain(operation) - expect(source).toContain('permission_required') - }) -}) - -async function failureCode(pending: Promise): Promise { - return pending.then( - () => null, - (error: unknown) => (error as { code?: unknown }).code - ) -} - -function nativeCase(name: string, payload: unknown): GatedCase { - return { - name, - run: (consumeRecentUserGesture) => - executeMobileWebNativeCapabilityOperation({ - operation: name, - payload, - authority: { - hapticFeedback: () => {}, - clipboardAvailability: async () => ({ hasText: false, hasImage: false }), - clipboardWrite: async () => ({ confirmation: 'in-app' }), - openExternal: async () => {}, - terminalPreferences: async () => ({ - textScale: 1, - autocompleteEnabled: true, - linkOpenMode: 'phone-browser' - }), - terminalTextScaleUpdate: async () => {}, - terminalCustomKeysUpdate: async () => {} - } as MobileWebNativeCapabilityAuthority, - consumeRecentUserGesture, - hasRecentUserGesture: () => false - }) - } -} - -function speechCase(name: string, payload: unknown): GatedCase { - return { - name, - run: (consumeRecentUserGesture) => - executeMobileWebSpeechOperation({ - operation: name, - payload, - client: unavailableClient(), - authority: {} as MobileWebSpeechAuthority, - consumeRecentUserGesture - }) - } -} - -function accountCase(name: string, payload: unknown): GatedCase { - return { - name, - run: (consumeRecentUserGesture) => - executeMobileWebAccountOperation({ - operation: name, - payload, - client: unavailableClient(), - nativeAuthority: {} as MobileWebNativeCapabilityAuthority, - consumeRecentUserGesture - }) - } -} - -function navigationCase(name: string, payload: unknown): GatedCase { - return { - name, - run: (consumeRecentUserGesture) => - executeMobileWebNavigationOperation({ - requestId: 'R'.repeat(22), - operation: name, - payload, - authority: { - route: () => {}, - reconnect: () => {}, - removeHost: () => {}, - consumeRecentUserGesture - } - }) - } -} - -function unavailableClient(): RpcClient { - return { - sendRequest: async () => ({ ok: false, error: { code: 'unavailable', message: 'no host' } }) - } as unknown as RpcClient -} diff --git a/mobile/src/mobile-web/mobile-web-user-gesture-requirement.ts b/mobile/src/mobile-web/mobile-web-user-gesture-requirement.ts deleted file mode 100644 index b4b0ea54625..00000000000 --- a/mobile/src/mobile-web/mobile-web-user-gesture-requirement.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { MobileWebBrokerError } from './mobile-web-broker-error' -import type { MobileWebNavigationAuthority } from './mobile-web-navigation-operations' - -export function mobileWebUserGestureConsumer( - navigationAuthority: MobileWebNavigationAuthority | undefined -): () => boolean { - return () => navigationAuthority?.consumeRecentUserGesture() ?? false -} - -// Witnesses a gesture without spending it, so a gated action that follows the same tap still has -// one to spend. -export function mobileWebUserGestureWitness( - navigationAuthority: MobileWebNavigationAuthority | undefined -): () => boolean { - return () => navigationAuthority?.hasRecentUserGesture() ?? false -} - -// A missing consumer denies: an operation reachable without the shell's gesture plumbing has no -// gesture to spend. -export function requireRecentUserGesture( - consumeRecentUserGesture: (() => boolean) | undefined -): void { - if (!consumeRecentUserGesture?.()) { - throw new MobileWebBrokerError('permission_required') - } -} diff --git a/mobile/src/mobile-web/mobile-web-user-gesture.test.ts b/mobile/src/mobile-web/mobile-web-user-gesture.test.ts deleted file mode 100644 index 6c4c29c4551..00000000000 --- a/mobile/src/mobile-web/mobile-web-user-gesture.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { - consumeRecentMobileWebUserGesture, - MOBILE_WEB_USER_GESTURE_MAX_AGE_MS -} from './mobile-web-user-gesture' - -describe('mobile web user gesture', () => { - it('accepts only a recent native-observed touch', () => { - expect( - consumeRecentMobileWebUserGesture({ - appState: 'active', - occurredAt: 1_000, - now: 1_001 - }) - ).toBe(true) - expect( - consumeRecentMobileWebUserGesture({ - appState: 'active', - occurredAt: 1_000, - now: 1_000 + MOBILE_WEB_USER_GESTURE_MAX_AGE_MS - }) - ).toBe(true) - expect( - consumeRecentMobileWebUserGesture({ - appState: 'active', - occurredAt: 1_000, - now: 1_001 + MOBILE_WEB_USER_GESTURE_MAX_AGE_MS - }) - ).toBe(false) - }) - - it('rejects missing and future touches', () => { - expect( - consumeRecentMobileWebUserGesture({ - appState: 'active', - occurredAt: null, - now: 1_000 - }) - ).toBe(false) - expect( - consumeRecentMobileWebUserGesture({ - appState: 'active', - occurredAt: 1_001, - now: 1_000 - }) - ).toBe(false) - }) - - it.each(['background', 'inactive', 'unknown', 'extension'] as const)( - 'rejects a recent touch while the native app is %s', - (appState) => { - expect( - consumeRecentMobileWebUserGesture({ - appState, - occurredAt: 1_000, - now: 1_001 - }) - ).toBe(false) - } - ) - - it('revokes the shell gesture when native lifecycle leaves foreground', () => { - const authoritySource = readFileSync( - new URL('./use-mobile-web-user-gesture-authority.ts', import.meta.url), - 'utf8' - ) - - expect(authoritySource).toContain("AppState.addEventListener('change'") - expect(authoritySource).toContain("if (nextState !== 'active')") - expect(authoritySource).toContain('occurredAtRef.current = null') - expect(authoritySource).toContain('appState: AppState.currentState') - expect(authoritySource).toContain( - "foregroundAuthorityRef.current?.updateAppForegroundState(nextState === 'active')" - ) - }) -}) diff --git a/mobile/src/mobile-web/mobile-web-user-gesture.ts b/mobile/src/mobile-web/mobile-web-user-gesture.ts deleted file mode 100644 index 42534b770ee..00000000000 --- a/mobile/src/mobile-web/mobile-web-user-gesture.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AppStateStatus } from 'react-native' - -export const MOBILE_WEB_USER_GESTURE_MAX_AGE_MS = 5_000 - -export function consumeRecentMobileWebUserGesture(args: { - appState: AppStateStatus - occurredAt: number | null - now: number -}): boolean { - if (args.appState !== 'active' || args.occurredAt === null) { - return false - } - const age = args.now - args.occurredAt - return age >= 0 && age <= MOBILE_WEB_USER_GESTURE_MAX_AGE_MS -} diff --git a/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.test.ts b/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.test.ts index 6527050196d..522ee29fd5c 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.test.ts @@ -5,26 +5,6 @@ import { executeMobileWebWorkspaceCreationCreateOperation } from './mobile-web-w import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' describe('mobile web workspace creation writes', () => { - it('requires a native-observed gesture before any host operation', async () => { - const authority = workspaceAuthority() - authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }]) - const sendRequest = vi.fn() - const consumeRecentUserGesture = vi.fn(() => false) - - await expect( - executeMobileWebWorkspaceCreationCreateOperation({ - operation: 'creationCreateBlank', - payload: blankPayload(authority.pageRepoId('host-repo-secret')), - client: { sendRequest } as unknown as RpcClient, - authority, - consumeRecentUserGesture - }) - ).rejects.toMatchObject({ code: 'permission_required' }) - - expect(consumeRecentUserGesture).toHaveBeenCalledOnce() - expect(sendRequest).not.toHaveBeenCalled() - }) - it('revalidates a PR and its fork base natively before creating', async () => { const authority = workspaceAuthority() authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }]) @@ -113,8 +93,7 @@ describe('mobile web workspace creation writes', () => { } }, client: { sendRequest } as unknown as RpcClient, - authority, - consumeRecentUserGesture: vi.fn(() => true) + authority }) expect(sendRequest).toHaveBeenCalledWith('github.workItem', { @@ -171,8 +150,7 @@ describe('mobile web workspace creation writes', () => { client: { sendRequest: vi.fn().mockResolvedValue({ ok: true, result: { capabilities: [] } }) } as unknown as RpcClient, - authority, - consumeRecentUserGesture: vi.fn(() => true) + authority }) ).rejects.toMatchObject({ code: 'not_found' }) }) diff --git a/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.ts b/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.ts index 85f3d76cd85..02a4726eae3 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.ts @@ -12,7 +12,6 @@ import type { MobileComposerCreateSelection } from '../tasks/mobile-composer-sou import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection' import { nativeHostWorkspaceCreationOperations } from '../worktree/native-host-workspace-creation-operations' import { MobileWebBrokerError } from './mobile-web-broker-error' -import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement' import { mobileWebHostRepoIdFromHost, type MobileWebWorkspaceAuthority @@ -23,12 +22,10 @@ export async function executeMobileWebWorkspaceCreationCreateOperation(args: { payload: unknown client: RpcClient authority: MobileWebWorkspaceAuthority - consumeRecentUserGesture?: () => boolean }): Promise { const operations = nativeHostWorkspaceCreationOperations(args.client) if (args.operation === 'creationCreateBlank') { const payload = MobileWebCreationBlankPayloadSchema.parse(args.payload) - requireRecentUserGesture(args.consumeRecentUserGesture) const capabilities = await operations.readRuntimeCapabilities() const hostRepoId = args.authority.hostRepoId(payload.repoId) const result = await operations.createBlankWorkspace({ @@ -42,7 +39,6 @@ export async function executeMobileWebWorkspaceCreationCreateOperation(args: { } if (args.operation === 'creationCreateFromSource') { const payload = MobileWebCreationFromSourcePayloadSchema.parse(args.payload) - requireRecentUserGesture(args.consumeRecentUserGesture) const capabilities = await operations.readRuntimeCapabilities() const hostRepoId = args.authority.hostRepoId(payload.targetRepoId) const selection = await authoritativeSelection(payload.selection, operations, args.authority) diff --git a/mobile/src/mobile-web/mobile-web-workspace-creation-provider-revalidation.test.ts b/mobile/src/mobile-web/mobile-web-workspace-creation-provider-revalidation.test.ts index fba496d322d..455d8437d56 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-creation-provider-revalidation.test.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-creation-provider-revalidation.test.ts @@ -180,8 +180,7 @@ function createFromSource(args: { agentChoice: 'blank' }, client: { sendRequest: args.sendRequest } as unknown as RpcClient, - authority: args.authority, - consumeRecentUserGesture: vi.fn(() => true) + authority: args.authority }) } diff --git a/mobile/src/mobile-web/mobile-web-workspace-creation-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-workspace-creation-roundtrip.test.ts index 418dbb92c75..c7d71806daa 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-creation-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-creation-roundtrip.test.ts @@ -7,7 +7,6 @@ import { MOBILE_WEB_PRODUCTION_WORKSPACE_CREATION_GRANTS } from './mobile-web-pr describe('mobile web workspace creation round trip', () => { it('carries page requests through schemas and resolves host authority only in native', async () => { - const consumeRecentUserGesture = vi.fn(() => true) const sendRequest = vi.fn(async (method: string) => { if (method === 'repo.list') { return { @@ -45,9 +44,7 @@ describe('mobile web workspace creation round trip', () => { navigationAuthority: { route: vi.fn(), reconnect: vi.fn(), - removeHost: vi.fn(), - consumeRecentUserGesture, - hasRecentUserGesture: () => true + removeHost: vi.fn() } }) @@ -75,7 +72,6 @@ describe('mobile web workspace creation round trip', () => { workspaceId: expect.stringMatching(/^workspace_/), name: 'mobile-workspace' }) - expect(consumeRecentUserGesture).toHaveBeenCalledOnce() expect(sendRequest).toHaveBeenCalledWith( 'worktree.create', expect.objectContaining({ diff --git a/mobile/src/mobile-web/mobile-web-workspace-operations.ts b/mobile/src/mobile-web/mobile-web-workspace-operations.ts index 34ebad8bdb5..eb854b8b8b0 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-operations.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-operations.ts @@ -29,7 +29,6 @@ export async function executeMobileWebWorkspaceOperation(args: { client: RpcClient authority: MobileWebWorkspaceAuthority snapshots: MobileWebWorkspaceSnapshotPager - consumeRecentUserGesture?: () => boolean }): Promise { if (args.capability === 'settings') { return executeSettingsOperation(args) diff --git a/mobile/src/mobile-web/use-mobile-web-app-foreground-authority.test.ts b/mobile/src/mobile-web/use-mobile-web-app-foreground-authority.test.ts new file mode 100644 index 00000000000..8b1b361cc79 --- /dev/null +++ b/mobile/src/mobile-web/use-mobile-web-app-foreground-authority.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +describe('mobile web app foreground authority', () => { + it('reports native lifecycle transitions to the broker', () => { + const source = readFileSync( + new URL('./use-mobile-web-app-foreground-authority.ts', import.meta.url), + 'utf8' + ) + + expect(source).toContain("AppState.addEventListener('change'") + expect(source).toContain( + "foregroundAuthorityRef.current?.updateAppForegroundState(nextState === 'active')" + ) + expect(source).toContain('subscription.remove()') + }) +}) diff --git a/mobile/src/mobile-web/use-mobile-web-app-foreground-authority.ts b/mobile/src/mobile-web/use-mobile-web-app-foreground-authority.ts new file mode 100644 index 00000000000..2eb4d66774f --- /dev/null +++ b/mobile/src/mobile-web/use-mobile-web-app-foreground-authority.ts @@ -0,0 +1,17 @@ +import { useEffect, type RefObject } from 'react' +import { AppState } from 'react-native' + +type MobileWebAppForegroundAuthority = { + updateAppForegroundState(foreground: boolean): void +} + +export function useMobileWebAppForegroundAuthority( + foregroundAuthorityRef: RefObject +): void { + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextState) => { + foregroundAuthorityRef.current?.updateAppForegroundState(nextState === 'active') + }) + return () => subscription.remove() + }, [foregroundAuthorityRef]) +} diff --git a/mobile/src/mobile-web/use-mobile-web-navigation-authority.ts b/mobile/src/mobile-web/use-mobile-web-navigation-authority.ts index 74e935a24af..13ca00cc6cf 100644 --- a/mobile/src/mobile-web/use-mobile-web-navigation-authority.ts +++ b/mobile/src/mobile-web/use-mobile-web-navigation-authority.ts @@ -16,9 +16,7 @@ export function useMobileWebNavigationAuthority({ router, clearColdResumeRoute, closeHostClient, - forceReconnectHost, - consumeRecentUserGesture, - hasRecentUserGesture + forceReconnectHost }: { hostId: string | undefined hostPublicKeyB64: string | undefined @@ -27,8 +25,6 @@ export function useMobileWebNavigationAuthority({ clearColdResumeRoute: () => void closeHostClient: (hostId: string) => void forceReconnectHost: (hostId: string) => void | Promise - consumeRecentUserGesture: () => boolean - hasRecentUserGesture: () => boolean }): MobileWebNavigationAuthority | undefined { return useMemo(() => { if (!hostId || !hostPublicKeyB64) { @@ -52,16 +48,12 @@ export function useMobileWebNavigationAuthority({ }, removeHost() { return removeHostAndCloseClient(hostId, hostPublicKeyB64, closeHostClient) - }, - consumeRecentUserGesture, - hasRecentUserGesture + } } }, [ clearColdResumeRoute, closeHostClient, - consumeRecentUserGesture, forceReconnectHost, - hasRecentUserGesture, hostId, hostPublicKeyB64, routeHandoffRef, diff --git a/mobile/src/mobile-web/use-mobile-web-user-gesture-authority.ts b/mobile/src/mobile-web/use-mobile-web-user-gesture-authority.ts deleted file mode 100644 index c514091a60c..00000000000 --- a/mobile/src/mobile-web/use-mobile-web-user-gesture-authority.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useCallback, useEffect, type MutableRefObject, type RefObject } from 'react' -import { AppState } from 'react-native' -import { consumeRecentMobileWebUserGesture } from './mobile-web-user-gesture' - -export type MobileWebUserGestureAuthority = { - consumeRecentUserGesture: () => boolean - // Witnesses the gesture without spending it: presenting an OS dialog must not disarm the gated - // action the dialog is confirming. - hasRecentUserGesture: () => boolean -} - -type MobileWebAppForegroundAuthority = { - updateAppForegroundState(foreground: boolean): void -} - -export function useMobileWebUserGestureAuthority( - occurredAtRef: MutableRefObject, - foregroundAuthorityRef: RefObject -): MobileWebUserGestureAuthority { - useEffect(() => { - const subscription = AppState.addEventListener('change', (nextState) => { - foregroundAuthorityRef.current?.updateAppForegroundState(nextState === 'active') - if (nextState !== 'active') { - occurredAtRef.current = null - } - }) - return () => subscription.remove() - }, [foregroundAuthorityRef, occurredAtRef]) - - const hasRecentUserGesture = useCallback( - () => - consumeRecentMobileWebUserGesture({ - appState: AppState.currentState, - occurredAt: occurredAtRef.current, - now: Date.now() - }), - [occurredAtRef] - ) - const consumeRecentUserGesture = useCallback(() => { - const recent = hasRecentUserGesture() - occurredAtRef.current = null - return recent - }, [hasRecentUserGesture, occurredAtRef]) - - return { consumeRecentUserGesture, hasRecentUserGesture } -}