mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge branch 'mr-gesture-delete' into mobile-rearch
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
+3
-13
@@ -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<MobileWebCapabilityBroker | null>(null)
|
||||
const postInitRef = useRef<() => Promise<void>>(() => Promise.resolve())
|
||||
const nativeRouteHandoffRef = useRef(new MobileWebNativeRouteHandoff())
|
||||
const recentWebGestureAtRef = useRef<number | null>(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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+28
-20
@@ -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)
|
||||
@@ -33,7 +33,6 @@ type MobileWebHybridShellPresentationProps = {
|
||||
onUsePrevious: () => void | Promise<void>
|
||||
onClearCache: () => void | Promise<void>
|
||||
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 ? (
|
||||
<View style={styles.webContainer} onTouchStart={onTouch}>
|
||||
<View style={styles.webContainer}>
|
||||
{packageLoading && packageProgress ? (
|
||||
<MobileWebPackageProgress progress={packageProgress} />
|
||||
) : null}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<unknown> {
|
||||
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')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<unknown>
|
||||
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<unkno
|
||||
payload: request.payload,
|
||||
client: args.connectedClient(),
|
||||
authority: args.workspaceAuthority,
|
||||
snapshots: args.workspaceSnapshots,
|
||||
consumeRecentUserGesture: mobileWebUserGestureConsumer(args.navigationAuthority)
|
||||
snapshots: args.workspaceSnapshots
|
||||
})
|
||||
if (request.capability === 'workspace' && request.operation === 'activate') {
|
||||
args.terminalArtifactAuthority.clear()
|
||||
@@ -118,11 +111,7 @@ async function executeSession(args: Deps, request: OnceRequest): Promise<unknown
|
||||
}
|
||||
|
||||
async function executeTerminal(args: Deps, request: OnceRequest): Promise<unknown> {
|
||||
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<unknown> {
|
||||
@@ -195,8 +184,7 @@ async function executeSpeech(args: Deps, request: OnceRequest): Promise<unknown>
|
||||
operation: request.operation,
|
||||
payload: request.payload,
|
||||
client: args.connectedClient(),
|
||||
authority: args.speechAuthority,
|
||||
consumeRecentUserGesture: mobileWebUserGestureConsumer(args.navigationAuthority)
|
||||
authority: args.speechAuthority
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<unknown> {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<RpcClient['sendRequest']>()
|
||||
|
||||
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<typeof operationContext>,
|
||||
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: {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<unknown> {
|
||||
if (args.operation === 'attachImage') {
|
||||
const payload = MobileWebNativeChatAttachImagePayloadSchema.parse(args.payload)
|
||||
requireRecentUserGesture(args.consumeRecentUserGesture)
|
||||
const binding = await resolveFreshMobileWebNativeChatPageBinding(
|
||||
args,
|
||||
payload.workspaceId,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -44,7 +44,6 @@ export async function executeMobileWebNativeChatOperation(args: {
|
||||
MobileWebNativeCapabilityAuthority,
|
||||
'sessionChatPendingRead' | 'sessionChatPendingWrite'
|
||||
>
|
||||
consumeRecentUserGesture: () => boolean
|
||||
}): Promise<unknown> {
|
||||
if (isMobileWebNativeChatImageOperation(args.operation)) {
|
||||
return executeMobileWebNativeChatImageOperation(args)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
reconnect(): void | Promise<void>
|
||||
removeHost(): void | Promise<void>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Partial<MobileWebBrokerError>>({
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<unknown> {
|
||||
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') {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<null | MobileWebTerminalDeviceInputResult> {
|
||||
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,
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
'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<string, string[]> = {
|
||||
'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<string>()
|
||||
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}`)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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<unknown>
|
||||
}
|
||||
|
||||
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<unknown> {
|
||||
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<unknown>): Promise<unknown> {
|
||||
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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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')"
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
@@ -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<unknown> {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -29,7 +29,6 @@ export async function executeMobileWebWorkspaceOperation(args: {
|
||||
client: RpcClient
|
||||
authority: MobileWebWorkspaceAuthority
|
||||
snapshots: MobileWebWorkspaceSnapshotPager
|
||||
consumeRecentUserGesture?: () => boolean
|
||||
}): Promise<unknown> {
|
||||
if (args.capability === 'settings') {
|
||||
return executeSettingsOperation(args)
|
||||
|
||||
@@ -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()')
|
||||
})
|
||||
})
|
||||
@@ -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<MobileWebAppForegroundAuthority | null>
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (nextState) => {
|
||||
foregroundAuthorityRef.current?.updateAppForegroundState(nextState === 'active')
|
||||
})
|
||||
return () => subscription.remove()
|
||||
}, [foregroundAuthorityRef])
|
||||
}
|
||||
@@ -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<void>
|
||||
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,
|
||||
|
||||
@@ -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<number | null>,
|
||||
foregroundAuthorityRef: RefObject<MobileWebAppForegroundAuthority | null>
|
||||
): 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user