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'