diff --git a/README.md b/README.md index 4bcd7f0..b78c4fa 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,31 @@ The download needs a browser `User-Agent`; the site answers `403` to curl's defa ## Two things worth knowing before you change anything -**x11vnc has no password, and that is correct.** It binds a unix socket inside `run/`, not a port. The -kernel refuses every other account before x11vnc is asked anything, so a password would be a second lock -on a door only one person can reach. The version this replaced bound `127.0.0.1:5907` with `-nopw`, which -is a different claim entirely — every local account could open it and take the display. +**x11vnc has no password, and that is correct — but only because it also has no port.** It binds a unix +socket inside `run/`, mode 0700. The kernel refuses every other account before x11vnc is asked anything, +so a password would be a second lock on a door only one person can reach. The version this replaced bound +`127.0.0.1:5907` with `-nopw`, which is a different claim entirely — every local account could open it and +take the display. + +**`-unixsock` does not remove the TCP listener, and the first alpha shipped believing it did.** It adds a +unix socket beside the port; x11vnc was on `0.0.0.0:5900` and `[::]:5900` with no password, reachable from +the whole tailnet and the whole LAN, under a comment saying there was no port at all. Three flags close it +and each one earns its place: `-rfbport 0` for IPv4, `-rfbportv6 0` for IPv6 — `-rfbport 0` alone leaves +`[::]:5900` up — and `-noipv6` for outgoing sockets. + +The check that matters is `ss -lntp` against the **running** process. Reading the arguments cannot show a +listener that exists because a flag is missing, and running the same arguments by hand did not reproduce +it either. + +**The game is centred on a fixed stage, and the whole stage is what you see.** The framebuffer is 800x600 +whatever the difficulty, `sidecar/session.ts` keeps the window in the middle of it, and the panel draws the +framebuffer entire — scaled to fit, never cropped. So changing difficulty moves the board rather than +resizing the panel, and the surround is teal (`-solid`) rather than the root's black. + +It used to park the window at the origin and have the client crop to the window's size, polled every four +seconds. That resized the panel under the player, and it was off by the 3x29 wine puts between the root +origin and the window — a black strip along the top and a row of cut-off cells along the bottom, worst on +expert. `GET /status` still reports the window geometry; nothing consumes it now, it is a diagnostic. **The wine prefix is confined to `C:` on every start.** A fresh prefix maps the whole filesystem to `Z:` and symlinks the profile into the real home, and Minesweeper X has Open and Save dialogs for its replays. diff --git a/sidecar/index.ts b/sidecar/index.ts index ef47f41..f811fe3 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -29,9 +29,13 @@ import { isRunning, start, status, stop } from './session'; ───────────────────────────────────────────────────────────────────────────────────────────────── WHAT REACHES x11vnc - Nothing but this file. x11vnc binds a socket inside a mode-0700 directory and has no port at all, so the - kernel refuses every other account before any credential could be checked. That is why `-nopw` is right - rather than lazy — see the block comment in `session.ts` where it is spawned. + Nothing but this file. x11vnc binds a socket inside a mode-0700 directory, so the kernel refuses every + other account before any credential could be checked. That is why `-nopw` is right rather than lazy — + see the block comment in `session.ts` where it is spawned. + + This paragraph used to end "and has no port at all", and that was false for as long as it existed: + `-unixsock` ADDS a unix socket, it does not remove the TCP listener, so x11vnc was on 0.0.0.0:5900 with + no password. `-rfbport 0` is what makes the sentence true, and it is in `session.ts` for that reason. This listener is loopback and unauthenticated, exactly like every other sidecar in the fleet; the platform is the authenticator and `X-Officer-User` is how a request proves it came through one. diff --git a/sidecar/session.ts b/sidecar/session.ts index ea470e4..94032a0 100644 --- a/sidecar/session.ts +++ b/sidecar/session.ts @@ -37,9 +37,35 @@ export type SessionStatus = { error?: string; }; +/** + * The stage: a fixed framebuffer, with the game centred in it. + * + * The game window changes size with the difficulty — 152x214 on beginner, 504x342 on expert, larger + * again on a custom board — and the whole framebuffer is what gets streamed, so the panel sees one + * stable rectangle whatever is being played. The alternative, and what this replaced, was parking the + * window at the origin and having the client crop to its size: that made the panel resize under the + * player mid-game, and it was wrong by the 3x29 wine puts between the root origin and the window, which + * showed as a black strip on top and a row of cut-off cells at the bottom. + * + * 800x600 is generous on beginner and that is deliberate: it holds the largest board Minesweeper X can + * make (30x24 custom, 504x470) with room to spare, so no difficulty ever reaches an edge. + */ const SCREEN_W = 800; const SCREEN_H = 600; +/** + * How often to check whether the board changed size. + * + * Nothing announces a resize — not RFB, which keeps the framebuffer, and not X without a window manager + * to listen through. The game only resizes when somebody picks a difficulty, so this is idle almost + * always; one chained `xdotool` per second is the price of the re-centre feeling immediate when it is + * not. + */ +const CENTRE_INTERVAL_MS = 1_000; + +/** The desktop behind the game. Windows 95's own, because that is what this is a picture of. */ +const STAGE_COLOUR = '#008080'; + let xvfb: Proc | null = null; let game: Proc | null = null; let vnc: Proc | null = null; @@ -63,24 +89,95 @@ function pickDisplay(): string | null { return null; } -/** The game's window. wine also creates a 1x1 owner stub, so the larger of the two is the real one. */ -export async function windowGeometry(): Promise { - if (!display) return null; - const search = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY: display }, 5_000); - if (!search.ok) return null; +type FoundWindow = WindowGeometry & { id: string }; - let best: WindowGeometry | null = null; - for (const id of search.stdout.trim().split('\n').filter(Boolean)) { - const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY: display }, 5_000); - if (!geom.ok) continue; - const read = (key: string) => Number(new RegExp(`^${key}=(\\d+)$`, 'm').exec(geom.stdout)?.[1] ?? NaN); - const candidate = { width: read('WIDTH'), height: read('HEIGHT'), x: read('X'), y: read('Y') }; - if (Object.values(candidate).some(Number.isNaN)) continue; - if (!best || candidate.width * candidate.height > best.width * best.height) best = candidate; +/** + * The game's window, with the id needed to move it. + * + * wine creates several 1x1 owner stubs under the same name, so the largest candidate is the real one. + * + * One `xdotool` process, not one per candidate: `search … getwindowgeometry --shell %@` chains the two + * commands and prints a block per window. That matters because this now runs on a timer rather than once + * at startup, and the old shape spawned five processes a tick. + */ +async function findWindow(): Promise { + if (!display) return null; + const found = await run( + ['xdotool', 'search', '--name', 'Minesweeper X', 'getwindowgeometry', '--shell', '%@'], + { DISPLAY: display }, + 5_000, + ); + if (!found.ok) return null; + + // WINDOW= opens each block; the keys after it belong to that window until the next one. + const blocks: Record[] = []; + for (const line of found.stdout.split('\n')) { + const match = /^(\w+)=(.+)$/.exec(line.trim()); + if (!match) continue; + const [, key, value] = match; + if (key === undefined || value === undefined) continue; // the regex guarantees both; the compiler does not + if (key === 'WINDOW') blocks.push({}); + const block = blocks[blocks.length - 1]; + if (block) block[key] = value; + } + + let best: FoundWindow | null = null; + for (const block of blocks) { + const id = block.WINDOW; + const candidate = { + width: Number(block.WIDTH), + height: Number(block.HEIGHT), + x: Number(block.X), + y: Number(block.Y), + }; + if (!id || Object.values(candidate).some(Number.isNaN)) continue; + if (!best || candidate.width * candidate.height > best.width * best.height) best = { id, ...candidate }; } return best && best.width > 1 ? best : null; } +/** The game's window geometry, for `/status`. Nothing in the panel needs it — it is a diagnostic. */ +export async function windowGeometry(): Promise { + const found = await findWindow(); + return found ? { width: found.width, height: found.height, x: found.x, y: found.y } : null; +} + +/** + * Put the game in the middle of the stage. + * + * Clamped at zero so a board wider or taller than the framebuffer loses its right and bottom edges + * rather than its menu bar — a negative origin would push the part you need to click off the top. + */ +async function centre(): Promise { + const win = await findWindow(); + if (!win || !display) return; + const x = Math.max(0, Math.round((SCREEN_W - win.width) / 2)); + const y = Math.max(0, Math.round((SCREEN_H - win.height) / 2)); + if (win.x === x && win.y === y) return; + await run(['xdotool', 'windowmove', win.id, String(x), String(y)], { DISPLAY: display }, 5_000); +} + +let centring: ReturnType | null = null; + +/** + * Re-centre whenever the board changes size, for as long as the session is up. + * + * Self-scheduling rather than `setInterval`: a tick spawns a process and awaits it, and an interval that + * fires faster than that completes would stack ticks on a slow machine. + */ +function watchForResize(): void { + if (centring) return; + const tick = async () => { + if (!isRunning()) { + centring = null; + return; + } + await centre().catch(() => undefined); + centring = setTimeout(tick, CENTRE_INTERVAL_MS); + }; + centring = setTimeout(tick, CENTRE_INTERVAL_MS); +} + /** * Whether the session is up, decided by the socket file plus a live x11vnc. * @@ -173,13 +270,7 @@ async function startInner(log: (line: string) => void): Promise { return status(); } - // Park the window at the framebuffer origin. Everything downstream then crops by using a canvas - // smaller than the framebuffer, with no offset arithmetic and nothing to keep in sync when the board - // resizes with the difficulty. - const found = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY: display! }, 5_000); - for (const id of found.stdout.trim().split('\n').filter(Boolean)) { - await run(['xdotool', 'windowmove', id, '0', '0'], { DISPLAY: display! }, 5_000); - } + await centre(); } // Wine has now booted the prefix, and booting it is what can restore `Z:`. The confinement in @@ -208,7 +299,26 @@ async function startInner(log: (line: string) => void): Promise { vnc = spawn( // -forever: survive a viewer disconnecting. -shared: more than one viewer. -noxdamage: the damage // extension reports nothing useful on Xvfb and costs a poll that misses updates. - ['x11vnc', '-display', display!, '-unixsock', VNC_SOCKET, '-forever', '-shared', '-nopw', '-noxdamage', '-wait', '5', '-defer', '5'], + // + // THE THREE PORT FLAGS ARE WHAT MAKE THE PARAGRAPH ABOVE TRUE, AND ALL THREE ARE LOAD-BEARING. + // + // `-unixsock` ADDS a unix socket; it does not remove the TCP one. So the first version of this + // listened on 0.0.0.0:5900 AND [::]:5900 with -nopw, while its own comment said there was no port + // to authenticate against — anything on the tailnet, anything on the LAN, and every other account + // on the machine could take the display. Found with `ss -lntp` on the running session. Reading the + // flags would never have shown it, because the flag that mattered was the one nobody had written. + // + // `-rfbport 0` alone leaves `[::]:5900` up; `-rfbportv6 0` is what closes it. Verified by `ss` on + // the real process both times, which is the only check that has been right about this so far — + // running the same argv by hand showed no IPv6 listener at all, almost certainly because the + // session under PM2 was still holding [::]:5900 and the hand-run copy silently failed to bind it. + // + // Do not drop one of these because a check looks clean. Check the RUNNING process. + // + // -solid: the stage is bigger than the game, so the surround is now something people look at. Teal + // rather than the root's black, which reads as a broken stream rather than a desktop. x11vnc sets it + // itself on a bare display, so this costs no new host binary. + ['x11vnc', '-display', display!, '-unixsock', VNC_SOCKET, '-rfbport', '0', '-rfbportv6', '0', '-noipv6', '-solid', STAGE_COLOUR, '-forever', '-shared', '-nopw', '-noxdamage', '-wait', '5', '-defer', '5'], { DISPLAY: display! }, 'x11vnc', ); @@ -221,6 +331,8 @@ async function startInner(log: (line: string) => void): Promise { } } + watchForResize(); + log('ready'); return status(); } @@ -246,6 +358,11 @@ async function reap(proc: Proc | null, name: string): Promise { } export async function stop(): Promise { + if (centring) { + clearTimeout(centring); + centring = null; + } + await reap(vnc, 'x11vnc'); vnc = null; rmSync(VNC_SOCKET, { force: true }); diff --git a/web/MinesweeperPanel.tsx b/web/MinesweeperPanel.tsx index 16b533b..4c98e8f 100644 --- a/web/MinesweeperPanel.tsx +++ b/web/MinesweeperPanel.tsx @@ -7,13 +7,20 @@ import { RfbClient } from './rfb'; type SessionStatus = { installed: boolean; running: boolean; - window: { width: number; height: number; x: number; y: number } | null; }; -// The framebuffer is bigger than the board — the board sits at 0,0 and the rest is empty desktop. -// Rendering into an offscreen canvas the size of the framebuffer and showing only the board's corner -// of it is what makes this look like a window rather than a small game adrift in grey. -const VISIBLE = { width: 164, height: 226 }; +// 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 = { 0: 1, 1: 2, 2: 4 }; @@ -48,30 +55,21 @@ export const MinesweeperPanel = () => { const clientRef = useRef(client); clientRef.current = client; const canvasRef = useRef(null); - const offscreenRef = useRef(null); const rfbRef = useRef(null); const buttonsRef = useRef(0); - const geometryRef = useRef(VISIBLE); const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [connected, setConnected] = useState(false); const [busy, setBusy] = useState(false); - const adopt = useCallback((next: SessionStatus) => { - // The board is 152x214 on beginner and wider on the other difficulties. Taking the size from - // status means the canvas is right on the first paint instead of after the first poll. - if (next.window) geometryRef.current = { width: next.window.width, height: next.window.height }; - setStatus(next); - }, []); - const refresh = useCallback(async () => { try { - adopt(await clientRef.current.get('/minesweeper/status')); + setStatus(await clientRef.current.get('/minesweeper/status')); } catch { setError('Could not reach the server.'); } - }, [adopt]); + }, []); useEffect(() => { void refresh(); @@ -81,7 +79,7 @@ export const MinesweeperPanel = () => { setBusy(true); setError(null); try { - adopt(await clientRef.current.post('/minesweeper/start', {})); + setStatus(await clientRef.current.post('/minesweeper/start', {})); } catch { setError('The session would not start. Is wine-minesweeper installed on the server?'); } finally { @@ -89,22 +87,6 @@ export const MinesweeperPanel = () => { } }; - // Paint the visible canvas from the offscreen one. Only the board's rectangle is copied, so the - // surrounding desktop never reaches the screen. - const present = useCallback(() => { - const canvas = canvasRef.current; - const offscreen = offscreenRef.current; - if (!canvas || !offscreen) return; - const context = canvas.getContext('2d'); - if (!context) return; - const { width, height } = geometryRef.current; - if (canvas.width !== width || canvas.height !== height) { - canvas.width = width; - canvas.height = height; - } - context.drawImage(offscreen, 0, 0, width, height, 0, 0, width, height); - }, []); - useEffect(() => { if (!status?.running) return; @@ -117,30 +99,33 @@ export const MinesweeperPanel = () => { 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 offscreen = document.createElement('canvas'); - offscreen.width = width; - offscreen.height = height; - offscreenRef.current = offscreen; + const canvas = canvasRef.current; + if (canvas) { + canvas.width = width; + canvas.height = height; + } setConnected(true); setError(null); }, onFrame: (image, x, y) => { - offscreenRef.current?.getContext('2d')?.putImageData(image, x, y); - present(); + canvasRef.current?.getContext('2d')?.putImageData(image, x, y); }, onCopyRect: (from, to) => { - const offscreen = offscreenRef.current; - const context = offscreen?.getContext('2d'); - if (!offscreen || !context) return; - context.drawImage(offscreen, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h); - present(); + // 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 offscreen = offscreenRef.current; - if (offscreen) { - offscreen.width = width; - offscreen.height = height; + const canvas = canvasRef.current; + if (canvas) { + canvas.width = width; + canvas.height = height; } rfb.requestUpdate(false); }, @@ -154,27 +139,7 @@ export const MinesweeperPanel = () => { rfbRef.current = null; setConnected(false); }; - }, [status?.running, present]); - - // The board resizes when the difficulty changes, and nothing in RFB announces that — the framebuffer - // stays the same size. Polling the window geometry is what keeps the visible canvas matched to it. - useEffect(() => { - if (!connected) return; - const timer = setInterval(async () => { - try { - const next = await clientRef.current.get('/minesweeper/status'); - if (!next.window) return; - const { width, height } = next.window; - if (width !== geometryRef.current.width || height !== geometryRef.current.height) { - geometryRef.current = { width, height }; - present(); - } - } catch { - /* a failed poll is not worth surfacing; the next one will do */ - } - }, 4000); - return () => clearInterval(timer); - }, [connected, present]); + }, [status?.running]); const toFramebuffer = (ev: React.MouseEvent) => { const rect = ev.currentTarget.getBoundingClientRect(); @@ -231,19 +196,26 @@ export const MinesweeperPanel = () => { return (
- 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. - className="rounded-sm shadow-lg [image-rendering:pixelated]" - style={{ cursor: connected ? 'default' : 'progress' }} - /> + {/* min-h-0 on the row, so the canvas gives way to the status line rather than overflowing it. */} +
+ 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' }} + /> +
{connected ? 'connected' : 'connecting…'}