479 lines
16 KiB
JavaScript
479 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
import os from 'node:os';
|
|
import { WebSocket } from 'ws';
|
|
import type {
|
|
AgentAction,
|
|
AgentEvent,
|
|
AgentStatus,
|
|
AgentToServer,
|
|
LogLevel,
|
|
BrowserSettings,
|
|
PresetApplyResult,
|
|
RecordingSettings,
|
|
ServerToAgent,
|
|
WatchSettings,
|
|
} from '@stream-control/shared';
|
|
import {
|
|
DEFAULT_BROWSER_SETTINGS,
|
|
DEFAULT_RECORDING_SETTINGS,
|
|
DEFAULT_WATCH_SETTINGS,
|
|
PROTOCOL_VERSION,
|
|
detectPlatform,
|
|
emptyStatus,
|
|
isRecordingEncoder,
|
|
normalizeBrowserSettings,
|
|
normalizeRecordingSettings,
|
|
normalizeWatchSettings,
|
|
safeJsonParse,
|
|
} from '@stream-control/shared';
|
|
import { loadConfig, persistIdentity, type AgentConfig } from './config.ts';
|
|
import { runDiagnostics } from './doctor.ts';
|
|
import { ObsController } from './obs.ts';
|
|
import { StreamWatcher } from './watcher.ts';
|
|
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts';
|
|
import { blankPage, 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';
|
|
/** Empreinte du bundle courant, calculée une fois au démarrage. */
|
|
const BUILD_ID = currentBuildId();
|
|
const RECONNECT_MIN_MS = 1000;
|
|
const RECONNECT_MAX_MS = 30_000;
|
|
|
|
let config: AgentConfig;
|
|
try {
|
|
config = loadConfig();
|
|
} catch (err) {
|
|
console.error(err instanceof Error ? err.message : err);
|
|
process.exit(1);
|
|
}
|
|
|
|
const obs = new ObsController(config.obs);
|
|
|
|
const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
|
|
recordState: () => obs.recordState(),
|
|
pauseRecording: async () => {
|
|
await obs.execute('record.pause');
|
|
},
|
|
resumeRecording: async () => {
|
|
await obs.execute('record.resume');
|
|
},
|
|
// Clôture, et non simple `record.stop` : la fenêtre du navigateur doit se
|
|
// fermer aussi, sans quoi la VM resterait sur une page morte.
|
|
stopRecording: async () => {
|
|
await stopCapture();
|
|
},
|
|
});
|
|
|
|
let browserSettings: BrowserSettings = DEFAULT_BROWSER_SETTINGS;
|
|
let recordingSettings: RecordingSettings = DEFAULT_RECORDING_SETTINGS;
|
|
/** Un preset attend d'être appliqué : OBS était injoignable ou occupé. */
|
|
let presetPending = false;
|
|
|
|
let socket: WebSocket | null = null;
|
|
let statusTimer: NodeJS.Timeout | null = null;
|
|
let reconnectTimer: NodeJS.Timeout | null = null;
|
|
let reconnectDelay = RECONNECT_MIN_MS;
|
|
let statusIntervalMs = 2000;
|
|
let shuttingDown = false;
|
|
|
|
// --- Transport vers le serveur de contrôle ----------------------------------
|
|
|
|
function send(message: AgentToServer): void {
|
|
if (socket?.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify(message));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Journalise localement et vers le serveur. `event` classe l'entrée dans
|
|
* l'historique de la VM ; l'omettre reste correct pour ce qui n'est que du
|
|
* bavardage de progression.
|
|
*/
|
|
function report(level: LogLevel, message: string, event?: AgentEvent): void {
|
|
const prefix = level === 'error' ? '✖' : level === 'warn' ? '!' : '·';
|
|
console.log(`${prefix} ${message}`);
|
|
send({ type: 'log', level, message, ts: Date.now(), event });
|
|
}
|
|
|
|
obs.on('log', (level: LogLevel, message: string, event?: AgentEvent) =>
|
|
report(level, message, event),
|
|
);
|
|
watcher.on('log', (level: LogLevel, message: string, event?: AgentEvent) =>
|
|
report(level, message, event),
|
|
);
|
|
|
|
function connect(): void {
|
|
if (shuttingDown) return;
|
|
|
|
const url = new URL(config.serverUrl);
|
|
console.log(`Connexion au serveur ${url.origin}${url.pathname}…`);
|
|
|
|
socket = new WebSocket(url, {
|
|
headers: { authorization: `Bearer ${config.token}` },
|
|
rejectUnauthorized: !config.insecureTls,
|
|
handshakeTimeout: 10_000,
|
|
});
|
|
|
|
socket.on('open', () => {
|
|
reconnectDelay = RECONNECT_MIN_MS;
|
|
send({
|
|
type: 'hello',
|
|
protocol: PROTOCOL_VERSION,
|
|
agentId: config.agentId,
|
|
name: config.name,
|
|
hostname: os.hostname(),
|
|
platform: detectPlatform(process.platform),
|
|
agentVersion: AGENT_VERSION,
|
|
});
|
|
});
|
|
|
|
socket.on('message', (raw) => {
|
|
const message = safeJsonParse<ServerToAgent>(raw.toString());
|
|
if (message) void handleServerMessage(message);
|
|
});
|
|
|
|
socket.on('close', (code, reason) => {
|
|
stopStatusLoop();
|
|
socket = null;
|
|
if (shuttingDown) return;
|
|
const why = reason.toString() || `code ${code}`;
|
|
console.warn(`Session serveur fermée (${why}), nouvelle tentative dans ${reconnectDelay / 1000}s`);
|
|
scheduleReconnect();
|
|
});
|
|
|
|
socket.on('error', (err: Error) => {
|
|
console.error(`Erreur de connexion : ${err.message}`);
|
|
});
|
|
}
|
|
|
|
function scheduleReconnect(): void {
|
|
const delay = reconnectDelay;
|
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
|
|
// Surtout pas de .unref() ici : pendant une coupure, ce minuteur est la seule
|
|
// chose qui maintienne la boucle d'évènements en vie. Déréférencé, le process
|
|
// sortirait avec le code 0 au lieu de retenter — la reconnexion ne servirait
|
|
// jamais. On garde plutôt la référence pour l'annuler à l'arrêt.
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = null;
|
|
connect();
|
|
}, delay);
|
|
}
|
|
|
|
// --- Traitement des messages serveur ----------------------------------------
|
|
|
|
async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
|
switch (message.type) {
|
|
case 'welcome': {
|
|
statusIntervalMs = message.statusIntervalMs || statusIntervalMs;
|
|
|
|
if (message.token && message.token !== config.token) {
|
|
config.token = message.token;
|
|
config.agentId = message.agentId;
|
|
persistIdentity(message.agentId, message.token);
|
|
} else if (!config.agentId) {
|
|
config.agentId = message.agentId;
|
|
}
|
|
|
|
obs.applySettings(message.obs);
|
|
applyWatchSettings(message.watch);
|
|
applyBrowserSettings(message.browser);
|
|
applyRecordingSettings(message.recording);
|
|
report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`);
|
|
|
|
if (message.autoConnectObs && !obs.isConnected) {
|
|
obs.connect().catch((err: Error) => report('warn', err.message));
|
|
}
|
|
startStatusLoop();
|
|
break;
|
|
}
|
|
|
|
case 'config': {
|
|
obs.applySettings(message.obs);
|
|
applyWatchSettings(message.watch);
|
|
applyBrowserSettings(message.browser);
|
|
applyRecordingSettings(message.recording);
|
|
if (message.autoConnectObs && !obs.isConnected) {
|
|
obs.connect().catch((err: Error) => report('warn', err.message));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'command': {
|
|
try {
|
|
const data = await runAction(message.action, message.params ?? {});
|
|
send({ type: 'result', requestId: message.requestId, ok: true, data });
|
|
void pushStatus(); // état rafraîchi immédiatement après l'action
|
|
} catch (err) {
|
|
const text = err instanceof Error ? err.message : String(err);
|
|
send({ type: 'result', requestId: message.requestId, ok: false, error: text });
|
|
report('error', `Échec de « ${message.action} » : ${text}`, 'command.failed');
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'ping': {
|
|
send({ type: 'pong', ts: Date.now() });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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}`, 'fullscreen.failed');
|
|
return `échec : ${err.message}`;
|
|
});
|
|
}
|
|
|
|
await obs.execute('record.start');
|
|
report('info', `Capture démarrée pour ${url}`, 'capture.started');
|
|
|
|
return { opened, fullscreen: fullscreenResult, recording: true };
|
|
}
|
|
|
|
async function stopCapture(): Promise<unknown> {
|
|
const result = await obs.execute('record.stop');
|
|
|
|
// Fermer la fenêtre détruit la source « capture de fenêtre » d'OBS, qu'il faut
|
|
// ensuite repointer à la main. D'où le défaut « page vide », qui décharge le
|
|
// lecteur sans faire disparaître la fenêtre.
|
|
let closed: unknown = 'conservée';
|
|
if (browserSettings.enabled && browserSettings.onStop !== 'keep') {
|
|
const action =
|
|
browserSettings.onStop === 'close'
|
|
? closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report)
|
|
: blankPage(browserSettings, report);
|
|
closed = await action.catch((err: Error) => {
|
|
report('warn', `Libération de la fenêtre impossible : ${err.message}`, 'command.failed');
|
|
return `échec : ${err.message}`;
|
|
});
|
|
}
|
|
|
|
report('info', 'Capture arrêtée', 'capture.stopped');
|
|
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) {
|
|
case 'watch.check':
|
|
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 'preset.apply':
|
|
return applyPresetNow(params);
|
|
case 'agent.update':
|
|
return selfUpdate(
|
|
typeof params.url === 'string' && params.url ? params.url : config.packageUrl,
|
|
{
|
|
recordState: () => obs.recordState(),
|
|
log: (level, message, event) => report(level, message, event),
|
|
},
|
|
);
|
|
default:
|
|
return obs.execute(action, params);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// --- Presets d'enregistrement ------------------------------------------------
|
|
|
|
/**
|
|
* Enregistre le preset voulu. L'application effective est différée : OBS peut
|
|
* être déconnecté ou en pleine capture au moment où la configuration arrive.
|
|
*/
|
|
function applyRecordingSettings(raw: RecordingSettings | undefined): void {
|
|
const next = normalizeRecordingSettings(raw ?? DEFAULT_RECORDING_SETTINGS);
|
|
const changed =
|
|
next.enabled !== recordingSettings.enabled ||
|
|
next.presetId !== recordingSettings.presetId ||
|
|
next.encoder !== recordingSettings.encoder;
|
|
|
|
recordingSettings = next;
|
|
if (next.enabled && changed) presetPending = true;
|
|
}
|
|
|
|
/**
|
|
* Applique le preset en attente dès que les conditions le permettent.
|
|
*
|
|
* Un échec ne relance pas la tentative : une clé refusée par cette version d'OBS
|
|
* le sera à chaque cycle, et marteler l'API n'y changerait rien. Le prochain
|
|
* enregistrement de la configuration, ou le bouton du dashboard, réessaiera.
|
|
*/
|
|
async function drainPresetApply(): Promise<void> {
|
|
if (!presetPending || !recordingSettings.enabled || !obs.isConnected) return;
|
|
|
|
const state = await obs.recordState().catch(() => null);
|
|
if (!state || state.active) return; // jamais pendant une capture
|
|
|
|
presetPending = false;
|
|
try {
|
|
report(
|
|
'info',
|
|
describePreset(await obs.applyRecordingPreset(recordingSettings)),
|
|
'preset.applied',
|
|
);
|
|
} catch (err) {
|
|
report(
|
|
'warn',
|
|
`Preset non appliqué : ${err instanceof Error ? err.message : String(err)}`,
|
|
'preset.failed',
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Application à la demande : passe outre l'interrupteur, l'opérateur a tranché. */
|
|
async function applyPresetNow(params: Record<string, unknown>): Promise<PresetApplyResult> {
|
|
const result = await obs.applyRecordingPreset({
|
|
enabled: true,
|
|
presetId:
|
|
typeof params.presetId === 'string' && params.presetId
|
|
? params.presetId
|
|
: recordingSettings.presetId,
|
|
encoder: isRecordingEncoder(params.encoder) ? params.encoder : recordingSettings.encoder,
|
|
});
|
|
presetPending = false;
|
|
report('info', describePreset(result), 'preset.applied');
|
|
return result;
|
|
}
|
|
|
|
function describePreset(result: PresetApplyResult): string {
|
|
const parts = [
|
|
`Preset « ${result.presetLabel} » appliqué — ${result.encoder}, ${result.video}`,
|
|
];
|
|
if (result.skipped.length > 0) parts.push(`refusé par OBS : ${result.skipped.join(', ')}`);
|
|
if (result.restartRequired) {
|
|
parts.push("l'encodeur a changé : redémarre OBS pour qu'il le charge");
|
|
}
|
|
return parts.join(' — ');
|
|
}
|
|
|
|
// --- Boucle de statut --------------------------------------------------------
|
|
|
|
async function buildStatus(): Promise<AgentStatus> {
|
|
const snapshot = await obs.snapshot();
|
|
const memory = memoryUsage();
|
|
const disk = await diskUsage(snapshot.recordDirectory);
|
|
|
|
return {
|
|
...emptyStatus(),
|
|
...snapshot,
|
|
watch: watcher.snapshot,
|
|
buildId: BUILD_ID,
|
|
canSelfUpdate: Boolean(runningBundlePath() && config.packageUrl),
|
|
lastRecordingPath: obs.recordingPath,
|
|
systemCpu: cpuUsagePercent(),
|
|
systemMemoryUsed: memory.used,
|
|
systemMemoryTotal: memory.total,
|
|
diskFreeBytes: disk?.freeBytes,
|
|
diskTotalBytes: disk?.totalBytes,
|
|
updatedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
async function pushStatus(): Promise<void> {
|
|
if (socket?.readyState !== WebSocket.OPEN) return;
|
|
try {
|
|
// Le cycle de statut sert aussi d'horloge au preset en attente : il tourne
|
|
// déjà, et il s'exécute justement quand OBS vient de redevenir joignable.
|
|
await drainPresetApply();
|
|
send({ type: 'status', status: await buildStatus() });
|
|
} catch (err) {
|
|
console.error('Collecte de statut en échec :', err);
|
|
}
|
|
}
|
|
|
|
function startStatusLoop(): void {
|
|
stopStatusLoop();
|
|
void pushStatus();
|
|
statusTimer = setInterval(() => void pushStatus(), statusIntervalMs);
|
|
statusTimer.unref?.();
|
|
}
|
|
|
|
function stopStatusLoop(): void {
|
|
if (statusTimer) clearInterval(statusTimer);
|
|
statusTimer = null;
|
|
}
|
|
|
|
// --- Cycle de vie ------------------------------------------------------------
|
|
|
|
async function shutdown(signal: string): Promise<void> {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
console.log(`\n${signal} reçu, arrêt de l'agent…`);
|
|
stopStatusLoop();
|
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
watcher.stop();
|
|
// L'enregistrement OBS en cours n'est volontairement pas interrompu.
|
|
await obs.disconnect().catch(() => undefined);
|
|
socket?.close(1000, 'Arrêt de l\'agent');
|
|
setTimeout(() => process.exit(0), 500).unref();
|
|
}
|
|
|
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
process.on('unhandledRejection', (reason) => {
|
|
console.error('Rejet non géré :', reason);
|
|
});
|
|
|
|
console.log(`stream-control agent v${AGENT_VERSION} — ${config.name} (${process.platform})`);
|
|
|
|
if (process.argv.includes('--check')) {
|
|
// Diagnostic seul : rien n'est démarré, aucune connexion n'est maintenue.
|
|
runDiagnostics(config).then((code) => process.exit(code));
|
|
} else {
|
|
connect();
|
|
}
|