From a51c75cc6cd61cfe4528e0e487aced84a27b9f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 24 Aug 2026 17:10:14 +0000 Subject: [PATCH] a control for when the input state goes wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /reset-input` and a "clicks acting wrong?" button in the panel's status line. It releases every modifier and mouse button the display thinks is held. Named for the SYMPTOM rather than the mechanism, because nobody experiencing this knows a modifier is stuck — they know their clicks stopped doing the right thing. ── both sides, and that ordering matters ── Releasing on the server alone leaves the panel's own button mask stale and the next mouse move re-asserts it, so the fix would appear not to work. Local state is cleared first, a pointer event with an empty mask says so, and only then does the server let go. ── a fixed list, not a query ── `xinput --query-state` would report exactly what is held, which is better. It is also a seventh host binary to declare and to require, for a repair. Releasing a key that was not pressed is a no-op, so the fixed list costs nothing. Modifiers and buttons only: a stuck letter types, which is obvious and harmless; a stuck modifier silently changes what every click means. ── not guarded like the others ── Every other command refuses while a dialog is open, because its clicks would fall through onto the board. This one clicks nothing, and refusing a repair in the state that most needs it would be the wrong way round. Verified by stranding `Shift_L` on the display with `xdotool keydown`, hitting the route, and watching `xinput --query-state` go from one key down to none. Co-Authored-By: Claude Opus 5 Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b --- README.md | 16 ++++++++++++ sidecar/index.ts | 13 ++++++++++ sidecar/input.ts | 53 ++++++++++++++++++++++++++++++++++++++++ web/MinesweeperPanel.tsx | 33 ++++++++++++++++++++++++- 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 sidecar/input.ts diff --git a/README.md b/README.md index 7904c81..f1b17cd 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,22 @@ Diagnosed by watching `user.reg` grow by 848 bytes at the moment the game exited time into the key by hand and seeing the game display it, then by switching difficulty and watching a clean stop write `Width`/`Height`/`Mines` through to disk. +## When the clicks go wrong: "clicks acting wrong?" in the panel + +`POST /reset-input`, and a button in the panel's status line. It releases every modifier and mouse button +the display believes is held, and clears the panel's own button mask first — releasing on the server alone +leaves the client stale and the next mouse move re-asserts it. + +It exists because synthesised input is STATE, and this side owns it: a press without a matching release +stays pressed on the X server forever, and nothing looks wrong. The panel is connected, the picture +updates, the game responds — it just responds to shift-click instead of click, so the board flags instead +of opening. The panel no longer sends modifiers and releases what it holds on blur, so this should never +be needed; "should never be needed" is why it wants a button rather than a note, because when it IS needed +the symptom names nothing and the only other cure is restarting the session and losing the game. + +Unlike the other commands it is NOT refused while a dialog is open — refusing a repair in the state that +most needs it would be the wrong way round. + ## Two gaps, both from having no window manager Worth knowing before you conclude something is broken. Neither is hard to fix; neither is fixed. diff --git a/sidecar/index.ts b/sidecar/index.ts index 33d55d0..4247b85 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -5,6 +5,7 @@ import { VNC_SOCKET, ensureDirs } from './paths'; import { activeDisplay, isRunning, start, status, stop } from './session'; import { DIFFICULTIES, currentDifficulty, isDifficulty, playReplay, selectDifficulty } from './menu'; import { isKnownReplay, listReplays } from './replays'; +import { resetInput } from './input'; /* officer-minesweeper — owns the game session, and serves both halves of the plugin from one listener. @@ -18,6 +19,7 @@ import { isKnownReplay, listReplays } from './replays'; POST /difficulty { level: beginner | intermediate | expert } — drives the game's own Game menu GET /replays ?level= — the saved games, best first POST /replay { file } — opens one, by name, and only a name /replays just returned + POST /reset-input releases every modifier and mouse button the display thinks is held GET /ws the display — an RFB stream, relayed frame-for-byte onto x11vnc's unix socket ───────────────────────────────────────────────────────────────────────────────────────────────── @@ -144,6 +146,17 @@ const server = Bun.serve({ return Response.json({ replays: listReplays(level ?? undefined) }); } + // Deliberately NOT guarded by `blockingDialog` like the others: this is the repair for a session + // that is misbehaving, and refusing to run it because something is on screen would refuse it in the + // case it is most needed. + if (req.method === 'POST' && url.pathname === '/reset-input') { + const display = activeDisplay(); + if (!isRunning() || !display) { + return Response.json({ error: 'the session is not running' }, { status: 409 }); + } + return Response.json(await resetInput(display)); + } + if (req.method === 'POST' && url.pathname === '/replay') { const display = activeDisplay(); if (!isRunning() || !display) { diff --git a/sidecar/input.ts b/sidecar/input.ts new file mode 100644 index 0000000..e7ccbb2 --- /dev/null +++ b/sidecar/input.ts @@ -0,0 +1,53 @@ +import { run } from './run'; + +/** + * Let go of everything the display thinks is held. + * + * ── Why this needs to exist at all ── + * + * Synthesised input is state, and this side owns it. A press without a matching release stays pressed on + * the X server forever, and there is nothing to notice: the panel looks connected, the picture updates, + * the game responds. It just responds WRONGLY. A stranded `Shift_L` turned every left click into a + * shift-click, which Minesweeper reads as a right click, so the board flagged instead of opening. + * + * The panel now releases what it holds on blur and on unmount, and no longer sends modifiers at all — so + * this should never be needed. "Should never be needed" is exactly the class of thing that wants a button, + * because when it IS needed the symptom names nothing and the only other cure is restarting the session + * and losing the game in progress. + * + * ── Why a fixed list rather than asking what is down ── + * + * `xinput --query-state` would report exactly what is held, which is better. It is also a seventh host + * binary this plugin would have to declare and a machine would have to have, for a repair. Releasing a key + * that was not pressed is a no-op, so the fixed list costs nothing and needs nothing. + * + * Modifiers and buttons only, deliberately. They are what DISTORTS input — a stuck letter types, which is + * obvious and harmless; a stuck modifier silently changes what every click means. + */ +const MODIFIERS = [ + 'Shift_L', + 'Shift_R', + 'Control_L', + 'Control_R', + 'Alt_L', + 'Alt_R', + 'Super_L', + 'Super_R', + 'Meta_L', + 'Meta_R', + 'ISO_Level3_Shift', +]; + +const BUTTONS = ['1', '2', '3', '4', '5']; + +export type ResetResult = { ok: boolean; released: { modifiers: number; buttons: number } }; + +export async function resetInput(display: string): Promise { + const keys = await run(['xdotool', 'keyup', ...MODIFIERS], { DISPLAY: display }, 5_000); + let buttons = 0; + for (const button of BUTTONS) { + const up = await run(['xdotool', 'mouseup', button], { DISPLAY: display }, 5_000); + if (up.ok) buttons++; + } + return { ok: keys.ok, released: { modifiers: keys.ok ? MODIFIERS.length : 0, buttons } }; +} diff --git a/web/MinesweeperPanel.tsx b/web/MinesweeperPanel.tsx index 799faa9..97f742b 100644 --- a/web/MinesweeperPanel.tsx +++ b/web/MinesweeperPanel.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react'; +import { toast } from 'sonner'; +import { Bomb, Loader2, Play, RefreshCw, Unplug } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import type { RfbError } from './rfb'; import { RfbClient, keysymFor } from './rfb'; @@ -276,6 +277,26 @@ export const MinesweeperPanel = () => { heldKeysRef.current.clear(); }; + /** + * The escape hatch: let go of everything, on both sides. + * + * Both sides is the point. Releasing on the server alone leaves this panel's own button mask stale, and + * the next mouse move re-asserts it — the fix would appear not to work. So the local state is cleared + * first, a pointer event with an empty mask is sent to say so, and only then does the server let go of + * whatever it still believes is down. + */ + const resetInput = async () => { + releaseHeldKeys(); + buttonsRef.current = 0; + rfbRef.current?.pointer(0, 0, 0); + try { + await clientRef.current.post('/minesweeper/reset-input', {}); + toast.success('Input reset.'); + } catch { + toast.error('Could not reset the input state.'); + } + }; + const onPointer = (ev: React.MouseEvent, kind: 'down' | 'up' | 'move') => { const rfb = rfbRef.current; if (!rfb) return; @@ -356,6 +377,16 @@ export const MinesweeperPanel = () => { + {/* Named for the SYMPTOM, not the mechanism: nobody experiencing this knows a modifier is stuck, + they know their clicks stopped doing the right thing. */} + {error ?

{error}

: null}