163 lines
5.2 KiB
TypeScript
163 lines
5.2 KiB
TypeScript
import { execFile, spawn } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import type { AgentEvent, BrowserSettings } from '@stream-control/shared';
|
|
import { sessionEnv } from './x11.ts';
|
|
|
|
const run = promisify(execFile);
|
|
|
|
export interface BrowserLog {
|
|
(level: 'info' | 'warn' | 'error', message: string, event?: AgentEvent): void;
|
|
}
|
|
|
|
export interface LaunchOptions {
|
|
/**
|
|
* Forcer XWayland. Une fenêtre Wayland native est invisible à xdotool, donc
|
|
* impossible à basculer en plein écran par une touche. On ne l'impose que
|
|
* lorsque ce rappel est réellement demandé : sous Wayland, XWayland ajoute une
|
|
* copie d'image dont une capture par rognage OBS n'a que faire.
|
|
*/
|
|
forceXWayland?: boolean;
|
|
}
|
|
|
|
function launchEnv(options: LaunchOptions): NodeJS.ProcessEnv {
|
|
const env = sessionEnv();
|
|
if (options.forceXWayland) env.MOZ_ENABLE_WAYLAND = '0';
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Seules les URL http(s) sont acceptées. Elles finissent en argument de
|
|
* processus — jamais dans un shell, donc pas d'injection possible — mais un
|
|
* `file://` ou un `javascript:` n'aurait rien à faire ici.
|
|
*/
|
|
function assertWebUrl(url: string): string {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
throw new Error(`URL invalide : ${url}`);
|
|
}
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
throw new Error(`Protocole non autorisé : ${parsed.protocol}`);
|
|
}
|
|
return parsed.toString();
|
|
}
|
|
|
|
/**
|
|
* Ouvre une page dans le navigateur de la session graphique.
|
|
*
|
|
* On lance l'exécutable tel qu'il est installé, avec son profil par défaut :
|
|
* l'onglet hérite donc de la session déjà ouverte. Le processus est détaché —
|
|
* un navigateur déjà lancé délègue à l'instance existante et rend la main
|
|
* aussitôt, ce qui est le cas nominal.
|
|
*/
|
|
export async function openUrl(
|
|
settings: BrowserSettings,
|
|
url: string,
|
|
log: BrowserLog,
|
|
options: LaunchOptions = {},
|
|
): Promise<{ command: string; url: string }> {
|
|
const target = assertWebUrl(url);
|
|
const args = [...settings.args, target];
|
|
|
|
log('info', `Ouverture de ${target} (${settings.command})`, 'browser.opened');
|
|
|
|
const child = spawn(settings.command, args, {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
env: launchEnv(options),
|
|
});
|
|
|
|
return new Promise((resolve, reject) => {
|
|
child.once('error', (err: NodeJS.ErrnoException) => {
|
|
reject(
|
|
new Error(
|
|
err.code === 'ENOENT'
|
|
? `Navigateur introuvable : « ${settings.command} ». Vérifie la commande configurée.`
|
|
: `Lancement du navigateur impossible : ${err.message}`,
|
|
),
|
|
);
|
|
});
|
|
|
|
// Pas d'erreur immédiate : le lancement est considéré comme parti.
|
|
setTimeout(() => {
|
|
child.unref();
|
|
resolve({ command: settings.command, url: target });
|
|
}, 400);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Décharge le lecteur en confiant une page vide à la fenêtre existante.
|
|
*
|
|
* La fenêtre survit — donc la source OBS aussi — et le flux cesse d'être décodé.
|
|
* `about:blank` n'est pas une URL fournie par l'utilisateur : elle contourne
|
|
* légitimement le filtre http/https appliqué aux liens de streamers.
|
|
*/
|
|
export async function blankPage(
|
|
settings: BrowserSettings,
|
|
log: BrowserLog,
|
|
options: LaunchOptions = {},
|
|
): Promise<{ command: string; url: string }> {
|
|
const args = [...settings.args, 'about:blank'];
|
|
log('info', 'Lecteur déchargé (page vide) — fenêtre conservée pour OBS', 'browser.closed');
|
|
|
|
const child = spawn(settings.command, args, {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
env: launchEnv(options),
|
|
});
|
|
|
|
return new Promise((resolve, reject) => {
|
|
child.once('error', (err: NodeJS.ErrnoException) =>
|
|
reject(new Error(`Chargement de la page vide impossible : ${err.message}`)),
|
|
);
|
|
setTimeout(() => {
|
|
child.unref();
|
|
resolve({ command: settings.command, url: 'about:blank' });
|
|
}, 400);
|
|
});
|
|
}
|
|
|
|
/** Ferme la fenêtre du lecteur, sans toucher au reste de la session. */
|
|
export async function closeWindow(
|
|
windowMatch: string,
|
|
log: BrowserLog,
|
|
): Promise<{ closed: number }> {
|
|
const match = windowMatch.trim();
|
|
if (!match) throw new Error('Aucun titre de fenêtre à cibler');
|
|
|
|
if (process.platform !== 'linux') {
|
|
throw new Error(`Fermeture de fenêtre non gérée sur ${process.platform}`);
|
|
}
|
|
|
|
const env = sessionEnv();
|
|
|
|
let ids: string[] = [];
|
|
try {
|
|
const { stdout } = await run('xdotool', ['search', '--onlyvisible', '--name', match], {
|
|
env,
|
|
timeout: 5000,
|
|
});
|
|
ids = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
throw new Error('xdotool est absent — apt install xdotool');
|
|
}
|
|
return { closed: 0 }; // aucune fenêtre ne correspond
|
|
}
|
|
|
|
for (const id of ids) {
|
|
// windowclose demande poliment la fermeture, contrairement à windowkill qui
|
|
// tuerait le client X entier — donc potentiellement tout le navigateur.
|
|
await run('xdotool', ['windowclose', id], { env, timeout: 5000 }).catch(() => undefined);
|
|
}
|
|
|
|
log('info', `${ids.length} fenêtre(s) « ${match} » fermée(s)`, 'browser.closed');
|
|
return { closed: ids.length };
|
|
}
|
|
|
|
export function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|