From 589c8cdf6e734232e00ba3e349a0ba370fd08796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 14:04:14 +0000 Subject: [PATCH] minesweeper panel keyed (578 -> 573) First panel, so it also proves the panel.* plumbing: the catalogue was {} until now, which made TranslationKeys resolve to `never` and every key a type error. rfb.ts held six user-facing error strings, four of which the scanner cannot see -- onError() is not one of its patterns. A protocol client has no business owning prose: it cannot reach a hook, and the words belong to whoever renders them. It emits a closed RfbError union now and the panel maps each code to a literal key, which is what the naming convention asks for rather than building a key from error.code. The install hint had wine-minesweeper mid-sentence. Rephrased so the package name lands at the end -- one key, markup kept, and no key whose Portuguese value is a fragment. --- .../src/apps/Minesweeper/MinesweeperPanel.tsx | 81 ++++++++++++------- .../officerdev/src/apps/Minesweeper/rfb.ts | 39 +++++++-- .../officerdev/src/locales/en-GB.json | 16 +++- .../officerdev/src/locales/pt-PT.json | 16 +++- 4 files changed, 114 insertions(+), 38 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/Minesweeper/MinesweeperPanel.tsx b/src/workspaces/officerdev/src/apps/Minesweeper/MinesweeperPanel.tsx index 7f0cba12..a0b9c46c 100644 --- a/src/workspaces/officerdev/src/apps/Minesweeper/MinesweeperPanel.tsx +++ b/src/workspaces/officerdev/src/apps/Minesweeper/MinesweeperPanel.tsx @@ -1,7 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { Bomb, Loader2, Play, RefreshCw } from "lucide-react"; -import { useClient } from "hooks/useClient"; -import { RfbClient } from "./rfb"; +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; @@ -16,7 +20,26 @@ 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 @@ -45,9 +68,9 @@ export const MinesweeperPanel = () => { const refresh = useCallback(async () => { try { - adopt(await clientRef.current.get("/minesweeper/status")); + adopt(await clientRef.current.get('/minesweeper/status')); } catch { - setError("Could not reach the server."); + setError('Could not reach the server.'); } }, [adopt]); @@ -59,9 +82,9 @@ export const MinesweeperPanel = () => { setBusy(true); setError(null); try { - adopt(await clientRef.current.post("/minesweeper/start", {})); + adopt(await clientRef.current.post('/minesweeper/start', {})); } catch { - setError("The session would not start. Is wine-minesweeper installed on the server?"); + setError('The session would not start. Is wine-minesweeper installed on the server?'); } finally { setBusy(false); } @@ -73,7 +96,7 @@ export const MinesweeperPanel = () => { const canvas = canvasRef.current; const offscreen = offscreenRef.current; if (!canvas || !offscreen) return; - const context = canvas.getContext("2d"); + const context = canvas.getContext('2d'); if (!context) return; const { width, height } = geometryRef.current; if (canvas.width !== width || canvas.height !== height) { @@ -86,15 +109,15 @@ export const MinesweeperPanel = () => { useEffect(() => { if (!status?.running) return; - const scheme = window.location.protocol === "https:" ? "wss" : "ws"; + 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 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"); + const offscreen = document.createElement('canvas'); offscreen.width = width; offscreen.height = height; offscreenRef.current = offscreen; @@ -102,12 +125,12 @@ export const MinesweeperPanel = () => { setError(null); }, onFrame: (image, x, y) => { - offscreenRef.current?.getContext("2d")?.putImageData(image, x, y); + offscreenRef.current?.getContext('2d')?.putImageData(image, x, y); present(); }, onCopyRect: (from, to) => { const offscreen = offscreenRef.current; - const context = offscreen?.getContext("2d"); + 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(); @@ -120,7 +143,7 @@ export const MinesweeperPanel = () => { } rfb.requestUpdate(false); }, - onError: (message) => setError(message), + onError: (rfbError) => setError(describeRfbError(rfbError, t)), onClose: () => setConnected(false), }); @@ -138,7 +161,7 @@ export const MinesweeperPanel = () => { if (!connected) return; const timer = setInterval(async () => { try { - const next = await clientRef.current.get("/minesweeper/status"); + 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) { @@ -159,14 +182,14 @@ export const MinesweeperPanel = () => { return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY }; }; - const onPointer = (ev: React.MouseEvent, kind: "down" | "up" | "move") => { + const onPointer = (ev: React.MouseEvent, kind: 'down' | 'up' | 'move') => { const rfb = rfbRef.current; if (!rfb) return; ev.preventDefault(); - if (kind !== "move") { + if (kind !== 'move') { const bit = BUTTON_MASK[ev.button] ?? 0; - buttonsRef.current = kind === "down" ? buttonsRef.current | bit : buttonsRef.current & ~bit; + buttonsRef.current = kind === 'down' ? buttonsRef.current | bit : buttonsRef.current & ~bit; } const { x, y } = toFramebuffer(ev); rfb.pointer(x, y, buttonsRef.current); @@ -176,9 +199,11 @@ export const MinesweeperPanel = () => { return ( -

Minesweeper is not installed on this server.

+

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

- It needs wine-minesweeper beside the platform, with wine and a 32-bit prefix. + {t('minesweeper.needs')} wine-minesweeper

); @@ -188,7 +213,7 @@ export const MinesweeperPanel = () => { return ( -

The Minesweeper session is not running.

+

{t('minesweeper.notRunning')}

{error ?

{error}

: null}
); @@ -209,17 +234,17 @@ export const MinesweeperPanel = () => { ref={canvasRef} width={VISIBLE.width} height={VISIBLE.height} - onMouseDown={(ev) => onPointer(ev, "down")} - onMouseUp={(ev) => onPointer(ev, "up")} - onMouseMove={(ev) => onPointer(ev, "move")} + 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" }} + style={{ cursor: connected ? 'default' : 'progress' }} />
- {connected ? "connected" : "connecting…"} + {connected ? 'connected' : 'connecting…'} diff --git a/src/workspaces/officerdev/src/apps/Minesweeper/rfb.ts b/src/workspaces/officerdev/src/apps/Minesweeper/rfb.ts index 37b2f0ea..96ef93de 100644 --- a/src/workspaces/officerdev/src/apps/Minesweeper/rfb.ts +++ b/src/workspaces/officerdev/src/apps/Minesweeper/rfb.ts @@ -26,10 +26,26 @@ export type RfbEvents = { 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: (message: string) => 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; @@ -54,7 +70,7 @@ export class RfbClient { this.closed = true; this.events.onClose(); }; - this.socket.onerror = () => this.events.onError('The connection to the Minesweeper session failed.'); + this.socket.onerror = () => this.events.onError({ code: 'connectionFailed' }); } close(): void { @@ -125,7 +141,7 @@ export class RfbClient { // 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('The VNC server refused the connection.'); + this.events.onError({ code: 'refused' }); this.close(); return false; } @@ -134,7 +150,7 @@ export class RfbClient { const types = Array.from(head.subarray(1)); if (!types.includes(1)) { - this.events.onError('The VNC server wants authentication, which this client does not implement.'); + this.events.onError({ code: 'authUnsupported' }); this.close(); return false; } @@ -147,7 +163,7 @@ export class RfbClient { const head = this.take(4); if (!head) return false; if (new DataView(head.buffer, head.byteOffset, 4).getUint32(0) !== 0) { - this.events.onError('The VNC server rejected the handshake.'); + this.events.onError({ code: 'handshakeRejected' }); this.close(); return false; } @@ -231,7 +247,7 @@ export class RfbClient { return this.take(8 + length) !== null; } - this.events.onError(`The VNC server sent an unknown message type (${type}).`); + this.events.onError({ code: 'unknownMessage', type }); this.close(); return false; } @@ -251,7 +267,14 @@ export class RfbClient { 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 }; + 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; @@ -259,7 +282,7 @@ export class RfbClient { else if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) { /* no payload — the rect's w/h ARE the new size */ } else { - this.events.onError(`The VNC server used encoding ${rect.encoding}, which this client did not ask for.`); + this.events.onError({ code: 'unknownEncoding', encoding: rect.encoding }); this.close(); return false; } diff --git a/src/workspaces/officerdev/src/locales/en-GB.json b/src/workspaces/officerdev/src/locales/en-GB.json index 0967ef42..86fb3306 100644 --- a/src/workspaces/officerdev/src/locales/en-GB.json +++ b/src/workspaces/officerdev/src/locales/en-GB.json @@ -1 +1,15 @@ -{} +{ + "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." + } + } +} diff --git a/src/workspaces/officerdev/src/locales/pt-PT.json b/src/workspaces/officerdev/src/locales/pt-PT.json index 0967ef42..83cbeb86 100644 --- a/src/workspaces/officerdev/src/locales/pt-PT.json +++ b/src/workspaces/officerdev/src/locales/pt-PT.json @@ -1 +1,15 @@ -{} +{ + "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." + } + } +}