import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import type { FullscreenOutcome } from './fullscreen.ts'; import { resolveXauthority, sessionEnv } from './x11.ts'; const run = promisify(execFile); export interface HotkeyRequest { /** Touche à envoyer : « f », « F11 », « space »… */ key: string; /** Fragment du titre de la fenêtre cible (insensible à la casse). */ windowMatch: string; } /** * Jeu de touches autorisé. Ces valeurs viennent de la configuration serveur et * finissent dans une ligne de commande : on refuse tout ce qui sort du lot * plutôt que d'échapper au cas par cas. */ const KEY_PATTERN = /^(?:[A-Za-z0-9]|F[1-9]|F1[0-2]|space|Return|Escape|Tab)$/; function assertKey(key: string): string { if (!KEY_PATTERN.test(key)) { throw new Error( `Touche « ${key} » non autorisée (lettres, chiffres, F1-F12, space, Return, Escape, Tab)`, ); } return key; } /** * Envoie une touche au lecteur vidéo, par exemple pour rebasculer en plein * écran après un show privé. * * Limite assumée : sous X11 comme sous Windows, la fenêtre cible doit passer au * premier plan. Les navigateurs ignorent les évènements clavier synthétiques * envoyés par XSendEvent (`xdotool key --window`), donc on active la fenêtre et * on injecte la touche via XTEST. Sur une VM d'enregistrement dédiée c'est sans * conséquence, mais ça vole le focus si quelqu'un est en train de s'en servir. */ export async function sendHotkey(request: HotkeyRequest): Promise { const key = assertKey(request.key); const match = request.windowMatch.trim(); if (!match) throw new Error('Aucun titre de fenêtre à cibler (windowMatch vide)'); switch (process.platform) { case 'linux': return sendLinux(key, match); case 'win32': return sendWindows(key, match); case 'darwin': return sendDarwin(key, match); default: throw new Error(`Envoi de touche non supporté sur ${process.platform}`); } } // --- X11 -------------------------------------------------------------------- interface VisibleWindow { id: string; title: string; } /** * Fenêtres techniques des compositeurs. Sous Wayland, le serveur XWayland n'en * expose qu'elles : les applications, elles, sont des clients Wayland natifs, * invisibles à xdotool. N'avoir qu'elles à l'écran est donc une signature, pas * un hasard — et le diagnostic doit le dire plutôt que de laisser croire à un * titre de fenêtre mal renseigné. */ const COMPOSITOR_WINDOWS = [/^mutter guard window$/i, /^gnome-shell$/i, /^kwin/i, /^plasmashell$/i]; export function looksLikeWayland(windows: VisibleWindow[]): boolean { return ( windows.length > 0 && windows.every((window) => COMPOSITOR_WINDOWS.some((pattern) => pattern.test(window.title))) ); } /** * 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(); const auth = resolveXauthority(); // « 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 : ${stderr}. Cookie d'autorisation ` + (auth ? `utilisé : ${auth} — il existe mais ne vaut pas pour cette session. ` + 'Ouvre puis referme une session graphique, ou redémarre le service.' : "introuvable : aucun des emplacements connus n'existe " + '(XAUTHORITY, /run/user//gdm/Xauthority, ~/.Xauthority). ' + "Une session graphique est-elle ouverte sur cette machine ?") ); } return null; } /** Fenêtres visibles portant un titre, avec leur identifiant. */ async function listVisibleWindows(env: NodeJS.ProcessEnv): Promise { let ids: string[]; try { // « . » : 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 fault = displayFault(err); if (fault) throw new Error(fault); return []; } const windows = await Promise.all( ids.map(async (id) => ({ id, title: await windowName(env, id) })), ); return windows.filter((window) => window.title !== ''); } async function windowName(env: NodeJS.ProcessEnv, id: string): Promise { try { const { stdout } = await run('xdotool', ['getwindowname', id], { env, timeout: 5000 }); return stdout.trim(); } catch { 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. } 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 = sessionEnv().DISPLAY; if (windows.length === 0) { return ( `Aucune fenêtre visible sur DISPLAY=${display}. L'agent ne voit pas ta session ` + "graphique : le navigateur tourne-t-il bien sur cette machine, dans la session de " + "l'utilisateur qui exécute l'agent ?" ); } if (looksLikeWayland(windows)) { const only = windows.map((window) => `« ${window.title} »`).join(', '); return ( `Session Wayland : côté X11, seule la fenêtre technique du compositeur est visible ` + `(${only}). Firefox y tourne en client Wayland natif — xdotool ne peut ni le voir ni ` + 'lui envoyer de touche, et aucun réglage ne changera cela. Passe le pilotage du ' + 'navigateur en mode « WebDriver BiDi » dans la configuration de cet agent : l\'agent ' + 'parle alors à Firefox directement, sans passer par le serveur d\'affichage.' ); } 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 = sessionEnv(); 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', target: target.title }; } // --- Windows ---------------------------------------------------------------- /** Échappe une valeur pour une chaîne littérale PowerShell entre apostrophes. */ function psLiteral(value: string): string { return `'${value.replace(/'/g, "''")}'`; } async function sendWindows(key: string, match: string): Promise { // SendKeys interprète certains caractères ; les touches nommées se notent {F11}. const sendKeysArg = key.length === 1 ? key.toLowerCase() : `{${key.toUpperCase()}}`; const script = ` $ErrorActionPreference = 'Stop' Add-Type @" using System; using System.Runtime.InteropServices; using System.Text; public class Win32 { [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd); [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); } "@ $needle = ${psLiteral(match)} $found = [IntPtr]::Zero $foundTitle = '' $callback = [Win32+EnumWindowsProc]{ param($hWnd, $lParam) if (-not [Win32]::IsWindowVisible($hWnd)) { return $true } $len = [Win32]::GetWindowTextLength($hWnd) if ($len -eq 0) { return $true } $sb = New-Object System.Text.StringBuilder($len + 1) [void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity) $title = $sb.ToString() if ($title -like ('*' + $needle + '*')) { $script:found = $hWnd; $script:foundTitle = $title; return $false } return $true } [void][Win32]::EnumWindows($callback, [IntPtr]::Zero) if ($script:found -eq [IntPtr]::Zero) { Write-Error ('Aucune fenetre ne correspond a ' + $needle); exit 1 } [void][Win32]::ShowWindow($script:found, 9) [void][Win32]::SetForegroundWindow($script:found) Start-Sleep -Milliseconds 300 Add-Type -AssemblyName System.Windows.Forms [System.Windows.Forms.SendKeys]::SendWait(${psLiteral(sendKeysArg)}) Write-Output $script:foundTitle `; // -EncodedCommand évite tout problème de guillemets entre cmd.exe et PowerShell. const encoded = Buffer.from(script, 'utf16le').toString('base64'); try { const { stdout } = await run( 'powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], { timeout: 20_000, windowsHide: true }, ); return { method: 'SendKeys', target: stdout.trim() || undefined }; } catch (err) { const stderr = (err as { stderr?: string }).stderr?.trim(); throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`); } } // --- macOS (confort de développement) --------------------------------------- async function sendDarwin(key: string, match: string): Promise { const script = ` tell application "System Events" set matches to (every process whose name contains "${match.replace(/["\\]/g, '')}") if (count of matches) = 0 then error "Aucun processus ne correspond" set target to item 1 of matches set frontmost of target to true delay 0.3 keystroke "${key.toLowerCase()}" end tell`; try { await run('osascript', ['-e', script], { timeout: 15_000 }); return { method: 'osascript', target: match }; } catch (err) { const stderr = (err as { stderr?: string }).stderr?.trim(); throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`); } }