From 90236c3bdec628e96d49f7f4cafc1dd6ba14fa6f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:11:54 -0700 Subject: [PATCH] refactor(browser): one page gate and one owner for a host command's hold (#30652) The revocation gate, the input cancel list and the hold's lifecycle each lived in two places. Sinks now call one gate, held input is recorded at the CDP send (and the one native key path), and runWithRemoteCliPageAdmission owns retire-on-revoke, release and error conversion. --- ...ent-browser-bridge-host-revocation.test.ts | 29 ++-- .../browser/agent-browser-bridge-queue.ts | 52 ++------ src/main/browser/agent-input-injection.ts | 32 +---- src/main/browser/cdp-debugger-channel.ts | 7 - src/main/browser/cdp-page-input-commands.ts | 6 +- .../cdp-ws-proxy-host-admission.test.ts | 2 +- src/main/browser/cdp-ws-proxy.ts | 4 +- src/main/browser/guest-cdp-command.ts | 28 ++-- src/main/browser/guest-direct-key-input.ts | 32 ++--- src/main/browser/remote-cli-page-admission.ts | 126 +++++++++++------- .../remote-cli-browser-scope.ts | 8 +- 11 files changed, 143 insertions(+), 183 deletions(-) diff --git a/src/main/browser/agent-browser-bridge-host-revocation.test.ts b/src/main/browser/agent-browser-bridge-host-revocation.test.ts index f7523473484..c1a92924431 100644 --- a/src/main/browser/agent-browser-bridge-host-revocation.test.ts +++ b/src/main/browser/agent-browser-bridge-host-revocation.test.ts @@ -43,7 +43,10 @@ import { captureRemoteCliBrowserScope, runInRemoteCliBrowserScope } from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' -import { assertRemoteCliPageAdmitted } from './remote-cli-page-admission' +import { + assertRemoteCliPageAccess, + runUnderRemoteCliPageAdmission +} from './remote-cli-page-admission' import { createFakeAgentBrowserChild, mockBrowserManager, @@ -57,6 +60,11 @@ overrideBridgeWebContentsLookup(AgentBrowserBridge.prototype, webContentsFromIdM const WORKSPACE = 'repo-host::/srv/app' +/** A helper step reaching the page's CDP proxy now. */ +function assertHelperStepAdmitted(): void { + runUnderRemoteCliPageAdmission(100, () => assertRemoteCliPageAccess(100)) +} + function dispatchedMouseEvents(wc: ReturnType): unknown[] { return wc.debugger.sendCommand.mock.calls.filter((call) => call[0] === 'Input.dispatchMouseEvent') } @@ -126,24 +134,21 @@ describe('AgentBrowserBridge under a host revocation', () => { revoked.signal ) await vi.waitFor(() => expect(helperCallbacks).toHaveLength(1)) - expect(() => assertRemoteCliPageAdmitted(100)).not.toThrow() + expect(() => assertHelperStepAdmitted()).not.toThrow() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every instance the hoisted mock constructs has a vi.fn stop. const proxy = CdpWsProxyMock.instances.at(-1) as { stop: ReturnType } let heldWhenRetired = false proxy.stop.mockImplementation(async () => { - heldWhenRetired = (() => { - try { - assertRemoteCliPageAdmitted(100) - return false - } catch { - return true - } - })() + try { + assertHelperStepAdmitted() + } catch { + heldWhenRetired = true + } }) revoked.abort() // The helper's next CDP step reaches the page's proxy and is refused there. - expect(() => assertRemoteCliPageAdmitted(100)).toThrow('stopped sharing') + expect(() => assertHelperStepAdmitted()).toThrow('stopped sharing') // Retired on the revocation itself, not when the helper's client times out. await vi.waitFor(() => expect(proxy.stop).toHaveBeenCalled()) helperCallbacks[0]!( @@ -153,7 +158,7 @@ describe('AgentBrowserBridge under a host revocation', () => { ) await expect(click).rejects.toMatchObject({ code: 'desktop_browser_bridge_unavailable' }) - expect(() => assertRemoteCliPageAdmitted(100)).not.toThrow() + expect(() => assertHelperStepAdmitted()).not.toThrow() // The helper's daemon outlives its client, so it is retired before the page is let go. expect(heldWhenRetired).toBe(true) expect(execFileMock).toHaveBeenCalledWith( diff --git a/src/main/browser/agent-browser-bridge-queue.ts b/src/main/browser/agent-browser-bridge-queue.ts index e63b2842e44..910ffba8f07 100644 --- a/src/main/browser/agent-browser-bridge-queue.ts +++ b/src/main/browser/agent-browser-bridge-queue.ts @@ -6,14 +6,12 @@ import type { EnqueueTargetedCommandOptions, ResolvedBrowserCommandTarget } from './agent-browser-bridge-types' -import { isAdmissionRevoked, runWithRemoteCliPageAdmission } from './remote-cli-page-admission' +import { runWithRemoteCliPageAdmission } from './remote-cli-page-admission' import { AGENT_BROWSER_CLEANUP_TIMEOUT_MS } from './agent-browser-bridge-types' import { releaseHeldPageInput } from './guest-direct-key-input' import { captureRemoteCliBrowserRevocation, - captureRemoteCliBrowserRevokedSignal, - captureRemoteCliBrowserScope, - runOutsideRemoteCliBrowserScope + captureRemoteCliBrowserScope } from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown { @@ -95,7 +93,7 @@ export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown const gate = (): void => assertNotRevoked(target.webContentsId) gate() return origin.bridged - ? this.runAdmitted(session, target.webContentsId, gate, () => + ? this.runHostCommand(session, target.webContentsId, gate, origin.revoked, () => execute(session, target) ) : execute(session, target) @@ -127,53 +125,31 @@ export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown return execute(sessionName, target) } - /** - * Holds the page for a host's command: every later page-affecting step, including the helper's - * through the CDP proxy, is refused once the host is revoked, and the command reports that. - */ - private async runAdmitted( + /** Holds the page for a host's command, so a revocation stops every later step of it. */ + private runHostCommand( sessionName: string, webContentsId: number, gate: () => void, + revoked: AbortSignal | undefined, execute: () => Promise ): Promise { - // Why: the helper's daemon outlives its killed client and keeps retrying; retire it while the - // page is still held, or its next step lands once the hold is gone. Retiring on the signal also - // frees the page's queue now instead of at the helper's exec timeout. - let retiring: Promise | null = null - const retire = (): Promise => - (retiring ??= runOutsideRemoteCliBrowserScope(() => + return runWithRemoteCliPageAdmission(webContentsId, gate, execute, { + revoked, + // Why keep the queue: commands waiting on the page still run, on a fresh helper session. + retire: () => this.destroySession(sessionName, { closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS, keepQueuedCommands: true - }) - )) - const signal = captureRemoteCliBrowserRevokedSignal() - const onRevoked = (): void => void retire() - signal?.addEventListener('abort', onRevoked, { once: true }) - const held = async (): Promise => { - try { - return await execute() - } finally { - signal?.removeEventListener('abort', onRevoked) - if (retiring || isAdmissionRevoked(gate)) { - await retire() - } - } - } - try { - return await runWithRemoteCliPageAdmission(webContentsId, gate, held, (release) => { + }), + releaseHeld: (release) => { const wc = this.getWebContents(webContentsId) return wc ? releaseHeldPageInput(wc, release, () => this.browserManager.guardUserFocusForAgentInput(webContentsId) ) : Promise.resolve() - }) - } catch (error) { - gate() - throw error - } + } + }) } protected async processQueue(sessionName: string): Promise { diff --git a/src/main/browser/agent-input-injection.ts b/src/main/browser/agent-input-injection.ts index 658d06bc7f2..dd6a3fc0d1c 100644 --- a/src/main/browser/agent-input-injection.ts +++ b/src/main/browser/agent-input-injection.ts @@ -1,10 +1,5 @@ import type { WebContents } from 'electron' -import { - assertRemoteCliPageAdmitted, - isInputRelease, - noteRemoteCliInput -} from './remote-cli-page-admission' -import { assertRemoteCliBrowserAccess } from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' +import { assertRemoteCliPageAccess } from './remote-cli-page-admission' // Why: Orca's guest shortcut and grab handlers act on keys the page receives, and an unhandled key // can reach the app menu. Keys an agent injects must do neither (#20595), whichever path sends them. @@ -14,38 +9,19 @@ export function isAgentInjectingInput(webContents: WebContents): boolean { return (injectionDepth.get(webContents) ?? 0) > 0 } -/** The CDP input event being delivered, so a revocation can release what a host pressed. */ -export type AgentInputEvent = { method: string; params: Record } - -/** Refuses agent input that a revoked SSH host's command sends, directly or through its helper. */ -export function assertAgentInputAdmitted(webContents: WebContents): void { - assertRemoteCliBrowserAccess(webContents.id) - assertRemoteCliPageAdmitted(webContents.id) -} - /** Marks input sent during `run` as the agent's: Orca shortcuts and app menu items ignore it. */ export async function withAgentInputInjection( webContents: WebContents, - run: () => Promise, - input?: AgentInputEvent + run: () => Promise ): Promise { - assertAgentInputAdmitted(webContents) - // Why: a press counts as held once it may have landed; a release only once it surely did. - const releasing = input !== undefined && isInputRelease(input.params) - if (input && !releasing) { - noteRemoteCliInput(webContents.id, input.method, input.params) - } + assertRemoteCliPageAccess(webContents.id) const depth = injectionDepth.get(webContents) ?? 0 injectionDepth.set(webContents, depth + 1) if (depth === 0) { webContents.setIgnoreMenuShortcuts(true) } try { - const result = await run() - if (input && releasing) { - noteRemoteCliInput(webContents.id, input.method, input.params) - } - return result + return await run() } finally { const remaining = (injectionDepth.get(webContents) ?? 1) - 1 if (remaining > 0) { diff --git a/src/main/browser/cdp-debugger-channel.ts b/src/main/browser/cdp-debugger-channel.ts index 82da834ca1d..18620aa6c6d 100644 --- a/src/main/browser/cdp-debugger-channel.ts +++ b/src/main/browser/cdp-debugger-channel.ts @@ -1,7 +1,6 @@ import { WebSocket } from 'ws' import type { WebContents } from 'electron' import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' -import { assertRemoteCliPageAdmitted } from './remote-cli-page-admission' import type { CdpClientResponseWriter } from './cdp-client-response-writer' import type { CdpSyntheticSessionRegistry } from './cdp-synthetic-session-registry' import { sendGuestCdpCommand } from './guest-cdp-command' @@ -94,12 +93,6 @@ export class CdpDebuggerChannel { params: Record, sessionId?: string ): Promise { - // Why: the proxy's queued input outlives the message's admission; a revoked host's must not land. - try { - assertRemoteCliPageAdmitted(this.webContents.id) - } catch (error) { - return Promise.reject(error) - } return sessionId ? sendGuestCdpCommand(this.webContents, method, params, sessionId) : sendGuestCdpCommand(this.webContents, method, params) diff --git a/src/main/browser/cdp-page-input-commands.ts b/src/main/browser/cdp-page-input-commands.ts index 57e0bf15fe5..89da2e803fd 100644 --- a/src/main/browser/cdp-page-input-commands.ts +++ b/src/main/browser/cdp-page-input-commands.ts @@ -85,10 +85,8 @@ export class CdpPageInputCommands { return await this.debuggerChannel.sendDebuggerCommand(method, params, sessionId) } const send = (): Promise => - withAgentInputInjection( - this.webContents, - () => this.debuggerChannel.sendDebuggerCommand(method, params, sessionId), - { method, params } + withAgentInputInjection(this.webContents, () => + this.debuggerChannel.sendDebuggerCommand(method, params, sessionId) ) const routedByFocus = !sessionId && isFocusRoutedCdpInput(method) if (!routedByFocus && !isFocusTakingCdpInput(method, params)) { diff --git a/src/main/browser/cdp-ws-proxy-host-admission.test.ts b/src/main/browser/cdp-ws-proxy-host-admission.test.ts index ef43cdc249e..8a18a963eaf 100644 --- a/src/main/browser/cdp-ws-proxy-host-admission.test.ts +++ b/src/main/browser/cdp-ws-proxy-host-admission.test.ts @@ -104,7 +104,7 @@ describe('CdpWsProxy under a host command', () => { } }, () => new Promise((resolve) => (finish = resolve)), - (release) => releaseHeldPageInput(page, release, () => guardFocus()) + { releaseHeld: (release) => releaseHeldPageInput(page, release, () => guardFocus()) } ) return { revoke: () => (revoked = true), diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 163e857c01d..127ad59f3f2 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -13,7 +13,7 @@ import type { AgentInputFocusGuard } from './agent-input-focus-guard' import { CdpPageInputCommands } from './cdp-page-input-commands' import { isTrustedCdpLoopbackRequest } from './cdp-loopback-request-guard' import { - assertRemoteCliPageAdmitted, + assertRemoteCliPageAccess, runUnderRemoteCliPageAdmission } from './remote-cli-page-admission' @@ -186,7 +186,7 @@ export class CdpWsProxy { this.responder.recordRequestSessionId(client, clientId, msg) // Why here too: a revoked host's helper is refused even its read-only steps, so it stops at once. try { - assertRemoteCliPageAdmitted(this.webContents.id) + assertRemoteCliPageAccess(this.webContents.id) } catch (error) { this.responder.sendError( clientId, diff --git a/src/main/browser/guest-cdp-command.ts b/src/main/browser/guest-cdp-command.ts index f3845e552a9..d71bca59b8e 100644 --- a/src/main/browser/guest-cdp-command.ts +++ b/src/main/browser/guest-cdp-command.ts @@ -1,7 +1,6 @@ import type { WebContents } from 'electron' import { BrowserError } from './browser-error' -import { assertRemoteCliBrowserAccess } from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' -import { isInputRelease, noteRemoteCliInput } from './remote-cli-page-admission' +import { assertRemoteCliPageAccess, trackRemoteCliInput } from './remote-cli-page-admission' // Why: Chromium resizes the page's view for these without checking the view still exists // (WebContentsImpl::SetDeviceEmulationSize). A crashed renderer takes its view with it until the @@ -28,8 +27,8 @@ export function sendGuestCdpCommand( sessionId?: string ): Promise { try { - // Why: only a host's own command runs in its scope; desktop-owned sends are never refused here. - assertRemoteCliBrowserAccess(guest.id) + // Why: only a host's own work carries its scope; desktop-owned sends are never refused here. + assertRemoteCliPageAccess(guest.id) } catch (error) { return Promise.reject(error) } @@ -43,20 +42,11 @@ export function sendGuestCdpCommand( ) ) } - // Why: a press counts as held once it may have landed; a release only once it surely did. - const releasing = params !== undefined && isInputRelease(params) - if (!releasing) { - noteRemoteCliInput(guest.id, method, params) - } - const sent = Promise.resolve( - sessionId === undefined - ? guest.debugger.sendCommand(method, params) - : guest.debugger.sendCommand(method, params, sessionId) + return trackRemoteCliInput(guest.id, method, params, () => + Promise.resolve( + sessionId === undefined + ? guest.debugger.sendCommand(method, params) + : guest.debugger.sendCommand(method, params, sessionId) + ) ) - return releasing - ? sent.then((result: unknown) => { - noteRemoteCliInput(guest.id, method, params) - return result - }) - : sent } diff --git a/src/main/browser/guest-direct-key-input.ts b/src/main/browser/guest-direct-key-input.ts index 08e9d2649ab..a33b34dd299 100644 --- a/src/main/browser/guest-direct-key-input.ts +++ b/src/main/browser/guest-direct-key-input.ts @@ -1,6 +1,6 @@ import type { WebContents } from 'electron' import { enablePageFocusEmulation, type AgentInputFocusGuard } from './agent-input-focus-guard' -import { assertAgentInputAdmitted, withAgentInputInjection } from './agent-input-injection' +import { withAgentInputInjection } from './agent-input-injection' import { resolveFocusedFrameRoute, settleWithin, @@ -12,7 +12,11 @@ import { sendGuestCdpCommand } from './guest-cdp-command' import { acquireElectronDebugger } from './electron-debugger-lease' import { normalizeCdpPointerButton } from './agent-browser-bridge-mouse' import { cdpPointerStateFor, releaseCdpPointerButton } from './cdp-pointer-input' -import type { HeldInputRelease } from './remote-cli-page-admission' +import { + assertRemoteCliPageAccess, + trackRemoteCliInput, + type HeldInputRelease +} from './remote-cli-page-admission' // Why: Chromium routes main-session CDP key and text input on a guest by the // window's focus, so it lands in whatever the user is typing into (#20595). Events sent to @@ -43,7 +47,7 @@ async function waitForPageToHandleInput(send: GuestCdpSender): Promise { function runEditCommand(webContents: WebContents, command: GuestEditCommand): void { // Why: it runs after awaiting the page, so a host's access may have been revoked meanwhile. - assertAgentInputAdmitted(webContents) + assertRemoteCliPageAccess(webContents.id) switch (command) { case 'selectAll': webContents.selectAll() @@ -91,10 +95,8 @@ async function sendToFrameSession( method: string, params: Record ): Promise { - return await withAgentInputInjection( - webContents, - () => sendFrameSessionInput(webContents, send, sessionId, method, params), - { method, params } + return await withAgentInputInjection(webContents, () => + sendFrameSessionInput(webContents, send, sessionId, method, params) ) } @@ -111,7 +113,6 @@ export async function dispatchPageKeyEvent( if (!isEmbeddedGuest(webContents)) { return await send('Input.dispatchKeyEvent', params) } - const keyInput = { method: 'Input.dispatchKeyEvent', params } const route = await resolveFocusedFrameRoute(webContents, send) if (route.kind === 'session') { // Why: Tab from a frame's last field can move focus out of the guest into Orca's window. @@ -137,9 +138,9 @@ export async function dispatchPageKeyEvent( ? await guardFocus() : () => {} try { - await withAgentInputInjection( - webContents, - async () => { + await withAgentInputInjection(webContents, () => + // Why: widget events skip the CDP send, so this key is recorded on the page's hold here. + trackRemoteCliInput(webContents.id, 'Input.dispatchKeyEvent', params, async () => { for (const event of plan.events) { webContents.sendInputEvent(event) } @@ -149,8 +150,7 @@ export async function dispatchPageKeyEvent( for (const command of plan.commands) { runEditCommand(webContents, command) } - }, - keyInput + }) ) } finally { release() @@ -159,11 +159,7 @@ export async function dispatchPageKeyEvent( } const release = await focusGuestForCdpInput(webContents, send, guardFocus) try { - return await withAgentInputInjection( - webContents, - () => send('Input.dispatchKeyEvent', params), - keyInput - ) + return await withAgentInputInjection(webContents, () => send('Input.dispatchKeyEvent', params)) } finally { release() } diff --git a/src/main/browser/remote-cli-page-admission.ts b/src/main/browser/remote-cli-page-admission.ts index c8097915083..e19476f28f9 100644 --- a/src/main/browser/remote-cli-page-admission.ts +++ b/src/main/browser/remote-cli-page-admission.ts @@ -1,78 +1,114 @@ /** - * The pages an SSH host's command is driving right now, each with the check that refuses its next - * step once that host is revoked. Every page-affecting sink (guest CDP sends, native input events, - * edit commands) consults it, so no queued or deferred step of a running command outlives the - * revocation. + * One SSH host command's hold on a page. While it runs, every page sink (guest CDP sends, native + * input, edit commands, the CDP proxy's intake) refuses work attributed to it once the host is + * revoked. Work is attributed by async context: the command's own through its bridged scope, its + * out-of-process helper's through the proxy message it arrived on. The hold also owns the + * command's input cancel list: what it pressed and has not released, let go of on revocation. */ import { AsyncLocalStorage } from 'node:async_hooks' -import { runOutsideRemoteCliBrowserScope } from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' +import { + assertRemoteCliBrowserAccess, + runOutsideRemoteCliBrowserScope +} from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' /** The CDP input event that lets go of something a command pressed. */ export type HeldInputRelease = { method: string; params: Record } +export type PageHoldHooks = { + /** Fires when the host is revoked; `retire` then runs at once, while the page is still held. */ + revoked?: AbortSignal + /** Stops the command's helper, whose daemon outlives its killed client and keeps retrying. */ + retire?: () => Promise + releaseHeld?: (release: HeldInputRelease) => Promise +} + type Admission = { admit: () => void - /** What this command pressed and has not released, keyed by button, key or touch. */ + /** Keyed by button, key or touch. */ held: Map } const admissions = new Map() -/** The admission a proxy message arrived under; its deferred steps keep it after the hold ends. */ +/** The hold a proxy message arrived under; its deferred steps keep it after the hold ends. */ const messageAdmission = new AsyncLocalStorage<() => void>() /** - * Runs a host's command holding the page. If the host is revoked mid-press, the command's own - * release is refused like every other step, so the desktop lets go of what it left held once the - * command ends: from outside the host's scope, with routing probes no longer refused. + * Runs a host's command holding the page. On revocation every later step is refused, releases + * included, so once the command ends the desktop lets go of what it left held, from outside the + * host's scope. A failure after revocation reports the revocation. */ export async function runWithRemoteCliPageAdmission( webContentsId: number, admit: () => void, run: () => Promise, - releaseHeld?: (release: HeldInputRelease) => Promise + hooks: PageHoldHooks = {} ): Promise { const admission: Admission = { admit, held: new Map() } + let retiring: Promise | undefined + const retire = (): Promise | undefined => + hooks.retire && (retiring ??= runOutsideRemoteCliBrowserScope(hooks.retire)) + const onRevoked = (): void => void retire() + hooks.revoked?.addEventListener('abort', onRevoked, { once: true }) admissions.set(webContentsId, admission) try { - return await run() - } finally { - if (admissions.get(webContentsId) === admission) { - admissions.delete(webContentsId) - } - if (releaseHeld && admission.held.size > 0 && isAdmissionRevoked(admit)) { - await runOutsideRemoteCliBrowserScope(() => releaseAll(admission.held, releaseHeld)) + try { + return await run() + } finally { + hooks.revoked?.removeEventListener('abort', onRevoked) + if (isRevoked(admit)) { + await retire() + } + if (admissions.get(webContentsId) === admission) { + admissions.delete(webContentsId) + } + const { releaseHeld } = hooks + if (releaseHeld && admission.held.size > 0 && isRevoked(admit)) { + await runOutsideRemoteCliBrowserScope(() => releaseAll(admission.held, releaseHeld)) + } } + } catch (error) { + admit() + throw error } } -/** Throws while a revoked host's command still holds this page; a no-op otherwise. */ -export function assertRemoteCliPageAdmitted(webContentsId: number | undefined): void { - if (webContentsId !== undefined) { - admissions.get(webContentsId)?.admit() - } - messageAdmission.getStore()?.() -} - /** Runs a proxy message's handling, deferred steps included, under the page's current hold. */ export function runUnderRemoteCliPageAdmission(webContentsId: number, handle: () => T): T { const admission = admissions.get(webContentsId) return admission ? messageAdmission.run(admission.admit, handle) : handle() } -/** Records input that reached a page a host's command holds, so a revocation can release it. */ -export function noteRemoteCliInput( - webContentsId: number | undefined, - method: string, - params?: Record -): void { - const admission = webContentsId === undefined ? undefined : admissions.get(webContentsId) - if (admission) { - trackInput(admission, method, params) - } +/** The page sinks' gate: throws for work of a revoked host's command; a no-op for the desktop's. */ +export function assertRemoteCliPageAccess(webContentsId?: number): void { + assertRemoteCliBrowserAccess(webContentsId) + messageAdmission.getStore()?.() } -/** True once the host behind this admission check was revoked. */ -export function isAdmissionRevoked(admit: () => void): boolean { +/** + * Delivers one input event, recording it on the page's hold: a press counts as held once it may + * have landed, a release only once it surely did. + */ +export function trackRemoteCliInput( + webContentsId: number | undefined, + method: string, + params: Record | undefined, + deliver: () => Promise +): Promise { + const admission = webContentsId === undefined ? undefined : admissions.get(webContentsId) + if (!admission || !params) { + return deliver() + } + if (!['mouseReleased', 'keyUp', 'touchEnd', 'touchCancel'].includes(String(params.type))) { + trackInput(admission, method, params) + return deliver() + } + return deliver().then((result) => { + trackInput(admission, method, params) + return result + }) +} + +function isRevoked(admit: () => void): boolean { try { admit() return false @@ -94,17 +130,9 @@ async function releaseAll( } } -export function isInputRelease(params: Record): boolean { - return ['mouseReleased', 'keyUp', 'touchEnd', 'touchCancel'].includes(String(params.type)) -} - -function trackInput( - admission: Admission, - method: string | undefined, - params: Record | undefined -): void { - const type = params?.type - if (!params || typeof type !== 'string') { +function trackInput(admission: Admission, method: string, params: Record): void { + const type = params.type + if (typeof type !== 'string') { return } if ( diff --git a/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts b/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts index 469f27d07e7..60cb60147c4 100644 --- a/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts +++ b/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts @@ -61,20 +61,18 @@ export function captureRemoteCliBrowserRevocation(): (webContentsId?: number) => */ export function captureRemoteCliBrowserScope(): { bridged: boolean + /** Fires when the host behind the command is revoked. */ + revoked?: AbortSignal run(work: () => T): T } { const active = scope.getStore() return { bridged: active !== undefined, + revoked: active?.revoked, run: (work) => (active ? scope.run(active, work) : scope.exit(work)) } } -/** The signal that fires when the host behind the current command is revoked, if any. */ -export function captureRemoteCliBrowserRevokedSignal(): AbortSignal | undefined { - return scope.getStore()?.revoked -} - /** Runs `work` in no host's scope, so long-lived resources it creates never inherit one. */ export function runOutsideRemoteCliBrowserScope(work: () => T): T { return scope.exit(work)