From 61f0b3b7d8dbf587b461a7eb968dbfd36fa2a7cd Mon Sep 17 00:00:00 2001 From: Jayko001 <86390011+Jayko001@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:28:42 +0000 Subject: [PATCH 1/2] Keep live view Caps Lock in sync on macOS macOS browsers send Caps Lock keydown only when enabling it and keyup only when disabling it. The keyboard handler forwarded the keydown as a full press and release, then dropped the keyup because the key was no longer pressed, so every other toggle never reached the remote and its Caps Lock state inverted. On macOS, report Caps Lock events as the resulting lock state instead. Live view toggles the remote with a keystroke only when that state differs from the remote's last known state, and records the change so mouse-entry modifier sync stays accurate. Co-Authored-By: Claude Opus 5.5 --- .../client/src/components/video.vue | 15 +++++ .../client/src/store/remote.ts | 9 +++ .../client/src/utils/guacamole-keyboard.js | 26 ++++++++ .../client/src/utils/guacamole-keyboard.ts | 10 +++ .../client/tests/guacamole-keyboard.test.ts | 51 ++++++++++++-- .../client/tests/video-input.test.ts | 66 ++++++++++++++++++- 6 files changed, 172 insertions(+), 5 deletions(-) diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index 6b035565..3984ba9e 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -584,6 +584,7 @@ this.$client.sendData('keyup', { key: this.keyMap(key) }) } + this.keyboard.oncapslock = this.onCapsLock this.keyboard.listenTo(this._overlay) } @@ -913,6 +914,20 @@ this.focused = false } + onCapsLock(capsLock: boolean) { + this.unmuteOnInteraction() + + if (!this.hosting || this.locked || this.$accessor.remote.capsLock === capsLock) { + return + } + + // Toggle with a keystroke so it stays ordered with the keys typed after it. + this.$accessor.remote.setCapsLock(capsLock) + const key = this.keyMap(0xffe5) + this.$client.sendData('keydown', { key }) + this.$client.sendData('keyup', { key }) + } + releaseInput() { this.resetKeyboard() for (const key of this.pressedMouseButtons) { diff --git a/images/chromium-headful/client/src/store/remote.ts b/images/chromium-headful/client/src/store/remote.ts index a4c47c27..89421583 100644 --- a/images/chromium-headful/client/src/store/remote.ts +++ b/images/chromium-headful/client/src/store/remote.ts @@ -36,6 +36,9 @@ export const getters = getterTree(state, { host: (state, getters, root) => { return root.user.members[state.id] || (state.implicitHosting && root.user.id) || null }, + capsLock: (state) => { + return state.keyboardModifierState === -1 ? null : Boolean(state.keyboardModifierState & 1) + }, }) export const mutations = mutationTree(state, { @@ -57,6 +60,12 @@ export const mutations = mutationTree(state, { state.keyboardModifierState = keyboardModifierState(capsLock, numLock, scrollLock) }, + setCapsLock(state, capsLock: boolean) { + if (state.keyboardModifierState !== -1) { + state.keyboardModifierState = (state.keyboardModifierState & ~1) | Number(capsLock) + } + }, + setLocked(state, locked: boolean) { state.locked = locked }, diff --git a/images/chromium-headful/client/src/utils/guacamole-keyboard.js b/images/chromium-headful/client/src/utils/guacamole-keyboard.js index e15e5198..881ffdb2 100644 --- a/images/chromium-headful/client/src/utils/guacamole-keyboard.js +++ b/images/chromium-headful/client/src/utils/guacamole-keyboard.js @@ -85,6 +85,17 @@ Guacamole.Keyboard = function Keyboard(element) { */ this.onkeyup = null; + /** + * Fired instead of onkeydown/onkeyup for Caps Lock on platforms where + * Caps Lock key events report a change in lock state rather than a + * physical press and release. + * + * @event + * @param {!boolean} capsLock + * Whether Caps Lock is locally enabled after the event. + */ + this.oncapslock = null; + /** * Set of known platform-specific or browser-specific quirks which must be * accounted for to properly interpret key events, even if the only way to @@ -209,6 +220,13 @@ Guacamole.Keyboard = function Keyboard(element) { */ this.modifiers = orig ? Guacamole.Keyboard.ModifierState.fromKeyboardEvent(orig) : new Guacamole.Keyboard.ModifierState(); + /** + * Whether Caps Lock was enabled at the time this event was received. + * + * @type {!boolean} + */ + this.capsLock = !!(orig && orig.getModifierState && orig.getModifierState('CapsLock')); + /** * An arbitrary timestamp in milliseconds, indicating this event's * position in time relative to other events. @@ -1135,6 +1153,14 @@ Guacamole.Keyboard = function Keyboard(element) { if (!first) return null; + // Caps Lock keydown/keyup only signal lock state changes on macOS + // (keydown when enabling, keyup when disabling), so report the state + if (first.keysym === 0xFFE5 && quirks.capsLockKeyupUnreliable && guac_keyboard.oncapslock) { + guac_keyboard.oncapslock(first.capsLock); + first.defaultPrevented = true; + return eventLog.shift(); + } + // Keydown event if (first instanceof KeydownEvent) { diff --git a/images/chromium-headful/client/src/utils/guacamole-keyboard.ts b/images/chromium-headful/client/src/utils/guacamole-keyboard.ts index 183cdcfd..2b790ba0 100644 --- a/images/chromium-headful/client/src/utils/guacamole-keyboard.ts +++ b/images/chromium-headful/client/src/utils/guacamole-keyboard.ts @@ -21,6 +21,16 @@ export interface GuacamoleKeyboardInterface { */ onkeyup?: (keysym: number) => void + /** + * Fired instead of onkeydown/onkeyup for Caps Lock on platforms where + * Caps Lock key events report a change in lock state rather than a + * physical press and release. + * + * @event + * @param {Boolean} capsLock Whether Caps Lock is locally enabled after the event. + */ + oncapslock?: (capsLock: boolean) => void + /** * Marks a key as pressed, firing the keydown event if registered. The * return value of this function depends on the return value of the diff --git a/images/chromium-headful/client/tests/guacamole-keyboard.test.ts b/images/chromium-headful/client/tests/guacamole-keyboard.test.ts index 918ed042..05732737 100644 --- a/images/chromium-headful/client/tests/guacamole-keyboard.test.ts +++ b/images/chromium-headful/client/tests/guacamole-keyboard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'bun:test' +import { afterEach, describe, expect, test } from 'bun:test' import createKeyboard from '../src/utils/guacamole-keyboard' @@ -11,7 +11,7 @@ class KeyboardTarget { this.listeners.set(type, listeners) } - dispatch(type: string, key: string, keyCode: number, repeat = false) { + dispatch(type: string, key: string, keyCode: number, repeat = false, capsLock = false) { const event = { key, keyCode, @@ -23,8 +23,8 @@ class KeyboardTarget { altKey: false, metaKey: false, preventDefault() {}, - getModifierState() { - return false + getModifierState(name: string) { + return name === 'CapsLock' && capsLock }, } as KeyboardEvent @@ -70,3 +70,46 @@ describe('GuacamoleKeyboard repeat handling', () => { expect(events).toEqual(['down:111', 'up:111', 'down:111', 'up:111']) }) }) + +describe('GuacamoleKeyboard Caps Lock handling', () => { + const platform = Object.getOwnPropertyDescriptor(navigator, 'platform')! + + afterEach(() => { + Object.defineProperty(navigator, 'platform', platform) + }) + + function keyboardOn(platformName: string) { + Object.defineProperty(navigator, 'platform', { value: platformName, configurable: true }) + const target = new KeyboardTarget() + const keyboard = createKeyboard(target as unknown as Element) + const events: string[] = [] + keyboard.onkeydown = (key) => { + events.push(`down:${key}`) + return false + } + keyboard.onkeyup = (key) => events.push(`up:${key}`) + keyboard.oncapslock = (capsLock) => events.push(`caps:${capsLock}`) + return { target, events } + } + + test('reports macOS Caps Lock keydown and keyup as lock state', () => { + const { target, events } = keyboardOn('MacIntel') + + target.dispatch('keydown', 'CapsLock', 20, false, true) + target.dispatch('keydown', 'a', 65, false, true) + target.dispatch('keypress', 'A', 65, false, true) + target.dispatch('keyup', 'a', 65, false, true) + target.dispatch('keyup', 'CapsLock', 20, false, false) + + expect(events).toEqual(['caps:true', 'down:65', 'up:65', 'caps:false']) + }) + + test('keeps Caps Lock as a key press on other platforms', () => { + const { target, events } = keyboardOn('Win32') + + target.dispatch('keydown', 'CapsLock', 20, false, true) + target.dispatch('keyup', 'CapsLock', 20, false, true) + + expect(events).toEqual(['down:65509', 'up:65509']) + }) +}) diff --git a/images/chromium-headful/client/tests/video-input.test.ts b/images/chromium-headful/client/tests/video-input.test.ts index 9faa3e5d..c1d45040 100644 --- a/images/chromium-headful/client/tests/video-input.test.ts +++ b/images/chromium-headful/client/tests/video-input.test.ts @@ -1,9 +1,12 @@ -import { describe, expect, test } from 'bun:test' +import { describe, expect, mock, test } from 'bun:test' import { readFileSync } from 'node:fs' import { runInNewContext } from 'node:vm' import ts from 'typescript' import { parseComponent } from 'vue-template-compiler' +mock.module('~/store', () => ({ accessor: {} })) +const { state: remoteState, getters: remoteGetters, mutations: remoteMutations } = await import('../src/store/remote') + // 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 @@ -172,3 +175,64 @@ describe('video input during read-only transitions', () => { methods.onMouseEnter.call({ hosting: true, locked: true }, {}) }) }) + +describe('video Caps Lock synchronization', () => { + function capsLockContext(keyboardModifierState: number, locked = false) { + const remote = remoteState() + remote.keyboardModifierState = keyboardModifierState + const data: string[] = [] + const ctx = { + hosting: true, + locked, + unmuteOnInteraction() {}, + keyMap: (key: number) => key, + $client: { sendData: (event: string, { key }: { key: number }) => data.push(`${event}:${key}`) }, + $accessor: { + remote: { + get capsLock() { + return (remoteGetters.capsLock as Function)(remote) + }, + setCapsLock: (capsLock: boolean) => remoteMutations.setCapsLock(remote, capsLock), + }, + }, + } + return { ctx, remote, data } + } + + test('toggles the remote only when local Caps Lock differs from it', () => { + const methods = videoMethods(async () => '') + const { ctx, remote, data } = capsLockContext(4) + + methods.onCapsLock.call(ctx, true) + expect(data).toEqual(['keydown:65509', 'keyup:65509']) + expect(remote.keyboardModifierState).toBe(5) + + methods.onCapsLock.call(ctx, true) + expect(data).toHaveLength(2) + + methods.onCapsLock.call(ctx, false) + expect(data).toEqual(['keydown:65509', 'keyup:65509', 'keydown:65509', 'keyup:65509']) + expect(remote.keyboardModifierState).toBe(4) + }) + + test('toggles on every Caps Lock change while the remote state is unknown', () => { + const methods = videoMethods(async () => '') + const { ctx, remote, data } = capsLockContext(-1) + + methods.onCapsLock.call(ctx, true) + methods.onCapsLock.call(ctx, false) + + expect(data).toEqual(['keydown:65509', 'keyup:65509', 'keydown:65509', 'keyup:65509']) + expect(remote.keyboardModifierState).toBe(-1) + }) + + test('does not toggle Caps Lock while locked', () => { + const methods = videoMethods(async () => '') + const { ctx, remote, data } = capsLockContext(0, true) + + methods.onCapsLock.call(ctx, true) + + expect(data).toEqual([]) + expect(remote.keyboardModifierState).toBe(0) + }) +}) From 0c4663b08e929a5d89d0e19a600388282cacf4dd Mon Sep 17 00:00:00 2001 From: Jayko001 <86390011+Jayko001@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:00:25 +0000 Subject: [PATCH 2/2] Forward macOS Caps Lock events as full presses in the keyboard library Replace the oncapslock callback and the component/store state sync with the noVNC approach: on macOS each Caps Lock keydown or keyup is one toggle, so the library forwards each as a press and release through onkeydown/onkeyup. This fixes the library for every consumer and keeps the toggle on the existing hosting/locked/unmute path. Co-Authored-By: Claude Opus 5.5 --- .../client/src/components/video.vue | 15 ----- .../client/src/store/remote.ts | 9 --- .../client/src/utils/guacamole-keyboard.js | 32 ++------- .../client/src/utils/guacamole-keyboard.ts | 10 --- .../client/tests/guacamole-keyboard.test.ts | 25 ++++--- .../client/tests/video-input.test.ts | 66 +------------------ 6 files changed, 18 insertions(+), 139 deletions(-) diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index 3984ba9e..6b035565 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -584,7 +584,6 @@ this.$client.sendData('keyup', { key: this.keyMap(key) }) } - this.keyboard.oncapslock = this.onCapsLock this.keyboard.listenTo(this._overlay) } @@ -914,20 +913,6 @@ this.focused = false } - onCapsLock(capsLock: boolean) { - this.unmuteOnInteraction() - - if (!this.hosting || this.locked || this.$accessor.remote.capsLock === capsLock) { - return - } - - // Toggle with a keystroke so it stays ordered with the keys typed after it. - this.$accessor.remote.setCapsLock(capsLock) - const key = this.keyMap(0xffe5) - this.$client.sendData('keydown', { key }) - this.$client.sendData('keyup', { key }) - } - releaseInput() { this.resetKeyboard() for (const key of this.pressedMouseButtons) { diff --git a/images/chromium-headful/client/src/store/remote.ts b/images/chromium-headful/client/src/store/remote.ts index 89421583..a4c47c27 100644 --- a/images/chromium-headful/client/src/store/remote.ts +++ b/images/chromium-headful/client/src/store/remote.ts @@ -36,9 +36,6 @@ export const getters = getterTree(state, { host: (state, getters, root) => { return root.user.members[state.id] || (state.implicitHosting && root.user.id) || null }, - capsLock: (state) => { - return state.keyboardModifierState === -1 ? null : Boolean(state.keyboardModifierState & 1) - }, }) export const mutations = mutationTree(state, { @@ -60,12 +57,6 @@ export const mutations = mutationTree(state, { state.keyboardModifierState = keyboardModifierState(capsLock, numLock, scrollLock) }, - setCapsLock(state, capsLock: boolean) { - if (state.keyboardModifierState !== -1) { - state.keyboardModifierState = (state.keyboardModifierState & ~1) | Number(capsLock) - } - }, - setLocked(state, locked: boolean) { state.locked = locked }, diff --git a/images/chromium-headful/client/src/utils/guacamole-keyboard.js b/images/chromium-headful/client/src/utils/guacamole-keyboard.js index 881ffdb2..f1533a03 100644 --- a/images/chromium-headful/client/src/utils/guacamole-keyboard.js +++ b/images/chromium-headful/client/src/utils/guacamole-keyboard.js @@ -85,17 +85,6 @@ Guacamole.Keyboard = function Keyboard(element) { */ this.onkeyup = null; - /** - * Fired instead of onkeydown/onkeyup for Caps Lock on platforms where - * Caps Lock key events report a change in lock state rather than a - * physical press and release. - * - * @event - * @param {!boolean} capsLock - * Whether Caps Lock is locally enabled after the event. - */ - this.oncapslock = null; - /** * Set of known platform-specific or browser-specific quirks which must be * accounted for to properly interpret key events, even if the only way to @@ -220,13 +209,6 @@ Guacamole.Keyboard = function Keyboard(element) { */ this.modifiers = orig ? Guacamole.Keyboard.ModifierState.fromKeyboardEvent(orig) : new Guacamole.Keyboard.ModifierState(); - /** - * Whether Caps Lock was enabled at the time this event was received. - * - * @type {!boolean} - */ - this.capsLock = !!(orig && orig.getModifierState && orig.getModifierState('CapsLock')); - /** * An arbitrary timestamp in milliseconds, indicating this event's * position in time relative to other events. @@ -316,10 +298,6 @@ Guacamole.Keyboard = function Keyboard(element) { if (this.modifiers.meta && this.keysym !== 0xFFE7 && this.keysym !== 0xFFE8) this.keyupReliable = false; - // We cannot rely on receiving keyup for Caps Lock on certain platforms - else if (this.keysym === 0xFFE5 && quirks.capsLockKeyupUnreliable) - this.keyupReliable = false; - // Determine whether default action for Alt+combinations must be prevented var prevent_alt = !this.modifiers.ctrl && !quirks.altIsTypableOnly; @@ -1153,11 +1131,11 @@ Guacamole.Keyboard = function Keyboard(element) { if (!first) return null; - // Caps Lock keydown/keyup only signal lock state changes on macOS - // (keydown when enabling, keyup when disabling), so report the state - if (first.keysym === 0xFFE5 && quirks.capsLockKeyupUnreliable && guac_keyboard.oncapslock) { - guac_keyboard.oncapslock(first.capsLock); - first.defaultPrevented = true; + // macOS sends Caps Lock keydown only when enabling it and keyup only + // when disabling it, so each event is one full press of the key + if (first.keysym === 0xFFE5 && quirks.capsLockKeyupUnreliable) { + first.defaultPrevented = !guac_keyboard.press(0xFFE5); + guac_keyboard.release(0xFFE5); return eventLog.shift(); } diff --git a/images/chromium-headful/client/src/utils/guacamole-keyboard.ts b/images/chromium-headful/client/src/utils/guacamole-keyboard.ts index 2b790ba0..183cdcfd 100644 --- a/images/chromium-headful/client/src/utils/guacamole-keyboard.ts +++ b/images/chromium-headful/client/src/utils/guacamole-keyboard.ts @@ -21,16 +21,6 @@ export interface GuacamoleKeyboardInterface { */ onkeyup?: (keysym: number) => void - /** - * Fired instead of onkeydown/onkeyup for Caps Lock on platforms where - * Caps Lock key events report a change in lock state rather than a - * physical press and release. - * - * @event - * @param {Boolean} capsLock Whether Caps Lock is locally enabled after the event. - */ - oncapslock?: (capsLock: boolean) => void - /** * Marks a key as pressed, firing the keydown event if registered. The * return value of this function depends on the return value of the diff --git a/images/chromium-headful/client/tests/guacamole-keyboard.test.ts b/images/chromium-headful/client/tests/guacamole-keyboard.test.ts index 05732737..0154fe7d 100644 --- a/images/chromium-headful/client/tests/guacamole-keyboard.test.ts +++ b/images/chromium-headful/client/tests/guacamole-keyboard.test.ts @@ -11,7 +11,7 @@ class KeyboardTarget { this.listeners.set(type, listeners) } - dispatch(type: string, key: string, keyCode: number, repeat = false, capsLock = false) { + dispatch(type: string, key: string, keyCode: number, repeat = false) { const event = { key, keyCode, @@ -23,8 +23,8 @@ class KeyboardTarget { altKey: false, metaKey: false, preventDefault() {}, - getModifierState(name: string) { - return name === 'CapsLock' && capsLock + getModifierState() { + return false }, } as KeyboardEvent @@ -88,27 +88,26 @@ describe('GuacamoleKeyboard Caps Lock handling', () => { return false } keyboard.onkeyup = (key) => events.push(`up:${key}`) - keyboard.oncapslock = (capsLock) => events.push(`caps:${capsLock}`) return { target, events } } - test('reports macOS Caps Lock keydown and keyup as lock state', () => { + test('forwards each macOS Caps Lock keydown and keyup as a full press', () => { const { target, events } = keyboardOn('MacIntel') - target.dispatch('keydown', 'CapsLock', 20, false, true) - target.dispatch('keydown', 'a', 65, false, true) - target.dispatch('keypress', 'A', 65, false, true) - target.dispatch('keyup', 'a', 65, false, true) - target.dispatch('keyup', 'CapsLock', 20, false, false) + target.dispatch('keydown', 'CapsLock', 20) + target.dispatch('keydown', 'a', 65) + target.dispatch('keypress', 'A', 65) + target.dispatch('keyup', 'a', 65) + target.dispatch('keyup', 'CapsLock', 20) - expect(events).toEqual(['caps:true', 'down:65', 'up:65', 'caps:false']) + expect(events).toEqual(['down:65509', 'up:65509', 'down:65', 'up:65', 'down:65509', 'up:65509']) }) test('keeps Caps Lock as a key press on other platforms', () => { const { target, events } = keyboardOn('Win32') - target.dispatch('keydown', 'CapsLock', 20, false, true) - target.dispatch('keyup', 'CapsLock', 20, false, true) + target.dispatch('keydown', 'CapsLock', 20) + target.dispatch('keyup', 'CapsLock', 20) expect(events).toEqual(['down:65509', 'up:65509']) }) diff --git a/images/chromium-headful/client/tests/video-input.test.ts b/images/chromium-headful/client/tests/video-input.test.ts index c1d45040..9faa3e5d 100644 --- a/images/chromium-headful/client/tests/video-input.test.ts +++ b/images/chromium-headful/client/tests/video-input.test.ts @@ -1,12 +1,9 @@ -import { describe, expect, mock, test } from 'bun:test' +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' -mock.module('~/store', () => ({ accessor: {} })) -const { state: remoteState, getters: remoteGetters, mutations: remoteMutations } = await import('../src/store/remote') - // 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 @@ -175,64 +172,3 @@ describe('video input during read-only transitions', () => { methods.onMouseEnter.call({ hosting: true, locked: true }, {}) }) }) - -describe('video Caps Lock synchronization', () => { - function capsLockContext(keyboardModifierState: number, locked = false) { - const remote = remoteState() - remote.keyboardModifierState = keyboardModifierState - const data: string[] = [] - const ctx = { - hosting: true, - locked, - unmuteOnInteraction() {}, - keyMap: (key: number) => key, - $client: { sendData: (event: string, { key }: { key: number }) => data.push(`${event}:${key}`) }, - $accessor: { - remote: { - get capsLock() { - return (remoteGetters.capsLock as Function)(remote) - }, - setCapsLock: (capsLock: boolean) => remoteMutations.setCapsLock(remote, capsLock), - }, - }, - } - return { ctx, remote, data } - } - - test('toggles the remote only when local Caps Lock differs from it', () => { - const methods = videoMethods(async () => '') - const { ctx, remote, data } = capsLockContext(4) - - methods.onCapsLock.call(ctx, true) - expect(data).toEqual(['keydown:65509', 'keyup:65509']) - expect(remote.keyboardModifierState).toBe(5) - - methods.onCapsLock.call(ctx, true) - expect(data).toHaveLength(2) - - methods.onCapsLock.call(ctx, false) - expect(data).toEqual(['keydown:65509', 'keyup:65509', 'keydown:65509', 'keyup:65509']) - expect(remote.keyboardModifierState).toBe(4) - }) - - test('toggles on every Caps Lock change while the remote state is unknown', () => { - const methods = videoMethods(async () => '') - const { ctx, remote, data } = capsLockContext(-1) - - methods.onCapsLock.call(ctx, true) - methods.onCapsLock.call(ctx, false) - - expect(data).toEqual(['keydown:65509', 'keyup:65509', 'keydown:65509', 'keyup:65509']) - expect(remote.keyboardModifierState).toBe(-1) - }) - - test('does not toggle Caps Lock while locked', () => { - const methods = videoMethods(async () => '') - const { ctx, remote, data } = capsLockContext(0, true) - - methods.onCapsLock.call(ctx, true) - - expect(data).toEqual([]) - expect(remote.keyboardModifierState).toBe(0) - }) -})