Files
minesweeper/web/MinesweeperPanel.tsx
T
pastilhasandClaude Opus 5 ef74f0c2dd keyboard, and back off the size a quarter step
── keys ──

`rfb.ts` already had a `key()` that nothing called. It now has `keysymFor`,
mapping `KeyboardEvent.key` to an X11 keysym — `key` rather than `code`,
because it is already the character the user's own layout produced and `code`
would need that layout re-implemented here to get back to a symbol. Latin-1
passes straight through, above it uses X's 0x01000000 Unicode offset, and the
named keys come from keysymdef.h.

The canvas is focusable and takes focus on mousedown. Scoping keys to focus is
what stops the panel eating keys meant for the rest of the app — this is one
panel on a dashboard, so a window listener would be a global keyboard trap.
Modified keys and unmapped keys are left alone: cmd-W stays the browser's and
Tab keeps moving focus. preventDefault only for keys actually sent.

Keys reach wine without a window manager — verified by pressing F2 on the
display and watching the board reset. So the README's guess that wine needed a
WM to hand it focus was wrong; Auto Options ignores Escape because that VB6
form has no Cancel button, which is a different thing. Corrected there.

20 keysym cases checked against keysymdef.h, including the off-by-one I put in
my own test first: XK_F2 is 0xffbf, not 0xffc0.

── size ──

Half steps overshot: 2x to 2.5x is too small to too large with nothing in
between. Quarter steps from 2x up, which keeps cells whole for the same reason
halves did — a 16px grid at 2.25x is a 36px cell — and lets the picture track
the panel instead of lurching. Plus a 24px device-pixel margin, because
filling the panel edge to edge is most of what "too large" looks like.

Also: the scale was ignoring the panel's own CSS `zoom`. `devicePixelRatio`
knows nothing about it, so at 75% every cell boundary went back onto fractional
device pixels — the exact defect the snapping exists to prevent, reachable from
a button in the panel header. Effective ratio is now dpr x zoom, read as
getBoundingClientRect().width / offsetWidth.

In a 1300x900 device-pixel panel: 2.5x becomes 2.25x, 1260 to 1134 px wide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 22:32:10 +00:00

326 lines
15 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import type { RfbError } from './rfb';
import { RfbClient, keysymFor } from './rfb';
type SessionStatus = {
installed: boolean;
running: boolean;
};
// The whole framebuffer is the picture, and the sidecar keeps the game centred in it. So there is no
// crop here, no board geometry to fetch, and nothing that has to be re-read when the difficulty changes
// — the canvas is whatever size the server said at handshake and stays that size for the session.
//
// It used to crop to the window's rectangle at the origin, which needed the geometry polled every four
// seconds and was wrong twice over: the panel resized under the player mid-game, and wine puts the
// window at 3,29 rather than 0,0, so a crop from the origin showed a black strip on top and cut a row
// of cells off the bottom. That was the "expert gets cut off at the bottom" report.
//
// Only a placeholder until `onReady`, which sizes the canvas from the real framebuffer. Drift with the
// sidecar's stage costs one frame of the wrong aspect ratio and nothing else.
const PLACEHOLDER = { width: 512, height: 352 };
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
/** Breathing room around the stage, in device pixels. Small — it is a frame, not a margin. */
const MARGIN_DEVICE_PX = 24;
// Plugin panels have no localisation mechanism yet — `officerdev`'s `useTranslation`/`panelCatalogs`
// (merged by `I18nBridge`) is platform-internal and not exported from the barrel, and
// `scripts/i18n/scan-hardcoded.ts` explicitly does not scan `plugins/`. So these stay plain English
// sentences rather than catalog keys, same as every other plugin's user-facing strings.
const describeRfbError = (error: RfbError): string => {
switch (error.code) {
case 'connectionFailed':
return 'The connection to the Minesweeper session failed.';
case 'refused':
return 'The VNC server refused the connection.';
case 'authUnsupported':
return 'The VNC server wants authentication, which this client does not implement.';
case 'handshakeRejected':
return 'The VNC server rejected the handshake.';
case 'unknownMessage':
return `The VNC server sent an unknown message type (${error.type}).`;
case 'unknownEncoding':
return `The VNC server used encoding ${error.encoding}, which this client did not ask for.`;
}
};
export const MinesweeperPanel = () => {
// useClient() rebuilds its verbs on EVERY render, so putting one in a dependency list gives that
// list a new identity every render. Held in a ref instead — the same fix the Jellyfin player needed
// after its playback reports silently stopped for days. Getting this wrong here meant `refresh` was
// unstable, its mount effect re-ran every render, and each run set state and caused another render:
// an endless fetch loop that starved the socket and left the panel saying "connecting…" forever.
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const rfbRef = useRef<RfbClient | null>(null);
const buttonsRef = useRef(0);
const boxRef = useRef<HTMLDivElement | null>(null);
const [frame, setFrame] = useState<{ width: number; height: number } | null>(null);
const [box, setBox] = useState<{ width: number; height: number; dpr: number } | null>(null);
const [status, setStatus] = useState<SessionStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const [busy, setBusy] = useState(false);
// Measured rather than assumed, because the scale below has to be exact and a panel is resizable.
// Re-attached when the session starts: until then this branch of the tree is not rendered and the ref
// is null, so a mount-only observer would watch nothing for the rest of the session.
useEffect(() => {
const node = boxRef.current;
if (!node) return;
const observer = new ResizeObserver(() => {
// `offsetWidth` is in the element's OWN pixels; `getBoundingClientRect` is in visual ones, so their
// ratio is the panel's CSS `zoom`. That matters because the panel header has a zoom control and
// `devicePixelRatio` knows nothing about it — at 75% the scale below would be off by a quarter and
// put the cell grid back on fractional boundaries, which is the exact thing it exists to prevent.
const rect = node.getBoundingClientRect();
const zoom = node.offsetWidth > 0 ? rect.width / node.offsetWidth : 1;
setBox({
width: node.offsetWidth,
height: node.offsetHeight,
dpr: (window.devicePixelRatio || 1) * zoom,
});
});
observer.observe(node);
return () => observer.disconnect();
}, [status?.running]);
/**
* How big to draw the stage. Computed in DEVICE pixels, so a 2x display gets twice the detail rather
* than the same picture twice the size — and allowed to scale UP past the framebuffer's own size,
* without which a wide panel leaves the canvas sitting at a fraction of the pixels available to it.
*
* ── Why the scale is snapped, and to what ──
*
* This is a 16px-per-cell bitmap from 1995. At an arbitrary scale a cell is 26.4 screen pixels, so the
* grid lines themselves land unevenly and the digits wobble: sharp everywhere and wrong overall, which
* is what "not quite right" looks like on pixel art. `image-rendering: pixelated` does not save you —
* it removes the blur, not the unevenness. A fractional devicePixelRatio produces this at what looks
* like 1:1, which is the case that is easiest to miss.
*
* QUARTER steps are allowed from 2x up, and that is not a compromise on the above. The unit that has
* to stay regular is the CELL, not the pixel: the board is a 16px grid, so at 2.25x a cell is exactly
* 36 screen pixels — as even as at 2x or 3x — and only sub-cell pixels alternate. Below 2x that
* alternation is too large a share of the pixel, so it stays whole-number there.
*
* Quarters rather than halves because the steps are what make the size QUANTISED, and coarse steps
* overshoot: with halves this went 2x to 2.5x, from too small to too large with nothing in between.
* Finer steps let the picture track the panel rather than lurch between two sizes.
*/
const stage = useMemo(() => {
if (!frame || !box || box.width <= 0 || box.height <= 0) return null;
// Air around the stage. Filling the panel edge to edge is most of what "too large" looks like, and it
// leaves the game with no frame at all.
const usableW = Math.max(1, box.width * box.dpr - MARGIN_DEVICE_PX * 2);
const usableH = Math.max(1, box.height * box.dpr - MARGIN_DEVICE_PX * 2);
const fit = Math.min(usableW / frame.width, usableH / frame.height);
// Below 1 there is nothing to floor to, so take a unit fraction: the rows that survive are then
// evenly spaced instead of dropped in clumps.
const scale = fit < 1 ? 1 / Math.ceil(1 / fit) : fit < 2 ? 1 : Math.floor(fit * 4) / 4;
return { width: (frame.width * scale) / box.dpr, height: (frame.height * scale) / box.dpr };
}, [frame, box]);
const refresh = useCallback(async () => {
try {
setStatus(await clientRef.current.get<SessionStatus>('/minesweeper/status'));
} catch {
setError('Could not reach the server.');
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const start = async () => {
setBusy(true);
setError(null);
try {
setStatus(await clientRef.current.post<SessionStatus>('/minesweeper/start', {}));
} catch {
setError('The session would not start. Is wine-minesweeper installed on the server?');
} finally {
setBusy(false);
}
};
useEffect(() => {
if (!status?.running) return;
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
// 'BEARER_TOKEN' is the key the rest of the app uses (Terminal.tsx, useAudioRecording). Reading
// 'token' here sent an empty string, and upgradeWs answered 401 before any RFB byte was exchanged.
const token = localStorage.getItem('BEARER_TOKEN') ?? sessionStorage.getItem('BEARER_TOKEN') ?? '';
// Plugin relay, not a platform websocket provider: `/api/plugin-ws/minesweeper` rather than the old
// `/api/minesweeper/ws`. Same byte relay onto x11vnc, mounted by the plugin instead of the shell.
const url = `${scheme}://${window.location.host}/api/plugin-ws/minesweeper?token=${encodeURIComponent(token)}`;
const rfb = new RfbClient(url, {
// No offscreen buffer: with the crop gone the visible canvas IS the framebuffer, so a second
// canvas would only add a full blit per frame to arrive at the same pixels.
onReady: (width, height) => {
const canvas = canvasRef.current;
if (canvas) {
canvas.width = width;
canvas.height = height;
}
setFrame({ width, height });
setConnected(true);
setError(null);
},
onFrame: (image, x, y) => {
canvasRef.current?.getContext('2d')?.putImageData(image, x, y);
},
onCopyRect: (from, to) => {
// Same canvas as source and destination, which is what CopyRect is: the 2D context takes a
// snapshot of the bitmap first, so an overlapping copy is well defined rather than smeared.
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (!canvas || !context) return;
context.drawImage(canvas, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h);
},
onResize: (width, height) => {
const canvas = canvasRef.current;
if (canvas) {
canvas.width = width;
canvas.height = height;
}
setFrame({ width, height });
rfb.requestUpdate(false);
},
onError: (rfbError) => setError(describeRfbError(rfbError)),
onClose: () => setConnected(false),
});
rfbRef.current = rfb;
return () => {
rfb.close();
rfbRef.current = null;
setConnected(false);
};
}, [status?.running]);
const toFramebuffer = (ev: React.MouseEvent<HTMLCanvasElement>) => {
const rect = ev.currentTarget.getBoundingClientRect();
const scaleX = ev.currentTarget.width / rect.width;
const scaleY = ev.currentTarget.height / rect.height;
return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY };
};
/**
* Keys, forwarded only while the canvas has focus.
*
* The canvas is focusable and takes focus on mousedown, which is the gesture you use to play anyway.
* Scoping it to focus is what keeps the panel from eating keys meant for the rest of the app — this is
* one panel among several on a dashboard, and a window-level listener would be a global keyboard trap.
*
* A key with a modifier is left alone: cmd-W and ctrl-R belong to the browser and the game wants none
* of them. So is anything `keysymFor` has no mapping for, which keeps Tab moving focus rather than
* disappearing into a game of Minesweeper. `preventDefault` happens only for keys actually sent.
*/
const onKey = (ev: React.KeyboardEvent<HTMLCanvasElement>, down: boolean) => {
const rfb = rfbRef.current;
if (!rfb || ev.metaKey || ev.ctrlKey || ev.altKey) return;
const keysym = keysymFor(ev.key);
if (keysym === null) return;
ev.preventDefault();
rfb.key(keysym, down);
};
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: 'down' | 'up' | 'move') => {
const rfb = rfbRef.current;
if (!rfb) return;
ev.preventDefault();
if (kind === 'down') ev.currentTarget.focus();
if (kind !== 'move') {
const bit = BUTTON_MASK[ev.button] ?? 0;
buttonsRef.current = kind === 'down' ? buttonsRef.current | bit : buttonsRef.current & ~bit;
}
const { x, y } = toFramebuffer(ev);
rfb.pointer(x, y, buttonsRef.current);
};
if (status && !status.installed) {
return (
<Centred>
<Bomb className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Minesweeper is not installed on this server.</p>
{/* The package name sits at the end so the sentence is one key rather than two around an
inline element — see docs/localization.md on the fragmentation that produces. */}
<p className="max-w-sm text-center text-xs text-muted-foreground">
It needs wine, a 32-bit prefix, and this package beside the platform: <code>wine-minesweeper</code>
</p>
</Centred>
);
}
if (status && !status.running) {
return (
<Centred>
<Bomb className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">The Minesweeper session is not running.</p>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<button
type="button"
onClick={start}
disabled={busy}
className="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground disabled:opacity-60"
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
{busy ? 'Starting' : 'Start it'}
</button>
</Centred>
);
}
return (
<div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4">
{/* min-h-0 on the row, so the canvas gives way to the status line rather than overflowing it. */}
<div ref={boxRef} className="flex min-h-0 w-full flex-1 items-center justify-center">
<canvas
ref={canvasRef}
width={PLACEHOLDER.width}
height={PLACEHOLDER.height}
tabIndex={0}
onMouseDown={(ev) => onPointer(ev, 'down')}
onMouseUp={(ev) => onPointer(ev, 'up')}
onMouseMove={(ev) => onPointer(ev, 'move')}
onKeyDown={(ev) => onKey(ev, true)}
onKeyUp={(ev) => onKey(ev, false)}
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.
//
// The max-* pair is only the fallback for the frame before `stage` is known — once it is, the
// explicit size governs and cannot exceed the box, so capping one axis would only squash the
// other.
className={`rounded-sm shadow-lg outline-none ring-primary/50 [image-rendering:pixelated] focus:ring-2 ${stage ? '' : 'max-h-full max-w-full'}`}
style={{ ...(stage ?? {}), cursor: connected ? 'default' : 'progress' }}
/>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{connected ? 'connected' : 'connecting…'}</span>
{/* Focus decides where keys go, and a canvas gives no hint that it can hold any. */}
<span>click the board to send it keys</span>
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
<RefreshCw className="size-3" /> refresh
</button>
</div>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
);
};
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex size-full flex-col items-center justify-center gap-3 p-6">{children}</div>
);