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));
}

View File

@@ -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> {

View File

@@ -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<

View File

@@ -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 ||

View File

@@ -111,6 +111,7 @@ export function handleAgentConnection(
statusIntervalMs: config.statusIntervalMs,
autoConnectObs: record.autoConnectObs,
watch: record.watch,
browser: record.browser,
});
break;
}

View File

@@ -5,6 +5,7 @@ import {
AGENT_ACTIONS,
DEFAULT_OBS_SETTINGS,
isAgentAction,
normalizeBrowserSettings,
normalizeWatchSettings,
parseStripchatUsername,
} from '@stream-control/shared';
@@ -89,6 +90,7 @@ api.patch('/agents/:id', (req, res) => {
autoConnectObs:
typeof req.body?.autoConnectObs === 'boolean' ? req.body.autoConnectObs : record.autoConnectObs,
watch: normalizeWatchSettings(req.body?.watch ?? record.watch),
browser: normalizeBrowserSettings(req.body?.browser ?? record.browser),
notes: typeof req.body?.notes === 'string' ? req.body.notes : record.notes,
});
@@ -144,7 +146,9 @@ api.post('/agents/:id/command', async (req, res) => {
}
try {
const data = await hub.sendCommand(record.id, action, req.body?.params);
const timeoutMs =
action === 'capture.start' ? record.browser.readyDelayMs + 30_000 : undefined;
const data = await hub.sendCommand(record.id, action, req.body?.params, timeoutMs);
hub.log(record.id, 'info', `Commande « ${action} » exécutée`);
res.json({ ok: true, data });
} catch (err) {
@@ -303,6 +307,7 @@ api.post('/watchlist/:id/record', async (req, res) => {
obs: record.obs,
autoConnectObs: record.autoConnectObs,
watch: normalizeWatchSettings({ ...record.watch, enabled: true, username: target.username }),
browser: record.browser,
notes: record.notes,
});
@@ -313,7 +318,21 @@ api.post('/watchlist/:id/record', async (req, res) => {
}
try {
await hub.sendCommand(record.id, 'record.start');
// Avec le pilotage du navigateur, un seul appel enchaîne ouverture de la
// page, plein écran et enregistrement. Sinon on se contente de lancer OBS,
// en supposant la page déjà ouverte par l'opérateur.
if (record.browser.enabled) {
// La séquence attend le chargement de la page : le délai d'attente doit
// dépasser readyDelayMs, sinon la commande expire avant d'avoir abouti.
await hub.sendCommand(
record.id,
'capture.start',
{ url: target.url },
record.browser.readyDelayMs + 30_000,
);
} else {
await hub.sendCommand(record.id, 'record.start');
}
hub.log(
record.id,
'info',
@@ -334,7 +353,11 @@ api.post('/watchlist/:id/stop', async (req, res) => {
return;
}
try {
await hub.sendCommand(target.agentId, 'record.stop');
const agent = agentsRepo.get(target.agentId);
await hub.sendCommand(
target.agentId,
agent?.browser.enabled ? 'capture.stop' : 'record.stop',
);
res.json({ ok: true });
} catch (err) {
res.status(502).json({ ok: false, error: err instanceof Error ? err.message : String(err) });

View File

@@ -7,12 +7,15 @@ import type {
ObsSettings,
Platform,
StreamState,
BrowserSettings,
WatchSettings,
WatchTarget,
} from '@stream-control/shared';
import {
DEFAULT_BROWSER_SETTINGS,
DEFAULT_OBS_SETTINGS,
DEFAULT_WATCH_SETTINGS,
normalizeBrowserSettings,
normalizeWatchSettings,
safeJsonParse,
stripchatProfileUrl,
@@ -86,6 +89,7 @@ function addColumnIfMissing(table: string, column: string, definition: string):
// Surveillance du stream source : stockée en JSON, le schéma évolue plus vite
// que la table (nouveaux fournisseurs, nouveaux statuts).
addColumnIfMissing('agents', 'watch_json', 'TEXT');
addColumnIfMissing('agents', 'browser_json', 'TEXT');
// Enrichissement des profils surveillés : photo et historique de diffusion.
addColumnIfMissing('watch_targets', 'avatar_url', 'TEXT');
@@ -105,6 +109,7 @@ export interface AgentRow {
obs_password: string;
auto_connect: number;
watch_json: string | null;
browser_json: string | null;
notes: string | null;
created_at: number;
last_seen_at: number | null;
@@ -120,6 +125,7 @@ export interface AgentRecord {
obs: ObsSettings;
autoConnectObs: boolean;
watch: WatchSettings;
browser: BrowserSettings;
notes: string | null;
createdAt: number;
lastSeenAt: number | null;
@@ -142,6 +148,9 @@ function toRecord(row: AgentRow): AgentRecord {
watch: normalizeWatchSettings(
row.watch_json ? safeJsonParse<WatchSettings>(row.watch_json) : DEFAULT_WATCH_SETTINGS,
),
browser: normalizeBrowserSettings(
row.browser_json ? safeJsonParse<BrowserSettings>(row.browser_json) : DEFAULT_BROWSER_SETTINGS,
),
notes: row.notes,
createdAt: Number(row.created_at),
lastSeenAt: row.last_seen_at === null ? null : Number(row.last_seen_at),
@@ -155,8 +164,8 @@ const stmts = {
insertAgent: db.prepare(`
INSERT INTO agents (id, name, hostname, platform, agent_version, token_hash,
obs_host, obs_port, obs_password, auto_connect, watch_json,
notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
browser_json, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`),
updateIdentity: db.prepare(`
UPDATE agents SET hostname = ?, platform = ?, agent_version = ?, last_seen_at = ?
@@ -164,7 +173,7 @@ const stmts = {
`),
updateSettings: db.prepare(`
UPDATE agents SET name = ?, obs_host = ?, obs_port = ?, obs_password = ?,
auto_connect = ?, watch_json = ?, notes = ?
auto_connect = ?, watch_json = ?, browser_json = ?, notes = ?
WHERE id = ?
`),
touchAgent: db.prepare('UPDATE agents SET last_seen_at = ? WHERE id = ?'),
@@ -207,6 +216,7 @@ export const agentsRepo = {
obs?: Partial<ObsSettings>;
autoConnectObs?: boolean;
watch?: WatchSettings;
browser?: BrowserSettings;
notes?: string | null;
}): AgentRecord {
const obs = { ...DEFAULT_OBS_SETTINGS, ...input.obs };
@@ -222,6 +232,7 @@ export const agentsRepo = {
obs.password,
input.autoConnectObs === false ? 0 : 1,
JSON.stringify(normalizeWatchSettings(input.watch ?? DEFAULT_WATCH_SETTINGS)),
JSON.stringify(normalizeBrowserSettings(input.browser ?? DEFAULT_BROWSER_SETTINGS)),
input.notes ?? null,
Date.now(),
);
@@ -250,6 +261,7 @@ export const agentsRepo = {
obs: ObsSettings;
autoConnectObs: boolean;
watch: WatchSettings;
browser: BrowserSettings;
notes: string | null;
},
): void {
@@ -260,6 +272,7 @@ export const agentsRepo = {
settings.obs.password,
settings.autoConnectObs ? 1 : 0,
JSON.stringify(normalizeWatchSettings(settings.watch)),
JSON.stringify(normalizeBrowserSettings(settings.browser)),
settings.notes,
id,
);

View File

@@ -103,6 +103,7 @@ class Hub {
agentId: string,
action: AgentAction,
params?: Record<string, unknown>,
timeoutMs = config.commandTimeoutMs,
): Promise<unknown> {
const connection = this.connections.get(agentId);
if (!connection) throw new Error('Agent hors-ligne');
@@ -114,7 +115,7 @@ class Hub {
const timer = setTimeout(() => {
connection.pending.delete(requestId);
reject(new Error(`Timeout : l'agent n'a pas répondu à « ${action} »`));
}, config.commandTimeoutMs);
}, timeoutMs);
connection.pending.set(requestId, { resolve, reject, timer });
@@ -137,6 +138,7 @@ class Hub {
obs: record.obs,
autoConnectObs: record.autoConnectObs,
watch: record.watch,
browser: record.browser,
};
connection.socket.send(JSON.stringify(message));
}
@@ -174,6 +176,7 @@ class Hub {
obs: { ...record.obs, password: record.obs.password ? '********' : '' },
autoConnectObs: record.autoConnectObs,
watch: record.watch,
browser: record.browser,
notes: record.notes,
status: this.statusOf(record.id),
};

View File

@@ -45,6 +45,10 @@ export const AGENT_ACTIONS = [
'recordDirectory.set',
'watch.check',
'hotkey.fullscreen',
'browser.open',
'browser.close',
'capture.start',
'capture.stop',
'agent.update',
'agent.ping',
] as const;
@@ -106,6 +110,54 @@ export interface WatchSettings {
fullscreen: FullscreenSettings;
}
/**
* Pilotage du navigateur de la VM.
*
* On lance le navigateur déjà installé, avec son profil et sa session : c'est ce
* qui donne accès au flux comme si l'opérateur l'ouvrait lui-même. Aucune
* instance dédiée, aucun profil de test.
*/
export interface BrowserSettings {
enabled: boolean;
/** Exécutable du navigateur. */
command: string;
/** Arguments placés avant l'URL. */
args: string[];
/** Délai avant l'envoi du plein écran, le temps que le lecteur démarre. */
readyDelayMs: number;
/** Fermer la fenêtre quand l'enregistrement s'arrête. */
closeOnStop: boolean;
}
export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
enabled: false,
command: 'firefox',
args: ['--new-window'],
readyDelayMs: 8000,
closeOnStop: true,
};
export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
const input = (raw ?? {}) as Partial<BrowserSettings>;
const base = DEFAULT_BROWSER_SETTINGS;
const delay = Number(input.readyDelayMs);
return {
enabled: input.enabled === true,
command:
typeof input.command === 'string' && input.command.trim()
? input.command.trim()
: base.command,
args: Array.isArray(input.args)
? input.args.filter((arg): arg is string => typeof arg === 'string' && arg.trim() !== '')
: base.args,
readyDelayMs: Number.isFinite(delay)
? Math.min(Math.max(Math.round(delay), 0), 120_000)
: base.readyDelayMs,
closeOnStop: input.closeOnStop !== false,
};
}
export const DEFAULT_WATCH_SETTINGS: WatchSettings = {
enabled: false,
provider: 'stripchat',
@@ -288,6 +340,7 @@ export interface WelcomeMessage {
/** Si vrai, l'agent tente de se connecter à OBS dès le démarrage. */
autoConnectObs: boolean;
watch: WatchSettings;
browser: BrowserSettings;
}
export interface CommandMessage {
@@ -302,6 +355,7 @@ export interface ConfigMessage {
obs: ObsSettings;
autoConnectObs: boolean;
watch: WatchSettings;
browser: BrowserSettings;
}
export interface PingMessage {
@@ -327,6 +381,7 @@ export interface AgentView {
obs: ObsSettings;
autoConnectObs: boolean;
watch: WatchSettings;
browser: BrowserSettings;
notes: string | null;
status: AgentStatus;
}

View File

@@ -1,5 +1,10 @@
import { useState } from 'react';
import type { AgentAction, AgentView, WatchSettings } from '@stream-control/shared';
import type {
AgentAction,
AgentView,
BrowserSettings,
WatchSettings,
} from '@stream-control/shared';
import { api } from '../api';
interface Props {
@@ -20,6 +25,11 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
const [token, setToken] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [watch, setWatch] = useState<WatchSettings>(agent.watch);
const [browser, setBrowser] = useState<BrowserSettings>(agent.browser);
function patchBrowser(patch: Partial<BrowserSettings>) {
setBrowser((current) => ({ ...current, ...patch }));
}
function patchWatch(patch: Partial<WatchSettings>) {
setWatch((current) => ({ ...current, ...patch }));
@@ -38,6 +48,7 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
autoConnectObs: autoConnect,
obs: { host, port: Number(port), password },
watch,
browser,
} as Partial<AgentView>);
notify('Configuration enregistrée');
onClose();
@@ -139,6 +150,72 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
</button>
</div>
<fieldset className="group">
<legend>Pilotage du navigateur</legend>
<label className="checkbox">
<input
type="checkbox"
checked={browser.enabled}
onChange={(event) => patchBrowser({ enabled: event.target.checked })}
/>
<span>
Ouvrir la page du streamer et passer en plein écran automatiquement
</span>
</label>
<div className="row">
<label className="field grow">
<span>Commande du navigateur</span>
<input
value={browser.command}
onChange={(event) => patchBrowser({ command: event.target.value })}
placeholder="firefox"
/>
</label>
<label className="field grow">
<span>Arguments (séparés par des espaces)</span>
<input
value={browser.args.join(' ')}
onChange={(event) =>
patchBrowser({ args: event.target.value.split(/\s+/).filter(Boolean) })
}
placeholder="--new-window"
/>
</label>
</div>
<div className="row">
<label className="field grow">
<span>Délai avant le plein écran (s)</span>
<input
value={String(Math.round(browser.readyDelayMs / 1000))}
onChange={(event) =>
patchBrowser({ readyDelayMs: (Number(event.target.value) || 0) * 1000 })
}
inputMode="numeric"
/>
<span className="muted small">
Le temps que la page charge et que le lecteur démarre.
</span>
</label>
</div>
<label className="checkbox">
<input
type="checkbox"
checked={browser.closeOnStop}
onChange={(event) => patchBrowser({ closeOnStop: event.target.checked })}
/>
<span>Fermer la fenêtre à l'arrêt de l'enregistrement</span>
</label>
<p className="muted small">
La touche et le titre de fenêtre utilisés pour le plein écran sont ceux
configurés ci-dessous, dans « Surveillance du stream ».
</p>
</fieldset>
<fieldset className="group">
<legend>Surveillance du stream</legend>