From c69312668380da756f10af06613aaa4b0a94942a Mon Sep 17 00:00:00 2001 From: Randolf Jung Date: Fri, 26 May 2023 09:08:02 +0200 Subject: [PATCH] Initial Input module implementation --- .../context/browsingContextProcessor.ts | 65 +- .../domains/input/ActionDispatcher.ts | 632 ++++++++++++++++++ src/bidiMapper/domains/input/ActionOption.ts | 30 + src/bidiMapper/domains/input/InputSource.ts | 153 +++++ src/bidiMapper/domains/input/InputState.ts | 121 ++++ .../domains/input/InputStateManager.ts | 41 ++ .../domains/input/USKeyboardLayout.ts | 271 ++++++++ src/bidiMapper/domains/input/keyUtils.ts | 472 +++++++++++++ src/utils/Mutex.spec.ts | 77 +++ src/utils/Mutex.ts | 69 ++ src/utils/assert.ts | 22 + 11 files changed, 1947 insertions(+), 6 deletions(-) create mode 100644 src/bidiMapper/domains/input/ActionDispatcher.ts create mode 100644 src/bidiMapper/domains/input/ActionOption.ts create mode 100644 src/bidiMapper/domains/input/InputSource.ts create mode 100644 src/bidiMapper/domains/input/InputState.ts create mode 100644 src/bidiMapper/domains/input/InputStateManager.ts create mode 100644 src/bidiMapper/domains/input/USKeyboardLayout.ts create mode 100644 src/bidiMapper/domains/input/keyUtils.ts create mode 100644 src/utils/Mutex.spec.ts create mode 100644 src/utils/Mutex.ts create mode 100644 src/utils/assert.ts diff --git a/src/bidiMapper/domains/context/browsingContextProcessor.ts b/src/bidiMapper/domains/context/browsingContextProcessor.ts index 55eb64dafc..2f30cf78bc 100644 --- a/src/bidiMapper/domains/context/browsingContextProcessor.ts +++ b/src/bidiMapper/domains/context/browsingContextProcessor.ts @@ -28,6 +28,9 @@ import {CdpClient, CdpConnection} from '../../CdpConnection.js'; import {IEventManager} from '../events/EventManager.js'; import {Realm} from '../script/realm.js'; import {RealmStorage} from '../script/realmStorage.js'; +import {ActionOption} from '../input/ActionOption.js'; +import {InputStateManager} from '../input/InputStateManager.js'; +import {ActionDispatcher} from '../input/ActionDispatcher.js'; import { BidiPreloadScript, @@ -46,6 +49,7 @@ export class BrowsingContextProcessor { readonly #realmStorage: RealmStorage; readonly #selfTargetId: string; readonly #preloadScriptStorage: PreloadScriptStorage; + readonly #inputStateManager = new InputStateManager(); constructor( realmStorage: RealmStorage, @@ -427,15 +431,64 @@ export class BrowsingContextProcessor { return {result: {}}; } - process_input_performActions( - _params: Input.PerformActionsParameters + async process_input_performActions( + params: Input.PerformActionsParameters ): Promise { - throw new Message.UnsupportedOperationException('Not implemented yet.'); + const context = this.#browsingContextStorage.getContext(params.context); + const inputState = this.#inputStateManager.get(context.top); + + const actionsByTick: ActionOption[][] = []; + for (const action of params.actions) { + switch (action.type) { + case Input.SourceActionsType.Pointer: { + action.parameters ??= {pointerType: Input.PointerType.Mouse}; + action.parameters.pointerType ??= Input.PointerType.Mouse; + + const source = inputState.getOrCreate( + action.id, + Input.SourceActionsType.Pointer, + action.parameters.pointerType + ); + if (source.subtype !== action.parameters.pointerType) { + throw new Message.InvalidArgumentException( + `Expected input source ${action.id} to be ${source.subtype}; got ${action.parameters.pointerType}.` + ); + } + break; + } + default: + inputState.getOrCreate(action.id, action.type); + } + const actions: ActionOption[] = []; + for (const item of action.actions) { + actions.push({ + id: action.id, + action: item, + }); + } + for (let i = 0; i < actions.length; i++) { + if (actionsByTick.length === i) { + actionsByTick.push([]); + } + actionsByTick[i]!.push(actions[i]!); + } + } + + const dispatcher = new ActionDispatcher(inputState, context); + await dispatcher.dispatchActions(actionsByTick); + + return {result: {}}; } - process_input_releaseActions( - _params: Input.ReleaseActionsParameters + async process_input_releaseActions( + params: Input.ReleaseActionsParameters ): Promise { - throw new Message.UnsupportedOperationException('Not implemented yet.'); + const context = this.#browsingContextStorage.getContext(params.context); + const topContext = context.top; + const inputState = this.#inputStateManager.get(topContext); + const dispatcher = new ActionDispatcher(inputState, context); + await dispatcher.dispatchTickActions(inputState.cancelList.reverse()); + this.#inputStateManager.delete(topContext); + return {result: {}}; } async process_browsingContext_close( diff --git a/src/bidiMapper/domains/input/ActionDispatcher.ts b/src/bidiMapper/domains/input/ActionDispatcher.ts new file mode 100644 index 0000000000..3d28e34f1d --- /dev/null +++ b/src/bidiMapper/domains/input/ActionDispatcher.ts @@ -0,0 +1,632 @@ +/** + * Copyright 2023 Google LLC. + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {CommonDataTypes, Input, Message} from '../../../protocol/protocol.js'; +import {assert} from '../../../utils/assert.js'; +import {BrowsingContextImpl} from '../context/browsingContextImpl.js'; + +import {ActionOption} from './ActionOption.js'; +import {KeySource, PointerSource, WheelSource} from './InputSource.js'; +import {InputState} from './InputState.js'; +import {KeyToKeyCode} from './USKeyboardLayout.js'; +import {getNormalizedKey, getKeyCode, getKeyLocation} from './keyUtils.js'; + +/** https://w3c.github.io/webdriver/#dfn-center-point */ +const CALCULATE_IN_VIEW_CENTER_PT_DECL = ((i: Element) => { + const t = i.getClientRects()[0] as DOMRect, + e = Math.max(0, Math.min(t.x, t.x + t.width)), + n = Math.min(window.innerWidth, Math.max(t.x, t.x + t.width)), + h = Math.max(0, Math.min(t.y, t.y + t.height)), + m = Math.min(window.innerHeight, Math.max(t.y, t.y + t.height)); + return [e + ((n - e) >> 1), h + ((m - h) >> 1)]; +}).toString(); + +async function getElementCenter( + context: BrowsingContextImpl, + element: CommonDataTypes.SharedReference +) { + const {result} = await ( + await context.getOrCreateSandbox(undefined) + ).callFunction( + CALCULATE_IN_VIEW_CENTER_PT_DECL, + {type: 'undefined'}, + [element], + false, + 'none', + {} + ); + if (result.type === 'exception') { + throw new Message.NoSuchNodeException( + `Origin element ${element.sharedId} was not found` + ); + } + assert(result.result.type === 'array'); + assert(result.result.value?.[0]?.type === 'number'); + assert(result.result.value?.[1]?.type === 'number'); + const { + result: { + value: [{value: x}, {value: y}], + }, + } = result; + return {x: x as number, y: y as number}; +} + +export class ActionDispatcher { + #tickStart = 0; + #tickDuration = 0; + #inputState: InputState; + #context: BrowsingContextImpl; + constructor(inputState: InputState, context: BrowsingContextImpl) { + this.#inputState = inputState; + this.#context = context; + } + + async dispatchActions( + optionsByTick: readonly (readonly Readonly[])[] + ) { + await this.#inputState.queue.run(async () => { + for (const options of optionsByTick) { + await this.dispatchTickActions(options); + } + }); + } + + async dispatchTickActions( + options: readonly Readonly[] + ): Promise { + this.#tickStart = performance.now(); + this.#tickDuration = 0; + for (const {action} of options) { + if ('duration' in action && action.duration !== undefined) { + this.#tickDuration = Math.max(this.#tickDuration, action.duration); + } + } + const promises: Promise[] = [ + new Promise((resolve) => setTimeout(resolve, this.#tickDuration)), + ]; + for (const option of options) { + promises.push(this.#dispatchAction(option)); + } + await Promise.all(promises); + } + + async #dispatchAction({id, action}: Readonly) { + const source = this.#inputState.get(id); + const keyState = this.#inputState.getGlobalKeyState(); + switch (action.type) { + case Input.ActionType.KeyDown: { + // SAFETY: The source is validated before. + await this.#dispatchKeyDownAction(source as KeySource, action); + this.#inputState.cancelList.push({ + id, + action: { + ...action, + type: Input.ActionType.KeyUp, + }, + }); + break; + } + case Input.ActionType.KeyUp: { + // SAFETY: The source is validated before. + await this.#dispatchKeyUpAction(source as KeySource, action); + break; + } + case Input.ActionType.Pause: { + // TODO: Implement waiting on the input source. + break; + } + case Input.ActionType.PointerDown: { + // SAFETY: The source is validated before. + await this.#dispatchPointerDownAction( + source as PointerSource, + keyState, + action + ); + this.#inputState.cancelList.push({ + id, + action: { + ...action, + type: Input.ActionType.PointerUp, + }, + }); + break; + } + case Input.ActionType.PointerMove: { + // SAFETY: The source is validated before. + await this.#dispatchPointerMoveAction( + source as PointerSource, + keyState, + action + ); + break; + } + case Input.ActionType.PointerUp: { + // SAFETY: The source is validated before. + await this.#dispatchPointerUpAction( + source as PointerSource, + keyState, + action + ); + break; + } + case Input.ActionType.Scroll: { + // SAFETY: The source is validated before. + await this.#dispatchScrollAction( + source as WheelSource, + keyState, + action + ); + break; + } + } + } + + #dispatchPointerDownAction( + source: PointerSource, + keyState: KeySource, + action: Readonly + ) { + const {button} = action; + if (source.pressed.has(button)) { + return; + } + source.pressed.add(button); + const {x, y, subtype: pointerType} = source; + const {width, height, pressure, twist, tangentialPressure} = action; + const {tiltX, tiltY} = 'tiltX' in action ? action : ({} as never); + // TODO: Implement azimuth/altitude angle. + + // --- Platform-specific code begins here --- + const {modifiers} = keyState; + switch (pointerType) { + case Input.PointerType.Mouse: + case Input.PointerType.Pen: + source.setClickCount({x, y, timeStamp: performance.now()}); + // TODO: Implement width and height when available. + return this.#context.cdpTarget.cdpClient.sendCommand( + 'Input.dispatchMouseEvent', + { + type: 'mousePressed', + x, + y, + modifiers, + button: (() => { + switch (button) { + case 0: + return 'left'; + case 1: + return 'middle'; + case 2: + return 'right'; + case 3: + return 'back'; + case 4: + return 'forward'; + default: + return 'none'; + } + })(), + buttons: source.buttons, + clickCount: source.clickCount, + pointerType, + tangentialPressure, + tiltX, + tiltY, + twist, + force: pressure, + } + ); + case Input.PointerType.Touch: + return this.#context.cdpTarget.cdpClient.sendCommand( + 'Input.dispatchTouchEvent', + { + type: 'touchStart', + touchPoints: [ + { + x, + y, + radiusX: width, + radiusY: height, + tangentialPressure, + tiltX, + tiltY, + twist, + force: pressure, + id: source.pointerId, + }, + ], + modifiers, + } + ); + } + // --- Platform-specific code ends here --- + } + + #dispatchPointerUpAction( + source: PointerSource, + keyState: KeySource, + action: Readonly + ) { + const {button} = action; + if (!source.pressed.has(button)) { + return; + } + source.pressed.delete(button); + const {x, y, subtype: pointerType} = source; + + // --- Platform-specific code begins here --- + const {modifiers} = keyState; + switch (pointerType) { + case Input.PointerType.Mouse: + case Input.PointerType.Pen: + // TODO: Implement width and height when available. + return this.#context.cdpTarget.cdpClient.sendCommand( + 'Input.dispatchMouseEvent', + { + type: 'mouseReleased', + x, + y, + modifiers, + button: (() => { + switch (button) { + case 0: + return 'left'; + case 1: + return 'middle'; + case 2: + return 'right'; + case 3: + return 'back'; + case 4: + return 'forward'; + default: + return 'none'; + } + })(), + buttons: source.buttons, + clickCount: source.clickCount, + pointerType, + } + ); + case Input.PointerType.Touch: + return this.#context.cdpTarget.cdpClient.sendCommand( + 'Input.dispatchTouchEvent', + { + type: 'touchEnd', + touchPoints: [ + { + x, + y, + id: source.pointerId, + }, + ], + modifiers, + } + ); + } + // --- Platform-specific code ends here --- + } + + async #dispatchPointerMoveAction( + source: PointerSource, + keyState: KeySource, + action: Readonly + ): Promise { + const {x: startX, y: startY, subtype: pointerType} = source; + const { + width, + height, + pressure, + twist, + tangentialPressure, + x: offsetX, + y: offsetY, + origin = 'viewport', + duration = this.#tickDuration, + } = action; + const {tiltX, tiltY} = 'tiltX' in action ? action : ({} as never); + // TODO: Implement azimuth/altitude angle. + + const {targetX, targetY} = await this.#getCoordinateFromOrigin( + origin, + offsetX, + offsetY, + startX, + startY + ); + + if (targetX < 0 || targetY < 0) { + throw new Message.MoveTargetOutOfBoundsException( + `Cannot move beyond viewport (x: ${targetX}, y: ${targetY})` + ); + } + + let last: boolean; + do { + const ratio = + duration > 0 ? (performance.now() - this.#tickStart) / duration : 1; + last = ratio >= 1; + + let x: number; + let y: number; + if (last) { + x = targetX; + y = targetY; + } else { + x = Math.round(ratio * (targetX - startX) + startX); + y = Math.round(ratio * (targetY - startY) + startY); + } + + if (source.x !== x || source.y !== y) { + // --- Platform-specific code begins here --- + const {modifiers} = keyState; + switch (pointerType) { + case Input.PointerType.Mouse: + case Input.PointerType.Pen: + // TODO: Implement width and height when available. + await this.#context.cdpTarget.cdpClient.sendCommand( + 'Input.dispatchMouseEvent', + { + type: 'mouseMoved', + x, + y, + modifiers, + clickCount: 0, + buttons: source.buttons, + pointerType, + tangentialPressure, + tiltX, + tiltY, + twist, + force: pressure, + } + ); + break; + case Input.PointerType.Touch: + await this.#context.cdpTarget.cdpClient.sendCommand( + 'Input.dispatchTouchEvent', + { + type: 'touchMove', + touchPoints: [ + { + x, + y, + radiusX: width, + radiusY: height, + tangentialPressure, + tiltX, + tiltY, + twist, + force: pressure, + id: source.pointerId, + }, + ], + modifiers, + } + ); + break; + } + // --- Platform-specific code ends here --- + + source.x = x; + source.y = y; + } + } while (!last); + } + + async #getCoordinateFromOrigin( + origin: Input.Origin, + offsetX: number, + offsetY: number, + startX: number, + startY: number + ) { + let targetX: number; + let targetY: number; + switch (origin) { + case 'viewport': + targetX = offsetX; + targetY = offsetY; + break; + case 'pointer': + targetX = startX + offsetX; + targetY = startY + offsetY; + break; + default: { + const {x: posX, y: posY} = await getElementCenter( + this.#context, + origin.element + ); + // SAFETY: These can never be special numbers. + targetX = posX + offsetX; + targetY = posY + offsetY; + break; + } + } + return {targetX, targetY}; + } + + async #dispatchScrollAction( + _source: WheelSource, + keyState: KeySource, + action: Readonly + ): Promise { + const { + deltaX: targetDeltaX, + deltaY: targetDeltaY, + x: offsetX, + y: offsetY, + origin = 'viewport', + duration = this.#tickDuration, + } = action; + + if (origin === 'pointer') { + throw new Message.InvalidArgumentException( + '"pointer" origin is invalid for scrolling.' + ); + } + + const {targetX, targetY} = await this.#getCoordinateFromOrigin( + origin, + offsetX, + offsetY, + 0, + 0 + ); + + if (targetX < 0 || targetY < 0) { + throw new Message.MoveTargetOutOfBoundsException( + `Cannot move beyond viewport (x: ${targetX}, y: ${targetY})` + ); + } + + let currentDeltaX = 0; + let currentDeltaY = 0; + let last: boolean; + do { + const ratio = + duration > 0 ? (performance.now() - this.#tickStart) / duration : 1; + last = ratio >= 1; + + let deltaX: number; + let deltaY: number; + if (last) { + deltaX = targetDeltaX - currentDeltaX; + deltaY = targetDeltaY - currentDeltaY; + } else { + deltaX = Math.round(ratio * targetDeltaX - currentDeltaX); + deltaY = Math.round(ratio * targetDeltaY - currentDeltaY); + } + + if (deltaX !== 0 || deltaY !== 0) { + // --- Platform-specific code begins here --- + const {modifiers} = keyState; + await this.#context.cdpTarget.cdpClient.sendCommand( + 'Input.dispatchMouseEvent', + { + type: 'mouseWheel', + deltaX, + deltaY, + x: targetX, + y: targetY, + modifiers, + } + ); + // --- Platform-specific code ends here --- + + currentDeltaX += deltaX; + currentDeltaY += deltaY; + } + } while (!last); + } + + #dispatchKeyDownAction( + source: KeySource, + action: Readonly + ) { + const rawKey = action.value; + const key = getNormalizedKey(rawKey); + const repeat = source.pressed.has(key); + const code = getKeyCode(rawKey); + const location = getKeyLocation(rawKey); + switch (key) { + case 'Alt': + source.alt = true; + break; + case 'Shift': + source.shift = true; + break; + case 'Control': + source.ctrl = true; + break; + case 'Meta': + source.meta = true; + break; + } + source.pressed.add(key); + const {modifiers} = source; + + // --- Platform-specific code begins here --- + // The spread is a little hack so JS gives us an array of unicode characters + // to measure. + const text = [...key].length === 1 ? key : undefined; + return this.#context.cdpTarget.cdpClient.sendCommand( + 'Input.dispatchKeyEvent', + { + type: text ? 'keyDown' : 'rawKeyDown', + windowsVirtualKeyCode: KeyToKeyCode[key], + key, + code, + text, + unmodifiedText: text, + autoRepeat: repeat, + isSystemKey: source.alt || undefined, + location: location < 2 ? location : undefined, + isKeypad: location === 3, + modifiers, + } + ); + // --- Platform-specific code ends here --- + } + + #dispatchKeyUpAction(source: KeySource, action: Readonly) { + const rawKey = action.value; + const key = getNormalizedKey(rawKey); + if (!source.pressed.has(key)) { + return; + } + const code = getKeyCode(rawKey); + const location = getKeyLocation(rawKey); + switch (key) { + case 'Alt': + source.alt = false; + break; + case 'Shift': + source.shift = false; + break; + case 'Control': + source.ctrl = false; + break; + case 'Meta': + source.meta = false; + break; + } + source.pressed.delete(key); + const {modifiers} = source; + + // --- Platform-specific code begins here --- + // The spread is a little hack so JS gives us an array of unicode characters + // to measure. + const text = [...key].length === 1 ? key : undefined; + return this.#context.cdpTarget.cdpClient.sendCommand( + 'Input.dispatchKeyEvent', + { + type: 'keyUp', + windowsVirtualKeyCode: KeyToKeyCode[key], + key, + code, + text, + unmodifiedText: text, + location: location < 2 ? location : undefined, + isSystemKey: source.alt || undefined, + isKeypad: location === 3, + modifiers, + } + ); + // --- Platform-specific code ends here --- + } +} diff --git a/src/bidiMapper/domains/input/ActionOption.ts b/src/bidiMapper/domains/input/ActionOption.ts new file mode 100644 index 0000000000..1e1819e2d2 --- /dev/null +++ b/src/bidiMapper/domains/input/ActionOption.ts @@ -0,0 +1,30 @@ +/** + * Copyright 2023 Google LLC. + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Input} from '../../../protocol/protocol.js'; + +export type ActionOption = ActionOptionFor< + | Input.NoneSourceAction + | Input.KeySourceAction + | Input.PointerSourceAction + | Input.WheelSourceAction +>; + +export interface ActionOptionFor { + id: string; + action: A; +} diff --git a/src/bidiMapper/domains/input/InputSource.ts b/src/bidiMapper/domains/input/InputSource.ts new file mode 100644 index 0000000000..b7ceb1856d --- /dev/null +++ b/src/bidiMapper/domains/input/InputSource.ts @@ -0,0 +1,153 @@ +/** + * Copyright 2023 Google LLC. + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Input} from '../../../protocol/protocol.js'; + +export import SourceType = Input.SourceActionsType; + +export class NoneSource { + type = SourceType.None as const; +} +export class KeySource { + type = SourceType.Key as const; + pressed = new Set(); + + #modifiers = 0; + get modifiers(): number { + return this.#modifiers; + } + get alt(): boolean { + return (this.#modifiers & 1) === 1; + } + set alt(value: boolean) { + this.#setModifier(value, 1); + } + get ctrl(): boolean { + return (this.#modifiers & 2) === 2; + } + set ctrl(value: boolean) { + this.#setModifier(value, 2); + } + get meta(): boolean { + return (this.#modifiers & 4) === 4; + } + set meta(value: boolean) { + this.#setModifier(value, 4); + } + get shift(): boolean { + return (this.#modifiers & 8) === 8; + } + set shift(value: boolean) { + this.#setModifier(value, 8); + } + + #setModifier(value: boolean, bit: number) { + if (value) { + this.#modifiers |= bit; + } else { + this.#modifiers ^= bit; + } + } +} + +interface ClickContext { + x: number; + y: number; + timeStamp: number; +} + +export class PointerSource { + type = SourceType.Pointer as const; + subtype: Input.PointerType; + pointerId: number; + pressed = new Set(); + x = 0; + y = 0; + + constructor(id: number, subtype: Input.PointerType) { + this.pointerId = id; + this.subtype = subtype; + } + + get buttons(): number { + let buttons = 0; + for (const button of this.pressed) { + switch (button) { + case 0: + buttons |= 1; + break; + case 1: + buttons |= 4; + break; + case 2: + buttons |= 2; + break; + case 3: + buttons |= 8; + break; + case 4: + buttons |= 16; + break; + } + } + return buttons; + } + + // --- Platform-specific state starts here --- + // This code should match https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:ui/events/event.cc;l=479 + static #DOUBLE_CLICK_TIME_MS = 500; + static #MAX_DOUBLE_CLICK_RADIUS = 2; + #clickCount = 0; + #lastClick?: ClickContext; + setClickCount(context: ClickContext) { + if ( + !this.#lastClick || + // The click needs to be within a certain amount of ms. + context.timeStamp - this.#lastClick.timeStamp > + PointerSource.#DOUBLE_CLICK_TIME_MS || + // The click needs to be within a square radius. + Math.abs(this.#lastClick.x - context.x) > + PointerSource.#MAX_DOUBLE_CLICK_RADIUS || + Math.abs(this.#lastClick.y - context.y) > + PointerSource.#MAX_DOUBLE_CLICK_RADIUS + ) { + this.#clickCount = 0; + } + ++this.#clickCount; + this.#lastClick = context; + } + + get clickCount(): number { + return this.#clickCount; + } + // --- Platform-specific state ends here --- +} + +export class WheelSource { + type = SourceType.Wheel as const; +} + +export type InputSource = NoneSource | KeySource | PointerSource | WheelSource; + +export type InputSourceFor = + Type extends SourceType.Key + ? KeySource + : Type extends SourceType.Pointer + ? PointerSource + : Type extends SourceType.Wheel + ? WheelSource + : NoneSource; diff --git a/src/bidiMapper/domains/input/InputState.ts b/src/bidiMapper/domains/input/InputState.ts new file mode 100644 index 0000000000..f3f09c054f --- /dev/null +++ b/src/bidiMapper/domains/input/InputState.ts @@ -0,0 +1,121 @@ +/** + * Copyright 2023 Google LLC. + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Input, Message} from '../../../protocol/protocol.js'; +import {Mutex} from '../../../utils/Mutex.js'; + +import {ActionOption} from './ActionOption.js'; +import { + InputSource, + PointerSource, + InputSourceFor, + NoneSource, + KeySource, + WheelSource, + SourceType, +} from './InputSource.js'; + +export class InputState { + cancelList: ActionOption[] = []; + #sources = new Map(); + #mutex = new Mutex(); + + getOrCreate( + id: string, + type: SourceType.Pointer, + subtype: Input.PointerType + ): PointerSource; + getOrCreate( + id: string, + type: Type + ): InputSourceFor; + getOrCreate( + id: string, + type: Type, + subtype?: Input.PointerType + ): InputSourceFor { + let source = this.#sources.get(id); + if (!source) { + switch (type) { + case SourceType.None: + source = new NoneSource(); + break; + case SourceType.Key: + source = new KeySource(); + break; + case SourceType.Pointer: { + let pointerId = subtype === Input.PointerType.Mouse ? 0 : 2; + const pointerIds = new Set(); + for (const [, source] of this.#sources) { + if (source.type === SourceType.Pointer) { + pointerIds.add(source.pointerId); + } + } + while (pointerIds.has(pointerId)) { + ++pointerId; + } + source = new PointerSource(pointerId, subtype as Input.PointerType); + break; + } + case SourceType.Wheel: + source = new WheelSource(); + break; + default: + throw new Message.InvalidArgumentException( + `Expected "${SourceType.None}", "${SourceType.Key}", "${SourceType.Pointer}", or "${SourceType.Wheel}". Found unknown source type ${type}.` + ); + } + this.#sources.set(id, source); + return source as InputSourceFor; + } + if (source.type !== type) { + throw new Message.InvalidArgumentException( + `Input source type of ${id} is ${source.type}, but received ${type}.` + ); + } + return source as InputSourceFor; + } + + get(id: string): InputSource { + const source = this.#sources.get(id); + if (!source) { + throw new Message.UnknownErrorException(`Internal error.`); + } + return source; + } + + getGlobalKeyState(): KeySource { + const state: KeySource = new KeySource(); + for (const [, source] of this.#sources) { + if (source.type !== SourceType.Key) { + continue; + } + for (const pressed of source.pressed) { + state.pressed.add(pressed); + } + state.alt ||= source.alt; + state.ctrl ||= source.ctrl; + state.meta ||= source.meta; + state.shift ||= source.shift; + } + return state; + } + + get queue() { + return this.#mutex; + } +} diff --git a/src/bidiMapper/domains/input/InputStateManager.ts b/src/bidiMapper/domains/input/InputStateManager.ts new file mode 100644 index 0000000000..eab827d769 --- /dev/null +++ b/src/bidiMapper/domains/input/InputStateManager.ts @@ -0,0 +1,41 @@ +/** + * Copyright 2023 Google LLC. + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Message} from '../../../protocol/protocol.js'; +import {BrowsingContextImpl} from '../context/browsingContextImpl.js'; + +import {InputState} from './InputState.js'; + +export class InputStateManager { + #states = new WeakMap(); + + get(context: BrowsingContextImpl) { + if (!context.isTopLevelContext()) { + throw new Message.UnknownErrorException('Internal error'); + } + let state = this.#states.get(context); + if (!state) { + state = new InputState(); + this.#states.set(context, state); + } + return state; + } + + delete(context: BrowsingContextImpl) { + this.#states.delete(context); + } +} diff --git a/src/bidiMapper/domains/input/USKeyboardLayout.ts b/src/bidiMapper/domains/input/USKeyboardLayout.ts new file mode 100644 index 0000000000..5bf1238e0c --- /dev/null +++ b/src/bidiMapper/domains/input/USKeyboardLayout.ts @@ -0,0 +1,271 @@ +/** + * Copyright 2023 Google LLC. + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// TODO: Remove this once https://crrev.com/c/4548290 is stably in Chromium. +// `Input.dispatchKeyboardEvent` will automatically handle these conversions. +export const KeyToKeyCode: Record = { + '0': 48, + '1': 49, + '2': 50, + '3': 51, + '4': 52, + '5': 53, + '6': 54, + '7': 55, + '8': 56, + '9': 57, + Abort: 3, + Help: 6, + Backspace: 8, + Tab: 9, + Numpad5: 12, + NumpadEnter: 13, + Enter: 13, + '\\r': 13, + '\\n': 13, + ShiftLeft: 16, + ShiftRight: 16, + ControlLeft: 17, + ControlRight: 17, + AltLeft: 18, + AltRight: 18, + Pause: 19, + CapsLock: 20, + Escape: 27, + Convert: 28, + NonConvert: 29, + Space: 32, + Numpad9: 33, + PageUp: 33, + Numpad3: 34, + PageDown: 34, + End: 35, + Numpad1: 35, + Home: 36, + Numpad7: 36, + ArrowLeft: 37, + Numpad4: 37, + Numpad8: 38, + ArrowUp: 38, + ArrowRight: 39, + Numpad6: 39, + Numpad2: 40, + ArrowDown: 40, + Select: 41, + Open: 43, + PrintScreen: 44, + Insert: 45, + Numpad0: 45, + Delete: 46, + NumpadDecimal: 46, + Digit0: 48, + Digit1: 49, + Digit2: 50, + Digit3: 51, + Digit4: 52, + Digit5: 53, + Digit6: 54, + Digit7: 55, + Digit8: 56, + Digit9: 57, + KeyA: 65, + KeyB: 66, + KeyC: 67, + KeyD: 68, + KeyE: 69, + KeyF: 70, + KeyG: 71, + KeyH: 72, + KeyI: 73, + KeyJ: 74, + KeyK: 75, + KeyL: 76, + KeyM: 77, + KeyN: 78, + KeyO: 79, + KeyP: 80, + KeyQ: 81, + KeyR: 82, + KeyS: 83, + KeyT: 84, + KeyU: 85, + KeyV: 86, + KeyW: 87, + KeyX: 88, + KeyY: 89, + KeyZ: 90, + MetaLeft: 91, + MetaRight: 92, + ContextMenu: 93, + NumpadMultiply: 106, + NumpadAdd: 107, + NumpadSubtract: 109, + NumpadDivide: 111, + F1: 112, + F2: 113, + F3: 114, + F4: 115, + F5: 116, + F6: 117, + F7: 118, + F8: 119, + F9: 120, + F10: 121, + F11: 122, + F12: 123, + F13: 124, + F14: 125, + F15: 126, + F16: 127, + F17: 128, + F18: 129, + F19: 130, + F20: 131, + F21: 132, + F22: 133, + F23: 134, + F24: 135, + NumLock: 144, + ScrollLock: 145, + AudioVolumeMute: 173, + AudioVolumeDown: 174, + AudioVolumeUp: 175, + MediaTrackNext: 176, + MediaTrackPrevious: 177, + MediaStop: 178, + MediaPlayPause: 179, + Semicolon: 186, + Equal: 187, + NumpadEqual: 187, + Comma: 188, + Minus: 189, + Period: 190, + Slash: 191, + Backquote: 192, + BracketLeft: 219, + Backslash: 220, + BracketRight: 221, + Quote: 222, + AltGraph: 225, + Props: 247, + Cancel: 3, + Clear: 12, + Shift: 16, + Control: 17, + Alt: 18, + Accept: 30, + ModeChange: 31, + ' ': 32, + Print: 42, + Execute: 43, + '\\u0000': 46, + a: 65, + b: 66, + c: 67, + d: 68, + e: 69, + f: 70, + g: 71, + h: 72, + i: 73, + j: 74, + k: 75, + l: 76, + m: 77, + n: 78, + o: 79, + p: 80, + q: 81, + r: 82, + s: 83, + t: 84, + u: 85, + v: 86, + w: 87, + x: 88, + y: 89, + z: 90, + Meta: 91, + '*': 106, + '+': 107, + '-': 109, + '/': 111, + ';': 186, + '=': 187, + ',': 188, + '.': 190, + '`': 192, + '[': 219, + '\\\\': 220, + ']': 221, + "'": 222, + Attn: 246, + CrSel: 247, + ExSel: 248, + EraseEof: 249, + Play: 250, + ZoomOut: 251, + ')': 48, + '!': 49, + '@': 50, + '#': 51, + $: 52, + '%': 53, + '^': 54, + '&': 55, + '(': 57, + A: 65, + B: 66, + C: 67, + D: 68, + E: 69, + F: 70, + G: 71, + H: 72, + I: 73, + J: 74, + K: 75, + L: 76, + M: 77, + N: 78, + O: 79, + P: 80, + Q: 81, + R: 82, + S: 83, + T: 84, + U: 85, + V: 86, + W: 87, + X: 88, + Y: 89, + Z: 90, + ':': 186, + '<': 188, + _: 189, + '>': 190, + '?': 191, + '~': 192, + '{': 219, + '|': 220, + '}': 221, + '"': 222, + Camera: 44, + EndCall: 95, + VolumeDown: 182, + VolumeUp: 183, +}; diff --git a/src/bidiMapper/domains/input/keyUtils.ts b/src/bidiMapper/domains/input/keyUtils.ts new file mode 100644 index 0000000000..4dd68c4986 --- /dev/null +++ b/src/bidiMapper/domains/input/keyUtils.ts @@ -0,0 +1,472 @@ +/** + * Copyright 2023 Google LLC. + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function getNormalizedKey(value: string): string { + switch (value) { + case '\uE000': + return 'Unidentified'; + case '\uE001': + return 'Cancel'; + case '\uE002': + return 'Help'; + case '\uE003': + return 'Backspace'; + case '\uE004': + return 'Tab'; + case '\uE005': + return 'Clear'; + case '\uE006': + return 'Return'; + case '\uE007': + return 'Enter'; + case '\uE008': + return 'Shift'; + case '\uE009': + return 'Control'; + case '\uE00A': + return 'Alt'; + case '\uE00B': + return 'Pause'; + case '\uE00C': + return 'Escape'; + case '\uE00D': + return ' '; + case '\uE00E': + return 'PageUp'; + case '\uE00F': + return 'PageDown'; + case '\uE010': + return 'End'; + case '\uE011': + return 'Home'; + case '\uE012': + return 'ArrowLeft'; + case '\uE013': + return 'ArrowUp'; + case '\uE014': + return 'ArrowRight'; + case '\uE015': + return 'ArrowDown'; + case '\uE016': + return 'Insert'; + case '\uE017': + return 'Delete'; + case '\uE018': + return ';'; + case '\uE019': + return '='; + case '\uE01A': + return '0'; + case '\uE01B': + return '1'; + case '\uE01C': + return '2'; + case '\uE01D': + return '3'; + case '\uE01E': + return '4'; + case '\uE01F': + return '5'; + case '\uE020': + return '6'; + case '\uE021': + return '7'; + case '\uE022': + return '8'; + case '\uE023': + return '9'; + case '\uE024': + return '*'; + case '\uE025': + return '+'; + case '\uE026': + return ','; + case '\uE027': + return '-'; + case '\uE028': + return '.'; + case '\uE029': + return '/'; + case '\uE031': + return 'F1'; + case '\uE032': + return 'F2'; + case '\uE033': + return 'F3'; + case '\uE034': + return 'F4'; + case '\uE035': + return 'F5'; + case '\uE036': + return 'F6'; + case '\uE037': + return 'F7'; + case '\uE038': + return 'F8'; + case '\uE039': + return 'F9'; + case '\uE03A': + return 'F10'; + case '\uE03B': + return 'F11'; + case '\uE03C': + return 'F12'; + case '\uE03D': + return 'Meta'; + case '\uE040': + return 'ZenkakuHankaku'; + case '\uE050': + return 'Shift'; + case '\uE051': + return 'Control'; + case '\uE052': + return 'Alt'; + case '\uE053': + return 'Meta'; + case '\uE054': + return 'PageUp'; + case '\uE055': + return 'PageDown'; + case '\uE056': + return 'End'; + case '\uE057': + return 'Home'; + case '\uE058': + return 'ArrowLeft'; + case '\uE059': + return 'ArrowUp'; + case '\uE05A': + return 'ArrowRight'; + case '\uE05B': + return 'ArrowDown'; + case '\uE05C': + return 'Insert'; + case '\uE05D': + return 'Delete'; + default: + return value; + } +} + +export function getKeyCode(key: string): string | undefined { + switch (key) { + case '`': + case '~': + return 'Backquote'; + case '\\': + case '|': + return 'Backslash'; + case '\uE003': + return 'Backspace'; + case '[': + case '{': + return 'BracketLeft'; + case ']': + case '}': + return 'BracketRight'; + case ',': + case '<': + return 'Comma'; + case '0': + case ')': + return 'Digit0'; + case '1': + case '!': + return 'Digit1'; + case '2': + case '@': + return 'Digit2'; + case '3': + case '#': + return 'Digit3'; + case '4': + case '$': + return 'Digit4'; + case '5': + case '%': + return 'Digit5'; + case '6': + case '^': + return 'Digit6'; + case '7': + case '&': + return 'Digit7'; + case '8': + case '*': + return 'Digit8'; + case '9': + case '(': + return 'Digit9'; + case '=': + case '+': + return 'Equal'; + case 'a': + case 'A': + return 'KeyA'; + case 'b': + case 'B': + return 'KeyB'; + case 'c': + case 'C': + return 'KeyC'; + case 'd': + case 'D': + return 'KeyD'; + case 'e': + case 'E': + return 'KeyE'; + case 'f': + case 'F': + return 'KeyF'; + case 'g': + case 'G': + return 'KeyG'; + case 'h': + case 'H': + return 'KeyH'; + case 'i': + case 'I': + return 'KeyI'; + case 'j': + case 'J': + return 'KeyJ'; + case 'k': + case 'K': + return 'KeyK'; + case 'l': + case 'L': + return 'KeyL'; + case 'm': + case 'M': + return 'KeyM'; + case 'n': + case 'N': + return 'KeyN'; + case 'o': + case 'O': + return 'KeyO'; + case 'p': + case 'P': + return 'KeyP'; + case 'q': + case 'Q': + return 'KeyQ'; + case 'r': + case 'R': + return 'KeyR'; + case 's': + case 'S': + return 'KeyS'; + case 't': + case 'T': + return 'KeyT'; + case 'u': + case 'U': + return 'KeyU'; + case 'v': + case 'V': + return 'KeyV'; + case 'w': + case 'W': + return 'KeyW'; + case 'x': + case 'X': + return 'KeyX'; + case 'y': + case 'Y': + return 'KeyY'; + case 'z': + case 'Z': + return 'KeyZ'; + case '-': + case '_': + return 'Minus'; + case '.': + return 'Period'; + case "'": + case '"': + return 'Quote'; + case ';': + case ':': + return 'Semicolon'; + case '/': + case '?': + return 'Slash'; + case '\uE00A': + return 'AltLeft'; + case '\uE052': + return 'AltRight'; + case '\uE009': + return 'ControlLeft'; + case '\uE051': + return 'ControlRight'; + case '\uE006': + return 'Enter'; + case '\uE03D': + return 'MetaLeft'; + case '\uE053': + return 'MetaRight'; + case '\uE008': + return 'ShiftLeft'; + case '\uE050': + return 'ShiftRight'; + case ' ': + case '\uE00D': + return 'Space'; + case '\uE004': + return 'Tab'; + case '\uE017': + return 'Delete'; + case '\uE010': + return 'End'; + case '\uE002': + return 'Help'; + case '\uE011': + return 'Home'; + case '\uE016': + return 'Insert'; + case '\uE00F': + return 'PageDown'; + case '\uE00E': + return 'PageUp'; + case '\uE015': + return 'ArrowDown'; + case '\uE012': + return 'ArrowLeft'; + case '\uE014': + return 'ArrowRight'; + case '\uE013': + return 'ArrowUp'; + case '\uE00C': + return 'Escape'; + case '\uE031': + return 'F1'; + case '\uE032': + return 'F2'; + case '\uE033': + return 'F3'; + case '\uE034': + return 'F4'; + case '\uE035': + return 'F5'; + case '\uE036': + return 'F6'; + case '\uE037': + return 'F7'; + case '\uE038': + return 'F8'; + case '\uE039': + return 'F9'; + case '\uE03A': + return 'F10'; + case '\uE03B': + return 'F11'; + case '\uE03C': + return 'F12'; + case '\uE01A': + case '\uE05C': + return 'Numpad0'; + case '\uE01B': + case '\uE056': + return 'Numpad1'; + case '\uE01C': + case '\uE05B': + return 'Numpad2'; + case '\uE01D': + case '\uE055': + return 'Numpad3'; + case '\uE01E': + case '\uE058': + return 'Numpad4'; + case '\uE01F': + return 'Numpad5'; + case '\uE020': + case '\uE05A': + return 'Numpad6'; + case '\uE021': + case '\uE057': + return 'Numpad7'; + case '\uE022': + case '\uE059': + return 'Numpad8'; + case '\uE023': + case '\uE054': + return 'Numpad9'; + case '\uE025': + return 'NumpadAdd'; + case '\uE026': + return 'NumpadComma'; + case '\uE028': + case '\uE05D': + return 'NumpadDecimal'; + case '\uE029': + return 'NumpadDivide'; + case '\uE007': + return 'NumpadEnter'; + case '\uE024': + return 'NumpadMultiply'; + case '\uE027': + return 'NumpadSubtract'; + default: + return; + } +} + +export function getKeyLocation(key: string): 0 | 1 | 2 | 3 { + switch (key) { + case '\uE007': + case '\uE008': + case '\uE009': + case '\uE00A': + case '\uE03D': + return 1; + case '\uE01A': + case '\uE01B': + case '\uE01C': + case '\uE01D': + case '\uE01E': + case '\uE01F': + case '\uE020': + case '\uE021': + case '\uE022': + case '\uE023': + case '\uE024': + case '\uE025': + case '\uE026': + case '\uE027': + case '\uE028': + case '\uE029': + case '\uE054': + case '\uE055': + case '\uE056': + case '\uE057': + case '\uE058': + case '\uE059': + case '\uE05A': + case '\uE05B': + case '\uE05C': + case '\uE05D': + return 3; + case '\uE050': + case '\uE051': + case '\uE052': + case '\uE053': + return 2; + default: + return 0; + } +} diff --git a/src/utils/Mutex.spec.ts b/src/utils/Mutex.spec.ts new file mode 100644 index 0000000000..576f9d3dee --- /dev/null +++ b/src/utils/Mutex.spec.ts @@ -0,0 +1,77 @@ +// Copyright 2022 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import {expect} from 'chai'; + +import {Mutex} from './Mutex.js'; + +describe('Mutex', () => { + async function triggerMicroTaskQueue(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + async function notAcquired(): Promise<'not acquired'> { + await triggerMicroTaskQueue(); + return 'not acquired'; + } + + it('should acquire the lock immediately if no one is holding it', async () => { + const mutex = new Mutex(); + const release = await mutex.acquire(); + release(); + }); + + it('should acquire the lock once another holder releases it', async () => { + const mutex = new Mutex(); + const lock1 = mutex.acquire(); + const lock2 = mutex.acquire(); + const release = await lock1; + // lock2 should not be resolved set. + expect(await Promise.race([lock2, notAcquired()])).equals('not acquired'); + release(); + await triggerMicroTaskQueue(); + expect(await lock2).instanceOf(Function); + }); + + it('should work for two async functions accessing shared state', async () => { + const mutex = new Mutex(); + const shared: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-empty-function + let action1Resolver = () => {}; + const action1ReadyPromise = new Promise((resolve) => { + action1Resolver = resolve; + }); + // eslint-disable-next-line @typescript-eslint/no-empty-function + let action2Resolver = () => {}; + const action2ReadyPromise = new Promise((resolve) => { + action2Resolver = resolve; + }); + + async function action1() { + const release = await mutex.acquire(); + try { + await action1ReadyPromise; + shared.push('action1'); + } finally { + release(); + } + } + + async function action2() { + const release = await mutex.acquire(); + try { + await action2ReadyPromise; + shared.push('action2'); + } finally { + release(); + } + } + const promises = Promise.all([action1(), action2()]); + action2Resolver(); + action1Resolver(); + await promises; + expect(shared[0]).to.eq('action1'); + expect(shared[1]).to.eq('action2'); + }); +}); diff --git a/src/utils/Mutex.ts b/src/utils/Mutex.ts new file mode 100644 index 0000000000..5aa87978da --- /dev/null +++ b/src/utils/Mutex.ts @@ -0,0 +1,69 @@ +/** + * Copyright 2023 Google LLC. + * Copyright (c) Microsoft Corporation. + * Copyright 2022 The Chromium Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type ReleaseFunction = () => void; + +/** + * Use Mutex class to coordinate local concurrent operations. + * Once `acquire` promise resolves, you hold the lock and must + * call `release` function returned by `acquire` to release the + * lock. Failing to `release` the lock may lead to deadlocks. + */ +export class Mutex { + #locked = false; + #acquirers: (() => void)[] = []; + + // This is FIFO. + acquire(): Promise { + const state = {resolved: false}; + if (this.#locked) { + return new Promise((resolve) => { + this.#acquirers.push(() => resolve(this.#release.bind(this, state))); + }); + } + this.#locked = true; + return Promise.resolve(this.#release.bind(this, state)); + } + + #release(state: {resolved: boolean}): void { + if (state.resolved) { + throw new Error('Cannot release more than once.'); + } + state.resolved = true; + + const resolve = this.#acquirers.shift(); + if (!resolve) { + this.#locked = false; + return; + } + resolve(); + } + + async run(action: () => Promise): Promise { + const release = await this.acquire(); + try { + // Note we need to await here because we want the await to release AFTER + // that await happens. Returning action() will trigger the release + // immediately which is counter to what we want. + const result = await action(); + return result; + } finally { + release(); + } + } +} diff --git a/src/utils/assert.ts b/src/utils/assert.ts new file mode 100644 index 0000000000..586833c132 --- /dev/null +++ b/src/utils/assert.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2023 Google LLC. + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function assert(predicate: T): asserts predicate { + if (!predicate) { + throw new Error('Internal assertion failed.'); + } +}