From 230ba44cb78134bda753604d9c9576810c932c1b Mon Sep 17 00:00:00 2001 From: Eric Feng Date: Mon, 14 Sep 2026 10:19:26 -0700 Subject: [PATCH 1/2] Harden live-view read-only toggles and acknowledge mode changes --- images/chromium-headful/client/README.md | 32 +++++ images/chromium-headful/client/src/app.vue | 74 ++++------ .../client/src/components/video.vue | 22 ++- .../client/src/store/remote.ts | 19 ++- .../client/src/utils/read-only.ts | 13 ++ .../client/tests/read-only.test.ts | 93 ++++++++++++ .../client/tests/video-input.test.ts | 133 ++++++++++++++++++ 7 files changed, 333 insertions(+), 53 deletions(-) create mode 100644 images/chromium-headful/client/README.md create mode 100644 images/chromium-headful/client/src/utils/read-only.ts create mode 100644 images/chromium-headful/client/tests/read-only.test.ts create mode 100644 images/chromium-headful/client/tests/video-input.test.ts diff --git a/images/chromium-headful/client/README.md b/images/chromium-headful/client/README.md new file mode 100644 index 000000000..1aa958529 --- /dev/null +++ b/images/chromium-headful/client/README.md @@ -0,0 +1,32 @@ +# Embedded live-view control + +The `readOnly=true` query parameter sets the initial input mode (aliases: `readonly`, +`ro`; accepted true values: `1`, `true`, `yes`). + +Embedded parents can change the mode without reconnecting: + +```js +iframe.contentWindow.postMessage( + { type: 'KERNEL_SET_READ_ONLY', readOnly: true, requestId: crypto.randomUUID() }, + new URL(iframe.src).origin, +) +``` + +The viewer accepts messages only from its immediate parent and the exact origin +in `document.referrer`. Parents must allow an origin referrer, for example with +`referrerPolicy="strict-origin-when-cross-origin"`. Missing or opaque referrers +cannot authorize mode changes. + +`KERNEL_CONNECTED` includes `capabilities: ['setReadOnly']` on supporting images. +After applying a valid request, the viewer sends `KERNEL_READ_ONLY_CHANGED` with +the same `requestId` and the applied boolean `readOnly` to the parent origin. +Requests without `requestId` remain supported for existing parents. +Parents must validate the iframe window, origin, request ID, and applied mode. + +For older images without the capability, change the query parameter and reload. +If an advertised capability fails to acknowledge a request, fall back to a reload +with the desired query mode. The dashboard uses a one-second acknowledgement timeout. + +Read-only mode locks local input and disables implicit hosting. Unlocking restores +the server's configured implicit-hosting setting. Mode changes persist across +transport reconnects; a full page reload uses the URL's initial mode again. diff --git a/images/chromium-headful/client/src/app.vue b/images/chromium-headful/client/src/app.vue index fa2206849..3d7a83039 100644 --- a/images/chromium-headful/client/src/app.vue +++ b/images/chromium-headful/client/src/app.vue @@ -177,6 +177,7 @@ import { Vue, Component, Ref, Watch } from 'vue-property-decorator' import Connect from '~/components/connect.vue' + import { isReadOnlyMessage } from '~/utils/read-only' import Disconnected from '~/components/disconnected.vue' import Video from '~/components/video.vue' import Menu from '~/components/menu.vue' @@ -209,7 +210,6 @@ shakeKbd = false wasConnected = false - readOnlyOverride: boolean | null = null get volume() { const numberParam = parseFloat(new URL(location.href).searchParams.get('volume') || '1.0') @@ -225,13 +225,32 @@ } get isReadOnlyMode() { - if (this.readOnlyOverride !== null) { - return this.readOnlyOverride - } + return this.$accessor.remote.readOnly + } + created() { const params = new URL(location.href).searchParams const value = params.get('readOnly') || params.get('readonly') || params.get('ro') - return typeof value === 'string' && ['1', 'true', 'yes'].includes(value.toLowerCase()) + this.$accessor.remote.setReadOnly(typeof value === 'string' && ['1', 'true', 'yes'].includes(value.toLowerCase())) + window.addEventListener('message', this.onParentMessage) + } + + beforeDestroy() { + window.removeEventListener('message', this.onParentMessage) + } + + onParentMessage(event: MessageEvent) { + if (window.parent === window || !isReadOnlyMessage(event, window.parent, this.parentOrigin)) return + + if (event.data.readOnly && !this.isReadOnlyMode) { + if (this.video) this.video.releaseInput() + this.$accessor.remote.release() + } + this.$accessor.remote.setReadOnly(event.data.readOnly) + window.parent.postMessage( + { type: 'KERNEL_READ_ONLY_CHANGED', readOnly: this.isReadOnlyMode, requestId: event.data.requestId }, + this.parentOrigin, + ) } get hideControls() { @@ -292,7 +311,10 @@ this.applyQueryResolution() try { if (window.parent !== window) { - window.parent.postMessage({ type: 'KERNEL_CONNECTED', connected: true }, this.parentOrigin) + window.parent.postMessage( + { type: 'KERNEL_CONNECTED', connected: true, capabilities: ['setReadOnly'] }, + this.parentOrigin, + ) } } catch (e) { console.error('Failed to post message to parent', e) @@ -329,46 +351,6 @@ this.$accessor.video.screenSet(resolution) } } - - if (this.isReadOnlyMode) { - this.applyReadOnlyMode(true, false) - } - } - - mounted() { - window.addEventListener('message', this.onParentMessage) - } - - beforeDestroy() { - window.removeEventListener('message', this.onParentMessage) - } - - private onParentMessage(event: MessageEvent) { - if (event.source !== window.parent) return - if (this.parentOrigin !== '*' && event.origin !== this.parentOrigin) return - - const data = event.data as { type?: string; readOnly?: unknown } - if (data?.type !== 'KERNEL_SET_READ_ONLY' || typeof data.readOnly !== 'boolean') return - - this.applyReadOnlyMode(data.readOnly, true) - } - - private applyReadOnlyMode(readOnly: boolean, releaseControl: boolean) { - this.readOnlyOverride = readOnly - - if (readOnly) { - if (releaseControl) { - this.$accessor.remote.release() - } - // Disable implicit hosting so the user doesn't automatically gain control - this.$accessor.remote.setImplicitHosting(false) - // Lock the session locally to block any input even if hosting is later requested - this.$accessor.remote.setLocked(true) - return - } - - this.$accessor.remote.setLocked(false) - this.$accessor.remote.setImplicitHosting(true) } // KERNEL: end custom resolution, frame rate, and readOnly control via query params diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index a63cff9c2..94d21a33b 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -263,6 +263,7 @@ @Prop(Boolean) readonly readOnly!: boolean private keyboard = GuacamoleKeyboard() + private pressedMouseButtons = new Set() private observer = new ResizeObserver(this.onResize.bind(this)) private focused = false private pastePending = false @@ -332,7 +333,7 @@ } get locked() { - return this.$accessor.remote.locked || (this.controlLocked && (!this.hosting || this.implicitHosting)) + return this.readOnly || this.$accessor.remote.locked || (this.controlLocked && (!this.hosting || this.implicitHosting)) } get scroll() { @@ -739,7 +740,7 @@ } async syncClipboard() { - if (!this.clipboard_read_available || !window.document.hasFocus()) { + if (!this.hosting || this.locked || !this.clipboard_read_available || !window.document.hasFocus()) { return } @@ -747,8 +748,11 @@ return } + if (!this.hosting || this.locked) return + try { const text = await navigator.clipboard.readText() + if (!this.hosting || this.locked) return if (this.clipboard !== text) { this.$accessor.remote.setClipboard(text) this.$accessor.remote.sendClipboard(text) @@ -858,6 +862,7 @@ this.focusOverlay(e) this.sendMousePos(e) + this.pressedMouseButtons.add(e.button + 1) this.$client.sendData('mousedown', { key: e.button + 1 }) } @@ -868,6 +873,7 @@ this.focusOverlay(e) this.sendMousePos(e) + this.pressedMouseButtons.delete(e.button + 1) this.$client.sendData('mouseup', { key: e.button + 1 }) } @@ -880,7 +886,7 @@ } onMouseEnter(e: MouseEvent) { - if (this.hosting) { + if (this.hosting && !this.locked) { this.$accessor.remote.syncKeyboardModifierState({ capsLock: e.getModifierState('CapsLock'), numLock: e.getModifierState('NumLock'), @@ -906,6 +912,14 @@ this.focused = false } + releaseInput() { + this.resetKeyboard() + for (const key of this.pressedMouseButtons) { + this.$client.sendData('mouseup', { key }) + } + this.pressedMouseButtons.clear() + } + resetKeyboard() { this.keyboard.reset() } @@ -946,6 +960,8 @@ // without this delay the remote pastes stale content. await new Promise((resolve) => setTimeout(resolve, 80)) + if (!this.hosting || this.locked) return + // Send the full Ctrl+V sequence. We can't rely on Guacamole having // captured the original Cmd/Ctrl keydown because Safari may intercept // modifier shortcuts before they reach iframe JavaScript. diff --git a/images/chromium-headful/client/src/store/remote.ts b/images/chromium-headful/client/src/store/remote.ts index 52c53d90a..f66e1e85b 100644 --- a/images/chromium-headful/client/src/store/remote.ts +++ b/images/chromium-headful/client/src/store/remote.ts @@ -12,6 +12,8 @@ export const state = () => ({ id: '', clipboard: '', locked: false, + readOnly: false, + configuredImplicitHosting: true, implicitHosting: true, fileTransfer: true, keyboardModifierState: -1, @@ -60,7 +62,14 @@ export const mutations = mutationTree(state, { }, setImplicitHosting(state, val: boolean) { - state.implicitHosting = val + state.configuredImplicitHosting = val + state.implicitHosting = val && !state.readOnly + }, + + setReadOnly(state, readOnly: boolean) { + state.readOnly = readOnly + state.locked = readOnly + state.implicitHosting = state.configuredImplicitHosting && !readOnly }, setFileTransfer(state, val: boolean) { @@ -74,7 +83,7 @@ export const mutations = mutationTree(state, { reset(state) { state.id = '' state.clipboard = '' - state.locked = false + state.locked = state.readOnly state.requesting = false }, }) @@ -82,8 +91,8 @@ export const mutations = mutationTree(state, { export const actions = actionTree( { state, getters, mutations }, { - sendClipboard({ getters }, clipboard: string) { - if (!accessor.connected || !getters.hosting) { + sendClipboard({ state, getters }, clipboard: string) { + if (!accessor.connected || state.readOnly || !getters.hosting) { return } @@ -176,6 +185,8 @@ export const actions = actionTree( }, syncKeyboardModifierState({ state }, { capsLock, numLock, scrollLock }) { + if (state.readOnly) return + if (state.keyboardModifierState === keyboardModifierState(capsLock, numLock, scrollLock)) { return } diff --git a/images/chromium-headful/client/src/utils/read-only.ts b/images/chromium-headful/client/src/utils/read-only.ts new file mode 100644 index 000000000..735af2c44 --- /dev/null +++ b/images/chromium-headful/client/src/utils/read-only.ts @@ -0,0 +1,13 @@ +export function isReadOnlyMessage(event: MessageEvent, parent: Window, parentOrigin: string): boolean { + return ( + event.source === parent && + parentOrigin !== '*' && + parentOrigin !== 'null' && + event.origin === parentOrigin && + event.data !== null && + typeof event.data === 'object' && + event.data.type === 'KERNEL_SET_READ_ONLY' && + typeof event.data.readOnly === 'boolean' && + (event.data.requestId === undefined || typeof event.data.requestId === 'string') + ) +} diff --git a/images/chromium-headful/client/tests/read-only.test.ts b/images/chromium-headful/client/tests/read-only.test.ts new file mode 100644 index 000000000..7761ddda8 --- /dev/null +++ b/images/chromium-headful/client/tests/read-only.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, mock, test } from 'bun:test' +import { isReadOnlyMessage } from '../src/utils/read-only' + +const accessor = { connected: true } +mock.module('~/store', () => ({ accessor })) +const { state, mutations, actions } = await import('../src/store/remote') + +const parent = {} as Window +const origin = 'https://dashboard.example' +const message = (overrides: Partial = {}) => + ({ + source: parent, + origin, + data: { type: 'KERNEL_SET_READ_ONLY', readOnly: true, requestId: '1' }, + ...overrides, + } as MessageEvent) + +describe('read-only parent messages', () => { + test('accepts both modes from the exact parent and origin', () => { + for (const readOnly of [true, false]) { + expect( + isReadOnlyMessage( + message({ data: { type: 'KERNEL_SET_READ_ONLY', readOnly, requestId: '1' } }), + parent, + origin, + ), + ).toBe(true) + } + }) + + test('accepts messages from existing parents without a request ID', () => { + expect(isReadOnlyMessage(message({ data: { type: 'KERNEL_SET_READ_ONLY', readOnly: true } }), parent, origin)).toBe( + true, + ) + }) + + test('rejects unrelated windows, origins, opaque origins, and malformed payloads', () => { + expect(isReadOnlyMessage(message({ source: {} as Window }), parent, origin)).toBe(false) + expect(isReadOnlyMessage(message({ origin: 'https://other.example' }), parent, origin)).toBe(false) + for (const parentOrigin of ['*', 'null']) + expect(isReadOnlyMessage(message({ origin: parentOrigin }), parent, parentOrigin)).toBe(false) + for (const data of [ + null, + undefined, + 'true', + {}, + { type: 'KERNEL_SET_READ_ONLY', readOnly: 'true', requestId: '1' }, + { type: 'KERNEL_SET_READ_ONLY', readOnly: true, requestId: 123 }, + ]) { + expect(isReadOnlyMessage(message({ data }), parent, origin)).toBe(false) + } + }) +}) + +describe('read-only input state', () => { + test('locks and unlocks without resetting the current controller', () => { + const remote = state() + remote.id = 'controller' + mutations.setReadOnly(remote, true) + expect(remote.locked).toBe(true) + expect(remote.implicitHosting).toBe(false) + mutations.setReadOnly(remote, false) + expect(remote.locked).toBe(false) + expect(remote.implicitHosting).toBe(true) + expect(remote.id).toBe('controller') + }) + + test('preserves server configuration and read-only mode across reconnects', () => { + const remote = state() + mutations.setReadOnly(remote, true) + mutations.setImplicitHosting(remote, true) + mutations.reset(remote) + expect(remote.locked).toBe(true) + expect(remote.implicitHosting).toBe(false) + mutations.setImplicitHosting(remote, false) + mutations.setReadOnly(remote, false) + expect(remote.implicitHosting).toBe(false) + }) + + test('blocks keyboard modifier writes and cache updates while read-only', () => { + const remote = state() + mutations.setReadOnly(remote, true) + actions.syncKeyboardModifierState({ state: remote } as never, { capsLock: true, numLock: true, scrollLock: false }) + expect(remote.keyboardModifierState).toBe(-1) + }) + + test('blocks clipboard writes even when the locked viewer still holds control', () => { + const remote = state() + mutations.setReadOnly(remote, true) + // Sending would access the absent global $client and fail this test. + actions.sendClipboard({ state: remote, getters: { hosting: true } } as never, 'secret') + }) +}) diff --git a/images/chromium-headful/client/tests/video-input.test.ts b/images/chromium-headful/client/tests/video-input.test.ts new file mode 100644 index 000000000..9a10d1b83 --- /dev/null +++ b/images/chromium-headful/client/tests/video-input.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import ts from 'typescript' +import { parseComponent } from 'vue-template-compiler' + +// Load the real component methods without mounting its media player or child components. +const script = parseComponent(readFileSync(new URL('../src/components/video.vue', import.meta.url), 'utf8')).script! + .content +const compiled = ts.transpileModule(script, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, experimentalDecorators: true }, +}).outputText + +function videoMethods(readText: () => Promise, granted = async () => true) { + const exports: { default?: { prototype: Record } } = {} + const noDecorator = () => () => undefined + runInNewContext(compiled, { + exports, + require: (name: string) => { + if (name === 'vue-property-decorator') { + return { + Vue: class {}, + Component: () => (type: unknown) => type, + Ref: noDecorator, + Watch: noDecorator, + Prop: noDecorator, + } + } + if (name === '~/utils/clipboard') return { isClipboardReadGranted: granted } + return {} + }, + window: { self: {}, top: {}, document: { hasFocus: () => true } }, + navigator: { clipboard: { readText } }, + setTimeout, + }) + return exports.default!.prototype +} + +function context() { + const sent: string[] = [] + const data: string[] = [] + const ctx = { + hosting: true, + locked: false, + clipboard_read_available: true, + clipboard: 'old remote clipboard', + $accessor: { + remote: { + setClipboard(text: string) { + ctx.clipboard = text + }, + sendClipboard(text: string) { + sent.push(text) + }, + }, + }, + $log: { + error: (error: unknown) => { + throw error + }, + }, + $client: { sendData: (event: string) => data.push(event) }, + keyMap: (key: number) => key, + pastePending: false, + } + return { ctx, sent, data } +} + +describe('video input during read-only transitions', () => { + test('does not cache a clipboard read that resolves after locking; paste syncs after unlocking', async () => { + let resolve!: (text: string) => void + let begin!: () => void + const started = new Promise((done) => { + begin = done + }) + const methods = videoMethods( + () => + new Promise((done) => { + resolve = done + begin() + }), + ) + const { ctx, sent, data } = context() + const pending = methods.syncClipboard.call(ctx) + await started + ctx.locked = true + resolve('new clipboard') + await pending + expect(ctx.clipboard).toBe('old remote clipboard') + expect(sent).toEqual([]) + + ctx.locked = false + await methods.onPaste.call(ctx, { clipboardData: { getData: () => 'new clipboard' } }) + expect(sent).toEqual(['new clipboard']) + expect(data).toEqual(['keydown', 'keydown', 'keyup', 'keyup']) + }) + + test('does not begin a clipboard read if locked while waiting for permission', async () => { + let resolve!: (granted: boolean) => void + let reads = 0 + const methods = videoMethods( + async () => { + reads++ + return 'clipboard' + }, + () => + new Promise((done) => { + resolve = done + }), + ) + const { ctx } = context() + const pending = methods.syncClipboard.call(ctx) + ctx.locked = true + resolve(true) + await pending + expect(reads).toBe(0) + }) + + test('does not send a paste shortcut if locked during the clipboard propagation delay', async () => { + const methods = videoMethods(async () => '') + const { ctx, data } = context() + const pending = methods.onPaste.call(ctx, { clipboardData: { getData: () => 'new clipboard' } }) + ctx.locked = true + await pending + expect(data).toEqual([]) + }) + + test('mouse entry does not synchronize keyboard modifiers or clipboard while locked', () => { + const methods = videoMethods(async () => '') + // These unprovided callbacks would throw if invoked while locked. + methods.onMouseEnter.call({ hosting: true, locked: true }, {}) + }) +}) From 587dadff8a5174730f26f690c39f68bd166c2ee6 Mon Sep 17 00:00:00 2001 From: Eric Feng Date: Mon, 14 Sep 2026 13:12:10 -0700 Subject: [PATCH 2/2] Resync lock-key modifiers after unlock and install live-view test dependencies --- .github/workflows/live-view-test.yaml | 11 +++++ .../client/src/components/video.vue | 3 +- .../client/src/store/remote.ts | 4 ++ .../client/tests/read-only.test.ts | 34 ++++++++++++++- .../client/tests/video-input.test.ts | 41 +++++++++++++++++++ 5 files changed, 91 insertions(+), 2 deletions(-) diff --git a/.github/workflows/live-view-test.yaml b/.github/workflows/live-view-test.yaml index efe9a73c3..98ebd02a5 100644 --- a/.github/workflows/live-view-test.yaml +++ b/.github/workflows/live-view-test.yaml @@ -17,6 +17,17 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: images/chromium-headful/client/package-lock.json + + - name: Install client dependencies + run: npm ci --no-audit --no-fund + working-directory: images/chromium-headful/client + - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index 94d21a33b..6b0355651 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -900,7 +900,8 @@ } onMouseLeave(e: MouseEvent) { - if (this.hosting) { + // Keep an invalidated cache until mouse entry synchronizes with the remote. + if (this.hosting && !this.locked && this.$accessor.remote.keyboardModifierState !== -1) { this.$accessor.remote.setKeyboardModifierState({ capsLock: e.getModifierState('CapsLock'), numLock: e.getModifierState('NumLock'), diff --git a/images/chromium-headful/client/src/store/remote.ts b/images/chromium-headful/client/src/store/remote.ts index f66e1e85b..a4c47c27c 100644 --- a/images/chromium-headful/client/src/store/remote.ts +++ b/images/chromium-headful/client/src/store/remote.ts @@ -67,6 +67,10 @@ export const mutations = mutationTree(state, { }, setReadOnly(state, readOnly: boolean) { + if (state.readOnly && !readOnly) { + // The local lock keys may have changed while remote input was blocked. + state.keyboardModifierState = -1 + } state.readOnly = readOnly state.locked = readOnly state.implicitHosting = state.configuredImplicitHosting && !readOnly diff --git a/images/chromium-headful/client/tests/read-only.test.ts b/images/chromium-headful/client/tests/read-only.test.ts index 7761ddda8..dee17e31d 100644 --- a/images/chromium-headful/client/tests/read-only.test.ts +++ b/images/chromium-headful/client/tests/read-only.test.ts @@ -1,7 +1,7 @@ import { describe, expect, mock, test } from 'bun:test' import { isReadOnlyMessage } from '../src/utils/read-only' -const accessor = { connected: true } +const accessor = { connected: true, remote: { setKeyboardModifierState: mock() } } mock.module('~/store', () => ({ accessor })) const { state, mutations, actions } = await import('../src/store/remote') @@ -91,3 +91,35 @@ describe('read-only input state', () => { actions.sendClipboard({ state: remote, getters: { hosting: true } } as never, 'secret') }) }) + +describe('keyboard modifiers after unlocking', () => { + test.each(['capsLock', 'numLock', 'scrollLock'] as const)( + 'resynchronizes %s once and resumes deduplication', + (key) => { + const remote = state() + const modifierState = { capsLock: false, numLock: false, scrollLock: false, [key]: true } + mutations.setKeyboardModifierState(remote, modifierState) + mutations.setReadOnly(remote, true) + actions.syncKeyboardModifierState({ state: remote } as never, modifierState) + mutations.setReadOnly(remote, false) + expect(remote.keyboardModifierState).toBe(-1) + + const sendMessage = mock() + accessor.remote.setKeyboardModifierState.mockImplementation((value) => + mutations.setKeyboardModifierState(remote, value), + ) + Object.defineProperty(globalThis, '$client', { value: { sendMessage }, configurable: true }) + try { + actions.syncKeyboardModifierState({ state: remote } as never, modifierState) + expect(sendMessage).toHaveBeenCalledTimes(1) + expect(sendMessage.mock.calls[0][1]).toEqual(modifierState) + mutations.setReadOnly(remote, false) + actions.syncKeyboardModifierState({ state: remote } as never, modifierState) + expect(sendMessage).toHaveBeenCalledTimes(1) + } finally { + Reflect.deleteProperty(globalThis, '$client') + accessor.remote.setKeyboardModifierState.mockReset() + } + }, + ) +}) diff --git a/images/chromium-headful/client/tests/video-input.test.ts b/images/chromium-headful/client/tests/video-input.test.ts index 9a10d1b83..9faa3e5dc 100644 --- a/images/chromium-headful/client/tests/video-input.test.ts +++ b/images/chromium-headful/client/tests/video-input.test.ts @@ -125,6 +125,47 @@ describe('video input during read-only transitions', () => { expect(data).toEqual([]) }) + test('mouse leave does not cache unsent modifier changes while locked', () => { + const methods = videoMethods(async () => '') + let reset = false + methods.onMouseLeave.call( + { + hosting: true, + locked: true, + resetKeyboard: () => { + reset = true + }, + }, + {}, + ) + expect(reset).toBe(true) + }) + + test('unlocking while hovered keeps modifiers unsynchronized through leave and reentry', () => { + const methods = videoMethods(async () => '') + const sent: unknown[] = [] + const localModifiers = { capsLock: true, numLock: true, scrollLock: true } + const remote = { + keyboardModifierState: 0, + setKeyboardModifierState: () => { + remote.keyboardModifierState = 7 + }, + syncKeyboardModifierState: (value: unknown) => { + sent.push(value) + }, + } + const ctx = { hosting: true, locked: true, $accessor: { remote }, resetKeyboard: () => {}, syncClipboard: () => {} } + const event = { getModifierState: () => true } + methods.onMouseLeave.call(ctx, event) + expect(remote.keyboardModifierState).toBe(0) + ctx.locked = false + remote.keyboardModifierState = -1 + methods.onMouseLeave.call(ctx, event) + expect(remote.keyboardModifierState).toBe(-1) + methods.onMouseEnter.call(ctx, event) + expect(sent).toEqual([localModifiers]) + }) + test('mouse entry does not synchronize keyboard modifiers or clipboard while locked', () => { const methods = videoMethods(async () => '') // These unprovided callbacks would throw if invoked while locked.