Feat : import session into auto firefox
All checks were successful
release / build (push) Successful in 27s
release / verify-windows (push) Successful in 1m15s

This commit is contained in:
jeanotx32
2026-08-12 03:45:35 +02:00
parent ae8ae6b91d
commit 8fef1aacb0
7 changed files with 258 additions and 1 deletions

View File

@@ -2,7 +2,12 @@ import { spawn, type ChildProcess } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { BrowserSettings, BrowserState, FullscreenSettings } from '@stream-control/shared';
import type {
BrowserSettings,
BrowserState,
FullscreenSettings,
SessionImportResult,
} from '@stream-control/shared';
import { BidiClient } from './bidi.ts';
import { assertWebUrl, type BrowserLog } from './browser.ts';
import type { FullscreenOutcome } from './fullscreen.ts';
@@ -221,6 +226,76 @@ export class FirefoxController {
this.detach();
}
/**
* Importe les cookies de session depuis le Firefox personnel de l'utilisateur.
*
* Le profil piloté est délibérément vierge — c'est ce qui le rend
* indépendant du reste de la session — mais un site comme Stripchat exige
* une session ouverte, et rejouer la connexion à chaque redémarrage n'a
* rien d'automatisable proprement. Copier les cookies déjà valides est plus
* simple et plus sûr que de manipuler un formulaire de connexion.
*
* Lecture seule côté source : rien n'est jamais écrit dans le profil
* personnel de l'utilisateur.
*/
async importSession(): Promise<SessionImportResult> {
try {
return await this.doImportSession();
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
this.log('warn', `Import de session impossible : ${text}`, 'browser.importFailed');
throw err;
}
}
private async doImportSession(): Promise<SessionImportResult> {
const root = mozillaProfilesRoot(this.settings.command);
const source = resolveDefaultProfile(root);
if (!source) {
throw new Error(
`Profil Firefox personnel introuvable sous ${root}. As-tu déjà ouvert Firefox au ` +
'moins une fois avec ce compte, en dehors du pilotage automatique ?',
);
}
const destination = this.profileFor(this.settings);
if (path.resolve(source) === path.resolve(destination)) {
throw new Error('Le profil personnel et le profil piloté sont le même — rien à importer.');
}
if (!fs.existsSync(path.join(source, 'cookies.sqlite'))) {
throw new Error(
`Aucun cookie enregistré dans ${source}. Connecte-toi d'abord sur le site voulu dans ` +
'ce Firefox, celui que tu utilises normalement.',
);
}
fs.mkdirSync(destination, { recursive: true });
// Une instance pilotée vivante verrait ses fichiers de cookies remplacés
// sous elle. reclaimProfile() rattrape aussi une instance orpheline d'un
// cycle d'agent précédent, pas seulement celle que ce process connaît.
this.detach();
await reclaimProfile(destination, this.log);
const copied: string[] = [];
for (const name of SESSION_FILES) {
try {
fs.copyFileSync(path.join(source, name), path.join(destination, name));
copied.push(name);
} catch {
// -wal/-shm normalement absents si Firefox a déjà tout validé dans le
// fichier principal ; permissions.sqlite est un bonus, pas une condition.
}
}
if (!copied.includes('cookies.sqlite')) {
throw new Error(`Copie des cookies impossible depuis ${source}.`);
}
this.log('info', `Session importée depuis ${source} (${copied.join(', ')})`, 'browser.imported');
return { sourceProfile: source, copied };
}
// --- Cycle de vie de l'instance --------------------------------------------
/** Vrai si l'appel a dû lancer Firefox. */
@@ -470,6 +545,94 @@ export class FirefoxController {
}
}
// --- Import de session -----------------------------------------------------
/** Fichiers copiés depuis le profil personnel. Aucun n'est jamais écrit côté source. */
const SESSION_FILES = ['cookies.sqlite', 'cookies.sqlite-wal', 'cookies.sqlite-shm', 'permissions.sqlite'];
/**
* Répertoire des profils du Firefox « normal » de l'utilisateur — celui où il
* se connecte à la main, distinct du profil dédié au pilotage.
*
* Un snap redirige tout `$HOME` visible depuis son bac à sable vers
* `~/snap/firefox/common` : le profil personnel y est déplacé aussi, pas
* seulement celui de l'agent.
*/
export function mozillaProfilesRoot(command: string): string {
const home = os.homedir();
if (process.platform === 'win32') {
return path.join(process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'Mozilla', 'Firefox');
}
if (process.platform === 'darwin') {
return path.join(home, 'Library', 'Application Support', 'Firefox');
}
return isSnapFirefox(command)
? path.join(home, 'snap', 'firefox', 'common', '.mozilla', 'firefox')
: path.join(home, '.mozilla', 'firefox');
}
interface IniSection {
name: string;
entries: Record<string, string>;
}
/** Lecteur minimal du format `.ini` de `profiles.ini` — sections et paires clé=valeur. */
function parseIni(content: string): IniSection[] {
const sections: IniSection[] = [];
let current: IniSection | null = null;
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith(';') || line.startsWith('#')) continue;
const header = /^\[(.+)\]$/.exec(line);
if (header) {
current = { name: header[1]!, entries: {} };
sections.push(current);
continue;
}
const at = line.indexOf('=');
if (at === -1 || !current) continue;
current.entries[line.slice(0, at).trim()] = line.slice(at + 1).trim();
}
return sections;
}
/**
* Résout le profil par défaut d'une installation Firefox, chemin absolu.
*
* Deux formats coexistent dans la nature : les versions récentes désignent le
* profil par défaut dans une section `[InstallXXXXXXXX]` séparée (prioritaire
* ici), les plus anciennes par un drapeau `Default=1` directement sur la
* section du profil. À défaut des deux, on retient `default-release` par
* convention de nommage, puis le premier profil listé.
*/
export function resolveDefaultProfile(root: string): string | null {
let ini: string;
try {
ini = fs.readFileSync(path.join(root, 'profiles.ini'), 'utf8');
} catch {
return null;
}
const sections = parseIni(ini);
const profiles = sections.filter((s) => s.name.startsWith('Profile'));
const byPath = (relPath: string) => profiles.find((p) => p.entries.Path === relPath) ?? null;
const install = sections.find((s) => s.name.startsWith('Install') && s.entries.Default);
const chosen =
(install ? byPath(install.entries.Default!) : null) ??
profiles.find((p) => p.entries.Default === '1') ??
profiles.find((p) => /default-release$/.test(p.entries.Path ?? '')) ??
profiles[0] ??
null;
if (!chosen?.entries.Path) return null;
return chosen.entries.IsRelative === '0' ? chosen.entries.Path : path.join(root, chosen.entries.Path);
}
// --- Verrou de profil ----------------------------------------------------------
/**

View File

@@ -335,6 +335,15 @@ async function runAction(action: AgentAction, params: Record<string, unknown>):
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':

View File

@@ -36,6 +36,7 @@ export type ObsAction = Exclude<
| 'agent.update'
| 'browser.open'
| 'browser.close'
| 'browser.importSession'
| 'capture.start'
| 'capture.stop'
| 'preset.apply'

View File

@@ -47,6 +47,7 @@ export const AGENT_ACTIONS = [
'hotkey.fullscreen',
'browser.open',
'browser.close',
'browser.importSession',
'capture.start',
'capture.stop',
'preset.apply',
@@ -70,6 +71,14 @@ export interface AgentActionParams {
'preset.apply': { presetId?: string; encoder?: RecordingEncoder };
}
/** Compte rendu de l'import de session (mode `bidi` uniquement). */
export interface SessionImportResult {
/** Profil Firefox personnel dont les cookies ont été copiés. */
sourceProfile: string;
/** Fichiers copiés : `cookies.sqlite`, son journal WAL, le stockage local… */
copied: string[];
}
// ---------------------------------------------------------------------------
// Presets d'enregistrement
// ---------------------------------------------------------------------------
@@ -698,6 +707,8 @@ export const AGENT_EVENTS = [
'fullscreen.failed',
'browser.opened',
'browser.closed',
'browser.imported',
'browser.importFailed',
'preset.applied',
'preset.failed',
'config.changed',

View File

@@ -46,6 +46,8 @@ const EVENT_STYLES: Record<AgentEvent, EventStyle> = {
'fullscreen.failed': { icon: '⛶', tone: 'warn' },
'browser.opened': { icon: '⊞', tone: 'info' },
'browser.closed': { icon: '⊟', tone: 'offline' },
'browser.imported': { icon: '⇄', tone: 'ok' },
'browser.importFailed': { icon: '⇄', tone: 'warn' },
'preset.applied': { icon: '≡', tone: 'ok' },
'preset.failed': { icon: '≡', tone: 'warn' },
'config.changed': { icon: '⚙', tone: 'info' },

View File

@@ -6,6 +6,7 @@ import type {
PresetApplyResult,
RecordingPreset,
RecordingSettings,
SessionImportResult,
StreamState,
WatchSettings,
WatchTarget,
@@ -85,6 +86,8 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
const [recording, setRecording] = useState<RecordingSettings>(agent.recording);
const [presetResult, setPresetResult] = useState<PresetApplyResult | null>(null);
const [applyingPreset, setApplyingPreset] = useState(false);
const [importResult, setImportResult] = useState<SessionImportResult | null>(null);
const [importingSession, setImportingSession] = useState(false);
const preset = findRecordingPreset(recording.presetId) ?? RECORDING_PRESETS[0]!;
const encoderInfo = RECORDING_ENCODERS[recording.encoder];
@@ -127,6 +130,25 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
}
}
/**
* Copie les cookies du Firefox personnel dans le profil piloté, pour que la
* capture démarre déjà connectée. Ferme au passage l'instance pilotée si
* elle tournait — la prochaine capture la relance avec les cookies neufs.
*/
async function importSession() {
setImportingSession(true);
setImportResult(null);
try {
const { data } = await api.command(agent.id, 'browser.importSession');
setImportResult(data as SessionImportResult);
notify('Session importée');
} catch (err) {
notify(err instanceof Error ? err.message : "Import de session impossible", 'error');
} finally {
setImportingSession(false);
}
}
async function save() {
setBusy(true);
try {
@@ -441,6 +463,33 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
</label>
)}
{browser.mode === 'bidi' && (
<div className="row" style={{ alignItems: 'flex-start' }}>
<div className="field grow">
<button
className="ghost"
disabled={importingSession}
onClick={() => void importSession()}
>
{importingSession ? 'Import' : 'Importer les cookies du Firefox personnel'}
</button>
<span className="muted small">
Le profil piloté part vierge, donc déconnecté des sites où tu es connecté
ailleurs. Cette action copie les cookies de session (et rien d'autre) depuis
ton Firefox habituel, en lecture seule rien n'y est jamais écrit. Connecte-toi
d'abord dans ce Firefox- si ce n'est pas déjà fait. L'instance pilotée en
cours, s'il y en a une, est fermée : la prochaine capture repart connectée.
</span>
{importResult && (
<p className="small">
Copié depuis <code>{importResult.sourceProfile}</code> :{' '}
{importResult.copied.join(', ')}
</p>
)}
</div>
</div>
)}
<div className="row">
<label className="field grow">
<span>Délai avant le plein écran (s)</span>