Minesweeper X under wine on a headless display, as a plugin. It was /api/minesweeper, a Dashboard screen, an officerdev panel, a permission registry entry and a hand-written websocket provider in server.tsx. The socket is why it had to live in the platform: Bun's route table is fixed at serve(), so nothing installed later could add one. That ceiling is gone, and this is the first plugin through the per-plugin relay. Three things changed rather than moved: The sidecar owns the session. The platform used to spawn bin/session.sh detached, so the game outlived every restart, nothing supervised it, and a crashed run left wine processes holding the prefix — the next start hung rather than failed. PM2 supervises a sidecar, and stop() uses 'wineserver -k' scoped by WINEPREFIX instead of 'pkill -f wineserver', which would have killed every other wine prefix on the machine. x11vnc moved from 127.0.0.1:5907 to a unix socket in a mode-0700 directory. -nopw was only ever safe because of the machine, not the platform: any local account could open that port and take the display. A fixed port is squattable too. There is now no listener to authenticate against. The wine prefix is confined to C:. This is the one that mattered. The platform classified Minesweeper as kind:'execution' — never grantable — because a fresh prefix maps the whole filesystem to Z: and symlinks the profile into the owner's real home, and the game has Open and Save dialogs. A plugin permission has no kind and is always grantable, so extracting it without this would have converted a sealed capability into a file picker running as the owner. Re-asserted on every start, because wine restores z: whenever it updates a prefix. Not solved: one display and one game, so two grantees share a board. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
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<number, number> = { 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<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'));
|
|
} 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<SessionStatus>('/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<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]);
|
|
|
|
const toFramebuffer = (ev: React.MouseEvent<HTMLCanvasElement>) => {
|
|
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<HTMLCanvasElement>, 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 (
|
|
<Centred>
|
|
<Bomb className="size-8 text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground">Minesweeper is not installed on this server.</p>
|
|
{/* 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. */}
|
|
<p className="max-w-sm text-center text-xs text-muted-foreground">
|
|
It needs wine, a 32-bit prefix, and this package beside the platform: <code>wine-minesweeper</code>
|
|
</p>
|
|
</Centred>
|
|
);
|
|
}
|
|
|
|
if (status && !status.running) {
|
|
return (
|
|
<Centred>
|
|
<Bomb className="size-8 text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground">The Minesweeper session is not running.</p>
|
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
|
<button
|
|
type="button"
|
|
onClick={start}
|
|
disabled={busy}
|
|
className="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground disabled:opacity-60"
|
|
>
|
|
{busy ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
|
{busy ? 'Starting' : 'Start it'}
|
|
</button>
|
|
</Centred>
|
|
);
|
|
}
|
|
|
|
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' }}
|
|
/>
|
|
<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">
|
|
<RefreshCw className="size-3" /> refresh
|
|
</button>
|
|
</div>
|
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Centred = ({ children }: { children: React.ReactNode }) => (
|
|
<div className="flex size-full flex-col items-center justify-center gap-3 p-6">{children}</div>
|
|
);
|