diff --git a/CLAUDE.md b/CLAUDE.md
index db1b5b50..13878549 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -207,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
- never grantable at any level). **22 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 4 execution, 5 admin. It said 27 for nine days.
+ never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 3 execution, 5 admin. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx
index ef5ec3a4..65c83f7c 100644
--- a/src/apps/officer-web/App.tsx
+++ b/src/apps/officer-web/App.tsx
@@ -106,7 +106,6 @@ 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
deleted file mode 100644
index 2bd68eea..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Minesweeper/MinesweeperScreen.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-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
deleted file mode 100644
index cc008d5d..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Minesweeper/defaultLayout.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-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
deleted file mode 100644
index ff9d1009..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Minesweeper/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export * from './MinesweeperScreen';
diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx
index 1104195f..a97f2bfe 100644
--- a/src/apps/officer-web/Screens/Dashboard/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/index.tsx
@@ -22,5 +22,4 @@ export * from './Dashboards';
export * from './Terminal';
export * from './Jobs';
export * from './QrTransfer';
-export * from './Minesweeper';
export * from './SendOff';
diff --git a/src/server.tsx b/src/server.tsx
index 0c8cbcff..62ac2844 100644
--- a/src/server.tsx
+++ b/src/server.tsx
@@ -16,7 +16,6 @@ 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';
@@ -162,7 +161,6 @@ const handlers: Record = {
'chat-notify': chatNotifyWebsocket,
'task-runner': taskRunnerWebsocket,
pipeline: pipelineWebsocket,
- minesweeper: minesweeperWebsocket,
sidecar: sidecarWebsocket,
};
@@ -189,7 +187,6 @@ 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',
};
@@ -368,7 +365,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' | 'minesweeper';
+type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline';
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
deleted file mode 100644
index dbea22ba..00000000
--- a/src/servers/api/minesweeper/router.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-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
deleted file mode 100644
index d36b94d4..00000000
--- a/src/servers/api/minesweeper/session.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-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
deleted file mode 100644
index 97705b94..00000000
--- a/src/servers/api/minesweeper/websocket.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-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/api/plugin-ws/target.ts b/src/servers/api/plugin-ws/target.ts
index bdba0036..29eb5980 100644
--- a/src/servers/api/plugin-ws/target.ts
+++ b/src/servers/api/plugin-ws/target.ts
@@ -4,10 +4,9 @@ import { snapshotPlugins } from '@@/plugins/mount';
/*
Where a plugin's socket actually goes.
- A target is a TCP address and never a URL, and that is what keeps the relay ignorant of the protocol on
- the other side. The ignorance is the point: it is the only reason one relay can serve an RFB display and a
- line-oriented sidecar without knowing which is which. The moment this returned a scheme or a path, the
- relay would have to care what it was carrying.
+ A target is an ADDRESS — a host and a port — never a URL a manifest wrote. The relay turns it into
+ `ws://:/ws`, and `/ws` is a convention rather than a setting for the same reason the host is
+ not configurable: every value here has to be one the platform derived, not one a plugin asserted.
The target is always a SIDECAR. A fixed-port form was written and removed the same day: nothing stops a
manifest naming a port it does not own, so `{ port: 5432 }` plus a grant would be a raw TCP relay onto
@@ -15,7 +14,10 @@ import { snapshotPlugins } from '@@/plugins/mount';
to the plugin, and only the second question matters.
A sidecar's port is different in kind, not degree: the platform watched it arrive at registration, so the
- association is observed rather than asserted.
+ association is observed rather than asserted. It is also the SAME port the sidecar serves its HTTP on —
+ one announcement, one listener, `/ws` alongside the routes. That is what the relay's move to
+ websocket-to-websocket bought, and why a plugin wanting a socket no longer has to choose between having
+ one and having ordinary routes.
**Host is always loopback and is not configurable.** A plugin socket that could name an arbitrary host
would be a request-forgery primitive handed to every plugin author and reachable by anyone the plugin is
diff --git a/src/servers/api/plugin-ws/websocket.ts b/src/servers/api/plugin-ws/websocket.ts
index b0d142a5..a428b713 100644
--- a/src/servers/api/plugin-ws/websocket.ts
+++ b/src/servers/api/plugin-ws/websocket.ts
@@ -1,4 +1,4 @@
-import type { ServerWebSocket, Socket } from 'bun';
+import type { ServerWebSocket } from 'bun';
import { resolveAuthToken } from '@@/auth-token';
import { isTokenBlacklisted } from 'officerdb';
import { isApiRequestAllowed } from '@@/permissions/authorize';
@@ -19,26 +19,37 @@ import { pluginWsTargetFor } from './target';
dynamic — one wildcard declared at boot can serve every plugin that will ever be installed, because the
plugin name is a path segment rather than a route.
- ── What it does and does not read ──
+ ── The upstream is a WEBSOCKET, not a raw TCP socket ──
- Bytes, both ways, without interpreting them. Same as the terminal's relay onto the pty sidecar and the
- minesweeper relay onto x11vnc: frame boundaries carry no meaning to a stream protocol, so chunks are
- forwarded exactly as they arrive.
+ This relayed onto a raw TCP address for about a day. It was changed while extracting Minesweeper, and the
+ reason is worth keeping because it is not a preference.
- A plugin's target is a TCP address, never a URL. That is deliberate — it keeps this file ignorant of
- whatever protocol is on the other side, which is the only reason one relay can serve an RFB display and a
- line-oriented sidecar without knowing which is which.
+ **A sidecar announces exactly one port.** That is the whole provenance argument for `target.ts` — the
+ platform watched that port arrive at registration, which is the only thing separating it from a number a
+ manifest asserted. But if this relay needs raw TCP, that one port has to BE a raw TCP listener, so the
+ sidecar cannot also serve HTTP on it, and the plugin's ordinary routes have to fall back to the typed
+ command vocabulary over the registration socket — the mechanism the fleet has spent months retiring.
+
+ Relaying websocket-to-websocket dissolves that. The sidecar serves HTTP and `/ws` on the single port it
+ announced, exactly as the pty sidecar already does; `createSidecarProxy` handles the HTTP half; and the
+ conversion from frames to whatever the plugin actually speaks happens INSIDE the plugin, which is the only
+ process that knows what that is. Minesweeper's x11vnc is the worked example: the sidecar owns the RFB
+ stream and the platform never learns that RFB exists.
+
+ This file is still ignorant of the payload — it moves frames without reading them, and a stream protocol's
+ boundaries carry no meaning either way. The ignorance simply sits one layer up.
*/
export type PluginWSData = {
provider: 'plugin-ws';
plugin: string;
- host: string;
- port: number;
+ /** `ws://127.0.0.1:/ws`, with the client's query minus its credential. */
+ upstream: string;
userId: number;
};
-type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
+type Frame = string | Uint8Array;
+type Relay = { ws: WebSocket | null; queue: Frame[]; done: boolean };
const relays = new Map, Relay>();
const closeClient = (ws: ServerWebSocket, code: number, reason: string) => {
@@ -46,7 +57,7 @@ const closeClient = (ws: ServerWebSocket, code: number, reason: st
relays.delete(ws);
if (relay) relay.done = true;
try {
- relay?.socket?.end();
+ relay?.ws?.close();
} catch {
/* already gone */
}
@@ -58,55 +69,63 @@ const closeClient = (ws: ServerWebSocket, code: number, reason: st
};
export const pluginWsWebsocket = {
- async open(ws: ServerWebSocket) {
+ open(ws: ServerWebSocket) {
/*
The queue is not an optimisation. Bun requires `server.upgrade` to be synchronous, so the client is
- already connected and may already be talking by the time `Bun.connect` resolves — and an RFB client
- sends its protocol version immediately. Dropping those bytes desynchronises the stream in a way that
- looks like a corrupt display rather than a lost packet. Learned by dev-proxy first.
+ already connected and may already be talking before the upstream is open — and an RFB client sends its
+ protocol version immediately. Dropping those frames desynchronises the stream in a way that looks like
+ a corrupt display rather than a lost packet. Learned by dev-proxy first.
*/
- const relay: Relay = { socket: null, queue: [], done: false };
+ const relay: Relay = { ws: null, queue: [], done: false };
relays.set(ws, relay);
+ let upstream: WebSocket;
try {
- const socket = await Bun.connect({
- hostname: ws.data.host,
- port: ws.data.port,
- socket: {
- data: (_s, chunk) => {
- try {
- ws.send(chunk);
- } catch {
- /* client gone; the close handler tidies up */
- }
- },
- close: () => closeClient(ws, 1011, 'upstream closed'),
- error: () => closeClient(ws, 1011, 'upstream error'),
- },
- });
+ // The sidecar binds loopback and is told who is calling, the same signal every HTTP sidecar trusts:
+ // a request without this header did not come through the platform.
+ upstream = new WebSocket(ws.data.upstream, { headers: { 'X-Officer-User': String(ws.data.userId) } } as never);
+ } catch {
+ closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
+ return;
+ }
+ relay.ws = upstream;
+ upstream.binaryType = 'arraybuffer';
- // Between the await above and here the client may have gone. Writing to a socket nobody is reading
- // would keep the upstream connection open for as long as the plugin's process lives.
+ upstream.onopen = () => {
+ // Between the upgrade and here the client may have gone. Sending into a socket nobody reads would hold
+ // the upstream connection open for as long as the plugin's process lives.
if (relay.done) {
- socket.end();
+ try {
+ upstream.close();
+ } catch {
+ /* already gone */
+ }
return;
}
- relay.socket = socket;
- for (const chunk of relay.queue) socket.write(chunk);
+ for (const frame of relay.queue) upstream.send(frame);
relay.queue.length = 0;
- } catch {
- // A plugin whose process is not listening is the ordinary case, not an exception: a sidecar that has
- // not booted yet, or a plugin whose service is stopped. The client is told, and told which plugin.
- closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
- }
+ };
+
+ upstream.onmessage = (ev) => {
+ try {
+ ws.send(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data as ArrayBuffer));
+ } catch {
+ /* client gone; the close handler tidies up */
+ }
+ };
+
+ // A plugin whose process is not listening is the ordinary case rather than an exception: a sidecar that
+ // has not booted, or a service the owner stopped. The client is told, and told which plugin.
+ upstream.onerror = () => closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
+ upstream.onclose = () => closeClient(ws, 1011, 'upstream closed');
},
- message(ws: ServerWebSocket, message: string | Uint8Array) {
+ message(ws: ServerWebSocket, message: string | Buffer) {
const relay = relays.get(ws);
if (!relay) return;
- const bytes = typeof message === 'string' ? new TextEncoder().encode(message) : message;
- if (relay.socket) relay.socket.write(bytes);
- else relay.queue.push(bytes);
+ const frame: Frame = typeof message === 'string' ? message : (message as unknown as Uint8Array);
+ if (relay.ws && relay.ws.readyState === WebSocket.OPEN) relay.ws.send(frame);
+ else relay.queue.push(frame);
},
close(ws: ServerWebSocket) {
@@ -114,7 +133,7 @@ export const pluginWsWebsocket = {
relays.delete(ws);
if (relay) relay.done = true;
try {
- relay?.socket?.end();
+ relay?.ws?.close();
} catch {
/* already gone */
}
@@ -165,12 +184,18 @@ export async function upgradePluginWs(req: Request, server: any): Promise
['/dashboards', dashboardsRouter],
['/file-browser', fileBrowserRouter],
['/terminal', terminalRouter],
- ['/minesweeper', minesweeperRouter],
['/sendoff', sendoffRouter],
['/sub-agents', subAgentsRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
diff --git a/src/servers/permissions/registry.test.ts b/src/servers/permissions/registry.test.ts
index 356af321..94bc94f6 100644
--- a/src/servers/permissions/registry.test.ts
+++ b/src/servers/permissions/registry.test.ts
@@ -23,7 +23,7 @@ import { assertPermissionTotality, isExemptApiPath } from './totality';
// switched off 2026-08-13. It was listed here until 2026-08-18 and made this file's own totality tests
// fail for five days — the mirror claiming a provider the server does not serve, which is precisely the
// drift the comment above warns about, caught by the check and then ignored because it was "the baseline".
-const REAL_WS = ['terminal', 'chat', 'chat-notify', 'task-runner', 'pipeline', 'sidecar', 'minesweeper'];
+const REAL_WS = ['terminal', 'chat', 'chat-notify', 'task-runner', 'pipeline', 'sidecar'];
const realApi = () => [...new Set(PERMISSIONS.flatMap((c) => c.api))];
const surface = () => ({
apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/sidecar'],
diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts
index 239f43fd..a58684e9 100644
--- a/src/servers/permissions/registry.ts
+++ b/src/servers/permissions/registry.ts
@@ -328,25 +328,6 @@ 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'],
- },
/*
SendOff is `app`, not `confined`, and the distinction is the point.
diff --git a/src/servers/plugins/manifest.ts b/src/servers/plugins/manifest.ts
index e8a10d6c..bec32d5e 100644
--- a/src/servers/plugins/manifest.ts
+++ b/src/servers/plugins/manifest.ts
@@ -285,10 +285,10 @@ export type PluginManifest = {
/**
* A websocket this plugin serves, relayed by the platform.
*
- * Declaring this makes `/api/plugin-ws/` a byte relay onto the address below. It exists because
- * Bun's route table is fixed at `serve()`, so a plugin installed afterwards could never add a socket route
- * — which is why `/minesweeper` had to live in the platform and `plugins/desktop` could not stream a
- * display at all.
+ * Declaring this makes `/api/plugin-ws/` a frame relay onto `ws://127.0.0.1:/ws`, where the
+ * port is the one the named sidecar announced at registration. It exists because Bun's route table is
+ * fixed at `serve()`, so a plugin installed afterwards could never add a socket route — which is why
+ * `/minesweeper` had to live in the platform and `plugins/desktop` could not stream a display at all.
*
* Authorisation is the plugin's OWN capability, asked as if the socket were a GET against its HTTP prefix.
* There is deliberately no separate socket permission: granting the plugin grants its socket, and nothing
@@ -308,7 +308,13 @@ export type PluginManifest = {
* manifest asserted. Provenance, not a claim.
*
* The cost is that a plugin wanting a socket needs a sidecar. That is not ceremony — it is the thing
- * that makes the port mean anything.
+ * that makes the port mean anything. It costs nothing else: the socket lives at `/ws` on the SAME
+ * listener the sidecar already serves its routes from, so a plugin does not choose between having a
+ * socket and having ordinary HTTP.
+ *
+ * **The sidecar serves a websocket, not a raw stream.** Whatever it is really talking to — a VNC display,
+ * a line protocol, another process entirely — the conversion happens inside the plugin, because the
+ * plugin is the only thing that knows. The platform moves frames it does not read.
*
* The host is always loopback and cannot be set. A plugin naming an arbitrary host would be a
* request-forgery primitive reachable by anyone the plugin is granted to.
diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
index 24829c8e..1e3388d4 100644
--- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
+++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
@@ -11,7 +11,6 @@ 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 sendOffMetas } from '../apps/SendOff';
import { appRegistryMetas as davMetas } from '../apps/Dav';
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
@@ -30,7 +29,6 @@ export const apps = [
...widgetMetas,
...monitorMetas,
...qrTransferMetas,
- ...minesweeperMetas,
...sendOffMetas,
...davMetas,
...commandHqMetas,
diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.tsx
index f22c06d7..18db3479 100644
--- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.tsx
+++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.tsx
@@ -73,7 +73,7 @@ const COPY = {
/**
* A validation failure, as a catalog key rather than English — the caller (a pure function with no
- * hook in scope) cannot call `t()` itself. Mirrors `RfbError` in the Minesweeper panel: an explicit
+ * hook in scope) cannot call `t()` itself. An explicit
* closed union, never a key built at runtime from the failure kind.
*/
export type NameProblem =
diff --git a/src/workspaces/officerdev/src/apps/Minesweeper/MinesweeperPanel.tsx b/src/workspaces/officerdev/src/apps/Minesweeper/MinesweeperPanel.tsx
deleted file mode 100644
index a0b9c46c..00000000
--- a/src/workspaces/officerdev/src/apps/Minesweeper/MinesweeperPanel.tsx
+++ /dev/null
@@ -1,259 +0,0 @@
-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';
-import { useTranslation } from '../../locales';
-
-type PanelT = ReturnType['t'];
-
-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 };
-
-/** Explicit map from RfbError to literal keys — never a key assembled from `error.code`. */
-const describeRfbError = (error: RfbError, t: PanelT): string => {
- switch (error.code) {
- case 'connectionFailed':
- return t('minesweeper.error.connectionFailed');
- case 'refused':
- return t('minesweeper.error.refused');
- case 'authUnsupported':
- return t('minesweeper.error.authUnsupported');
- case 'handshakeRejected':
- return t('minesweeper.error.handshakeRejected');
- case 'unknownMessage':
- return t('minesweeper.error.unknownMessage', { type: error.type });
- case 'unknownEncoding':
- return t('minesweeper.error.unknownEncoding', { encoding: error.encoding });
- }
-};
-
-export const MinesweeperPanel = () => {
- const { t } = useTranslation();
- // 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') ?? '';
- 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: (rfbError) => setError(describeRfbError(rfbError, t)),
- 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 (
-
-
-
{t('minesweeper.notInstalled')}
- {/* 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. */}
-
-);
diff --git a/src/workspaces/officerdev/src/apps/Minesweeper/index.ts b/src/workspaces/officerdev/src/apps/Minesweeper/index.ts
deleted file mode 100644
index 92c92559..00000000
--- a/src/workspaces/officerdev/src/apps/Minesweeper/index.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-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[] = [
- // i18n-exempt: product name
- { 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
deleted file mode 100644
index 96ef93de..00000000
--- a/src/workspaces/officerdev/src/apps/Minesweeper/rfb.ts
+++ /dev/null
@@ -1,341 +0,0 @@
-/**
- * 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: (error: RfbError) => void;
- onClose: () => void;
-};
-
-/**
- * What went wrong, as a code rather than a sentence.
- *
- * A protocol client has no business holding user-facing prose: it cannot reach a React hook, and the
- * words belong to whoever is rendering them. The panel maps each code to a catalog key — an explicit
- * map from a closed union to literal keys, which is what the naming convention asks for instead of
- * building a key at runtime.
- */
-export type RfbError =
- | { code: 'connectionFailed' }
- | { code: 'refused' }
- | { code: 'authUnsupported' }
- | { code: 'handshakeRejected' }
- | { code: 'unknownMessage'; type: number }
- | { code: 'unknownEncoding'; encoding: number };
-
-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({ code: 'connectionFailed' });
- }
-
- close(): void {
- this.closed = true;
- try {
- this.socket.close();
- } catch {
- /* already closed */
- }
- }
-
- // Uint8Array, not plain Uint8Array: the default is Uint8Array, whose
- // buffer may be a SharedArrayBuffer, and WebSocket.send takes only BufferSource. Every caller here
- // builds its own array, so narrowing costs nothing. The terminal relay narrows the same way.
- 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({ code: 'refused' });
- 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({ code: 'authUnsupported' });
- 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({ code: 'handshakeRejected' });
- 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({ code: 'unknownMessage', 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({ code: 'unknownEncoding', encoding: rect.encoding });
- 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);
- }
-}
diff --git a/src/workspaces/officerdev/src/locales/de-DE.json b/src/workspaces/officerdev/src/locales/de-DE.json
index d4f0e11c..cc4ee736 100644
--- a/src/workspaces/officerdev/src/locales/de-DE.json
+++ b/src/workspaces/officerdev/src/locales/de-DE.json
@@ -3,19 +3,6 @@
"save": "Speichern",
"delete": "Löschen"
},
- "minesweeper": {
- "notInstalled": "Minesweeper ist auf diesem Server nicht installiert.",
- "needs": "Es benötigt Wine, ein 32-Bit-Prefix und dieses Paket neben der Plattform:",
- "notRunning": "Die Minesweeper-Sitzung läuft nicht.",
- "error": {
- "connectionFailed": "Die Verbindung zur Minesweeper-Sitzung ist fehlgeschlagen.",
- "refused": "Der VNC-Server hat die Verbindung abgelehnt.",
- "authUnsupported": "Der VNC-Server verlangt eine Authentifizierung, die dieser Client nicht unterstützt.",
- "handshakeRejected": "Der VNC-Server hat den Handshake abgelehnt.",
- "unknownMessage": "Der VNC-Server hat einen unbekannten Nachrichtentyp gesendet ({{type}}).",
- "unknownEncoding": "Der VNC-Server hat die Kodierung {{encoding}} verwendet, die dieser Client nicht angefordert hat."
- }
- },
"qrTransfer": {
"chooseFile": "Wählen Sie eine Datei zum Senden",
"startScanning": "Scannen starten",
diff --git a/src/workspaces/officerdev/src/locales/en-GB.json b/src/workspaces/officerdev/src/locales/en-GB.json
index 8f0b02cf..0e7c5976 100644
--- a/src/workspaces/officerdev/src/locales/en-GB.json
+++ b/src/workspaces/officerdev/src/locales/en-GB.json
@@ -3,19 +3,6 @@
"save": "Save",
"delete": "Delete"
},
- "minesweeper": {
- "notInstalled": "Minesweeper is not installed on this server.",
- "needs": "It needs wine, a 32-bit prefix, and this package beside the platform:",
- "notRunning": "The Minesweeper session is not running.",
- "error": {
- "connectionFailed": "The connection to the Minesweeper session failed.",
- "refused": "The VNC server refused the connection.",
- "authUnsupported": "The VNC server wants authentication, which this client does not implement.",
- "handshakeRejected": "The VNC server rejected the handshake.",
- "unknownMessage": "The VNC server sent an unknown message type ({{type}}).",
- "unknownEncoding": "The VNC server used encoding {{encoding}}, which this client did not ask for."
- }
- },
"qrTransfer": {
"chooseFile": "Choose a file to send",
"startScanning": "Start scanning",
diff --git a/src/workspaces/officerdev/src/locales/es-ES.json b/src/workspaces/officerdev/src/locales/es-ES.json
index 248079dc..5c5b3b8c 100644
--- a/src/workspaces/officerdev/src/locales/es-ES.json
+++ b/src/workspaces/officerdev/src/locales/es-ES.json
@@ -3,19 +3,6 @@
"save": "Guardar",
"delete": "Eliminar"
},
- "minesweeper": {
- "notInstalled": "Minesweeper no está instalado en este servidor.",
- "needs": "Necesita wine, un prefijo de 32 bits y este paquete junto a la plataforma:",
- "notRunning": "La sesión de Minesweeper no está en ejecución.",
- "error": {
- "connectionFailed": "La conexión a la sesión de Minesweeper falló.",
- "refused": "El servidor VNC rechazó la conexión.",
- "authUnsupported": "El servidor VNC pide autenticación, que este cliente no implementa.",
- "handshakeRejected": "El servidor VNC rechazó el handshake.",
- "unknownMessage": "El servidor VNC envió un tipo de mensaje desconocido ({{type}}).",
- "unknownEncoding": "El servidor VNC usó la codificación {{encoding}}, que este cliente no solicitó."
- }
- },
"qrTransfer": {
"chooseFile": "Elige un archivo para enviar",
"startScanning": "Empezar a escanear",
diff --git a/src/workspaces/officerdev/src/locales/fr-FR.json b/src/workspaces/officerdev/src/locales/fr-FR.json
index 1d61b307..6913cf04 100644
--- a/src/workspaces/officerdev/src/locales/fr-FR.json
+++ b/src/workspaces/officerdev/src/locales/fr-FR.json
@@ -3,19 +3,6 @@
"save": "Enregistrer",
"delete": "Supprimer"
},
- "minesweeper": {
- "notInstalled": "Minesweeper n'est pas installé sur ce serveur.",
- "needs": "Il faut wine, un préfixe 32 bits, et ce paquet à côté de la plateforme :",
- "notRunning": "La session Minesweeper n'est pas en cours d'exécution.",
- "error": {
- "connectionFailed": "La connexion à la session Minesweeper a échoué.",
- "refused": "Le serveur VNC a refusé la connexion.",
- "authUnsupported": "Le serveur VNC demande une authentification que ce client ne prend pas en charge.",
- "handshakeRejected": "Le serveur VNC a rejeté la prise de contact.",
- "unknownMessage": "Le serveur VNC a envoyé un type de message inconnu ({{type}}).",
- "unknownEncoding": "Le serveur VNC a utilisé l'encodage {{encoding}}, que ce client n'a pas demandé."
- }
- },
"qrTransfer": {
"chooseFile": "Choisissez un fichier à envoyer",
"startScanning": "Démarrer le scan",
diff --git a/src/workspaces/officerdev/src/locales/pt-PT.json b/src/workspaces/officerdev/src/locales/pt-PT.json
index bf1fc449..ee7e0156 100644
--- a/src/workspaces/officerdev/src/locales/pt-PT.json
+++ b/src/workspaces/officerdev/src/locales/pt-PT.json
@@ -3,19 +3,6 @@
"save": "Guardar",
"delete": "Eliminar"
},
- "minesweeper": {
- "notInstalled": "O Minesweeper não está instalado neste servidor.",
- "needs": "Precisa do wine, de um prefixo de 32 bits e deste pacote junto à plataforma:",
- "notRunning": "A sessão do Minesweeper não está em execução.",
- "error": {
- "connectionFailed": "A ligação à sessão do Minesweeper falhou.",
- "refused": "O servidor VNC recusou a ligação.",
- "authUnsupported": "O servidor VNC pede autenticação, que este cliente não implementa.",
- "handshakeRejected": "O servidor VNC rejeitou o handshake.",
- "unknownMessage": "O servidor VNC enviou um tipo de mensagem desconhecido ({{type}}).",
- "unknownEncoding": "O servidor VNC usou a codificação {{encoding}}, que este cliente não pediu."
- }
- },
"qrTransfer": {
"chooseFile": "Escolha um ficheiro para enviar",
"startScanning": "Começar a digitalizar",