/**
* Shared Context Manager with Persistent Chrome Profile
*
* Manages ONE global persistent BrowserContext for ALL sessions.
* This is critical for avoiding bot detection:
*
* - Google tracks browser fingerprints (Canvas, WebGL, Audio, Fonts, etc.)
* - Multiple contexts = Multiple fingerprints = Suspicious!
* - ONE persistent context = ONE consistent fingerprint = Normal user
* - Persistent user_data_dir = SAME fingerprint across all app restarts!
*
* Based on the Python implementation from shared_context_manager.py
*/
import type { BrowserContext } from 'patchright';
import { chromium } from 'patchright';
import { CONFIG } from '../config.js';
import { log } from '../utils/logger.js';
import { AuthManager } from '../auth/auth-manager.js';
import fs from 'fs';
import path from 'path';
/**
* Shared Context Manager
*
* Benefits:
* 1. ONE consistent browser fingerprint for all sessions
* 2. Fingerprint persists across app restarts (user_data_dir)
* 3. Mimics real user behavior (one browser, multiple tabs)
* 4. Google sees: "Same browser since day 1"
*/
export class SharedContextManager {
private authManager: AuthManager;
private globalContext: BrowserContext | null = null;
private contextCreatedAt: number | null = null;
private currentProfileDir: string | null = null;
private isIsolatedProfile: boolean = false;
private currentHeadlessMode: boolean | null = null;
constructor(authManager: AuthManager) {
this.authManager = authManager;
log.info('π SharedContextManager initialized (PERSISTENT MODE)');
log.info(` Chrome Profile: ${CONFIG.chromeProfileDir}`);
log.success(' Fingerprint: PERSISTENT across restarts! π―');
// Cleanup old isolated profiles at startup (best-effort)
if (CONFIG.cleanupInstancesOnStartup) {
void this.pruneIsolatedProfiles('startup');
}
}
/**
* Get the global shared persistent context, or create new if needed
*
* Context is recreated only when:
* - First time (no context exists in this app instance)
* - Context was closed/invalid
*
* Note: Auth expiry does NOT recreate context - we reuse the SAME
* fingerprint and just re-login!
*
* @param overrideHeadless Optional override for headless mode (true = show browser)
*/
async getOrCreateContext(overrideHeadless?: boolean): Promise<BrowserContext> {
// Check if headless mode needs to be changed (e.g., show_browser=true)
// If yes, close the browser so it gets recreated with the new mode
if (this.needsHeadlessModeChange(overrideHeadless)) {
log.warning('π Headless mode change detected - recreating browser context...');
await this.closeContext();
}
if (await this.needsRecreation()) {
log.warning('π Creating/Loading persistent context...');
await this.recreateContext(overrideHeadless);
} else {
log.success('β»οΈ Reusing existing persistent context');
}
return this.globalContext!;
}
/**
* Check if global context needs to be recreated
*/
private async needsRecreation(): Promise<boolean> {
// No context exists yet (first time or after manual close)
if (!this.globalContext) {
log.info(' βΉοΈ No context exists yet');
return true;
}
// Async validity check (will throw if closed)
try {
await this.globalContext.cookies();
log.dim(' β
Context still valid (browser open)');
return false;
} catch {
log.warning(' β οΈ Context appears closed - will recreate');
this.globalContext = null;
this.contextCreatedAt = null;
this.currentHeadlessMode = null;
return true;
}
}
/**
* Create/Load the global PERSISTENT context with Chrome user_data_dir
*
* This is THE KEY to fingerprint persistence!
*
* First time:
* - Chrome creates new profile in user_data_dir
* - Generates fingerprint (Canvas, WebGL, Audio, etc.)
* - Saves everything to disk
*
* Subsequent starts:
* - Chrome loads profile from user_data_dir
* - SAME fingerprint as before! β
* - Google sees: "Same browser since day 1"
*
* @param overrideHeadless Optional override for headless mode (true = show browser)
*/
private async recreateContext(overrideHeadless?: boolean): Promise<void> {
// Close old context if exists
if (this.globalContext) {
try {
log.info(' ποΈ Closing old context...');
await this.globalContext.close();
} catch (error) {
log.warning(` β οΈ Error closing old context: ${error}`);
}
}
// Check for saved auth
const statePath = await this.authManager.getValidStatePath();
if (statePath) {
log.success(` π Found auth state: ${statePath}`);
log.info(' π‘ Will load cookies into persistent profile');
} else {
log.warning(' π No saved auth - fresh persistent profile');
log.info(' π‘ First login will save auth to persistent profile');
}
// Determine headless mode: use override if provided, otherwise use CONFIG
const shouldBeHeadless = overrideHeadless !== undefined ? !overrideHeadless : CONFIG.headless;
if (overrideHeadless !== undefined) {
log.info(` Browser visibility override: ${overrideHeadless ? 'VISIBLE' : 'HEADLESS'}`);
}
// Build launch options for persistent context
// NOTE: userDataDir is passed as first parameter, NOT in options!
const launchOptions = {
headless: shouldBeHeadless,
channel: 'chrome' as const,
viewport: CONFIG.viewport,
locale: 'en-US',
timezoneId: 'Europe/Berlin',
// β
CRITICAL FIX: Pass storageState directly at launch!
// This is the PROPER way to handle session cookies (Playwright bug workaround)
// Benefits:
// - Session cookies persist correctly
// - No need for addCookies() workarounds
// - Chrome loads everything automatically
...(statePath && { storageState: statePath }),
args: [
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
'--no-first-run',
'--no-default-browser-check',
],
};
// π₯ CRITICAL: launchPersistentContext creates/loads Chrome profile
// Strategy handling for concurrent instances
const baseProfile = CONFIG.chromeProfileDir;
const strategy = CONFIG.profileStrategy;
const tryLaunch = async (userDataDir: string) => {
log.info(' π Launching persistent Chrome context...');
log.dim(` π Profile location: ${userDataDir}`);
if (statePath) {
log.info(` π Loading auth state: ${statePath}`);
}
return chromium.launchPersistentContext(userDataDir, launchOptions);
};
try {
if (strategy === 'isolated') {
const isolatedDir = await this.prepareIsolatedProfileDir(baseProfile);
this.globalContext = await tryLaunch(isolatedDir);
this.currentProfileDir = isolatedDir;
this.isIsolatedProfile = true;
} else {
// single or auto β first try base
this.globalContext = await tryLaunch(baseProfile);
this.currentProfileDir = baseProfile;
this.isIsolatedProfile = false;
}
} catch (e: unknown) {
const msg = String(e instanceof Error ? e.message : e);
const isSingleton = /ProcessSingleton|SingletonLock|profile is already in use/i.test(msg);
if (strategy === 'single' || !isSingleton) {
// hard fail
if (isSingleton && strategy === 'single') {
log.error(
'β Chrome profile already in use and strategy=single. Close other instance or set NOTEBOOK_PROFILE_STRATEGY=auto/isolated.'
);
}
throw e;
}
// auto strategy with lock β fall back to isolated instance dir
log.warning(
'β οΈ Base Chrome profile in use by another process. Falling back to isolated per-instance profile...'
);
const isolatedDir = await this.prepareIsolatedProfileDir(baseProfile);
this.globalContext = await tryLaunch(isolatedDir);
this.currentProfileDir = isolatedDir;
this.isIsolatedProfile = true;
}
this.contextCreatedAt = Date.now();
this.currentHeadlessMode = shouldBeHeadless;
// Track close event to force recreation next time
try {
this.globalContext.on('close', () => {
log.warning(' π Persistent context was closed externally');
this.globalContext = null;
this.contextCreatedAt = null;
this.currentHeadlessMode = null;
});
} catch {
/* Event handler registration may fail on closed context */
}
// Validate cookies if we loaded state
if (statePath) {
try {
if (await this.authManager.validateCookiesExpiry(this.globalContext)) {
log.success(' β
Authentication state loaded successfully');
log.success(' π― Session cookies persisted correctly!');
} else {
log.warning(' β οΈ Cookies expired - will need re-login');
}
} catch (error) {
log.warning(` β οΈ Could not validate auth state: ${error}`);
}
}
log.success('β
Persistent context ready!');
log.dim(` Context ID: ${this.getContextId()}`);
log.dim(` Chrome Profile: ${CONFIG.chromeProfileDir}`);
log.success(' π― Fingerprint: PERSISTENT (same across restarts!)');
}
/**
* Manually close the global context (e.g., on shutdown)
*
* Note: This closes the context for ALL sessions!
* Chrome will save everything to user_data_dir automatically.
*/
async closeContext(): Promise<void> {
if (this.globalContext) {
log.warning('π Closing persistent context...');
log.info(' πΎ Chrome is saving profile to disk...');
try {
await this.globalContext.close();
this.globalContext = null;
this.contextCreatedAt = null;
this.currentHeadlessMode = null;
log.success('β
Persistent context closed');
log.success(` πΎ Profile saved: ${this.currentProfileDir || CONFIG.chromeProfileDir}`);
} catch (error) {
log.error(`β Error closing context: ${error}`);
}
}
// Best-effort cleanup on shutdown
if (CONFIG.cleanupInstancesOnShutdown) {
try {
// If this process used an isolated profile, remove it now
if (this.isIsolatedProfile && this.currentProfileDir) {
await this.safeRemoveIsolatedProfile(this.currentProfileDir);
}
} catch (err) {
log.warning(` β οΈ Cleanup (self) failed: ${err}`);
}
try {
await this.pruneIsolatedProfiles('shutdown');
} catch (err) {
log.warning(` β οΈ Cleanup (prune) failed: ${err}`);
}
}
}
private async prepareIsolatedProfileDir(baseProfile: string): Promise<string> {
const stamp = `${process.pid}-${Date.now()}`;
const dir = path.join(CONFIG.chromeInstancesDir, `instance-${stamp}`);
try {
fs.mkdirSync(dir, { recursive: true });
if (CONFIG.cloneProfileOnIsolated && fs.existsSync(baseProfile)) {
log.info(' 𧬠Cloning base Chrome profile into isolated instance (may take time)...');
// Best-effort clone without locks
await (fs.promises as any).cp(baseProfile, dir, {
recursive: true,
errorOnExist: false,
force: true,
filter: (src: string) => {
const bn = path.basename(src);
return !/^Singleton/i.test(bn) && !bn.endsWith('.lock') && !bn.endsWith('.tmp');
},
} as any);
log.success(' β
Clone complete');
} else {
log.info(' π§ͺ Using fresh isolated Chrome profile (no clone)');
}
} catch (err) {
log.warning(` β οΈ Could not prepare isolated profile: ${err}`);
}
return dir;
}
private async pruneIsolatedProfiles(phase: 'startup' | 'shutdown'): Promise<void> {
const root = CONFIG.chromeInstancesDir;
let entries: Array<{ path: string; mtimeMs: number }>;
try {
const names = await fs.promises.readdir(root, { withFileTypes: true });
entries = [];
for (const d of names) {
if (!d.isDirectory()) continue;
const p = path.join(root, d.name);
try {
const st = await fs.promises.stat(p);
entries.push({ path: p, mtimeMs: st.mtimeMs });
} catch {
/* Ignore stat errors for individual directories */
}
}
} catch {
return; // directory absent is fine
}
if (entries.length === 0) return;
const now = Date.now();
const ttlMs = CONFIG.instanceProfileTtlHours * 3600 * 1000;
const maxCount = Math.max(0, CONFIG.instanceProfileMaxCount);
// Sort newest first
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
const keep: Set<string> = new Set();
const toDelete: Set<string> = new Set();
// Keep newest up to maxCount
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
const ageMs = now - e.mtimeMs;
const overTtl = ttlMs > 0 && ageMs > ttlMs;
const overCount = i >= maxCount;
const isCurrent =
this.currentProfileDir && path.resolve(e.path) === path.resolve(this.currentProfileDir);
if (!isCurrent && (overTtl || overCount)) {
toDelete.add(e.path);
} else {
keep.add(e.path);
}
}
if (toDelete.size === 0) return;
log.info(`π§Ή Pruning isolated profiles (${phase})...`);
for (const p of toDelete) {
try {
await this.safeRemoveIsolatedProfile(p);
log.dim(` ποΈ removed ${p}`);
} catch (err) {
log.warning(` β οΈ Failed to remove ${p}: ${err}`);
}
}
}
private async safeRemoveIsolatedProfile(dir: string): Promise<void> {
// Never remove the base profile
if (path.resolve(dir) === path.resolve(CONFIG.chromeProfileDir)) return;
// Only remove within instances root
if (!path.resolve(dir).startsWith(path.resolve(CONFIG.chromeInstancesDir))) return;
// Best-effort: try removing typical lock files first, then the directory
try {
await fs.promises.rm(dir, { recursive: true, force: true } as any);
} catch {
// If rm is not available in older node, fallback to rmdir
try {
await (fs.promises as any).rmdir(dir, { recursive: true });
} catch {
/* Ignore errors during fallback removal */
}
}
}
/**
* Get information about the global persistent context
*/
getContextInfo(): {
exists: boolean;
age_seconds?: number;
age_hours?: number;
fingerprint_id?: string;
user_data_dir: string;
persistent: boolean;
} {
if (!this.globalContext) {
return {
exists: false,
user_data_dir: CONFIG.chromeProfileDir,
persistent: true,
};
}
const ageSeconds = this.contextCreatedAt
? (Date.now() - this.contextCreatedAt) / 1000
: undefined;
const ageHours = ageSeconds ? ageSeconds / 3600 : undefined;
return {
exists: true,
age_seconds: ageSeconds,
age_hours: ageHours,
fingerprint_id: this.getContextId(),
user_data_dir: CONFIG.chromeProfileDir,
persistent: true,
};
}
/**
* Get the current headless mode of the browser context
*
* @returns boolean | null - true if headless, false if visible, null if no context exists
*/
getCurrentHeadlessMode(): boolean | null {
return this.currentHeadlessMode;
}
/**
* Check if the browser context needs to be recreated due to headless mode change
*
* @param overrideHeadless - Optional override for headless mode (true = show browser)
* @returns boolean - true if context needs to be recreated with new mode
*/
needsHeadlessModeChange(overrideHeadless?: boolean): boolean {
// No context exists yet = will be created with correct mode anyway
if (this.currentHeadlessMode === null) {
return false;
}
// Calculate target headless mode
// If override is specified, use it (!overrideHeadless because true = show browser = headless false)
// Otherwise, use CONFIG.headless (which may have been temporarily modified by browser_options)
const targetHeadless = overrideHeadless !== undefined ? !overrideHeadless : CONFIG.headless;
// Compare with current mode
const needsChange = this.currentHeadlessMode !== targetHeadless;
if (needsChange) {
log.info(
` Browser mode change detected: ${this.currentHeadlessMode ? 'HEADLESS' : 'VISIBLE'} β ${targetHeadless ? 'HEADLESS' : 'VISIBLE'}`
);
}
return needsChange;
}
/**
* Get context ID for logging
*/
private getContextId(): string {
if (!this.globalContext) {
return 'none';
}
// Use internal Playwright _guid as ID (internal property, not typed)
return `ctx-${(this.globalContext as unknown as { _guid?: string })._guid || 'unknown'}`;
}
}