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,
|
AgentStatus,
|
||||||
AgentToServer,
|
AgentToServer,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
|
BrowserSettings,
|
||||||
ServerToAgent,
|
ServerToAgent,
|
||||||
WatchSettings,
|
WatchSettings,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_BROWSER_SETTINGS,
|
||||||
DEFAULT_WATCH_SETTINGS,
|
DEFAULT_WATCH_SETTINGS,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
detectPlatform,
|
detectPlatform,
|
||||||
emptyStatus,
|
emptyStatus,
|
||||||
|
normalizeBrowserSettings,
|
||||||
normalizeWatchSettings,
|
normalizeWatchSettings,
|
||||||
safeJsonParse,
|
safeJsonParse,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
@@ -22,6 +25,8 @@ import { runDiagnostics } from './doctor.ts';
|
|||||||
import { ObsController } from './obs.ts';
|
import { ObsController } from './obs.ts';
|
||||||
import { StreamWatcher } from './watcher.ts';
|
import { StreamWatcher } from './watcher.ts';
|
||||||
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.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';
|
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
|
||||||
|
|
||||||
const AGENT_VERSION = '0.1.0';
|
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 socket: WebSocket | null = null;
|
||||||
let statusTimer: NodeJS.Timeout | null = null;
|
let statusTimer: NodeJS.Timeout | null = null;
|
||||||
let reconnectTimer: 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);
|
obs.applySettings(message.obs);
|
||||||
applyWatchSettings(message.watch);
|
applyWatchSettings(message.watch);
|
||||||
|
applyBrowserSettings(message.browser);
|
||||||
report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`);
|
report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`);
|
||||||
|
|
||||||
if (message.autoConnectObs && !obs.isConnected) {
|
if (message.autoConnectObs && !obs.isConnected) {
|
||||||
@@ -161,6 +169,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
|||||||
case 'config': {
|
case 'config': {
|
||||||
obs.applySettings(message.obs);
|
obs.applySettings(message.obs);
|
||||||
applyWatchSettings(message.watch);
|
applyWatchSettings(message.watch);
|
||||||
|
applyBrowserSettings(message.browser);
|
||||||
if (message.autoConnectObs && !obs.isConnected) {
|
if (message.autoConnectObs && !obs.isConnected) {
|
||||||
obs.connect().catch((err: Error) => report('warn', err.message));
|
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,
|
* Séquence complète : ouvrir la page, laisser le lecteur démarrer, passer en
|
||||||
* tout le reste part vers obs-websocket.
|
* 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> {
|
async function runAction(action: AgentAction, params: Record<string, unknown>): Promise<unknown> {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -197,6 +265,14 @@ async function runAction(action: AgentAction, params: Record<string, unknown>):
|
|||||||
return watcher.checkNow();
|
return watcher.checkNow();
|
||||||
case 'hotkey.fullscreen':
|
case 'hotkey.fullscreen':
|
||||||
return watcher.restoreFullscreen();
|
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':
|
case 'agent.update':
|
||||||
return selfUpdate(
|
return selfUpdate(
|
||||||
typeof params.url === 'string' && params.url ? params.url : config.packageUrl,
|
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));
|
watcher.applySettings(normalizeWatchSettings(raw ?? DEFAULT_WATCH_SETTINGS));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyBrowserSettings(raw: BrowserSettings | undefined): void {
|
||||||
|
browserSettings = normalizeBrowserSettings(raw ?? DEFAULT_BROWSER_SETTINGS);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Boucle de statut --------------------------------------------------------
|
// --- Boucle de statut --------------------------------------------------------
|
||||||
|
|
||||||
async function buildStatus(): Promise<AgentStatus> {
|
async function buildStatus(): Promise<AgentStatus> {
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shar
|
|||||||
*/
|
*/
|
||||||
export type ObsAction = Exclude<
|
export type ObsAction = Exclude<
|
||||||
AgentAction,
|
AgentAction,
|
||||||
'watch.check' | 'hotkey.fullscreen' | 'agent.update'
|
| 'watch.check'
|
||||||
|
| 'hotkey.fullscreen'
|
||||||
|
| 'agent.update'
|
||||||
|
| 'browser.open'
|
||||||
|
| 'browser.close'
|
||||||
|
| 'capture.start'
|
||||||
|
| 'capture.stop'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type ObsSnapshot = Pick<
|
type ObsSnapshot = Pick<
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ export class StreamWatcher extends EventEmitter {
|
|||||||
return { ...this.state };
|
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 {
|
applySettings(settings: WatchSettings): void {
|
||||||
const restart =
|
const restart =
|
||||||
settings.enabled !== this.settings.enabled ||
|
settings.enabled !== this.settings.enabled ||
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export function handleAgentConnection(
|
|||||||
statusIntervalMs: config.statusIntervalMs,
|
statusIntervalMs: config.statusIntervalMs,
|
||||||
autoConnectObs: record.autoConnectObs,
|
autoConnectObs: record.autoConnectObs,
|
||||||
watch: record.watch,
|
watch: record.watch,
|
||||||
|
browser: record.browser,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
AGENT_ACTIONS,
|
AGENT_ACTIONS,
|
||||||
DEFAULT_OBS_SETTINGS,
|
DEFAULT_OBS_SETTINGS,
|
||||||
isAgentAction,
|
isAgentAction,
|
||||||
|
normalizeBrowserSettings,
|
||||||
normalizeWatchSettings,
|
normalizeWatchSettings,
|
||||||
parseStripchatUsername,
|
parseStripchatUsername,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
@@ -89,6 +90,7 @@ api.patch('/agents/:id', (req, res) => {
|
|||||||
autoConnectObs:
|
autoConnectObs:
|
||||||
typeof req.body?.autoConnectObs === 'boolean' ? req.body.autoConnectObs : record.autoConnectObs,
|
typeof req.body?.autoConnectObs === 'boolean' ? req.body.autoConnectObs : record.autoConnectObs,
|
||||||
watch: normalizeWatchSettings(req.body?.watch ?? record.watch),
|
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,
|
notes: typeof req.body?.notes === 'string' ? req.body.notes : record.notes,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,7 +146,9 @@ api.post('/agents/:id/command', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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`);
|
hub.log(record.id, 'info', `Commande « ${action} » exécutée`);
|
||||||
res.json({ ok: true, data });
|
res.json({ ok: true, data });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -303,6 +307,7 @@ api.post('/watchlist/:id/record', async (req, res) => {
|
|||||||
obs: record.obs,
|
obs: record.obs,
|
||||||
autoConnectObs: record.autoConnectObs,
|
autoConnectObs: record.autoConnectObs,
|
||||||
watch: normalizeWatchSettings({ ...record.watch, enabled: true, username: target.username }),
|
watch: normalizeWatchSettings({ ...record.watch, enabled: true, username: target.username }),
|
||||||
|
browser: record.browser,
|
||||||
notes: record.notes,
|
notes: record.notes,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,7 +318,21 @@ api.post('/watchlist/:id/record', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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(
|
hub.log(
|
||||||
record.id,
|
record.id,
|
||||||
'info',
|
'info',
|
||||||
@@ -334,7 +353,11 @@ api.post('/watchlist/:id/stop', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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 });
|
res.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(502).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
res.status(502).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import type {
|
|||||||
ObsSettings,
|
ObsSettings,
|
||||||
Platform,
|
Platform,
|
||||||
StreamState,
|
StreamState,
|
||||||
|
BrowserSettings,
|
||||||
WatchSettings,
|
WatchSettings,
|
||||||
WatchTarget,
|
WatchTarget,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_BROWSER_SETTINGS,
|
||||||
DEFAULT_OBS_SETTINGS,
|
DEFAULT_OBS_SETTINGS,
|
||||||
DEFAULT_WATCH_SETTINGS,
|
DEFAULT_WATCH_SETTINGS,
|
||||||
|
normalizeBrowserSettings,
|
||||||
normalizeWatchSettings,
|
normalizeWatchSettings,
|
||||||
safeJsonParse,
|
safeJsonParse,
|
||||||
stripchatProfileUrl,
|
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
|
// Surveillance du stream source : stockée en JSON, le schéma évolue plus vite
|
||||||
// que la table (nouveaux fournisseurs, nouveaux statuts).
|
// que la table (nouveaux fournisseurs, nouveaux statuts).
|
||||||
addColumnIfMissing('agents', 'watch_json', 'TEXT');
|
addColumnIfMissing('agents', 'watch_json', 'TEXT');
|
||||||
|
addColumnIfMissing('agents', 'browser_json', 'TEXT');
|
||||||
|
|
||||||
// Enrichissement des profils surveillés : photo et historique de diffusion.
|
// Enrichissement des profils surveillés : photo et historique de diffusion.
|
||||||
addColumnIfMissing('watch_targets', 'avatar_url', 'TEXT');
|
addColumnIfMissing('watch_targets', 'avatar_url', 'TEXT');
|
||||||
@@ -105,6 +109,7 @@ export interface AgentRow {
|
|||||||
obs_password: string;
|
obs_password: string;
|
||||||
auto_connect: number;
|
auto_connect: number;
|
||||||
watch_json: string | null;
|
watch_json: string | null;
|
||||||
|
browser_json: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
last_seen_at: number | null;
|
last_seen_at: number | null;
|
||||||
@@ -120,6 +125,7 @@ export interface AgentRecord {
|
|||||||
obs: ObsSettings;
|
obs: ObsSettings;
|
||||||
autoConnectObs: boolean;
|
autoConnectObs: boolean;
|
||||||
watch: WatchSettings;
|
watch: WatchSettings;
|
||||||
|
browser: BrowserSettings;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
lastSeenAt: number | null;
|
lastSeenAt: number | null;
|
||||||
@@ -142,6 +148,9 @@ function toRecord(row: AgentRow): AgentRecord {
|
|||||||
watch: normalizeWatchSettings(
|
watch: normalizeWatchSettings(
|
||||||
row.watch_json ? safeJsonParse<WatchSettings>(row.watch_json) : DEFAULT_WATCH_SETTINGS,
|
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,
|
notes: row.notes,
|
||||||
createdAt: Number(row.created_at),
|
createdAt: Number(row.created_at),
|
||||||
lastSeenAt: row.last_seen_at === null ? null : Number(row.last_seen_at),
|
lastSeenAt: row.last_seen_at === null ? null : Number(row.last_seen_at),
|
||||||
@@ -155,8 +164,8 @@ const stmts = {
|
|||||||
insertAgent: db.prepare(`
|
insertAgent: db.prepare(`
|
||||||
INSERT INTO agents (id, name, hostname, platform, agent_version, token_hash,
|
INSERT INTO agents (id, name, hostname, platform, agent_version, token_hash,
|
||||||
obs_host, obs_port, obs_password, auto_connect, watch_json,
|
obs_host, obs_port, obs_password, auto_connect, watch_json,
|
||||||
notes, created_at)
|
browser_json, notes, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`),
|
`),
|
||||||
updateIdentity: db.prepare(`
|
updateIdentity: db.prepare(`
|
||||||
UPDATE agents SET hostname = ?, platform = ?, agent_version = ?, last_seen_at = ?
|
UPDATE agents SET hostname = ?, platform = ?, agent_version = ?, last_seen_at = ?
|
||||||
@@ -164,7 +173,7 @@ const stmts = {
|
|||||||
`),
|
`),
|
||||||
updateSettings: db.prepare(`
|
updateSettings: db.prepare(`
|
||||||
UPDATE agents SET name = ?, obs_host = ?, obs_port = ?, obs_password = ?,
|
UPDATE agents SET name = ?, obs_host = ?, obs_port = ?, obs_password = ?,
|
||||||
auto_connect = ?, watch_json = ?, notes = ?
|
auto_connect = ?, watch_json = ?, browser_json = ?, notes = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`),
|
`),
|
||||||
touchAgent: db.prepare('UPDATE agents SET last_seen_at = ? WHERE id = ?'),
|
touchAgent: db.prepare('UPDATE agents SET last_seen_at = ? WHERE id = ?'),
|
||||||
@@ -207,6 +216,7 @@ export const agentsRepo = {
|
|||||||
obs?: Partial<ObsSettings>;
|
obs?: Partial<ObsSettings>;
|
||||||
autoConnectObs?: boolean;
|
autoConnectObs?: boolean;
|
||||||
watch?: WatchSettings;
|
watch?: WatchSettings;
|
||||||
|
browser?: BrowserSettings;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
}): AgentRecord {
|
}): AgentRecord {
|
||||||
const obs = { ...DEFAULT_OBS_SETTINGS, ...input.obs };
|
const obs = { ...DEFAULT_OBS_SETTINGS, ...input.obs };
|
||||||
@@ -222,6 +232,7 @@ export const agentsRepo = {
|
|||||||
obs.password,
|
obs.password,
|
||||||
input.autoConnectObs === false ? 0 : 1,
|
input.autoConnectObs === false ? 0 : 1,
|
||||||
JSON.stringify(normalizeWatchSettings(input.watch ?? DEFAULT_WATCH_SETTINGS)),
|
JSON.stringify(normalizeWatchSettings(input.watch ?? DEFAULT_WATCH_SETTINGS)),
|
||||||
|
JSON.stringify(normalizeBrowserSettings(input.browser ?? DEFAULT_BROWSER_SETTINGS)),
|
||||||
input.notes ?? null,
|
input.notes ?? null,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -250,6 +261,7 @@ export const agentsRepo = {
|
|||||||
obs: ObsSettings;
|
obs: ObsSettings;
|
||||||
autoConnectObs: boolean;
|
autoConnectObs: boolean;
|
||||||
watch: WatchSettings;
|
watch: WatchSettings;
|
||||||
|
browser: BrowserSettings;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
@@ -260,6 +272,7 @@ export const agentsRepo = {
|
|||||||
settings.obs.password,
|
settings.obs.password,
|
||||||
settings.autoConnectObs ? 1 : 0,
|
settings.autoConnectObs ? 1 : 0,
|
||||||
JSON.stringify(normalizeWatchSettings(settings.watch)),
|
JSON.stringify(normalizeWatchSettings(settings.watch)),
|
||||||
|
JSON.stringify(normalizeBrowserSettings(settings.browser)),
|
||||||
settings.notes,
|
settings.notes,
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class Hub {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
action: AgentAction,
|
action: AgentAction,
|
||||||
params?: Record<string, unknown>,
|
params?: Record<string, unknown>,
|
||||||
|
timeoutMs = config.commandTimeoutMs,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const connection = this.connections.get(agentId);
|
const connection = this.connections.get(agentId);
|
||||||
if (!connection) throw new Error('Agent hors-ligne');
|
if (!connection) throw new Error('Agent hors-ligne');
|
||||||
@@ -114,7 +115,7 @@ class Hub {
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
connection.pending.delete(requestId);
|
connection.pending.delete(requestId);
|
||||||
reject(new Error(`Timeout : l'agent n'a pas répondu à « ${action} »`));
|
reject(new Error(`Timeout : l'agent n'a pas répondu à « ${action} »`));
|
||||||
}, config.commandTimeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
connection.pending.set(requestId, { resolve, reject, timer });
|
connection.pending.set(requestId, { resolve, reject, timer });
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@ class Hub {
|
|||||||
obs: record.obs,
|
obs: record.obs,
|
||||||
autoConnectObs: record.autoConnectObs,
|
autoConnectObs: record.autoConnectObs,
|
||||||
watch: record.watch,
|
watch: record.watch,
|
||||||
|
browser: record.browser,
|
||||||
};
|
};
|
||||||
connection.socket.send(JSON.stringify(message));
|
connection.socket.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
@@ -174,6 +176,7 @@ class Hub {
|
|||||||
obs: { ...record.obs, password: record.obs.password ? '********' : '' },
|
obs: { ...record.obs, password: record.obs.password ? '********' : '' },
|
||||||
autoConnectObs: record.autoConnectObs,
|
autoConnectObs: record.autoConnectObs,
|
||||||
watch: record.watch,
|
watch: record.watch,
|
||||||
|
browser: record.browser,
|
||||||
notes: record.notes,
|
notes: record.notes,
|
||||||
status: this.statusOf(record.id),
|
status: this.statusOf(record.id),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export const AGENT_ACTIONS = [
|
|||||||
'recordDirectory.set',
|
'recordDirectory.set',
|
||||||
'watch.check',
|
'watch.check',
|
||||||
'hotkey.fullscreen',
|
'hotkey.fullscreen',
|
||||||
|
'browser.open',
|
||||||
|
'browser.close',
|
||||||
|
'capture.start',
|
||||||
|
'capture.stop',
|
||||||
'agent.update',
|
'agent.update',
|
||||||
'agent.ping',
|
'agent.ping',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -106,6 +110,54 @@ export interface WatchSettings {
|
|||||||
fullscreen: FullscreenSettings;
|
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 = {
|
export const DEFAULT_WATCH_SETTINGS: WatchSettings = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
provider: 'stripchat',
|
provider: 'stripchat',
|
||||||
@@ -288,6 +340,7 @@ export interface WelcomeMessage {
|
|||||||
/** Si vrai, l'agent tente de se connecter à OBS dès le démarrage. */
|
/** Si vrai, l'agent tente de se connecter à OBS dès le démarrage. */
|
||||||
autoConnectObs: boolean;
|
autoConnectObs: boolean;
|
||||||
watch: WatchSettings;
|
watch: WatchSettings;
|
||||||
|
browser: BrowserSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandMessage {
|
export interface CommandMessage {
|
||||||
@@ -302,6 +355,7 @@ export interface ConfigMessage {
|
|||||||
obs: ObsSettings;
|
obs: ObsSettings;
|
||||||
autoConnectObs: boolean;
|
autoConnectObs: boolean;
|
||||||
watch: WatchSettings;
|
watch: WatchSettings;
|
||||||
|
browser: BrowserSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PingMessage {
|
export interface PingMessage {
|
||||||
@@ -327,6 +381,7 @@ export interface AgentView {
|
|||||||
obs: ObsSettings;
|
obs: ObsSettings;
|
||||||
autoConnectObs: boolean;
|
autoConnectObs: boolean;
|
||||||
watch: WatchSettings;
|
watch: WatchSettings;
|
||||||
|
browser: BrowserSettings;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useState } from 'react';
|
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';
|
import { api } from '../api';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -20,6 +25,11 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
|
|||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [watch, setWatch] = useState<WatchSettings>(agent.watch);
|
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>) {
|
function patchWatch(patch: Partial<WatchSettings>) {
|
||||||
setWatch((current) => ({ ...current, ...patch }));
|
setWatch((current) => ({ ...current, ...patch }));
|
||||||
@@ -38,6 +48,7 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
|
|||||||
autoConnectObs: autoConnect,
|
autoConnectObs: autoConnect,
|
||||||
obs: { host, port: Number(port), password },
|
obs: { host, port: Number(port), password },
|
||||||
watch,
|
watch,
|
||||||
|
browser,
|
||||||
} as Partial<AgentView>);
|
} as Partial<AgentView>);
|
||||||
notify('Configuration enregistrée');
|
notify('Configuration enregistrée');
|
||||||
onClose();
|
onClose();
|
||||||
@@ -139,6 +150,72 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<fieldset className="group">
|
||||||
<legend>Surveillance du stream</legend>
|
<legend>Surveillance du stream</legend>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user