Feat : import session into auto firefox
This commit is contained in:
22
README.md
22
README.md
@@ -259,6 +259,28 @@ Deux garde-fous, l'un préventif et l'un correctif :
|
||||
comme aux redémarrages de l'agent — le processus est lancé détaché, et l'agent se
|
||||
rattache au port plutôt que de relancer.
|
||||
|
||||
#### Session déconnectée (cookies)
|
||||
|
||||
Le profil piloté part vierge, par choix — c'est ce qui le rend indépendant du reste de la
|
||||
session graphique, insensible à ce que l'opérateur ouvre ou ferme par ailleurs. Mais un site
|
||||
comme Stripchat exige une session ouverte, et rejouer un formulaire de connexion à chaque
|
||||
redémarrage n'a rien d'automatisable proprement.
|
||||
|
||||
Le bouton **« Importer les cookies du Firefox personnel »**, dans la configuration de
|
||||
l'agent, copie `cookies.sqlite` (et `permissions.sqlite`) depuis le profil Firefox normal de
|
||||
l'utilisateur vers le profil piloté. Toujours en lecture seule côté source : rien n'y est
|
||||
jamais écrit. Connecte-toi d'abord dans ce Firefox-là, celui que tu utilises au quotidien sur
|
||||
la VM ; l'agent retrouve son profil par défaut via `profiles.ini`, comme le ferait Firefox
|
||||
lui-même au démarrage.
|
||||
|
||||
L'instance pilotée en cours, s'il y en a une, est fermée par l'opération : la capture
|
||||
suivante la relance et repart avec les cookies fraîchement copiés. Un import se refait à
|
||||
volonté — chaque copie remplace la précédente, rien ne s'accumule.
|
||||
|
||||
Limite assumée : seuls les cookies sont copiés, pas le `localStorage` ni l'`IndexedDB`. La
|
||||
quasi-totalité des sites, Stripchat compris, portent la session dans un cookie ; si un site
|
||||
s'appuyait uniquement sur un jeton en stockage local, cet import ne suffirait pas.
|
||||
|
||||
#### Si le plein écran est refusé
|
||||
|
||||
Firefox refuse le plein écran à un document **dont la fenêtre n'a pas le focus**. C'est la
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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-là 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>
|
||||
|
||||
Reference in New Issue
Block a user