REVAMP : Firefox handling
This commit is contained in:
180
packages/agent/src/bidi.ts
Normal file
180
packages/agent/src/bidi.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
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);
|
||||
}
|
||||
31
packages/agent/src/fullscreen.ts
Normal file
31
packages/agent/src/fullscreen.ts
Normal 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`;
|
||||
}
|
||||
@@ -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} »`);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user