Feat : Control streamer
Some checks failed
release / build (push) Successful in 27s
release / verify-windows (push) Failing after 56s

This commit is contained in:
jeanotx32
2026-08-11 21:11:04 +02:00
parent 6916ff4be3
commit 37e0c73ab5
10 changed files with 386 additions and 11 deletions

View File

@@ -0,0 +1,112 @@
import { execFile, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import type { BrowserSettings } from '@stream-control/shared';
const run = promisify(execFile);
export interface BrowserLog {
(level: 'info' | 'warn' | 'error', message: string): void;
}
/**
* 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,
): Promise<{ command: string; url: string }> {
const target = assertWebUrl(url);
const args = [...settings.args, target];
log('info', `Ouverture de ${target} (${settings.command})`);
const child = spawn(settings.command, args, {
detached: true,
stdio: 'ignore',
env: { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' },
});
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);
});
}
/** 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 = { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' };
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)`);
return { closed: ids.length };
}
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}