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
|
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.
|
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é
|
#### 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
|
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 fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
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 { BidiClient } from './bidi.ts';
|
||||||
import { assertWebUrl, type BrowserLog } from './browser.ts';
|
import { assertWebUrl, type BrowserLog } from './browser.ts';
|
||||||
import type { FullscreenOutcome } from './fullscreen.ts';
|
import type { FullscreenOutcome } from './fullscreen.ts';
|
||||||
@@ -221,6 +226,76 @@ export class FirefoxController {
|
|||||||
this.detach();
|
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 --------------------------------------------
|
// --- Cycle de vie de l'instance --------------------------------------------
|
||||||
|
|
||||||
/** Vrai si l'appel a dû lancer Firefox. */
|
/** 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 ----------------------------------------------------------
|
// --- Verrou de profil ----------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -335,6 +335,15 @@ async function runAction(action: AgentAction, params: Record<string, unknown>):
|
|||||||
return openPage(requireUrl(params));
|
return openPage(requireUrl(params));
|
||||||
case 'browser.close':
|
case 'browser.close':
|
||||||
return driver()?.quit() ?? closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report);
|
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':
|
case 'capture.start':
|
||||||
return startCapture(params);
|
return startCapture(params);
|
||||||
case 'capture.stop':
|
case 'capture.stop':
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type ObsAction = Exclude<
|
|||||||
| 'agent.update'
|
| 'agent.update'
|
||||||
| 'browser.open'
|
| 'browser.open'
|
||||||
| 'browser.close'
|
| 'browser.close'
|
||||||
|
| 'browser.importSession'
|
||||||
| 'capture.start'
|
| 'capture.start'
|
||||||
| 'capture.stop'
|
| 'capture.stop'
|
||||||
| 'preset.apply'
|
| 'preset.apply'
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const AGENT_ACTIONS = [
|
|||||||
'hotkey.fullscreen',
|
'hotkey.fullscreen',
|
||||||
'browser.open',
|
'browser.open',
|
||||||
'browser.close',
|
'browser.close',
|
||||||
|
'browser.importSession',
|
||||||
'capture.start',
|
'capture.start',
|
||||||
'capture.stop',
|
'capture.stop',
|
||||||
'preset.apply',
|
'preset.apply',
|
||||||
@@ -70,6 +71,14 @@ export interface AgentActionParams {
|
|||||||
'preset.apply': { presetId?: string; encoder?: RecordingEncoder };
|
'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
|
// Presets d'enregistrement
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -698,6 +707,8 @@ export const AGENT_EVENTS = [
|
|||||||
'fullscreen.failed',
|
'fullscreen.failed',
|
||||||
'browser.opened',
|
'browser.opened',
|
||||||
'browser.closed',
|
'browser.closed',
|
||||||
|
'browser.imported',
|
||||||
|
'browser.importFailed',
|
||||||
'preset.applied',
|
'preset.applied',
|
||||||
'preset.failed',
|
'preset.failed',
|
||||||
'config.changed',
|
'config.changed',
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const EVENT_STYLES: Record<AgentEvent, EventStyle> = {
|
|||||||
'fullscreen.failed': { icon: '⛶', tone: 'warn' },
|
'fullscreen.failed': { icon: '⛶', tone: 'warn' },
|
||||||
'browser.opened': { icon: '⊞', tone: 'info' },
|
'browser.opened': { icon: '⊞', tone: 'info' },
|
||||||
'browser.closed': { icon: '⊟', tone: 'offline' },
|
'browser.closed': { icon: '⊟', tone: 'offline' },
|
||||||
|
'browser.imported': { icon: '⇄', tone: 'ok' },
|
||||||
|
'browser.importFailed': { icon: '⇄', tone: 'warn' },
|
||||||
'preset.applied': { icon: '≡', tone: 'ok' },
|
'preset.applied': { icon: '≡', tone: 'ok' },
|
||||||
'preset.failed': { icon: '≡', tone: 'warn' },
|
'preset.failed': { icon: '≡', tone: 'warn' },
|
||||||
'config.changed': { icon: '⚙', tone: 'info' },
|
'config.changed': { icon: '⚙', tone: 'info' },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
PresetApplyResult,
|
PresetApplyResult,
|
||||||
RecordingPreset,
|
RecordingPreset,
|
||||||
RecordingSettings,
|
RecordingSettings,
|
||||||
|
SessionImportResult,
|
||||||
StreamState,
|
StreamState,
|
||||||
WatchSettings,
|
WatchSettings,
|
||||||
WatchTarget,
|
WatchTarget,
|
||||||
@@ -85,6 +86,8 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
|
|||||||
const [recording, setRecording] = useState<RecordingSettings>(agent.recording);
|
const [recording, setRecording] = useState<RecordingSettings>(agent.recording);
|
||||||
const [presetResult, setPresetResult] = useState<PresetApplyResult | null>(null);
|
const [presetResult, setPresetResult] = useState<PresetApplyResult | null>(null);
|
||||||
const [applyingPreset, setApplyingPreset] = useState(false);
|
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 preset = findRecordingPreset(recording.presetId) ?? RECORDING_PRESETS[0]!;
|
||||||
const encoderInfo = RECORDING_ENCODERS[recording.encoder];
|
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() {
|
async function save() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -441,6 +463,33 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
|
|||||||
</label>
|
</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">
|
<div className="row">
|
||||||
<label className="field grow">
|
<label className="field grow">
|
||||||
<span>Délai avant le plein écran (s)</span>
|
<span>Délai avant le plein écran (s)</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user