/** * LoginManager — Electron BrowserWindow-based web login for cookie providers * * Opens a native Electron window navigated to the provider's login page. * Polls the session cookie store for target cookies after login completes. * * Events: * "status" — { status: string, message: string, providerId: string } * status values: starting, navigating, waiting, polling, complete, error, cancelled */ const { BrowserWindow, session } = require("electron"); const { EventEmitter } = require("events"); const path = require("path"); // In production, the tokenExtractionConfig is bundled under open-sse/services/. // We resolve relative to the Electron resources path. let TOKEN_EXTRACTION_CONFIGS = null; function getConfigs() { if (TOKEN_EXTRACTION_CONFIGS) return TOKEN_EXTRACTION_CONFIGS; try { const mod = require("../open-sse/services/tokenExtractionConfig"); TOKEN_EXTRACTION_CONFIGS = mod.TOKEN_EXTRACTION_CONFIGS; } catch { // Fallback: try from app resources try { const mod = require("./open-sse/services/tokenExtractionConfig"); TOKEN_EXTRACTION_CONFIGS = mod.TOKEN_EXTRACTION_CONFIGS; } catch {} } return TOKEN_EXTRACTION_CONFIGS; } class LoginManager extends EventEmitter { constructor() { super(); this.window = null; this.activeProviderId = null; this.resolvePromise = null; this.rejectPromise = null; this.timeoutId = null; this.isCompleted = false; this.pollIntervalId = null; this.loginSession = null; } /** * Start a login flow for a web-cookie provider. * @param {string} providerId - e.g. "claude-web", "chatgpt-web" * @param {object} [options] * @param {number} [options.timeout] - Total timeout in ms (default: config or 300s) * @returns {Promise<{success: boolean, credentials?: Record, error?: string}>} */ startLogin(providerId, options = {}) { const configs = getConfigs(); if (!configs) { return Promise.resolve({ success: false, error: "tokenExtractionConfig module not found", }); } const extractionConfig = configs.get(providerId); if (!extractionConfig) { return Promise.resolve({ success: false, error: `No extraction config for provider: ${providerId}`, }); } if (this.activeProviderId) { return Promise.resolve({ success: false, error: "A login process is already in progress", }); } this.activeProviderId = providerId; this.isCompleted = false; const timeout = options.timeout || extractionConfig.pollingConfig.timeout || 300_000; const minLoginTime = extractionConfig.pollingConfig.minLoginTime || 5000; const pollInterval = extractionConfig.pollingConfig.pollInterval || 1000; return new Promise((resolve, reject) => { this.resolvePromise = resolve; this.rejectPromise = reject; this.emit("status", { providerId, status: "starting", message: `Opening ${extractionConfig.displayName} login...`, }); try { this._openLoginWindow(providerId, extractionConfig, timeout, minLoginTime, pollInterval); } catch (err) { this._cleanup(); this.emit("status", { providerId, status: "error", message: `Failed to open window: ${err.message}`, }); resolve({ success: false, error: err.message }); } }); } /** * Open the Electron BrowserWindow for login */ _openLoginWindow(providerId, config, timeout, minLoginTime, pollInterval) { this.window = new BrowserWindow({ width: 1000, height: 750, title: `Login - ${config.displayName}`, webPreferences: { contextIsolation: true, nodeIntegration: false, session: session.fromPartition(`login-${providerId}-${Date.now()}`), }, show: true, autoHideMenuBar: true, }); const winSession = this.window.webContents.session; // Track navigation for success URL detection let navigatedToLogin = false; const startTime = Date.now(); this.window.webContents.on("did-navigate", (_event, url) => { if (this.isCompleted) return; try { const parsedUrl = new URL(url); // Check if we've navigated away from the login page (successful login) if (navigatedToLogin && config.successUrlPattern) { if (config.successUrlPattern.test(url)) { this.emit("status", { providerId, status: "detected", message: "Login page redirect detected — extracting cookies...", }); } } if (!navigatedToLogin) { navigatedToLogin = true; } this.emit("status", { providerId, status: "navigating", message: `Navigated to ${parsedUrl.hostname}`, }); } catch { // ignore bad URLs } }); // Load the login page this.emit("status", { providerId, status: "navigating", message: `Loading ${config.loginUrl}...`, }); this.window.loadURL(config.loginUrl); // Show window when ready this.window.once("ready-to-show", () => { this.window.show(); }); // Handle window close by user this.window.on("closed", () => { if (!this.isCompleted) { this._cleanup(); this.emit("status", { providerId, status: "cancelled", message: "Login window closed by user", }); if (this.resolvePromise) { this.resolvePromise({ success: false, error: "Login window closed" }); } } }); // Start polling for cookies after minLoginTime has elapsed this.timeoutId = setTimeout(() => { if (this.isCompleted) return; this._startPolling(providerId, config, winSession, pollInterval, startTime, minLoginTime); }, minLoginTime); // Overall timeout this._timeoutTimer = setTimeout(() => { if (!this.isCompleted) { this._cleanup(); this.emit("status", { providerId, status: "error", message: "Login timed out", }); if (this.resolvePromise) { this.resolvePromise({ success: false, error: "Login timed out" }); } } }, timeout); } /** * Poll the Electron session cookie store for the target cookies */ _startPolling(providerId, config, winSession, pollInterval, startTime, minLoginTime) { const maxPolls = Math.floor(config.pollingConfig.timeout / pollInterval); let pollCount = 0; const poll = () => { if (this.isCompleted) return; pollCount++; // Emit progress every 30 polls if (pollCount % 30 === 0) { const elapsed = Math.round((Date.now() - startTime) / 60000); this.emit("status", { providerId, status: "waiting", message: `Waiting for login... (${elapsed}m)`, }); } winSession.cookies .get({}) .then((cookies) => { if (this.isCompleted) return; const tokenSources = config.tokenSources; const credentials = {}; // Collect all cookie-based sources const cookieSources = tokenSources.filter((s) => s.type === "cookie"); for (const source of cookieSources) { const domain = source.domain || undefined; const matched = cookies.find( (c) => c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, ""))) ); if (matched) { credentials[source.name] = matched.value; } } // Check localStorage-based tokens via executeJavaScript const storageSources = tokenSources.filter( (s) => s.type === "localStorage" || s.type === "sessionStorage" ); if (storageSources.length > 0 && this.window && !this.window.isDestroyed()) { // Execute JS to extract all localStorage/sessionStorage tokens const storageType = storageSources[0].type === "localStorage" ? "localStorage" : "sessionStorage"; const keys = storageSources.map((s) => s.key); const js = `(() => { const res = {}; ${JSON.stringify(keys)}.forEach(k => { try { res[k] = ${storageType}.getItem(k); } catch {} }); return res; })()`; this.window.webContents .executeJavaScript(js) .then((values) => { if (values && typeof values === "object") { Object.assign(credentials, values); } this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval); }) .catch(() => { this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval); }); } else { this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval); } }) .catch(() => { if (!this.isCompleted) { this.pollIntervalId = setTimeout(poll, pollInterval); } }); }; // Start first poll this.pollIntervalId = setTimeout(poll, 0); } /** * Check if we have all required credentials, otherwise continue polling */ _checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval) { if (this.isCompleted) return; // Collect the required source names/keys const requiredKeys = [ ...cookieSources.map((s) => s.name), ...storageSources.map((s) => s.key), ]; const foundKeys = Object.keys(credentials); const allFound = requiredKeys.every((k) => foundKeys.includes(k)); if (allFound && foundKeys.length > 0) { // Success — all credentials extracted this._completeLogin(providerId, foundKeys.reduce((acc, k) => { acc[k] = credentials[k]; return acc; }, {})); } else if (!this.isCompleted) { // Continue polling using the configured interval this.pollIntervalId = setTimeout(poll, pollInterval); } } /** * Complete the login flow successfully */ _completeLogin(providerId, credentials) { this._cleanup(); this.emit("status", { providerId, status: "complete", message: "Credentials extracted successfully", }); if (this.resolvePromise) { this.resolvePromise({ success: true, credentials }); } } /** * Cancel the current login flow */ cancel() { if (!this.activeProviderId) return; this._cleanup(); this.emit("status", { providerId: this.activeProviderId, status: "cancelled", message: "Login cancelled", }); if (this.resolvePromise) { this.resolvePromise({ success: false, error: "Login cancelled" }); } } /** * Clean up all resources */ _cleanup() { this.isCompleted = true; this.activeProviderId = null; if (this.timeoutId) { clearTimeout(this.timeoutId); this.timeoutId = null; } if (this._timeoutTimer) { clearTimeout(this._timeoutTimer); this._timeoutTimer = null; } if (this.pollIntervalId) { clearTimeout(this.pollIntervalId); this.pollIntervalId = null; } if (this.window && !this.window.isDestroyed()) { this.window.close(); } this.window = null; this.loginSession = null; } /** * Get the active provider ID, if any */ getActiveProvider() { return this.activeProviderId; } } module.exports = { LoginManager, loginManager: new LoginManager() };