Feat : Control streamer
This commit is contained in:
112
packages/agent/src/browser.ts
Normal file
112
packages/agent/src/browser.ts
Normal 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));
|
||||
}
|
||||
@@ -6,14 +6,17 @@ import type {
|
||||
AgentStatus,
|
||||
AgentToServer,
|
||||
LogLevel,
|
||||
BrowserSettings,
|
||||
ServerToAgent,
|
||||
WatchSettings,
|
||||
} from '@stream-control/shared';
|
||||
import {
|
||||
DEFAULT_BROWSER_SETTINGS,
|
||||
DEFAULT_WATCH_SETTINGS,
|
||||
PROTOCOL_VERSION,
|
||||
detectPlatform,
|
||||
emptyStatus,
|
||||
normalizeBrowserSettings,
|
||||
normalizeWatchSettings,
|
||||
safeJsonParse,
|
||||
} from '@stream-control/shared';
|
||||
@@ -22,6 +25,8 @@ import { runDiagnostics } from './doctor.ts';
|
||||
import { ObsController } from './obs.ts';
|
||||
import { StreamWatcher } from './watcher.ts';
|
||||
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts';
|
||||
import { closeWindow, delay as sleep, openUrl } from './browser.ts';
|
||||
import { sendHotkey } from './hotkey.ts';
|
||||
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
|
||||
|
||||
const AGENT_VERSION = '0.1.0';
|
||||
@@ -50,6 +55,8 @@ const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
|
||||
},
|
||||
});
|
||||
|
||||
let browserSettings: BrowserSettings = DEFAULT_BROWSER_SETTINGS;
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let statusTimer: NodeJS.Timeout | null = null;
|
||||
let reconnectTimer: NodeJS.Timeout | null = null;
|
||||
@@ -149,6 +156,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
||||
|
||||
obs.applySettings(message.obs);
|
||||
applyWatchSettings(message.watch);
|
||||
applyBrowserSettings(message.browser);
|
||||
report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`);
|
||||
|
||||
if (message.autoConnectObs && !obs.isConnected) {
|
||||
@@ -161,6 +169,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
||||
case 'config': {
|
||||
obs.applySettings(message.obs);
|
||||
applyWatchSettings(message.watch);
|
||||
applyBrowserSettings(message.browser);
|
||||
if (message.autoConnectObs && !obs.isConnected) {
|
||||
obs.connect().catch((err: Error) => report('warn', err.message));
|
||||
}
|
||||
@@ -187,9 +196,68 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function requireUrl(params: Record<string, unknown>): string {
|
||||
const url = params.url;
|
||||
if (typeof url !== 'string' || !url.trim()) throw new Error('Paramètre « url » manquant');
|
||||
return url.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aiguille une action : la surveillance et le clavier sont gérés par l'agent,
|
||||
* tout le reste part vers obs-websocket.
|
||||
* Séquence complète : ouvrir la page, laisser le lecteur démarrer, passer en
|
||||
* plein écran, lancer l'enregistrement. Chaque étape est journalisée séparément
|
||||
* pour qu'un échec désigne son maillon.
|
||||
*/
|
||||
async function startCapture(params: Record<string, unknown>): Promise<unknown> {
|
||||
if (!browserSettings.enabled) {
|
||||
throw new Error('Pilotage du navigateur désactivé sur cet agent');
|
||||
}
|
||||
|
||||
const url = requireUrl(params);
|
||||
const opened = await openUrl(browserSettings, url, report);
|
||||
|
||||
const wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs);
|
||||
report('info', `Attente de ${Math.round(wait / 1000)} s avant le plein écran`);
|
||||
await sleep(wait);
|
||||
|
||||
const fullscreen = watcher.snapshotSettings.fullscreen;
|
||||
let fullscreenResult: unknown = 'ignoré';
|
||||
if (fullscreen.enabled) {
|
||||
// Un échec ici ne doit pas empêcher l'enregistrement : mieux vaut capturer
|
||||
// une fenêtre non maximisée que ne rien capturer du tout.
|
||||
fullscreenResult = await sendHotkey({
|
||||
key: fullscreen.key,
|
||||
windowMatch: fullscreen.windowMatch,
|
||||
}).catch((err: Error) => {
|
||||
report('warn', `Plein écran impossible : ${err.message}`);
|
||||
return `échec : ${err.message}`;
|
||||
});
|
||||
}
|
||||
|
||||
await obs.execute('record.start');
|
||||
report('info', `Capture démarrée pour ${url}`);
|
||||
|
||||
return { opened, fullscreen: fullscreenResult, recording: true };
|
||||
}
|
||||
|
||||
async function stopCapture(): Promise<unknown> {
|
||||
const result = await obs.execute('record.stop');
|
||||
|
||||
let closed: unknown = 'conservée';
|
||||
if (browserSettings.enabled && browserSettings.closeOnStop) {
|
||||
closed = await closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report).catch(
|
||||
(err: Error) => {
|
||||
report('warn', `Fermeture de la fenêtre impossible : ${err.message}`);
|
||||
return `échec : ${err.message}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return { ...(result as object), window: closed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aiguille une action : navigateur, surveillance et clavier sont gérés par
|
||||
* l'agent lui-même ; tout le reste part vers obs-websocket.
|
||||
*/
|
||||
async function runAction(action: AgentAction, params: Record<string, unknown>): Promise<unknown> {
|
||||
switch (action) {
|
||||
@@ -197,6 +265,14 @@ async function runAction(action: AgentAction, params: Record<string, unknown>):
|
||||
return watcher.checkNow();
|
||||
case 'hotkey.fullscreen':
|
||||
return watcher.restoreFullscreen();
|
||||
case 'browser.open':
|
||||
return openUrl(browserSettings, requireUrl(params), report);
|
||||
case 'browser.close':
|
||||
return closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report);
|
||||
case 'capture.start':
|
||||
return startCapture(params);
|
||||
case 'capture.stop':
|
||||
return stopCapture();
|
||||
case 'agent.update':
|
||||
return selfUpdate(
|
||||
typeof params.url === 'string' && params.url ? params.url : config.packageUrl,
|
||||
@@ -214,6 +290,10 @@ function applyWatchSettings(raw: WatchSettings | undefined): void {
|
||||
watcher.applySettings(normalizeWatchSettings(raw ?? DEFAULT_WATCH_SETTINGS));
|
||||
}
|
||||
|
||||
function applyBrowserSettings(raw: BrowserSettings | undefined): void {
|
||||
browserSettings = normalizeBrowserSettings(raw ?? DEFAULT_BROWSER_SETTINGS);
|
||||
}
|
||||
|
||||
// --- Boucle de statut --------------------------------------------------------
|
||||
|
||||
async function buildStatus(): Promise<AgentStatus> {
|
||||
|
||||
@@ -8,7 +8,13 @@ import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shar
|
||||
*/
|
||||
export type ObsAction = Exclude<
|
||||
AgentAction,
|
||||
'watch.check' | 'hotkey.fullscreen' | 'agent.update'
|
||||
| 'watch.check'
|
||||
| 'hotkey.fullscreen'
|
||||
| 'agent.update'
|
||||
| 'browser.open'
|
||||
| 'browser.close'
|
||||
| 'capture.start'
|
||||
| 'capture.stop'
|
||||
>;
|
||||
|
||||
type ObsSnapshot = Pick<
|
||||
|
||||
@@ -55,6 +55,11 @@ export class StreamWatcher extends EventEmitter {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/** Réglages courants — la séquence de capture réutilise ceux du plein écran. */
|
||||
get snapshotSettings(): WatchSettings {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
applySettings(settings: WatchSettings): void {
|
||||
const restart =
|
||||
settings.enabled !== this.settings.enabled ||
|
||||
|
||||
Reference in New Issue
Block a user