#!/usr/bin/env node import os from 'node:os'; import { WebSocket } from 'ws'; import type { AgentAction, AgentEvent, AgentStatus, AgentToServer, FullscreenSettings, 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 { FirefoxController, type StreamQualityOutcome } from './firefox.ts'; import { describeFullscreen, type FullscreenOutcome } from './fullscreen.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(); }, restoreFullscreen: (settings) => restoreFullscreen(settings), }); let browserSettings: BrowserSettings = DEFAULT_BROWSER_SETTINGS; /** * Instance Firefox pilotée, créée à la volée. * * Elle n'existe que dans le mode `bidi` : la construire d'office lancerait un * navigateur sur toutes les VM, y compris celles où l'opérateur ouvre les pages * lui-même. */ let firefox: FirefoxController | null = null; 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(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 { 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 { 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, régler la qualité, lancer l'enregistrement. Chaque étape est * journalisée séparément pour qu'un échec désigne son maillon. L'ordre entre * plein écran et qualité n'est pas arbitraire — voir le commentaire sur * l'appel à {@link selectMaxStreamQuality}. */ async function startCapture(params: Record): Promise { if (!browserSettings.enabled) { throw new Error('Pilotage du navigateur désactivé sur cet agent'); } const url = requireUrl(params); const opened = await openPage(url); 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 restoreFullscreen(fullscreen) .then((outcome) => { // `skipped` prime sur `confirmed` : un envoi non renvoyé n'est jamais // un échec de ce côté-ci, quel que soit le sort de celui qu'il a // réutilisé. const failed = !outcome.skipped && outcome.confirmed === false; report( failed ? 'warn' : 'info', describeFullscreen(outcome, fullscreen.key), failed ? 'fullscreen.failed' : 'fullscreen.restored', ); return outcome; }) .catch((err: Error) => { report('warn', `Plein écran impossible : ${err.message}`, 'fullscreen.failed'); return `échec : ${err.message}`; }); } // Après, et non avant, le plein écran : celui-ci envoie une touche au // lecteur, et la plupart des lecteurs vidéo traitent n'importe quelle touche // comme une activité qui réaffiche les contrôles — y compris une touche sans // rapport avec la qualité. Dans l'ordre inverse, le curseur écarté en fin de // sélection de qualité (pour laisser la surbrillance des contrôles // s'estomper) se faisait donc aussitôt annuler par cette touche. Ici, // l'éloignement du curseur reste la dernière chose à toucher la page avant // l'enregistrement. await selectMaxStreamQuality(); // Dernière chance d'écrire un preset en attente : une fois l'enregistrement // commencé, OBS refuse toute modification jusqu'à l'arrêt. Sans cet appel // explicite, l'écriture dépendait du hasard d'un cycle de statut (toutes les // 2 s) tombant avant le démarrage — perdu, le preset restait en attente // jusqu'au prochain arrêt, et cet enregistrement tournait sur l'ancien réglage. await drainPresetApply(); await obs.execute('record.start'); report('info', `Capture démarrée pour ${url}`, 'capture.started'); return { opened, fullscreen: fullscreenResult, recording: true }; } /** Ouvre une page par la voie correspondant au mode de pilotage. */ async function openPage(url: string): Promise { const bidi = driver(); if (bidi) return bidi.navigate(url); return openUrl(browserSettings, url, report, { forceXWayland: watcher.snapshotSettings.fullscreen.enabled, }); } /** Décharge ou ferme la fenêtre, selon le réglage et le mode de pilotage. */ async function releasePage(): Promise { const bidi = driver(); if (bidi) return browserSettings.onStop === 'close' ? bidi.quit() : bidi.blank(); return browserSettings.onStop === 'close' ? closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report) : blankPage(browserSettings, report, { forceXWayland: watcher.snapshotSettings.fullscreen.enabled, }); } async function stopCapture(): Promise { 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') { closed = await releasePage().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): Promise { switch (action) { case 'watch.check': return watcher.checkNow(); case 'hotkey.fullscreen': return watcher.restoreFullscreen(); case 'browser.open': return openPage(requireUrl(params)); case 'browser.close': return driver()?.quit() ?? closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report); case 'browser.importSession': { const bidi = driver(); if (!bidi) { throw new Error( "L'import de session exige le mode de pilotage « WebDriver BiDi » sur cet agent", ); } return bidi.importSession(); } case 'capture.start': return startCapture(params); case 'capture.stop': return stopCapture(); case 'record.start': // Même raison que dans startCapture() : sans ce passage explicite, un // preset changé juste avant restait en attente jusqu'au prochain arrêt, // et cette capture démarrait sur l'ancien réglage. await drainPresetApply(); return obs.execute('record.start'); 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); if (browserSettings.mode !== 'bidi') { // On repasse en mode `launch` : le canal n'a plus de sens, mais on laisse // volontairement la fenêtre ouverte — OBS la capture peut-être encore. firefox?.dispose(); firefox = null; return; } if (firefox) firefox.applySettings(browserSettings); else firefox = new FirefoxController(browserSettings, report); } /** Contrôleur BiDi, ou `null` si l'agent n'est pas dans ce mode. */ function driver(): FirefoxController | null { return browserSettings.mode === 'bidi' ? firefox : null; } /** * Au-delà de ce délai depuis le dernier envoi, un nouveau rappel est à * nouveau autorisé. Choisi généreux face au 1200 ms que `restoreFullscreen` * attend en interne avant de relire l'état : le but n'est pas de couvrir * l'animation, mais la fenêtre où le flux public vient tout juste de démarrer * et peut encore vaciller (bref retour en privé, requalification du statut). */ const FULLSCREEN_COOLDOWN_MS = 10_000; /** Aucun rappel encore envoyé sur cette instance d'agent. */ let lastFullscreenAt = 0; /** Deux appels concurrents doivent partager le même envoi, pas en déclencher deux. */ let fullscreenInFlight: Promise | null = null; /** * Rappelle le plein écran par la voie correspondant au mode de pilotage. * * En BiDi, la demande part dans Firefox et son effet est relu dans la page. En * `launch`, on en reste à une touche envoyée au serveur d'affichage, sans * moyen de savoir ce qu'elle a produit — d'où le `confirmed` absent. * * Point de passage unique, et c'est voulu : deux mécanismes indépendants * peuvent réclamer ce rappel — la séquence d'ouverture ({@link startCapture}) * et {@link StreamWatcher}, qui le redemande de lui-même après un show privé. * Sans coordination, un flux qui vacille juste après le démarrage (un retour * bref en privé pendant que la séquence d'ouverture patiente encore) fait * partir les deux envois à quelques secondes d'écart. La touche du lecteur * basculant l'affichage plutôt que le forçant, le second envoi annule le * premier au lieu de le confirmer — d'où une fenêtre qui, au lieu de * s'agrandir, finit par rétrécir. Le garde-fou vit ici et non chez l'un des * deux appelants : c'est le seul endroit que les deux traversent forcément. */ async function restoreFullscreen(settings: FullscreenSettings): Promise { // Un envoi est déjà en cours : partager son résultat, pas en déclencher un // second. Marqué `skipped` malgré le succès qu'il rapporte — c'est cette // marque, et non la réussite, qui doit gouverner le message affiché à // l'appelant (voir describeFullscreen) : sans elle, les deux appelants // journalisent chacun « touche envoyée » pour un seul envoi réel, ce qui // se lit comme deux rappels distincts alors qu'il n'y en a eu qu'un. if (fullscreenInFlight) { const outcome = await fullscreenInFlight; return { ...outcome, skipped: true }; } const sinceLast = Date.now() - lastFullscreenAt; if (lastFullscreenAt > 0 && sinceLast < FULLSCREEN_COOLDOWN_MS) { return { method: 'ignoré (rappel trop rapproché)', skipped: true }; } const bidi = driver(); fullscreenInFlight = ( bidi ? bidi.restoreFullscreen(settings) : sendHotkey({ key: settings.key, windowMatch: settings.windowMatch }) ).finally(() => { fullscreenInFlight = null; lastFullscreenAt = Date.now(); }); return fullscreenInFlight; } /** * Sélectionne la meilleure qualité offerte par le lecteur Stripchat. * * Sans effet hors mode BiDi : il n'existe aucun moyen d'interagir avec le * contenu de la page sans lui. Un échec ne bloque jamais l'enregistrement — * mieux vaut capturer une qualité non maximale que ne rien capturer du tout. */ async function selectMaxStreamQuality(): Promise { const bidi = driver(); const settings = watcher.snapshotSettings.streamQuality; if (!bidi || !settings.enabled) return; await sleep(settings.delayMs); const result: StreamQualityOutcome = await bidi .selectMaxStreamQuality() .catch((err: Error) => ({ ok: false, reason: err.message })); if (!result.ok) { // Le diagnostic (menu ouvert mais balisage inattendu) part dans le même // message : plus vite exploitable que de redemander une capture DevTools. const suffix = result.debug ? ` — ${result.debug}` : ''; report('warn', `Qualité du flux non réglée : ${result.reason}${suffix}`, 'quality.failed'); return; } report( 'info', result.already ? `Qualité du flux déjà au maximum (${result.label})` : `Qualité du flux réglée sur ${result.label}`, 'quality.selected', ); } // --- 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 { 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): Promise { 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 { const snapshot = await obs.snapshot(); const memory = memoryUsage(); const disk = await diskUsage(snapshot.recordDirectory); return { ...emptyStatus(), ...snapshot, watch: watcher.snapshot, browser: driver()?.state, 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 { 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 { 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(); // Le canal BiDi se ferme, mais pas Firefox : sa fenêtre est la source de // capture d'OBS, et l'enregistrement en cours doit lui survivre. firefox?.dispose(); // 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(); }