fix : Firefox handling 1
This commit is contained in:
@@ -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.
|
||||
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'),
|
||||
);
|
||||
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(
|
||||
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.
|
||||
|
||||
@@ -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é -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user