Fix : try FS 2
All checks were successful
release / build (push) Successful in 22s
release / verify-windows (push) Successful in 1m15s

This commit is contained in:
jeanotx32
2026-08-11 23:47:53 +02:00
parent f1a2066d56
commit 936c598013
6 changed files with 148 additions and 16 deletions

View File

@@ -164,6 +164,7 @@ L'envoi de touche se fait au niveau du système, pas via OBS.
| OS | Mécanisme | À prévoir | | OS | Mécanisme | À prévoir |
| --- | --- | --- | | --- | --- | --- |
| Ubuntu | `xdotool windowactivate` + XTEST | `apt install xdotool`, session **X11** (pas Wayland), `DISPLAY` accessible à l'agent | | 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/<uid>/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 | | 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) | | 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 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é. 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 `<titre de la page> — 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 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 lancer le navigateur en mode kiosque (`chromium --kiosk`) : il n'y a alors plus de plein
écran à restaurer. écran à restaurer.

View File

@@ -258,8 +258,12 @@ WorkingDirectory=$INSTALL_DIR
Environment=NODE_ENV=production Environment=NODE_ENV=production
Environment=AGENT_CONFIG=$CONFIG_FILE Environment=AGENT_CONFIG=$CONFIG_FILE
# Accès à la session graphique, nécessaire au rappel plein écran via xdotool. # 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/<uid>, 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=DISPLAY=:0
Environment=XAUTHORITY=$USER_HOME/.Xauthority
ExecStart=$(command -v node) $INSTALL_DIR/agent.cjs ExecStart=$(command -v node) $INSTALL_DIR/agent.cjs
Restart=always Restart=always
RestartSec=5 RestartSec=5
@@ -275,12 +279,11 @@ systemctl restart "$SERVICE"
# --- Vérification ----------------------------------------------------------- # --- Vérification -----------------------------------------------------------
echo echo
# Mêmes variables que l'unité systemd, sinon le diagnostic signale un DISPLAY # 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 CHECK_OK=true
sudo -u "$RUN_USER" \ sudo -u "$RUN_USER" \
AGENT_CONFIG="$CONFIG_FILE" \ AGENT_CONFIG="$CONFIG_FILE" \
DISPLAY=":0" \ DISPLAY=":0" \
XAUTHORITY="$USER_HOME/.Xauthority" \
"$(command -v node)" "$INSTALL_DIR/agent.cjs" --check || CHECK_OK=false "$(command -v node)" "$INSTALL_DIR/agent.cjs" --check || CHECK_OK=false
sleep 2 sleep 2

View File

@@ -1,6 +1,7 @@
import { execFile, spawn } from 'node:child_process'; import { execFile, spawn } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import type { AgentEvent, BrowserSettings } from '@stream-control/shared'; import type { AgentEvent, BrowserSettings } from '@stream-control/shared';
import { x11Env } from './x11.ts';
const run = promisify(execFile); const run = promisify(execFile);
@@ -47,7 +48,7 @@ export async function openUrl(
const child = spawn(settings.command, args, { const child = spawn(settings.command, args, {
detached: true, detached: true,
stdio: 'ignore', stdio: 'ignore',
env: { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' }, env: x11Env(),
}); });
return new Promise((resolve, reject) => { 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}`); 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[] = []; let ids: string[] = [];
try { try {

View File

@@ -3,6 +3,7 @@ import net from 'node:net';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
import { CONFIG_PATH, type AgentConfig } from './config.ts'; import { CONFIG_PATH, type AgentConfig } from './config.ts';
import { resolveDisplay, resolveXauthority, x11Env } from './x11.ts';
const run = promisify(execFile); const run = promisify(execFile);
@@ -101,7 +102,7 @@ function probeHandshake(
* renseigné. Sans lui, les trois produisent le même message. * renseigné. Sans lui, les trois produisent le même message.
*/ */
async function reportVisibleWindows(display: string): Promise<void> { async function reportVisibleWindows(display: string): Promise<void> {
const env = { ...process.env, DISPLAY: display }; const env = { ...x11Env(), DISPLAY: display };
let ids: string[] = []; let ids: string[] = [];
try { try {
@@ -219,16 +220,32 @@ export async function runDiagnostics(config: AgentConfig): Promise<number> {
? line('ok', 'xdotool', 'installé') ? line('ok', 'xdotool', 'installé')
: line('warn', 'xdotool', 'absent — apt install xdotool'), : line('warn', 'xdotool', 'absent — apt install xdotool'),
); );
const display = process.env.DISPLAY; const display = resolveDisplay();
results.push( results.push(
display line(
? line('ok', 'DISPLAY', display) 'ok',
: line('warn', 'DISPLAY', 'non défini — le rappel plein écran échouera'), '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) { if (process.env.WAYLAND_DISPLAY) {
line('warn', 'session', 'Wayland détecté — xdotool exige X11'); 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') { } else if (process.platform === 'win32') {
const hasPowershell = await commandExists('powershell.exe', ['-NoProfile', '-Command', 'exit']); const hasPowershell = await commandExists('powershell.exe', ['-NoProfile', '-Command', 'exit']);
results.push( results.push(

View File

@@ -1,5 +1,6 @@
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { resolveXauthority, x11Env } from './x11.ts';
const run = promisify(execFile); const run = promisify(execFile);
@@ -77,6 +78,7 @@ function displayFault(err: unknown): string | null {
return 'xdotool est absent — installe-le : apt install xdotool'; return 'xdotool est absent — installe-le : apt install xdotool';
} }
const stderr = String((err as { stderr?: string }).stderr ?? '').trim(); 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 // « 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. // sur le mauvais fichier — le cas le plus courant sous GDM.
if ( if (
@@ -85,9 +87,13 @@ function displayFault(err: unknown): string | null {
) )
) { ) {
return ( return (
`xdotool ne peut pas ouvrir l'affichage DISPLAY=${process.env.DISPLAY ?? ':0'} : ${stderr}. ` + `xdotool ne peut pas ouvrir l'affichage : ${stderr}. Cookie d'autorisation ` +
"Vérifie XAUTHORITY dans l'unité systemd — sous GDM le fichier n'est pas " + (auth
'~/.Xauthority mais /run/user/<uid>/gdm/Xauthority.' ? `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/<uid>/gdm/Xauthority, ~/.Xauthority). ' +
"Une session graphique est-elle ouverte sur cette machine ?")
); );
} }
return null; return null;
@@ -160,7 +166,7 @@ async function findWindow(env: NodeJS.ProcessEnv, match: string): Promise<Visibl
} }
function describeNoMatch(match: string, windows: VisibleWindow[]): string { function describeNoMatch(match: string, windows: VisibleWindow[]): string {
const display = process.env.DISPLAY ?? ':0'; const display = x11Env().DISPLAY;
if (windows.length === 0) { if (windows.length === 0) {
return ( return (
@@ -180,7 +186,7 @@ function describeNoMatch(match: string, windows: VisibleWindow[]): string {
} }
async function sendLinux(key: string, match: string): Promise<HotkeyResult> { async function sendLinux(key: string, match: string): Promise<HotkeyResult> {
const env = { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' }; const env = x11Env();
const target = await findWindow(env, match); const target = await findWindow(env, match);
await run('xdotool', ['windowactivate', '--sync', target.id], { env, timeout: 5000 }); await run('xdotool', ['windowactivate', '--sync', target.id], { env, timeout: 5000 });

89
packages/agent/src/x11.ts Normal file
View File

@@ -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/<uid>`, 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;
}