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
This commit is contained in:
@@ -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.
|
||||
|
||||
+7
-3
@@ -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.
|
||||
|
||||
+138
-21
@@ -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<WindowGeometry | null> {
|
||||
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<FoundWindow | null> {
|
||||
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<string, string>[] = [];
|
||||
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<WindowGeometry | null> {
|
||||
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<void> {
|
||||
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<typeof setTimeout> | 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<SessionStatus> {
|
||||
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<SessionStatus> {
|
||||
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<SessionStatus> {
|
||||
}
|
||||
}
|
||||
|
||||
watchForResize();
|
||||
|
||||
log('ready');
|
||||
return status();
|
||||
}
|
||||
@@ -246,6 +358,11 @@ async function reap(proc: Proc | null, name: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function stop(): Promise<SessionStatus> {
|
||||
if (centring) {
|
||||
clearTimeout(centring);
|
||||
centring = null;
|
||||
}
|
||||
|
||||
await reap(vnc, 'x11vnc');
|
||||
vnc = null;
|
||||
rmSync(VNC_SOCKET, { force: true });
|
||||
|
||||
+54
-82
@@ -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<number, number> = { 0: 1, 1: 2, 2: 4 };
|
||||
|
||||
@@ -48,30 +55,21 @@ export const MinesweeperPanel = () => {
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const offscreenRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const rfbRef = useRef<RfbClient | null>(null);
|
||||
const buttonsRef = useRef(0);
|
||||
const geometryRef = useRef(VISIBLE);
|
||||
|
||||
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 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<SessionStatus>('/minesweeper/status'));
|
||||
setStatus(await clientRef.current.get<SessionStatus>('/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<SessionStatus>('/minesweeper/start', {}));
|
||||
setStatus(await clientRef.current.post<SessionStatus>('/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<SessionStatus>('/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<HTMLCanvasElement>) => {
|
||||
const rect = ev.currentTarget.getBoundingClientRect();
|
||||
@@ -231,19 +196,26 @@ export const MinesweeperPanel = () => {
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={VISIBLE.width}
|
||||
height={VISIBLE.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.
|
||||
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. */}
|
||||
<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">
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// One panel. The board is 152x214 and there is nothing to put beside it — a split here would be a
|
||||
// second panel of empty space.
|
||||
// One panel. The game is one fixed-size stage and there is nothing to put beside it — a split here
|
||||
// would be a second panel of empty space.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'minesweeper',
|
||||
|
||||
Reference in New Issue
Block a user