From 02a816c8dc26db7681def5d3fa4f0a8a2dd07020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 24 Aug 2026 17:04:05 +0000 Subject: [PATCH] =?UTF-8?q?stop=20stranding=20modifiers=20=E2=80=94=20ever?= =?UTF-8?q?y=20left=20click=20was=20a=20right=20click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "no way to send a left click". `xinput --query-state` on the display showed `Shift_L` down on the XTEST device with nothing holding it, so every click reached the game as shift-click, which Minesweeper reads as a right click. The board flagged instead of opening and nothing about the panel looked wrong. Mine, from the keyboard work two commits ago, and two separate mistakes. ── modifiers should never have been sent ── Press shift, release it while the canvas no longer has focus, and the keyup never arrives — the key is then down on the server forever. They are also POINTLESS: `ev.key` is the character the layout already produced, so a shifted key arrives as `A` and x11vnc presses shift itself to type it. Sending our own on top was redundant on the good path and load-bearing on the bad one. `keysymFor` now returns null for them, which also hands them back to the browser — shift-tab should still move focus out of a game of Minesweeper. ── and nothing released what was held ── Any key can strand the same way, so the panel now tracks what it has pressed and releases it on blur and on unmount. Cheap, and the failure it prevents is invisible from the panel: the server has no way to know a key was released somewhere it could not see. The live display is unstuck too — `xdotool keyup` on the modifiers, since the fix stops it happening again but does not undo the one already stranded. The general lesson, which is the same one the difficulty command taught: input synthesised into another program's session is state you now own. Press without a guaranteed release is a leak, and it does not look like one. Co-Authored-By: Claude Opus 5 Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b --- web/MinesweeperPanel.tsx | 24 ++++++++++++++++++++++++ web/rfb.ts | 21 ++++++++++++++++----- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/web/MinesweeperPanel.tsx b/web/MinesweeperPanel.tsx index 88e35f3..799faa9 100644 --- a/web/MinesweeperPanel.tsx +++ b/web/MinesweeperPanel.tsx @@ -72,6 +72,9 @@ export const MinesweeperPanel = () => { const canvasRef = useRef(null); const rfbRef = useRef(null); const buttonsRef = useRef(0); + // Every keysym we have sent a press for and not yet a release. A key held when focus leaves never gets + // its keyup, and a key stuck down on an X server stays stuck until something releases it. + const heldKeysRef = useRef(new Set()); const boxRef = useRef(null); @@ -220,6 +223,7 @@ export const MinesweeperPanel = () => { rfbRef.current = rfb; return () => { + releaseHeldKeys(); rfb.close(); rfbRef.current = null; setConnected(false); @@ -250,9 +254,28 @@ export const MinesweeperPanel = () => { const keysym = keysymFor(ev.key); if (keysym === null) return; ev.preventDefault(); + if (down) heldKeysRef.current.add(keysym); + else heldKeysRef.current.delete(keysym); rfb.key(keysym, down); }; + /** + * Release everything on the way out. + * + * A key held when the canvas loses focus never sends its keyup, and the server has no way to know: it + * stays down until something says otherwise. That is not a cosmetic leak — a stranded modifier turned + * every left click into a shift-click, which Minesweeper reads as a right click, and the board flagged + * instead of opening. + * + * Modifiers are no longer sent at all (see `keysymFor`), so this now covers the ordinary keys. Cheap, + * and the failure it prevents is invisible from the panel. + */ + const releaseHeldKeys = () => { + const rfb = rfbRef.current; + for (const keysym of heldKeysRef.current) rfb?.key(keysym, false); + heldKeysRef.current.clear(); + }; + const onPointer = (ev: React.MouseEvent, kind: 'down' | 'up' | 'move') => { const rfb = rfbRef.current; if (!rfb) return; @@ -314,6 +337,7 @@ export const MinesweeperPanel = () => { onMouseMove={(ev) => onPointer(ev, 'move')} onKeyDown={(ev) => onKey(ev, true)} onKeyUp={(ev) => onKey(ev, false)} + onBlur={releaseHeldKeys} onContextMenu={(ev) => ev.preventDefault()} // Nearest-neighbour: smoothing a 1995 bitmap looks like a photograph of a screen rather than // the screen. It is half of what keeps this sharp; `stage` above is the other half. diff --git a/web/rfb.ts b/web/rfb.ts index 52bd01a..1814c91 100644 --- a/web/rfb.ts +++ b/web/rfb.ts @@ -380,13 +380,24 @@ const NAMED_KEYSYMS: Record = { ArrowRight: 0xff53, ArrowDown: 0xff54, Insert: 0xff63, - Shift: 0xffe1, - Control: 0xffe3, - Alt: 0xffe9, - Meta: 0xffeb, - CapsLock: 0xffe5, }; +/* + MODIFIERS ARE DELIBERATELY ABSENT, AND THAT IS A FIX RATHER THAN AN OMISSION. + + They were here, and it stranded `Shift_L` in the down state on the server: press shift, release it while + the canvas no longer has focus, and the keyup never arrives. Every left click then reached the game as + shift-click, which reads as a right click — the board flagged instead of opening and nothing about the + panel looked wrong. + + They are not needed. `ev.key` is the character the layout ALREADY produced, so a shifted key arrives as + `A` and x11vnc presses shift itself to type it. Sending our own shift on top was redundant on the good + path and load-bearing on the bad one. + + `keysymFor` returning null for them also means they stay the browser's, which is what you want: shift-tab + should still move focus out of a game of Minesweeper. +*/ + export function keysymFor(key: string): number | null { const named = NAMED_KEYSYMS[key]; if (named !== undefined) return named;