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 { return new Promise((resolve) => setTimeout(resolve, ms)); }