fix : Firefox handling 1
This commit is contained in:
23
README.md
23
README.md
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
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(
|
results.push(
|
||||||
(await commandExists('firefox', ['--version']))
|
line('warn', 'firefox', 'absent — le mode de pilotage « WebDriver BiDi » échouera'),
|
||||||
? 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
|
// 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.
|
||||||
|
|||||||
@@ -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é -------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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, où 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user