Files
minesweeper/web/MinesweeperPanel.tsx
T
pastilhasandClaude Opus 5 e6cec953e4 a fixed stage for the game, and close the VNC port nobody meant to open
── the stage ──

The framebuffer is now the picture. 800x600 whatever the difficulty, the game
centred in it by the sidecar, and the panel draws it entire — scaled to fit,
centred by its flex parent, never cropped. Changing difficulty moves the board
instead of resizing the panel.

What this replaces parked the window at the origin and had the client crop to
the window's size, polled every four seconds. Two things wrong with it. The
panel resized under the player mid-game; and wine puts the window at 3,29
rather than 0,0, so cropping from the origin showed a black strip on top and
cut a row of cells off the bottom — the "expert gets cut off" report.

Centring runs on a one-second timer because nothing announces a resize: not
RFB, which keeps the framebuffer, and not X without a window manager to listen
through. One chained `xdotool search … getwindowgeometry` per tick, one
process, and a `windowmove` only when the numbers actually changed.

The client loses the geometry poll, the offscreen canvas and the crop with it.

── the port ──

x11vnc was listening on 0.0.0.0:5900 and [::]:5900 with -nopw. Every account
on this machine, every device on the tailnet and everything on the LAN could
open it and take the display — which is precisely the hole the 5907 version
was replaced to close, reopened wider and documented as impossible.

`-unixsock` ADDS a unix socket. It does not remove the TCP one. Three flags do:
-rfbport 0 for IPv4, -rfbportv6 0 for IPv6, -noipv6 for outgoing. `-rfbport 0`
alone leaves [::]:5900 up, which is how this nearly shipped half-fixed.

Found with `ss -lntp` on the running process, and that is the only check that
was right about it at any point. Running the same argv by hand showed no IPv6
listener — most likely because the PM2 session still held [::]:5900 and the
hand-run copy failed to bind it silently. Comments in session.ts and index.ts
that asserted there was no port are corrected rather than deleted.

Verified on the live socket: the streamed framebuffer is 447k teal pixels
around a 152x214 window at 324,193 — dead centre — and window grey arrives as
c0 c0 c0, no TCP listener for the x11vnc pid.

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

233 lines
10 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import type { RfbError } from './rfb';
import { RfbClient } 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: 800, height: 600 };
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
// 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 [status, setStatus] = useState<SessionStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const [busy, setBusy] = useState(false);
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;
}
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;
}
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 };
};
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: 'down' | 'up' | 'move') => {
const rfb = rfbRef.current;
if (!rfb) return;
ev.preventDefault();
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 className="flex min-h-0 w-full flex-1 items-center justify-center">
<canvas
ref={canvasRef}
width={PLACEHOLDER.width}
height={PLACEHOLDER.height}
onMouseDown={(ev) => onPointer(ev, 'down')}
onMouseUp={(ev) => onPointer(ev, 'up')}
onMouseMove={(ev) => onPointer(ev, 'move')}
onContextMenu={(ev) => ev.preventDefault()}
// Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a
// photograph of a screen rather than the screen.
//
// max-w and max-h together, and no explicit width: a canvas is a replaced element, so CSS
// shrinks it to fit while holding its ratio. That is the whole "centred in the panel" — the
// flex parent does the centring and this does the scaling.
className="max-h-full max-w-full rounded-sm shadow-lg [image-rendering:pixelated]"
style={{ cursor: connected ? 'default' : 'progress' }}
/>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{connected ? 'connected' : 'connecting…'}</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>
);