minesweeper leaves the platform; plugin sockets relay ws-to-ws
Minesweeper is now plugins/minesweeper (gitea.officer.dev/plugins/minesweeper). Removes /api/minesweeper, the Dashboard screen, the officerdev panel, the permission registry entry, the App.tsx route and the hand-written websocket provider in server.tsx. Registry entries: 22 -> 21, execution 4 -> 3. The relay changed transport, and that is the part worth reading. A sidecar announces exactly one port. That single announcement is the whole provenance argument in target.ts — the platform watched the port arrive at registration, which is the only thing separating it from a number a manifest asserted. But a raw-TCP relay requires that port 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. That is the mechanism the fleet has spent months retiring, and Minesweeper would have been the first thing to go back to it. Relaying websocket-to-websocket dissolves the conflict. The sidecar serves HTTP and /ws on the one 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. The relay is still ignorant of the payload — the ignorance sits one layer up. Also: the caller's JWT is no longer forwarded upstream. A sidecar has no use for it and forwarding one puts a bearer token into whatever that process logs. X-Officer-User carries the only part it needs. (The terminal relay still forwards its token to the pty sidecar; not touched here.) Verified: bunx tsgo clean, bun test 1403 pass / 0 fail, bun run docs:check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -106,7 +106,6 @@ export function App() {
|
||||
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
|
||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
|
||||
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
||||
|
||||
@@ -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<LayoutNode>('screens/minesweeper', defaultLayout);
|
||||
|
||||
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['minesweeper'], fallback: 'minesweeper' }} />;
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './MinesweeperScreen';
|
||||
@@ -22,5 +22,4 @@ export * from './Dashboards';
|
||||
export * from './Terminal';
|
||||
export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
export * from './Minesweeper';
|
||||
export * from './SendOff';
|
||||
|
||||
+1
-4
@@ -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<string, any> = {
|
||||
'chat-notify': chatNotifyWebsocket,
|
||||
'task-runner': taskRunnerWebsocket,
|
||||
pipeline: pipelineWebsocket,
|
||||
minesweeper: minesweeperWebsocket,
|
||||
sidecar: sidecarWebsocket,
|
||||
};
|
||||
|
||||
@@ -189,7 +187,6 @@ const WS_ROUTES: Record<string, UpgradableProvider> = {
|
||||
'/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');
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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<string, string> = {}): 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<boolean> {
|
||||
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<WindowGeometry | null> {
|
||||
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<SessionStatus> {
|
||||
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']);
|
||||
@@ -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<ServerWebSocket<MinesweeperWSData>, Relay>();
|
||||
|
||||
const closeClient = (ws: ServerWebSocket<MinesweeperWSData>, 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<MinesweeperWSData>) {
|
||||
// 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<MinesweeperWSData>, 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<MinesweeperWSData>) {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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://<host>:<port>/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
|
||||
|
||||
@@ -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:<announced port>/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<ArrayBuffer>;
|
||||
type Relay = { ws: WebSocket | null; queue: Frame[]; done: boolean };
|
||||
const relays = new Map<ServerWebSocket<PluginWSData>, Relay>();
|
||||
|
||||
const closeClient = (ws: ServerWebSocket<PluginWSData>, code: number, reason: string) => {
|
||||
@@ -46,7 +57,7 @@ const closeClient = (ws: ServerWebSocket<PluginWSData>, 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<PluginWSData>, code: number, reason: st
|
||||
};
|
||||
|
||||
export const pluginWsWebsocket = {
|
||||
async open(ws: ServerWebSocket<PluginWSData>) {
|
||||
open(ws: ServerWebSocket<PluginWSData>) {
|
||||
/*
|
||||
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<PluginWSData>, message: string | Uint8Array) {
|
||||
message(ws: ServerWebSocket<PluginWSData>, 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<ArrayBuffer>);
|
||||
if (relay.ws && relay.ws.readyState === WebSocket.OPEN) relay.ws.send(frame);
|
||||
else relay.queue.push(frame);
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<PluginWSData>) {
|
||||
@@ -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<Respon
|
||||
// recipient lookup.
|
||||
if (!target) return new Response('Not found', { status: 404 });
|
||||
|
||||
// The rest of the query is the plugin's to interpret and is passed through untouched. The CREDENTIAL is
|
||||
// not: a sidecar has no use for the caller's JWT, and forwarding one puts a bearer token into whatever
|
||||
// that process logs. `X-Officer-User` carries the only part it needs.
|
||||
const forwarded = new URLSearchParams(url.search);
|
||||
forwarded.delete('token');
|
||||
const query = forwarded.toString();
|
||||
|
||||
const ok = server.upgrade(req, {
|
||||
data: {
|
||||
provider: 'plugin-ws',
|
||||
plugin,
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
upstream: `ws://${target.host}:${target.port}/ws${query ? `?${query}` : ''}`,
|
||||
userId: user.id,
|
||||
} satisfies PluginWSData,
|
||||
});
|
||||
|
||||
@@ -27,7 +27,6 @@ import { pluginsRouter } from './api/plugins/router';
|
||||
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
import { minesweeperRouter } from './api/minesweeper/router';
|
||||
import { sendoffRouter } from './api/sendoff/router';
|
||||
import { subAgentsRouter } from './api/sub-agents/router';
|
||||
import { caldavRouter } from './api/dav/sidecar-server';
|
||||
@@ -144,7 +143,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/dashboards', dashboardsRouter],
|
||||
['/file-browser', fileBrowserRouter],
|
||||
['/terminal', terminalRouter],
|
||||
['/minesweeper', minesweeperRouter],
|
||||
['/sendoff', sendoffRouter],
|
||||
['/sub-agents', subAgentsRouter],
|
||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -285,10 +285,10 @@ export type PluginManifest = {
|
||||
/**
|
||||
* A websocket this plugin serves, relayed by the platform.
|
||||
*
|
||||
* Declaring this makes `/api/plugin-ws/<appName>` 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/<appName>` a frame relay onto `ws://127.0.0.1:<port>/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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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 =
|
||||
|
||||
@@ -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<typeof useTranslation>['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<number, number> = { 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<HTMLCanvasElement | null>(null);
|
||||
const offscreenRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const rfbRef = useRef<RfbClient | null>(null);
|
||||
const buttonsRef = useRef(0);
|
||||
const geometryRef = useRef(VISIBLE);
|
||||
|
||||
const [status, setStatus] = useState<SessionStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const adopt = useCallback((next: SessionStatus) => {
|
||||
// The board is 152x214 on beginner and wider on the other difficulties. Taking the size from
|
||||
// status means the canvas is right on the first paint instead of after the first poll.
|
||||
if (next.window) geometryRef.current = { width: next.window.width, height: next.window.height };
|
||||
setStatus(next);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
adopt(await clientRef.current.get<SessionStatus>('/minesweeper/status'));
|
||||
} catch {
|
||||
setError('Could not reach the server.');
|
||||
}
|
||||
}, [adopt]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
adopt(await clientRef.current.post<SessionStatus>('/minesweeper/start', {}));
|
||||
} catch {
|
||||
setError('The session would not start. Is wine-minesweeper installed on the server?');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Paint the visible canvas from the offscreen one. Only the board's rectangle is copied, so the
|
||||
// surrounding desktop never reaches the screen.
|
||||
const present = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const offscreen = offscreenRef.current;
|
||||
if (!canvas || !offscreen) return;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
const { width, height } = geometryRef.current;
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
context.drawImage(offscreen, 0, 0, width, height, 0, 0, width, height);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status?.running) return;
|
||||
|
||||
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
// 'BEARER_TOKEN' is the key the rest of the app uses (Terminal.tsx, useAudioRecording). Reading
|
||||
// 'token' here sent an empty string, and upgradeWs answered 401 before any RFB byte was exchanged.
|
||||
const token = localStorage.getItem('BEARER_TOKEN') ?? sessionStorage.getItem('BEARER_TOKEN') ?? '';
|
||||
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<SessionStatus>('/minesweeper/status');
|
||||
if (!next.window) return;
|
||||
const { width, height } = next.window;
|
||||
if (width !== geometryRef.current.width || height !== geometryRef.current.height) {
|
||||
geometryRef.current = { width, height };
|
||||
present();
|
||||
}
|
||||
} catch {
|
||||
/* a failed poll is not worth surfacing; the next one will do */
|
||||
}
|
||||
}, 4000);
|
||||
return () => clearInterval(timer);
|
||||
}, [connected, present]);
|
||||
|
||||
const toFramebuffer = (ev: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const rect = ev.currentTarget.getBoundingClientRect();
|
||||
const scaleX = ev.currentTarget.width / rect.width;
|
||||
const scaleY = ev.currentTarget.height / rect.height;
|
||||
return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY };
|
||||
};
|
||||
|
||||
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: 'down' | 'up' | 'move') => {
|
||||
const rfb = rfbRef.current;
|
||||
if (!rfb) return;
|
||||
ev.preventDefault();
|
||||
|
||||
if (kind !== 'move') {
|
||||
const bit = BUTTON_MASK[ev.button] ?? 0;
|
||||
buttonsRef.current = kind === 'down' ? buttonsRef.current | bit : buttonsRef.current & ~bit;
|
||||
}
|
||||
const { x, y } = toFramebuffer(ev);
|
||||
rfb.pointer(x, y, buttonsRef.current);
|
||||
};
|
||||
|
||||
if (status && !status.installed) {
|
||||
return (
|
||||
<Centred>
|
||||
<Bomb className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{t('minesweeper.notInstalled')}</p>
|
||||
{/* The package name sits at the end so the sentence is one key rather than two around an
|
||||
inline element — see docs/localization.md on the fragmentation that produces. */}
|
||||
<p className="max-w-sm text-center text-xs text-muted-foreground">
|
||||
{t('minesweeper.needs')} <code>wine-minesweeper</code>
|
||||
</p>
|
||||
</Centred>
|
||||
);
|
||||
}
|
||||
|
||||
if (status && !status.running) {
|
||||
return (
|
||||
<Centred>
|
||||
<Bomb className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{t('minesweeper.notRunning')}</p>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={start}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground disabled:opacity-60"
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
||||
{busy ? 'Starting' : 'Start it'}
|
||||
</button>
|
||||
</Centred>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={VISIBLE.width}
|
||||
height={VISIBLE.height}
|
||||
onMouseDown={(ev) => onPointer(ev, 'down')}
|
||||
onMouseUp={(ev) => onPointer(ev, 'up')}
|
||||
onMouseMove={(ev) => onPointer(ev, 'move')}
|
||||
onContextMenu={(ev) => ev.preventDefault()}
|
||||
// Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a
|
||||
// photograph of a screen rather than the screen.
|
||||
className="rounded-sm shadow-lg [image-rendering:pixelated]"
|
||||
style={{ cursor: connected ? 'default' : 'progress' }}
|
||||
/>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{connected ? 'connected' : 'connecting…'}</span>
|
||||
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
|
||||
<RefreshCw className="size-3" /> refresh
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Centred = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-3 p-6">{children}</div>
|
||||
);
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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<ArrayBuffer>, not plain Uint8Array: the default is Uint8Array<ArrayBufferLike>, 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<ArrayBuffer>): 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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user