928 lines
34 KiB
TypeScript
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);
|
|
}
|