REVAMP : Firefox handling
All checks were successful
release / build (push) Successful in 25s
release / verify-windows (push) Successful in 1m15s

This commit is contained in:
jeanotx32
2026-08-12 01:46:14 +02:00
parent 3bd4d5f2ba
commit 77bfa0b8c5
14 changed files with 1223 additions and 108 deletions

View File

@@ -6,6 +6,7 @@ import type {
AgentEvent,
AgentStatus,
AgentToServer,
FullscreenSettings,
LogLevel,
BrowserSettings,
PresetApplyResult,
@@ -32,6 +33,8 @@ 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 } from './firefox.ts';
import { describeFullscreen, type FullscreenOutcome } from './fullscreen.ts';
import { sendHotkey } from './hotkey.ts';
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
@@ -64,9 +67,18 @@ const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
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;
@@ -238,9 +250,7 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
}
const url = requireUrl(params);
const opened = await openUrl(browserSettings, url, report, {
forceXWayland: watcher.snapshotSettings.fullscreen.enabled,
});
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`);
@@ -251,13 +261,19 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
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}`;
});
fullscreenResult = await restoreFullscreen(fullscreen)
.then((outcome) => {
report(
outcome.confirmed === false ? 'warn' : 'info',
describeFullscreen(outcome, fullscreen.key),
outcome.confirmed === false ? 'fullscreen.failed' : 'fullscreen.restored',
);
return outcome;
})
.catch((err: Error) => {
report('warn', `Plein écran impossible : ${err.message}`, 'fullscreen.failed');
return `échec : ${err.message}`;
});
}
await obs.execute('record.start');
@@ -266,6 +282,27 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
return { opened, fullscreen: fullscreenResult, recording: true };
}
/** Ouvre une page par la voie correspondant au mode de pilotage. */
async function openPage(url: string): Promise<unknown> {
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<unknown> {
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<unknown> {
const result = await obs.execute('record.stop');
@@ -274,13 +311,7 @@ async function stopCapture(): Promise<unknown> {
// 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, {
forceXWayland: watcher.snapshotSettings.fullscreen.enabled,
});
closed = await action.catch((err: Error) => {
closed = await releasePage().catch((err: Error) => {
report('warn', `Libération de la fenêtre impossible : ${err.message}`, 'command.failed');
return `échec : ${err.message}`;
});
@@ -301,11 +332,9 @@ async function runAction(action: AgentAction, params: Record<string, unknown>):
case 'hotkey.fullscreen':
return watcher.restoreFullscreen();
case 'browser.open':
return openUrl(browserSettings, requireUrl(params), report, {
forceXWayland: watcher.snapshotSettings.fullscreen.enabled,
});
return openPage(requireUrl(params));
case 'browser.close':
return closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report);
return driver()?.quit() ?? closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report);
case 'capture.start':
return startCapture(params);
case 'capture.stop':
@@ -331,6 +360,35 @@ function applyWatchSettings(raw: WatchSettings | undefined): void {
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;
}
/**
* 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.
*/
async function restoreFullscreen(settings: FullscreenSettings): Promise<FullscreenOutcome> {
const bidi = driver();
if (bidi) return bidi.restoreFullscreen(settings);
return sendHotkey({ key: settings.key, windowMatch: settings.windowMatch });
}
// --- Presets d'enregistrement ------------------------------------------------
@@ -416,6 +474,7 @@ async function buildStatus(): Promise<AgentStatus> {
...emptyStatus(),
...snapshot,
watch: watcher.snapshot,
browser: driver()?.state,
buildId: BUILD_ID,
canSelfUpdate: Boolean(runningBundlePath() && config.packageUrl),
lastRecordingPath: obs.recordingPath,
@@ -462,6 +521,9 @@ async function shutdown(signal: string): Promise<void> {
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');