diff --git a/sidecar/menu.ts b/sidecar/menu.ts index 52cf8e8..90f4ba7 100644 --- a/sidecar/menu.ts +++ b/sidecar/menu.ts @@ -60,6 +60,34 @@ export function currentDifficulty(window: WindowGeometry): Difficulty | null { return null; } +/** + * Any window that is not the game itself — a dialog, and therefore something in the way. + * + * This guard exists because of what happens WITHOUT it, which is worse than a failed command. 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. Retrying does it three times. On a live game + * that reveals cells and can lose it. + * + * Found the honest way: `POST /difficulty` failed three times against a "you have the fastest time" + * prompt that had been waiting for a name, and the fix is to refuse rather than to click harder. + * + * Named windows only. wine litters the display with 1x1 stubs, and the game's own title is the one thing + * that is never a dialog. + */ +export async function blockingDialog(display: string): Promise { + const found = await run(['xdotool', 'search', '--name', '.'], { DISPLAY: display }, 5_000); + if (!found.ok) return null; + + for (const id of found.stdout.trim().split('\n').filter(Boolean)) { + const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY: display }, 5_000); + if (!geom.ok || Number(/^WIDTH=(\d+)$/m.exec(geom.stdout)?.[1] ?? 1) <= 1) continue; + const name = await run(['xdotool', 'getwindowname', id], { DISPLAY: display }, 5_000); + const title = name.stdout.trim(); + if (title && title !== 'Minesweeper X') return title; + } + return null; +} + export type SelectResult = | { ok: true; level: Difficulty; changed: boolean } | { ok: false; reason: string; window: WindowGeometry | null }; @@ -76,6 +104,11 @@ export async function selectDifficulty(level: Difficulty, display: string): Prom const spec = DIFFICULTIES[level]; return withStableWindow(async () => { + const blocked = await blockingDialog(display); + if (blocked) { + return { ok: false, reason: `"${blocked}" is open on the board — dismiss it first`, window: null }; + } + let last: WindowGeometry | null = null; for (let attempt = 0; attempt < 3; attempt++) { @@ -125,6 +158,9 @@ export type PlayResult = { ok: true; file: string } | { ok: false; reason: strin */ export async function playReplay(file: string, display: string): Promise { return withStableWindow(async () => { + const blocked = await blockingDialog(display); + if (blocked) return { ok: false, reason: `"${blocked}" is open on the board — dismiss it first` }; + const win = await findWindow(); if (!win) return { ok: false, reason: 'the game window is not there' };