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

- {t('minesweeper.needs')} wine-minesweeper -

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

{t('minesweeper.notRunning')}

- {error ?

{error}

: null} - -
- ); - } - - return ( -
- onPointer(ev, 'down')} - onMouseUp={(ev) => onPointer(ev, 'up')} - onMouseMove={(ev) => onPointer(ev, 'move')} - onContextMenu={(ev) => ev.preventDefault()} - // Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a - // photograph of a screen rather than the screen. - className="rounded-sm shadow-lg [image-rendering:pixelated]" - style={{ cursor: connected ? 'default' : 'progress' }} - /> -
- {connected ? 'connected' : 'connecting…'} - -
- {error ?

{error}

: null} -
- ); -}; - -const Centred = ({ children }: { children: React.ReactNode }) => ( -
{children}
-); diff --git a/src/workspaces/officerdev/src/apps/Minesweeper/index.ts b/src/workspaces/officerdev/src/apps/Minesweeper/index.ts 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",