Files
stream-control/packages/agent/src/firefox.ts
jeanotx32 ea8eeec129
All checks were successful
release / build (push) Successful in 25s
release / verify-windows (push) Successful in 1m6s
fix bla
2026-08-12 04:18:44 +02:00

928 lines
34 KiB
TypeScript

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<void> | 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<FullscreenOutcome> {
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<SessionImportResult> {
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<SessionImportResult> {
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<boolean> {
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<boolean> {
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<void> {
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<void> {
// 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<T>(method: string, params: Record<string, unknown>, timeoutMs: number): Promise<T> {
const client = this.client;
if (!client) return Promise.reject(new Error('Firefox piloté non connecté'));
return client.send<T>(method, params, timeoutMs);
}
private async isFullscreen(): Promise<boolean> {
const result = await this.evaluate('document.fullscreenElement !== null').catch(() => null);
return result === true;
}
private async evaluate(expression: string, userActivation = false): Promise<unknown> {
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<string, string>;
}
/** 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<void> {
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 <chemin>`).
*
* Réservé à Linux, où `/proc/<pid>/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<number> {
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<string, string> = {
// 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function message(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}