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): Promise { // 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'], WINE_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'], WINE_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. Serial and parallel device nodes go too — a 1997 puzzle game has no business holding an * open descriptor on `/dev/ttyS0`. * * Re-asserted on every start rather than only at creation, because wine recreates `z:` whenever it decides * the prefix needs updating — a wine package upgrade is enough, and it would restore the hole silently. */ 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 }); } } }