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.
+