387 lines
12 KiB
JavaScript
387 lines
12 KiB
JavaScript
/**
|
|
* 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<string, string>, 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() };
|