Feat : added and tweaked state machine
All checks were successful
release / build (push) Successful in 39s
release / verify-windows (push) Successful in 1m17s

This commit is contained in:
jeanotx32
2026-08-11 23:21:35 +02:00
parent a5777ad920
commit 2380196169
14 changed files with 469 additions and 53 deletions

View File

@@ -59,6 +59,11 @@ const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
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;

View File

@@ -26,6 +26,8 @@ export interface WatchActions {
recordState(): Promise<{ active: boolean; paused: boolean }>;
pauseRecording(): Promise<void>;
resumeRecording(): Promise<void>;
/** Clôture définitive : ferme aussi la fenêtre du navigateur si elle est pilotée. */
stopRecording(): Promise<void>;
}
/**
@@ -44,6 +46,8 @@ export class StreamWatcher extends EventEmitter {
private state: WatchState;
private timer: NodeJS.Timeout | null = null;
private fullscreenTimer: NodeJS.Timeout | null = null;
/** Compte à rebours de clôture, armé tant que le flux reste hors-ligne. */
private offlineTimer: NodeJS.Timeout | null = null;
private ticking = false;
/** Le flux public a été interrompu : il faudra rappeler le plein écran. */
private fullscreenPending = false;
@@ -88,6 +92,7 @@ export class StreamWatcher extends EventEmitter {
this.state.pendingConfirmations = 0;
this.state.autoPaused = false;
this.fullscreenPending = false;
this.cancelOfflineStop();
}
if (restart) this.start();
}
@@ -114,6 +119,7 @@ export class StreamWatcher extends EventEmitter {
this.timer = null;
if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer);
this.fullscreenTimer = null;
this.cancelOfflineStop();
}
/** Sonde immédiate, utilisée par l'action `watch.check`. */
@@ -167,6 +173,9 @@ export class StreamWatcher extends EventEmitter {
this.state.pendingConfirmations = next === 'private' ? this.state.pendingConfirmations + 1 : 0;
// Le retour du flux, public ou privé, annule toute clôture programmée.
if (next !== 'offline') this.cancelOfflineStop();
if (next === 'private') {
// Le lecteur quitte le plein écran dès que l'overlay de show privé apparaît.
this.fullscreenPending = true;
@@ -174,8 +183,110 @@ export class StreamWatcher extends EventEmitter {
return;
}
if (next === 'public') await this.handlePublic();
// 'offline' / 'unknown' : on ne touche à rien, l'opérateur reste maître.
if (next === 'public') {
await this.handlePublic();
return;
}
if (next === 'offline') {
await this.handleOffline();
return;
}
// 'unknown' : sonde sans verdict exploitable, on ne touche à rien.
}
/**
* Flux hors-ligne : pause immédiate, clôture différée.
*
* Les deux temps répondent à deux risques distincts. Enregistrer l'écran
* d'attente ne sert à rien, d'où la pause sans délai. Mais une coupure de
* quelques minutes est fréquente, et clore tout de suite découperait le
* fichier en deux — d'où le compte à rebours avant l'arrêt réel.
*/
private async handleOffline(): Promise<void> {
if (!this.settings.stopOnOffline) return;
this.state.offlineSince ??= Date.now();
try {
const record = await this.actions.recordState();
if (!record.active) {
// Rien à clore : inutile d'armer quoi que ce soit.
this.cancelOfflineStop();
return;
}
// Le garde `autoPaused` évite de rejouer la pause à chaque sonde, et donc
// de lutter contre un opérateur qui aurait repris la main.
if (!this.state.autoPaused && !record.paused) {
await this.actions.pauseRecording();
this.state.autoPaused = true;
this.emit('log', 'info', 'Flux hors-ligne : enregistrement mis en pause', 'record.paused');
}
} catch (err) {
this.emit(
'log',
'error',
`Pause hors-ligne impossible : ${err instanceof Error ? err.message : String(err)}`,
'command.failed',
);
return;
}
this.scheduleOfflineStop();
}
private scheduleOfflineStop(): void {
if (this.offlineTimer) return; // déjà armé pour cette coupure
const delayMs = this.settings.offlineStopDelayMs;
this.state.stopScheduledAt = Date.now() + delayMs;
this.emit(
'log',
'info',
`Clôture de l'enregistrement dans ${formatDelay(delayMs)} si le flux ne revient pas`,
'watch.offline',
);
this.offlineTimer = setTimeout(() => {
this.offlineTimer = null;
void this.stopForOffline();
}, delayMs);
this.offlineTimer.unref?.();
}
private cancelOfflineStop(): void {
if (this.offlineTimer) clearTimeout(this.offlineTimer);
this.offlineTimer = null;
this.state.offlineSince = undefined;
this.state.stopScheduledAt = undefined;
}
private async stopForOffline(): Promise<void> {
const offlineFor = this.state.offlineSince ? Date.now() - this.state.offlineSince : 0;
this.state.stopScheduledAt = undefined;
// Le flux a pu revenir entre l'armement et l'échéance.
if (this.state.state !== 'offline') return;
try {
const record = await this.actions.recordState();
if (!record.active) return;
await this.actions.stopRecording();
this.state.autoPaused = false;
this.emit(
'log',
'info',
`Flux hors-ligne depuis ${formatDelay(offlineFor)} : enregistrement clos`,
'record.stopped',
);
} catch (err) {
this.emit(
'log',
'error',
`Clôture automatique impossible : ${err instanceof Error ? err.message : String(err)}`,
'command.failed',
);
}
}
private async handlePrivate(): Promise<void> {
@@ -268,6 +379,16 @@ const TRANSITIONS: Record<StreamState, AgentEvent | undefined> = {
unknown: undefined,
};
/** Durée lisible pour les messages de journal : « 1 h », « 12 min », « 45 s ». */
function formatDelay(ms: number): string {
if (ms >= 3_600_000) {
const hours = ms / 3_600_000;
return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} h`;
}
if (ms >= 60_000) return `${Math.round(ms / 60_000)} min`;
return `${Math.round(ms / 1000)} s`;
}
function label(state: StreamState): string {
switch (state) {
case 'public':