diff --git a/README.md b/README.md index 0a416ae..4b9f607 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ L'envoi de touche se fait au niveau du système, pas via OBS. | OS | Mécanisme | À prévoir | | --- | --- | --- | | Ubuntu | `xdotool windowactivate` + XTEST | `apt install xdotool`, session **X11** (pas Wayland), `DISPLAY` accessible à l'agent | +| | | Le cookie X est résolu à l'exécution : `$XAUTHORITY`, puis `/run/user//gdm/Xauthority`, les cookies Xwayland, puis `~/.Xauthority` | | Windows | `SetForegroundWindow` + `SendKeys` | L'agent doit tourner dans la session interactive — d'où la tâche planifiée plutôt qu'un service | | macOS | AppleScript System Events | Autorisation Accessibilité (prévu pour le développement) | @@ -175,6 +176,21 @@ temps. Le bouton ⛶ sur la fiche de l'agent renvoie la touche à la demande, et ⟳ force une sonde immédiate — les deux servent à valider le titre de fenêtre sans attendre un vrai show privé. +Le **titre de fenêtre** à renseigner est celui de la fenêtre, pas le nom du processus. Sous +Firefox il vaut ` — Mozilla Firefox`, et le titre d'une page Stripchat se +termine par `| Stripchat` : `Stripchat` comme `Firefox` conviennent donc. En cas d'échec, +l'agent liste les fenêtres qu'il voit réellement, et `agent.cjs --check` fait de même sans +rien déclencher. + +Trois causes distinctes produisaient autrefois le même message « aucune fenêtre ne +correspond » ; elles sont maintenant séparées : + +| Symptôme | Cause | +| --- | --- | +| `Authorization required` / `Invalid MIT-MAGIC-COOKIE-1 key` | cookie X introuvable ou périmé | +| `Aucune fenêtre visible sur DISPLAY=:0` | session Wayland, ou navigateur hors session | +| Liste des fenêtres ouvertes | titre mal renseigné — recopier un fragment de la liste | + Si le rappel du plein écran s'avère fragile sur ta VM, l'alternative sans clavier est de lancer le navigateur en mode kiosque (`chromium --kiosk`) : il n'y a alors plus de plein écran à restaurer. diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh index 32eb167..88c8f9f 100644 --- a/deploy/install-agent.sh +++ b/deploy/install-agent.sh @@ -258,8 +258,12 @@ WorkingDirectory=$INSTALL_DIR Environment=NODE_ENV=production Environment=AGENT_CONFIG=$CONFIG_FILE # Accès à la session graphique, nécessaire au rappel plein écran via xdotool. +# XAUTHORITY n'est volontairement pas figé ici : son chemin dépend du +# gestionnaire de session (GDM le place sous /run/user/, Wayland lui donne +# un suffixe aléatoire) et change d'une session à l'autre. L'agent le résout à +# l'exécution. En déclarer un ici serait pire que rien : un ~/.Xauthority +# périmé, laissé par une ancienne session, serait retenu de préférence au bon. Environment=DISPLAY=:0 -Environment=XAUTHORITY=$USER_HOME/.Xauthority ExecStart=$(command -v node) $INSTALL_DIR/agent.cjs Restart=always RestartSec=5 @@ -275,12 +279,11 @@ systemctl restart "$SERVICE" # --- Vérification ----------------------------------------------------------- echo # Mêmes variables que l'unité systemd, sinon le diagnostic signale un DISPLAY -# absent que le service, lui, aura bien. +# absent que le service, lui, aura bien. Le cookie X est résolu par l'agent. CHECK_OK=true sudo -u "$RUN_USER" \ AGENT_CONFIG="$CONFIG_FILE" \ DISPLAY=":0" \ - XAUTHORITY="$USER_HOME/.Xauthority" \ "$(command -v node)" "$INSTALL_DIR/agent.cjs" --check || CHECK_OK=false sleep 2 diff --git a/packages/agent/src/browser.ts b/packages/agent/src/browser.ts index ed49920..726d31e 100644 --- a/packages/agent/src/browser.ts +++ b/packages/agent/src/browser.ts @@ -1,6 +1,7 @@ import { execFile, spawn } from 'node:child_process'; import { promisify } from 'node:util'; import type { AgentEvent, BrowserSettings } from '@stream-control/shared'; +import { x11Env } from './x11.ts'; const run = promisify(execFile); @@ -47,7 +48,7 @@ export async function openUrl( const child = spawn(settings.command, args, { detached: true, stdio: 'ignore', - env: { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' }, + env: x11Env(), }); return new Promise((resolve, reject) => { @@ -81,7 +82,7 @@ export async function closeWindow( throw new Error(`Fermeture de fenêtre non gérée sur ${process.platform}`); } - const env = { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' }; + const env = x11Env(); let ids: string[] = []; try { diff --git a/packages/agent/src/doctor.ts b/packages/agent/src/doctor.ts index 805e892..42a80f2 100644 --- a/packages/agent/src/doctor.ts +++ b/packages/agent/src/doctor.ts @@ -3,6 +3,7 @@ import net from 'node:net'; import { promisify } from 'node:util'; import { WebSocket } from 'ws'; import { CONFIG_PATH, type AgentConfig } from './config.ts'; +import { resolveDisplay, resolveXauthority, x11Env } from './x11.ts'; const run = promisify(execFile); @@ -101,7 +102,7 @@ function probeHandshake( * renseigné. Sans lui, les trois produisent le même message. */ async function reportVisibleWindows(display: string): Promise { - const env = { ...process.env, DISPLAY: display }; + const env = { ...x11Env(), DISPLAY: display }; let ids: string[] = []; try { @@ -219,16 +220,32 @@ export async function runDiagnostics(config: AgentConfig): Promise { ? line('ok', 'xdotool', 'installé') : line('warn', 'xdotool', 'absent — apt install xdotool'), ); - const display = process.env.DISPLAY; + const display = resolveDisplay(); results.push( - display - ? line('ok', 'DISPLAY', display) - : line('warn', 'DISPLAY', 'non défini — le rappel plein écran échouera'), + line( + 'ok', + 'DISPLAY', + process.env.DISPLAY?.trim() ? display : `${display} (déduit, DISPLAY non transmis)`, + ), ); + + // Le cookie est la cause n°1 d'un rappel plein écran en échec : un service + // systemd n'hérite pas de celui de la session graphique. + const xauthority = resolveXauthority(); + results.push( + xauthority + ? line('ok', 'XAUTHORITY', xauthority) + : line( + 'warn', + 'XAUTHORITY', + "aucun cookie trouvé — xdotool sera refusé par le serveur X", + ), + ); + if (process.env.WAYLAND_DISPLAY) { line('warn', 'session', 'Wayland détecté — xdotool exige X11'); } - if (hasXdotool && display) await reportVisibleWindows(display); + if (hasXdotool) 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 d9f3974..da8bd24 100644 --- a/packages/agent/src/hotkey.ts +++ b/packages/agent/src/hotkey.ts @@ -1,5 +1,6 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { resolveXauthority, x11Env } from './x11.ts'; const run = promisify(execFile); @@ -77,6 +78,7 @@ function displayFault(err: unknown): string | null { 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 ( @@ -85,9 +87,13 @@ function displayFault(err: unknown): string | null { ) ) { 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.' + `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; @@ -160,7 +166,7 @@ async function findWindow(env: NodeJS.ProcessEnv, match: string): Promise { - const env = { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' }; + const env = x11Env(); const target = await findWindow(env, match); await run('xdotool', ['windowactivate', '--sync', target.id], { env, timeout: 5000 }); diff --git a/packages/agent/src/x11.ts b/packages/agent/src/x11.ts new file mode 100644 index 0000000..b8a9a77 --- /dev/null +++ b/packages/agent/src/x11.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * Accès au serveur X depuis un agent lancé hors session graphique. + * + * Un service systemd n'hérite ni de `DISPLAY` ni du cookie d'autorisation, et le + * chemin de ce cookie dépend du gestionnaire de session : `~/.Xauthority` n'est + * plus la norme depuis longtemps. GDM le place sous `/run/user/`, et une + * session Wayland le nomme avec un suffixe aléatoire. + * + * Résoudre à l'exécution plutôt qu'à l'installation évite de figer dans l'unité + * systemd un chemin qui sera faux au prochain gestionnaire de session — ou dès + * la première session Wayland. + */ + +/** Premier candidat existant, ou null si aucun cookie n'est lisible. */ +export function resolveXauthority(): string | null { + for (const candidate of xauthorityCandidates()) { + try { + if (fs.statSync(candidate).isFile()) return candidate; + } catch { + /* candidat suivant */ + } + } + return null; +} + +function xauthorityCandidates(): string[] { + const candidates: string[] = []; + + // Une valeur explicite prime, mais seulement si le fichier existe : l'unité + // systemd en déclare une par défaut, qui peut très bien ne mener nulle part. + if (process.env.XAUTHORITY) candidates.push(process.env.XAUTHORITY); + + const runtime = runtimeDir(); + if (runtime) { + candidates.push(path.join(runtime, 'gdm', 'Xauthority')); + // Xwayland sous GNOME : `.mutter-Xwaylandauth.XXXXXX`, suffixe aléatoire. + try { + for (const entry of fs.readdirSync(runtime)) { + if (entry.startsWith('.mutter-Xwaylandauth')) candidates.push(path.join(runtime, entry)); + } + } catch { + /* répertoire illisible : on passe */ + } + } + + candidates.push(path.join(os.homedir(), '.Xauthority')); + return candidates; +} + +function runtimeDir(): string | null { + if (process.env.XDG_RUNTIME_DIR) return process.env.XDG_RUNTIME_DIR; + const uid = typeof process.getuid === 'function' ? process.getuid() : null; + return uid === null ? null : `/run/user/${uid}`; +} + +/** + * Numéro d'écran à utiliser. On préfère la valeur héritée, puis la socket X + * réellement présente — supposer `:0` échoue silencieusement sur une machine où + * la session tourne sur un autre écran. + */ +export function resolveDisplay(): string { + // Une chaîne vide n'est pas « non défini » pour `??` : on la traite comme telle. + const declared = process.env.DISPLAY?.trim(); + if (declared) return declared; + + try { + const sockets = fs + .readdirSync('/tmp/.X11-unix') + .filter((entry) => /^X\d+$/.test(entry)) + .map((entry) => Number(entry.slice(1))) + .sort((a, b) => a - b); + if (sockets.length > 0) return `:${sockets[0]}`; + } catch { + /* pas de socket listée : on retombe sur la valeur usuelle */ + } + return ':0'; +} + +/** Environnement d'exécution des outils graphiques (xdotool, navigateur). */ +export function x11Env(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, DISPLAY: resolveDisplay() }; + const auth = resolveXauthority(); + if (auth) env.XAUTHORITY = auth; + return env; +}