REVAMP : Firefox handling
This commit is contained in:
130
README.md
130
README.md
@@ -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`).
|
- 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é.
|
- 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.
|
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
|
- Pour le rappel du plein écran sous Ubuntu : Firefox, en mode de pilotage « WebDriver BiDi »
|
||||||
(voir [Prérequis pour le rappel du plein écran](#prérequis-pour-le-rappel-du-plein-écran)).
|
(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)
|
## 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
|
Un passage `hors-ligne` (`idle`) ne provoque ni pause ni reprise : seul le retour effectif
|
||||||
du flux public relance l'enregistrement.
|
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 |
|
| 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 |
|
| 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) |
|
| 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
|
La fenêtre du lecteur passe **au premier plan** : les navigateurs ignorent les évènements
|
||||||
ignorent les évènements clavier synthétiques envoyés sans focus (`XSendEvent`). Sans
|
clavier synthétiques envoyés sans focus (`XSendEvent`).
|
||||||
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é.
|
|
||||||
|
|
||||||
Le **titre de fenêtre** à renseigner est celui de la fenêtre, pas le nom du processus. Sous
|
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
|
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
|
l'agent liste les fenêtres qu'il voit réellement, et `agent.cjs --check` fait de même sans
|
||||||
rien déclencher.
|
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 |
|
| Symptôme | Cause |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Authorization required` / `Invalid MIT-MAGIC-COOKIE-1 key` | cookie X introuvable ou périmé |
|
| `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 |
|
| `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 |
|
| 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
|
### Wayland
|
||||||
|
|
||||||
Ubuntu démarre en session Wayland par défaut, et Firefox y tourne en client Wayland natif.
|
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
|
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.
|
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`
|
Les contournements d'avant sont conservés ici pour mémoire, mais ils coûtent tous quelque
|
||||||
en lançant le navigateur, ce qui suffit — *à condition qu'aucune instance Firefox ne
|
chose et aucun n'est à préférer :
|
||||||
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.
|
|
||||||
|
|
||||||
Si le rappel du plein écran s'avère fragile sur ta VM, l'alternative sans clavier est de
|
- forcer Firefox sous XWayland (`MOZ_ENABLE_WAYLAND=0`) ajoute une copie d'image par trame ;
|
||||||
lancer le navigateur en mode kiosque (`chromium --kiosk`) : il n'y a alors plus de plein
|
- basculer la session en Xorg fait chuter les performances de capture (voir
|
||||||
écran à restaurer.
|
« Xorg, Wayland et le coût de la capture »).
|
||||||
|
|
||||||
## Presets d'enregistrement
|
## 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
|
- **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.
|
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
|
Xorg n'était nécessaire que pour le rappel du plein écran par xdotool. Le mode de pilotage
|
||||||
côté OBS, Wayland est le meilleur choix en performance.
|
« 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
|
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
|
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
|
fenêtre du navigateur, ou en ouvrir une nouvelle, invalide cet identifiant : la source
|
||||||
devient noire et il faut la repointer à la main.
|
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
|
- **aucun argument** par défaut (`--new-window` est proscrit) : Firefox confie l'URL à la
|
||||||
fenêtre déjà ouverte ;
|
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 »
|
### « Firefox est déjà ouvert »
|
||||||
|
|
||||||
Ce dialogue signifie que le processus lancé par l'agent n'a pas trouvé l'instance déjà en
|
Propre au mode **lancement simple** : ce dialogue signifie que le processus lancé par
|
||||||
cours : il bute alors sur le verrou de profil au lieu de lui confier l'URL.
|
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
|
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 »
|
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` |
|
| `XAUTHORITY` | `$XAUTHORITY` s'il existe, puis GDM, Xwayland, `~/.Xauthority` |
|
||||||
| `XDG_RUNTIME_DIR` | valeur héritée, sinon `/run/user/<uid>` |
|
| `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` |
|
| `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
|
l'agent tourne bien sous **le même compte** que la session graphique : l'utilisateur est
|
||||||
choisi par `--user` à l'installation.
|
choisi par `--user` à l'installation.
|
||||||
|
|
||||||
### Éviter l'accumulation d'onglets
|
### É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
|
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
|
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
|
continuent de décoder leur page. Sur une VM d'enregistrement, règle une fois pour toutes
|
||||||
|
|||||||
@@ -138,13 +138,20 @@ else
|
|||||||
info "Node.js $(node -v) présent"
|
info "Node.js $(node -v) présent"
|
||||||
fi
|
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 xdotool >/dev/null 2>&1; then
|
||||||
if command -v apt-get >/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
|
apt-get install -y xdotool >/dev/null
|
||||||
else
|
else
|
||||||
echo "! xdotool absent : le rappel plein écran sera indisponible."
|
echo "! xdotool absent : seul le pilotage « WebDriver BiDi » sera disponible."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
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
|
* processus — jamais dans un shell, donc pas d'injection possible — mais un
|
||||||
* `file://` ou un `javascript:` n'aurait rien à faire ici.
|
* `file://` ou un `javascript:` n'aurait rien à faire ici.
|
||||||
*/
|
*/
|
||||||
function assertWebUrl(url: string): string {
|
export function assertWebUrl(url: string): string {
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(url);
|
parsed = new URL(url);
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { promisify } from 'node:util';
|
|||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import { CONFIG_PATH, type AgentConfig } from './config.ts';
|
import { CONFIG_PATH, type AgentConfig } from './config.ts';
|
||||||
import { looksLikeWayland } from './hotkey.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);
|
const run = promisify(execFile);
|
||||||
|
|
||||||
@@ -266,10 +272,36 @@ export async function runDiagnostics(config: AgentConfig): Promise<number> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (process.env.WAYLAND_DISPLAY) {
|
const wayland = resolveWaylandDisplay();
|
||||||
line('warn', 'session', 'Wayland détecté — xdotool exige X11');
|
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') {
|
} else if (process.platform === 'win32') {
|
||||||
const hasPowershell = await commandExists('powershell.exe', ['-NoProfile', '-Command', 'exit']);
|
const hasPowershell = await commandExists('powershell.exe', ['-NoProfile', '-Command', 'exit']);
|
||||||
results.push(
|
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 { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
import type { FullscreenOutcome } from './fullscreen.ts';
|
||||||
import { resolveXauthority, sessionEnv } from './x11.ts';
|
import { resolveXauthority, sessionEnv } from './x11.ts';
|
||||||
|
|
||||||
const run = promisify(execFile);
|
const run = promisify(execFile);
|
||||||
@@ -11,11 +12,6 @@ export interface HotkeyRequest {
|
|||||||
windowMatch: string;
|
windowMatch: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HotkeyResult {
|
|
||||||
method: string;
|
|
||||||
window?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Jeu de touches autorisé. Ces valeurs viennent de la configuration serveur et
|
* 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
|
* 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
|
* 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.
|
* 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 key = assertKey(request.key);
|
||||||
const match = request.windowMatch.trim();
|
const match = request.windowMatch.trim();
|
||||||
if (!match) throw new Error('Aucun titre de fenêtre à cibler (windowMatch vide)');
|
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 (
|
return (
|
||||||
`Session Wayland : côté X11, seule la fenêtre technique du compositeur est visible ` +
|
`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 ` +
|
`(${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 ' +
|
'lui envoyer de touche, et aucun réglage ne changera cela. Passe le pilotage du ' +
|
||||||
"fenêtres d'abord, l'agent pose MOZ_ENABLE_WAYLAND=0 au lancement ; une instance déjà " +
|
'navigateur en mode « WebDriver BiDi » dans la configuration de cet agent : l\'agent ' +
|
||||||
"ouverte en Wayland récupérerait l'URL et le réglage resterait sans effet), ou ouvrir " +
|
'parle alors à Firefox directement, sans passer par le serveur d\'affichage.'
|
||||||
'une session Xorg depuis l\'écran de connexion GDM.'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 env = sessionEnv();
|
||||||
const target = await findWindow(env, match);
|
const target = await findWindow(env, match);
|
||||||
|
|
||||||
await run('xdotool', ['windowactivate', '--sync', target.id], { env, timeout: 5000 });
|
await run('xdotool', ['windowactivate', '--sync', target.id], { env, timeout: 5000 });
|
||||||
await run('xdotool', ['key', '--clearmodifiers', key], { 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 ----------------------------------------------------------------
|
// --- Windows ----------------------------------------------------------------
|
||||||
@@ -229,7 +224,7 @@ function psLiteral(value: string): string {
|
|||||||
return `'${value.replace(/'/g, "''")}'`;
|
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}.
|
// SendKeys interprète certains caractères ; les touches nommées se notent {F11}.
|
||||||
const sendKeysArg = key.length === 1 ? key.toLowerCase() : `{${key.toUpperCase()}}`;
|
const sendKeysArg = key.length === 1 ? key.toLowerCase() : `{${key.toUpperCase()}}`;
|
||||||
|
|
||||||
@@ -282,7 +277,7 @@ Write-Output $script:foundTitle
|
|||||||
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
|
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
|
||||||
{ timeout: 20_000, windowsHide: true },
|
{ timeout: 20_000, windowsHide: true },
|
||||||
);
|
);
|
||||||
return { method: 'SendKeys', window: stdout.trim() || undefined };
|
return { method: 'SendKeys', target: stdout.trim() || undefined };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const stderr = (err as { stderr?: string }).stderr?.trim();
|
const stderr = (err as { stderr?: string }).stderr?.trim();
|
||||||
throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`);
|
throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`);
|
||||||
@@ -291,7 +286,7 @@ Write-Output $script:foundTitle
|
|||||||
|
|
||||||
// --- macOS (confort de développement) ---------------------------------------
|
// --- 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 = `
|
const script = `
|
||||||
tell application "System Events"
|
tell application "System Events"
|
||||||
set matches to (every process whose name contains "${match.replace(/["\\]/g, '')}")
|
set matches to (every process whose name contains "${match.replace(/["\\]/g, '')}")
|
||||||
@@ -304,7 +299,7 @@ end tell`;
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await run('osascript', ['-e', script], { timeout: 15_000 });
|
await run('osascript', ['-e', script], { timeout: 15_000 });
|
||||||
return { method: 'osascript', window: match };
|
return { method: 'osascript', target: match };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const stderr = (err as { stderr?: string }).stderr?.trim();
|
const stderr = (err as { stderr?: string }).stderr?.trim();
|
||||||
throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`);
|
throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
AgentEvent,
|
AgentEvent,
|
||||||
AgentStatus,
|
AgentStatus,
|
||||||
AgentToServer,
|
AgentToServer,
|
||||||
|
FullscreenSettings,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
BrowserSettings,
|
BrowserSettings,
|
||||||
PresetApplyResult,
|
PresetApplyResult,
|
||||||
@@ -32,6 +33,8 @@ import { ObsController } from './obs.ts';
|
|||||||
import { StreamWatcher } from './watcher.ts';
|
import { StreamWatcher } from './watcher.ts';
|
||||||
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts';
|
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts';
|
||||||
import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.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 { sendHotkey } from './hotkey.ts';
|
||||||
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
|
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
|
||||||
|
|
||||||
@@ -64,9 +67,18 @@ const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
|
|||||||
stopRecording: async () => {
|
stopRecording: async () => {
|
||||||
await stopCapture();
|
await stopCapture();
|
||||||
},
|
},
|
||||||
|
restoreFullscreen: (settings) => restoreFullscreen(settings),
|
||||||
});
|
});
|
||||||
|
|
||||||
let browserSettings: BrowserSettings = DEFAULT_BROWSER_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;
|
let recordingSettings: RecordingSettings = DEFAULT_RECORDING_SETTINGS;
|
||||||
/** Un preset attend d'être appliqué : OBS était injoignable ou occupé. */
|
/** Un preset attend d'être appliqué : OBS était injoignable ou occupé. */
|
||||||
let presetPending = false;
|
let presetPending = false;
|
||||||
@@ -238,9 +250,7 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = requireUrl(params);
|
const url = requireUrl(params);
|
||||||
const opened = await openUrl(browserSettings, url, report, {
|
const opened = await openPage(url);
|
||||||
forceXWayland: watcher.snapshotSettings.fullscreen.enabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
const wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs);
|
const wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs);
|
||||||
report('info', `Attente de ${Math.round(wait / 1000)} s avant le plein écran`);
|
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) {
|
if (fullscreen.enabled) {
|
||||||
// Un échec ici ne doit pas empêcher l'enregistrement : mieux vaut capturer
|
// 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.
|
// une fenêtre non maximisée que ne rien capturer du tout.
|
||||||
fullscreenResult = await sendHotkey({
|
fullscreenResult = await restoreFullscreen(fullscreen)
|
||||||
key: fullscreen.key,
|
.then((outcome) => {
|
||||||
windowMatch: fullscreen.windowMatch,
|
report(
|
||||||
}).catch((err: Error) => {
|
outcome.confirmed === false ? 'warn' : 'info',
|
||||||
report('warn', `Plein écran impossible : ${err.message}`, 'fullscreen.failed');
|
describeFullscreen(outcome, fullscreen.key),
|
||||||
return `échec : ${err.message}`;
|
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');
|
await obs.execute('record.start');
|
||||||
@@ -266,6 +282,27 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
|
|||||||
return { opened, fullscreen: fullscreenResult, recording: true };
|
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> {
|
async function stopCapture(): Promise<unknown> {
|
||||||
const result = await obs.execute('record.stop');
|
const result = await obs.execute('record.stop');
|
||||||
|
|
||||||
@@ -274,13 +311,7 @@ async function stopCapture(): Promise<unknown> {
|
|||||||
// lecteur sans faire disparaître la fenêtre.
|
// lecteur sans faire disparaître la fenêtre.
|
||||||
let closed: unknown = 'conservée';
|
let closed: unknown = 'conservée';
|
||||||
if (browserSettings.enabled && browserSettings.onStop !== 'keep') {
|
if (browserSettings.enabled && browserSettings.onStop !== 'keep') {
|
||||||
const action =
|
closed = await releasePage().catch((err: Error) => {
|
||||||
browserSettings.onStop === 'close'
|
|
||||||
? closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report)
|
|
||||||
: blankPage(browserSettings, report, {
|
|
||||||
forceXWayland: watcher.snapshotSettings.fullscreen.enabled,
|
|
||||||
});
|
|
||||||
closed = await action.catch((err: Error) => {
|
|
||||||
report('warn', `Libération de la fenêtre impossible : ${err.message}`, 'command.failed');
|
report('warn', `Libération de la fenêtre impossible : ${err.message}`, 'command.failed');
|
||||||
return `échec : ${err.message}`;
|
return `échec : ${err.message}`;
|
||||||
});
|
});
|
||||||
@@ -301,11 +332,9 @@ async function runAction(action: AgentAction, params: Record<string, unknown>):
|
|||||||
case 'hotkey.fullscreen':
|
case 'hotkey.fullscreen':
|
||||||
return watcher.restoreFullscreen();
|
return watcher.restoreFullscreen();
|
||||||
case 'browser.open':
|
case 'browser.open':
|
||||||
return openUrl(browserSettings, requireUrl(params), report, {
|
return openPage(requireUrl(params));
|
||||||
forceXWayland: watcher.snapshotSettings.fullscreen.enabled,
|
|
||||||
});
|
|
||||||
case 'browser.close':
|
case 'browser.close':
|
||||||
return closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report);
|
return driver()?.quit() ?? closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report);
|
||||||
case 'capture.start':
|
case 'capture.start':
|
||||||
return startCapture(params);
|
return startCapture(params);
|
||||||
case 'capture.stop':
|
case 'capture.stop':
|
||||||
@@ -331,6 +360,35 @@ function applyWatchSettings(raw: WatchSettings | undefined): void {
|
|||||||
|
|
||||||
function applyBrowserSettings(raw: BrowserSettings | undefined): void {
|
function applyBrowserSettings(raw: BrowserSettings | undefined): void {
|
||||||
browserSettings = normalizeBrowserSettings(raw ?? DEFAULT_BROWSER_SETTINGS);
|
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 ------------------------------------------------
|
// --- Presets d'enregistrement ------------------------------------------------
|
||||||
@@ -416,6 +474,7 @@ async function buildStatus(): Promise<AgentStatus> {
|
|||||||
...emptyStatus(),
|
...emptyStatus(),
|
||||||
...snapshot,
|
...snapshot,
|
||||||
watch: watcher.snapshot,
|
watch: watcher.snapshot,
|
||||||
|
browser: driver()?.state,
|
||||||
buildId: BUILD_ID,
|
buildId: BUILD_ID,
|
||||||
canSelfUpdate: Boolean(runningBundlePath() && config.packageUrl),
|
canSelfUpdate: Boolean(runningBundlePath() && config.packageUrl),
|
||||||
lastRecordingPath: obs.recordingPath,
|
lastRecordingPath: obs.recordingPath,
|
||||||
@@ -462,6 +521,9 @@ async function shutdown(signal: string): Promise<void> {
|
|||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
watcher.stop();
|
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.
|
// L'enregistrement OBS en cours n'est volontairement pas interrompu.
|
||||||
await obs.disconnect().catch(() => undefined);
|
await obs.disconnect().catch(() => undefined);
|
||||||
socket?.close(1000, 'Arrêt de l\'agent');
|
socket?.close(1000, 'Arrêt de l\'agent');
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import type {
|
import type {
|
||||||
AgentEvent,
|
AgentEvent,
|
||||||
|
FullscreenSettings,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
StreamState,
|
StreamState,
|
||||||
WatchSettings,
|
WatchSettings,
|
||||||
WatchState,
|
WatchState,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
import { emptyWatchState, fetchStripchatStatus } 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 {
|
export interface ProbeResult {
|
||||||
/** Statut brut renvoyé par la plateforme. */
|
/** Statut brut renvoyé par la plateforme. */
|
||||||
@@ -28,6 +29,15 @@ export interface WatchActions {
|
|||||||
resumeRecording(): Promise<void>;
|
resumeRecording(): Promise<void>;
|
||||||
/** Clôture définitive : ferme aussi la fenêtre du navigateur si elle est pilotée. */
|
/** Clôture définitive : ferme aussi la fenêtre du navigateur si elle est pilotée. */
|
||||||
stopRecording(): Promise<void>;
|
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);
|
if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer);
|
||||||
this.fullscreenTimer = setTimeout(() => {
|
this.fullscreenTimer = setTimeout(() => {
|
||||||
this.fullscreenTimer = null;
|
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.settings.fullscreen.delayMs);
|
||||||
this.fullscreenTimer.unref?.();
|
this.fullscreenTimer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
async restoreFullscreen(): Promise<{ method: string; window?: string }> {
|
async restoreFullscreen(): Promise<FullscreenOutcome> {
|
||||||
const { key, windowMatch } = this.settings.fullscreen;
|
const fullscreen = this.settings.fullscreen;
|
||||||
try {
|
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(
|
this.emit(
|
||||||
'log',
|
'log',
|
||||||
'info',
|
result.confirmed === false ? 'warn' : 'info',
|
||||||
`Plein écran rappelé : touche « ${key} » envoyée à « ${result.window ?? windowMatch} »`,
|
describeFullscreen(result, fullscreen.key),
|
||||||
'fullscreen.restored',
|
result.confirmed === false ? 'fullscreen.failed' : 'fullscreen.restored',
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -80,6 +80,34 @@ export function resolveDisplay(): string {
|
|||||||
return ':0';
|
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.
|
* Bus de session de l'utilisateur.
|
||||||
*
|
*
|
||||||
@@ -122,5 +150,8 @@ export function sessionEnv(): NodeJS.ProcessEnv {
|
|||||||
const bus = resolveSessionBus();
|
const bus = resolveSessionBus();
|
||||||
if (bus) env.DBUS_SESSION_BUS_ADDRESS = bus;
|
if (bus) env.DBUS_SESSION_BUS_ADDRESS = bus;
|
||||||
|
|
||||||
|
const wayland = resolveWaylandDisplay();
|
||||||
|
if (wayland) env.WAYLAND_DISPLAY = wayland;
|
||||||
|
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,14 +377,27 @@ export interface WatchSettings {
|
|||||||
*/
|
*/
|
||||||
export interface BrowserSettings {
|
export interface BrowserSettings {
|
||||||
enabled: boolean;
|
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. */
|
/** Exécutable du navigateur. */
|
||||||
command: string;
|
command: string;
|
||||||
/**
|
/**
|
||||||
* Arguments placés avant l'URL. Vide par défaut, et ce n'est pas un oubli :
|
* Arguments placés avant l'URL (mode `launch` seulement). Vide par défaut, et
|
||||||
* `--new-window` créerait une fenêtre neuve à chaque capture, avec un nouvel
|
* ce n'est pas un oubli : `--new-window` créerait une fenêtre neuve à chaque
|
||||||
* identifiant X11 — la source « capture de fenêtre » d'OBS perdrait sa cible
|
* capture, avec un nouvel identifiant X11 — la source « capture de fenêtre »
|
||||||
* et il faudrait la repointer à la main. Sans argument, Firefox confie l'URL à
|
* d'OBS perdrait sa cible et il faudrait la repointer à la main. Sans
|
||||||
* la fenêtre déjà ouverte, qu'OBS continue de capturer.
|
* argument, Firefox confie l'URL à la fenêtre déjà ouverte, qu'OBS capture.
|
||||||
*/
|
*/
|
||||||
args: string[];
|
args: string[];
|
||||||
/** Délai avant l'envoi du plein écran, le temps que le lecteur démarre. */
|
/** 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.
|
* le flux tourner.
|
||||||
*/
|
*/
|
||||||
onStop: BrowserStopAction;
|
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 const BROWSER_STOP_ACTIONS = ['blank', 'keep', 'close'] as const;
|
||||||
export type BrowserStopAction = (typeof BROWSER_STOP_ACTIONS)[number];
|
export type BrowserStopAction = (typeof BROWSER_STOP_ACTIONS)[number];
|
||||||
|
|
||||||
export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
|
export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
|
||||||
enabled: false,
|
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',
|
command: 'firefox',
|
||||||
args: [],
|
args: [],
|
||||||
readyDelayMs: 8000,
|
readyDelayMs: 8000,
|
||||||
onStop: 'blank',
|
onStop: 'blank',
|
||||||
|
remotePort: 9222,
|
||||||
|
profileDir: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
|
export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
|
||||||
const input = (raw ?? {}) as Partial<BrowserSettings>;
|
const input = (raw ?? {}) as Partial<BrowserSettings>;
|
||||||
const base = DEFAULT_BROWSER_SETTINGS;
|
const base = DEFAULT_BROWSER_SETTINGS;
|
||||||
const delay = Number(input.readyDelayMs);
|
const delay = Number(input.readyDelayMs);
|
||||||
|
const port = Number(input.remotePort);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: input.enabled === true,
|
enabled: input.enabled === true,
|
||||||
|
mode: (BROWSER_CONTROL_MODES as readonly string[]).includes(input.mode as string)
|
||||||
|
? (input.mode as BrowserControlMode)
|
||||||
|
: base.mode,
|
||||||
command:
|
command:
|
||||||
typeof input.command === 'string' && input.command.trim()
|
typeof input.command === 'string' && input.command.trim()
|
||||||
? 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)
|
? Math.min(Math.max(Math.round(delay), 0), 120_000)
|
||||||
: base.readyDelayMs,
|
: base.readyDelayMs,
|
||||||
onStop: readStopAction(raw),
|
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. */
|
/** Surveillance du stream source, absente si elle n'est pas configurée. */
|
||||||
watch?: WatchState;
|
watch?: WatchState;
|
||||||
|
|
||||||
|
/** Navigateur piloté, absent hors du mode `bidi`. */
|
||||||
|
browser?: BrowserState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Empreinte courte du binaire en cours d'exécution. Deux agents partageant
|
* 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
|
* 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>
|
<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 && (
|
{watch?.enabled && (
|
||||||
<WatchStrip
|
<WatchStrip
|
||||||
watch={watch}
|
watch={watch}
|
||||||
|
|||||||
@@ -343,6 +343,43 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</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">
|
<div className="row">
|
||||||
<label className="field grow">
|
<label className="field grow">
|
||||||
<span>Commande du navigateur</span>
|
<span>Commande du navigateur</span>
|
||||||
@@ -352,21 +389,55 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
|
|||||||
placeholder="firefox"
|
placeholder="firefox"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field grow">
|
{browser.mode === 'launch' ? (
|
||||||
<span>Arguments (séparés par des espaces)</span>
|
<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
|
<input
|
||||||
value={browser.args.join(' ')}
|
value={browser.profileDir}
|
||||||
onChange={(event) =>
|
onChange={(event) => patchBrowser({ profileDir: event.target.value })}
|
||||||
patchBrowser({ args: event.target.value.split(/\s+/).filter(Boolean) })
|
placeholder="~/.stream-control/firefox-profile (par défaut)"
|
||||||
}
|
|
||||||
placeholder="aucun"
|
|
||||||
/>
|
/>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
Laisse vide : le lien part vers la fenêtre déjà ouverte. <code>--new-window</code>
|
Un profil séparé est obligatoire : deux instances ne peuvent pas partager le
|
||||||
{' '}en créerait une nouvelle à chaque capture, et OBS perdrait sa cible.
|
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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="field grow">
|
<label className="field grow">
|
||||||
@@ -404,8 +475,18 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
La touche et le titre de fenêtre utilisés pour le plein écran sont ceux
|
{browser.mode === 'bidi' ? (
|
||||||
configurés ci-dessous, dans « Surveillance du stream ».
|
<>
|
||||||
|
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>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@@ -574,14 +655,16 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
|
|||||||
onChange={(event) => patchFullscreen({ key: event.target.value })}
|
onChange={(event) => patchFullscreen({ key: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field grow">
|
{browser.mode === 'launch' && (
|
||||||
<span>Titre de la fenêtre du lecteur</span>
|
<label className="field grow">
|
||||||
<input
|
<span>Titre de la fenêtre du lecteur</span>
|
||||||
value={watch.fullscreen.windowMatch}
|
<input
|
||||||
onChange={(event) => patchFullscreen({ windowMatch: event.target.value })}
|
value={watch.fullscreen.windowMatch}
|
||||||
placeholder="fragment du titre, ex. Stripchat"
|
onChange={(event) => patchFullscreen({ windowMatch: event.target.value })}
|
||||||
/>
|
placeholder="fragment du titre, ex. Stripchat"
|
||||||
</label>
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className="field small-field">
|
<label className="field small-field">
|
||||||
<span>Délai (s)</span>
|
<span>Délai (s)</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user