Files
minesweeper/sidecar/prefix.ts
T
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

183 lines
9.0 KiB
TypeScript

import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { WINEPREFIX } from './paths';
import { run } from './run';
/**
* Building the wine prefix, once, on first start.
*
* ── Why this is code and not an install step ──
*
* The plugin system has no install hook, and it should not grow one for this: an install that takes four
* minutes and downloads from Microsoft is not something to do behind a progress bar the owner cannot
* cancel. Doing it inside the first `POST /start` means it happens when somebody actually wants to play,
* it can report what it is doing, and a machine that installs the plugin and never opens it pays nothing.
*
* It is also self-healing. Deleting the prefix — which is the standard fix for a wedged wine — is safe,
* because the next start rebuilds it.
*
* ── The two facts that decide the whole setup ──
*
* Minesweeper X is a 68 KB i386 PE (machine `0x014c`, verified), so the prefix must be `win32`. A 64-bit
* prefix cannot load it and the failure is a bare `Bad EXE format`.
*
* It was built with Visual Basic 6, so it needs `MSVBVM60.DLL`, which wine does not ship. Without it the
* process exits immediately having printed one line — `err:module:import_dll Library MSVBVM60.DLL not
* found` — and nothing else. That is a quiet enough failure that it is worth checking for the DLL directly
* rather than inferring readiness from the prefix existing.
*/
/** The one file whose presence means this prefix can actually run the game. */
const VB6_RUNTIME = join(WINEPREFIX, 'drive_c', 'windows', 'system32', 'msvbvm60.dll');
export const prefixReady = (): boolean => existsSync(VB6_RUNTIME);
export const WINE_ENV = {
WINEPREFIX,
WINEARCH: 'win32',
// wine narrates every unimplemented call otherwise, and the game triggers thousands.
WINEDEBUG: '-all',
// Never prompt to install .NET or an embedded IE. The dialog appears on a display nobody is watching,
// and the start then hangs until it times out.
WINEDLLOVERRIDES: 'mscoree,mshtml=',
};
export type PrefixResult = { ok: boolean; detail: string };
/**
* Create the prefix if it is not there, then install the VB6 runtime into it.
*
* `winetricks -q vb6run` fetches Microsoft's own `VB6.0-KB290887-X86.exe` and verifies its checksum before
* unpacking, which is why this does not carry a redistributable of its own.
*/
export async function ensurePrefix(log: (line: string) => void, display: string): Promise<PrefixResult> {
// THE DISPLAY IS NOT OPTIONAL, and this cost a cold install to learn.
//
// `winetricks -q vb6run` runs Microsoft's redistributable, and that installer creates a window even
// when told to be quiet. With no `DISPLAY` wine cannot load a graphics driver, the installer dies, and
// winetricks reports `returned status 43` — which names neither wine nor a display and reads like a
// corrupt download.
//
// This used to run BEFORE Xvfb started, because building a prefix looks like preparation rather than
// something that needs a screen. It never showed up here: this machine's prefix was seeded from an
// older one, so the build path only ever ran on a prefix that was already complete. The first genuine
// cold install — alpha — failed immediately.
const env = { ...WINE_ENV, DISPLAY: display };
// Before the early return as well: an existing prefix may predate this, or have had `z:` restored by a
// wine upgrade. It is a few `unlink` calls, so it is cheaper to re-assert than to detect.
if (prefixReady()) {
confinePrefix();
return { ok: true, detail: 'prefix ready' };
}
if (!Bun.which('wine')) {
return { ok: false, detail: 'wine is not installed. On Debian or Ubuntu this also needs i386 multiarch: `sudo dpkg --add-architecture i386 && sudo apt update && sudo apt install wine wine32:i386`' };
}
if (!Bun.which('winetricks')) {
return { ok: false, detail: 'winetricks is not installed, and it is what fetches the Visual Basic 6 runtime the game needs' };
}
if (!existsSync(WINEPREFIX)) {
log('creating the 32-bit wine prefix (first run only, this takes a minute)');
// `wineboot -i` rather than letting the first wine call create it implicitly: implicit creation happens
// inside the game launch, races the window-detection poll, and looks like the game failing to start.
const boot = await run(['wineboot', '-i'], env, 300_000);
if (!boot.ok && !existsSync(WINEPREFIX)) {
return { ok: false, detail: `wineboot failed: ${(boot.stderr || boot.stdout).trim().slice(-400)}` };
}
}
log('installing the Visual Basic 6 runtime into the prefix (downloads from Microsoft)');
const tricks = await run(['winetricks', '-q', 'vb6run'], env, 600_000);
// winetricks exits 0 having done nothing often enough that the exit code is not the answer. The file is.
if (!prefixReady()) {
return {
ok: false,
detail: `the VB6 runtime did not install; msvbvm60.dll is still missing. winetricks said: ${(tricks.stderr || tricks.stdout).trim().slice(-400)}`,
};
}
// Last, not first: `winetricks` runs wine, and wine recreates `z:` on the way past.
confinePrefix();
log('prefix ready and confined to drive C');
return { ok: true, detail: 'prefix built' };
}
/**
* Cut the prefix off from the rest of the machine.
*
* ── Why this exists, and why it is not optional ──
*
* A fresh wine prefix is not a sandbox. `wineboot` creates `dosdevices/z: -> /`, which maps the ENTIRE
* filesystem to a drive letter, and symlinks the profile's Documents, Downloads and Music straight into the
* owner's real home. Minesweeper X has a replay feature with Open and Save dialogs, so anyone looking at the
* display gets a file browser over all of that, running as the server owner.
*
* That is precisely why the platform's permission registry classified Minesweeper as `execution` — a kind
* that can never be granted to anyone. But **a plugin permission is always grantable**, by design and with
* no field to opt out of. So extracting this feature without also fixing the prefix would have converted a
* structurally ungrantable capability into one an owner could hand to a Member, complete with a file picker
* over the owner's home. The extraction is what made this urgent; the hole was always there.
*
* `docs/affordances.md` has the rule that applies: if it can be fixed instead of documented, fix it. A note
* saying "do not grant this" outlives everyone's memory of why.
*
* ── What is left ──
*
* `C:` alone, pointing at the prefix. The game keeps its own directory, its settings and its replays; it can
* see nothing else.
*
* ── It runs TWICE per start, and that is not belt-and-braces ──
*
* Wine rebuilds `dosdevices` when it boots a prefix it thinks needs updating, and a wine package upgrade is
* enough to make it think so. Observed directly: after a run, every `com*` link had come back with a fresh
* timestamp — so a confinement that happens only before wine starts is a confinement wine can undo.
*
* `z:` did not come back in that observation, and the device nodes are harmless. But "the dangerous one
* happened not to regenerate this time" is not a property to rely on, so `confineAndVerify` runs again once
* the game is up and says so loudly if `z:` returned. The second pass is a handful of `unlink` calls.
*/
/**
* Confine, then check it held. Returns whether `z:` had to be removed a second time — which means wine
* regenerated it and the first pass was not enough.
*/
export function confineAndVerify(log: (line: string) => void): boolean {
const z = join(WINEPREFIX, 'dosdevices', 'z:');
const regenerated = existsSync(z);
confinePrefix();
if (regenerated) {
// Not fatal — it has just been removed again, before anything could look at the display. Worth saying
// out loud because it means a wine upgrade landed, and the NEXT thing to check is whether the prefix
// still runs the game at all.
log('wine regenerated the Z: drive mapping during startup; removed it again');
}
return regenerated;
}
export function confinePrefix(): void {
const dosdevices = join(WINEPREFIX, 'dosdevices');
if (existsSync(dosdevices)) {
for (const entry of readdirSync(dosdevices)) {
// Keep `c:` and only `c:`. Everything else is a door out of the prefix or a device node.
if (entry.toLowerCase() === 'c:') continue;
rmSync(join(dosdevices, entry), { force: true, recursive: true });
}
}
const users = join(WINEPREFIX, 'drive_c', 'users');
if (!existsSync(users)) return;
for (const user of readdirSync(users)) {
const profile = join(users, user);
for (const folder of readdirSync(profile)) {
const path = join(profile, folder);
// A symlink here points into the real home. Replace it with an ordinary directory so the game still
// has somewhere to save a replay, and that somewhere is inside the prefix.
if (!lstatSync(path).isSymbolicLink()) continue;
rmSync(path, { force: true });
mkdirSync(path, { recursive: true });
}
}
}