difficulty and replay panels in the left column
Top panel: Expert / Intermediate / Beginner. Bottom: that difficulty's saved
games, best first, click to play one on the board.
── the selection lives in the URL ──
`/minesweeper/expert`, not a channel and not panel state. It survives a
refresh, it can be linked, and the two panels never pass anything between
themselves — both read `useParams`. The plugin section route already existed;
nothing had used it.
The side effect hangs off the URL too: an effect on the selected level posts
the difficulty, so arriving at `/minesweeper/expert` by click, deep link or
back button all leave the game on expert. `POST /difficulty` verifies and is
idempotent, so a repeat changes nothing. One `<Navigate replace>` after the
hooks canonicalises both the bare route and a bogus section, and it waits for
`/status` so the URL lands on what the game IS rather than a guess — which is
why `difficulty` is now part of that payload.
Levels are NavLinks so cmd-click works and the active style comes from the
router. Replay rows are buttons, because they do not navigate anywhere, they
make the game do something. That is the line navigation-audit.md draws.
── two things on the server ──
`GET /replays` reads the directory rather than any index, because the filename
IS the metadata — Minesweeper X names a recording `<Lvl> <secs> - <date>.mvr`
and keeps no catalogue. A name that does not parse is skipped rather than
guessed at. Ordering is the server's: a loss is never "best" however short it
was, so wins rank by time and losses fall to the end, still watchable.
`POST /replay` drives Extras > Play Recording... and TYPES the filename into a
file dialog, which is why it only accepts a name `isKnownReplay` just matched
against the directory. An unchecked client string reaching `xdotool type` can
carry a path, a wildcard, or a newline that submits something else. Verified by
the dialog closing, not by the window size — a beginner replay leaves the
window beginner-sized, which is also what nothing-happened looks like.
Verified end to end against the running session: the expert list comes back
ranked 58.63 through 82.06 with losses after, playing 58.63 starts on the
board, and `{"file":"../../../etc/passwd"}` is a 404.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
This commit is contained in:
+35
-3
@@ -3,7 +3,8 @@ import { createSidecarConnector } from '@@/sidecar/connect';
|
||||
import { API_URL } from '@@/officer-url.mjs';
|
||||
import { VNC_SOCKET, ensureDirs } from './paths';
|
||||
import { activeDisplay, isRunning, start, status, stop } from './session';
|
||||
import { DIFFICULTIES, isDifficulty, selectDifficulty } from './menu';
|
||||
import { DIFFICULTIES, currentDifficulty, isDifficulty, playReplay, selectDifficulty } from './menu';
|
||||
import { isKnownReplay, listReplays } from './replays';
|
||||
|
||||
/*
|
||||
officer-minesweeper — owns the game session, and serves both halves of the plugin from one listener.
|
||||
@@ -11,10 +12,12 @@ import { DIFFICULTIES, isDifficulty, selectDifficulty } from './menu';
|
||||
─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
HTTP CONTRACT — the platform strips its /api/minesweeper mount prefix before forwarding.
|
||||
|
||||
GET /status { installed, running, window, error? }
|
||||
GET /status { installed, running, window, difficulty, error? }
|
||||
POST /start brings the session up; idempotent, and builds the wine prefix on a cold install
|
||||
POST /stop takes it down
|
||||
POST /difficulty { level: beginner | intermediate | expert } — drives the game's own Game menu
|
||||
GET /replays ?level=<one of those> — the saved games, best first
|
||||
POST /replay { file } — opens one, by name, and only a name /replays just returned
|
||||
GET /ws the display — an RFB stream, relayed frame-for-byte onto x11vnc's unix socket
|
||||
|
||||
─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -100,7 +103,12 @@ const server = Bun.serve({
|
||||
}
|
||||
|
||||
try {
|
||||
if (req.method === 'GET' && url.pathname === '/status') return Response.json(await status());
|
||||
if (req.method === 'GET' && url.pathname === '/status') {
|
||||
// `difficulty` is derived here rather than in `status()` so that `session.ts` does not have to
|
||||
// import from `menu.ts`, which imports from it — the cycle would work and would still be a trap.
|
||||
const current = await status();
|
||||
return Response.json({ ...current, difficulty: current.window ? currentDifficulty(current.window) : null });
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/start') {
|
||||
const result = await start(log);
|
||||
// 503 rather than 500: the session is unavailable and the reason is in the body, which is what the
|
||||
@@ -126,6 +134,30 @@ const server = Bun.serve({
|
||||
const result = await selectDifficulty(body.level, display);
|
||||
return Response.json(result, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
|
||||
// Read off the directory the game writes to, so it needs no running session.
|
||||
if (req.method === 'GET' && url.pathname === '/replays') {
|
||||
const level = url.searchParams.get('level');
|
||||
if (level !== null && !isDifficulty(level)) {
|
||||
return Response.json({ error: `level must be one of ${Object.keys(DIFFICULTIES).join(', ')}` }, { status: 400 });
|
||||
}
|
||||
return Response.json({ replays: listReplays(level ?? undefined) });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/replay') {
|
||||
const display = activeDisplay();
|
||||
if (!isRunning() || !display) {
|
||||
return Response.json({ error: 'the session is not running' }, { status: 409 });
|
||||
}
|
||||
const body = (await req.json().catch(() => null)) as { file?: unknown } | null;
|
||||
// Checked against the DIRECTORY, not against a pattern. The name is about to be typed into a
|
||||
// file dialog, and the only string safe to type there is one that names a file we just listed.
|
||||
if (!isKnownReplay(body?.file)) {
|
||||
return Response.json({ error: 'no such replay' }, { status: 404 });
|
||||
}
|
||||
const result = await playReplay(body.file, display);
|
||||
return Response.json(result, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[${NAME}] ${req.method} ${url.pathname} failed`, err);
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
|
||||
+66
-1
@@ -24,8 +24,13 @@ import { findWindow, withStableWindow, type WindowGeometry } from './session';
|
||||
* the Game menu these are the numbers that break, and `verify` below is what will tell you.
|
||||
*/
|
||||
|
||||
/** Offset of the "Game" menu bar item from the window's top-left. */
|
||||
/** Offsets of the menu bar items from the window's top-left. Left-aligned, so board size does not move them. */
|
||||
const GAME_MENU = { x: 26, y: 11 };
|
||||
const EXTRAS_MENU = { x: 90, y: 11 };
|
||||
|
||||
/** "Play Recording…" in the Extras menu, and the File name box inside the dialog it opens. */
|
||||
const PLAY_RECORDING_Y = 62;
|
||||
const FILE_DIALOG = { name: 'Open Recording', nameBox: { x: 206, y: 190 } };
|
||||
|
||||
/** How far into an item to click horizontally — anywhere inside its width will do. */
|
||||
const ITEM_X = 45;
|
||||
@@ -101,3 +106,63 @@ export async function selectDifficulty(level: Difficulty, display: string): Prom
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type PlayResult = { ok: true; file: string } | { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
* Open a saved recording, by driving Extras -> Play Recording... and its file dialog.
|
||||
*
|
||||
* The filename is TYPED into a dialog, which is why `index.ts` only accepts one that `isKnownReplay`
|
||||
* matched against the directory first. A client-supplied string reaching `xdotool type` unchecked is a
|
||||
* string that can carry a path, a wildcard, or a newline that submits something else entirely.
|
||||
*
|
||||
* The dialog opens in C:\Minesweeper because that is the process's working directory, so a bare filename
|
||||
* is enough and no path is ever typed.
|
||||
*
|
||||
* Verified by the dialog going away. Checking the window size instead would be wrong: a replay of a
|
||||
* beginner game leaves the window at beginner size, which is also what it looks like when nothing
|
||||
* happened at all.
|
||||
*/
|
||||
export async function playReplay(file: string, display: string): Promise<PlayResult> {
|
||||
return withStableWindow(async () => {
|
||||
const win = await findWindow();
|
||||
if (!win) return { ok: false, reason: 'the game window is not there' };
|
||||
|
||||
const click = (x: number, y: number) =>
|
||||
run(['xdotool', 'mousemove', String(x), String(y), 'click', '1'], { DISPLAY: display }, 5_000);
|
||||
|
||||
await click(win.x + EXTRAS_MENU.x, win.y + EXTRAS_MENU.y);
|
||||
await Bun.sleep(450);
|
||||
await click(win.x + ITEM_X + 80, win.y + PLAY_RECORDING_Y);
|
||||
await Bun.sleep(900);
|
||||
|
||||
const dialog = await findDialog(display);
|
||||
if (!dialog) return { ok: false, reason: 'the file dialog did not open' };
|
||||
|
||||
await click(dialog.x + FILE_DIALOG.nameBox.x, dialog.y + FILE_DIALOG.nameBox.y);
|
||||
await Bun.sleep(250);
|
||||
await run(['xdotool', 'type', '--delay', '20', file], { DISPLAY: display }, 15_000);
|
||||
await Bun.sleep(250);
|
||||
await run(['xdotool', 'key', 'Return'], { DISPLAY: display }, 5_000);
|
||||
|
||||
// The dialog closing is the game accepting the name. It stays open on one it cannot find.
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await Bun.sleep(250);
|
||||
if (!(await findDialog(display))) return { ok: true, file };
|
||||
}
|
||||
return { ok: false, reason: 'the file dialog stayed open, so the game did not accept that name' };
|
||||
});
|
||||
}
|
||||
|
||||
/** The Open Recording dialog, if it is up. Its own window, so it is found by name rather than guessed at. */
|
||||
async function findDialog(display: string): Promise<{ x: number; y: number } | null> {
|
||||
const found = await run(
|
||||
['xdotool', 'search', '--name', FILE_DIALOG.name, 'getwindowgeometry', '--shell', '%@'],
|
||||
{ DISPLAY: display },
|
||||
5_000,
|
||||
);
|
||||
if (!found.ok) return null;
|
||||
const x = /^X=(\d+)$/m.exec(found.stdout);
|
||||
const y = /^Y=(\d+)$/m.exec(found.stdout);
|
||||
return x && y ? { x: Number(x[1]), y: Number(y[1]) } : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { GAME_DIR } from './paths';
|
||||
import type { Difficulty } from './menu';
|
||||
|
||||
/**
|
||||
* The saved games, read off disk rather than out of the game.
|
||||
*
|
||||
* Minesweeper X names a recording `<Lvl> <seconds> - <date> <time>[ (Lost)].mvr` and keeps no index, so
|
||||
* the filename IS the metadata. That is fine here — it is the game's own format, not one we chose, and
|
||||
* parsing it beats the alternative of a sidecar database that has to be kept in step with a directory
|
||||
* the game writes to behind our back.
|
||||
*
|
||||
* A name that does not parse is skipped rather than guessed at. Something the user dropped in the folder
|
||||
* by hand should not appear in a ranked list with an invented time.
|
||||
*/
|
||||
|
||||
const LEVELS: Record<string, Difficulty> = { Beg: 'beginner', Int: 'intermediate', Exp: 'expert' };
|
||||
|
||||
/** `Exp 58.63 - Feb 4 2026 00.16.29 AM.mvr`, with an optional ` (Lost)` before the extension. */
|
||||
const NAME = /^(Beg|Int|Exp) (\d+(?:\.\d+)?) - (.+?)( \(Lost\))?\.mvr$/;
|
||||
|
||||
export type Replay = {
|
||||
/** The filename, which is also the id. Never built by the client — see the guard in `index.ts`. */
|
||||
file: string;
|
||||
level: Difficulty;
|
||||
/** Seconds, as the game recorded them. */
|
||||
time: number;
|
||||
/** The game's own date string, kept verbatim rather than parsed into something with a timezone. */
|
||||
played: string;
|
||||
lost: boolean;
|
||||
};
|
||||
|
||||
export function listReplays(level?: Difficulty): Replay[] {
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(GAME_DIR);
|
||||
} catch {
|
||||
return []; // no prefix yet, which is the same answer as no replays
|
||||
}
|
||||
|
||||
const replays: Replay[] = [];
|
||||
for (const file of names) {
|
||||
const match = NAME.exec(file);
|
||||
if (!match) continue;
|
||||
const parsed = LEVELS[match[1]!];
|
||||
if (!parsed || (level && parsed !== level)) continue;
|
||||
replays.push({ file, level: parsed, time: Number(match[2]), played: match[3]!, lost: !!match[4] });
|
||||
}
|
||||
|
||||
// Best first, and a loss is never "best" however short it was — a 0.32s loss is a misclick, not a
|
||||
// record. So wins rank by time and losses fall to the end, most recent first, still watchable.
|
||||
return replays.sort((a, b) => {
|
||||
if (a.lost !== b.lost) return a.lost ? 1 : -1;
|
||||
if (a.lost) return b.played.localeCompare(a.played);
|
||||
return a.time - b.time;
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether a name is a replay this directory actually holds. The only way a client-supplied name is used. */
|
||||
export const isKnownReplay = (file: unknown): file is string =>
|
||||
typeof file === 'string' && listReplays().some((r) => r.file === file);
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Navigate, NavLink, useParams } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { LEVELS, isLevel, type Level } from './levels';
|
||||
|
||||
/**
|
||||
* The difficulty list — and the URL is what holds the choice.
|
||||
*
|
||||
* `/minesweeper/expert` rather than a channel or panel state, because it is exactly the kind of thing
|
||||
* `docs/navigation-audit.md` calls addressable: it survives a refresh, it can be linked, and the replay
|
||||
* panel reads it from the URL instead of having it passed across. Rows are real `NavLink`s, so cmd-click
|
||||
* and middle-click behave and the active style comes from the router rather than a hand-rolled compare.
|
||||
*
|
||||
* The SIDE EFFECT hangs off the URL too. An effect on the selected level posts the difficulty, so
|
||||
* arriving at `/minesweeper/expert` by any route — click, deep link, back button — leaves the game on
|
||||
* expert. `POST /difficulty` verifies and is idempotent, so a repeat costs a round trip and changes
|
||||
* nothing.
|
||||
*/
|
||||
export const LevelsPanel = () => {
|
||||
const { section } = useParams();
|
||||
const client = useClient();
|
||||
// useClient() returns fresh verbs every render; a ref keeps the effect below from re-running forever.
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const selected = isLevel(section) ? section : null;
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['minesweeper', 'status'],
|
||||
queryFn: () => client.get<{ difficulty: Level | null; running: boolean }>('/minesweeper/status'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
clientRef.current
|
||||
.post('/minesweeper/difficulty', { level: selected })
|
||||
.catch(() => toast.error(`Could not switch the game to ${selected}.`));
|
||||
}, [selected]);
|
||||
|
||||
// After the hooks, never before: one guard canonicalising both the bare route and a bogus section.
|
||||
// It waits for status so the URL lands on what the game IS rather than on a guess.
|
||||
if (!selected) {
|
||||
if (!status) return <Shell />;
|
||||
return <Navigate to={`/minesweeper/${status.difficulty ?? 'beginner'}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
{LEVELS.map((level) => (
|
||||
<NavLink
|
||||
key={level.key}
|
||||
to={`/minesweeper/${level.key}`}
|
||||
// Re-asserting the level it is already on is the point: a replay leaves the board at the
|
||||
// replay's size, and clicking the current level is the obvious way back.
|
||||
onClick={() => {
|
||||
if (level.key === selected) void clientRef.current.post('/minesweeper/difficulty', { level: level.key });
|
||||
}}
|
||||
className={({ isActive }) =>
|
||||
`rounded-md px-3 py-2 text-sm transition-colors ${
|
||||
isActive ? 'bg-accent font-medium text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{level.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</Shell>
|
||||
);
|
||||
};
|
||||
|
||||
const Shell = ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className="flex size-full flex-col gap-1 p-2">{children}</div>
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, Play, Trophy } from 'lucide-react';
|
||||
import { useParams } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { isLevel, type Replay } from './levels';
|
||||
|
||||
/**
|
||||
* The saved games for whichever difficulty the URL names, best first.
|
||||
*
|
||||
* Rows are BUTTONS, not links, and that is a rule rather than a preference: a row here does not navigate
|
||||
* anywhere, it makes the game do something. `docs/navigation-audit.md` draws exactly that line — a control
|
||||
* that mutates stays a button, and only one that opens an addressable thing is an anchor. Which difficulty
|
||||
* is showing IS addressable, and that is the panel next door.
|
||||
*
|
||||
* Ordering comes from the server, which is where it belongs: a loss is never "best" however short it was,
|
||||
* so wins rank by time and losses fall to the end. Re-sorting here would be a second place to disagree.
|
||||
*/
|
||||
export const ReplaysPanel = () => {
|
||||
const { section } = useParams();
|
||||
const client = useClient();
|
||||
const level = isLevel(section) ? section : null;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['minesweeper', 'replays', level],
|
||||
queryFn: () => client.get<{ replays: Replay[] }>(`/minesweeper/replays?level=${level}`),
|
||||
enabled: !!level,
|
||||
});
|
||||
|
||||
const play = useMutation({
|
||||
mutationFn: (file: string) => client.post('/minesweeper/replay', { file }),
|
||||
onError: () => toast.error('The game would not open that recording.'),
|
||||
});
|
||||
|
||||
const replays = data?.replays ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{level ? `${replays.length} saved ${replays.length === 1 ? "game" : "games"}` : "Replays"}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
{isLoading ? (
|
||||
<p className="px-1 py-2 text-xs text-muted-foreground">Loading…</p>
|
||||
) : replays.length === 0 ? (
|
||||
<p className="px-1 py-2 text-xs text-muted-foreground">No recordings for this level yet.</p>
|
||||
) : (
|
||||
replays.map((replay, index) => {
|
||||
const pending = play.isPending && play.variables === replay.file;
|
||||
return (
|
||||
<button
|
||||
key={replay.file}
|
||||
type="button"
|
||||
onClick={() => play.mutate(replay.file)}
|
||||
disabled={play.isPending}
|
||||
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent/50 disabled:opacity-60"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Play className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
{/* A rank only means anything while the list is ranked, so losses do not get one. */}
|
||||
<span className="w-4 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
||||
{replay.lost ? "" : index + 1}
|
||||
</span>
|
||||
<span className={`shrink-0 text-sm tabular-nums ${replay.lost ? "text-muted-foreground" : ""}`}>
|
||||
{replay.time.toFixed(2)}s
|
||||
</span>
|
||||
{index === 0 && !replay.lost ? <Trophy className="size-3.5 shrink-0 text-amber-500" /> : null}
|
||||
{replay.lost ? <span className="shrink-0 text-[10px] text-muted-foreground">lost</span> : null}
|
||||
<span className="ml-auto truncate text-[11px] text-muted-foreground">{replay.played}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+7
-7
@@ -3,11 +3,11 @@ import type { LayoutNode } from 'officerdev';
|
||||
// Two panels stacked on the left, the game on the right — the same arrangement Atelier uses, and the one
|
||||
// the rest of the app reads as "controls beside a surface".
|
||||
//
|
||||
// The left column is EMPTY on purpose. `appType: null` is a state of the framework rather than a missing
|
||||
// app, and `normalizeLayout` allows it explicitly, so these survive the plugin's own allow-list instead of
|
||||
// being rewritten to the fallback — which on a one-panel plugin would have rendered three Minesweepers.
|
||||
// The screen is `locked`, so an empty panel here is a plain bordered box with no app picker. That is the
|
||||
// intended look until something goes in them.
|
||||
// Difficulty on top, that difficulty's saved games below. 30/70 because the top panel holds exactly three
|
||||
// rows and the bottom one is a list that wants the room.
|
||||
//
|
||||
// Which difficulty is selected lives in the URL (`/minesweeper/expert`), not here and not in a channel, so
|
||||
// the two panels never pass it between themselves — they both read `useParams`.
|
||||
//
|
||||
// The game panel keeps the id `minesweeper` it had when this was a single panel, so any per-panel state
|
||||
// already keyed on it survives the change.
|
||||
@@ -22,8 +22,8 @@ export const defaultLayout: LayoutNode = {
|
||||
id: 'minesweeper-side',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'minesweeper-side-top', appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: 'minesweeper-side-bottom', appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: 'minesweeper-side-top', appType: 'minesweeper-levels' }, size: 30 },
|
||||
{ node: { type: 'panel', id: 'minesweeper-side-bottom', appType: 'minesweeper-replays' }, size: 70 },
|
||||
],
|
||||
},
|
||||
// 25 rather than Atelier's 30: the board is drawn at a whole-number scale, so the panel losing a
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/** The three standard difficulties, in the order the panel lists them: hardest first. */
|
||||
export const LEVELS = [
|
||||
{ key: 'expert', label: 'Expert' },
|
||||
{ key: 'intermediate', label: 'Intermediate' },
|
||||
{ key: 'beginner', label: 'Beginner' },
|
||||
] as const;
|
||||
|
||||
export type Level = (typeof LEVELS)[number]['key'];
|
||||
|
||||
export const isLevel = (value: string | undefined): value is Level =>
|
||||
!!value && LEVELS.some((l) => l.key === value);
|
||||
|
||||
export type Replay = { file: string; level: Level; time: number; played: string; lost: boolean };
|
||||
+5
-1
@@ -1,10 +1,14 @@
|
||||
// `panels.ts`, not `.tsx` — discovery checks for that exact name.
|
||||
import { Bomb } from 'lucide-react';
|
||||
import { Bomb, Film, ListOrdered } from 'lucide-react';
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { MinesweeperPanel } from './MinesweeperPanel';
|
||||
import { LevelsPanel } from './LevelsPanel';
|
||||
import { ReplaysPanel } from './ReplaysPanel';
|
||||
|
||||
// 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[] = [
|
||||
{ key: 'minesweeper', name: 'Minesweeper', icon: Bomb, component: MinesweeperPanel, availableOnPanel: false },
|
||||
{ key: 'minesweeper-levels', name: 'Difficulty', icon: ListOrdered, component: LevelsPanel, availableOnPanel: false },
|
||||
{ key: 'minesweeper-replays', name: 'Replays', icon: Film, component: ReplaysPanel, availableOnPanel: false },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user