import { spawn, type ChildProcess } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import type { BrowserSettings, BrowserState, FullscreenSettings, SessionImportResult, } from '@stream-control/shared'; import { BidiClient } from './bidi.ts'; import { assertWebUrl, type BrowserLog } from './browser.ts'; import type { FullscreenOutcome } from './fullscreen.ts'; import { sessionEnv } from './x11.ts'; /** * Attente maximale de l'ouverture du port de pilotage. * * Généreuse à dessein : un snap Firefox démarrant à froid sur une VM, avec un * profil neuf à construire, dépasse couramment la minute. Ce délai ne coûte * rien quand tout va bien — la connexion aboutit dès que le port répond. */ const LAUNCH_TIMEOUT_MS = 150_000; /** * Firefox piloté par l'agent, via WebDriver BiDi. * * Le renversement par rapport au mode `launch` : l'agent possède le navigateur * au lieu de lui envoyer des URL en espérant. Il en découle les trois choses * qui manquaient — naviguer sans relancer de processus, déclencher le plein * écran sans passer par le serveur d'affichage, et *vérifier* que la page y est * passée. * * Le processus est volontairement détaché : un redémarrage de l'agent ne doit * pas fermer la fenêtre, sinon OBS perdrait sa source de capture. Au démarrage, * l'agent tente donc de se rattacher au port avant d'envisager un lancement. */ export class FirefoxController { private client: BidiClient | null = null; private context: string | null = null; private child: ChildProcess | null = null; private url: string | null = null; /** Dernier verdict de plein écran ; remis à zéro par toute navigation. */ private fullscreen: boolean | undefined; private lastError: string | undefined; /** Sérialise les préparations concurrentes : une seule instance à lancer. */ private starting: Promise | null = null; constructor( private settings: BrowserSettings, private readonly log: BrowserLog, ) {} applySettings(settings: BrowserSettings): void { const moved = settings.remotePort !== this.settings.remotePort || settings.command !== this.settings.command || this.profileFor(settings) !== this.profileFor(this.settings); this.settings = settings; // Changer de port ou de profil désigne une autre instance : garder le canal // ouvert ferait piloter l'ancienne, sans que rien ne le signale. if (moved) this.detach(); } get state(): BrowserState { return { connected: this.client?.isOpen === true, url: this.url ?? undefined, fullscreen: this.fullscreen, lastError: this.lastError, }; } /** Ouvre l'URL dans l'onglet piloté, en lançant Firefox si nécessaire. */ async navigate(url: string): Promise<{ url: string; launched: boolean }> { // Même filtre que le mode `launch` : l'URL vient du serveur, et un // `file://` n'a rien à faire dans une page de capture. assertWebUrl(url); const launched = await this.ensureReady(); // `complete` attend l'évènement `load`. Une page de stream peut le repousser // longtemps (lecteur, publicités) sans être pour autant inutilisable : on // borne l'attente et on continue, la navigation ayant bien eu lieu. try { await this.send('browsingContext.navigate', { context: this.context, url, wait: 'complete', }, 60_000); } catch (err) { this.log( 'warn', `Chargement de ${url} non confirmé (${message(err)}) — la page est ouverte, la suite continue`, ); } this.url = url; this.fullscreen = undefined; this.log('info', `Page ${url} ouverte dans Firefox piloté`, 'browser.opened'); return { url, launched }; } /** * Rappelle le plein écran en envoyant la touche du lecteur, et seulement elle. * * Volontairement une seule voie, sans repli. Une version antérieure appelait * `requestFullscreen()` sur l'élément vidéo brut quand la touche ne semblait * pas avoir agi — mais « ne semblait pas » se mesurait à * `document.fullscreenElement`, qui ne dit rien du bouton « théâtre » de * Stripchat : ce mode-là garde la barre du haut visible et n'a souvent rien * à voir avec l'API plein écran native du navigateur. Le repli déclenchait * alors un vrai plein écran natif — vidéo seule, tout le reste disparu — * précisément l'effet que la touche du lecteur, elle, évite. Mieux vaut * envoyer le geste attendu par le site et ne pas le corriger à l'aveugle. */ async restoreFullscreen(settings: FullscreenSettings): Promise { await this.ensureReady(); // Sans activation de l'onglet, la touche partirait vers une page en // arrière-plan, dont le lecteur ignore les évènements clavier. await this.send('browsingContext.activate', { context: this.context }, 5000).catch(() => undefined, ); await this.send('input.performActions', { context: this.context, actions: [ { type: 'key', id: 'stream-control-keyboard', actions: [ { type: 'keyDown', value: webdriverKey(settings.key) }, { type: 'keyUp', value: webdriverKey(settings.key) }, ], }, ], }, 10_000); await this.send('input.releaseActions', { context: this.context }, 5000).catch(() => undefined); // Le passage en plein écran est animé : relire trop tôt renverrait faux // alors que la touche a bien été prise en compte. await delay(1200); const confirmed = await this.isFullscreen(); this.fullscreen = confirmed; // `confirmed` reste `undefined`, pas `false`, quand l'API native ne signale // rien : c'est le cas attendu pour un mode « théâtre » propre au site, pas // un échec. Le distinguer évite de journaliser en avertissement une touche // qui a très bien pu faire son effet. return { method: 'webdriver (touche)', target: this.url ?? undefined, confirmed: confirmed ? true : undefined, }; } /** Décharge le lecteur sans détruire la fenêtre — donc sans casser la source OBS. */ async blank(): Promise<{ url: string }> { if (!this.client?.isOpen) { // Pas de canal : rien à décharger, et surtout pas de quoi justifier de // lancer Firefox pour l'occasion. return { url: 'about:blank' }; } await this.send('browsingContext.navigate', { context: this.context, url: 'about:blank', wait: 'complete', }, 15_000).catch((err: Error) => { this.log('warn', `Page vide non chargée : ${err.message}`); }); this.url = 'about:blank'; this.fullscreen = undefined; this.log('info', 'Lecteur déchargé (page vide) — fenêtre conservée pour OBS', 'browser.closed'); return { url: 'about:blank' }; } /** Quitte Firefox. La source « capture de fenêtre » d'OBS sera à repointer. */ async quit(): Promise<{ closed: boolean }> { if (!this.client?.isOpen) return { closed: false }; await this.send('browser.close', {}, 10_000).catch(() => undefined); this.detach(); this.log('info', 'Firefox piloté fermé', 'browser.closed'); return { closed: true }; } /** Ferme le canal sans toucher au navigateur (arrêt de l'agent). */ dispose(): void { this.detach(); } /** * Importe les cookies de session depuis le Firefox personnel de l'utilisateur. * * Le profil piloté est délibérément vierge — c'est ce qui le rend * indépendant du reste de la session — mais un site comme Stripchat exige * une session ouverte, et rejouer la connexion à chaque redémarrage n'a * rien d'automatisable proprement. Copier les cookies déjà valides est plus * simple et plus sûr que de manipuler un formulaire de connexion. * * Lecture seule côté source : rien n'est jamais écrit dans le profil * personnel de l'utilisateur. */ async importSession(): Promise { try { return await this.doImportSession(); } catch (err) { const text = err instanceof Error ? err.message : String(err); this.log('warn', `Import de session impossible : ${text}`, 'browser.importFailed'); throw err; } } private async doImportSession(): Promise { const root = mozillaProfilesRoot(this.settings.command); const source = resolveDefaultProfile(root); if (!source) { throw new Error( `Profil Firefox personnel introuvable sous ${root}. As-tu déjà ouvert Firefox au ` + 'moins une fois avec ce compte, en dehors du pilotage automatique ?', ); } const destination = this.profileFor(this.settings); if (path.resolve(source) === path.resolve(destination)) { throw new Error('Le profil personnel et le profil piloté sont le même — rien à importer.'); } if (!fs.existsSync(path.join(source, 'cookies.sqlite'))) { throw new Error( `Aucun cookie enregistré dans ${source}. Connecte-toi d'abord sur le site voulu dans ` + 'ce Firefox, celui que tu utilises normalement.', ); } fs.mkdirSync(destination, { recursive: true }); // Une instance pilotée vivante verrait ses fichiers de cookies remplacés // sous elle. reclaimProfile() rattrape aussi une instance orpheline d'un // cycle d'agent précédent, pas seulement celle que ce process connaît. this.detach(); await reclaimProfile(destination, this.log); const copied: string[] = []; for (const name of SESSION_FILES) { try { fs.copyFileSync(path.join(source, name), path.join(destination, name)); copied.push(name); } catch { // -wal/-shm normalement absents si Firefox a déjà tout validé dans le // fichier principal ; permissions.sqlite est un bonus, pas une condition. } } if (!copied.includes('cookies.sqlite')) { throw new Error(`Copie des cookies impossible depuis ${source}.`); } this.log('info', `Session importée depuis ${source} (${copied.join(', ')})`, 'browser.imported'); return { sourceProfile: source, copied }; } // --- Cycle de vie de l'instance -------------------------------------------- /** Vrai si l'appel a dû lancer Firefox. */ private async ensureReady(): Promise { if (this.client?.isOpen && this.context) return false; // Une seule préparation à la fois : deux commandes arrivant ensemble // lanceraient sinon deux Firefox sur le même profil, dont le second // échouerait sur le verrou. if (this.starting) { await this.starting; return false; } let launched = false; this.starting = (async () => { // Rattachement d'abord : après un redémarrage de l'agent, la fenêtre est // toujours là — et c'est celle qu'OBS capture. if (await this.attach(2000)) return; launched = true; await this.launch(); })(); try { await this.starting; return launched; } finally { this.starting = null; } } private async attach(timeoutMs: number): Promise { let client: BidiClient; try { client = await BidiClient.open(this.settings.remotePort, timeoutMs); } catch { return false; } try { await this.adopt(client); return true; } catch (err) { // Le port répond mais la session ne s'établit pas : ce n'est pas un // Firefox exploitable, on repart proprement plutôt que de le garder. client.close(); this.lastError = message(err); return false; } } 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); // Une instance bloquée d'un lancement précédent — celui-ci n'a jamais pu // se connecter et n'a donc jamais été arrêtée — laisserait le verrou en // place : chaque nouvelle tentative se heurterait alors indéfiniment au // dialogue « Firefox est déjà ouvert », qui n'ouvre jamais le port. await reclaimProfile(profile, this.log); writeProfilePrefs(profile); const args = [ '--profile', profile, // Sans cela, le processus lancé confierait l'URL à un Firefox déjà ouvert // et rendrait la main sans jamais ouvrir le port de pilotage. '--no-remote', '--remote-debugging-port', String(this.settings.remotePort), 'about:blank', ]; 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', 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. let fatal: string | null = null; child.once('error', (err: NodeJS.ErrnoException) => { fatal = err.code === 'ENOENT' ? `Navigateur introuvable : « ${this.settings.command} »` : `Lancement du navigateur impossible : ${err.message}`; }); child.once('exit', (code) => { if (this.child === child) this.child = null; // 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) { // Sans le journal : il est ajouté une seule fois, au rattrapage. 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 ?`; } }); child.unref(); // Le premier démarrage d'un snap sur une VM dépasse couramment la minute : // décompression, mise en place du confinement, profil neuf à construire. // Un délai de 45 s abandonnait un navigateur qui était simplement lent. const started = Date.now(); const heartbeat = setInterval(() => { this.log('info', `Firefox pas encore joignable après ${Math.round((Date.now() - started) / 1000)} s — on patiente`); }, 20_000); heartbeat.unref?.(); try { const client = await BidiClient.open(this.settings.remotePort, LAUNCH_TIMEOUT_MS, () => fatal); await this.adopt(client); this.log('info', `Firefox piloté prêt après ${Math.round((Date.now() - started) / 1000)} s`); } catch (err) { this.lastError = `${message(err)} ${tail(logPath)}`.trim(); // Sans cela, ce processus devient le verrou bloqué de la prochaine // tentative — exactement le symptôme que reclaimProfile() rattrape par // ailleurs, mais autant ne pas le produire. if (this.child?.pid) killGroup(this.child.pid); this.child = null; throw new Error(this.lastError); } finally { clearInterval(heartbeat); } } /** Établit la session BiDi et retient l'onglet à piloter. */ private async adopt(client: BidiClient): Promise { // Tolérant : selon la version, le remote agent ouvre déjà une session pour // la connexion au point d'entrée `/session`, et refuse alors d'en créer une // seconde. L'échec ne devient une erreur que si `getTree` échoue aussi. await client .send('session.new', { capabilities: { alwaysMatch: {} } }, 15_000) .catch(() => undefined); const tree = await client.send<{ contexts: Array<{ context: string; url: string }> }>( 'browsingContext.getTree', {}, 10_000, ); const top = tree.contexts[0]; if (!top) throw new Error('Firefox répond mais n\'expose aucun onglet'); client.once('closed', () => { if (this.client === client) { this.client = null; this.context = null; this.log('warn', 'Canal de pilotage Firefox perdu — reconnexion à la prochaine commande'); } }); this.client = client; this.context = top.context; this.url = top.url || null; this.lastError = undefined; } private detach(): void { this.client?.close(); this.client = null; this.context = null; // L'URL décrit l'onglet piloté : sans canal, elle ne décrit plus rien et // laisserait croire à un lecteur encore chargé. this.url = null; } // --- Primitives ------------------------------------------------------------- private send(method: string, params: Record, timeoutMs: number): Promise { const client = this.client; if (!client) return Promise.reject(new Error('Firefox piloté non connecté')); return client.send(method, params, timeoutMs); } private async isFullscreen(): Promise { const result = await this.evaluate('document.fullscreenElement !== null').catch(() => null); return result === true; } private async evaluate(expression: string, userActivation = false): Promise { const response = await this.send<{ type: string; result?: { type: string; value?: unknown }; exceptionDetails?: { text?: string }; }>( 'script.evaluate', { expression, target: { context: this.context }, awaitPromise: true, userActivation, }, 15_000, ); if (response.type === 'exception') { throw new Error(response.exceptionDetails?.text ?? 'exception dans la page'); } return response.result?.value; } private profileFor(settings: BrowserSettings): string { return settings.profileDir || defaultProfileDir(settings.command); } } // --- Import de session ----------------------------------------------------- /** Fichiers copiés depuis le profil personnel. Aucun n'est jamais écrit côté source. */ const SESSION_FILES = ['cookies.sqlite', 'cookies.sqlite-wal', 'cookies.sqlite-shm', 'permissions.sqlite']; /** * Répertoire des profils du Firefox « normal » de l'utilisateur — celui où il * se connecte à la main, distinct du profil dédié au pilotage. * * Un snap redirige tout `$HOME` visible depuis son bac à sable vers * `~/snap/firefox/common` : le profil personnel y est déplacé aussi, pas * seulement celui de l'agent. */ export function mozillaProfilesRoot(command: string): string { const home = os.homedir(); if (process.platform === 'win32') { return path.join(process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'Mozilla', 'Firefox'); } if (process.platform === 'darwin') { return path.join(home, 'Library', 'Application Support', 'Firefox'); } return isSnapFirefox(command) ? path.join(home, 'snap', 'firefox', 'common', '.mozilla', 'firefox') : path.join(home, '.mozilla', 'firefox'); } interface IniSection { name: string; entries: Record; } /** Lecteur minimal du format `.ini` de `profiles.ini` — sections et paires clé=valeur. */ function parseIni(content: string): IniSection[] { const sections: IniSection[] = []; let current: IniSection | null = null; for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith(';') || line.startsWith('#')) continue; const header = /^\[(.+)\]$/.exec(line); if (header) { current = { name: header[1]!, entries: {} }; sections.push(current); continue; } const at = line.indexOf('='); if (at === -1 || !current) continue; current.entries[line.slice(0, at).trim()] = line.slice(at + 1).trim(); } return sections; } /** * Résout le profil par défaut d'une installation Firefox, chemin absolu. * * Deux formats coexistent dans la nature : les versions récentes désignent le * profil par défaut dans une section `[InstallXXXXXXXX]` séparée (prioritaire * ici), les plus anciennes par un drapeau `Default=1` directement sur la * section du profil. À défaut des deux, on retient `default-release` par * convention de nommage, puis le premier profil listé. */ export function resolveDefaultProfile(root: string): string | null { let ini: string; try { ini = fs.readFileSync(path.join(root, 'profiles.ini'), 'utf8'); } catch { return null; } const sections = parseIni(ini); const profiles = sections.filter((s) => s.name.startsWith('Profile')); const byPath = (relPath: string) => profiles.find((p) => p.entries.Path === relPath) ?? null; const install = sections.find((s) => s.name.startsWith('Install') && s.entries.Default); const chosen = (install ? byPath(install.entries.Default!) : null) ?? profiles.find((p) => p.entries.Default === '1') ?? profiles.find((p) => /default-release$/.test(p.entries.Path ?? '')) ?? profiles[0] ?? null; if (!chosen?.entries.Path) return null; return chosen.entries.IsRelative === '0' ? chosen.entries.Path : path.join(root, chosen.entries.Path); } // --- Verrou de profil ---------------------------------------------------------- /** * Libère le profil d'une instance bloquée, avant de le confier à une nouvelle. * * Le profil est exclusif à l'agent : aucun humain n'y travaille en temps * normal. Un verrou signale donc soit une instance qu'un lancement précédent * a laissée pendre (le délai de connexion a expiré sans que le processus soit * arrêté), soit un lancement manuel de diagnostic resté ouvert — dans les deux * cas, l'interrompre est sans conséquence. Sans ce nettoyage, chaque nouvelle * tentative se heurte au dialogue « Firefox est déjà ouvert », qui n'ouvre * jamais le port de pilotage et bloque indéfiniment. */ async function reclaimProfile(profile: string, log: BrowserLog): Promise { const killed = await killProcessesUsingProfile(profile); if (killed > 0) { log('warn', `${killed} instance(s) Firefox bloquée(s) sur ce profil, arrêtée(s)`); await delay(500); // laisser le noyau libérer les descripteurs de fichier avant de relire le verrou } // Supprimés seulement après : effacer le verrou d'un processus encore vivant // laisserait le prochain lancement écrire dans le même profil en parallèle. for (const name of ['lock', '.parentlock', 'parent.lock']) { try { fs.rmSync(path.join(profile, name), { force: true }); } catch { /* absent, rien à faire */ } } } /** * Termine les processus dont la ligne de commande référence exactement ce * profil (`--profile `). * * Réservé à Linux, où `/proc//cmdline` rend le contrôle précis et sans * dépendance externe : c'est aussi la seule plateforme où l'agent tourne en * production. La correspondance porte sur le chemin exact du profil — jamais * sur un nom de processus — donc rien en dehors de ce que l'agent a lui-même * lancé sur ce profil ne peut être atteint par erreur. */ async function killProcessesUsingProfile(profile: string): Promise { if (process.platform !== 'linux') return 0; let entries: string[]; try { entries = fs.readdirSync('/proc').filter((entry) => /^\d+$/.test(entry)); } catch { return 0; } let killed = 0; for (const entry of entries) { const pid = Number(entry); if (pid === process.pid) continue; let args: string[]; try { args = fs.readFileSync(`/proc/${entry}/cmdline`, 'utf8').split('\0').filter(Boolean); } catch { continue; // processus disparu entre la liste et la lecture, ou inaccessible } const at = args.indexOf('--profile'); if (at === -1 || args[at + 1] !== profile) continue; killGroup(pid); killed++; } return killed; } /** * Tue un processus et, si possible, tout son groupe. * * Un lancement détaché (`detached: true`) devient le meneur de son propre * groupe : ne signaler que son PID laisserait vivre les processus de * contenu de Firefox, qui héritent de ce groupe sans en dépendre du PID * principal. */ function killGroup(pid: number): void { try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch { /* déjà mort */ } } } // --- 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); if (!resolved) return false; // Deux formes rencontrées en pratique : un lien symbolique jusqu'à // `/usr/bin/snap` (le cas documenté), ou — sur certaines images Ubuntu — // `/usr/bin/firefox` en petit script shell qui exécute le snap en son sein, // sans aucun lien symbolique dans la chaîne. La première vérification ne // voyait pas la seconde forme. return snapSignature(symlinkChain(resolved)) || wrapperReferencesSnap(resolved); } /** * Vrai si un script d'aiguillage mentionne le snap dans son propre texte. * * Ne s'applique qu'à un petit script shell (garde du `#!` et d'une taille * raisonnable) : un exécutable binaire réel ne doit pas être lu comme du * texte, et une lecture qui échoue n'est qu'un indice de moins, pas une * preuve d'absence. Un faux positif ici est sans conséquence — il ne fait que * choisir l'emplacement de profil déjà valable pour un Firefox non confiné — * donc la détection est volontairement large plutôt que stricte sur le * libellé exact. */ function wrapperReferencesSnap(resolved: string): boolean { try { const stat = fs.statSync(resolved); if (!stat.isFile() || stat.size === 0 || stat.size > 8192) return false; const content = fs.readFileSync(resolved, 'utf8'); return content.startsWith('#!') && /\/snap\/|\bsnap run\b/.test(content); } catch { return false; } } /** 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; } /** * Bavardage de snapd, systématique au démarrage d'un snap et sans rapport avec * un échec de Firefox. * * Le filtrer n'est pas cosmétique : ces lignes arrivent en dernier et * chassaient la vraie erreur d'un extrait trop court. */ const SNAPD_NOISE = [ /^update\.go:\d+: cannot change mount namespace/i, /because it would affect the host in/i, /^snap-update-ns failed/i, /^cannot create symlink in "\/var\/lib\/snapd/i, ]; /** Dernières lignes utiles du journal de Firefox, pour accompagner un échec. */ function tail(logPath: string, lines = 20): string { let content: string; try { content = fs.readFileSync(logPath, 'utf8'); } catch { return ''; } const all = content.split('\n').map((line) => line.trim()).filter(Boolean); const useful = all.filter((line) => !SNAPD_NOISE.some((pattern) => pattern.test(line))); const dropped = all.length - useful.length; const kept = useful.slice(-lines); if (kept.length === 0) { return dropped > 0 ? `Firefox n'a écrit que ${dropped} ligne(s) de bavardage snapd — journal complet : ${logPath}` : `Firefox n'a rien écrit — journal : ${logPath}`; } return ( `Firefox a écrit : ${kept.join(' / ')}` + (dropped > 0 ? ` (+${dropped} ligne(s) de bavardage snapd ignorées)` : '') + ` — journal complet : ${logPath}` ); } // --- Profil dédié ------------------------------------------------------------- /** * Préférences du profil piloté, réécrites à chaque lancement. * * `user.js` est relu par Firefox à chaque démarrage : réécrire est donc * idempotent, et une valeur qu'un opérateur aurait modifiée à la main revient * d'elle-même. Ces réglages ne sont pas du confort — chacun supprime quelque * chose qui finirait autrement dans le fichier enregistré, ou qui empêcherait * l'enregistrement de démarrer sans surveillance. */ function writeProfilePrefs(profileDir: string): void { fs.mkdirSync(profileDir, { recursive: true }); const prefs: Array<[string, string | number | boolean]> = [ // Le bandeau « … est maintenant en plein écran » se serait retrouvé dans les // premières secondes de chaque enregistrement. ['full-screen-api.warning.timeout', 0], ['full-screen-api.warning.delay', -1], ['full-screen-api.transition-duration.enter', '0 0'], ['full-screen-api.transition-duration.leave', '0 0'], // Repli pour les Firefox antérieurs au paramètre `userActivation` de BiDi. ['full-screen-api.allow-trusted-requests-only', false], // Sans lecture automatique, le flux reste sur une image fixe et l'agent // enregistre un écran mort sans que rien ne le signale. ['media.autoplay.default', 0], ['media.autoplay.blocking_policy', 0], // Tout ce qui ouvrirait un onglet ou une fenêtre au premier démarrage : la // page du streamer n'y serait plus au premier plan. ['browser.aboutwelcome.enabled', false], ['browser.startup.homepage_override.mstone', 'ignore'], ['browser.startup.page', 0], ['browser.newtabpage.enabled', false], ['browser.shell.checkDefaultBrowser', false], ['browser.uitour.enabled', false], ['datareporting.policy.dataSubmissionEnabled', false], ['datareporting.policy.firstRunURL', ''], ['toolkit.telemetry.reportingpolicy.firstRun', false], // Un dialogue modal bloquerait toute commande BiDi jusqu'à ce que quelqu'un // aille cliquer sur la VM. ['browser.sessionstore.resume_from_crash', false], ['browser.tabs.warnOnClose', false], ['dom.disable_beforeunload', true], // Une mise à jour appliquée au redémarrage fermerait la fenêtre que capture // OBS, au milieu d'un enregistrement. ['app.update.auto', false], ]; const content = [ '// Genere par stream-control-agent — reecrit a chaque lancement.', ...prefs.map(([key, value]) => `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`), '', ].join('\n'); fs.writeFileSync(path.join(profileDir, 'user.js'), content, 'utf8'); } // --- Touches ------------------------------------------------------------------ /** * Traduit un nom de touche en valeur WebDriver. * * Les caractères imprimables se notent tels quels ; les touches nommées passent * par des points de code de la zone à usage privé, définis par la spécification * WebDriver. `f` reste `f`, mais `F11` devient U+E03B. */ export function webdriverKey(key: string): string { const named: Record = { // Points de code WebDriver (zone a usage prive), ecrits en echappement : // un caractere invisible dans le source serait indebuggable. tab: '\uE004', return: '\uE006', escape: '\uE00C', space: '\uE00D', }; const lower = key.toLowerCase(); if (named[lower]) return named[lower]; const fn = /^f(\d{1,2})$/.exec(lower); if (fn) { const index = Number(fn[1]); // F1 = U+E031, séquentiel jusqu'à F12. if (index >= 1 && index <= 12) return String.fromCharCode(0xe030 + index); } return key; } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function message(err: unknown): string { return err instanceof Error ? err.message : String(err); }