diff --git a/README.md b/README.md index efafe99..181a86b 100644 --- a/README.md +++ b/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 diff --git a/packages/agent/src/firefox.ts b/packages/agent/src/firefox.ts index 711bbe8..c9eb195 100644 --- a/packages/agent/src/firefox.ts +++ b/packages/agent/src/firefox.ts @@ -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 { + 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 { + 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; +} + +/** 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 ---------------------------------------------------------- /** diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 783d935..ef0de7f 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -335,6 +335,15 @@ async function runAction(action: AgentAction, params: Record): 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': diff --git a/packages/agent/src/obs.ts b/packages/agent/src/obs.ts index 0c7b27f..26102b9 100644 --- a/packages/agent/src/obs.ts +++ b/packages/agent/src/obs.ts @@ -36,6 +36,7 @@ export type ObsAction = Exclude< | 'agent.update' | 'browser.open' | 'browser.close' + | 'browser.importSession' | 'capture.start' | 'capture.stop' | 'preset.apply' diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a32c4e1..b9947a1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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', diff --git a/packages/web/src/components/AgentHistory.tsx b/packages/web/src/components/AgentHistory.tsx index 25191ad..22950a6 100644 --- a/packages/web/src/components/AgentHistory.tsx +++ b/packages/web/src/components/AgentHistory.tsx @@ -46,6 +46,8 @@ const EVENT_STYLES: Record = { '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' }, diff --git a/packages/web/src/components/AgentSettings.tsx b/packages/web/src/components/AgentSettings.tsx index b17d40c..b94e69b 100644 --- a/packages/web/src/components/AgentSettings.tsx +++ b/packages/web/src/components/AgentSettings.tsx @@ -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(agent.recording); const [presetResult, setPresetResult] = useState(null); const [applyingPreset, setApplyingPreset] = useState(false); + const [importResult, setImportResult] = useState(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 )} + {browser.mode === 'bidi' && ( +
+
+ + + 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. + + {importResult && ( +

+ Copié depuis {importResult.sourceProfile} :{' '} + {importResult.copied.join(', ')} +

+ )} +
+
+ )} +