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

180
packages/agent/src/bidi.ts Normal file
View File

@@ -0,0 +1,180 @@
import { EventEmitter } from 'node:events';
import { WebSocket } from 'ws';
/**
* Client WebDriver BiDi minimal.
*
* Firefox expose ce protocole dès qu'on le lance avec `--remote-debugging-port`
* : pas de geckodriver, pas de Selenium, une simple WebSocket JSON. On n'en
* implémente que le transport — corrélation requête/réponse et distribution des
* évènements — parce que les quatre commandes dont l'agent a besoin
* (`session.new`, `browsingContext.navigate`, `input.performActions`,
* `script.evaluate`) ne justifient pas une dépendance de plus dans un bundle
* qui se télécharge à chaque mise à jour.
*
* Intérêt décisif ici : ce canal parle à Firefox, pas au serveur d'affichage.
* Il fonctionne donc identiquement en session Wayland native, où xdotool ne
* voit rien.
*/
interface Pending {
resolve(value: unknown): void;
reject(error: Error): void;
timer: NodeJS.Timeout;
}
/** Réponse d'erreur du protocole : `error` est un code, `message` du texte. */
interface BidiError {
error: string;
message?: string;
stacktrace?: string;
}
export class BidiClient extends EventEmitter {
private nextId = 1;
private readonly pending = new Map<number, Pending>();
private closing = false;
private constructor(private readonly socket: WebSocket) {
super();
socket.on('message', (raw) => this.dispatch(raw.toString()));
socket.on('close', () => this.fail(new Error('Canal BiDi fermé par Firefox')));
socket.on('error', (err: Error) => this.fail(err));
}
get isOpen(): boolean {
return !this.closing && this.socket.readyState === WebSocket.OPEN;
}
/**
* Se connecte au remote agent, en retentant jusqu'à `timeoutMs`.
*
* La boucle n'est pas de la superstition : Firefox ouvre son port plusieurs
* secondes après le `spawn`, et le délai varie du simple au décuple selon que
* le profil est neuf ou déjà chaud. Sans réessai, le premier enregistrement
* après un démarrage de VM échouerait systématiquement.
*/
static async open(port: number, timeoutMs: number, giveUp?: () => string | null): Promise<BidiClient> {
const deadline = Date.now() + timeoutMs;
let lastError = 'aucune tentative';
for (;;) {
try {
return new BidiClient(await handshake(port, 4000));
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
// Attendre la fin du délai quand le processus est déjà mort ne renseigne
// personne : ça ne fait que retarder de 45 s un diagnostic déjà connu.
const abandon = giveUp?.();
if (abandon) throw new Error(abandon);
if (Date.now() >= deadline) {
throw new Error(
`Aucune réponse BiDi sur 127.0.0.1:${port} après ${Math.round(timeoutMs / 1000)} s ` +
`(${lastError}). Firefox a-t-il bien démarré avec --remote-debugging-port ?`,
);
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
/**
* Envoie une commande et attend son résultat.
*
* Le délai n'est pas une commodité : une navigation vers une page qui ne
* finit jamais de charger laisserait sinon la promesse en suspens pour
* toujours, et avec elle la séquence de capture.
*/
send<T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = 30_000,
): Promise<T> {
if (!this.isOpen) return Promise.reject(new Error('Canal BiDi indisponible'));
const id = this.nextId++;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Commande « ${method} » sans réponse après ${timeoutMs} ms`));
}, timeoutMs);
timer.unref?.();
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
this.socket.send(JSON.stringify({ id, method, params }));
});
}
close(): void {
this.closing = true;
this.fail(new Error('Canal BiDi fermé par l\'agent'));
this.socket.close();
}
private dispatch(raw: string): void {
let message: Record<string, unknown>;
try {
message = JSON.parse(raw) as Record<string, unknown>;
} catch {
return; // trame illisible : rien de mieux à faire que l'ignorer
}
if (message.type === 'event') {
this.emit('bidi-event', message.method as string, message.params);
return;
}
const entry = this.pending.get(message.id as number);
if (!entry) return;
this.pending.delete(message.id as number);
clearTimeout(entry.timer);
if (message.type === 'success') {
entry.resolve(message.result);
} else {
const error = message as unknown as BidiError;
entry.reject(new Error(`${error.error}${error.message ? ` : ${error.message}` : ''}`));
}
}
/** Rejette tout ce qui attend encore : une socket morte ne répondra jamais. */
private fail(err: Error): void {
for (const [id, entry] of this.pending) {
clearTimeout(entry.timer);
entry.reject(err);
this.pending.delete(id);
}
if (!this.closing) {
this.closing = true;
this.emit('closed', err);
}
}
}
/** Une tentative de connexion, résolue seulement si la socket s'ouvre. */
function handshake(port: number, timeoutMs: number): Promise<WebSocket> {
return new Promise((resolve, reject) => {
// 127.0.0.1 explicitement, jamais « localhost » : sur une machine où celui-ci
// résout d'abord en ::1, la connexion échouerait alors que Firefox écoute.
const socket = new WebSocket(`ws://127.0.0.1:${port}/session`, {
handshakeTimeout: timeoutMs,
});
const cleanup = () => {
socket.removeAllListeners('open');
socket.removeAllListeners('error');
};
socket.once('open', () => {
cleanup();
resolve(socket);
});
socket.once('error', (err: Error) => {
cleanup();
socket.close();
reject(err);
});
});
}

View File

@@ -30,7 +30,7 @@ function launchEnv(options: LaunchOptions): NodeJS.ProcessEnv {
* processus — jamais dans un shell, donc pas d'injection possible — mais un
* `file://` ou un `javascript:` n'aurait rien à faire ici.
*/
function assertWebUrl(url: string): string {
export function assertWebUrl(url: string): string {
let parsed: URL;
try {
parsed = new URL(url);

View File

@@ -4,7 +4,13 @@ import { promisify } from 'node:util';
import { WebSocket } from 'ws';
import { CONFIG_PATH, type AgentConfig } from './config.ts';
import { looksLikeWayland } from './hotkey.ts';
import { resolveDisplay, resolveSessionBus, resolveXauthority, sessionEnv } from './x11.ts';
import {
resolveDisplay,
resolveSessionBus,
resolveWaylandDisplay,
resolveXauthority,
sessionEnv,
} from './x11.ts';
const run = promisify(execFile);
@@ -266,10 +272,36 @@ export async function runDiagnostics(config: AgentConfig): Promise<number> {
),
);
if (process.env.WAYLAND_DISPLAY) {
line('warn', 'session', 'Wayland détecté — xdotool exige X11');
const wayland = resolveWaylandDisplay();
if (wayland) {
line(
'ok',
'session',
`Wayland (${wayland}) — xdotool y est aveugle : le rappel du plein écran exige ` +
'le mode de pilotage « WebDriver BiDi »',
);
}
if (hasXdotool) await reportVisibleWindows(display);
// Firefox est le seul navigateur que le mode BiDi sache piloter : son
// absence rend ce mode inutilisable, quel que soit le reste.
results.push(
(await commandExists('firefox', ['--version']))
? line('ok', 'firefox', 'installé — pilotage WebDriver BiDi possible')
: line('warn', 'firefox', 'absent — le mode de pilotage « WebDriver BiDi » échouera'),
);
// Port par défaut : le vrai vient de la configuration serveur, que ce
// diagnostic hors ligne ne connaît pas.
const bidiError = await probeTcp('127.0.0.1', 9222, 1500);
line(
'ok',
'pilotage',
bidiError
? 'aucune instance Firefox pilotée sur 127.0.0.1:9222 (normale hors enregistrement)'
: 'instance Firefox pilotée détectée sur 127.0.0.1:9222',
);
if (hasXdotool && !wayland) await reportVisibleWindows(display);
} else if (process.platform === 'win32') {
const hasPowershell = await commandExists('powershell.exe', ['-NoProfile', '-Command', 'exit']);
results.push(

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);
}

View File

@@ -0,0 +1,31 @@
/**
* Résultat d'un rappel de plein écran, quelle que soit la méthode employée.
*
* `confirmed` est le champ qui compte. xdotool ne peut jamais le renseigner :
* il envoie une touche et n'a aucun moyen de savoir ce que la page en a fait.
* BiDi, lui, relit `document.fullscreenElement` — donc un `false` ici est une
* information, pas une incertitude, et c'est ce qui permet d'enchaîner sur une
* seconde tentative plutôt que de laisser l'enregistrement en fenêtré.
*/
export interface FullscreenOutcome {
/** `webdriver`, `xdotool`, `SendKeys`, `osascript`… */
method: string;
/** Titre de fenêtre ou URL, selon ce que la méthode a pu identifier. */
target?: string;
/** Vérifié dans la page. `undefined` = la méthode ne sait pas vérifier. */
confirmed?: boolean;
}
/** Phrase de journal, uniforme entre les méthodes. */
export function describeFullscreen(outcome: FullscreenOutcome, key: string): string {
const where = outcome.target ? ` sur « ${outcome.target} »` : '';
if (outcome.confirmed === true) return `Plein écran confirmé${where} (${outcome.method})`;
if (outcome.confirmed === false) {
return (
`Plein écran demandé${where} mais non confirmé : la page n'est pas passée en plein ` +
'écran. Le lecteur a-t-il fini de charger ?'
);
}
return `Touche « ${key} » envoyée${where} (${outcome.method}) — effet non vérifiable`;
}

View File

@@ -1,5 +1,6 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { FullscreenOutcome } from './fullscreen.ts';
import { resolveXauthority, sessionEnv } from './x11.ts';
const run = promisify(execFile);
@@ -11,11 +12,6 @@ export interface HotkeyRequest {
windowMatch: string;
}
export interface HotkeyResult {
method: string;
window?: string;
}
/**
* Jeu de touches autorisé. Ces valeurs viennent de la configuration serveur et
* finissent dans une ligne de commande : on refuse tout ce qui sort du lot
@@ -42,7 +38,7 @@ function assertKey(key: string): string {
* on injecte la touche via XTEST. Sur une VM d'enregistrement dédiée c'est sans
* conséquence, mais ça vole le focus si quelqu'un est en train de s'en servir.
*/
export async function sendHotkey(request: HotkeyRequest): Promise<HotkeyResult> {
export async function sendHotkey(request: HotkeyRequest): Promise<FullscreenOutcome> {
const key = assertKey(request.key);
const match = request.windowMatch.trim();
if (!match) throw new Error('Aucun titre de fenêtre à cibler (windowMatch vide)');
@@ -197,10 +193,9 @@ function describeNoMatch(match: string, windows: VisibleWindow[]): string {
return (
`Session Wayland : côté X11, seule la fenêtre technique du compositeur est visible ` +
`(${only}). Firefox y tourne en client Wayland natif — xdotool ne peut ni le voir ni ` +
'lui envoyer de touche. Deux issues : relancer Firefox sous XWayland (ferme toutes ses ' +
"fenêtres d'abord, l'agent pose MOZ_ENABLE_WAYLAND=0 au lancement ; une instance déjà " +
"ouverte en Wayland récupérerait l'URL et le réglage resterait sans effet), ou ouvrir " +
'une session Xorg depuis l\'écran de connexion GDM.'
'lui envoyer de touche, et aucun réglage ne changera cela. Passe le pilotage du ' +
'navigateur en mode « WebDriver BiDi » dans la configuration de cet agent : l\'agent ' +
'parle alors à Firefox directement, sans passer par le serveur d\'affichage.'
);
}
@@ -212,14 +207,14 @@ function describeNoMatch(match: string, windows: VisibleWindow[]): string {
);
}
async function sendLinux(key: string, match: string): Promise<HotkeyResult> {
async function sendLinux(key: string, match: string): Promise<FullscreenOutcome> {
const env = sessionEnv();
const target = await findWindow(env, match);
await run('xdotool', ['windowactivate', '--sync', target.id], { env, timeout: 5000 });
await run('xdotool', ['key', '--clearmodifiers', key], { env, timeout: 5000 });
return { method: 'xdotool', window: target.title };
return { method: 'xdotool', target: target.title };
}
// --- Windows ----------------------------------------------------------------
@@ -229,7 +224,7 @@ function psLiteral(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
async function sendWindows(key: string, match: string): Promise<HotkeyResult> {
async function sendWindows(key: string, match: string): Promise<FullscreenOutcome> {
// SendKeys interprète certains caractères ; les touches nommées se notent {F11}.
const sendKeysArg = key.length === 1 ? key.toLowerCase() : `{${key.toUpperCase()}}`;
@@ -282,7 +277,7 @@ Write-Output $script:foundTitle
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
{ timeout: 20_000, windowsHide: true },
);
return { method: 'SendKeys', window: stdout.trim() || undefined };
return { method: 'SendKeys', target: stdout.trim() || undefined };
} catch (err) {
const stderr = (err as { stderr?: string }).stderr?.trim();
throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`);
@@ -291,7 +286,7 @@ Write-Output $script:foundTitle
// --- macOS (confort de développement) ---------------------------------------
async function sendDarwin(key: string, match: string): Promise<HotkeyResult> {
async function sendDarwin(key: string, match: string): Promise<FullscreenOutcome> {
const script = `
tell application "System Events"
set matches to (every process whose name contains "${match.replace(/["\\]/g, '')}")
@@ -304,7 +299,7 @@ end tell`;
try {
await run('osascript', ['-e', script], { timeout: 15_000 });
return { method: 'osascript', window: match };
return { method: 'osascript', target: match };
} catch (err) {
const stderr = (err as { stderr?: string }).stderr?.trim();
throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`);

View File

@@ -6,6 +6,7 @@ import type {
AgentEvent,
AgentStatus,
AgentToServer,
FullscreenSettings,
LogLevel,
BrowserSettings,
PresetApplyResult,
@@ -32,6 +33,8 @@ import { ObsController } from './obs.ts';
import { StreamWatcher } from './watcher.ts';
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts';
import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.ts';
import { FirefoxController } from './firefox.ts';
import { describeFullscreen, type FullscreenOutcome } from './fullscreen.ts';
import { sendHotkey } from './hotkey.ts';
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
@@ -64,9 +67,18 @@ const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
stopRecording: async () => {
await stopCapture();
},
restoreFullscreen: (settings) => restoreFullscreen(settings),
});
let browserSettings: BrowserSettings = DEFAULT_BROWSER_SETTINGS;
/**
* Instance Firefox pilotée, créée à la volée.
*
* Elle n'existe que dans le mode `bidi` : la construire d'office lancerait un
* navigateur sur toutes les VM, y compris celles où l'opérateur ouvre les pages
* lui-même.
*/
let firefox: FirefoxController | null = null;
let recordingSettings: RecordingSettings = DEFAULT_RECORDING_SETTINGS;
/** Un preset attend d'être appliqué : OBS était injoignable ou occupé. */
let presetPending = false;
@@ -238,9 +250,7 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
}
const url = requireUrl(params);
const opened = await openUrl(browserSettings, url, report, {
forceXWayland: watcher.snapshotSettings.fullscreen.enabled,
});
const opened = await openPage(url);
const wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs);
report('info', `Attente de ${Math.round(wait / 1000)} s avant le plein écran`);
@@ -251,13 +261,19 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
if (fullscreen.enabled) {
// Un échec ici ne doit pas empêcher l'enregistrement : mieux vaut capturer
// une fenêtre non maximisée que ne rien capturer du tout.
fullscreenResult = await sendHotkey({
key: fullscreen.key,
windowMatch: fullscreen.windowMatch,
}).catch((err: Error) => {
report('warn', `Plein écran impossible : ${err.message}`, 'fullscreen.failed');
return `échec : ${err.message}`;
});
fullscreenResult = await restoreFullscreen(fullscreen)
.then((outcome) => {
report(
outcome.confirmed === false ? 'warn' : 'info',
describeFullscreen(outcome, fullscreen.key),
outcome.confirmed === false ? 'fullscreen.failed' : 'fullscreen.restored',
);
return outcome;
})
.catch((err: Error) => {
report('warn', `Plein écran impossible : ${err.message}`, 'fullscreen.failed');
return `échec : ${err.message}`;
});
}
await obs.execute('record.start');
@@ -266,6 +282,27 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
return { opened, fullscreen: fullscreenResult, recording: true };
}
/** Ouvre une page par la voie correspondant au mode de pilotage. */
async function openPage(url: string): Promise<unknown> {
const bidi = driver();
if (bidi) return bidi.navigate(url);
return openUrl(browserSettings, url, report, {
forceXWayland: watcher.snapshotSettings.fullscreen.enabled,
});
}
/** Décharge ou ferme la fenêtre, selon le réglage et le mode de pilotage. */
async function releasePage(): Promise<unknown> {
const bidi = driver();
if (bidi) return browserSettings.onStop === 'close' ? bidi.quit() : bidi.blank();
return browserSettings.onStop === 'close'
? closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report)
: blankPage(browserSettings, report, {
forceXWayland: watcher.snapshotSettings.fullscreen.enabled,
});
}
async function stopCapture(): Promise<unknown> {
const result = await obs.execute('record.stop');
@@ -274,13 +311,7 @@ async function stopCapture(): Promise<unknown> {
// lecteur sans faire disparaître la fenêtre.
let closed: unknown = 'conservée';
if (browserSettings.enabled && browserSettings.onStop !== 'keep') {
const action =
browserSettings.onStop === 'close'
? closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report)
: blankPage(browserSettings, report, {
forceXWayland: watcher.snapshotSettings.fullscreen.enabled,
});
closed = await action.catch((err: Error) => {
closed = await releasePage().catch((err: Error) => {
report('warn', `Libération de la fenêtre impossible : ${err.message}`, 'command.failed');
return `échec : ${err.message}`;
});
@@ -301,11 +332,9 @@ async function runAction(action: AgentAction, params: Record<string, unknown>):
case 'hotkey.fullscreen':
return watcher.restoreFullscreen();
case 'browser.open':
return openUrl(browserSettings, requireUrl(params), report, {
forceXWayland: watcher.snapshotSettings.fullscreen.enabled,
});
return openPage(requireUrl(params));
case 'browser.close':
return closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report);
return driver()?.quit() ?? closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report);
case 'capture.start':
return startCapture(params);
case 'capture.stop':
@@ -331,6 +360,35 @@ function applyWatchSettings(raw: WatchSettings | undefined): void {
function applyBrowserSettings(raw: BrowserSettings | undefined): void {
browserSettings = normalizeBrowserSettings(raw ?? DEFAULT_BROWSER_SETTINGS);
if (browserSettings.mode !== 'bidi') {
// On repasse en mode `launch` : le canal n'a plus de sens, mais on laisse
// volontairement la fenêtre ouverte — OBS la capture peut-être encore.
firefox?.dispose();
firefox = null;
return;
}
if (firefox) firefox.applySettings(browserSettings);
else firefox = new FirefoxController(browserSettings, report);
}
/** Contrôleur BiDi, ou `null` si l'agent n'est pas dans ce mode. */
function driver(): FirefoxController | null {
return browserSettings.mode === 'bidi' ? firefox : null;
}
/**
* Rappelle le plein écran par la voie correspondant au mode de pilotage.
*
* En BiDi, la demande part dans Firefox et son effet est relu dans la page. En
* `launch`, on en reste à une touche envoyée au serveur d'affichage, sans
* moyen de savoir ce qu'elle a produit — d'où le `confirmed` absent.
*/
async function restoreFullscreen(settings: FullscreenSettings): Promise<FullscreenOutcome> {
const bidi = driver();
if (bidi) return bidi.restoreFullscreen(settings);
return sendHotkey({ key: settings.key, windowMatch: settings.windowMatch });
}
// --- Presets d'enregistrement ------------------------------------------------
@@ -416,6 +474,7 @@ async function buildStatus(): Promise<AgentStatus> {
...emptyStatus(),
...snapshot,
watch: watcher.snapshot,
browser: driver()?.state,
buildId: BUILD_ID,
canSelfUpdate: Boolean(runningBundlePath() && config.packageUrl),
lastRecordingPath: obs.recordingPath,
@@ -462,6 +521,9 @@ async function shutdown(signal: string): Promise<void> {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = null;
watcher.stop();
// Le canal BiDi se ferme, mais pas Firefox : sa fenêtre est la source de
// capture d'OBS, et l'enregistrement en cours doit lui survivre.
firefox?.dispose();
// L'enregistrement OBS en cours n'est volontairement pas interrompu.
await obs.disconnect().catch(() => undefined);
socket?.close(1000, 'Arrêt de l\'agent');

View File

@@ -1,13 +1,14 @@
import { EventEmitter } from 'node:events';
import type {
AgentEvent,
FullscreenSettings,
LogLevel,
StreamState,
WatchSettings,
WatchState,
} from '@stream-control/shared';
import { emptyWatchState, fetchStripchatStatus } from '@stream-control/shared';
import { sendHotkey } from './hotkey.ts';
import { describeFullscreen, type FullscreenOutcome } from './fullscreen.ts';
export interface ProbeResult {
/** Statut brut renvoyé par la plateforme. */
@@ -28,6 +29,15 @@ export interface WatchActions {
resumeRecording(): Promise<void>;
/** Clôture définitive : ferme aussi la fenêtre du navigateur si elle est pilotée. */
stopRecording(): Promise<void>;
/**
* Rappelle le plein écran du lecteur.
*
* Injecté plutôt qu'appelé en direct : selon le mode de pilotage, c'est une
* touche envoyée au serveur d'affichage ou une commande WebDriver adressée à
* Firefox. Le surveillant n'a pas à connaître cette différence — il sait
* seulement que le flux public est revenu.
*/
restoreFullscreen(settings: FullscreenSettings): Promise<FullscreenOutcome>;
}
/**
@@ -347,20 +357,25 @@ export class StreamWatcher extends EventEmitter {
if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer);
this.fullscreenTimer = setTimeout(() => {
this.fullscreenTimer = null;
void this.restoreFullscreen();
// L'échec est déjà journalisé par restoreFullscreen ; le rattraper ici
// évite un rejet non géré pour une erreur dont on a déjà rendu compte.
void this.restoreFullscreen().catch(() => undefined);
}, this.settings.fullscreen.delayMs);
this.fullscreenTimer.unref?.();
}
async restoreFullscreen(): Promise<{ method: string; window?: string }> {
const { key, windowMatch } = this.settings.fullscreen;
async restoreFullscreen(): Promise<FullscreenOutcome> {
const fullscreen = this.settings.fullscreen;
try {
const result = await sendHotkey({ key, windowMatch });
const result = await this.actions.restoreFullscreen(fullscreen);
// Un `confirmed: false` n'est pas un échec de commande : la demande est
// partie, la page ne l'a pas suivie. Le distinguer permet de la relire
// dans l'historique sans la confondre avec une erreur de configuration.
this.emit(
'log',
'info',
`Plein écran rappelé : touche « ${key} » envoyée à « ${result.window ?? windowMatch} »`,
'fullscreen.restored',
result.confirmed === false ? 'warn' : 'info',
describeFullscreen(result, fullscreen.key),
result.confirmed === false ? 'fullscreen.failed' : 'fullscreen.restored',
);
return result;
} catch (err) {

View File

@@ -80,6 +80,34 @@ export function resolveDisplay(): string {
return ':0';
}
/**
* Socket Wayland de la session, si elle existe.
*
* Sans cette variable, une application lancée par l'agent retombe sur XWayland
* alors que la session est native — c'est-à-dire sur une copie d'image
* supplémentaire, payée à chaque trame pendant tout l'enregistrement.
*/
export function resolveWaylandDisplay(): string | null {
const declared = process.env.WAYLAND_DISPLAY?.trim();
if (declared) return declared;
const runtime = runtimeDir();
if (!runtime) return null;
try {
const sockets = fs
.readdirSync(runtime)
.filter((entry) => /^wayland-\d+$/.test(entry))
.sort();
for (const socket of sockets) {
if (fs.statSync(path.join(runtime, socket)).isSocket()) return socket;
}
} catch {
/* pas de session Wayland accessible */
}
return null;
}
/**
* Bus de session de l'utilisateur.
*
@@ -122,5 +150,8 @@ export function sessionEnv(): NodeJS.ProcessEnv {
const bus = resolveSessionBus();
if (bus) env.DBUS_SESSION_BUS_ADDRESS = bus;
const wayland = resolveWaylandDisplay();
if (wayland) env.WAYLAND_DISPLAY = wayland;
return env;
}

View File

@@ -377,14 +377,27 @@ export interface WatchSettings {
*/
export interface BrowserSettings {
enabled: boolean;
/**
* Comment l'agent obtient la page.
*
* `launch` relance l'exécutable à chaque fois et lui confie l'URL : simple,
* mais l'agent ne sait rien de ce qui se passe ensuite, et le plein écran
* dépend alors de xdotool — donc d'une session X11.
*
* `bidi` ouvre Firefox une fois et garde un canal WebDriver BiDi. L'agent
* navigue, déclenche le plein écran et le *vérifie* dans la page, sans passer
* par le serveur d'affichage : c'est le seul mode qui fonctionne en session
* Wayland native.
*/
mode: BrowserControlMode;
/** Exécutable du navigateur. */
command: string;
/**
* Arguments placés avant l'URL. Vide par défaut, et ce n'est pas un oubli :
* `--new-window` créerait une fenêtre neuve à chaque capture, avec un nouvel
* identifiant X11 — la source « capture de fenêtre » d'OBS perdrait sa cible
* et il faudrait la repointer à la main. Sans argument, Firefox confie l'URL à
* la fenêtre déjà ouverte, qu'OBS continue de capturer.
* Arguments placés avant l'URL (mode `launch` seulement). Vide par défaut, et
* ce n'est pas un oubli : `--new-window` créerait une fenêtre neuve à chaque
* capture, avec un nouvel identifiant X11 — la source « capture de fenêtre »
* d'OBS perdrait sa cible et il faudrait la repointer à la main. Sans
* argument, Firefox confie l'URL à la fenêtre déjà ouverte, qu'OBS capture.
*/
args: string[];
/** Délai avant l'envoi du plein écran, le temps que le lecteur démarre. */
@@ -398,26 +411,75 @@ export interface BrowserSettings {
* le flux tourner.
*/
onStop: BrowserStopAction;
/**
* Port local du « remote agent » de Firefox (mode `bidi`).
*
* Il n'écoute que sur la boucle locale et n'est ouvert que par l'instance que
* l'agent lance lui-même. Le rendre configurable permet de faire cohabiter
* plusieurs profils sur une même machine.
*/
remotePort: number;
/**
* Profil Firefox dédié (mode `bidi`). Vide : l'agent en gère un sous son
* répertoire de données.
*
* Un profil séparé est nécessaire, pas cosmétique : le remote agent ne
* s'active qu'au démarrage du processus, et deux instances ne peuvent pas
* partager un profil — pointer sur celui de l'opérateur donnerait
* « Firefox est déjà ouvert ».
*/
profileDir: string;
}
/**
* Ce que l'agent sait du navigateur qu'il pilote.
*
* Le mode `launch` ne permet rien de tel : on lance un processus et on espère.
* Ce retour est l'autre bénéfice de BiDi, à côté du plein écran — savoir que la
* page est bien celle attendue, sans aller regarder l'écran de la VM.
*/
export interface BrowserState {
/** Canal BiDi établi avec une instance vivante. */
connected: boolean;
/** URL de l'onglet piloté. */
url?: string;
/** Un élément de la page est en plein écran. */
fullscreen?: boolean;
/** Dernier échec de pilotage, effacé à la reconnexion. */
lastError?: string;
}
export const BROWSER_CONTROL_MODES = ['launch', 'bidi'] as const;
export type BrowserControlMode = (typeof BROWSER_CONTROL_MODES)[number];
export const BROWSER_STOP_ACTIONS = ['blank', 'keep', 'close'] as const;
export type BrowserStopAction = (typeof BROWSER_STOP_ACTIONS)[number];
export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
enabled: false,
// `launch` reste le défaut : basculer un agent existant en `bidi` ouvre une
// nouvelle fenêtre Firefox, que la source OBS doit être repointée sur une
// fois. C'est un choix d'opérateur, pas un effet de bord de mise à jour.
mode: 'launch',
command: 'firefox',
args: [],
readyDelayMs: 8000,
onStop: 'blank',
remotePort: 9222,
profileDir: '',
};
export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
const input = (raw ?? {}) as Partial<BrowserSettings>;
const base = DEFAULT_BROWSER_SETTINGS;
const delay = Number(input.readyDelayMs);
const port = Number(input.remotePort);
return {
enabled: input.enabled === true,
mode: (BROWSER_CONTROL_MODES as readonly string[]).includes(input.mode as string)
? (input.mode as BrowserControlMode)
: base.mode,
command:
typeof input.command === 'string' && input.command.trim()
? input.command.trim()
@@ -429,6 +491,12 @@ export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
? Math.min(Math.max(Math.round(delay), 0), 120_000)
: base.readyDelayMs,
onStop: readStopAction(raw),
// Bornes hautes des ports non privilégiés : sous 1024, l'agent tourne sans
// droit de liaison et Firefox échouerait au démarrage.
remotePort: Number.isFinite(port)
? Math.min(Math.max(Math.round(port), 1024), 65_535)
: base.remotePort,
profileDir: typeof input.profileDir === 'string' ? input.profileDir.trim() : base.profileDir,
};
}
@@ -560,6 +628,9 @@ export interface AgentStatus {
/** Surveillance du stream source, absente si elle n'est pas configurée. */
watch?: WatchState;
/** Navigateur piloté, absent hors du mode `bidi`. */
browser?: BrowserState;
/**
* Empreinte courte du binaire en cours d'exécution. Deux agents partageant
* cette valeur tournent sur le même build — c'est ce qui rend visible un

View File

@@ -87,6 +87,18 @@ export function AgentCard({
<p className="error small">{status.obsError}</p>
)}
{/* Le mode BiDi est le seul à savoir ce que fait le navigateur : autant le
dire, plutôt que de laisser deviner depuis l'écran de la VM. */}
{status.browser && (
<p className="muted small">
{status.browser.connected ? '🦊 Firefox piloté' : '🦊 Firefox non connecté'}
{status.browser.url ? ` · ${status.browser.url}` : ''}
{status.browser.lastError ? (
<span className="error"> · {status.browser.lastError}</span>
) : null}
</p>
)}
{watch?.enabled && (
<WatchStrip
watch={watch}

View File

@@ -343,6 +343,43 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
</span>
</label>
<label className="field">
<span>Méthode de pilotage</span>
<select
value={browser.mode}
onChange={(event) =>
patchBrowser({ mode: event.target.value as BrowserSettings['mode'] })
}
>
<option value="bidi">WebDriver BiDi l'agent pilote Firefox (recommandé)</option>
<option value="launch">Lancement simple — une commande par page</option>
</select>
<span className="muted small">
{browser.mode === 'bidi' ? (
<>
L'agent ouvre Firefox une fois et garde un canal de commande : il navigue,
déclenche le plein écran et <strong>vérifie</strong> qu'il a pris. Seule
méthode qui fonctionne en session Wayland — elle ne passe pas par le serveur
d'affichage.
</>
) : (
<>
Une commande est lancée par page, et le plein écran passe par xdotool : il
faut alors une session Xorg, et l'effet de la touche reste invérifiable.
</>
)}
</span>
</label>
{browser.mode === 'bidi' && (
<p className="muted small">
Au premier passage en BiDi, l'agent ouvre une <em>nouvelle</em> fenêtre Firefox,
sur un profil qui lui est propre. Pointe la source « capture de fenêtre » d'OBS
dessus une fois : ensuite elle reste ouverte, y compris entre deux
enregistrements et lors d'un redémarrage de l'agent.
</p>
)}
<div className="row">
<label className="field grow">
<span>Commande du navigateur</span>
@@ -352,21 +389,55 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
placeholder="firefox"
/>
</label>
<label className="field grow">
<span>Arguments (séparés par des espaces)</span>
{browser.mode === 'launch' ? (
<label className="field grow">
<span>Arguments (séparés par des espaces)</span>
<input
value={browser.args.join(' ')}
onChange={(event) =>
patchBrowser({ args: event.target.value.split(/\s+/).filter(Boolean) })
}
placeholder="aucun"
/>
<span className="muted small">
Laisse vide : le lien part vers la fenêtre déjà ouverte.{' '}
<code>--new-window</code> en créerait une nouvelle à chaque capture, et OBS
perdrait sa cible.
</span>
</label>
) : (
<label className="field grow">
<span>Port de pilotage</span>
<input
value={String(browser.remotePort)}
onChange={(event) =>
patchBrowser({ remotePort: Number(event.target.value) || 9222 })
}
inputMode="numeric"
/>
<span className="muted small">
Écoute uniquement sur 127.0.0.1, à ne changer que si le port est déjà pris.
</span>
</label>
)}
</div>
{browser.mode === 'bidi' && (
<label className="field">
<span>Profil Firefox dédié</span>
<input
value={browser.args.join(' ')}
onChange={(event) =>
patchBrowser({ args: event.target.value.split(/\s+/).filter(Boolean) })
}
placeholder="aucun"
value={browser.profileDir}
onChange={(event) => patchBrowser({ profileDir: event.target.value })}
placeholder="~/.stream-control/firefox-profile (par défaut)"
/>
<span className="muted small">
Laisse vide : le lien part vers la fenêtre déjà ouverte. <code>--new-window</code>
{' '}en créerait une nouvelle à chaque capture, et OBS perdrait sa cible.
Un profil séparé est obligatoire : deux instances ne peuvent pas partager le
même, et pointer celui de l'opérateur redonnerait « Firefox est déjà ouvert ».
L'agent y écrit les réglages qui comptent pour un enregistrement — lecture
automatique autorisée, bandeau de plein écran supprimé, aucun onglet d'accueil.
</span>
</label>
</div>
)}
<div className="row">
<label className="field grow">
@@ -404,8 +475,18 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
</label>
<p className="muted small">
La touche et le titre de fenêtre utilisés pour le plein écran sont ceux
configurés ci-dessous, dans « Surveillance du stream ».
{browser.mode === 'bidi' ? (
<>
La touche du plein écran est celle configurée ci-dessous, dans « Surveillance du
stream ». Le titre de fenêtre, lui, ne sert plus : l'agent s'adresse à l'onglet,
pas à une fenêtre.
</>
) : (
<>
La touche et le titre de fenêtre utilisés pour le plein écran sont ceux
configurés ci-dessous, dans « Surveillance du stream ».
</>
)}
</p>
</fieldset>
@@ -574,14 +655,16 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
onChange={(event) => patchFullscreen({ key: event.target.value })}
/>
</label>
<label className="field grow">
<span>Titre de la fenêtre du lecteur</span>
<input
value={watch.fullscreen.windowMatch}
onChange={(event) => patchFullscreen({ windowMatch: event.target.value })}
placeholder="fragment du titre, ex. Stripchat"
/>
</label>
{browser.mode === 'launch' && (
<label className="field grow">
<span>Titre de la fenêtre du lecteur</span>
<input
value={watch.fullscreen.windowMatch}
onChange={(event) => patchFullscreen({ windowMatch: event.target.value })}
placeholder="fragment du titre, ex. Stripchat"
/>
</label>
)}
<label className="field small-field">
<span>Délai (s)</span>
<input