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

130
README.md
View File

@@ -42,8 +42,9 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`.
- Node.js **22+** sur le serveur et sur chaque VM (le serveur utilise `node:sqlite`).
- OBS **28+** sur chaque VM, avec *Outils → Paramètres du serveur WebSocket* activé.
Note le port (4455 par défaut) et le mot de passe.
- Pour le rappel du plein écran sous Ubuntu : `xdotool` et une session X11
(voir [Prérequis pour le rappel du plein écran](#prérequis-pour-le-rappel-du-plein-écran)).
- Pour le rappel du plein écran sous Ubuntu : Firefox, en mode de pilotage « WebDriver BiDi »
(voir [Deux façons de piloter le navigateur](#deux-façons-de-piloter-le-navigateur)).
Le mode historique exige en plus `xdotool` et une session X11.
## Démarrage rapide (développement)
@@ -157,9 +158,75 @@ moment coûte plus cher que quelques secondes d'écran d'attente enregistrées :
Un passage `hors-ligne` (`idle`) ne provoque ni pause ni reprise : seul le retour effectif
du flux public relance l'enregistrement.
### Prérequis pour le rappel du plein écran
### Deux façons de piloter le navigateur
L'envoi de touche se fait au niveau du système, pas via OBS.
Le rappel du plein écran dépend entièrement de ce choix, qui se règle par agent dans
« Pilotage du navigateur ».
| | **WebDriver BiDi** (recommandé) | **Lancement simple** (historique) |
| --- | --- | --- |
| Ouverture d'une page | commande dans l'instance pilotée | un processus lancé par page |
| Plein écran | commande WebDriver adressée à Firefox | touche envoyée au serveur d'affichage |
| Effet vérifié ? | oui — `document.fullscreenElement` est relu | non |
| Session Wayland | **fonctionne** | impossible (voir plus bas) |
| Dépendances | Firefox | xdotool + session X11 |
#### WebDriver BiDi
Firefox expose ce protocole dès qu'on le lance avec `--remote-debugging-port` : ni
geckodriver, ni Selenium, une simple WebSocket JSON sur la boucle locale. L'agent
implémente le strict nécessaire (`bidi.ts`, `firefox.ts`).
Le renversement est là : **l'agent possède le navigateur** au lieu de lui envoyer des URL
en espérant. Il parle à Firefox, pas au serveur d'affichage — d'où le fonctionnement
identique sous Wayland, où xdotool ne voit rien.
Séquence du rappel de plein écran, dans cet ordre volontaire :
1. `browsingContext.activate` — sans quoi la touche partirait vers un onglet d'arrière-plan ;
2. `input.performActions` — la touche configurée (`f` par défaut), délivrée à la page comme
un vrai évènement (`isTrusted: true`), donc traitée par le lecteur du site ;
3. relecture de `document.fullscreenElement` ;
4. si la page n'a pas bougé : `requestFullscreen()` sur l'élément `<video>`, avec
`userActivation` ;
5. relecture, et verdict rapporté dans l'historique de la VM.
**Un profil Firefox dédié est obligatoire**, pas cosmétique : le port de pilotage ne
s'ouvre qu'au démarrage du processus, et deux instances ne peuvent pas partager un profil.
L'agent en gère un sous `~/.stream-control/firefox-profile` et y réécrit un `user.js` à
chaque lancement — chaque préférence y supprime quelque chose qui finirait dans le fichier
enregistré, ou qui empêcherait un démarrage sans surveillance :
| Préférence | Pourquoi |
| --- | --- |
| `full-screen-api.warning.timeout = 0` | le bandeau « … est maintenant en plein écran » se retrouvait dans les premières secondes de chaque fichier |
| `media.autoplay.default = 0` | sans lecture automatique, l'agent enregistre une image fixe sans que rien ne le signale |
| `browser.aboutwelcome.enabled = false`, `browser.startup.page = 0`, … | tout onglet d'accueil passerait devant la page du streamer |
| `browser.sessionstore.resume_from_crash = false` | un dialogue modal bloquerait toute commande |
| `app.update.auto = false` | une mise à jour fermerait la fenêtre que capture OBS, en plein enregistrement |
**Au premier passage en BiDi, une nouvelle fenêtre Firefox s'ouvre** : pointe la source
« capture de fenêtre » d'OBS dessus une fois. Ensuite elle survit aux enregistrements
comme aux redémarrages de l'agent — le processus est lancé détaché, et l'agent se
rattache au port plutôt que de relancer.
#### Si le plein écran est refusé
Firefox refuse le plein écran à un document **dont la fenêtre n'a pas le focus**. C'est la
première cause d'échec sur une VM : il suffit qu'OBS ou un terminal l'ait pris. L'agent
relit alors trois conditions depuis la page et nomme celle qui manque, plutôt que de
répercuter un « Fullscreen request denied » qui ne dit rien :
| Message | Cause |
| --- | --- |
| `la fenêtre Firefox n'a pas le focus` | une autre fenêtre est active dans la session |
| `aucun élément vidéo dans la page` | le lecteur n'a pas fini de charger — augmente le délai avant plein écran |
| `l'API plein écran est désactivée dans ce profil` | `full-screen-api.enabled` forcé à faux |
#### Lancement simple
Conservé comme défaut pour ne pas changer le comportement d'un agent existant à la mise à
jour. L'envoi de touche se fait au niveau du système, pas via OBS.
| OS | Mécanisme | À prévoir |
| --- | --- | --- |
@@ -168,13 +235,8 @@ L'envoi de touche se fait au niveau du système, pas via OBS.
| Windows | `SetForegroundWindow` + `SendKeys` | L'agent doit tourner dans la session interactive — d'où la tâche planifiée plutôt qu'un service |
| macOS | AppleScript System Events | Autorisation Accessibilité (prévu pour le développement) |
Dans les deux cas la fenêtre du lecteur passe **au premier plan** : les navigateurs
ignorent les évènements clavier synthétiques envoyés sans focus (`XSendEvent`). Sans
conséquence sur une VM d'enregistrement dédiée, gênant si quelqu'un s'en sert en même
temps.
Le bouton ⛶ sur la fiche de l'agent renvoie la touche à la demande, et ⟳ force une sonde
immédiate — les deux servent à valider le titre de fenêtre sans attendre un vrai show privé.
La fenêtre du lecteur passe **au premier plan** : les navigateurs ignorent les évènements
clavier synthétiques envoyés sans focus (`XSendEvent`).
Le **titre de fenêtre** à renseigner est celui de la fenêtre, pas le nom du processus. Sous
Firefox il vaut `<titre de la page> — Mozilla Firefox`, et le titre d'une page Stripchat se
@@ -182,9 +244,6 @@ termine par `| Stripchat` : `Stripchat` comme `Firefox` conviennent donc. En cas
l'agent liste les fenêtres qu'il voit réellement, et `agent.cjs --check` fait de même sans
rien déclencher.
Trois causes distinctes produisaient autrefois le même message « aucune fenêtre ne
correspond » ; elles sont maintenant séparées :
| Symptôme | Cause |
| --- | --- |
| `Authorization required` / `Invalid MIT-MAGIC-COOKIE-1 key` | cookie X introuvable ou périmé |
@@ -192,6 +251,9 @@ correspond » ; elles sont maintenant séparées :
| `Aucune fenêtre visible sur DISPLAY=:0` | navigateur lancé hors de la session de l'agent |
| Liste des fenêtres ouvertes | titre mal renseigné — recopier un fragment de la liste |
Le bouton ⛶ sur la fiche de l'agent renvoie la touche à la demande, et ⟳ force une sonde
immédiate.
### Wayland
Ubuntu démarre en session Wayland par défaut, et Firefox y tourne en client Wayland natif.
@@ -199,18 +261,14 @@ Une telle fenêtre est **invisible à xdotool** : ni activation, ni envoi de tou
symptôme est net — côté X11, seule `mutter guard window` apparaît, la fenêtre technique du
compositeur. L'agent reconnaît cette signature et le dit explicitement.
Deux issues :
**La réponse est le mode WebDriver BiDi**, qui ne passe pas par le serveur d'affichage.
- **Rester en Wayland, passer Firefox sous XWayland.** L'agent pose `MOZ_ENABLE_WAYLAND=0`
en lançant le navigateur, ce qui suffit — *à condition qu'aucune instance Firefox ne
tourne déjà*. Firefox délègue l'URL à l'instance existante, qui garde ses propres
variables d'environnement : ferme toutes ses fenêtres avant de lancer la capture.
- **Ouvrir une session Xorg.** Sur l'écran de connexion GDM, roue dentée en bas à droite →
« Ubuntu sur Xorg ». Tout redevient pilotable, y compris une fenêtre ouverte à la main.
Les contournements d'avant sont conservés ici pour mémoire, mais ils coûtent tous quelque
chose et aucun n'est à préférer :
Si le rappel du plein écran s'avère fragile sur ta VM, l'alternative sans clavier est de
lancer le navigateur en mode kiosque (`chromium --kiosk`) : il n'y a alors plus de plein
écran à restaurer.
- forcer Firefox sous XWayland (`MOZ_ENABLE_WAYLAND=0`) ajoute une copie d'image par trame ;
- basculer la session en Xorg fait chuter les performances de capture (voir
« Xorg, Wayland et le coût de la capture »).
## Presets d'enregistrement
@@ -271,8 +329,9 @@ Sur une VM sans accélération 3D, la méthode de capture pèse lourd :
- **Xorg** : la capture d'écran XSHM recopie tout le tampon à chaque image. La capture de
fenêtre XComposite est nettement plus économe — à préférer systématiquement.
Xorg n'est nécessaire que pour le rappel du plein écran par xdotool. Si le cadrage se fait
côté OBS, Wayland est le meilleur choix en performance.
Xorg n'était nécessaire que pour le rappel du plein écran par xdotool. Le mode de pilotage
« WebDriver BiDi » supprime cette contrainte : **Wayland est le bon choix**, en performance
comme en simplicité.
Un preset trop lourd pour la VM fait chuter les images par seconde : la qualité perçue
baisse alors malgré un meilleur CRF. Après un changement, surveille « FPS » et les deux
@@ -369,7 +428,12 @@ La source « capture de fenêtre » d'OBS mémorise un identifiant de fenêtre X
fenêtre du navigateur, ou en ouvrir une nouvelle, invalide cet identifiant : la source
devient noire et il faut la repointer à la main.
L'agent évite donc les deux :
En mode **WebDriver BiDi** le problème disparaît : l'agent ne relance jamais de processus,
il navigue dans l'onglet qu'il pilote. La fenêtre est ouverte une fois et survit à tout,
y compris à un redémarrage de l'agent — celui-ci se rattache au port au lieu de relancer.
La seule fois où il faut repointer la source OBS est le passage initial en BiDi.
En mode **lancement simple**, l'agent évite les deux pièges :
- **aucun argument** par défaut (`--new-window` est proscrit) : Firefox confie l'URL à la
fenêtre déjà ouverte ;
@@ -381,8 +445,10 @@ arguments ou leur comportement d'arrêt ont été personnalisés.
### « Firefox est déjà ouvert »
Ce dialogue signifie que le processus lancé par l'agent n'a pas trouvé l'instance déjà en
cours : il bute alors sur le verrou de profil au lieu de lui confier l'URL.
Propre au mode **lancement simple** : ce dialogue signifie que le processus lancé par
l'agent n'a pas trouvé l'instance déjà en cours, et bute sur le verrou de profil au lieu de
lui confier l'URL. Le mode BiDi ne peut pas le rencontrer — il n'y a qu'une instance, sur
un profil qui n'appartient qu'à l'agent.
Le passage de relais se fait par le **bus de session D-Bus** — le seul mécanisme disponible
sous Wayland, le protocole X de remoting n'y existant pas. Or un service systemd « system »
@@ -395,13 +461,17 @@ n'hérite pas de ce bus, pas plus qu'il n'hérite du cookie X. L'agent résout d
| `XAUTHORITY` | `$XAUTHORITY` s'il existe, puis GDM, Xwayland, `~/.Xauthority` |
| `XDG_RUNTIME_DIR` | valeur héritée, sinon `/run/user/<uid>` |
| `DBUS_SESSION_BUS_ADDRESS` | valeur héritée, sinon la socket `$XDG_RUNTIME_DIR/bus` |
| `WAYLAND_DISPLAY` | valeur héritée, sinon la socket `wayland-<n>` du répertoire d'exécution |
`agent.cjs --check` affiche les quatre. Si le bus ressort en avertissement, vérifie que
`agent.cjs --check` les affiche toutes. Si le bus ressort en avertissement, vérifie que
l'agent tourne bien sous **le même compte** que la session graphique : l'utilisateur est
choisi par `--user` à l'installation.
### Éviter l'accumulation d'onglets
Propre lui aussi au mode **lancement simple** ; en BiDi l'agent navigue toujours dans le
même onglet.
Avec les réglages d'usine de Firefox, un lien venu de l'extérieur ouvre un **nouvel
onglet** : chaque cycle d'enregistrement en laisse donc derrière lui, et les anciens
continuent de décoder leur page. Sur une VM d'enregistrement, règle une fois pour toutes

View File

@@ -138,13 +138,20 @@ else
info "Node.js $(node -v) présent"
fi
# --- xdotool (rappel plein écran) -------------------------------------------
# --- Prérequis du rappel plein écran ----------------------------------------
# Firefox est le prérequis du mode de pilotage « WebDriver BiDi », le seul qui
# fonctionne en session Wayland. xdotool ne sert plus qu'au mode historique.
if ! command -v firefox >/dev/null 2>&1; then
echo "! Firefox absent : le pilotage « WebDriver BiDi » sera indisponible."
echo " Installe-le sur cette VM (snap install firefox, ou apt install firefox)."
fi
if ! command -v xdotool >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
info "Installation de xdotool (rappel plein écran)"
info "Installation de xdotool (mode de pilotage historique)"
apt-get install -y xdotool >/dev/null
else
echo "! xdotool absent : le rappel plein écran sera indisponible."
echo "! xdotool absent : seul le pilotage « WebDriver BiDi » sera disponible."
fi
fi

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