REVAMP : Firefox handling
All checks were successful
release / build (push) Successful in 25s
release / verify-windows (push) Successful in 1m15s

This commit is contained in:
jeanotx32
2026-08-12 01:46:14 +02:00
parent 3bd4d5f2ba
commit 77bfa0b8c5
14 changed files with 1223 additions and 108 deletions

View File

@@ -0,0 +1,526 @@
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 } 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';
/**
* 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, puis le vérifie.
*
* Deux tentatives, dans cet ordre volontaire. La touche d'abord : c'est le
* geste de l'opérateur, elle passe par le gestionnaire du lecteur et donne
* exactement le cadrage attendu par le site. L'appel direct à
* `requestFullscreen` ensuite, seulement si la première n'a rien produit —
* il fonctionne toujours mais met en plein écran l'élément vidéo brut, sans
* l'habillage du lecteur.
*/
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);
if (await this.isFullscreen()) {
this.fullscreen = true;
return { method: 'webdriver (touche)', target: this.url ?? undefined, confirmed: true };
}
this.log(
'info',
`La touche « ${settings.key} » n'a pas basculé la page ; tentative directe sur l'élément vidéo`,
);
const detail = await this.requestFullscreenOnVideo();
await delay(800);
const confirmed = await this.isFullscreen();
this.fullscreen = confirmed;
if (confirmed) {
return { method: 'webdriver (élément vidéo)', target: this.url ?? undefined, confirmed };
}
// « Fullscreen request denied » ne dit rien à personne. Les trois conditions
// que Gecko exige sont lisibles depuis la page : on les relit pour nommer
// celle qui manque, plutôt que de laisser l'opérateur deviner.
const why = await this.explainRefusal(detail);
this.lastError = why;
return { method: `webdriver — ${why}`, target: this.url ?? undefined, confirmed: false };
}
/**
* Nomme la condition manquante après un refus de plein écran.
*
* Le focus est de loin la première cause : Firefox refuse le plein écran à un
* document dont la fenêtre n'est pas celle qu'a sélectionnée le gestionnaire
* de fenêtres — et sur une VM, il suffit qu'OBS ou un terminal l'ait pris.
*/
private async explainRefusal(detail: string): Promise<string> {
const raw = await this.evaluate(
'JSON.stringify({ focus: document.hasFocus(), api: document.fullscreenEnabled, video: !!document.querySelector("video") })',
).catch(() => null);
const page = typeof raw === 'string' ? (JSON.parse(raw) as Record<string, boolean>) : null;
if (!page) return detail;
if (!page.video) return 'aucun élément vidéo dans la page — le lecteur a-t-il fini de charger ?';
if (!page.focus) {
return (
"la fenêtre Firefox n'a pas le focus : Firefox refuse le plein écran à un onglet " +
"en arrière-plan. Ferme les autres fenêtres de la session, ou laisse Firefox seul " +
"au premier plan sur cette VM"
);
}
if (!page.api) return "l'API plein écran est désactivée dans ce profil Firefox";
return detail;
}
/** 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();
}
// --- 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);
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})`);
// 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',
env: sessionEnv(),
});
this.child = child;
// 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;
// Sortie immédiate avec le port jamais ouvert : le verrou de profil est
// de loin la cause la plus fréquente, et le message de Firefox part sur
// une stdio ignorée.
if (!this.client?.isOpen) {
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();
const client = await BidiClient.open(this.settings.remotePort, 45_000, () => fatal).catch(
(err: Error) => {
this.lastError = err.message;
throw err;
},
);
await this.adopt(client);
}
/** É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;
}
/**
* Demande le plein écran sur l'élément vidéo.
*
* `userActivation` fait croire à Firefox à un geste utilisateur : sans lui,
* l'API refuse l'appel. Le profil pose aussi
* `full-screen-api.allow-trusted-requests-only=false`, pour les versions de
* Firefox qui ne connaissent pas encore ce paramètre de commande.
*/
private async requestFullscreenOnVideo(): Promise<string> {
const expression = `(async () => {
const video = document.querySelector('video');
if (!video) return 'aucune vidéo dans la page';
try {
await (video.requestFullscreen ? video.requestFullscreen() : Promise.reject(new Error('API absente')));
return 'ok';
} catch (err) {
return 'refus : ' + (err && err.message ? err.message : err);
}
})()`;
const result = await this.evaluate(expression, true).catch((err: Error) => err.message);
return typeof result === 'string' ? result : 'ok';
}
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 || path.join(os.homedir(), '.stream-control', 'firefox-profile');
}
}
// --- 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);
}