diff --git a/README.md b/README.md index d583163..dd78f11 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,27 @@ L'agent évite donc les deux : Les agents configurés avant ce correctif sont migrés au démarrage du serveur, sauf si leurs arguments ou leur comportement d'arrêt ont été personnalisés. +### « Firefox est déjà ouvert » + +Ce dialogue signifie que le processus lancé par l'agent n'a pas trouvé l'instance déjà en +cours : il bute alors sur le verrou de profil au lieu de lui confier l'URL. + +Le passage de relais se fait par le **bus de session D-Bus** — le seul mécanisme disponible +sous Wayland, le protocole X de remoting n'y existant pas. Or un service systemd « system » +n'hérite pas de ce bus, pas plus qu'il n'hérite du cookie X. L'agent résout donc lui-même, +à chaque lancement : + +| Variable | Origine | +| --- | --- | +| `DISPLAY` | valeur héritée, sinon la socket X présente dans `/tmp/.X11-unix` | +| `XAUTHORITY` | `$XAUTHORITY` s'il existe, puis GDM, Xwayland, `~/.Xauthority` | +| `XDG_RUNTIME_DIR` | valeur héritée, sinon `/run/user/` | +| `DBUS_SESSION_BUS_ADDRESS` | valeur héritée, sinon la socket `$XDG_RUNTIME_DIR/bus` | + +`agent.cjs --check` affiche les quatre. Si le bus ressort en avertissement, vérifie que +l'agent tourne bien sous **le même compte** que la session graphique : l'utilisateur est +choisi par `--user` à l'installation. + ### Éviter l'accumulation d'onglets Avec les réglages d'usine de Firefox, un lien venu de l'extérieur ouvre un **nouvel diff --git a/packages/agent/src/browser.ts b/packages/agent/src/browser.ts index 7bdb4a8..d0be16b 100644 --- a/packages/agent/src/browser.ts +++ b/packages/agent/src/browser.ts @@ -1,7 +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'; +import { sessionEnv } from './x11.ts'; const run = promisify(execFile); @@ -9,6 +9,22 @@ export interface BrowserLog { (level: 'info' | 'warn' | 'error', message: string, event?: AgentEvent): void; } +export interface LaunchOptions { + /** + * Forcer XWayland. Une fenêtre Wayland native est invisible à xdotool, donc + * impossible à basculer en plein écran par une touche. On ne l'impose que + * lorsque ce rappel est réellement demandé : sous Wayland, XWayland ajoute une + * copie d'image dont une capture par rognage OBS n'a que faire. + */ + forceXWayland?: boolean; +} + +function launchEnv(options: LaunchOptions): NodeJS.ProcessEnv { + const env = sessionEnv(); + if (options.forceXWayland) env.MOZ_ENABLE_WAYLAND = '0'; + return env; +} + /** * Seules les URL http(s) sont acceptées. Elles finissent en argument de * processus — jamais dans un shell, donc pas d'injection possible — mais un @@ -39,6 +55,7 @@ export async function openUrl( settings: BrowserSettings, url: string, log: BrowserLog, + options: LaunchOptions = {}, ): Promise<{ command: string; url: string }> { const target = assertWebUrl(url); const args = [...settings.args, target]; @@ -48,12 +65,7 @@ export async function openUrl( const child = spawn(settings.command, args, { detached: true, stdio: 'ignore', - // Sous une session Wayland, une fenêtre Firefox native est invisible à - // xdotool : ni activation, ni touche, donc pas de plein écran. XWayland la - // rend pilotable, sans rien changer à la lecture vidéo. Sans effet sur une - // session X11, et sans effet non plus si une instance Firefox tourne déjà — - // elle récupérerait l'URL avec ses propres variables d'environnement. - env: { ...x11Env(), MOZ_ENABLE_WAYLAND: '0' }, + env: launchEnv(options), }); return new Promise((resolve, reject) => { @@ -85,6 +97,7 @@ export async function openUrl( export async function blankPage( settings: BrowserSettings, log: BrowserLog, + options: LaunchOptions = {}, ): Promise<{ command: string; url: string }> { const args = [...settings.args, 'about:blank']; log('info', 'Lecteur déchargé (page vide) — fenêtre conservée pour OBS', 'browser.closed'); @@ -92,7 +105,7 @@ export async function blankPage( const child = spawn(settings.command, args, { detached: true, stdio: 'ignore', - env: { ...x11Env(), MOZ_ENABLE_WAYLAND: '0' }, + env: launchEnv(options), }); return new Promise((resolve, reject) => { @@ -118,7 +131,7 @@ export async function closeWindow( throw new Error(`Fermeture de fenêtre non gérée sur ${process.platform}`); } - const env = x11Env(); + const env = sessionEnv(); let ids: string[] = []; try { diff --git a/packages/agent/src/doctor.ts b/packages/agent/src/doctor.ts index 8ed3ca6..80cd56a 100644 --- a/packages/agent/src/doctor.ts +++ b/packages/agent/src/doctor.ts @@ -4,7 +4,7 @@ import { promisify } from 'node:util'; import { WebSocket } from 'ws'; import { CONFIG_PATH, type AgentConfig } from './config.ts'; import { looksLikeWayland } from './hotkey.ts'; -import { resolveDisplay, resolveXauthority, x11Env } from './x11.ts'; +import { resolveDisplay, resolveSessionBus, resolveXauthority, sessionEnv } from './x11.ts'; const run = promisify(execFile); @@ -103,7 +103,7 @@ function probeHandshake( * renseigné. Sans lui, les trois produisent le même message. */ async function reportVisibleWindows(display: string): Promise { - const env = { ...x11Env(), DISPLAY: display }; + const env = { ...sessionEnv(), DISPLAY: display }; let ids: string[] = []; try { @@ -253,6 +253,19 @@ export async function runDiagnostics(config: AgentConfig): Promise { ), ); + // Sans le bus de session, Firefox ne trouve pas l'instance déjà lancée et + // affiche « Firefox est déjà ouvert » au lieu d'ouvrir la page. + const bus = resolveSessionBus(); + results.push( + bus + ? line('ok', 'bus session', bus) + : line( + 'warn', + 'bus session', + "introuvable — Firefox affichera « déjà ouvert » au lieu d'ouvrir la page", + ), + ); + if (process.env.WAYLAND_DISPLAY) { line('warn', 'session', 'Wayland détecté — xdotool exige X11'); } diff --git a/packages/agent/src/hotkey.ts b/packages/agent/src/hotkey.ts index 1a3775e..c8238df 100644 --- a/packages/agent/src/hotkey.ts +++ b/packages/agent/src/hotkey.ts @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { resolveXauthority, x11Env } from './x11.ts'; +import { resolveXauthority, sessionEnv } from './x11.ts'; const run = promisify(execFile); @@ -182,7 +182,7 @@ async function findWindow(env: NodeJS.ProcessEnv, match: string): Promise { - const env = x11Env(); + const env = sessionEnv(); const target = await findWindow(env, match); await run('xdotool', ['windowactivate', '--sync', target.id], { env, timeout: 5000 }); diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 24f6ad5..002ce55 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -238,7 +238,9 @@ async function startCapture(params: Record): Promise { } const url = requireUrl(params); - const opened = await openUrl(browserSettings, url, report); + const opened = await openUrl(browserSettings, url, report, { + forceXWayland: watcher.snapshotSettings.fullscreen.enabled, + }); const wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs); report('info', `Attente de ${Math.round(wait / 1000)} s avant le plein écran`); @@ -275,7 +277,9 @@ async function stopCapture(): Promise { const action = browserSettings.onStop === 'close' ? closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report) - : blankPage(browserSettings, report); + : blankPage(browserSettings, report, { + forceXWayland: watcher.snapshotSettings.fullscreen.enabled, + }); closed = await action.catch((err: Error) => { report('warn', `Libération de la fenêtre impossible : ${err.message}`, 'command.failed'); return `échec : ${err.message}`; @@ -297,7 +301,9 @@ async function runAction(action: AgentAction, params: Record): case 'hotkey.fullscreen': return watcher.restoreFullscreen(); case 'browser.open': - return openUrl(browserSettings, requireUrl(params), report); + return openUrl(browserSettings, requireUrl(params), report, { + forceXWayland: watcher.snapshotSettings.fullscreen.enabled, + }); case 'browser.close': return closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report); case 'capture.start': diff --git a/packages/agent/src/x11.ts b/packages/agent/src/x11.ts index b8a9a77..651f6a6 100644 --- a/packages/agent/src/x11.ts +++ b/packages/agent/src/x11.ts @@ -80,10 +80,47 @@ export function resolveDisplay(): string { return ':0'; } -/** Environnement d'exécution des outils graphiques (xdotool, navigateur). */ -export function x11Env(): NodeJS.ProcessEnv { +/** + * Bus de session de l'utilisateur. + * + * Firefox s'en sert pour confier une URL à l'instance déjà lancée. Sans lui, le + * nouveau processus ne la trouve pas, bute sur le verrou de profil et affiche + * « Firefox est déjà ouvert » au lieu d'ouvrir la page — c'est le mécanisme + * retenu sous Wayland, où le protocole X de remoting n'existe pas. + */ +export function resolveSessionBus(): string | null { + if (process.env.DBUS_SESSION_BUS_ADDRESS) return process.env.DBUS_SESSION_BUS_ADDRESS; + + const runtime = runtimeDir(); + if (!runtime) return null; + + const socket = path.join(runtime, 'bus'); + try { + if (fs.statSync(socket).isSocket()) return `unix:path=${socket}`; + } catch { + /* pas de bus de session accessible */ + } + return null; +} + +/** + * Environnement d'exécution des outils de session (xdotool, navigateur). + * + * Un service systemd « system » n'hérite de rien de la session graphique : ni + * écran, ni cookie d'autorisation, ni bus. Les trois se résolvent ici, à chaque + * appel, pour survivre à un changement de session sans réinstallation. + */ +export function sessionEnv(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, DISPLAY: resolveDisplay() }; + const auth = resolveXauthority(); if (auth) env.XAUTHORITY = auth; + + const runtime = runtimeDir(); + if (runtime) env.XDG_RUNTIME_DIR = runtime; + + const bus = resolveSessionBus(); + if (bus) env.DBUS_SESSION_BUS_ADDRESS = bus; + return env; }