Commit Graph
23 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 1cdec11b84 put the status row above the board, and make it readable
text-xs muted at the bottom of the panel was where none of it got read.
Everything in that row is either a state you need before you touch the game
(connected, click the board to send it keys) or a repair for when it stops
behaving — a footer is the wrong place for both.

Now above the board, text-sm, text-primary, with the icons up to size-4 and
hover:underline instead of a colour change that is no longer available at
primary. `shrink-0` so it keeps its height and the canvas gives way, which is
the same reason the board's row has `min-h-0`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-24 17:13:47 +00:00
pastilhasandClaude Opus 5 a51c75cc6c a control for when the input state goes wrong
`POST /reset-input` and a "clicks acting wrong?" button in the panel's status
line. It releases every modifier and mouse button the display thinks is held.

Named for the SYMPTOM rather than the mechanism, because nobody experiencing
this knows a modifier is stuck — they know their clicks stopped doing the right
thing.

── both sides, and that ordering matters ──

Releasing on the server alone leaves the panel's own button mask stale and the
next mouse move re-asserts it, so the fix would appear not to work. Local state
is cleared first, a pointer event with an empty mask says so, and only then does
the server let go.

── a fixed list, not a query ──

`xinput --query-state` would report exactly what is held, which is better. It is
also a seventh host binary to declare and to require, for a repair. Releasing a
key that was not pressed is a no-op, so the fixed list costs nothing. Modifiers
and buttons only: a stuck letter types, which is obvious and harmless; a stuck
modifier silently changes what every click means.

── not guarded like the others ──

Every other command refuses while a dialog is open, because its clicks would
fall through onto the board. This one clicks nothing, and refusing a repair in
the state that most needs it would be the wrong way round.

Verified by stranding `Shift_L` on the display with `xdotool keydown`, hitting
the route, and watching `xinput --query-state` go from one key down to none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-24 17:10:14 +00:00
pastilhasandClaude Opus 5 02a816c8dc stop stranding modifiers — every left click was a right click
Reported as "no way to send a left click". `xinput --query-state` on the
display showed `Shift_L` down on the XTEST device with nothing holding it, so
every click reached the game as shift-click, which Minesweeper reads as a right
click. The board flagged instead of opening and nothing about the panel looked
wrong.

Mine, from the keyboard work two commits ago, and two separate mistakes.

── modifiers should never have been sent ──

Press shift, release it while the canvas no longer has focus, and the keyup
never arrives — the key is then down on the server forever. They are also
POINTLESS: `ev.key` is the character the layout already produced, so a shifted
key arrives as `A` and x11vnc presses shift itself to type it. Sending our own
on top was redundant on the good path and load-bearing on the bad one.

`keysymFor` now returns null for them, which also hands them back to the
browser — shift-tab should still move focus out of a game of Minesweeper.

── and nothing released what was held ──

Any key can strand the same way, so the panel now tracks what it has pressed
and releases it on blur and on unmount. Cheap, and the failure it prevents is
invisible from the panel: the server has no way to know a key was released
somewhere it could not see.

The live display is unstuck too — `xdotool keyup` on the modifiers, since the
fix stops it happening again but does not undo the one already stranded.

The general lesson, which is the same one the difficulty command taught: input
synthesised into another program's session is state you now own. Press without
a guaranteed release is a leak, and it does not look like one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-24 17:04:05 +00:00
pastilhasandClaude Opus 5 035c374563 quit the game cleanly on stop, so best times survive
Where they live: `HKCU\Software\Brightsoft\Minesweeper X` in the prefix's
`user.reg`. `Time1/2/3` are beginner, intermediate and expert; `Name1/2/3` are
the names, which is why the name you type sticks as the default. Nothing is
stored beside the executable except the recordings.

Why they were always empty: the game holds that whole key in memory and commits
it ONLY on a clean exit. `stop()` sent SIGTERM. So every restart of this sidecar
threw away every record set since the last one — six wins in one evening left a
Best Times table reading 9999.99, which is what started this.

`stop()` now drives Game > Exit and waits for the process to go before reaping.
It runs FIRST, ahead of even reaping x11vnc, because it needs the display alive
and it is racing PM2's clock.

── how it was pinned down ──

Not by reading. `user.reg` grew by 848 bytes at the exact moment the game
exited, which named the file. Writing `Time1="12.34"` / `Name1="Pastilhas"`
into it by hand and restarting made the game display them, which proved the key
and the load path. Then switching difficulty and stopping cleanly wrote
Width/Height/Mines through from 30/16/99 to 8/8/10, which proved the write path
carries in-memory state — beginner is 8x8 here, not the 9x9 I had assumed.

── the part this does NOT fix ──

`pm2 restart` will usually still cut it short: PM2 SIGKILLs 1600ms after
SIGTERM by default and a full stop takes about 5.6 seconds. That is not new —
this sidecar's teardown has always exceeded that budget — but it does mean the
fix only reliably applies to an explicit POST /stop. Raising it needs a
`kill_timeout` on `Pm2App` in the platform's plugins/ecosystem.ts, which has no
such field. Left alone rather than decided quietly; it is in the README.

Also reverted: the 12.34 "Pastilhas" beginner record was my probe, not a game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 23:50:37 +00:00
pastilhasandClaude Opus 5 34fad8be35 refuse a command when a dialog is in the way
Found by `POST /difficulty` failing three times against a "you have the fastest
time for intermediate level" prompt that had been sitting there waiting for a
name.

Failing was not the problem. What the three attempts DID was: a modal dialog
swallows the click meant for the menu bar, the menu never opens, and the second
click — aimed at where a menu item would have been — lands on the BOARD. Three
times. On a live game that reveals cells and can lose it.

So both commands now look for any named top-level window that is not the game,
and refuse with its title rather than clicking harder. Named windows only, since
wine litters the display with 1x1 stubs.

Verified against a real one: with the Open Recording dialog up, both
/difficulty and /replay return `"Open Recording" is open on the board — dismiss
it first`, and the board is untouched.

The lesson generalises past this plugin: driving another program's UI by
synthetic clicks means every command needs to check the world is still the shape
it assumed. A retry loop without that check is not resilience, it is the same
mistake three times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 23:34:05 +00:00
pastilhasandClaude Opus 5 5d62668883 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
2026-08-23 23:23:44 +00:00
pastilhasandClaude Opus 5 b94c1a6557 workspace layout: two empty panels left, the game right
The arrangement the rest of the app uses for controls beside a surface, and
the same shape as Atelier's. 25/75 rather than Atelier's 30/70, because the
board is drawn at a whole-number scale and a quarter of the width is enough to
drop it a step.

The left panels are `appType: null`, which is the framework's empty-panel
state rather than a missing app. That distinction is load-bearing here:
PluginScreen passes `appTypes.allowed` built from the plugin's own panel keys,
and this plugin has exactly one. Anything not on that list is rewritten to the
fallback — so if `null` were not explicitly allowed by `normalizeLayout`, this
layout would render three Minesweepers. It is allowed, and there is a test for
it.

The screen is `locked`, so an empty panel renders as a plain bordered box with
no app picker. That is the intended look until something goes in them.

The game panel keeps the id `minesweeper` it had as a single panel, so any
per-panel state keyed on it survives.

Checked before writing it: no persisted layout exists for this screen in
`user_state`, so the new default actually applies rather than being shadowed by
a saved one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 23:07:56 +00:00
pastilhasandClaude Opus 5 977448868c POST /difficulty — set the board from outside the game
First command in what will presumably be several, so the shape matters more
than the feature: name the OUTCOME, verify it against the world, and report
what the game now IS rather than that a click was sent.

    POST /api/minesweeper/difficulty  { "level": "expert" }
    -> { "ok": true, "level": "expert", "changed": true }

The proxy forwards <prefix>/*, so this needed no platform-side change.

── clicks, not keys, and that was measured ──

The obvious mechanism is `alt+g` then the underlined letter. It does not work
reliably: the same sequence gave a different difficulty depending on what was
selected before, and `alt+g e` landed on Intermediate more than once. Arrow
navigation was no better. A menu keystroke is a SEQUENCE, so it depends on
timing and on where the highlight already was; a click is one event that names
its target. Six scrambled clicks in a row hit six correct items.

There is a second reason keys are wrong here. With no window manager, X input
focus is PointerRoot — keys go to whatever the pointer is over — so a sequence
that RESIZES the window can move it out from under the pointer and silently
drop the rest of its own keystrokes. That is exactly what the first attempt
did, and it looked like a mapping problem.

── two things that make it a command rather than a click ──

`withStableWindow` holds off the re-centring watcher for the duration. A menu
interaction is two clicks ~450ms apart and the watcher ticks every second, so
it WILL move the window between them, and the second click then lands where
the item used to be — which on this game is the board. A stray click on a live
board reveals a cell and can lose the game.

And the check is the window's SIZE, not that the click was sent: 152x214,
280x342, 504x342 are distinct, so the result is verifiable. That is what makes
retrying safe, and what makes a repeat a no-op (`changed: false`) rather than a
second difficulty change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 23:02:59 +00:00
pastilhasandClaude Opus 5 5c8c7937f5 stage 512x384, so a replay can open
"Your screen cannot fit a window of that size" on Extras > Play Recording.
The replay window is 504x342 — exactly expert, which the 512x352 stage holds
with ten pixels to spare — so the message is not about the window it is about
to draw.

Not a positional check either, which was the first guess: from a centred
beginner window an expert window would run 236px off the right edge, and the
game repositions it without complaint. It is a minimum-screen check wanting
slack it never spends, roughly the title bar it does not have here.

Measured rather than reasoned about, on a stage enlarged for the purpose: the
replay window is 504x342, the file dialog 420x239. Then 512x352 verified
refusing a replay and 512x384 verified opening one.

The height is the whole fix; the width is unchanged. That matters because 42
more pixels of stage cost nothing at the sizes people use: the panel caps the
scale at 2x, so any stage up to about 726x451 draws the game at exactly the
same size. The stage only starts costing pixels once the fit falls under the
cap — which is also why the number is generous rather than the minimum that
passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 22:54:04 +00:00
pastilhasandClaude Opus 5 e5a7c5ffff zoom multiplies the baseline instead of being swallowed by it
The 2x cap flattened the panel's zoom control into doing nothing, and the
reason is worth writing down: `fit` is the SAME NUMBER at every zoom level.
CSS `zoom` takes off `offsetWidth` exactly what it adds to the pixel ratio, so
the fit is zoom-invariant by construction — and capping it therefore caps every
zoom level to the same size.

So the cap applies to the baseline, the size at 100%, and zoom multiplies what
comes out. 100% stays exactly where it is; zooming in goes past 2x again.

Quarter steps now go all the way down rather than whole numbers under 2x. With
whole numbers, zooming out from a 2x baseline fell straight to 1x and halved
the board in one press. Cells stay whole at every quarter — 20px at 1.25x —
which was always the property that mattered, not the size of the step.

The resulting ladder over the panel's 70-160% range: 1.25, 1.5, 1.75, 2.0,
2.0, 2.25, 2.5, 2.75, 3.0, 3.0. Eight of the ten presses move it; two land on
the same quarter, which is the price of keeping the grid even.

Also `overflow-hidden` on the box, so zooming past what the panel holds clips
rather than shoving the status line out of the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 22:41:14 +00:00
pastilhasandClaude Opus 5 48cbb24c4c cap the scale at 2x
The quarter steps did not actually shrink it. In a panel around 1500x950
device pixels the fit is 2.56, and floor(2.56 x 4) / 4 is still 2.5 — the finer
steps changed where the next size would land, not this one.

So a ceiling instead of another nudge: 2x, whatever room the panel has. That is
a taste decision rather than a limit of the method — past 2x this stops reading
as a window on a desktop — and the panel header has a zoom control, so it is a
default, not a verdict. Raise MAX_SCALE and the quarter steps take over again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 22:36:51 +00:00
pastilhasandClaude Opus 5 ef74f0c2dd keyboard, and back off the size a quarter step
── keys ──

`rfb.ts` already had a `key()` that nothing called. It now has `keysymFor`,
mapping `KeyboardEvent.key` to an X11 keysym — `key` rather than `code`,
because it is already the character the user's own layout produced and `code`
would need that layout re-implemented here to get back to a symbol. Latin-1
passes straight through, above it uses X's 0x01000000 Unicode offset, and the
named keys come from keysymdef.h.

The canvas is focusable and takes focus on mousedown. Scoping keys to focus is
what stops the panel eating keys meant for the rest of the app — this is one
panel on a dashboard, so a window listener would be a global keyboard trap.
Modified keys and unmapped keys are left alone: cmd-W stays the browser's and
Tab keeps moving focus. preventDefault only for keys actually sent.

Keys reach wine without a window manager — verified by pressing F2 on the
display and watching the board reset. So the README's guess that wine needed a
WM to hand it focus was wrong; Auto Options ignores Escape because that VB6
form has no Cancel button, which is a different thing. Corrected there.

20 keysym cases checked against keysymdef.h, including the off-by-one I put in
my own test first: XK_F2 is 0xffbf, not 0xffc0.

── size ──

Half steps overshot: 2x to 2.5x is too small to too large with nothing in
between. Quarter steps from 2x up, which keeps cells whole for the same reason
halves did — a 16px grid at 2.25x is a 36px cell — and lets the picture track
the panel instead of lurching. Plus a 24px device-pixel margin, because
filling the panel edge to edge is most of what "too large" looks like.

Also: the scale was ignoring the panel's own CSS `zoom`. `devicePixelRatio`
knows nothing about it, so at 75% every cell boundary went back onto fractional
device pixels — the exact defect the snapping exists to prevent, reachable from
a button in the panel header. Effective ratio is now dpr x zoom, read as
getBoundingClientRect().width / offsetWidth.

In a 1300x900 device-pixel panel: 2.5x becomes 2.25x, 1260 to 1134 px wide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 22:32:10 +00:00
pastilhasandClaude Opus 5 e4247cd760 smaller stage, half steps above 2x — bigger, still even
Trimming the stage alone does nothing, and that is worth writing down: the
scale is snapped, so at 3x the game is 1512 device pixels wide whether the
stage is 560 or 512. Shrinking the surround only pays when it crosses a step.
So both halves land together.

── the stage ──

560x400 down to 512x352, which is expert (504x342, measured) plus four pixels
a side. There is nothing left to give — intermediate is 342 tall too, so
anything shorter clips a board people actually play.

── half steps from 2x ──

Not a retreat from whole-number scaling. The unit that has to stay regular is
the CELL, not the pixel: this is a 16px grid, so at 3.5x a cell is exactly 56
screen pixels — as even as 3x or 4x — and only sub-cell pixels alternate
between three and four. Below 2x that alternation is a third of a pixel rather
than a seventh, so whole numbers still apply there. The case that started all
this, a fractional devicePixelRatio at nominal 1:1, put the CELL boundaries at
26.4px, which is a different thing entirely.

Together, on a 1800x1400 device-pixel panel: 3.0x becomes 3.5x, and the game
goes from 1512 to 1764 device pixels wide.

Known cost, and it is in the README: the Extras > Auto Options dialog is 328
wide and opens 25px right of the game, so it needs a 554-wide stage to fit
whole from beginner. It cannot be closed from the panel anyway — no window
manager, no title bar, no OK button — so that is the thing to fix before this
number is revisited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 22:24:57 +00:00
pastilhasandClaude Opus 5 105068170c run the game from inside the prefix, so it can save a replay
`Run-time error '76': Path not found` on finishing a game. Error 76 is VB6's
"path not found", and the path it could not find was the game's own.

Confining the prefix removed `z:`, which left `C:` as the only drive — and the
executable was in the plugin checkout, which `C:` does not cover. So wine gave
the game `\\?\unix\home\pastilhas\...\Minesweeper X.exe` as its own location
(confirmed with `winepath`), VB6 file I/O cannot write to a path of that shape,
and the first thing that needs to is the auto-saved recording. Which happens at
the END of a game, so the failure arrived after somebody had played.

The comment in prefix.ts already claimed "the game keeps its own directory, its
settings and its replays". It did not. It does now: `installGame` copies the
68 KB executable into `C:\Minesweeper` on every start and the game runs from
there, with cwd set to match so relative writes stop landing in C:\windows.

Copied rather than symlinked — a symlink out of drive_c is a door out of the
prefix, which is the exact thing confinePrefix exists to close. The cost is
that replays now live inside prefix/, so deleting it to unwedge wine takes them
along; that is in the README rather than left to be discovered.

Verified end to end rather than by inspection: set Auto-Save Recording to All,
played a beginner game to a loss, and
`Beg 1.16 - Aug 23 2026 22.09.18 PM (Lost).mvr` appeared in C:\Minesweeper.
Before this, that same moment was the error box. The default is "High scores",
which is why an ordinary losing game never reproduced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 22:10:30 +00:00
pastilhasandClaude Opus 5 26c8dd3e1a scale by whole pixels, and make the stage small enough to matter
Two halves of the same problem: the picture was sharp everywhere and wrong
overall.

── whole-number scaling ──

The canvas is now sized to the largest WHOLE number of device pixels per
source pixel that fits the panel, measured with a ResizeObserver and the
device pixel ratio. Below 1x it takes a unit fraction instead, so the rows
that survive are evenly spaced rather than dropped in clumps.

This is a 16px-per-cell bitmap from 1995. At a fractional scale one cell gets
three screen pixels and its neighbour gets two, the grid goes uneven and the
digits wobble — which reads as "not quite right" rather than as blur, and
`image-rendering: pixelated` does not help because unevenness is not blur.
A fractional devicePixelRatio produces exactly this at what looks like 1:1.

It is also allowed to scale UP past the framebuffer's own size, which was the
other half of it: `max-w-full` only ever shrinks, so on a wide panel the
canvas sat at its intrinsic size using a fraction of the pixels available.

── a smaller stage ──

800x600 down to 560x400. The stage is what gets scaled to fill the panel, so
every pixel of empty surround is panel the game does not get, and the whole-
number steps are far apart: in an 1866-device-pixel panel, 800 wide lands on
2x and 560 wide lands on 3x. Same panel, half again as much game.

560x400 is expert (504x342, measured on this machine) plus a margin. A custom
board bigger than the stage is now clipped at the right and bottom rather than
shrunk; `centre` clamps at zero so the menu bar stays reachable. Widen the two
constants if that turns out to be the wrong trade.

Verified: beginner centres at 204,93 on the new stage, which is exact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 21:26:10 +00:00
pastilhasandClaude Opus 5 ad2aa632a5 hide the cursor the server draws — yours is the accurate one
`-nocursor`. The RFB client asks for Raw and CopyRect and nothing else, so
x11vnc has no cursor pseudo-encoding to send a shape through and composites
the pointer into the framebuffer instead. That is a second cursor in the
picture, a round trip behind the real one, and on a bare X server with no
window manager it is the big black X of the root cursor rather than an arrow.

Verified on the live socket by parking the pointer at 100,100 and counting
what is not stage-teal in a 48x48 patch around it: 960 pixels before, 0 after.

Not fixed by teaching the client the cursor encodings — the answer is not to
draw the remote cursor better, it is not to draw it at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 21:19:31 +00:00
pastilhasandClaude Opus 5 e6cec953e4 a fixed stage for the game, and close the VNC port nobody meant to open
── the stage ──

The framebuffer is now the picture. 800x600 whatever the difficulty, the game
centred in it by the sidecar, and the panel draws it entire — scaled to fit,
centred by its flex parent, never cropped. Changing difficulty moves the board
instead of resizing the panel.

What this replaces parked the window at the origin and had the client crop to
the window's size, polled every four seconds. Two things wrong with it. The
panel resized under the player mid-game; and wine puts the window at 3,29
rather than 0,0, so cropping from the origin showed a black strip on top and
cut a row of cells off the bottom — the "expert gets cut off" report.

Centring runs on a one-second timer because nothing announces a resize: not
RFB, which keeps the framebuffer, and not X without a window manager to listen
through. One chained `xdotool search … getwindowgeometry` per tick, one
process, and a `windowmove` only when the numbers actually changed.

The client loses the geometry poll, the offscreen canvas and the crop with it.

── the port ──

x11vnc was listening on 0.0.0.0:5900 and [::]:5900 with -nopw. Every account
on this machine, every device on the tailnet and everything on the LAN could
open it and take the display — which is precisely the hole the 5907 version
was replaced to close, reopened wider and documented as impossible.

`-unixsock` ADDS a unix socket. It does not remove the TCP one. Three flags do:
-rfbport 0 for IPv4, -rfbportv6 0 for IPv6, -noipv6 for outgoing. `-rfbport 0`
alone leaves [::]:5900 up, which is how this nearly shipped half-fixed.

Found with `ss -lntp` on the running process, and that is the only check that
was right about it at any point. Running the same argv by hand showed no IPv6
listener — most likely because the PM2 session still held [::]:5900 and the
hand-run copy failed to bind it silently. Comments in session.ts and index.ts
that asserted there was no port are corrected rather than deleted.

Verified on the live socket: the streamed framebuffer is 447k teal pixels
around a 152x214 window at 324,193 — dead centre — and window grey arrives as
c0 c0 c0, no TCP listener for the x11vnc pid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 21:17:12 +00:00
pastilhasandClaude Opus 5 76b6a9c567 correct the pixel shifts — the stream was cyan, not grey
SetPixelFormat asked for 32bpp big-endian with shifts 16/8/0, which is the
pixel 0x00RRGGBB and puts X,R,G,B on the wire. The Raw path copies four bytes
straight into ImageData and forces alpha opaque, so every channel landed one
place along and blue was dropped: window grey rendered cyan, the red LED
digits rendered green, blue "1" cells and the title bar rendered black.

Shifts are now 24/16/8, making the pixel 0xRRGGBB00 and the wire R,G,B,X —
which is what the file already claimed, in two comments, since it was written.

Verified against the live x11vnc socket rather than by reading: probing the
same framebuffer under both formats returns `00 c0 c0 c0` for window grey with
the old shifts and `c0 c0 c0 00` with the new ones, and `00 ff 00 00` vs
`ff 00 00 00` for the digits. x11vnc honours a shift of 24 without complaint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 21:03:44 +00:00
pastilhasandClaude Opus 5 b8a4f56c7c fold in the provenance from the standalone build
wine-minesweeper/ was where this was built before it was a plugin, and it is
about to be deleted — it has no git remote, so its history lives on one machine
and nowhere else. Two things in it are still true and not captured here.

Why wine comes from Ubuntu rather than WineHQ: the distro package is older and
sufficient, and the alternative is a third-party apt source on a machine running
a wallet and a mail store, for a game. And that enabling i386 pulled four krb5
libraries forward, checked before installing rather than after — the one step
here that changes how the whole machine resolves packages, and one that
uninstalling the plugin never undoes.

Also the original verification baseline, which is useful for comparison when
something feels wrong: the board is 152x214, a synthetic click really does reach
the game, RFB 003.008, and cold start under two seconds excluding the prefix.

The shell prototype is not kept. What it knew is in sidecar/session.ts next to
the code that depends on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 20:33:02 +00:00
pastilhasandClaude Opus 5 41020b1b65 build the wine prefix after the display exists, not before
A cold install could never work. `winetricks -q vb6run` runs Microsoft's
redistributable, and that installer creates a window even when told to be
quiet — so with no DISPLAY wine cannot load a graphics driver, the installer
dies, and winetricks reports `returned status 43`. That message names neither
wine nor a display, and reads like a corrupt download.

ensurePrefix ran before Xvfb started, because building a prefix looks like
preparation rather than something that needs a screen.

It never showed up in testing here: this machine's prefix was seeded from the
pre-extraction one, so the build path only ever ran against a prefix that was
already complete. The first genuine cold install — alpha — failed on the first
Start. Diagnosed from `err:winediag:nodrv_CreateWindow` in the wine output,
which is the only line that says what is actually wrong.

Order is now display, prefix, game. ensurePrefix takes the display explicitly
rather than reading the ambient environment, so it cannot silently run without
one again, and a failed build now tears the display down instead of leaving an
Xvfb behind.

Verified by moving this machine's prefix aside and starting cold: prefix built
from nothing, msvbvm60.dll installed, session up with the window at 152x214,
Z: confined, socket mode 700, and RFB 003.008 through the platform relay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 20:21:32 +00:00
pastilhasandClaude Opus 5 4fe9fa2e97 declare the i386 architecture instead of asking the owner to enable it
wine's apt entry is now ['wine', 'wine32:i386'] with multiarch: 'i386'. The
platform enables the architecture and updates the package lists before
installing, so `dpkg --add-architecture i386` is no longer a step somebody has
to know about and run by hand before the plugin will work.

Both packages, not one: `wine` provides the launcher on PATH, `wine32:i386` the
32-bit loader an i386 executable needs. Installing either alone gives a wine
that cannot run this game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 18:24:55 +00:00
pastilhasandClaude Opus 5 b88cacecac confine the prefix again after wine boots, and fix two manifest package names
Two things the live install found.

The manifest was refused at discovery. `apt: 'wine wine32:i386'` is two
packages and an architecture suffix, and a package name may be neither — nor
should it be, since `dpkg --add-architecture i386` has to happen first and no
package name can express that. And Fedora's Xvfb package is
`xorg-x11-server-Xvfb`, with a capital X, which a manifest package name may not
have; the dnf entry is dropped rather than guessed, which turns into an
instruction to the owner instead of a wrong install.

More importantly: confining the prefix before wine starts is a confinement wine
can undo. Observed directly — after a run, every com* link had come back with a
fresh timestamp, because wine rebuilds dosdevices when it boots a prefix it
thinks needs updating, and a wine package upgrade is enough to make it think
so. Z: did not come back that time, but "the dangerous one happened not to
regenerate" is not a property to rely on. confineAndVerify now runs again once
the game is up and says so if Z: returned.

Verified on the live server: Z: and the three home symlinks are gone from a
prefix that had them, run/ is mode 700, nothing listens on 5907, and the RFB
handshake arrives through the platform relay with a valid owner token and
through nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 17:52:02 +00:00
pastilhasandClaude Opus 5 f3cc1242c4 minesweeper: extracted from the platform
Minesweeper X under wine on a headless display, as a plugin. It was
/api/minesweeper, a Dashboard screen, an officerdev panel, a permission
registry entry and a hand-written websocket provider in server.tsx.

The socket is why it had to live in the platform: Bun's route table is fixed
at serve(), so nothing installed later could add one. That ceiling is gone,
and this is the first plugin through the per-plugin relay.

Three things changed rather than moved:

The sidecar owns the session. The platform used to spawn bin/session.sh
detached, so the game outlived every restart, nothing supervised it, and a
crashed run left wine processes holding the prefix — the next start hung
rather than failed. PM2 supervises a sidecar, and stop() uses 'wineserver -k'
scoped by WINEPREFIX instead of 'pkill -f wineserver', which would have killed
every other wine prefix on the machine.

x11vnc moved from 127.0.0.1:5907 to a unix socket in a mode-0700 directory.
-nopw was only ever safe because of the machine, not the platform: any local
account could open that port and take the display. A fixed port is squattable
too. There is now no listener to authenticate against.

The wine prefix is confined to C:. This is the one that mattered. The platform
classified Minesweeper as kind:'execution' — never grantable — because a fresh
prefix maps the whole filesystem to Z: and symlinks the profile into the
owner's real home, and the game has Open and Save dialogs. A plugin permission
has no kind and is always grantable, so extracting it without this would have
converted a sealed capability into a file picker running as the owner.
Re-asserted on every start, because wine restores z: whenever it updates a
prefix.

Not solved: one display and one game, so two grantees share a board.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 17:43:17 +00:00