Feat : import session into auto firefox
This commit is contained in:
@@ -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 ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -36,6 +36,7 @@ export type ObsAction = Exclude<
|
||||
| 'agent.update'
|
||||
| 'browser.open'
|
||||
| 'browser.close'
|
||||
| 'browser.importSession'
|
||||
| 'capture.start'
|
||||
| 'capture.stop'
|
||||
| 'preset.apply'
|
||||
|
||||
Reference in New Issue
Block a user