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; 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 }; const BUTTON_MASK: Record = { 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(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')); } catch { setError('Could not reach the server.'); } }, [adopt]); useEffect(() => { void refresh(); }, [refresh]); const start = async () => { setBusy(true); setError(null); try { adopt(await clientRef.current.post('/minesweeper/start', {})); } catch { setError('The session would not start. Is wine-minesweeper installed on the server?'); } finally { setBusy(false); } }; // 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; 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, { onReady: (width, height) => { const offscreen = document.createElement('canvas'); offscreen.width = width; offscreen.height = height; offscreenRef.current = offscreen; setConnected(true); setError(null); }, onFrame: (image, x, y) => { offscreenRef.current?.getContext('2d')?.putImageData(image, x, y); present(); }, 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(); }, onResize: (width, height) => { const offscreen = offscreenRef.current; if (offscreen) { offscreen.width = width; offscreen.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, 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]); const toFramebuffer = (ev: React.MouseEvent) => { 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, 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 (

Minesweeper is not installed on this server.

{/* 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. */}

It needs wine, a 32-bit prefix, and this package beside the platform: wine-minesweeper

); } if (status && !status.running) { return (

The Minesweeper session is not running.

{error ?

{error}

: null}
); } 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' }} />
{connected ? 'connected' : 'connecting…'}
{error ?

{error}

: null}
); }; const Centred = ({ children }: { children: React.ReactNode }) => (
{children}
);