REVAMP : Firefox handling
This commit is contained in:
526
packages/agent/src/firefox.ts
Normal file
526
packages/agent/src/firefox.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user