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 **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. 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` à 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
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 : enregistré, ou qui empêcherait un démarrage sans surveillance :
| Préférence | Pourquoi | | 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 | | `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 | | `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 **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 « 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 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 { 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 { defaultProfileDir, isSnapFirefox } from './firefox.ts';
import { looksLikeWayland } from './hotkey.ts'; import { looksLikeWayland } from './hotkey.ts';
import { import {
resolveDisplay, 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 // Firefox est le seul navigateur que le mode BiDi sache piloter : son
// absence rend ce mode inutilisable, quel que soit le reste. // absence rend ce mode inutilisable, quel que soit le reste.
results.push( if (await commandExists('firefox', ['--version'])) {
(await commandExists('firefox', ['--version'])) const snap = isSnapFirefox('firefox');
? line('ok', 'firefox', 'installé — pilotage WebDriver BiDi possible') results.push(line('ok', 'firefox', snap ? 'installé (paquet snap)' : 'installé'));
: line('warn', 'firefox', 'absent — le mode de pilotage « WebDriver BiDi » échouera'), // 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(
line('warn', 'firefox', 'absent — le mode de pilotage « WebDriver BiDi » échouera'),
);
}
// Port par défaut : le vrai vient de la configuration serveur, que ce // Port par défaut : le vrai vient de la configuration serveur, que ce
// diagnostic hors ligne ne connaît pas. // diagnostic hors ligne ne connaît pas.

View File

@@ -265,6 +265,12 @@ export class FirefoxController {
private async launch(): Promise<void> { private async launch(): Promise<void> {
const profile = this.profileFor(this.settings); 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); writeProfilePrefs(profile);
const args = [ const args = [
@@ -280,14 +286,21 @@ export class FirefoxController {
this.log('info', `Lancement de Firefox piloté (profil ${profile})`); 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 // Détaché : la fenêtre doit survivre à un redémarrage de l'agent, sinon la
// source de capture d'OBS disparaît avec elle. // source de capture d'OBS disparaît avec elle.
const child = spawn(this.settings.command, args, { const child = spawn(this.settings.command, args, {
detached: true, detached: true,
stdio: 'ignore', stdio: ['ignore', logFd, logFd],
env: sessionEnv(), env: sessionEnv(),
}); });
this.child = child; this.child = child;
fs.closeSync(logFd);
// Ce que le processus a déjà démenti. Interrogé à chaque tentative de // 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. // 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) => { child.once('exit', (code) => {
if (this.child === child) this.child = null; if (this.child === child) this.child = null;
// Sortie immédiate avec le port jamais ouvert : le verrou de profil est // Un code 0 n'est pas un échec : sur Ubuntu, `firefox` est un lanceur qui
// de loin la cause la plus fréquente, et le message de Firefox part sur // passe la main au snap et rend aussitôt la main. Le vrai navigateur
// une stdio ignorée. // démarre derrière — abandonner ici tuerait le cas nominal.
if (!this.client?.isOpen) { if (code !== 0 && !this.client?.isOpen) {
fatal = fatal =
`Firefox s'est arrêté (code ${code}) sans ouvrir le port de pilotage. ` + `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(); child.unref();
const client = await BidiClient.open(this.settings.remotePort, 45_000, () => fatal).catch( const client = await BidiClient.open(this.settings.remotePort, 45_000, () => fatal).catch(
(err: Error) => { (err: Error) => {
this.lastError = err.message; this.lastError = `${err.message} ${tail(logPath)}`.trim();
throw err; throw new Error(this.lastError);
}, },
); );
await this.adopt(client); await this.adopt(client);
@@ -420,10 +434,119 @@ export class FirefoxController {
} }
private profileFor(settings: BrowserSettings): string { 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é ------------------------------------------------------------- // --- Profil dédié -------------------------------------------------------------
/** /**

View File

@@ -428,13 +428,15 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
<input <input
value={browser.profileDir} value={browser.profileDir}
onChange={(event) => patchBrowser({ profileDir: event.target.value })} 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"> <span className="muted small">
Un profil séparé est obligatoire : deux instances ne peuvent pas partager le 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 ». 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 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. 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> </span>
</label> </label>
)} )}