diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index d660712d..64b3e4ef 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -106,6 +106,7 @@ export function App() { } /> {/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */} } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Minesweeper/MinesweeperScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Minesweeper/MinesweeperScreen.tsx new file mode 100644 index 00000000..2bd68eea --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Minesweeper/MinesweeperScreen.tsx @@ -0,0 +1,20 @@ +import type { LayoutNode } from 'officerdev'; +import { WorkspaceView } from 'officerdev'; +import { useDashboardState } from 'state/useDashboardState'; +import { defaultLayout } from './defaultLayout'; + +// /minesweeper — Minesweeper X running under wine on a virtual display, streamed over RFB. +// +// The pixels arrive through /api/minesweeper/ws, which is a byte relay onto x11vnc's own TCP socket on +// loopback — the same shape as the terminal's relay onto the pty sidecar, and the reason this works at +// all where `plugins/desktop` could not: a websocket provider is a platform concern, and a plugin +// cannot claim one. +// +// Owner-only, and not because a game is dangerous: wine runs as the server owner, so the game's menus +// are file dialogs with the owner's home behind them. + +export const MinesweeperScreen = () => { + const workspace = useDashboardState('screens/minesweeper', defaultLayout); + + return ; +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Minesweeper/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Minesweeper/defaultLayout.ts new file mode 100644 index 00000000..cc008d5d --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Minesweeper/defaultLayout.ts @@ -0,0 +1,9 @@ +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. +export const defaultLayout: LayoutNode = { + type: 'panel', + id: 'minesweeper', + appType: 'minesweeper', +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Minesweeper/index.tsx b/src/apps/officer-web/Screens/Dashboard/Minesweeper/index.tsx new file mode 100644 index 00000000..ff9d1009 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Minesweeper/index.tsx @@ -0,0 +1 @@ +export * from './MinesweeperScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 0359e08a..45b80443 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -22,3 +22,4 @@ export * from './Dashboards'; export * from './Terminal'; export * from './Jobs'; export * from './QrTransfer'; +export * from './Minesweeper'; diff --git a/src/server.tsx b/src/server.tsx index 5ef6cfce..b8b813c8 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -16,6 +16,7 @@ import { resolveAuthToken } from './servers/auth-token'; import { isWsProviderAllowed } from './servers/permissions/authorize'; import { isTokenBlacklisted, getUserById } from 'officerdb'; import { terminalWebsocket } from './servers/api/terminal/websocket'; +import { minesweeperWebsocket } from './servers/api/minesweeper/websocket'; import { chatWebsocket } from './servers/api/chat/websocket'; import { chatNotifyWebsocket } from './servers/api/chat/notify-delivery'; import { registerChatPushNotifications } from './servers/api/chat/notify-push'; @@ -159,6 +160,7 @@ const handlers: Record = { 'chat-notify': chatNotifyWebsocket, 'task-runner': taskRunnerWebsocket, pipeline: pipelineWebsocket, + minesweeper: minesweeperWebsocket, sidecar: sidecarWebsocket, }; @@ -185,6 +187,7 @@ const WS_ROUTES: Record = { '/api/tasks/run/ws': 'task-runner', '/api/tasks/pipeline/ws': 'pipeline', '/api/terminal/ws': 'terminal', + '/api/minesweeper/ws': 'minesweeper', '/api/chat/ws': 'chat', '/api/chat/notify/ws': 'chat-notify', }; @@ -363,7 +366,7 @@ console.log( * cannot serve is now a typecheck failure instead of a runtime one. `sidecar` and `dev-proxy` are * deliberately absent — they upgrade through their own handlers, not through here. */ -type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline'; +type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline' | 'minesweeper'; async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) { const token = new URL(req.url).searchParams.get('token'); diff --git a/src/servers/api/minesweeper/router.ts b/src/servers/api/minesweeper/router.ts new file mode 100644 index 00000000..dbea22ba --- /dev/null +++ b/src/servers/api/minesweeper/router.ts @@ -0,0 +1,28 @@ +import { createRouter } from '../../create-router'; +import { sessionStatus, startSession, stopSession } from './session'; + +/** + * Session control. The pixels do not come through here — they come through the websocket relay; this + * is only "is it up, bring it up, take it down", plus the window size the panel needs to size its + * canvas. + */ +export const minesweeperRouter = createRouter(); + +minesweeperRouter.get('/status', async (c) => c.json(await sessionStatus())); + +minesweeperRouter.post('/start', async (c) => { + const status = await sessionStatus(); + if (!status.installed) { + return c.json({ error: 'Minesweeper is not installed on this server (wine-minesweeper is missing)' }, 501); + } + if (status.running) return c.json(status); + + const result = await startSession(); + if (!result.ok) return c.json({ error: `Could not start the session: ${result.out.trim()}` }, 500); + return c.json(await sessionStatus()); +}); + +minesweeperRouter.post('/stop', async (c) => { + await stopSession(); + return c.json(await sessionStatus()); +}); diff --git a/src/servers/api/minesweeper/session.ts b/src/servers/api/minesweeper/session.ts new file mode 100644 index 00000000..d36b94d4 --- /dev/null +++ b/src/servers/api/minesweeper/session.ts @@ -0,0 +1,111 @@ +import { join } from 'node:path'; +import { OFFICER_ROOT } from '../../data-path'; + +/** + * The Minesweeper X session: a virtual display with one Windows game on it, and x11vnc serving that + * display on loopback. + * + * The platform does not implement any of that — it drives `bin/session.sh`, which is the same script + * used by hand. One implementation, so a session started from the browser and one started from a + * terminal are the same session with the same lifecycle. + */ + +export const MINESWEEPER_DIR = join(OFFICER_ROOT, 'wine-minesweeper'); +const SESSION_SCRIPT = join(MINESWEEPER_DIR, 'bin', 'session.sh'); + +export const VNC_HOST = '127.0.0.1'; +export const VNC_PORT = Number(process.env.MINESWEEPER_VNC_PORT ?? 5907); +const DISPLAY = process.env.MINESWEEPER_DISPLAY ?? ':7'; + +export type WindowGeometry = { width: number; height: number; x: number; y: number }; +export type SessionStatus = { + installed: boolean; + running: boolean; + /** Absent until the game has drawn its window. */ + window: WindowGeometry | null; +}; + +const run = async (cmd: string[], env: Record = {}): Promise<{ ok: boolean; out: string }> => { + const proc = Bun.spawn(cmd, { + cwd: MINESWEEPER_DIR, + env: { ...process.env, ...env }, + stdout: 'pipe', + stderr: 'pipe', + }); + const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + const code = await proc.exited; + return { ok: code === 0, out: `${out}${err}` }; +}; + +export const isInstalled = (): boolean => Bun.file(SESSION_SCRIPT).size > 0; + +/** + * Whether x11vnc is listening, decided by reading the kernel's socket table rather than by connecting. + * + * This used to open a TCP connection and drop it. That wedged the VNC server: libvncserver peeks at a + * new connection to decide whether it is a WebSocket handshake, and that peek blocks its main loop + * until the client speaks or the peek times out. A health check that connects and says nothing is + * therefore a small stall, and the panel polls status on a timer — so the stalls overlapped, the accept + * backlog filled (Recv-Q 32/32), and the server stopped accepting the real viewer too. + * + * The rule it cost to learn: a liveness check must not be indistinguishable from a client. Reading + * /proc/net/tcp is free, cannot block, and cannot be mistaken for a viewer. + */ +async function isServing(): Promise { + const wanted = VNC_PORT.toString(16).toUpperCase().padStart(4, '0'); + const LISTEN = '0A'; + + for (const table of ['/proc/net/tcp', '/proc/net/tcp6']) { + const text = await Bun.file(table) + .text() + .catch(() => ''); + + for (const line of text.split('\n').slice(1)) { + const columns = line.trim().split(/\s+/); + const local = columns[1]; + const state = columns[3]; + if (!local || state !== LISTEN) continue; + if (local.split(':')[1] === wanted) return true; + } + } + return false; +} + + +/** + * The game window's size, so the browser can size its canvas to the board rather than to the whole + * framebuffer. `session.sh` parks the window at 0,0, so the offset is expected to be zero and is + * reported anyway — a non-zero one means something moved it and the canvas would be misaligned. + * + * The board changes size when the difficulty changes, which is why this is polled rather than read + * once at connect. + */ +export async function windowGeometry(): Promise { + const search = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY }); + if (!search.ok) return null; + + let best: WindowGeometry | null = null; + for (const id of search.out.trim().split('\n').filter(Boolean)) { + const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY }); + if (!geom.ok) continue; + + const read = (key: string): number => Number(new RegExp(`^${key}=(\\d+)$`, 'm').exec(geom.out)?.[1] ?? NaN); + const candidate = { width: read('WIDTH'), height: read('HEIGHT'), x: read('X'), y: read('Y') }; + if (Object.values(candidate).some(Number.isNaN)) continue; + + // wine creates a 1x1 owner stub alongside the real window; the larger one is the game. + if (!best || candidate.width * candidate.height > best.width * best.height) best = candidate; + } + return best && best.width > 1 ? best : null; +} + +export async function sessionStatus(): Promise { + const installed = isInstalled(); + if (!installed) return { installed, running: false, window: null }; + + const running = await isServing(); + return { installed, running, window: running ? await windowGeometry() : null }; +} + +export const startSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'start']); +export const stopSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'stop']); diff --git a/src/servers/api/minesweeper/websocket.ts b/src/servers/api/minesweeper/websocket.ts new file mode 100644 index 00000000..97705b94 --- /dev/null +++ b/src/servers/api/minesweeper/websocket.ts @@ -0,0 +1,95 @@ +import type { ServerWebSocket, Socket } from 'bun'; +import { VNC_HOST, VNC_PORT } from './session'; + +/** + * A byte relay between the browser and x11vnc, in the same spirit as the terminal's relay onto the pty + * sidecar — this file authenticates the upgrade (upgradeWs, in server.tsx) and moves bytes without + * reading them. + * + * One difference decides the shape: x11vnc serves raw TCP, not a WebSocket. So this is the job + * websockify would otherwise do, done in-process with `Bun.connect` rather than by adding a second + * daemon and a second port to protect. RFB is a stream protocol, so frame boundaries carry no meaning + * and chunks are forwarded exactly as they arrive. + */ + +export type MinesweeperWSData = { provider: 'minesweeper'; search: string }; + +type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean }; + +const relays = new Map, Relay>(); + +const closeClient = (ws: ServerWebSocket, code: number, reason: string) => { + const relay = relays.get(ws); + relays.delete(ws); + if (relay) relay.done = true; + try { + relay?.socket?.end(); + } catch { + /* already gone */ + } + try { + ws.close(code, reason); + } catch { + /* already closed */ + } +}; + +export const minesweeperWebsocket = { + open(ws: ServerWebSocket) { + // Registered synchronously, so bytes arriving before the TCP connection is up are queued rather + // than dropped. The RFB handshake starts with the SERVER talking, but a client that pipelines its + // version string would otherwise lose it. + const relay: Relay = { socket: null, queue: [], done: false }; + relays.set(ws, relay); + + void Bun.connect({ + hostname: VNC_HOST, + port: VNC_PORT, + socket: { + open(socket) { + if (relay.done) { + socket.end(); + return; + } + relay.socket = socket; + for (const chunk of relay.queue) socket.write(chunk); + relay.queue.length = 0; + }, + data(_socket, chunk) { + try { + ws.send(chunk); + } catch { + /* client gone; the close handler tears down the socket */ + } + }, + close: () => closeClient(ws, 1000, 'vnc closed'), + error: () => closeClient(ws, 1011, 'vnc error'), + }, + }).catch(() => { + // No x11vnc listening — the session is not running. 1011 rather than a silent close, so the + // panel can say so instead of showing an empty canvas. + closeClient(ws, 1011, 'minesweeper session is not running'); + }); + }, + + message(ws: ServerWebSocket, raw: string | Buffer) { + const relay = relays.get(ws); + if (!relay) return; + + // RFB is binary in both directions; a text frame is a client that is not speaking it. + const bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : new Uint8Array(raw); + if (relay.socket) relay.socket.write(bytes); + else relay.queue.push(bytes); + }, + + close(ws: ServerWebSocket) { + const relay = relays.get(ws); + relays.delete(ws); + if (relay) relay.done = true; + try { + relay?.socket?.end(); + } catch { + /* ignore */ + } + }, +}; diff --git a/src/servers/hono.ts b/src/servers/hono.ts index ce276d0e..080b3278 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -26,6 +26,7 @@ import { pluginsRouter } from './api/plugins/router'; import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths'; import { agentHandoffRouter } from './api/agent-handoff/router'; import { terminalRouter } from './api/terminal/sidecar-server'; +import { minesweeperRouter } from './api/minesweeper/router'; import { caldavRouter } from './api/dav/sidecar-server'; // import { memosRouter } from './api/memos/router'; import { appStoreRouter } from './api/app-store/router'; @@ -139,6 +140,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType ['/dashboards', dashboardsRouter], ['/file-browser', fileBrowserRouter], ['/terminal', terminalRouter], + ['/minesweeper', minesweeperRouter], // ['/memos', memosRouter], // plugin — switched off 2026-08-13 ['/app-store', appStoreRouter], ['/plugins', pluginsRouter], diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index b1f06476..f8e1280b 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -328,6 +328,25 @@ const CORE_REGISTRY: Permission[] = [ ws: ['task-runner', 'pipeline'], routes: ['/jobs'], }, + // Minesweeper X, on a virtual display, streamed to the browser over RFB. + // + // `execution` rather than `app`, and the reason is not that a game is dangerous. The wine process runs + // as the SERVER OWNER on the owner's display — so its File and Extras menus are file dialogs with the + // owner's home behind them, and "granted Minesweeper" would quietly mean "granted a file picker as the + // owner". `plugins/desktop` reached the same conclusion from the same premise. + // + // There is a second reason to keep it here for now: one display and one game means two viewers share a + // board and fight over the mouse. Per-viewer sessions would be a display and a wine prefix each, which + // is cheap for this but is a design decision rather than an implementation detail. + { + key: 'minesweeper', + label: 'Minesweeper', + description: 'A Windows Minesweeper session on a virtual display, as the server owner', + kind: 'execution', + api: ['/minesweeper'], + ws: ['minesweeper'], + routes: ['/minesweeper'], + }, { key: 'items', label: 'Permission authoring', diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 2c4cb15e..b3ab92d6 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -11,6 +11,7 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory'; import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer'; +import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper'; import { appRegistryMetas as davMetas } from '../apps/Dav'; import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ'; import type { QueryClient } from '@tanstack/react-query'; @@ -28,6 +29,7 @@ export const apps = [ ...widgetMetas, ...monitorMetas, ...qrTransferMetas, + ...minesweeperMetas, ...davMetas, ...commandHqMetas, ...appStoreMetas, diff --git a/src/workspaces/officerdev/src/apps/Minesweeper/MinesweeperPanel.tsx b/src/workspaces/officerdev/src/apps/Minesweeper/MinesweeperPanel.tsx new file mode 100644 index 00000000..195cc69f --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Minesweeper/MinesweeperPanel.tsx @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Bomb, Loader2, Play, RefreshCw } from "lucide-react"; +import { useClient } from "hooks/useClient"; +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 }; + +export const MinesweeperPanel = () => { + const client = useClient(); + 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 client.get("/minesweeper/status")); + } catch { + setError("Could not reach the server."); + } + }, [client, adopt]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const start = async () => { + setBusy(true); + setError(null); + try { + adopt(await client.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") ?? ""; + const url = `${scheme}://${window.location.host}/api/minesweeper/ws?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: (message) => setError(message), + 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 client.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, client, 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.

+

+ It needs wine-minesweeper beside the platform, with wine and a 32-bit prefix. +

+
+ ); + } + + 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}
+); diff --git a/src/workspaces/officerdev/src/apps/Minesweeper/index.ts b/src/workspaces/officerdev/src/apps/Minesweeper/index.ts new file mode 100644 index 00000000..467f104f --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Minesweeper/index.ts @@ -0,0 +1,13 @@ +import type { AppRegistryMeta } from '../../AppRegistry'; +import { Bomb } from 'lucide-react'; +import { MinesweeperPanel } from './MinesweeperPanel'; + +export { MinesweeperPanel } from './MinesweeperPanel'; +export { RfbClient } from './rfb'; + +// Minesweeper X on a virtual display, streamed over RFB. The client is ./rfb.ts — a few hundred lines +// rather than a vendored noVNC, because we control the server and need two encodings out of ten. + +export const appRegistryMetas: AppRegistryMeta[] = [ + { key: 'minesweeper', name: 'Minesweeper', icon: Bomb, component: MinesweeperPanel }, +]; diff --git a/src/workspaces/officerdev/src/apps/Minesweeper/rfb.ts b/src/workspaces/officerdev/src/apps/Minesweeper/rfb.ts new file mode 100644 index 00000000..276c067b --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Minesweeper/rfb.ts @@ -0,0 +1,315 @@ +/** + * A minimal RFB 3.8 client — enough to watch a framebuffer and click on it, and no more. + * + * ── Why this rather than noVNC ── + * + * noVNC is the obvious choice and it is a good library. It is also ~200 KB of vendored JavaScript for a + * job this file does in a few hundred lines, and vendoring is this repository's reluctant exception + * (hls.js, committed unminified with provenance hashes) rather than its habit. What we need is one + * server we control, one pixel format we choose, and two encodings. The rest of RFB — the eight other + * encodings, the compression, the authentication schemes, the clipboard, the audio — is surface we would + * carry and never execute. + * + * So: Raw and CopyRect, one pixel format, security type None. If this ever has to face a VNC server we + * do not configure, that is the moment to reach for noVNC instead of growing this. + * + * ── The pixel format ── + * + * The client DICTATES the format via SetPixelFormat rather than accepting the server's, so decoding is + * a memcpy into ImageData instead of a per-pixel shift-and-mask. We ask for 32bpp true colour with + * big-endian byte order, which puts the bytes on the wire as R,G,B,X — exactly the order a canvas + * ImageData buffer wants, so a Raw rectangle copies straight in. + */ + +export type RfbEvents = { + onReady: (width: number, height: number) => void; + onFrame: (image: ImageData, x: number, y: number) => void; + onCopyRect: (from: { x: number; y: number }, to: { x: number; y: number; w: number; h: number }) => void; + onResize: (width: number, height: number) => void; + onError: (message: string) => void; + onClose: () => void; +}; + +const ENCODING_RAW = 0; +const ENCODING_COPY_RECT = 1; +const PSEUDO_ENCODING_DESKTOP_SIZE = -223; + +type Phase = 'version' | 'security' | 'security-result' | 'init' | 'running'; + +export class RfbClient { + private socket: WebSocket; + private buffer = new Uint8Array(0); + private phase: Phase = 'version'; + private width = 0; + private height = 0; + private events: RfbEvents; + private closed = false; + + constructor(url: string, events: RfbEvents) { + this.events = events; + this.socket = new WebSocket(url); + this.socket.binaryType = 'arraybuffer'; + this.socket.onmessage = (ev) => this.receive(new Uint8Array(ev.data as ArrayBuffer)); + this.socket.onclose = () => { + this.closed = true; + this.events.onClose(); + }; + this.socket.onerror = () => this.events.onError('The connection to the Minesweeper session failed.'); + } + + close(): void { + this.closed = true; + try { + this.socket.close(); + } catch { + /* already closed */ + } + } + + private send(bytes: Uint8Array): void { + if (this.socket.readyState === WebSocket.OPEN) this.socket.send(bytes); + } + + private receive(chunk: Uint8Array): void { + const merged = new Uint8Array(this.buffer.length + chunk.length); + merged.set(this.buffer); + merged.set(chunk, this.buffer.length); + this.buffer = merged; + + // Each step consumes a whole message or nothing at all, so a message split across TCP chunks simply + // waits for the rest. Returning false means "not enough bytes yet". + while (!this.closed && this.step()) { + /* keep draining */ + } + } + + private take(n: number): Uint8Array | null { + if (this.buffer.length < n) return null; + const head = this.buffer.subarray(0, n); + this.buffer = this.buffer.subarray(n); + return head; + } + + private step(): boolean { + switch (this.phase) { + case 'version': + return this.stepVersion(); + case 'security': + return this.stepSecurity(); + case 'security-result': + return this.stepSecurityResult(); + case 'init': + return this.stepInit(); + case 'running': + return this.stepMessage(); + } + } + + private stepVersion(): boolean { + const head = this.take(12); + if (!head) return false; + // Answer 3.8 regardless of what the server offered; x11vnc speaks it and anything older is a server + // we are not configuring. + this.send(new TextEncoder().encode('RFB 003.008\n')); + this.phase = 'security'; + return true; + } + + private stepSecurity(): boolean { + if (this.buffer.length < 1) return false; + const count = this.buffer[0] as number; + if (count === 0) { + // A zero count means the server is refusing us, and the reason follows as a string. + const body = this.take(1 + 4); + if (!body) return false; + this.events.onError('The VNC server refused the connection.'); + this.close(); + return false; + } + const head = this.take(1 + count); + if (!head) return false; + + const types = Array.from(head.subarray(1)); + if (!types.includes(1)) { + this.events.onError('The VNC server wants authentication, which this client does not implement.'); + this.close(); + return false; + } + this.send(new Uint8Array([1])); // None — the relay is loopback-only and Officer is the authenticator + this.phase = 'security-result'; + return true; + } + + private stepSecurityResult(): boolean { + const head = this.take(4); + if (!head) return false; + if (new DataView(head.buffer, head.byteOffset, 4).getUint32(0) !== 0) { + this.events.onError('The VNC server rejected the handshake.'); + this.close(); + return false; + } + this.send(new Uint8Array([1])); // shared: do not disconnect other viewers + this.phase = 'init'; + return true; + } + + private stepInit(): boolean { + if (this.buffer.length < 24) return false; + const nameLength = new DataView(this.buffer.buffer, this.buffer.byteOffset + 20, 4).getUint32(0); + const head = this.take(24 + nameLength); + if (!head) return false; + + const view = new DataView(head.buffer, head.byteOffset, head.byteLength); + this.width = view.getUint16(0); + this.height = view.getUint16(2); + + this.setPixelFormat(); + this.setEncodings(); + this.phase = 'running'; + this.events.onReady(this.width, this.height); + this.requestUpdate(false); + return true; + } + + /** 32bpp true colour, big-endian, shifts 16/8/0 — i.e. R,G,B,X on the wire, which is ImageData order. */ + private setPixelFormat(): void { + const message = new Uint8Array(20); + const view = new DataView(message.buffer); + message[0] = 0; // SetPixelFormat + message[4] = 32; // bits per pixel + message[5] = 24; // depth + message[6] = 1; // big-endian + message[7] = 1; // true colour + view.setUint16(8, 255); // red max + view.setUint16(10, 255); // green max + view.setUint16(12, 255); // blue max + message[14] = 16; // red shift + message[15] = 8; // green shift + message[16] = 0; // blue shift + this.send(message); + } + + private setEncodings(): void { + const encodings = [ENCODING_COPY_RECT, ENCODING_RAW, PSEUDO_ENCODING_DESKTOP_SIZE]; + const message = new Uint8Array(4 + encodings.length * 4); + const view = new DataView(message.buffer); + message[0] = 2; // SetEncodings + view.setUint16(2, encodings.length); + encodings.forEach((encoding, i) => view.setInt32(4 + i * 4, encoding)); + this.send(message); + } + + requestUpdate(incremental = true): void { + const message = new Uint8Array(10); + const view = new DataView(message.buffer); + message[0] = 3; // FramebufferUpdateRequest + message[1] = incremental ? 1 : 0; + view.setUint16(6, this.width); + view.setUint16(8, this.height); + this.send(message); + } + + private stepMessage(): boolean { + if (this.buffer.length < 1) return false; + const type = this.buffer[0] as number; + + // 0 FramebufferUpdate, 1 SetColourMapEntries, 2 Bell, 3 ServerCutText. Only the first matters here; + // the others still have to be CONSUMED or the stream desynchronises and every later frame is garbage. + if (type === 0) return this.stepFramebufferUpdate(); + if (type === 2) return this.take(1) !== null; + if (type === 1) { + if (this.buffer.length < 6) return false; + const count = new DataView(this.buffer.buffer, this.buffer.byteOffset + 4, 2).getUint16(0); + return this.take(6 + count * 6) !== null; + } + if (type === 3) { + if (this.buffer.length < 8) return false; + const length = new DataView(this.buffer.buffer, this.buffer.byteOffset + 4, 4).getUint32(0); + return this.take(8 + length) !== null; + } + + this.events.onError(`The VNC server sent an unknown message type (${type}).`); + this.close(); + return false; + } + + /** + * Parsed without consuming anything until the WHOLE update is present. A partial rectangle cannot be + * drawn and cannot be put back, so the alternative is a second parser for resumed state. + */ + private stepFramebufferUpdate(): boolean { + if (this.buffer.length < 4) return false; + const rectangles = new DataView(this.buffer.buffer, this.buffer.byteOffset + 2, 2).getUint16(0); + + let offset = 4; + type Rect = { x: number; y: number; w: number; h: number; encoding: number; at: number }; + const rects: Rect[] = []; + + for (let i = 0; i < rectangles; i++) { + if (this.buffer.length < offset + 12) return false; + const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 12); + const rect = { x: view.getUint16(0), y: view.getUint16(2), w: view.getUint16(4), h: view.getUint16(6), encoding: view.getInt32(8), at: offset + 12 }; + offset += 12; + + if (rect.encoding === ENCODING_RAW) offset += rect.w * rect.h * 4; + else if (rect.encoding === ENCODING_COPY_RECT) offset += 4; + else if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) { + /* no payload — the rect's w/h ARE the new size */ + } else { + this.events.onError(`The VNC server used encoding ${rect.encoding}, which this client did not ask for.`); + this.close(); + return false; + } + + if (this.buffer.length < offset) return false; + rects.push(rect); + } + + for (const rect of rects) { + if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) { + this.width = rect.w; + this.height = rect.h; + this.events.onResize(rect.w, rect.h); + continue; + } + if (rect.encoding === ENCODING_COPY_RECT) { + const src = new DataView(this.buffer.buffer, this.buffer.byteOffset + rect.at, 4); + this.events.onCopyRect({ x: src.getUint16(0), y: src.getUint16(2) }, rect); + continue; + } + // Raw: the wire bytes are already R,G,B,X, so this is a copy rather than a conversion. The alpha + // byte is whatever the server sent, so force it opaque. + const pixels = new Uint8ClampedArray(this.buffer.subarray(rect.at, rect.at + rect.w * rect.h * 4)); + for (let i = 3; i < pixels.length; i += 4) pixels[i] = 255; + this.events.onFrame(new ImageData(pixels, rect.w, rect.h), rect.x, rect.y); + } + + this.take(offset); + + // One request per update message, issued once the whole message is drawn. Requesting per + // rectangle would send several for one update, and requesting on a timer would queue updates the + // client has not consumed yet — the classic way a VNC view ends up lagging behind the pointer. + this.requestUpdate(true); + return true; + } + + /** buttons is an RFB button mask: bit 0 left, bit 1 middle, bit 2 right. */ + pointer(x: number, y: number, buttons: number): void { + const message = new Uint8Array(6); + const view = new DataView(message.buffer); + message[0] = 5; // PointerEvent + message[1] = buttons; + view.setUint16(2, Math.max(0, Math.min(this.width - 1, Math.round(x)))); + view.setUint16(4, Math.max(0, Math.min(this.height - 1, Math.round(y)))); + this.send(message); + } + + key(keysym: number, down: boolean): void { + const message = new Uint8Array(8); + const view = new DataView(message.buffer); + message[0] = 4; // KeyEvent + message[1] = down ? 1 : 0; + view.setUint32(4, keysym); + this.send(message); + } +}