diff --git a/packages/agent/src/doctor.ts b/packages/agent/src/doctor.ts index 6300976..805e892 100644 --- a/packages/agent/src/doctor.ts +++ b/packages/agent/src/doctor.ts @@ -92,6 +92,57 @@ function probeHandshake( }); } +/** + * Énumère ce que l'agent voit réellement de la session graphique. + * + * C'est le seul contrôle qui distingue les trois causes d'un rappel plein écran + * en échec : xdotool sans accès à l'affichage, affichage accessible mais vide + * (fenêtres Wayland natives, invisibles à xdotool), ou titre de fenêtre mal + * renseigné. Sans lui, les trois produisent le même message. + */ +async function reportVisibleWindows(display: string): Promise { + const env = { ...process.env, DISPLAY: display }; + let ids: string[] = []; + + try { + const { stdout } = await run('xdotool', ['search', '--onlyvisible', '--name', '.'], { + env, + timeout: 5000, + }); + ids = stdout.split('\n').map((entry) => entry.trim()).filter(Boolean); + } catch (err) { + const stderr = String((err as { stderr?: string }).stderr ?? '').trim(); + if (stderr) { + line('warn', 'fenêtres', `xdotool en échec : ${stderr}`); + return; + } + // Code 1 sans message : l'affichage répond, il n'y a simplement rien. + } + + if (ids.length === 0) { + line( + 'warn', + 'fenêtres', + 'aucune fenêtre visible — session Wayland (fenêtres invisibles à xdotool) ' + + "ou navigateur lancé hors de cette session ?", + ); + return; + } + + const titles: string[] = []; + for (const id of ids.slice(0, 12)) { + try { + const { stdout } = await run('xdotool', ['getwindowname', id], { env, timeout: 5000 }); + if (stdout.trim()) titles.push(stdout.trim()); + } catch { + /* titre indisponible, sans importance ici */ + } + } + + line('ok', 'fenêtres', `${ids.length} visible(s)`); + for (const title of titles) console.log(` · ${title}`); +} + async function commandExists(command: string, args: string[]): Promise { try { await run(command, args, { timeout: 8000, windowsHide: true }); @@ -177,6 +228,7 @@ export async function runDiagnostics(config: AgentConfig): Promise { if (process.env.WAYLAND_DISPLAY) { line('warn', 'session', 'Wayland détecté — xdotool exige X11'); } + if (hasXdotool && display) await reportVisibleWindows(display); } else if (process.platform === 'win32') { const hasPowershell = await commandExists('powershell.exe', ['-NoProfile', '-Command', 'exit']); results.push( diff --git a/packages/agent/src/hotkey.ts b/packages/agent/src/hotkey.ts index 75ef0f1..d9f3974 100644 --- a/packages/agent/src/hotkey.ts +++ b/packages/agent/src/hotkey.ts @@ -60,42 +60,133 @@ export async function sendHotkey(request: HotkeyRequest): Promise // --- X11 -------------------------------------------------------------------- -async function sendLinux(key: string, match: string): Promise { - const env = { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' }; +interface VisibleWindow { + id: string; + title: string; +} +/** + * Traduit un échec de xdotool en cause identifiable. + * + * xdotool sort en code 1 aussi bien quand rien ne correspond que quand il ne + * peut pas ouvrir l'affichage. Confondre les deux envoyait l'opérateur corriger + * un titre de fenêtre parfaitement valide. + */ +function displayFault(err: unknown): string | null { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return 'xdotool est absent — installe-le : apt install xdotool'; + } + const stderr = String((err as { stderr?: string }).stderr ?? '').trim(); + // « Invalid MIT-MAGIC-COOKIE-1 key » est le symptôme d'un XAUTHORITY qui pointe + // sur le mauvais fichier — le cas le plus courant sous GDM. + if ( + /can't open display|no protocol specified|authorization required|bad display|magic-cookie/i.test( + stderr, + ) + ) { + return ( + `xdotool ne peut pas ouvrir l'affichage DISPLAY=${process.env.DISPLAY ?? ':0'} : ${stderr}. ` + + "Vérifie XAUTHORITY dans l'unité systemd — sous GDM le fichier n'est pas " + + '~/.Xauthority mais /run/user//gdm/Xauthority.' + ); + } + return null; +} + +/** Fenêtres visibles portant un titre, avec leur identifiant. */ +async function listVisibleWindows(env: NodeJS.ProcessEnv): Promise { let ids: string[]; try { - const { stdout } = await run( - 'xdotool', - ['search', '--onlyvisible', '--name', match], - { env, timeout: 5000 }, - ); - ids = stdout.split('\n').map((line) => line.trim()).filter(Boolean); + // « . » : toute fenêtre dont le titre contient au moins un caractère. + const { stdout } = await run('xdotool', ['search', '--onlyvisible', '--name', '.'], { + env, + timeout: 5000, + }); + ids = stdout.split('\n').map((line) => line.trim()).filter(Boolean).slice(0, 60); } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT') { - throw new Error('xdotool est absent — installe-le : apt install xdotool'); - } - // xdotool sort en code 1 quand rien ne correspond. - throw new Error(`Aucune fenêtre visible ne correspond à « ${match} »`); + const fault = displayFault(err); + if (fault) throw new Error(fault); + return []; } - if (ids.length === 0) throw new Error(`Aucune fenêtre visible ne correspond à « ${match} »`); - // La dernière fenêtre listée est en général la plus récemment mappée. - const windowId = ids[ids.length - 1]!; + const windows = await Promise.all( + ids.map(async (id) => ({ id, title: await windowName(env, id) })), + ); + return windows.filter((window) => window.title !== ''); +} - let title = windowId; +async function windowName(env: NodeJS.ProcessEnv, id: string): Promise { try { - const { stdout } = await run('xdotool', ['getwindowname', windowId], { env, timeout: 5000 }); - title = stdout.trim() || windowId; + const { stdout } = await run('xdotool', ['getwindowname', id], { env, timeout: 5000 }); + return stdout.trim(); } catch { - /* le titre n'est qu'informatif */ + return ''; + } +} + +/** + * Localise la fenêtre cible. + * + * Deux passes : on laisse d'abord xdotool filtrer lui-même, puis, s'il ne trouve + * rien, on énumère et on compare en JavaScript. La seconde passe ne dépend ni de + * la syntaxe d'expression régulière de xdotool ni de sa sensibilité à la casse, + * et elle fournit la liste des fenêtres réellement présentes — de loin + * l'information la plus utile quand ça échoue. + */ +async function findWindow(env: NodeJS.ProcessEnv, match: string): Promise { + try { + const { stdout } = await run('xdotool', ['search', '--onlyvisible', '--name', match], { + env, + timeout: 5000, + }); + const ids = stdout.split('\n').map((line) => line.trim()).filter(Boolean); + if (ids.length > 0) { + // La dernière listée est en général la plus récemment mappée. + const id = ids[ids.length - 1]!; + return { id, title: (await windowName(env, id)) || id }; + } + } catch (err) { + const fault = displayFault(err); + if (fault) throw new Error(fault); + // Sinon : simple absence de correspondance, la seconde passe prend le relais. } - await run('xdotool', ['windowactivate', '--sync', windowId], { env, timeout: 5000 }); + const windows = await listVisibleWindows(env); + const needle = match.toLowerCase(); + const hit = [...windows].reverse().find((window) => window.title.toLowerCase().includes(needle)); + if (hit) return hit; + + throw new Error(describeNoMatch(match, windows)); +} + +function describeNoMatch(match: string, windows: VisibleWindow[]): string { + const display = process.env.DISPLAY ?? ':0'; + + if (windows.length === 0) { + return ( + `Aucune fenêtre visible sur DISPLAY=${display}. L'agent ne voit pas ta session ` + + 'graphique : soit le navigateur tourne dans une autre session, soit la session est ' + + 'en Wayland — une fenêtre Wayland native est invisible à xdotool. ' + + 'Lance Firefox avec MOZ_ENABLE_WAYLAND=0, ou ouvre une session Xorg.' + ); + } + + const shown = windows.slice(-12).map((window) => `« ${window.title} »`).join(', '); + const more = windows.length > 12 ? ` (+${windows.length - 12} autres)` : ''; + return ( + `Aucune fenêtre visible ne correspond à « ${match} ». Fenêtres ouvertes : ${shown}${more}. ` + + "Reporte un fragment de l'une d'elles dans « Titre de la fenêtre du lecteur »." + ); +} + +async function sendLinux(key: string, match: string): Promise { + const env = { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' }; + const target = await findWindow(env, match); + + await run('xdotool', ['windowactivate', '--sync', target.id], { env, timeout: 5000 }); await run('xdotool', ['key', '--clearmodifiers', key], { env, timeout: 5000 }); - return { method: 'xdotool', window: title }; + return { method: 'xdotool', window: target.title }; } // --- Windows ----------------------------------------------------------------