mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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.
This commit is contained in:
@@ -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<typeof mockWebContents>): 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<typeof vi.fn> }
|
||||
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(
|
||||
|
||||
@@ -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<T>(
|
||||
/** Holds the page for a host's command, so a revocation stops every later step of it. */
|
||||
private runHostCommand<T>(
|
||||
sessionName: string,
|
||||
webContentsId: number,
|
||||
gate: () => void,
|
||||
revoked: AbortSignal | undefined,
|
||||
execute: () => Promise<T>
|
||||
): Promise<T> {
|
||||
// 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<void> | null = null
|
||||
const retire = (): Promise<void> =>
|
||||
(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<T> => {
|
||||
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<void> {
|
||||
|
||||
@@ -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<string, unknown> }
|
||||
|
||||
/** 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<T>(
|
||||
webContents: WebContents,
|
||||
run: () => Promise<T>,
|
||||
input?: AgentInputEvent
|
||||
run: () => Promise<T>
|
||||
): Promise<T> {
|
||||
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) {
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
sessionId?: string
|
||||
): Promise<unknown> {
|
||||
// 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)
|
||||
|
||||
@@ -85,10 +85,8 @@ export class CdpPageInputCommands {
|
||||
return await this.debuggerChannel.sendDebuggerCommand(method, params, sessionId)
|
||||
}
|
||||
const send = (): Promise<unknown> =>
|
||||
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)) {
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('CdpWsProxy under a host command', () => {
|
||||
}
|
||||
},
|
||||
() => new Promise<void>((resolve) => (finish = resolve)),
|
||||
(release) => releaseHeldPageInput(page, release, () => guardFocus())
|
||||
{ releaseHeld: (release) => releaseHeldPageInput(page, release, () => guardFocus()) }
|
||||
)
|
||||
return {
|
||||
revoke: () => (revoked = true),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<unknown> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 <webview> 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<void> {
|
||||
|
||||
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<string, unknown>
|
||||
): Promise<unknown> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> }
|
||||
|
||||
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<void>
|
||||
releaseHeld?: (release: HeldInputRelease) => Promise<unknown>
|
||||
}
|
||||
|
||||
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<string, HeldInputRelease>
|
||||
}
|
||||
|
||||
const admissions = new Map<number, Admission>()
|
||||
/** 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<T>(
|
||||
webContentsId: number,
|
||||
admit: () => void,
|
||||
run: () => Promise<T>,
|
||||
releaseHeld?: (release: HeldInputRelease) => Promise<unknown>
|
||||
hooks: PageHoldHooks = {}
|
||||
): Promise<T> {
|
||||
const admission: Admission = { admit, held: new Map() }
|
||||
let retiring: Promise<void> | undefined
|
||||
const retire = (): Promise<void> | 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<T>(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<string, unknown>
|
||||
): 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<T>(
|
||||
webContentsId: number | undefined,
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
deliver: () => Promise<T>
|
||||
): Promise<T> {
|
||||
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<string, unknown>): boolean {
|
||||
return ['mouseReleased', 'keyUp', 'touchEnd', 'touchCancel'].includes(String(params.type))
|
||||
}
|
||||
|
||||
function trackInput(
|
||||
admission: Admission,
|
||||
method: string | undefined,
|
||||
params: Record<string, unknown> | undefined
|
||||
): void {
|
||||
const type = params?.type
|
||||
if (!params || typeof type !== 'string') {
|
||||
function trackInput(admission: Admission, method: string, params: Record<string, unknown>): void {
|
||||
const type = params.type
|
||||
if (typeof type !== 'string') {
|
||||
return
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -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<T>(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<T>(work: () => T): T {
|
||||
return scope.exit(work)
|
||||
|
||||
Reference in New Issue
Block a user