fix : Firefox handling 1
All checks were successful
release / build (push) Successful in 26s
release / verify-windows (push) Successful in 1m13s

This commit is contained in:
jeanotx32
2026-08-12 02:05:35 +02:00
parent 77bfa0b8c5
commit dcbc846ae7
4 changed files with 168 additions and 17 deletions

View File

@@ -193,8 +193,7 @@ Séquence du rappel de plein écran, dans cet ordre volontaire :
**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
L'agent en gère un 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 |
@@ -205,6 +204,26 @@ enregistré, ou qui empêcherait un démarrage sans surveillance :
| `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 |
#### Firefox en snap (Ubuntu)
Sur Ubuntu, `firefox` est un snap, et cela impose deux choses.
L'interface `home` d'un snap **exclut délibérément les fichiers et répertoires cachés**
ceux commençant par un point, réputés contenir des données sensibles. Un profil sous
`~/.stream-control/` y est donc inaccessible, et Firefox meurt sans jamais ouvrir son port.
L'agent détecte le paquet snap en suivant la chaîne de liens de la commande
(`/usr/bin/firefox``/snap/bin/firefox``/usr/bin/snap`) et place alors son profil sous
`~/snap/firefox/common/stream-control-profile`. Un profil explicitement configuré dans un
répertoire caché est refusé **avant** le lancement, avec l'explication.
Par ailleurs, `firefox` n'est ici qu'un lanceur : il passe la main au snap et **sort
aussitôt en code 0** pendant que le vrai navigateur démarre derrière. L'agent ne traite donc
qu'une sortie non nulle comme un échec ; sinon il abandonnerait le cas nominal au bout de
quelques centaines de millisecondes.
La sortie de Firefox est redirigée vers `<profil>/firefox.log`, et ses dernières lignes
accompagnent tout échec de démarrage — c'est le seul endroit où il explique son refus.
**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

View File

@@ -3,6 +3,7 @@ import net from 'node:net';
import { promisify } from 'node:util';
import { WebSocket } from 'ws';
import { CONFIG_PATH, type AgentConfig } from './config.ts';
import { defaultProfileDir, isSnapFirefox } from './firefox.ts';
import { looksLikeWayland } from './hotkey.ts';
import {
resolveDisplay,
@@ -284,11 +285,17 @@ export async function runDiagnostics(config: AgentConfig): Promise<number> {
// Firefox est le seul navigateur que le mode BiDi sache piloter : son
// absence rend ce mode inutilisable, quel que soit le reste.
if (await commandExists('firefox', ['--version'])) {
const snap = isSnapFirefox('firefox');
results.push(line('ok', 'firefox', snap ? 'installé (paquet snap)' : 'installé'));
// Le confinement décide de l'emplacement du profil : un snap ne peut pas
// lire un répertoire caché du dossier personnel.
line('ok', 'profil', `${defaultProfileDir('firefox')}${snap ? ' (hors répertoire caché)' : ''}`);
} else {
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'),
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.

View File

@@ -265,6 +265,12 @@ export class FirefoxController {
private async launch(): Promise<void> {
const profile = this.profileFor(this.settings);
// Vérifié avant de lancer : sous confinement snap, l'échec se manifesterait
// sinon par 45 s d'attente et un port qui ne s'ouvre jamais.
const blocked = profileRefusedBySnap(this.settings.command, profile);
if (blocked) throw new Error(blocked);
writeProfilePrefs(profile);
const args = [
@@ -280,14 +286,21 @@ export class FirefoxController {
this.log('info', `Lancement de Firefox piloté (profil ${profile})`);
// Sortie redirigée vers un fichier plutôt qu'ignorée : c'est le seul endroit
// où Firefox explique pourquoi il refuse de démarrer, et un tube se
// romprait au redémarrage de l'agent — le processus, lui, est détaché.
const logPath = path.join(profile, 'firefox.log');
const logFd = fs.openSync(logPath, 'w');
// 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',
stdio: ['ignore', logFd, logFd],
env: sessionEnv(),
});
this.child = child;
fs.closeSync(logFd);
// 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.
@@ -300,21 +313,22 @@ export class FirefoxController {
});
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) {
// Un code 0 n'est pas un échec : sur Ubuntu, `firefox` est un lanceur qui
// passe la main au snap et rend aussitôt la main. Le vrai navigateur
// démarre derrière — abandonner ici tuerait le cas nominal.
if (code !== 0 && !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 ?`;
`Le profil ${profile} est-il déjà ouvert dans une autre instance ? ` +
tail(logPath);
}
});
child.unref();
const client = await BidiClient.open(this.settings.remotePort, 45_000, () => fatal).catch(
(err: Error) => {
this.lastError = err.message;
throw err;
this.lastError = `${err.message} ${tail(logPath)}`.trim();
throw new Error(this.lastError);
},
);
await this.adopt(client);
@@ -420,10 +434,119 @@ export class FirefoxController {
}
private profileFor(settings: BrowserSettings): string {
return settings.profileDir || path.join(os.homedir(), '.stream-control', 'firefox-profile');
return settings.profileDir || defaultProfileDir(settings.command);
}
}
// --- Confinement snap ---------------------------------------------------------
/**
* Emplacement du profil géré par l'agent.
*
* Il dépend du mode d'installation de Firefox, et ce n'est pas du zèle : sur
* Ubuntu, Firefox est un snap, et l'interface `home` d'un snap **exclut
* délibérément les fichiers et répertoires cachés** — ceux commençant par un
* point sont réputés sensibles. Un profil sous `~/.stream-control` y est donc
* inaccessible, et Firefox meurt sans jamais ouvrir son port de pilotage.
*/
export function defaultProfileDir(command: string): string {
const home = os.homedir();
return isSnapFirefox(command)
? path.join(home, 'snap', 'firefox', 'common', 'stream-control-profile')
: path.join(home, '.stream-control', 'firefox-profile');
}
/**
* Refus prévisible du confinement : un chemin caché confié à un Firefox snap.
*
* Renvoie l'explication, ou `null` si rien ne s'y oppose. Le contrôle est fait
* avant le lancement, parce que le symptôme — un port qui ne s'ouvre pas — ne
* désigne pas sa cause.
*/
export function profileRefusedBySnap(command: string, profile: string): string | null {
if (!isSnapFirefox(command)) return null;
const home = os.homedir();
const relative = path.relative(home, profile);
// Un segment caché suffit : la règle porte sur le chemin entier, pas sur sa fin.
const hidden = relative.split(path.sep).find((segment) => segment.startsWith('.'));
if (!hidden) return null;
return (
`Firefox est installé en snap, et un snap n'a pas accès aux répertoires cachés de ` +
`ton dossier personnel (« ${hidden} » ici). Le profil ${profile} lui est donc ` +
`interdit. Laisse le champ « Profil Firefox dédié » vide pour reprendre le défaut ` +
`(${defaultProfileDir(command)}), ou indique un chemin sans segment commençant par un point.`
);
}
/**
* Verdict sur une chaîne de liens symboliques déjà résolue.
*
* Séparé du système de fichiers pour être vérifiable : sur Ubuntu,
* `/usr/bin/firefox` mène à `/snap/bin/firefox`, lui-même lien vers
* `/usr/bin/snap`. Aucun maillon pris isolément ne suffit — c'est la chaîne
* entière qu'il faut regarder.
*/
export function snapSignature(chain: string[]): boolean {
return chain.some((entry) => entry.startsWith('/snap/') || path.basename(entry) === 'snap');
}
/** Vrai si la commande mène à un Firefox empaqueté en snap. */
export function isSnapFirefox(command: string): boolean {
const resolved = locate(command);
return resolved !== null && snapSignature(symlinkChain(resolved));
}
/** Chemin de départ suivi de chaque cible de lien, en absolu. */
export function symlinkChain(start: string): string[] {
const chain = [start];
let current = start;
// Borne volontaire : un lien circulaire ne doit pas figer l'agent.
for (let hop = 0; hop < 10; hop++) {
let target: string;
try {
if (!fs.lstatSync(current).isSymbolicLink()) break;
target = fs.readlinkSync(current);
} catch {
break;
}
current = path.resolve(path.dirname(current), target);
chain.push(current);
}
return chain;
}
/** Chemin absolu d'une commande, en parcourant `PATH` comme le ferait le shell. */
function locate(command: string): string | null {
if (command.includes(path.sep)) return fs.existsSync(command) ? command : null;
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
if (!dir) continue;
const candidate = path.join(dir, command);
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
/** Dernières lignes du journal de Firefox, pour accompagner un échec. */
function tail(logPath: string, lines = 4): string {
let content: string;
try {
content = fs.readFileSync(logPath, 'utf8');
} catch {
return '';
}
const kept = content
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.slice(-lines);
return kept.length ? `Firefox a écrit : ${kept.join(' / ')}` : '';
}
// --- Profil dédié -------------------------------------------------------------
/**

View File

@@ -428,13 +428,15 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
<input
value={browser.profileDir}
onChange={(event) => patchBrowser({ profileDir: event.target.value })}
placeholder="~/.stream-control/firefox-profile (par défaut)"
placeholder="laisse vide — l'agent choisit selon l'installation de Firefox"
/>
<span className="muted small">
Un profil séparé est obligatoire : deux instances ne peuvent pas partager le
même, et pointer celui de l'opérateur redonnerait « Firefox est déjà ouvert ».
L'agent y écrit les réglages qui comptent pour un enregistrement — lecture
automatique autorisée, bandeau de plein écran supprimé, aucun onglet d'accueil.
Sur Ubuntu, Firefox est un snap, le chemin ne doit contenir aucun segment
commençant par un point : un snap n'accède pas aux répertoires cachés.
</span>
</label>
)}