diff --git a/README.md b/README.md index 57b5ff9..0aa6a54 100644 --- a/README.md +++ b/README.md @@ -193,8 +193,7 @@ Séquence du rappel de plein écran, dans cet ordre volontaire : **Un profil Firefox dédié est obligatoire**, pas cosmétique : le port de pilotage ne s'ouvre qu'au démarrage du processus, et deux instances ne peuvent pas partager un profil. -L'agent en gère un sous `~/.stream-control/firefox-profile` et y réécrit un `user.js` à -chaque lancement — chaque préférence y supprime quelque chose qui finirait dans le fichier +L'agent en gère un et y réécrit un `user.js` à chaque lancement — chaque préférence y supprime quelque chose qui finirait dans le fichier enregistré, ou qui empêcherait un démarrage sans surveillance : | Préférence | Pourquoi | @@ -205,6 +204,26 @@ enregistré, ou qui empêcherait un démarrage sans surveillance : | `browser.sessionstore.resume_from_crash = false` | un dialogue modal bloquerait toute commande | | `app.update.auto = false` | une mise à jour fermerait la fenêtre que capture OBS, en plein enregistrement | +#### Firefox en snap (Ubuntu) + +Sur Ubuntu, `firefox` est un snap, et cela impose deux choses. + +L'interface `home` d'un snap **exclut délibérément les fichiers et répertoires cachés** — +ceux commençant par un point, réputés contenir des données sensibles. Un profil sous +`~/.stream-control/` y est donc inaccessible, et Firefox meurt sans jamais ouvrir son port. +L'agent détecte le paquet snap en suivant la chaîne de liens de la commande +(`/usr/bin/firefox` → `/snap/bin/firefox` → `/usr/bin/snap`) et place alors son profil sous +`~/snap/firefox/common/stream-control-profile`. Un profil explicitement configuré dans un +répertoire caché est refusé **avant** le lancement, avec l'explication. + +Par ailleurs, `firefox` n'est ici qu'un lanceur : il passe la main au snap et **sort +aussitôt en code 0** pendant que le vrai navigateur démarre derrière. L'agent ne traite donc +qu'une sortie non nulle comme un échec ; sinon il abandonnerait le cas nominal au bout de +quelques centaines de millisecondes. + +La sortie de Firefox est redirigée vers `/firefox.log`, et ses dernières lignes +accompagnent tout échec de démarrage — c'est le seul endroit où il explique son refus. + **Au premier passage en BiDi, une nouvelle fenêtre Firefox s'ouvre** : pointe la source « capture de fenêtre » d'OBS dessus une fois. Ensuite elle survit aux enregistrements comme aux redémarrages de l'agent — le processus est lancé détaché, et l'agent se diff --git a/packages/agent/src/doctor.ts b/packages/agent/src/doctor.ts index 2b579d6..8cc731a 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 { defaultProfileDir, isSnapFirefox } from './firefox.ts'; import { looksLikeWayland } from './hotkey.ts'; import { resolveDisplay, @@ -284,11 +285,17 @@ export async function runDiagnostics(config: AgentConfig): Promise { // Firefox est le seul navigateur que le mode BiDi sache piloter : son // absence rend ce mode inutilisable, quel que soit le reste. - results.push( - (await commandExists('firefox', ['--version'])) - ? line('ok', 'firefox', 'installé — pilotage WebDriver BiDi possible') - : line('warn', 'firefox', 'absent — le mode de pilotage « WebDriver BiDi » échouera'), - ); + if (await commandExists('firefox', ['--version'])) { + const snap = isSnapFirefox('firefox'); + results.push(line('ok', 'firefox', snap ? 'installé (paquet snap)' : 'installé')); + // Le confinement décide de l'emplacement du profil : un snap ne peut pas + // lire un répertoire caché du dossier personnel. + line('ok', 'profil', `${defaultProfileDir('firefox')}${snap ? ' (hors répertoire caché)' : ''}`); + } else { + results.push( + line('warn', 'firefox', 'absent — le mode de pilotage « WebDriver BiDi » échouera'), + ); + } // Port par défaut : le vrai vient de la configuration serveur, que ce // diagnostic hors ligne ne connaît pas. diff --git a/packages/agent/src/firefox.ts b/packages/agent/src/firefox.ts index b80619e..1ac75f1 100644 --- a/packages/agent/src/firefox.ts +++ b/packages/agent/src/firefox.ts @@ -265,6 +265,12 @@ export class FirefoxController { private async launch(): Promise { const profile = this.profileFor(this.settings); + + // Vérifié avant de lancer : sous confinement snap, l'échec se manifesterait + // sinon par 45 s d'attente et un port qui ne s'ouvre jamais. + const blocked = profileRefusedBySnap(this.settings.command, profile); + if (blocked) throw new Error(blocked); + writeProfilePrefs(profile); const args = [ @@ -280,14 +286,21 @@ export class FirefoxController { this.log('info', `Lancement de Firefox piloté (profil ${profile})`); + // Sortie redirigée vers un fichier plutôt qu'ignorée : c'est le seul endroit + // où Firefox explique pourquoi il refuse de démarrer, et un tube se + // romprait au redémarrage de l'agent — le processus, lui, est détaché. + const logPath = path.join(profile, 'firefox.log'); + const logFd = fs.openSync(logPath, 'w'); + // Détaché : la fenêtre doit survivre à un redémarrage de l'agent, sinon la // source de capture d'OBS disparaît avec elle. const child = spawn(this.settings.command, args, { detached: true, - stdio: 'ignore', + stdio: ['ignore', logFd, logFd], env: sessionEnv(), }); this.child = child; + fs.closeSync(logFd); // Ce que le processus a déjà démenti. Interrogé à chaque tentative de // connexion, il évite d'attendre 45 s un port que plus personne n'ouvrira. @@ -300,21 +313,22 @@ export class FirefoxController { }); child.once('exit', (code) => { if (this.child === child) this.child = null; - // Sortie immédiate avec le port jamais ouvert : le verrou de profil est - // de loin la cause la plus fréquente, et le message de Firefox part sur - // une stdio ignorée. - if (!this.client?.isOpen) { + // Un code 0 n'est pas un échec : sur Ubuntu, `firefox` est un lanceur qui + // passe la main au snap et rend aussitôt la main. Le vrai navigateur + // démarre derrière — abandonner ici tuerait le cas nominal. + if (code !== 0 && !this.client?.isOpen) { fatal = `Firefox s'est arrêté (code ${code}) sans ouvrir le port de pilotage. ` + - `Le profil ${profile} est-il déjà ouvert dans une autre instance ?`; + `Le profil ${profile} est-il déjà ouvert dans une autre instance ? ` + + tail(logPath); } }); child.unref(); const client = await BidiClient.open(this.settings.remotePort, 45_000, () => fatal).catch( (err: Error) => { - this.lastError = err.message; - throw err; + this.lastError = `${err.message} ${tail(logPath)}`.trim(); + throw new Error(this.lastError); }, ); await this.adopt(client); @@ -420,10 +434,119 @@ export class FirefoxController { } private profileFor(settings: BrowserSettings): string { - return settings.profileDir || path.join(os.homedir(), '.stream-control', 'firefox-profile'); + return settings.profileDir || defaultProfileDir(settings.command); } } +// --- Confinement snap --------------------------------------------------------- + +/** + * Emplacement du profil géré par l'agent. + * + * Il dépend du mode d'installation de Firefox, et ce n'est pas du zèle : sur + * Ubuntu, Firefox est un snap, et l'interface `home` d'un snap **exclut + * délibérément les fichiers et répertoires cachés** — ceux commençant par un + * point sont réputés sensibles. Un profil sous `~/.stream-control` y est donc + * inaccessible, et Firefox meurt sans jamais ouvrir son port de pilotage. + */ +export function defaultProfileDir(command: string): string { + const home = os.homedir(); + return isSnapFirefox(command) + ? path.join(home, 'snap', 'firefox', 'common', 'stream-control-profile') + : path.join(home, '.stream-control', 'firefox-profile'); +} + +/** + * Refus prévisible du confinement : un chemin caché confié à un Firefox snap. + * + * Renvoie l'explication, ou `null` si rien ne s'y oppose. Le contrôle est fait + * avant le lancement, parce que le symptôme — un port qui ne s'ouvre pas — ne + * désigne pas sa cause. + */ +export function profileRefusedBySnap(command: string, profile: string): string | null { + if (!isSnapFirefox(command)) return null; + + const home = os.homedir(); + const relative = path.relative(home, profile); + // Un segment caché suffit : la règle porte sur le chemin entier, pas sur sa fin. + const hidden = relative.split(path.sep).find((segment) => segment.startsWith('.')); + if (!hidden) return null; + + return ( + `Firefox est installé en snap, et un snap n'a pas accès aux répertoires cachés de ` + + `ton dossier personnel (« ${hidden} » ici). Le profil ${profile} lui est donc ` + + `interdit. Laisse le champ « Profil Firefox dédié » vide pour reprendre le défaut ` + + `(${defaultProfileDir(command)}), ou indique un chemin sans segment commençant par un point.` + ); +} + +/** + * Verdict sur une chaîne de liens symboliques déjà résolue. + * + * Séparé du système de fichiers pour être vérifiable : sur Ubuntu, + * `/usr/bin/firefox` mène à `/snap/bin/firefox`, lui-même lien vers + * `/usr/bin/snap`. Aucun maillon pris isolément ne suffit — c'est la chaîne + * entière qu'il faut regarder. + */ +export function snapSignature(chain: string[]): boolean { + return chain.some((entry) => entry.startsWith('/snap/') || path.basename(entry) === 'snap'); +} + +/** Vrai si la commande mène à un Firefox empaqueté en snap. */ +export function isSnapFirefox(command: string): boolean { + const resolved = locate(command); + return resolved !== null && snapSignature(symlinkChain(resolved)); +} + +/** Chemin de départ suivi de chaque cible de lien, en absolu. */ +export function symlinkChain(start: string): string[] { + const chain = [start]; + let current = start; + + // Borne volontaire : un lien circulaire ne doit pas figer l'agent. + for (let hop = 0; hop < 10; hop++) { + let target: string; + try { + if (!fs.lstatSync(current).isSymbolicLink()) break; + target = fs.readlinkSync(current); + } catch { + break; + } + current = path.resolve(path.dirname(current), target); + chain.push(current); + } + return chain; +} + +/** Chemin absolu d'une commande, en parcourant `PATH` comme le ferait le shell. */ +function locate(command: string): string | null { + if (command.includes(path.sep)) return fs.existsSync(command) ? command : null; + + for (const dir of (process.env.PATH ?? '').split(path.delimiter)) { + if (!dir) continue; + const candidate = path.join(dir, command); + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +/** Dernières lignes du journal de Firefox, pour accompagner un échec. */ +function tail(logPath: string, lines = 4): string { + let content: string; + try { + content = fs.readFileSync(logPath, 'utf8'); + } catch { + return ''; + } + + const kept = content + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .slice(-lines); + return kept.length ? `Firefox a écrit : ${kept.join(' / ')}` : ''; +} + // --- Profil dédié ------------------------------------------------------------- /** diff --git a/packages/web/src/components/AgentSettings.tsx b/packages/web/src/components/AgentSettings.tsx index 0c77f92..b17d40c 100644 --- a/packages/web/src/components/AgentSettings.tsx +++ b/packages/web/src/components/AgentSettings.tsx @@ -428,13 +428,15 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr patchBrowser({ profileDir: event.target.value })} - placeholder="~/.stream-control/firefox-profile (par défaut)" + placeholder="laisse vide — l'agent choisit selon l'installation de Firefox" /> Un profil séparé est obligatoire : deux instances ne peuvent pas partager le même, et pointer celui de l'opérateur redonnerait « Firefox est déjà ouvert ». L'agent y écrit les réglages qui comptent pour un enregistrement — lecture automatique autorisée, bandeau de plein écran supprimé, aucun onglet d'accueil. + Sur Ubuntu, où Firefox est un snap, le chemin ne doit contenir aucun segment + commençant par un point : un snap n'accède pas aux répertoires cachés. )}