Feat : open link on current firefox session
All checks were successful
release / build (push) Successful in 44s
release / verify-windows (push) Successful in 1m19s

This commit is contained in:
jeanotx32
2026-08-12 00:40:20 +02:00
parent 55bb55ae55
commit 6ad8429cf1
6 changed files with 176 additions and 22 deletions

View File

@@ -335,6 +335,40 @@ Le délai d'amorçage n'est pas une précaution de style : un modèle qui sort d
repasse « public » quelques secondes avant de se remettre en place. Déclencher sur la
première lecture produirait des fichiers de dix secondes.
## Capture de fenêtre OBS et réutilisation
La source « capture de fenêtre » d'OBS mémorise un identifiant de fenêtre X11. Fermer la
fenêtre du navigateur, ou en ouvrir une nouvelle, invalide cet identifiant : la source
devient noire et il faut la repointer à la main.
L'agent évite donc les deux :
- **aucun argument** par défaut (`--new-window` est proscrit) : Firefox confie l'URL à la
fenêtre déjà ouverte ;
- **à l'arrêt, page vide** plutôt que fermeture : la fenêtre survit, et le lecteur cesse de
décoder la vidéo.
Les agents configurés avant ce correctif sont migrés au démarrage du serveur, sauf si leurs
arguments ou leur comportement d'arrêt ont été personnalisés.
### Éviter l'accumulation d'onglets
Avec les réglages d'usine de Firefox, un lien venu de l'extérieur ouvre un **nouvel
onglet** : chaque cycle d'enregistrement en laisse donc derrière lui, et les anciens
continuent de décoder leur page. Sur une VM d'enregistrement, règle une fois pour toutes
dans `about:config` :
```
browser.link.open_newwindow = 1
```
L'URL remplace alors le contenu de l'onglet courant. Ouverture et page vide réutilisent le
même onglet, dans la même fenêtre — rien ne s'accumule et OBS ne perd jamais sa cible.
Dans OBS, règle aussi la **priorité de correspondance** de la source sur « Faire
correspondre le titre, sinon trouver une fenêtre du même type » : le titre suit l'onglet
actif et change à chaque streamer.
## API HTTP
Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de session>`.

View File

@@ -75,6 +75,37 @@ export async function openUrl(
});
}
/**
* Décharge le lecteur en confiant une page vide à la fenêtre existante.
*
* La fenêtre survit — donc la source OBS aussi — et le flux cesse d'être décodé.
* `about:blank` n'est pas une URL fournie par l'utilisateur : elle contourne
* légitimement le filtre http/https appliqué aux liens de streamers.
*/
export async function blankPage(
settings: BrowserSettings,
log: BrowserLog,
): Promise<{ command: string; url: string }> {
const args = [...settings.args, 'about:blank'];
log('info', 'Lecteur déchargé (page vide) — fenêtre conservée pour OBS', 'browser.closed');
const child = spawn(settings.command, args, {
detached: true,
stdio: 'ignore',
env: { ...x11Env(), MOZ_ENABLE_WAYLAND: '0' },
});
return new Promise((resolve, reject) => {
child.once('error', (err: NodeJS.ErrnoException) =>
reject(new Error(`Chargement de la page vide impossible : ${err.message}`)),
);
setTimeout(() => {
child.unref();
resolve({ command: settings.command, url: 'about:blank' });
}, 400);
});
}
/** Ferme la fenêtre du lecteur, sans toucher au reste de la session. */
export async function closeWindow(
windowMatch: string,

View File

@@ -31,7 +31,7 @@ import { runDiagnostics } from './doctor.ts';
import { ObsController } from './obs.ts';
import { StreamWatcher } from './watcher.ts';
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts';
import { closeWindow, delay as sleep, openUrl } from './browser.ts';
import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.ts';
import { sendHotkey } from './hotkey.ts';
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
@@ -267,14 +267,19 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
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.closeOnStop) {
closed = await closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report).catch(
(err: Error) => {
report('warn', `Fermeture de la fenêtre impossible : ${err.message}`, 'command.failed');
return `échec : ${err.message}`;
},
);
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');

View File

@@ -151,6 +151,49 @@ function migrateIdleStatus(): void {
migrateIdleStatus();
/**
* `--new-window` et la fermeture systématique de la fenêtre détruisaient à chaque
* capture la source « capture de fenêtre » d'OBS, qu'il fallait ensuite repointer
* à la main. Les agents configurés avant le correctif portent ces valeurs dans
* leur JSON.
*
* Même précaution qu'au-dessus : seules les configurations restées au défaut sont
* reprises, ce qui rend la migration idempotente et respecte les réglages
* personnalisés.
*/
function migrateBrowserWindowReuse(): void {
const rows = db
.prepare('SELECT id, browser_json FROM agents WHERE browser_json IS NOT NULL')
.all() as unknown as Array<{ id: string; browser_json: string }>;
const update = db.prepare('UPDATE agents SET browser_json = ? WHERE id = ?');
let migrated = 0;
for (const row of rows) {
const browser = safeJsonParse<BrowserSettings & { closeOnStop?: boolean }>(row.browser_json);
if (!browser) continue;
const atLegacyDefault =
Array.isArray(browser.args) &&
browser.args.length === 1 &&
browser.args[0] === '--new-window' &&
browser.closeOnStop === true &&
browser.onStop === undefined;
if (!atLegacyDefault) continue;
const { closeOnStop: _legacy, ...rest } = browser;
update.run(JSON.stringify({ ...rest, args: [], onStop: 'blank' }), row.id);
migrated += 1;
}
if (migrated > 0) {
console.log(
`Migration : réutilisation de la fenêtre du navigateur activée sur ${migrated} agent(s)`,
);
}
}
migrateBrowserWindowReuse();
export interface AgentRow {
id: string;
name: string;

View File

@@ -379,20 +379,36 @@ export interface BrowserSettings {
enabled: boolean;
/** Exécutable du navigateur. */
command: string;
/** Arguments placés avant l'URL. */
/**
* Arguments placés avant l'URL. Vide par défaut, et ce n'est pas un oubli :
* `--new-window` créerait une fenêtre neuve à chaque capture, avec un nouvel
* identifiant X11 — la source « capture de fenêtre » d'OBS perdrait sa cible
* et il faudrait la repointer à la main. Sans argument, Firefox confie l'URL à
* la fenêtre déjà ouverte, qu'OBS continue de capturer.
*/
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;
/**
* Sort de la fenêtre à l'arrêt de l'enregistrement.
*
* `blank` charge une page vide : la fenêtre survit, donc la source OBS aussi,
* et le lecteur cesse de décoder la vidéo. `close` ferme la fenêtre — c'est
* précisément ce qui casse la capture OBS. `keep` ne touche à rien et laisse
* le flux tourner.
*/
onStop: BrowserStopAction;
}
export const BROWSER_STOP_ACTIONS = ['blank', 'keep', 'close'] as const;
export type BrowserStopAction = (typeof BROWSER_STOP_ACTIONS)[number];
export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
enabled: false,
command: 'firefox',
args: ['--new-window'],
args: [],
readyDelayMs: 8000,
closeOnStop: true,
onStop: 'blank',
};
export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
@@ -412,10 +428,21 @@ export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
readyDelayMs: Number.isFinite(delay)
? Math.min(Math.max(Math.round(delay), 0), 120_000)
: base.readyDelayMs,
closeOnStop: input.closeOnStop !== false,
onStop: readStopAction(raw),
};
}
/** Lit le sort de la fenêtre, en acceptant l'ancien booléen `closeOnStop`. */
function readStopAction(raw: unknown): BrowserStopAction {
const input = (raw ?? {}) as Partial<BrowserSettings> & { closeOnStop?: boolean };
if ((BROWSER_STOP_ACTIONS as readonly string[]).includes(input.onStop as string)) {
return input.onStop as BrowserStopAction;
}
// Configuration écrite avant l'introduction du choix à trois valeurs.
if (typeof input.closeOnStop === 'boolean') return input.closeOnStop ? 'close' : 'keep';
return DEFAULT_BROWSER_SETTINGS.onStop;
}
export const DEFAULT_WATCH_SETTINGS: WatchSettings = {
enabled: false,
provider: 'stripchat',

View File

@@ -359,8 +359,12 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
onChange={(event) =>
patchBrowser({ args: event.target.value.split(/\s+/).filter(Boolean) })
}
placeholder="--new-window"
placeholder="aucun"
/>
<span className="muted small">
Laisse vide : le lien part vers la fenêtre déjà ouverte. <code>--new-window</code>
{' '}en créerait une nouvelle à chaque capture, et OBS perdrait sa cible.
</span>
</label>
</div>
@@ -380,13 +384,23 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
</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 className="field">
<span>À l'arrêt de l'enregistrement</span>
<select
value={browser.onStop}
onChange={(event) =>
patchBrowser({ onStop: event.target.value as BrowserSettings['onStop'] })
}
>
<option value="blank">Charger une page vide (recommandé)</option>
<option value="keep">Ne rien faire</option>
<option value="close">Fermer la fenêtre</option>
</select>
<span className="muted small">
La page vide libère le décodeur vidéo tout en conservant la fenêtre donc la
source OBS. Fermer la fenêtre oblige à la repointer dans OBS au prochain
enregistrement.
</span>
</label>
<p className="muted small">