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}