diff --git a/.gitignore b/.gitignore index 749e642..15e9330 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .idea/ .cursor/ .DS_Store +.claude/ # Environment variables (keep .env.example) .env diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..82da520 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,210 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +AIStudioToAPI is a proxy server that wraps Google AI Studio's web interface and exposes it as API endpoints compatible with OpenAI, Gemini, and Anthropic API formats. The system uses browser automation (Playwright with Camoufox/Firefox) to interact with AI Studio's web interface and translates API requests into browser interactions. + +## Common Commands + +### Development + +```bash +npm run dev # Start dev server with hot reload (server + UI) +npm run dev:server # Start only the server in dev mode +npm run dev:ui # Build UI in watch mode +``` + +### Production + +```bash +npm start # Build UI and start production server +``` + +### Authentication Setup + +```bash +npm run setup-auth # Interactive auth setup (launches browser) +npm run save-auth # Save authentication credentials +``` + +### Code Quality + +```bash +npm run lint # Lint JavaScript and CSS +npm run lint:fix # Auto-fix linting issues +npm run lint:js # Lint only JavaScript files +npm run lint:css # Lint only CSS/Less files +npm run format # Format all files with Prettier +npm run format:check # Check formatting without changes +``` + +### UI Development + +```bash +npm run build:ui # Build Vue.js UI for production +npm run preview:ui # Preview built UI +``` + +## Architecture + +### Core System Components + +The system follows a modular architecture with clear separation of concerns: + +**ProxyServerSystem** (`src/core/ProxyServerSystem.js`) + +- Main orchestrator that integrates all modules +- Manages HTTP/WebSocket servers +- Coordinates between authentication, browser management, and request handling +- Entry point: `main.js` instantiates and starts this system + +**BrowserManager** (`src/core/BrowserManager.js`) + +- Manages headless Firefox/Camoufox browser instances +- Implements multi-context architecture: maintains a pool of browser contexts (Map: authIndex -> {context, page, healthMonitorInterval}) +- Handles context switching between different Google accounts +- Injects and manages the client-side script (`build.js`) that communicates with AI Studio +- Supports background context initialization and rebalancing + +**ConnectionRegistry** (`src/core/ConnectionRegistry.js`) + +- Manages WebSocket connections from browser contexts +- Routes messages to appropriate MessageQueue instances +- Implements grace period for reconnection attempts +- Supports multiple concurrent connections (one per auth context) + +**RequestHandler** (`src/core/RequestHandler.js`) + +- Processes incoming API requests +- Coordinates retry logic and account switching +- Delegates to AuthSwitcher for account management +- Delegates to FormatConverter for API format translation + +**AuthSwitcher** (`src/auth/AuthSwitcher.js`) + +- Handles automatic account switching based on: + - Usage count (SWITCH_ON_USES) + - Failure threshold (FAILURE_THRESHOLD) + - Immediate status codes (IMMEDIATE_SWITCH_STATUS_CODES: 429, 503) +- Manages system busy state during switches + +**FormatConverter** (`src/core/FormatConverter.js`) + +- Converts between API formats (OpenAI ↔ Gemini ↔ Anthropic) +- Handles streaming and non-streaming responses + +**AuthSource** (`src/auth/AuthSource.js`) + +- Loads authentication data from `configs/auth/auth-N.json` files +- Validates and deduplicates accounts by email +- Maintains rotation indices for account switching + +### Request Flow + +1. Client sends API request (OpenAI/Gemini/Anthropic format) → Express routes +2. RequestHandler receives request → FormatConverter normalizes to Gemini format +3. RequestHandler checks ConnectionRegistry for active WebSocket +4. If no connection: BrowserManager initializes/switches browser context +5. Request sent via WebSocket to browser context → injected script interacts with AI Studio +6. Response streams back via WebSocket → FormatConverter translates to requested format +7. On failure: AuthSwitcher may trigger account switch based on configured thresholds + +### Multi-Context Architecture + +The system maintains multiple browser contexts simultaneously: + +- Each Google account gets its own browser context and page +- Contexts are initialized on-demand or in background +- Current account tracked via `browserManager.currentAuthIndex` +- Background initialization prevents request delays when switching accounts +- Context pool rebalancing ensures optimal resource usage + +### UI Structure + +- **Frontend**: Vue.js 3 + Element Plus + Vite +- **Location**: `ui/` directory +- **Build output**: `ui/dist/` (served by Express) +- **Features**: Account management, VNC login, status monitoring, auth file upload/download + +## Configuration + +### Environment Variables + +Key variables (see `.env.example` for full list): + +- `PORT`: API server port (default: 7860) +- `WS_PORT`: WebSocket port for browser communication (default: 9998) +- `API_KEYS`: Comma-separated API keys for client authentication +- `INITIAL_AUTH_INDEX`: Starting account index (default: 0) +- `STREAMING_MODE`: "real" or "fake" streaming +- `SWITCH_ON_USES`: Auto-switch after N requests (default: 40) +- `FAILURE_THRESHOLD`: Switch after N consecutive failures (default: 3) +- `IMMEDIATE_SWITCH_STATUS_CODES`: Status codes triggering immediate switch (default: 429,503) +- `HTTP_PROXY`/`HTTPS_PROXY`: Proxy configuration for Google services +- `CAMOUFOX_EXECUTABLE_PATH`: Custom browser executable path +- `LOG_LEVEL`: Set to "DEBUG" for verbose logging + +### Model Configuration + +Edit `configs/models.json` to customize available models and their settings. + +### Authentication Files + +- Location: `configs/auth/auth-N.json` (N = 0, 1, 2, ...) +- Format: Playwright browser context state (cookies, localStorage, etc.) +- Generated by: `npm run setup-auth` or VNC login in Docker + +## Key Technical Details + +### Browser Automation + +- Uses Playwright with Camoufox (privacy-focused Firefox fork) +- Injects `build.js` script into AI Studio page for WebSocket communication +- Script location: `public/build.js` (built from `ui/app/`) +- Health monitoring via periodic checks and reconnection logic + +### WebSocket Communication + +- Browser contexts connect to WebSocket server on WS_PORT +- Each connection identified by authIndex +- MessageQueue pattern for request/response correlation +- Grace period (60s) for reconnection before triggering callback + +### Account Switching + +- Automatic switching based on usage/failures +- Supports immediate switching on specific HTTP status codes +- System busy flag prevents concurrent switches +- Lightweight reconnect attempts before full context switch + +### Streaming Modes + +- **Real streaming**: True SSE streaming from AI Studio +- **Fake streaming**: Buffer complete response, then stream to client + +## Development Notes + +### Testing + +- Test files in `test/` directory +- Client test scripts in `scripts/client/` +- Auth test scripts in `scripts/auth/` + +### Linting & Formatting + +- ESLint for JavaScript (includes Vue plugin) +- Stylelint for CSS/Less +- Prettier for code formatting +- Pre-commit hooks via Husky + lint-staged + +### Docker + +- Dockerfile supports VNC for browser interaction +- Auth files mounted via volume: `/app/configs/auth` +- Environment variables for configuration + +### Git Workflow + +- Main branch: `main` diff --git a/scripts/client/build.js b/scripts/client/build.js index b1f887f..22f1b6f 100644 --- a/scripts/client/build.js +++ b/scripts/client/build.js @@ -101,6 +101,18 @@ class ConnectionManager extends EventTarget { // This line is dynamically replaced by BrowserManager.js based on WS_PORT environment variable. constructor(endpoint = "ws://127.0.0.1:9998") { super(); + + // Read authIndex from window.chrome._contextId (injected by BrowserManager via addInitScript) + const contextId = window.chrome?._contextId; + this.authIndex = contextId !== undefined ? contextId : -1; + + // Validate authIndex: must be >= 0 and not NaN for multi-context architecture + if (Number.isNaN(this.authIndex) || !Number.isInteger(this.authIndex) || this.authIndex < 0) { + const errorMsg = `❌ FATAL: Invalid authIndex (${this.authIndex}). Missing window.chrome._contextId. This is a configuration error.`; + console.error(errorMsg); + throw new Error(errorMsg); + } + this.endpoint = endpoint; this.socket = null; this.isConnected = false; @@ -110,10 +122,12 @@ class ConnectionManager extends EventTarget { async establish() { if (this.isConnected) return Promise.resolve(); - Logger.output("Connecting to server:", this.endpoint); + // Add authIndex to WebSocket URL for server-side identification + const wsUrl = this.authIndex >= 0 ? `${this.endpoint}?authIndex=${this.authIndex}` : this.endpoint; + Logger.output("Connecting to server:", wsUrl); return new Promise((resolve, reject) => { try { - this.socket = new WebSocket(this.endpoint); + this.socket = new WebSocket(wsUrl); this.socket.addEventListener("open", () => { this.isConnected = true; this.reconnectAttempts = 0; @@ -199,7 +213,7 @@ class RequestProcessor { const attemptPromise = (async () => { try { - Logger.output(`Executing request:`, requestSpec.method, requestSpec.path); + Logger.debug(`Executing request:`, requestSpec.method, requestSpec.path); const requestUrl = this._constructUrl(requestSpec); const requestConfig = this._buildRequestConfig(requestSpec, abortController.signal); @@ -572,7 +586,7 @@ class ProxySystem extends EventTarget { async _processProxyRequest(requestSpec) { const operationId = requestSpec.request_id; const mode = requestSpec.streaming_mode || "fake"; - Logger.output(`Browser received request`); + Logger.debug(`Browser received request`); let cancelTimeout; try { diff --git a/src/auth/AuthSource.js b/src/auth/AuthSource.js index f2ab9ee..a7412bb 100644 --- a/src/auth/AuthSource.js +++ b/src/auth/AuthSource.js @@ -53,7 +53,9 @@ class AuthSource { `[Auth] Reload complete. ${this.availableIndices.length} valid sources available: [${this.availableIndices.join(", ")}]` ); this.lastScannedIndices = newIndices; + return true; // Changes detected } + return false; // No changes } removeAuth(index) { diff --git a/src/auth/AuthSwitcher.js b/src/auth/AuthSwitcher.js index e4ba8d7..eaf392c 100644 --- a/src/auth/AuthSwitcher.js +++ b/src/auth/AuthSwitcher.js @@ -77,6 +77,9 @@ class AuthSwitcher { try { await this.browserManager.launchOrSwitchContext(singleIndex); this.resetCounters(); + this.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); this.logger.info( `✅ [Auth] Single account #${singleIndex} restart/refresh successful, usage count reset.` @@ -127,8 +130,13 @@ class AuthSwitcher { ); try { + // Pre-cleanup: remove excess contexts BEFORE creating new one to avoid exceeding maxContexts + await this.browserManager.preCleanupForSwitch(accountIndex); await this.browserManager.switchAccount(accountIndex); this.resetCounters(); + this.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); if (failedAccounts.length > 0) { this.logger.info( @@ -157,8 +165,13 @@ class AuthSwitcher { this.logger.warn("=================================================="); try { + // Pre-cleanup: remove excess contexts BEFORE creating new one to avoid exceeding maxContexts + await this.browserManager.preCleanupForSwitch(originalStartAccount); await this.browserManager.switchAccount(originalStartAccount); this.resetCounters(); + this.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); this.logger.info( `✅ [Auth] Final attempt succeeded! Switched to account #${originalStartAccount}.` ); @@ -213,8 +226,13 @@ class AuthSwitcher { this.isSystemBusy = true; try { this.logger.info(`🔄 [Auth] Starting switch to specified account #${targetIndex}...`); + // Pre-cleanup: remove excess contexts BEFORE creating new one to avoid exceeding maxContexts + await this.browserManager.preCleanupForSwitch(targetIndex); await this.browserManager.switchAccount(targetIndex); this.resetCounters(); + this.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); this.logger.info(`✅ [Auth] Successfully switched to account #${targetIndex}, counters reset.`); return { newIndex: targetIndex, success: true }; } catch (error) { diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index e436f5c..b621c92 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -22,8 +22,21 @@ class BrowserManager { this.config = config; this.authSource = authSource; this.browser = null; + + // Multi-context architecture: Store all initialized contexts + // Map: authIndex -> {context, page, healthMonitorInterval} + this.contexts = new Map(); + + // Context pool state tracking + this.initializingContexts = new Set(); // Indices currently being initialized in background + this.abortedContexts = new Set(); // Indices that should be aborted during background init + this._backgroundPreloadTask = null; // Current background preload task promise (only one at a time) + this._backgroundPreloadAbort = false; // Flag to signal background task to abort + + // Legacy single context references (for backward compatibility) this.context = null; this.page = null; + // currentAuthIndex is the single source of truth for current account, accessed via getter/setter // -1 means no account is currently active (invalid/error state) this._currentAuthIndex = -1; @@ -33,6 +46,10 @@ class BrowserManager { // Used by ConnectionRegistry callback to skip unnecessary reconnect attempts this.isClosingIntentionally = false; + // Background wakeup service status (instance-level, tracks this.page) + // Prevents multiple BackgroundWakeup instances from running simultaneously + this.backgroundWakeupRunning = false; + // Added for background wakeup logic from new core this.noButtonCount = 0; @@ -107,7 +124,9 @@ class BrowserManager { * @param {number} authIndex - The auth index to update */ async _updateAuthFile(authIndex) { - if (!this.context) return; + // Retrieve the target account's context from the multi-context Map to avoid cross-contamination of auth data by using this.context + const contextData = this.contexts.get(authIndex); + if (!contextData || !contextData.context) return; // Check availability of auto-update feature from config if (!this.config.enableAuthUpdate) { @@ -128,7 +147,7 @@ class BrowserManager { return; } - const storageState = await this.context.storageState(); + const storageState = await contextData.context.storageState(); // Merge new credentials into existing data authData.cookies = storageState.cookies; @@ -146,6 +165,28 @@ class BrowserManager { } } + /** + * Get pool target indices based on current account and rotation order + * @param {number} maxContexts - Max pool size (0 = unlimited) + * @returns {number[]} Target indices for the pool + */ + // _getPoolTargetIndices(maxContexts) { + // const rotation = this.authSource.getRotationIndices(); + // if (rotation.length === 0) return []; + // if (maxContexts === 0 || maxContexts >= rotation.length) return [...rotation]; + // + // const currentCanonical = + // this._currentAuthIndex >= 0 ? this.authSource.getCanonicalIndex(this._currentAuthIndex) : null; + // const startPos = currentCanonical !== null ? rotation.indexOf(currentCanonical) : -1; + // const start = startPos >= 0 ? startPos : 0; + // + // const result = []; + // for (let i = 0; i < maxContexts && i < rotation.length; i++) { + // result.push(rotation[(start + i) % rotation.length]); + // } + // return result; + // } + /** * Interface: Notify user activity * Used to force wake up the Launch detection when a request comes in @@ -223,6 +264,12 @@ class BrowserManager { window._privacyProtectionInjected = true; try { + // 0. Inject authIndex for multi-account identification (disguised as Chrome internal property) + if (!window.chrome) { + window.chrome = {}; + } + window.chrome._contextId = ${authIndex}; + // 1. Mask WebDriver property Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); @@ -300,14 +347,14 @@ class BrowserManager { 'button span:has-text("Code")', ]; - this.logger.info('[Browser] Trying to locate "Code" entry point using smart selectors...'); + this.logger.debug('[Browser] Trying to locate "Code" entry point using smart selectors...'); for (const selector of selectors) { try { // Use a short timeout for quick fail-over const element = page.locator(selector).first(); if (await element.isVisible({ timeout: 2000 })) { - this.logger.info(`[Browser] ✅ Smart match: "${selector}", clicking...`); + this.logger.debug(`[Browser] ✅ Smart match: "${selector}", clicking...`); // Direct click with force as per new logic await element.click({ force: true, timeout: 10000 }); return true; @@ -323,6 +370,7 @@ class BrowserManager { /** * Helper: Load and configure build.js script content * Applies environment-specific configurations (TARGET_DOMAIN, WS_PORT, LOG_LEVEL) + * Note: authIndex is now injected via window.chrome._contextId in addInitScript, not here * @returns {string} Configured build.js script content */ _loadAndConfigureBuildScript() { @@ -354,7 +402,7 @@ class BrowserManager { const lines = buildScriptContent.split("\n"); let portReplaced = false; for (let i = 0; i < lines.length; i++) { - if (lines[i].includes('constructor(endpoint = "ws://127.0.0.1:9998")')) { + if (lines[i].includes('constructor(endpoint = "ws://127.0.0.1:9998"')) { this.logger.info(`[Config] Found port config line: ${lines[i]}`); lines[i] = ` constructor(endpoint = "ws://127.0.0.1:${process.env.WS_PORT}") {`; this.logger.info(`[Config] Replaced with: ${lines[i]}`); @@ -406,10 +454,10 @@ class BrowserManager { * @param {string} buildScriptContent - The script content to inject * @param {string} logPrefix - Log prefix for step messages (e.g., "[Browser]" or "[Reconnect]") */ - async _injectScriptToEditor(buildScriptContent, logPrefix = "[Browser]") { - this.logger.info(`${logPrefix} Preparing UI interaction, forcefully removing all possible overlay layers...`); + async _injectScriptToEditor(page, buildScriptContent, logPrefix = "[Browser]") { + this.logger.debug(`${logPrefix} Preparing UI interaction, forcefully removing all possible overlay layers...`); /* eslint-disable no-undef */ - await this.page.evaluate(() => { + await page.evaluate(() => { const overlays = document.querySelectorAll("div.cdk-overlay-backdrop"); if (overlays.length > 0) { console.log(`[ProxyClient] (Internal JS) Found and removed ${overlays.length} overlay layers.`); @@ -418,22 +466,22 @@ class BrowserManager { }); /* eslint-enable no-undef */ - this.logger.info(`${logPrefix} (Step 1/5) Preparing to click "Code" button...`); + this.logger.debug(`${logPrefix} (Step 1/5) Preparing to click "Code" button...`); const maxTimes = 15; for (let i = 1; i <= maxTimes; i++) { try { - this.logger.info(` [Attempt ${i}/${maxTimes}] Cleaning overlay layers and clicking...`); + this.logger.debug(` [Attempt ${i}/${maxTimes}] Cleaning overlay layers and clicking...`); /* eslint-disable no-undef */ - await this.page.evaluate(() => { + await page.evaluate(() => { document.querySelectorAll("div.cdk-overlay-backdrop").forEach(el => el.remove()); }); /* eslint-enable no-undef */ - await this.page.waitForTimeout(500); + await page.waitForTimeout(500); // Use Smart Click instead of hardcoded locator - await this._smartClickCode(this.page); + await this._smartClickCode(page); - this.logger.info(" ✅ Click successful!"); + this.logger.debug(" ✅ Click successful!"); break; } catch (error) { this.logger.warn(` [Attempt ${i}/${maxTimes}] Click failed: ${error.message.split("\n")[0]}`); @@ -443,20 +491,20 @@ class BrowserManager { } } - this.logger.info( + this.logger.debug( `${logPrefix} (Step 2/5) "Code" button clicked successfully, waiting for editor to become visible...` ); - const editorContainerLocator = this.page.locator("div.monaco-editor").first(); + const editorContainerLocator = page.locator("div.monaco-editor").first(); await editorContainerLocator.waitFor({ state: "visible", timeout: 60000, }); - this.logger.info( + this.logger.debug( `${logPrefix} (Cleanup #2) Preparing to click editor, forcefully removing all possible overlay layers again...` ); /* eslint-disable no-undef */ - await this.page.evaluate(() => { + await page.evaluate(() => { const overlays = document.querySelectorAll("div.cdk-overlay-backdrop"); if (overlays.length > 0) { console.log( @@ -466,26 +514,26 @@ class BrowserManager { } }); /* eslint-enable no-undef */ - await this.page.waitForTimeout(250); + await page.waitForTimeout(250); - this.logger.info(`${logPrefix} (Step 3/5) Editor displayed, focusing and pasting script...`); + this.logger.debug(`${logPrefix} (Step 3/5) Editor displayed, focusing and pasting script...`); await editorContainerLocator.click({ timeout: 30000 }); /* eslint-disable no-undef */ - await this.page.evaluate(text => navigator.clipboard.writeText(text), buildScriptContent); + await page.evaluate(text => navigator.clipboard.writeText(text), buildScriptContent); /* eslint-enable no-undef */ const isMac = os.platform() === "darwin"; const pasteKey = isMac ? "Meta+V" : "Control+V"; - await this.page.keyboard.press(pasteKey); - this.logger.info(`${logPrefix} (Step 4/5) Script pasted.`); - this.logger.info(`${logPrefix} (Step 5/5) Clicking "Preview" button to activate script...`); - await this.page.locator('button:text("Preview")').click(); - this.logger.info(`${logPrefix} ✅ UI interaction complete, script is now running.`); + await page.keyboard.press(pasteKey); + this.logger.debug(`${logPrefix} (Step 4/5) Script pasted.`); + this.logger.debug(`${logPrefix} (Step 5/5) Clicking "Preview" button to activate script...`); + await page.locator('button:text("Preview")').click(); + this.logger.debug(`${logPrefix} ✅ UI interaction complete, script is now running.`); // Active Trigger (Hack to wake up Google Backend) - this.logger.info(`${logPrefix} ⚡ Sending active trigger request to Launch flow...`); + this.logger.debug(`${logPrefix} ⚡ Sending active trigger request to Launch flow...`); try { - await this.page.evaluate(async () => { + await page.evaluate(async () => { try { await fetch("https://generativelanguage.googleapis.com/v1beta/models?key=ActiveTrigger", { headers: { "Content-Type": "application/json" }, @@ -498,67 +546,67 @@ class BrowserManager { } catch (e) { /* empty */ } - - this._startHealthMonitor(); } /** * Helper: Navigate to target page and wake up the page * Contains the common navigation and page activation logic + * @param {Page} page - The page object to navigate * @param {string} logPrefix - Log prefix for messages (e.g., "[Browser]" or "[Reconnect]") */ - async _navigateAndWakeUpPage(logPrefix = "[Browser]") { - this.logger.info(`${logPrefix} Navigating to target page...`); + async _navigateAndWakeUpPage(page, logPrefix = "[Browser]") { + this.logger.debug(`${logPrefix} Navigating to target page...`); const targetUrl = "https://aistudio.google.com/u/0/apps/bundled/blank?showPreview=true&showCode=true&showAssistant=true"; - await this.page.goto(targetUrl, { + await page.goto(targetUrl, { timeout: 180000, waitUntil: "domcontentloaded", }); - this.logger.info(`${logPrefix} Page loaded.`); + this.logger.debug(`${logPrefix} Page loaded.`); // Wake up window using JS and Human Movement try { - await this.page.bringToFront(); + await page.bringToFront(); // Get viewport size for realistic movement range - const vp = this.page.viewportSize() || { height: 1080, width: 1920 }; + const vp = page.viewportSize() || { height: 1080, width: 1920 }; // 1. Move to a random point to simulate activity const randomX = Math.floor(Math.random() * (vp.width * 0.7)); const randomY = Math.floor(Math.random() * (vp.height * 0.7)); - await this._simulateHumanMovement(this.page, randomX, randomY); + await this._simulateHumanMovement(page, randomX, randomY); // 2. Move to (1,1) specifically for a safe click, using human simulation - await this._simulateHumanMovement(this.page, 1, 1); - await this.page.mouse.down(); - await this.page.waitForTimeout(50 + Math.random() * 100); - await this.page.mouse.up(); + await this._simulateHumanMovement(page, 1, 1); + await page.mouse.down(); + await page.waitForTimeout(50 + Math.random() * 100); + await page.mouse.up(); - this.logger.info(`${logPrefix} ✅ Executed realistic page activation (Random -> 1,1 Click).`); + this.logger.debug(`${logPrefix} ✅ Executed realistic page activation (Random -> 1,1 Click).`); } catch (e) { this.logger.warn(`${logPrefix} Wakeup minor error: ${e.message}`); } - await this.page.waitForTimeout(2000 + Math.random() * 2000); + await page.waitForTimeout(2000 + Math.random() * 2000); } /** * Helper: Check page status and detect various error conditions * Detects: cookie expiration, region restrictions, 403 errors, page load failures + * @param {Page} page - The page object to check * @param {string} logPrefix - Log prefix for messages (e.g., "[Browser]" or "[Reconnect]") * @throws {Error} If any error condition is detected */ - async _checkPageStatusAndErrors(logPrefix = "[Browser]") { - const currentUrl = this.page.url(); + async _checkPageStatusAndErrors(page, logPrefix = "[Browser]") { + const currentUrl = page.url(); let pageTitle = ""; try { - pageTitle = await this.page.title(); + pageTitle = await page.title(); } catch (e) { this.logger.warn(`${logPrefix} Unable to get page title: ${e.message}`); } - this.logger.info(`${logPrefix} [Diagnostic] URL: ${currentUrl}`); - this.logger.info(`${logPrefix} [Diagnostic] Title: "${pageTitle}"`); + this.logger.debug(`${logPrefix} [Diagnostic] URL: ${currentUrl}`); + this.logger.debug(`${logPrefix} [Diagnostic] Title: "${pageTitle}"`); // Check for various error conditions if ( @@ -590,10 +638,11 @@ class BrowserManager { /** * Helper: Handle various popups with intelligent detection * Uses short polling instead of long hard-coded timeouts + * @param {Page} page - The page object to check for popups * @param {string} logPrefix - Log prefix for messages (e.g., "[Browser]" or "[Reconnect]") */ - async _handlePopups(logPrefix = "[Browser]") { - this.logger.info(`${logPrefix} 🔍 Starting intelligent popup detection (max 6s)...`); + async _handlePopups(page, logPrefix = "[Browser]") { + this.logger.debug(`${logPrefix} 🔍 Starting intelligent popup detection (max 6s)...`); const popupConfigs = [ { @@ -630,15 +679,15 @@ class BrowserManager { if (handledPopups.has(popup.name)) continue; try { - const element = this.page.locator(popup.selector).first(); + const element = page.locator(popup.selector).first(); // Quick visibility check with very short timeout if (await element.isVisible({ timeout: 200 })) { - this.logger.info(popup.logFound); + this.logger.debug(popup.logFound); await element.click({ force: true }); handledPopups.add(popup.name); foundAny = true; // Short pause after clicking to let next popup appear - await this.page.waitForTimeout(800); + await page.waitForTimeout(800); } } catch (error) { // Element not visible or doesn't exist is expected here, @@ -673,14 +722,14 @@ class BrowserManager { // 1. Must have completed minimum iterations (ensure slow popups have time to load) // 2. Consecutive idle count exceeds threshold (no new popups appearing) if (i >= minIterations - 1 && consecutiveIdleCount >= idleThreshold) { - this.logger.info( + this.logger.debug( `${logPrefix} ✅ Popup detection complete (${i + 1} iterations, ${handledPopups.size} popups handled)` ); break; } if (i < maxIterations - 1) { - await this.page.waitForTimeout(pollInterval); + await page.waitForTimeout(pollInterval); } } } @@ -688,125 +737,178 @@ class BrowserManager { /** * Feature: Background Health Monitor (The "Scavenger") * Periodically cleans up popups and keeps the session alive. + * In multi-context mode, stores the interval in the context data. */ _startHealthMonitor() { + const authIndex = this._currentAuthIndex; + if (authIndex < 0) { + this.logger.warn("[Browser] Cannot start health monitor: no active auth index"); + return; + } + + // Get context data + const contextData = this.contexts.get(authIndex); + if (!contextData) { + this.logger.warn(`[Browser] Cannot start health monitor: context #${authIndex} not found`); + return; + } + // Clear existing interval if any - if (this.healthMonitorInterval) clearInterval(this.healthMonitorInterval); + if (contextData.healthMonitorInterval) { + clearInterval(contextData.healthMonitorInterval); + } - this.logger.info("[Browser] 🛡️ Background health monitor service (Scavenger) started..."); + this.logger.info(`[Context#${authIndex}] 🛡️ Background health monitor service (Scavenger) started...`); let tickCount = 0; // Run every 4 seconds - this.healthMonitorInterval = setInterval(async () => { - const page = this.page; - if (!page || page.isClosed()) { - clearInterval(this.healthMonitorInterval); - return; - } - - tickCount++; - + contextData.healthMonitorInterval = setInterval(async () => { try { - // 1. Keep-Alive: Random micro-actions (30% chance) - if (Math.random() < 0.3) { - try { - // Optimized randomness based on viewport - const vp = page.viewportSize() || { height: 1080, width: 1920 }; + // Check if this is still the current active account + // This prevents background contexts from running healthMonitor unnecessarily + if (this._currentAuthIndex !== authIndex) { + // Silently skip - this context is not active + return; + } - // Scroll - // eslint-disable-next-line no-undef - await page.evaluate(() => window.scrollBy(0, (Math.random() - 0.5) * 20)); - // Human-like mouse jitter - const x = Math.floor(Math.random() * (vp.width * 0.8)); - const y = Math.floor(Math.random() * (vp.height * 0.8)); - await this._simulateHumanMovement(page, x, y); - } catch (e) { - /* empty */ + const page = contextData.page; + // Double check page status + if (!page || page.isClosed()) { + if (contextData.healthMonitorInterval) { + clearInterval(contextData.healthMonitorInterval); + contextData.healthMonitorInterval = null; + this.logger.info(`[HealthMonitor#${authIndex}] Page closed, stopped background task.`); } + return; } - // 2. Anti-Timeout: Click top-left corner (1,1) every ~1 minute (15 ticks) - if (tickCount % 15 === 0) { - try { - await this._simulateHumanMovement(page, 1, 1); - await page.mouse.down(); - await page.waitForTimeout(100 + Math.random() * 100); - await page.mouse.up(); - } catch (e) { - /* empty */ + tickCount++; + + try { + // 1. Keep-Alive: Random micro-actions (30% chance) + if (Math.random() < 0.3) { + try { + // Optimized randomness based on viewport + const vp = page.viewportSize() || { height: 1080, width: 1920 }; + + // Scroll + // eslint-disable-next-line no-undef + await page.evaluate(() => window.scrollBy(0, (Math.random() - 0.5) * 20)); + // Human-like mouse jitter + const x = Math.floor(Math.random() * (vp.width * 0.8)); + const y = Math.floor(Math.random() * (vp.height * 0.8)); + await this._simulateHumanMovement(page, x, y); + } catch (e) { + /* empty */ + } } - } - // 3. Auto-Save Auth: Every ~24 hours (21600 ticks * 4s = 86400s) - if (tickCount % 21600 === 0) { - if (this._currentAuthIndex >= 0) { + // 2. Anti-Timeout: Click top-left corner (1,1) every ~1 minute (15 ticks) + if (tickCount % 15 === 0) { try { - this.logger.info("[HealthMonitor] 💾 Triggering daily periodic auth file update..."); - await this._updateAuthFile(this._currentAuthIndex); + await this._simulateHumanMovement(page, 1, 1); + await page.mouse.down(); + await page.waitForTimeout(100 + Math.random() * 100); + await page.mouse.up(); } catch (e) { - this.logger.warn(`[HealthMonitor] Auth update failed: ${e.message}`); + /* empty */ } } - } - // 4. Popup & Overlay Cleanup - await page.evaluate(() => { - const blockers = [ - "div.cdk-overlay-backdrop", - "div.cdk-overlay-container", - "div.cdk-global-overlay-wrapper", - ]; + // 3. Auto-Save Auth: Every ~24 hours (21600 ticks * 4s = 86400s) + if (tickCount % 21600 === 0) { + try { + this.logger.info( + `[HealthMonitor#${authIndex}] 💾 Triggering daily periodic auth file update...` + ); + await this._updateAuthFile(authIndex); + } catch (e) { + this.logger.warn(`[HealthMonitor#${authIndex}] Auth update failed: ${e.message}`); + } + } - const targetTexts = ["Reload", "Retry", "Got it", "Dismiss", "Not now"]; + // 4. Popup & Overlay Cleanup + await page.evaluate(() => { + const blockers = [ + "div.cdk-overlay-backdrop", + "div.cdk-overlay-container", + "div.cdk-global-overlay-wrapper", + ]; - // Remove passive blockers - blockers.forEach(selector => { - // eslint-disable-next-line no-undef - document.querySelectorAll(selector).forEach(el => el.remove()); - }); + const targetTexts = ["Reload", "Retry", "Got it", "Dismiss", "Not now"]; - // Click active buttons if visible - // eslint-disable-next-line no-undef - document.querySelectorAll("button").forEach(btn => { - // 检查元素是否占据空间(简单的可见性检查) - const rect = btn.getBoundingClientRect(); - const isVisible = rect.width > 0 && rect.height > 0; - - if (isVisible) { - const text = (btn.innerText || "").trim(); - const ariaLabel = btn.getAttribute("aria-label"); - - // 匹配文本 或 aria-label - if (targetTexts.includes(text) || ariaLabel === "Close") { - console.log(`[ProxyClient] HealthMonitor clicking: ${text || "Close Button"}`); - btn.click(); + // Remove passive blockers + blockers.forEach(selector => { + // eslint-disable-next-line no-undef + document.querySelectorAll(selector).forEach(el => el.remove()); + }); + + // Click active buttons if visible + // eslint-disable-next-line no-undef + document.querySelectorAll("button").forEach(btn => { + // Check if the element occupies space (simple visibility check) + const rect = btn.getBoundingClientRect(); + const isVisible = rect.width > 0 && rect.height > 0; + + if (isVisible) { + const text = (btn.innerText || "").trim(); + const ariaLabel = btn.getAttribute("aria-label"); + + // Match text or aria-label + if (targetTexts.includes(text) || ariaLabel === "Close") { + console.log(`[ProxyClient] HealthMonitor clicking: ${text || "Close Button"}`); + btn.click(); + } } - } + }); }); - }); - } catch (err) { - // Silent catch to prevent log spamming on navigation + } catch (err) { + // Silent catch to prevent log spamming on navigation + } + } catch (globalError) { + // Catch any other unexpected errors in the interval + this.logger.warn(`[HealthMonitor#${authIndex}] Detailed error: ${globalError.message}`); + // If the page is definitely gone, stop the monitor + if (globalError.message.includes("Target page, context or browser has been closed")) { + if (contextData.healthMonitorInterval) { + clearInterval(contextData.healthMonitorInterval); + contextData.healthMonitorInterval = null; + this.logger.info( + `[HealthMonitor#${authIndex}] Page closed (detected by error), stopped background task.` + ); + } + } } }, 4000); } /** * Helper: Save debug information (screenshot and HTML) to root directory + * @param {string} suffix - Suffix for the debug file names + * @param {number} [authIndex] - Optional auth index to get the correct page from contexts Map */ - async _saveDebugArtifacts(suffix = "final") { - if (!this.page || this.page.isClosed()) return; + async _saveDebugArtifacts(suffix = "final", authIndex = null) { + // Prioritize retrieving the page for the specific account from the contexts Map, falling back to this.page + let targetPage = this.page; + if (authIndex !== null && this.contexts.has(authIndex)) { + const ctxData = this.contexts.get(authIndex); + if (ctxData && ctxData.page) { + targetPage = ctxData.page; + } + } + if (!targetPage || targetPage.isClosed()) return; try { const timestamp = new Date().toISOString().replace(/[:.]/g, "-").substring(0, 19); const screenshotPath = path.join(process.cwd(), `debug_screenshot_${suffix}_${timestamp}.png`); - await this.page.screenshot({ + await targetPage.screenshot({ fullPage: true, path: screenshotPath, }); this.logger.info(`[Debug] Failure screenshot saved to: ${screenshotPath}`); const htmlPath = path.join(process.cwd(), `debug_page_source_${suffix}_${timestamp}.html`); - const htmlContent = await this.page.content(); + const htmlContent = await targetPage.content(); fs.writeFileSync(htmlPath, htmlContent); this.logger.info(`[Debug] Failure page source saved to: ${htmlPath}`); } catch (e) { @@ -817,18 +919,44 @@ class BrowserManager { /** * Feature: Background Wakeup & "Launch" Button Handler * Specifically handles the "Rocket/Launch" button which blocks model loading. + * This service is bound to this.page (instance-level), not individual contexts. + * Only one instance should run at a time, tracking the current active page. */ async _startBackgroundWakeup() { - const currentPage = this.page; - // Initial buffer + // Prevent multiple instances from running simultaneously + if (this.backgroundWakeupRunning) { + this.logger.info("[Browser] BackgroundWakeup already running, skipping duplicate start."); + return; + } + + this.logger.info("[Browser] Starting BackgroundWakeup initialization..."); + this.backgroundWakeupRunning = true; + + // Initial buffer - wait before starting the main loop to let page stabilize await new Promise(r => setTimeout(r, 1500)); - if (!currentPage || currentPage.isClosed() || this.page !== currentPage) return; + // Verify page is still valid after the initial delay + try { + if (!this.page || this.page.isClosed()) { + this.backgroundWakeupRunning = false; + this.logger.info( + "[Browser] BackgroundWakeup stopped: page became null or closed during startup delay." + ); + return; + } + } catch (error) { + this.backgroundWakeupRunning = false; + this.logger.warn(`[Browser] BackgroundWakeup stopped: error checking page status: ${error.message}`); + return; + } this.logger.info("[Browser] 🛡️ Background Wakeup Service (Rocket Handler) started..."); - while (currentPage && !currentPage.isClosed() && this.page === currentPage) { + // Main loop: directly use this.page, automatically follows context switches + while (this.page && !this.page.isClosed()) { try { + const currentPage = this.page; // Capture for this iteration + // 1. Force page wake-up await currentPage.bringToFront().catch(() => {}); @@ -950,7 +1078,14 @@ class BrowserManager { await new Promise(r => setTimeout(r, 2000)); } else { this.logger.info(`[Browser] ✅ Click successful, button disappeared.`); - await new Promise(r => setTimeout(r, 60000)); // Long sleep on success + // Long sleep on success, but check for context switches every second + for (let i = 0; i < 60; i++) { + if (this.noButtonCount === 0) { + this.logger.info(`[Browser] ⚡ Woken up early due to user activity or context switch.`); + break; // Wake up early if user activity detected + } + await new Promise(r => setTimeout(r, 1000)); + } } } else { this.noButtonCount++; @@ -970,6 +1105,18 @@ class BrowserManager { await new Promise(r => setTimeout(r, 1000)); } } + + // Reset flag when loop exits + this.backgroundWakeupRunning = false; + + // Log the reason for stopping + if (!this.page) { + this.logger.info("[Browser] Background Wakeup Service stopped: this.page is null."); + } else if (this.page.isClosed()) { + this.logger.info("[Browser] Background Wakeup Service stopped: this.page was closed."); + } else { + this.logger.info("[Browser] Background Wakeup Service stopped: unknown reason."); + } } async launchBrowserForVNC(extraArgs = {}) { @@ -1024,156 +1171,823 @@ class BrowserManager { return { browser: vncBrowser, context }; } - async launchOrSwitchContext(authIndex) { - if (typeof authIndex !== "number" || authIndex < 0) { - this.logger.error(`[Browser] Invalid authIndex: ${authIndex}. authIndex must be >= 0.`); - this._currentAuthIndex = -1; - throw new Error(`Invalid authIndex: ${authIndex}. Must be >= 0.`); + /** + * Preload a pool of contexts at startup + * Synchronously initializes the first context, then starts remaining in background + * @param {number[]} startupOrder - Ordered list of auth indices to try + * @param {number} maxContexts - Max pool size (0 = unlimited) + * @returns {Promise<{firstReady: number|null}>} + */ + async preloadContextPool(startupOrder, maxContexts) { + const poolSize = maxContexts === 0 ? startupOrder.length : Math.min(maxContexts, startupOrder.length); + this.logger.info( + `🚀 [ContextPool] Starting pool preload (pool=${poolSize}, order=[${startupOrder.join(", ")}])...` + ); + + // Launch browser if not already running + if (!this.browser) { + await this._ensureBrowser(); } - // [Auth Switch] Save current auth data before switching - if (this.browser && this._currentAuthIndex >= 0) { + // Synchronously try ALL indices until one succeeds (fallback beyond poolSize) + let firstReady = null; + + for (let i = 0; i < startupOrder.length; i++) { + const authIndex = startupOrder[i]; + + // Skip if already initialized or being initialized + if (this.contexts.has(authIndex) || this.initializingContexts.has(authIndex)) { + this.logger.info(`[ContextPool] Context #${authIndex} already exists or being initialized, skipping`); + firstReady = authIndex; + break; + } + + this.initializingContexts.add(authIndex); try { - await this._updateAuthFile(this._currentAuthIndex); - } catch (e) { - this.logger.warn(`[Browser] Failed to save current auth during switch: ${e.message}`); + this.logger.info(`[ContextPool] Initializing context #${authIndex}...`); + await this._initializeContext(authIndex); + firstReady = authIndex; + this.logger.info(`✅ [ContextPool] First context #${authIndex} ready.`); + break; + } catch (error) { + this.logger.error(`❌ [ContextPool] Context #${authIndex} failed: ${error.message}`); + } finally { + // Note: _initializeContext already removes from initializingContexts in its finally block } } - const proxyConfig = parseProxyFromEnv(); - if (proxyConfig) { - this.logger.info(`[Browser] 🌐 Using proxy: ${proxyConfig.server}`); + if (firstReady === null) { + if (this.browser) await this.closeBrowser(); + return { firstReady: null }; } - if (!this.browser) { - this.logger.info("🚀 [Browser] Main browser instance not running, performing first-time launch..."); - if (!fs.existsSync(this.browserExecutablePath)) { - this._currentAuthIndex = -1; - throw new Error(`Browser executable not found at path: ${this.browserExecutablePath}`); - } - this.browser = await firefox.launch({ - args: this.launchArgs, - executablePath: this.browserExecutablePath, - firefoxUserPrefs: this.firefoxUserPrefs, - headless: true, // Main browser is always headless - ...(proxyConfig ? { proxy: proxyConfig } : {}), - }); - this.browser.on("disconnected", () => { + // Early return if pool size is 1 (single context mode) - no need for background preload + if (poolSize === 1) { + this.logger.info(`[ContextPool] Single context mode (maxContexts=1), skipping background preload.`); + return { firstReady }; + } + + // Background: calculate remaining contexts using rotation order (same logic as rebalanceContextPool) + // This ensures startup pool matches the rotation order used during account switching + const rotation = this.authSource.getRotationIndices(); + const currentCanonical = this.authSource.getCanonicalIndex(firstReady); + const startPos = currentCanonical !== null ? Math.max(rotation.indexOf(currentCanonical), 0) : 0; + const ordered = []; + for (let i = 0; i < rotation.length; i++) { + ordered.push(rotation[(startPos + i) % rotation.length]); + } + + // Calculate how many more contexts we need to reach poolSize + const needCount = poolSize - this.contexts.size; + if (needCount > 0) { + // Get candidates from ordered list (excluding already initialized contexts) + // Convert existing contexts to canonical indices to handle duplicate accounts + const existingCanonical = new Set( + [...this.contexts.keys()].map(idx => this.authSource.getCanonicalIndex(idx) ?? idx) + ); + const candidates = ordered.filter( + idx => !existingCanonical.has(idx) && !this.initializingContexts.has(idx) + ); + + if (candidates.length > 0) { + this.logger.info( + `[ContextPool] Background preload will try [${candidates.join(", ")}] to reach pool size ${poolSize} (need ${needCount} more)` + ); + // Pass all candidates, not just the first needCount + // This allows the background task to try subsequent accounts if earlier ones fail + this._preloadBackgroundContexts(candidates, poolSize); + } + } + + return { firstReady }; + } + + /** + * Launch browser instance if not already running + */ + async _ensureBrowser() { + if (this.browser) return; + + const proxyConfig = parseProxyFromEnv(); + this.logger.info("🚀 [Browser] Launching main browser instance..."); + if (!fs.existsSync(this.browserExecutablePath)) { + this._currentAuthIndex = -1; + throw new Error(`Browser executable not found at path: ${this.browserExecutablePath}`); + } + this.browser = await firefox.launch({ + args: this.launchArgs, + executablePath: this.browserExecutablePath, + firefoxUserPrefs: this.firefoxUserPrefs, + headless: true, + ...(proxyConfig ? { proxy: proxyConfig } : {}), + }); + this.browser.on("disconnected", () => { + if (!this.isClosingIntentionally) { this.logger.error("❌ [Browser] Main browser unexpectedly disconnected!"); - this.browser = null; - this.context = null; - this.page = null; - this._currentAuthIndex = -1; - this.logger.warn("[Browser] Reset currentAuthIndex to -1 due to unexpected disconnect."); + } else { + this.logger.info("[Browser] Main browser closed intentionally."); + } + this.browser = null; + this._cleanupAllContexts(); + }); + this.logger.info("✅ [Browser] Main browser instance launched successfully."); + } + + /** + * Abort any ongoing background preload task and wait for it to complete + * This is a public method that encapsulates access to internal preload state + * @returns {Promise} Resolves when the background task has been aborted and cleaned up + */ + async abortBackgroundPreload() { + if (!this._backgroundPreloadTask) { + return; // No task to abort + } + + this.logger.info(`[ContextPool] Aborting background preload task...`); + this._backgroundPreloadAbort = true; + + try { + await this._backgroundPreloadTask; + } catch (error) { + // Ignore errors from aborted task + this.logger.debug(`[ContextPool] Background preload aborted: ${error.message}`); + } + + this.logger.info(`[ContextPool] Background preload aborted successfully`); + } + + /** + * Background sequential initialization of contexts (fire-and-forget) + * Only one instance should be active at a time - new calls abort old ones + * @param {number[]} indices - Auth indices to initialize (candidates, may exceed pool size) + * @param {number} maxPoolSize - Stop when this.contexts.size reaches this limit (0 = no limit) + */ + async _preloadBackgroundContexts(indices, maxPoolSize = 0) { + // If there's an existing background task, abort it and wait for it to finish + await this.abortBackgroundPreload(); + + // Reset abort flag and create new background task + this._backgroundPreloadAbort = false; + const currentTask = this._executePreloadTask(indices, maxPoolSize); + this._backgroundPreloadTask = currentTask; + + // Don't await here - this is fire-and-forget + // But ensure we clean up the task reference when done + currentTask + .catch(error => { + this.logger.error(`[ContextPool] Background preload task failed: ${error.message}`); + }) + .finally(() => { + // Only clear if this is still the current task + if (this._backgroundPreloadTask === currentTask) { + this._backgroundPreloadTask = null; + } }); - this.logger.info("✅ [Browser] Main browser instance successfully launched."); + } + + /** + * Internal method to execute the actual preload task + * @private + */ + async _executePreloadTask(indices, maxPoolSize) { + this.logger.info( + `[ContextPool] Background preload starting for [${indices.join(", ")}] (poolCap=${maxPoolSize || "unlimited"})...` + ); + + let aborted = false; + + for (const authIndex of indices) { + // Check if abort was requested + if (this._backgroundPreloadAbort) { + this.logger.info(`[ContextPool] Background preload aborted by request`); + aborted = true; + break; + } + + // Check if browser is available, launch if needed + if (!this.browser) { + this.logger.info(`[ContextPool] Browser not available, launching browser for background preload...`); + try { + await this._ensureBrowser(); + this.logger.info(`[ContextPool] Browser launched successfully for background preload`); + } catch (error) { + this.logger.error( + `[ContextPool] Failed to launch browser for background preload: ${error.message}` + ); + break; + } + } + + // Check pool size limit + if (maxPoolSize > 0 && this.contexts.size >= maxPoolSize) { + this.logger.info(`[ContextPool] Pool size limit reached, stopping preload`); + break; + } + + // Skip if already exists or being initialized by another task + if (this.contexts.has(authIndex)) { + this.logger.debug(`[ContextPool] Context #${authIndex} already exists, skipping`); + continue; + } + if (this.initializingContexts.has(authIndex)) { + this.logger.info( + `[ContextPool] Context #${authIndex} already being initialized by another task, skipping` + ); + continue; + } + + this.initializingContexts.add(authIndex); + try { + this.logger.info(`[ContextPool] Background preload init context #${authIndex}...`); + await this._initializeContext(authIndex, true); // Mark as background task + this.logger.info(`✅ [ContextPool] Background context #${authIndex} ready.`); + } catch (error) { + // Check if this is an abort error (user deleted the account during initialization) + const isAbortError = error.message && error.message.includes("aborted for index"); + if (isAbortError) { + this.logger.info(`[ContextPool] Background context #${authIndex} aborted (account deleted)`); + // If aborted due to background preload abort, mark as aborted + aborted = true; + } else { + this.logger.error(`❌ [ContextPool] Background context #${authIndex} failed: ${error.message}`); + } + } + // Note: initializingContexts and abortedContexts cleanup is handled in _initializeContext's finally block } - if (this.healthMonitorInterval) { - clearInterval(this.healthMonitorInterval); - this.healthMonitorInterval = null; - this.logger.info("[Browser] Stopped background tasks (Scavenger) for old page."); + if (!aborted) { + this.logger.info(`[ContextPool] Background preload complete.`); } + } - if (this.context) { - this.logger.info("[Browser] Closing old API browser context..."); - const closePromise = this.context.close(); - const timeoutPromise = new Promise(r => setTimeout(r, 5000)); // 5秒超时 - await Promise.race([closePromise, timeoutPromise]); - this.context = null; - this.page = null; - this.logger.info("[Browser] Old API context closed."); + /** + * Pre-cleanup before switching to a new account + * Removes contexts that will be excess after the switch to avoid exceeding maxContexts + * @param {number} targetAuthIndex - The account index we're about to switch to + */ + async preCleanupForSwitch(targetAuthIndex) { + const maxContexts = this.config.maxContexts; + const isUnlimited = maxContexts === 0; + + // Abort any ongoing background preload task before cleanup + // This prevents race conditions where background tasks continue initializing contexts + // that will be immediately removed by the new rebalance after switch + await this.abortBackgroundPreload(); + + // Test: Check if initializingContexts is empty after aborting background task + if (this.initializingContexts.size > 0) { + const initializingList = [...this.initializingContexts].join(", "); + this.logger.error( + `[ContextPool] Pre-cleanup ERROR: initializingContexts not empty after aborting background task! Contexts still initializing: [${initializingList}]` + ); + throw new Error( + `Pre-cleanup failed: initializingContexts not empty (${initializingList}). This should not happen after aborting background task.` + ); } - const sourceDescription = `File auth-${authIndex}.json`; - this.logger.info("=================================================="); - this.logger.info(`🔄 [Browser] Creating new API browser context for account #${authIndex}`); - this.logger.info(` • Auth source: ${sourceDescription}`); - this.logger.info("=================================================="); + // In unlimited mode, no need to pre-cleanup + if (isUnlimited) { + this.logger.debug(`[ContextPool] Pre-cleanup skipped: unlimited mode`); + return; + } + + // If target context already exists or is being initialized, no new context will be created + if (this.contexts.has(targetAuthIndex)) { + this.logger.debug(`[ContextPool] Pre-cleanup skipped: target context #${targetAuthIndex} already exists`); + return; + } + + if (this.initializingContexts.has(targetAuthIndex)) { + this.logger.debug( + `[ContextPool] Pre-cleanup skipped: target context #${targetAuthIndex} is being initialized` + ); + return; + } + + // Calculate how many contexts we'll have after adding the new one + // Include contexts that are currently being initialized in background + const currentSize = this.contexts.size + this.initializingContexts.size; + const futureSize = currentSize + 1; + + // If we won't exceed the limit, no cleanup needed + if (futureSize <= maxContexts) { + this.logger.debug( + `[ContextPool] Pre-cleanup skipped: future size ${futureSize} (${this.contexts.size} ready + ${this.initializingContexts.size} initializing + 1 new) <= maxContexts ${maxContexts}` + ); + return; + } + + // We need to remove (futureSize - maxContexts) contexts + const removeCount = futureSize - maxContexts; + + // Build removal priority list (from lowest to highest priority to keep): + // Priority 1: Old duplicate accounts (removedIndices from duplicateGroups) + // Priority 2: Accounts in rotation, ordered by distance from target (farthest first) + + const rotation = this.authSource.getRotationIndices(); + const targetCanonical = this.authSource.getCanonicalIndex(targetAuthIndex); + const duplicateGroups = this.authSource.getDuplicateGroups(); + + // Get all old duplicate indices (not in rotation) + const oldDuplicates = new Set(); + for (const group of duplicateGroups) { + for (const idx of group.removedIndices) { + oldDuplicates.add(idx); + } + } + + // Build rotation order starting from target (accounts closer to target have higher priority) + const startPos = Math.max(rotation.indexOf(targetCanonical), 0); + const orderedFromTarget = []; + for (let i = 0; i < rotation.length; i++) { + orderedFromTarget.push(rotation[(startPos + i) % rotation.length]); + } + + // Collect all context indices (existing + initializing) + const allContextIndices = new Set([...this.contexts.keys(), ...this.initializingContexts]); + + // Build removal priority list + const removalPriority = []; + + // Special case: If target is an old duplicate, prioritize removing its canonical version + // Because we're about to create the old duplicate, and they're the same account + const isTargetOldDuplicate = oldDuplicates.has(targetAuthIndex); + if (isTargetOldDuplicate) { + // Find the canonical version of target in existing contexts + for (const idx of allContextIndices) { + if (this.authSource.getCanonicalIndex(idx) === targetCanonical && idx === targetCanonical) { + removalPriority.push(idx); + break; + } + } + } + + // Priority 1: Old duplicate accounts (lowest priority to keep) + for (const idx of allContextIndices) { + if (oldDuplicates.has(idx) && !removalPriority.includes(idx)) { + removalPriority.push(idx); + } + } + + // Priority 2: Accounts in rotation, from farthest to closest (reverse rotation order) + for (let i = orderedFromTarget.length - 1; i >= 0; i--) { + const canonical = orderedFromTarget[i]; + // Find all contexts with this canonical index + for (const idx of allContextIndices) { + if (this.authSource.getCanonicalIndex(idx) === canonical && !removalPriority.includes(idx)) { + removalPriority.push(idx); + } + } + } + + // Remove contexts according to priority until we have enough space + const toRemove = removalPriority.slice(0, removeCount); + + this.logger.info( + `[ContextPool] Pre-cleanup: removing ${toRemove.length} contexts before switch to #${targetAuthIndex}: [${toRemove}] (${this.contexts.size} ready + ${this.initializingContexts.size} initializing)` + ); + + for (const idx of toRemove) { + await this.closeContext(idx); + } + } + + /** + * Rebalance context pool after account changes + * Removes excess contexts and starts missing ones in background + */ + async rebalanceContextPool() { + const maxContexts = this.config.maxContexts; + // maxContexts === 0 means unlimited pool size + const isUnlimited = maxContexts === 0; + + // Build full rotation ordered from current account + const rotation = this.authSource.getRotationIndices(); + const currentCanonical = + this._currentAuthIndex >= 0 ? this.authSource.getCanonicalIndex(this._currentAuthIndex) : null; + const startPos = currentCanonical !== null ? Math.max(rotation.indexOf(currentCanonical), 0) : 0; + const ordered = []; + for (let i = 0; i < rotation.length; i++) { + ordered.push(rotation[(startPos + i) % rotation.length]); + } + + // Targets = first maxContexts from ordered (or all available if unlimited) + // In unlimited mode, include all valid accounts (rotation + duplicates) + let targets; + if (isUnlimited) { + targets = new Set(this.authSource.availableIndices); + } else { + targets = new Set(ordered.slice(0, maxContexts)); + } - const storageStateObject = this.authSource.getAuth(authIndex); - if (!storageStateObject) { - throw new Error(`Failed to get or parse auth source for index ${authIndex}.`); + // Remove contexts not in targets (except current) + // Special handling: if current account is a duplicate (old version), also remove its canonical version + // BUT only in limited mode - in unlimited mode, keep all contexts + const toRemove = []; + const currentCanonicalIndex = currentCanonical; // Already calculated above + const isDuplicateAccount = + this._currentAuthIndex >= 0 && + currentCanonicalIndex !== null && + currentCanonicalIndex !== this._currentAuthIndex; + + for (const idx of this.contexts.keys()) { + // Skip current account + if (idx === this._currentAuthIndex) continue; + + // If current is a duplicate AND we're in limited mode, remove the canonical version (we're using the old one) + if (!isUnlimited && isDuplicateAccount && idx === currentCanonicalIndex) { + toRemove.push(idx); + continue; + } + + // Remove if not in targets + if (!targets.has(idx)) { + toRemove.push(idx); + } } - const buildScriptContent = this._loadAndConfigureBuildScript(); + // Candidates: all accounts from ordered that are not yet initialized + // Pass the full ordered list to allow fallback if target accounts fail + // The background task will stop when poolSize is reached + // Convert activeContexts to canonical indices to handle duplicate accounts + const activeContextsRaw = new Set([...this.contexts.keys()].filter(idx => !toRemove.includes(idx))); + const activeContexts = new Set( + [...activeContextsRaw].map(idx => this.authSource.getCanonicalIndex(idx) ?? idx) + ); + // Don't filter out initializingContexts here - let _executePreloadTask handle it + // This ensures that if a background task is aborted, the account will be retried + // If a foreground task is running, _executePreloadTask will skip it (line 1382) + const candidates = ordered.filter(idx => !activeContexts.has(idx)); + + this.logger.info( + `[ContextPool] Rebalance: targets=[${[...targets]}], remove=[${toRemove}], candidates=[${candidates}]` + ); + + for (const idx of toRemove) { + await this.closeContext(idx); + } + + // Preload candidates if we have room in the pool + if (candidates.length > 0 && (isUnlimited || this.contexts.size < maxContexts)) { + this._preloadBackgroundContexts(candidates, isUnlimited ? 0 : maxContexts); + } + } + + /** + * Wait for a background context initialization to complete + * @param {number} authIndex - The auth index to wait for + * @param {number} timeoutMs - Timeout in milliseconds + */ + async _waitForContextInit(authIndex, timeoutMs = 120000) { + const start = Date.now(); + while (this.initializingContexts.has(authIndex)) { + if (Date.now() - start > timeoutMs) { + throw new Error(`Timeout waiting for context #${authIndex} initialization`); + } + await new Promise(r => setTimeout(r, 500)); + } + } + + /** + * Initialize a single context for the given auth index + * This is a helper method used by both preloadContextPool and launchOrSwitchContext + * @param {number} authIndex - The auth index to initialize + * @param {boolean} isBackgroundTask - Whether this is a background preload task (can be aborted by _backgroundPreloadAbort) + * @returns {Promise<{context, page}>} + */ + async _initializeContext(authIndex, isBackgroundTask = false) { + let context = null; + let page = null; try { + // Check if this context has been marked for abort before starting + if (this.abortedContexts.has(authIndex)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + + // Check if background preload was aborted (only for background tasks) + if (isBackgroundTask && this._backgroundPreloadAbort) { + throw new Error(`Context initialization aborted for index ${authIndex} (background preload aborted)`); + } + + const proxyConfig = parseProxyFromEnv(); + const storageStateObject = this.authSource.getAuth(authIndex); + if (!storageStateObject) { + throw new Error(`Failed to get or parse auth source for index ${authIndex}.`); + } + + const buildScriptContent = this._loadAndConfigureBuildScript(); + // Viewport Randomization const randomWidth = 1920 + Math.floor(Math.random() * 50); const randomHeight = 1080 + Math.floor(Math.random() * 50); - this.context = await this.browser.newContext({ + // Check abort status before expensive operations + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + + context = await this.browser.newContext({ deviceScaleFactor: 1, storageState: storageStateObject, viewport: { height: randomHeight, width: randomWidth }, ...(proxyConfig ? { proxy: proxyConfig } : {}), }); + // Check abort status after context creation + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + // Inject Privacy Script immediately after context creation const privacyScript = this._getPrivacyProtectionScript(authIndex); - await this.context.addInitScript(privacyScript); + await context.addInitScript(privacyScript); - this.page = await this.context.newPage(); + page = await context.newPage(); // Pure JS Wakeup (Focus & Click) try { - await this.page.bringToFront(); + await page.bringToFront(); // eslint-disable-next-line no-undef - await this.page.evaluate(() => window.focus()); - // Get viewport size for realistic movement range - const vp = this.page.viewportSize() || { height: 1080, width: 1920 }; + await page.evaluate(() => window.focus()); + const vp = page.viewportSize() || { height: 1080, width: 1920 }; const startX = Math.floor(Math.random() * (vp.width * 0.5)); const startY = Math.floor(Math.random() * (vp.height * 0.5)); - await this._simulateHumanMovement(this.page, startX, startY); - await this.page.mouse.down(); - await this.page.waitForTimeout(100); - await this.page.mouse.up(); - this.logger.info("[Browser] ⚡ Forced window wake-up via JS focus."); + await this._simulateHumanMovement(page, startX, startY); + await page.mouse.down(); + await page.waitForTimeout(100); + await page.mouse.up(); } catch (e) { - this.logger.warn(`[Browser] Wakeup minor error: ${e.message}`); + this.logger.warn(`[Context#${authIndex}] Wakeup minor error: ${e.message}`); } - this.page.on("console", msg => { + page.on("console", msg => { const msgText = msg.text(); if (msgText.includes("Content-Security-Policy")) { return; } - if (msgText.includes("[ProxyClient]")) { - this.logger.info(`[Browser] ${msgText.replace("[ProxyClient] ", "")}`); + this.logger.info(`[Context#${authIndex}] ${msgText.replace("[ProxyClient] ", "")}`); } else if (msg.type() === "error") { - this.logger.error(`[Browser Page Error] ${msgText}`); + this.logger.error(`[Context#${authIndex} Page Error] ${msgText}`); } }); - await this._navigateAndWakeUpPage("[Browser]"); + // Check abort status before navigation (most time-consuming part) + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } - // Check for cookie expiration, region restrictions, and other errors - await this._checkPageStatusAndErrors("[Browser]"); + await this._navigateAndWakeUpPage(page, `[Context#${authIndex}]`); - // Handle various popups (Cookie consent, Got it, Onboarding, etc.) - await this._handlePopups("[Browser]"); + // Check abort status after navigation + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } - await this._injectScriptToEditor(buildScriptContent, "[Browser]"); + await this._checkPageStatusAndErrors(page, `[Context#${authIndex}]`); - // Start background wakeup service - only started here during initial browser launch - this._startBackgroundWakeup(); + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } - this._currentAuthIndex = authIndex; + await this._handlePopups(page, `[Context#${authIndex}]`); - // [Auth Update] Save the refreshed cookies to the auth file immediately + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + + await this._injectScriptToEditor(page, buildScriptContent, `[Context#${authIndex}]`); + + // Final check before adding to contexts map + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + + // Save to contexts map - with atomic abort check to prevent race condition + // between the check above and actually adding to the map + if (!this.abortedContexts.has(authIndex) && !(isBackgroundTask && this._backgroundPreloadAbort)) { + this.contexts.set(authIndex, { + context, + healthMonitorInterval: null, + page, + }); + } else { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + + // Update auth file await this._updateAuthFile(authIndex); + return { context, page }; + } catch (error) { + // Check if this is an abort error + const isAbortError = error.message && error.message.includes("aborted for index"); + if (isAbortError) { + this.logger.info(`[Browser] Context #${authIndex} initialization aborted as requested.`); + } else { + this.logger.error(`❌ [Browser] Context initialization failed for index ${authIndex}, cleaning up...`); + } + + // Remove from contexts map if it was added + if (this.contexts.has(authIndex)) { + this.contexts.delete(authIndex); + this.logger.info(`[Browser] Removed failed context #${authIndex} from contexts map`); + } + + // Close context if it was created + if (context) { + try { + await context.close(); + if (isAbortError) { + this.logger.info(`[Browser] Cleaned up aborted context for index ${authIndex}`); + } else { + this.logger.info(`[Browser] Cleaned up leaked context for index ${authIndex}`); + } + } catch (closeError) { + this.logger.warn(`[Browser] Failed to close context during cleanup: ${closeError.message}`); + } + } + throw error; + } finally { + // Ensure cleanup of tracking sets even if error is thrown + this.initializingContexts.delete(authIndex); + this.abortedContexts.delete(authIndex); + } + } + + async launchOrSwitchContext(authIndex) { + if (typeof authIndex !== "number" || authIndex < 0) { + this.logger.error(`[Browser] Invalid authIndex: ${authIndex}. authIndex must be >= 0.`); + this._currentAuthIndex = -1; + throw new Error(`Invalid authIndex: ${authIndex}. Must be >= 0.`); + } + + // [Auth Switch] Save current auth data before switching + if (this.browser && this._currentAuthIndex >= 0 && this._currentAuthIndex !== authIndex) { + try { + await this._updateAuthFile(this._currentAuthIndex); + } catch (e) { + this.logger.warn(`[Browser] Failed to save current auth during switch: ${e.message}`); + } + } + + // Wait for background initialization if in progress + if (this.initializingContexts.has(authIndex)) { + this.logger.info(`[Browser] Context #${authIndex} is being initialized in background, waiting...`); + await this._waitForContextInit(authIndex); + } + + // Check if browser is running, launch if needed + if (!this.browser) { + await this._ensureBrowser(); + } + + // Check if context already exists (fast switch path) + if (this.contexts.has(authIndex)) { + this.logger.info("=================================================="); + this.logger.info(`⚡ [FastSwitch] Switching to pre-loaded context for account #${authIndex}`); + this.logger.info("=================================================="); + + // Validate that the page is still alive before switching + const contextData = this.contexts.get(authIndex); + if (!contextData || !contextData.page || contextData.page.isClosed()) { + this.logger.warn( + `[FastSwitch] Page for account #${authIndex} is closed, cleaning up and re-initializing...` + ); + // Clean up the dead context + await this.closeContext(authIndex); + // Fall through to slow path to re-initialize + } else { + // Quick auth status check without navigation + try { + const currentUrl = contextData.page.url(); + const pageTitle = await contextData.page.title(); + + // Check if redirected to login page (auth expired) + if ( + currentUrl.includes("accounts.google.com") || + currentUrl.includes("ServiceLogin") || + pageTitle.includes("Sign in") || + pageTitle.includes("登录") + ) { + this.logger.warn( + `[FastSwitch] Account #${authIndex} auth expired (redirected to login), cleaning up and re-initializing...` + ); + // Clean up the expired context + await this.closeContext(authIndex); + // Fall through to slow path to re-initialize + } else { + // Page is alive and auth is valid, proceed with fast switch + // Stop background tasks for old context + if (this._currentAuthIndex >= 0 && this.contexts.has(this._currentAuthIndex)) { + const oldContextData = this.contexts.get(this._currentAuthIndex); + if (oldContextData.healthMonitorInterval) { + clearInterval(oldContextData.healthMonitorInterval); + oldContextData.healthMonitorInterval = null; + } + } + + // Switch to new context + this.context = contextData.context; + this.page = contextData.page; + this._currentAuthIndex = authIndex; + + // Reset BackgroundWakeup state for new context + this.noButtonCount = 0; + + // Start background tasks for new context + this._startHealthMonitor(); + this._startBackgroundWakeup(); // Internal check prevents duplicate instances + + // Optimize UX: Force page to front immediately + try { + await this.page.bringToFront(); + } catch (ignored) { + /* ignore error if page is busy */ + } + + this.logger.info(`✅ [FastSwitch] Switched to account #${authIndex} instantly!`); + return; + } + } catch (error) { + this.logger.warn( + `[FastSwitch] Failed to check auth status for account #${authIndex}: ${error.message}, cleaning up and re-initializing...` + ); + // Clean up the problematic context + await this.closeContext(authIndex); + // Fall through to slow path to re-initialize + } + } + } + + // Context doesn't exist, need to initialize it (slow path) + this.logger.info("=================================================="); + this.logger.info(`🔄 [Browser] Context for account #${authIndex} not found, initializing...`); + this.logger.info("=================================================="); + + // Check again if another caller started initializing while we were checking + // This protects against race condition where multiple callers finish waiting + // at the same time and all try to initialize the same context + if (this.initializingContexts.has(authIndex)) { + this.logger.info(`[Browser] Another caller is initializing context #${authIndex}, waiting...`); + await this._waitForContextInit(authIndex); + // After waiting, recursively call to use the fast path or retry + return await this.launchOrSwitchContext(authIndex); + } + + this.initializingContexts.add(authIndex); + + try { + // Stop background tasks for old context + if (this._currentAuthIndex >= 0 && this.contexts.has(this._currentAuthIndex)) { + const oldContextData = this.contexts.get(this._currentAuthIndex); + if (oldContextData.healthMonitorInterval) { + clearInterval(oldContextData.healthMonitorInterval); + oldContextData.healthMonitorInterval = null; + } + } + + // Initialize new context (isBackgroundTask=false for foreground initialization) + const { context, page } = await this._initializeContext(authIndex, false); + + // Update current references + this.context = context; + this.page = page; + this._currentAuthIndex = authIndex; + + // Reset BackgroundWakeup state for new context + this.noButtonCount = 0; + + // Start background tasks + this._startHealthMonitor(); + this._startBackgroundWakeup(); // Internal check prevents duplicate instances + this.logger.info("=================================================="); this.logger.info(`✅ [Browser] Account ${authIndex} context initialized successfully!`); this.logger.info("✅ [Browser] Browser client is ready."); this.logger.info("=================================================="); } catch (error) { this.logger.error(`❌ [Browser] Account ${authIndex} context initialization failed: ${error.message}`); - await this._saveDebugArtifacts("init_failed"); - await this.closeBrowser(); + await this._saveDebugArtifacts("init_failed", authIndex); + + // Clean up if HealthMonitor was started + if (this.contexts.has(authIndex)) { + const contextData = this.contexts.get(authIndex); + if (contextData.healthMonitorInterval) { + clearInterval(contextData.healthMonitorInterval); + this.logger.info(`[Browser] Cleaned up health monitor for failed context #${authIndex}`); + } + } + + // Reset state + this.context = null; + this.page = null; this._currentAuthIndex = -1; + // DO NOT reset backgroundWakeupRunning here! + // If a BackgroundWakeup was running, it will detect this.page === null and exit on its own. + // Resetting the flag here could allow a new instance to start before the old one exits. + throw error; } } @@ -1187,34 +2001,61 @@ class BrowserManager { * * @returns {Promise} true if reconnect was successful, false otherwise */ - async attemptLightweightReconnect() { - // Verify browser and page are still valid - if (!this.browser || !this.page) { - this.logger.warn("[Reconnect] Browser or page is not available, cannot perform lightweight reconnect."); + /** + * Attempt lightweight reconnect for a specific account + * Refreshes the page and re-injects the proxy script without restarting the browser + * @param {number} authIndex - The auth index to reconnect (defaults to current if not specified) + * @returns {Promise} true if reconnect was successful, false otherwise + */ + async attemptLightweightReconnect(authIndex = null) { + // Use provided authIndex or fall back to current + const targetAuthIndex = authIndex !== null ? authIndex : this._currentAuthIndex; + + if (targetAuthIndex < 0) { + this.logger.warn("[Reconnect] Invalid auth index, cannot perform lightweight reconnect."); return false; } - // Check if page is closed - if (this.page.isClosed()) { - this.logger.warn("[Reconnect] Page is closed, cannot perform lightweight reconnect."); + // Get the context data for this account + const contextData = this.contexts.get(targetAuthIndex); + if (!contextData || !contextData.page) { + this.logger.warn( + `[Reconnect] No context found for account #${targetAuthIndex}, cannot perform lightweight reconnect.` + ); return false; } - const authIndex = this._currentAuthIndex; - if (authIndex < 0) { - this.logger.warn("[Reconnect] No current auth index, cannot perform lightweight reconnect."); + const page = contextData.page; + + // Verify browser and page are still valid + if (!this.browser || !page) { + this.logger.warn( + `[Reconnect] Browser or page is not available for account #${targetAuthIndex}, cannot perform lightweight reconnect.` + ); + return false; + } + + // Check if page is closed + if (page.isClosed()) { + this.logger.warn( + `[Reconnect] Page is closed for account #${targetAuthIndex}, cannot perform lightweight reconnect.` + ); return false; } this.logger.info("=================================================="); - this.logger.info(`🔄 [Reconnect] Starting lightweight reconnect for account #${authIndex}...`); + this.logger.info(`🔄 [Reconnect] Starting lightweight reconnect for account #${targetAuthIndex}...`); this.logger.info("=================================================="); - // Stop existing background tasks - if (this.healthMonitorInterval) { - clearInterval(this.healthMonitorInterval); - this.healthMonitorInterval = null; - this.logger.info("[Reconnect] Stopped background health monitor."); + // Stop existing background tasks only if this is the current account + const isCurrentAccount = targetAuthIndex === this._currentAuthIndex; + if (isCurrentAccount) { + const ctxData = this.contexts.get(targetAuthIndex); + if (ctxData && ctxData.healthMonitorInterval) { + clearInterval(ctxData.healthMonitorInterval); + ctxData.healthMonitorInterval = null; + this.logger.info("[Reconnect] Stopped background health monitor."); + } } try { @@ -1222,47 +2063,164 @@ class BrowserManager { const buildScriptContent = this._loadAndConfigureBuildScript(); // Navigate to target page and wake it up - await this._navigateAndWakeUpPage("[Reconnect]"); + await this._navigateAndWakeUpPage(page, "[Reconnect]"); // Check for cookie expiration, region restrictions, and other errors - await this._checkPageStatusAndErrors("[Reconnect]"); + await this._checkPageStatusAndErrors(page, "[Reconnect]"); // Handle various popups (Cookie consent, Got it, Onboarding, etc.) - await this._handlePopups("[Reconnect]"); + await this._handlePopups(page, "[Reconnect]"); // Use shared script injection helper with [Reconnect] log prefix - await this._injectScriptToEditor(buildScriptContent, "[Reconnect]"); + await this._injectScriptToEditor(page, buildScriptContent, "[Reconnect]"); // [Auth Update] Save the refreshed cookies to the auth file immediately - await this._updateAuthFile(authIndex); + await this._updateAuthFile(targetAuthIndex); this.logger.info("=================================================="); - this.logger.info(`✅ [Reconnect] Lightweight reconnect successful for account #${authIndex}!`); + this.logger.info(`✅ [Reconnect] Lightweight reconnect successful for account #${targetAuthIndex}!`); this.logger.info("=================================================="); + // Restart background tasks only if this is the current account + if (isCurrentAccount) { + // Reset BackgroundWakeup state after reconnect + this.noButtonCount = 0; + this._startHealthMonitor(); + this._startBackgroundWakeup(); // Internal check prevents duplicate instances + } + return true; } catch (error) { - this.logger.error(`❌ [Reconnect] Lightweight reconnect failed: ${error.message}`); - await this._saveDebugArtifacts("reconnect_failed"); + this.logger.error( + `❌ [Reconnect] Lightweight reconnect failed for account #${targetAuthIndex}: ${error.message}` + ); + await this._saveDebugArtifacts("reconnect_failed", targetAuthIndex); return false; } } + /** + * Close a single context for a specific account + * + * IMPORTANT: When deleting an account, always call this method BEFORE closeConnectionByAuth() + * Calling order: closeContext() -> closeConnectionByAuth() + * + * Reason: This method removes the context from the contexts Map BEFORE closing it. + * When context.close() triggers WebSocket disconnect, ConnectionRegistry._removeConnection() + * will check if the context still exists. If not found, it skips reconnect logic. + * If you call closeConnectionByAuth() first, _removeConnection() will see the context + * still exists and may trigger unnecessary reconnect attempts. + * + * @param {number} authIndex - The auth index to close + */ + async closeContext(authIndex) { + // If context is being initialized in background, signal abort and wait + if (this.initializingContexts.has(authIndex)) { + this.logger.info(`[Browser] Context #${authIndex} is being initialized, marking for abort and waiting...`); + this.abortedContexts.add(authIndex); + await this._waitForContextInit(authIndex); + this.abortedContexts.delete(authIndex); + } + + if (!this.contexts.has(authIndex)) { + // Context doesn't exist (was never initialized or was aborted) + // Still check if we need to close the browser + // Only close if there are no contexts AND no contexts being initialized + if (this.contexts.size === 0 && this.initializingContexts.size === 0 && this.browser) { + this.logger.info(`[Browser] All contexts closed, closing browser instance...`); + await this.closeBrowser(); + } + return; + } + + const contextData = this.contexts.get(authIndex); + + // Stop health monitor for this context + if (contextData.healthMonitorInterval) { + clearInterval(contextData.healthMonitorInterval); + contextData.healthMonitorInterval = null; + this.logger.info(`[Browser] Stopped health monitor for context #${authIndex}`); + } + + // Remove from contexts map FIRST, before closing context + // This ensures that when context.close() triggers WebSocket disconnect, + // _removeConnection will see that the context is already gone and skip reconnect logic + this.contexts.delete(authIndex); + + // If this was the current context, reset current references + if (this._currentAuthIndex === authIndex) { + this.context = null; + this.page = null; + this._currentAuthIndex = -1; + // DO NOT reset backgroundWakeupRunning here! + // If a BackgroundWakeup was running, it will detect this.page === null and exit on its own. + // Resetting the flag here could allow a new instance to start before the old one exits. + this.logger.info(`[Browser] Current context was closed, currentAuthIndex reset to -1.`); + } + + // Close the context AFTER removing from map + try { + if (contextData.context) { + await contextData.context.close(); + this.logger.info(`[Browser] Context #${authIndex} closed.`); + } + } catch (e) { + this.logger.warn(`[Browser] Error closing context #${authIndex}: ${e.message}`); + } + + // If this was the last context, close the browser to free resources + // This ensures a clean state when all accounts are deleted + // Only close if there are no contexts AND no contexts being initialized + if (this.contexts.size === 0 && this.initializingContexts.size === 0 && this.browser) { + this.logger.info(`[Browser] All contexts closed, closing browser instance...`); + await this.closeBrowser(); + } + } + + /** + * Helper: Clean up all context resources (health monitors, etc.) + * Called when browser is closing or has disconnected + */ + _cleanupAllContexts() { + // Clean up all context health monitors + for (const [authIndex, contextData] of this.contexts.entries()) { + if (contextData.healthMonitorInterval) { + clearInterval(contextData.healthMonitorInterval); + contextData.healthMonitorInterval = null; + this.logger.info(`[Browser] Stopped health monitor for context #${authIndex}`); + } + } + + // Reset all references + this.contexts.clear(); + this.initializingContexts.clear(); + this.abortedContexts.clear(); + this.context = null; + this.page = null; + this._currentAuthIndex = -1; + // DO NOT reset backgroundWakeupRunning here! + // If a BackgroundWakeup was running, it will detect this.page === null and exit on its own. + // Resetting the flag here could allow a new instance to start before the old one exits. + } + /** * Unified cleanup method for the main browser instance. * Handles intervals, timeouts, and resetting all references. + * In multi-context mode, cleans up all contexts. */ async closeBrowser() { // Set flag to indicate intentional close - prevents ConnectionRegistry from // attempting lightweight reconnect when WebSocket disconnects this.isClosingIntentionally = true; + // Legacy single health monitor cleanup (for backward compatibility) if (this.healthMonitorInterval) { clearInterval(this.healthMonitorInterval); this.healthMonitorInterval = null; } + if (this.browser) { - this.logger.info("[Browser] Closing main browser instance..."); + this.logger.info("[Browser] Closing main browser instance and all contexts..."); try { // Give close() 5 seconds, otherwise force proceed await Promise.race([this.browser.close(), new Promise(resolve => setTimeout(resolve, 5000))]); @@ -1270,12 +2228,9 @@ class BrowserManager { this.logger.warn(`[Browser] Error during close (ignored): ${e.message}`); } - // Reset all references this.browser = null; - this.context = null; - this.page = null; - this._currentAuthIndex = -1; - this.logger.info("[Browser] Main browser instance closed, currentAuthIndex reset to -1."); + this._cleanupAllContexts(); + this.logger.info("[Browser] Main browser instance and all contexts closed, currentAuthIndex reset to -1."); } // Reset flag after close is complete diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index 4aa95e5..cdf4855 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -16,27 +16,86 @@ class ConnectionRegistry extends EventEmitter { /** * @param {Object} logger - Logger instance * @param {Function} [onConnectionLostCallback] - Optional callback to invoke when connection is lost after grace period + * @param {Function} [getCurrentAuthIndex] - Function to get current auth index */ - constructor(logger, onConnectionLostCallback = null) { + constructor(logger, onConnectionLostCallback = null, getCurrentAuthIndex = null, browserManager = null) { super(); this.logger = logger; this.onConnectionLostCallback = onConnectionLostCallback; - this.connections = new Set(); + this.getCurrentAuthIndex = getCurrentAuthIndex; + this.browserManager = browserManager; + // Map: authIndex -> WebSocket connection + this.connectionsByAuth = new Map(); this.messageQueues = new Map(); - this.reconnectGraceTimer = null; - this.isReconnecting = false; // Flag to prevent multiple simultaneous reconnect attempts + // Map: authIndex -> timerId, supports independent grace period for each account + this.reconnectGraceTimers = new Map(); + // Map: authIndex -> boolean, supports independent reconnect status for each account + this.reconnectingAccounts = new Map(); } addConnection(websocket, clientInfo) { - if (this.reconnectGraceTimer) { - clearTimeout(this.reconnectGraceTimer); - this.reconnectGraceTimer = null; - this.messageQueues.forEach(queue => queue.close()); - this.messageQueues.clear(); + const authIndex = clientInfo.authIndex; + + // Validate authIndex: must be a valid non-negative integer + if (authIndex === undefined || authIndex < 0 || !Number.isInteger(authIndex)) { + this.logger.error( + `[Server] Rejecting connection with invalid authIndex: ${authIndex}. Connection will be closed.` + ); + this._safeCloseWebSocket(websocket, 1008, "Invalid authIndex"); + return; + } + + // Check if there's already a connection for this authIndex + const existingConnection = this.connectionsByAuth.get(authIndex); + if (existingConnection && existingConnection !== websocket) { + this.logger.warn( + `[Server] Duplicate connection detected for authIndex=${authIndex}, closing old connection...` + ); + try { + // Remove event listeners to prevent them from firing during close + existingConnection.removeAllListeners(); + } catch (e) { + this.logger.warn(`[Server] Error removing listeners from old connection: ${e.message}`); + } + this._safeCloseWebSocket(existingConnection, 1000, "Replaced by new connection"); + } + + // Clear grace timer for this authIndex + if (this.reconnectGraceTimers.has(authIndex)) { + clearTimeout(this.reconnectGraceTimers.get(authIndex)); + this.reconnectGraceTimers.delete(authIndex); + this.logger.info(`[Server] Grace timer cleared for reconnected authIndex=${authIndex}`); } - this.connections.add(websocket); - this.logger.info(`[Server] Internal WebSocket client connected (from: ${clientInfo.address})`); + // Clear reconnecting status for this authIndex when connection is re-established + if (this.reconnectingAccounts.has(authIndex)) { + this.reconnectingAccounts.delete(authIndex); + this.logger.info(`[Server] Cleared reconnecting status for reconnected authIndex=${authIndex}`); + } + + // Clear message queues for reconnected current account + // IMPORTANT: This must be done regardless of whether grace timer existed + // Scenario: If WebSocket reconnects after grace period timeout (>5s), + // grace timer is already deleted, but new message queues may have been created + // When WebSocket disconnects, browser aborts all in-flight requests + // Keeping these queues would cause them to hang until timeout + const currentAuthIndex = this.getCurrentAuthIndex ? this.getCurrentAuthIndex() : -1; + if (authIndex === currentAuthIndex && this.messageQueues.size > 0) { + this.logger.info( + `[Server] Reconnected current account #${authIndex}, clearing ${this.messageQueues.size} stale message queues...` + ); + this.closeAllMessageQueues(); + } + + // Store connection by authIndex + this.connectionsByAuth.set(authIndex, websocket); + this.logger.info( + `[Server] Internal WebSocket client connected (from: ${clientInfo.address}, authIndex: ${authIndex})` + ); + + // Store authIndex on websocket for cleanup + websocket._authIndex = authIndex; + websocket.on("message", data => this._handleIncomingMessage(data.toString())); websocket.on("close", () => this._removeConnection(websocket)); websocket.on("error", error => @@ -46,33 +105,82 @@ class ConnectionRegistry extends EventEmitter { } _removeConnection(websocket) { - this.connections.delete(websocket); - this.logger.info("[Server] Internal WebSocket client disconnected."); + const disconnectedAuthIndex = websocket._authIndex; - // Clear any existing grace timer before starting a new one - // This prevents multiple timers from running if connections disconnect in quick succession - if (this.reconnectGraceTimer) { - clearTimeout(this.reconnectGraceTimer); + // Remove from connectionsByAuth if it has an authIndex + if (disconnectedAuthIndex !== undefined && disconnectedAuthIndex >= 0) { + this.connectionsByAuth.delete(disconnectedAuthIndex); + this.logger.info(`[Server] Internal WebSocket client disconnected (authIndex: ${disconnectedAuthIndex}).`); + } else { + this.logger.info("[Server] Internal WebSocket client disconnected."); + // Early return for invalid authIndex - no reconnect logic needed + this.emit("connectionRemoved", websocket); + return; } - this.logger.info("[Server] Starting 5-second reconnect grace period..."); - this.reconnectGraceTimer = setTimeout(async () => { + // Check if the page still exists for this account + // If page is closed/missing, it means the context was intentionally closed, skip reconnect + if (this.browserManager) { + const contextData = this.browserManager.contexts.get(disconnectedAuthIndex); + if (!contextData || !contextData.page || contextData.page.isClosed()) { + this.logger.info( + `[Server] Account #${disconnectedAuthIndex} page is closed/missing, skipping reconnect logic.` + ); + // Clear any existing grace timer + if (this.reconnectGraceTimers.has(disconnectedAuthIndex)) { + clearTimeout(this.reconnectGraceTimers.get(disconnectedAuthIndex)); + this.reconnectGraceTimers.delete(disconnectedAuthIndex); + } + // Clear reconnecting status + if (this.reconnectingAccounts.has(disconnectedAuthIndex)) { + this.reconnectingAccounts.delete(disconnectedAuthIndex); + } + // Close pending message queues if this is the current account + // to prevent in-flight requests from hanging until timeout + const currentAuthIndex = this.getCurrentAuthIndex ? this.getCurrentAuthIndex() : -1; + if (disconnectedAuthIndex === currentAuthIndex) { + this.closeAllMessageQueues(); + } + this.emit("connectionRemoved", websocket); + return; + } + } + + // Clear any existing grace timer for THIS account before starting a new one + if (this.reconnectGraceTimers.has(disconnectedAuthIndex)) { + clearTimeout(this.reconnectGraceTimers.get(disconnectedAuthIndex)); + } + + this.logger.info(`[Server] Starting 5-second reconnect grace period for account #${disconnectedAuthIndex}...`); + const graceTimerId = setTimeout(async () => { this.logger.info( - "[Server] Grace period ended, no reconnection detected. Connection lost confirmed, cleaning up all pending requests..." + `[Server] Grace period ended for account #${disconnectedAuthIndex}, no reconnection detected.` ); - this.messageQueues.forEach(queue => queue.close()); - this.messageQueues.clear(); - // Attempt lightweight reconnect if callback is provided and not already reconnecting - if (this.onConnectionLostCallback && !this.isReconnecting) { - this.isReconnecting = true; + // Re-check if this is the current account at the time of grace period expiry + const currentAuthIndex = this.getCurrentAuthIndex ? this.getCurrentAuthIndex() : -1; + const isCurrentAccount = disconnectedAuthIndex === currentAuthIndex; + + // Only clear message queues if this is the current account + if (isCurrentAccount) { + this.closeAllMessageQueues(); + } else { + this.logger.info( + `[Server] Non-current account #${disconnectedAuthIndex} disconnected, keeping message queues intact.` + ); + } + + // Attempt lightweight reconnect if callback is provided and this account is not already reconnecting + const isAccountReconnecting = this.reconnectingAccounts.get(disconnectedAuthIndex) || false; + if (this.onConnectionLostCallback && !isAccountReconnecting) { + this.reconnectingAccounts.set(disconnectedAuthIndex, true); const lightweightReconnectTimeoutMs = 55000; this.logger.info( - `[Server] Attempting lightweight reconnect (timeout ${lightweightReconnectTimeoutMs / 1000}s)...` + `[Server] Attempting lightweight reconnect for account #${disconnectedAuthIndex} (timeout ${lightweightReconnectTimeoutMs / 1000}s)...` ); let timeoutId; try { - const callbackPromise = this.onConnectionLostCallback(); + const callbackPromise = this.onConnectionLostCallback(disconnectedAuthIndex); const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout( () => reject(new Error("Lightweight reconnect timed out")), @@ -80,22 +188,28 @@ class ConnectionRegistry extends EventEmitter { ); }); await Promise.race([callbackPromise, timeoutPromise]); - this.logger.info("[Server] Lightweight reconnect callback completed."); + this.logger.info( + `[Server] Lightweight reconnect callback completed for account #${disconnectedAuthIndex}.` + ); } catch (error) { - this.logger.error(`[Server] Lightweight reconnect failed: ${error.message}`); + this.logger.error( + `[Server] Lightweight reconnect failed for account #${disconnectedAuthIndex}: ${error.message}` + ); } finally { if (timeoutId) { clearTimeout(timeoutId); } - this.isReconnecting = false; + this.reconnectingAccounts.delete(disconnectedAuthIndex); } } - this.emit("connectionLost"); - - this.reconnectGraceTimer = null; + this.reconnectGraceTimers.delete(disconnectedAuthIndex); }, 5000); + if (disconnectedAuthIndex !== undefined && disconnectedAuthIndex >= 0) { + this.reconnectGraceTimers.set(disconnectedAuthIndex, graceTimerId); + } + this.emit("connectionRemoved", websocket); } @@ -114,7 +228,7 @@ class ConnectionRegistry extends EventEmitter { this.logger.warn(`[Server] Received message for unknown or outdated request ID: ${requestId}`); } } catch (error) { - this.logger.error("[Server] Failed to parse internal WebSocket message"); + this.logger.error(`[Server] Failed to parse internal WebSocket message: ${error.message}`); } } @@ -134,20 +248,69 @@ class ConnectionRegistry extends EventEmitter { } } - hasActiveConnections() { - return this.connections.size > 0; - } - isReconnectingInProgress() { - return this.isReconnecting; + // Only check if current account is reconnecting, to avoid non-current account reconnection affecting current account's request handling + const currentAuthIndex = this.getCurrentAuthIndex ? this.getCurrentAuthIndex() : -1; + return currentAuthIndex >= 0 && (this.reconnectingAccounts.get(currentAuthIndex) || false); } isInGracePeriod() { - return !!this.reconnectGraceTimer; + // Only check if current account is in grace period, to avoid non-current account disconnection affecting current account's request handling + const currentAuthIndex = this.getCurrentAuthIndex ? this.getCurrentAuthIndex() : -1; + return currentAuthIndex >= 0 && this.reconnectGraceTimers.has(currentAuthIndex); + } + + getConnectionByAuth(authIndex) { + const connection = this.connectionsByAuth.get(authIndex); + if (connection) { + this.logger.debug(`[Registry] Found WebSocket connection for authIndex=${authIndex}`); + } else if (this.logger.getLevel?.() === "DEBUG") { + this.logger.debug( + `[Registry] No WebSocket connection found for authIndex=${authIndex}. Available: [${Array.from(this.connectionsByAuth.keys()).join(", ")}]` + ); + } + return connection; } - getFirstConnection() { - return this.connections.values().next().value; + /** + * Close WebSocket connection for a specific account + * + * IMPORTANT: When deleting an account, always call BrowserManager.closeContext() BEFORE this method + * Calling order: closeContext() -> closeConnectionByAuth() + * + * Reason: closeContext() removes the context from the contexts Map before closing it. + * When this method closes the WebSocket, _removeConnection() will check if the context exists. + * If context is already removed, _removeConnection() skips reconnect logic (which is desired for deletion). + * If you call this method first, _removeConnection() may trigger unnecessary reconnect attempts. + * + * @param {number} authIndex - The auth index to close connection for + */ + closeConnectionByAuth(authIndex) { + const connection = this.connectionsByAuth.get(authIndex); + if (connection) { + this.logger.info(`[Registry] Closing WebSocket connection for authIndex=${authIndex}`); + try { + connection.close(); + } catch (e) { + this.logger.warn(`[Registry] Error closing WebSocket for authIndex=${authIndex}: ${e.message}`); + } + // Remove from map immediately (the close event will also trigger _removeConnection) + this.connectionsByAuth.delete(authIndex); + + // Clear any grace timers for this account + if (this.reconnectGraceTimers.has(authIndex)) { + clearTimeout(this.reconnectGraceTimers.get(authIndex)); + this.reconnectGraceTimers.delete(authIndex); + } + + // Clear reconnecting status for this account + if (this.reconnectingAccounts.has(authIndex)) { + this.reconnectingAccounts.delete(authIndex); + this.logger.info(`[Registry] Cleared reconnecting status for authIndex=${authIndex}`); + } + } else { + this.logger.debug(`[Registry] No WebSocket connection to close for authIndex=${authIndex}`); + } } createMessageQueue(requestId) { @@ -163,6 +326,52 @@ class ConnectionRegistry extends EventEmitter { this.messageQueues.delete(requestId); } } + + /** + * Force close all message queues + * Used when the active account is deleted/reset and we want to terminate all pending requests immediately + */ + closeAllMessageQueues() { + if (this.messageQueues.size > 0) { + this.logger.info(`[Registry] Force closing ${this.messageQueues.size} pending message queues...`); + this.messageQueues.forEach((queue, requestId) => { + try { + queue.close(); + } catch (e) { + this.logger.warn(`[Registry] Failed to close message queue for request ${requestId}: ${e.message}`); + } + }); + this.messageQueues.clear(); + } + } + + /** + * Safely close a WebSocket connection with readyState check + * @param {WebSocket} ws - The WebSocket to close + * @param {number} code - Close code (e.g., 1000, 1008) + * @param {string} reason - Close reason + */ + _safeCloseWebSocket(ws, code, reason) { + if (!ws) { + return; + } + + // WebSocket readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED + // Only attempt to close if not already closing or closed + if (ws.readyState === 0 || ws.readyState === 1) { + try { + ws.close(code, reason); + } catch (error) { + this.logger.warn( + `[Registry] Failed to close WebSocket (code=${code}, reason="${reason}"): ${error.message}` + ); + } + } else { + this.logger.debug( + `[Registry] WebSocket already closing/closed (readyState=${ws.readyState}), skipping close()` + ); + } + } } module.exports = ConnectionRegistry; diff --git a/src/core/ProxyServerSystem.js b/src/core/ProxyServerSystem.js index eef87d7..6c58e8a 100644 --- a/src/core/ProxyServerSystem.js +++ b/src/core/ProxyServerSystem.js @@ -44,34 +44,53 @@ class ProxyServerSystem extends EventEmitter { // Create ConnectionRegistry with lightweight reconnect callback // When WebSocket connection is lost but browser is still running, // this callback attempts to refresh the page and re-inject the script - this.connectionRegistry = new ConnectionRegistry(this.logger, async () => { - // Skip if browser is being intentionally closed (not an unexpected disconnect) - if (this.browserManager.isClosingIntentionally) { - this.logger.info("[System] Browser is closing intentionally, skipping reconnect attempt."); - return; - } - // Skip if the system is busy switching/recovering to avoid conflicting refreshes - if (this.requestHandler?.isSystemBusy) { - this.logger.info( - "[System] System is busy (switching/recovering), skipping lightweight reconnect attempt." - ); - return; - } + this.connectionRegistry = new ConnectionRegistry( + this.logger, + async authIndex => { + // Skip if browser is being intentionally closed (not an unexpected disconnect) + if (this.browserManager.isClosingIntentionally) { + this.logger.info("[System] Browser is closing intentionally, skipping reconnect attempt."); + return; + } - if (this.browserManager.browser && this.browserManager.page && !this.browserManager.page.isClosed()) { - this.logger.error( - "[System] WebSocket lost but browser still running, attempting lightweight reconnect..." - ); - const success = await this.browserManager.attemptLightweightReconnect(); - if (!success) { - this.logger.warn( - "[System] Lightweight reconnect failed. Will attempt full recovery on next request." + // Check if this is the current account + const currentAuthIndex = this.browserManager.currentAuthIndex; + const isCurrentAccount = authIndex === currentAuthIndex; + + // Only check isSystemBusy if this is the current account + if (isCurrentAccount && this.requestHandler?.isSystemBusy) { + this.logger.info( + `[System] Current account #${authIndex} is busy (switching/recovering), skipping lightweight reconnect attempt.` ); + return; } - } else { - this.logger.info("[System] Browser not available, skipping lightweight reconnect."); - } - }); + + // Get the context and page for this specific account + const contextData = this.browserManager.contexts.get(authIndex); + if (!contextData || !contextData.page || contextData.page.isClosed()) { + this.logger.info( + `[System] Account #${authIndex} page not available or closed, skipping lightweight reconnect.` + ); + return; + } + + if (this.browserManager.browser) { + this.logger.error( + `[System] WebSocket lost for account #${authIndex} but browser still running, attempting lightweight reconnect...` + ); + const success = await this.browserManager.attemptLightweightReconnect(authIndex); + if (!success) { + this.logger.warn( + `[System] Lightweight reconnect failed for account #${authIndex}. Will attempt full recovery on next request.` + ); + } + } else { + this.logger.info("[System] Browser not available, skipping lightweight reconnect."); + } + }, + () => this.browserManager.currentAuthIndex, + this.browserManager + ); this.requestHandler = new RequestHandler( this, this.connectionRegistry, @@ -101,6 +120,7 @@ class ProxyServerSystem extends EventEmitter { return; // Exit early } + // Determine startup order let startupOrder = allRotationIndices.length > 0 ? [...allRotationIndices] : [...allAvailableIndices]; const hasInitialAuthIndex = Number.isInteger(initialAuthIndex); if (hasInitialAuthIndex) { @@ -108,7 +128,7 @@ class ProxyServerSystem extends EventEmitter { if (canonicalInitialIndex !== null && startupOrder.includes(canonicalInitialIndex)) { if (canonicalInitialIndex !== initialAuthIndex) { this.logger.warn( - `[System] Specified startup index #${initialAuthIndex} is a duplicate for the same email, using latest auth index #${canonicalInitialIndex} instead.` + `[System] Specified startup index #${initialAuthIndex} is a duplicate, using latest auth index #${canonicalInitialIndex} instead.` ); } else { this.logger.info( @@ -123,32 +143,31 @@ class ProxyServerSystem extends EventEmitter { } } else { this.logger.info( - `[System] No valid startup index specified, will try in default order [${startupOrder.join(", ")}].` + `[System] No valid startup index specified, will activate first available context [${startupOrder[0]}].` ); } - let isStarted = false; - for (const index of startupOrder) { - try { - this.logger.info(`[System] Attempting to start service with account #${index}...`); - this.requestHandler.authSwitcher.isSystemBusy = true; - await this.browserManager.launchOrSwitchContext(index); + // Context pool startup + const maxContexts = this.config.maxContexts; + this.logger.info(`[System] Starting context pool (maxContexts=${maxContexts})...`); - isStarted = true; - this.logger.info(`[System] ✅ Successfully started with account #${index}!`); - break; - } catch (error) { - this.logger.error(`[System] ❌ Failed to start with account #${index}. Reason: ${error.message}`); - } finally { - this.requestHandler.authSwitcher.isSystemBusy = false; + try { + this.requestHandler.authSwitcher.isSystemBusy = true; + const { firstReady } = await this.browserManager.preloadContextPool(startupOrder, maxContexts); + + if (firstReady === null) { + this.logger.error("[System] Failed to initialize any context!"); + this.emit("started"); + return; } - } - if (!isStarted) { - this.logger.warn( - "[System] All authentication sources failed to initialize. Starting in account binding mode without an active account." - ); - // Don't throw an error, just proceed to start servers + // Activate first ready context (fast switch since already preloaded) + await this.browserManager.launchOrSwitchContext(firstReady); + this.logger.info(`[System] ✅ Successfully activated account #${firstReady}!`); + } catch (error) { + this.logger.error(`[System] ❌ Startup failed: ${error.message}`); + } finally { + this.requestHandler.authSwitcher.isSystemBusy = false; } this.emit("started"); @@ -499,23 +518,62 @@ class ProxyServerSystem extends EventEmitter { this.wsServer.on("error", err => { if (!isListening) { - this.logger.error( - `[System] WebSocket server failed to start: ${err.message}` - ); + this.logger.error(`[System] WebSocket server failed to start: ${err.message}`); reject(err); } else { - this.logger.error( - `[System] WebSocket server runtime error: ${err.message}` - ); + this.logger.error(`[System] WebSocket server runtime error: ${err.message}`); } }); this.wsServer.on("connection", (ws, req) => { + // Parse authIndex from query parameter + const url = new URL(req.url, `http://${req.headers.host}`); + const authIndexParam = url.searchParams.get("authIndex"); + const authIndex = authIndexParam !== null ? parseInt(authIndexParam, 10) : -1; + + // Validate authIndex: must be a valid non-negative integer + if (Number.isNaN(authIndex) || authIndex < 0) { + this.logger.error( + `[System] Rejecting WebSocket connection with invalid authIndex: ${authIndexParam} (parsed as ${authIndex})` + ); + this._safeCloseWebSocket(ws, 1008, "Invalid authIndex: must be a non-negative integer"); + return; + } + this.connectionRegistry.addConnection(ws, { address: req.socket.remoteAddress, + authIndex, }); }); }); } + + /** + * Safely close a WebSocket connection with readyState check + * @param {WebSocket} ws - The WebSocket to close + * @param {number} code - Close code (e.g., 1000, 1008) + * @param {string} reason - Close reason + */ + _safeCloseWebSocket(ws, code, reason) { + if (!ws) { + return; + } + + // WebSocket readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED + // Only attempt to close if not already closing or closed + if (ws.readyState === 0 || ws.readyState === 1) { + try { + ws.close(code, reason); + } catch (error) { + this.logger.warn( + `[System] Failed to close WebSocket (code=${code}, reason="${reason}"): ${error.message}` + ); + } + } else { + this.logger.debug( + `[System] WebSocket already closing/closed (readyState=${ws.readyState}), skipping close()` + ); + } + } } module.exports = ProxyServerSystem; diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index 5238318..0651894 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -65,7 +65,7 @@ class RequestHandler { } await new Promise(resolve => setTimeout(resolve, 100)); } - return this.connectionRegistry.hasActiveConnections(); + return !!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex); } _isConnectionResetError(error) { @@ -78,7 +78,7 @@ class RequestHandler { } /** - * Wait for WebSocket connection to be established + * Wait for WebSocket connection to be established for current account * @param {number} timeoutMs - Maximum time to wait in milliseconds * @returns {Promise} true if connection established, false if timeout */ @@ -87,12 +87,27 @@ class RequestHandler { const checkInterval = 200; // Check every 200ms while (Date.now() - startTime < timeoutMs) { - if (this.connectionRegistry.hasActiveConnections()) { + const connection = this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex); + // Check both existence and readyState (1 = OPEN) + if (connection && connection.readyState === 1) { return true; } await new Promise(resolve => setTimeout(resolve, checkInterval)); } + this.logger.warn( + `[Request] Timeout waiting for WebSocket connection for account #${this.currentAuthIndex}. Closing unresponsive context...` + ); + // Proactively close the unresponsive context so subsequent attempts re-initialize it + if (this.browserManager) { + try { + await this.browserManager.closeContext(this.currentAuthIndex); + } catch (e) { + this.logger.warn( + `[System] Failed to close unresponsive context for account #${this.currentAuthIndex}: ${e.message}` + ); + } + } return false; } @@ -158,13 +173,13 @@ class RequestHandler { ); return false; } - // After waiting, also wait for WebSocket connection to be established - if (!this.connectionRegistry.hasActiveConnections()) { + // After waiting, also wait for WebSocket connection to be established for current account + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { const connectionReady = await this._waitForConnection(10000); if (!connectionReady) { // The other process failed to establish connection, return error this.logger.error( - "[System] WebSocket connection not established after system ready, browser startup may have failed." + `[System] WebSocket connection not established for account #${this.currentAuthIndex} after system ready, browser startup may have failed.` ); await this._sendErrorResponse( res, @@ -177,11 +192,20 @@ class RequestHandler { return true; } - this.logger.error( - "❌ [System] Browser WebSocket connection disconnected! Possible process crash. Attempting recovery..." - ); - + // Determine if this is first-time startup or actual crash recovery const recoveryAuthIndex = this.currentAuthIndex; + const isFirstTimeStartup = recoveryAuthIndex < 0 && !this.browserManager.browser; + + if (isFirstTimeStartup) { + this.logger.info( + "🚀 [System] Browser not yet started. Initializing browser with first available account..." + ); + } else { + this.logger.error( + "❌ [System] Browser WebSocket connection disconnected! Possible process crash. Attempting recovery..." + ); + } + let wasDirectRecovery = false; let recoverySuccess = false; @@ -204,7 +228,6 @@ class RequestHandler { this.logger.info("✅ [System] WebSocket connection is ready!"); recoverySuccess = true; } else if (this.authSource.getRotationIndices().length > 0) { - this.logger.warn("⚠️ [System] No current account, attempting to switch to first available account..."); // Don't set isSystemBusy here - let switchToNextAuth manage it const result = await this.authSwitcher.switchToNextAuth(); if (!result.success) { @@ -233,6 +256,9 @@ class RequestHandler { if (wasDirectRecovery && this.authSource.getRotationIndices().length > 1) { this.logger.warn("⚠️ [System] Attempting to switch to alternative account..."); + // Reset isSystemBusy before calling switchToNextAuth to avoid "already in progress" rejection + this.authSwitcher.isSystemBusy = false; + wasDirectRecovery = false; // Prevent finally block from resetting again try { const result = await this.authSwitcher.switchToNextAuth(); if (!result.success) { @@ -281,8 +307,9 @@ class RequestHandler { async processRequest(req, res) { const requestId = this._generateRequestId(); - // Check browser connection - if (!this.connectionRegistry.hasActiveConnections()) { + // Check current account's browser connection + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { + this.logger.warn(`[Request] No WebSocket connection for current account #${this.currentAuthIndex}`); const recovered = await this._handleBrowserRecovery(res); if (!recovered) return; } @@ -297,8 +324,8 @@ class RequestHandler { "Server undergoing internal maintenance (account switching/recovery), please try again later." ); } - // After system ready, ensure connection is available - if (!this.connectionRegistry.hasActiveConnections()) { + // After system ready, ensure connection is available for current account + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { const connectionReady = await this._waitForConnection(10000); if (!connectionReady) { return this._sendErrorResponse( @@ -382,8 +409,9 @@ class RequestHandler { const requestId = this._generateRequestId(); this.logger.info(`[Upload] Processing upload request ${req.method} ${req.path} (ID: ${requestId})`); - // Check browser connection - if (!this.connectionRegistry.hasActiveConnections()) { + // Check current account's browser connection + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { + this.logger.warn(`[Upload] No WebSocket connection for current account #${this.currentAuthIndex}`); const recovered = await this._handleBrowserRecovery(res); if (!recovered) return; } @@ -398,7 +426,7 @@ class RequestHandler { "Server undergoing internal maintenance (account switching/recovery), please try again later." ); } - if (!this.connectionRegistry.hasActiveConnections()) { + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { const connectionReady = await this._waitForConnection(10000); if (!connectionReady) { return this._sendErrorResponse( @@ -448,8 +476,9 @@ class RequestHandler { async processOpenAIRequest(req, res) { const requestId = this._generateRequestId(); - // Check browser connection - if (!this.connectionRegistry.hasActiveConnections()) { + // Check current account's browser connection + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { + this.logger.warn(`[Request] No WebSocket connection for current account #${this.currentAuthIndex}`); const recovered = await this._handleBrowserRecovery(res); if (!recovered) return; } @@ -464,8 +493,8 @@ class RequestHandler { "Server undergoing internal maintenance (account switching/recovery), please try again later." ); } - // After system ready, ensure connection is available - if (!this.connectionRegistry.hasActiveConnections()) { + // After system ready, ensure connection is available for current account + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { const connectionReady = await this._waitForConnection(10000); if (!connectionReady) { return this._sendErrorResponse( @@ -673,7 +702,7 @@ class RequestHandler { this.connectionRegistry.removeMessageQueue(requestId); if (this.needsSwitchingAfterRequest) { this.logger.info( - `[Auth] Rotation count reached switching threshold, will automatically switch account in background...` + `[Auth] Rotation count reached switching threshold (${this.authSwitcher.usageCount}/${this.config.switchOnUses}), will automatically switch account in background...` ); this.authSwitcher.switchToNextAuth().catch(err => { this.logger.error(`[Auth] Background account switching task failed: ${err.message}`); @@ -688,8 +717,9 @@ class RequestHandler { async processClaudeRequest(req, res) { const requestId = this._generateRequestId(); - // Check browser connection - if (!this.connectionRegistry.hasActiveConnections()) { + // Check current account's browser connection + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { + this.logger.warn(`[Request] No WebSocket connection for current account #${this.currentAuthIndex}`); const recovered = await this._handleBrowserRecovery(res); if (!recovered) return; } @@ -705,7 +735,7 @@ class RequestHandler { "Server undergoing internal maintenance, please try again later." ); } - if (!this.connectionRegistry.hasActiveConnections()) { + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { const connectionReady = await this._waitForConnection(10000); if (!connectionReady) { return this._sendClaudeErrorResponse( @@ -911,7 +941,7 @@ class RequestHandler { this.connectionRegistry.removeMessageQueue(requestId); if (this.needsSwitchingAfterRequest) { this.logger.info( - `[Auth] Rotation count reached switching threshold, will automatically switch account in background...` + `[Auth] Rotation count reached switching threshold (${this.authSwitcher.usageCount}/${this.config.switchOnUses}), will automatically switch account in background...` ); this.authSwitcher.switchToNextAuth().catch(err => { this.logger.error(`[Auth] Background account switching task failed: ${err.message}`); @@ -926,8 +956,9 @@ class RequestHandler { async processClaudeCountTokens(req, res) { const requestId = this._generateRequestId(); - // Check browser connection - if (!this.connectionRegistry.hasActiveConnections()) { + // Check current account's browser connection + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { + this.logger.warn(`[Request] No WebSocket connection for current account #${this.currentAuthIndex}`); const recovered = await this._handleBrowserRecovery(res); if (!recovered) return; } @@ -943,7 +974,7 @@ class RequestHandler { "Server undergoing internal maintenance, please try again later." ); } - if (!this.connectionRegistry.hasActiveConnections()) { + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { const connectionReady = await this._waitForConnection(10000); if (!connectionReady) { return this._sendClaudeErrorResponse( @@ -1807,7 +1838,7 @@ class RequestHandler { } _cancelBrowserRequest(requestId) { - const connection = this.connectionRegistry.getFirstConnection(); + const connection = this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex); if (connection) { this.logger.info(`[Request] Cancelling request #${requestId}`); connection.send( @@ -1834,7 +1865,7 @@ class RequestHandler { return false; } - const connection = this.connectionRegistry.getFirstConnection(); + const connection = this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex); if (connection) { connection.send( JSON.stringify({ @@ -2001,8 +2032,11 @@ class RequestHandler { } _forwardRequest(proxyRequest) { - const connection = this.connectionRegistry.getFirstConnection(); + const connection = this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex); if (connection) { + this.logger.debug( + `[Request] Forwarding request #${proxyRequest.request_id} via connection for authIndex=${this.currentAuthIndex}` + ); connection.send( JSON.stringify({ event_type: "proxy_request", @@ -2010,7 +2044,9 @@ class RequestHandler { }) ); } else { - throw new Error("Unable to forward request: No available WebSocket connection."); + throw new Error( + `Unable to forward request: No WebSocket connection found for authIndex=${this.currentAuthIndex}` + ); } } diff --git a/src/routes/AuthRoutes.js b/src/routes/AuthRoutes.js index 5214875..a2b489f 100644 --- a/src/routes/AuthRoutes.js +++ b/src/routes/AuthRoutes.js @@ -24,8 +24,12 @@ class AuthRoutes { // Rate limiting configuration from environment variables this.rateLimitEnabled = process.env.RATE_LIMIT_MAX_ATTEMPTS !== "0"; - this.rateLimitWindow = parseInt(process.env.RATE_LIMIT_WINDOW_MINUTES) || 15; // minutes - this.rateLimitMaxAttempts = parseInt(process.env.RATE_LIMIT_MAX_ATTEMPTS) || 5; + + const parsedWindow = parseInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 10); + this.rateLimitWindow = Number.isFinite(parsedWindow) && parsedWindow > 0 ? parsedWindow : 15; // minutes + + const parsedMaxAttempts = parseInt(process.env.RATE_LIMIT_MAX_ATTEMPTS, 10); + this.rateLimitMaxAttempts = Number.isFinite(parsedMaxAttempts) && parsedMaxAttempts > 0 ? parsedMaxAttempts : 5; if (this.rateLimitEnabled) { this.logger.info( diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index 5a40f18..6d47d95 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -107,33 +107,46 @@ class StatusRoutes { app.get("/api/status", isAuthenticated, async (req, res) => { // Force a reload of auth sources on each status check for real-time accuracy - this.serverSystem.authSource.reloadAuthSources(); + const hasChanges = this.serverSystem.authSource.reloadAuthSources(); const { authSource, browserManager, requestHandler } = this.serverSystem; // If the system is busy switching accounts, skip the validity check to prevent race conditions if (requestHandler.isSystemBusy) { + // Rebalance context pool if auth files changed + if (hasChanges) { + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[System] Background rebalance failed: ${err.message}`); + }); + } return res.json(this._getStatusData()); } - // After reloading, only check for auth validity if a browser is active. - if (browserManager.browser) { - const currentAuthIndex = requestHandler.currentAuthIndex; - - if (currentAuthIndex === -1 || !authSource.availableIndices.includes(currentAuthIndex)) { + // After reloading, only check for auth validity if a browser is active and has a valid current account. + const currentAuthIndex = requestHandler.currentAuthIndex; + if (browserManager.browser && currentAuthIndex >= 0) { + if (!authSource.availableIndices.includes(currentAuthIndex)) { this.logger.warn( `[System] Current auth index #${currentAuthIndex} is no longer valid after reload (e.g., file deleted).` ); - this.logger.warn("[System] Closing browser connection due to invalid auth."); + this.logger.warn("[System] Closing context for invalid auth."); try { - // Await closing to prevent repeated checks on subsequent status polls - await browserManager.closeBrowser(); + // Close only the invalid account's context, not the entire browser + await browserManager.closeContext(currentAuthIndex); } catch (err) { - this.logger.error(`[System] Error while closing browser automatically: ${err.message}`); + this.logger.error(`[System] Error while closing context automatically: ${err.message}`); } } } + // Rebalance context pool if auth files changed (e.g., user manually added/removed files) + if (hasChanges) { + this.logger.info("[System] Auth file changes detected, rebalancing context pool..."); + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[System] Background rebalance failed: ${err.message}`); + }); + } + res.json(this._getStatusData()); }); @@ -204,6 +217,12 @@ class StatusRoutes { const removedIndices = []; const failed = []; + // Abort any ongoing background preload task before deletion + // This prevents race conditions where background tasks continue initializing contexts + // that are about to be deleted + await this.serverSystem.browserManager.abortBackgroundPreload(); + + // Delete duplicate auth files for (const group of duplicateGroups) { const removed = Array.isArray(group.removedIndices) ? group.removedIndices : []; if (removed.length === 0) continue; @@ -233,6 +252,32 @@ class StatusRoutes { }); } + // Reload auth sources to update internal state immediately after dedup deletions + if (removedIndices.length > 0) { + authSource.reloadAuthSources(); + } + + // Close contexts for removed duplicate accounts + if (removedIndices.length > 0) { + for (const idx of removedIndices) { + try { + await this.serverSystem.browserManager.closeContext(idx); + this.serverSystem.connectionRegistry.closeConnectionByAuth(idx); + } catch (error) { + this.logger.warn( + `[Auth] Failed to close context for removed duplicate #${idx}: ${error.message}` + ); + } + } + } + + // Rebalance context pool after dedup + if (removedIndices.length > 0) { + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); + } + return res.status(200).json({ message: "accountDedupSuccess", removedIndices, @@ -291,6 +336,12 @@ class StatusRoutes { }); } + // Abort any ongoing background preload task before deletion + // This prevents race conditions where background tasks continue initializing contexts + // that are about to be deleted + await this.serverSystem.browserManager.abortBackgroundPreload(); + + // Delete auth files for (const targetIndex of validIndices) { try { authSource.removeAuth(targetIndex); @@ -302,15 +353,52 @@ class StatusRoutes { } } - // If current active account was deleted, close browser connection + // Reload auth sources to update internal state immediately after deletions + if (successIndices.length > 0) { + authSource.reloadAuthSources(); + } + + // If current active account was deleted, close context first, then connection if (includesCurrent && successIndices.includes(currentAuthIndex)) { this.logger.warn( - `[WebUI] Current active account #${currentAuthIndex} was deleted. Closing browser connection...` + `[WebUI] Current active account #${currentAuthIndex} was deleted. Closing context and connection...` ); - this.serverSystem.browserManager.closeBrowser().catch(err => { - this.logger.error(`[WebUI] Error closing browser after batch deletion: ${err.message}`); + // Set system busy flag to prevent new requests during cleanup + const previousBusy = this.serverSystem.isSystemBusy === true; + if (!previousBusy) { + this.serverSystem.isSystemBusy = true; + } + try { + // 1. Terminate all pending requests immediately + this.serverSystem.connectionRegistry.closeAllMessageQueues(); + // 2. Close context first so page is gone when _removeConnection checks + await this.serverSystem.browserManager.closeContext(currentAuthIndex); + // 3. Then close WebSocket connection + this.serverSystem.connectionRegistry.closeConnectionByAuth(currentAuthIndex); + } finally { + // Reset system busy flag after cleanup completes + if (!previousBusy) { + this.serverSystem.isSystemBusy = false; + } + } + } + + // Close contexts and connections for all successfully deleted accounts (except current, already handled) + for (const idx of successIndices) { + if (idx !== currentAuthIndex) { + this.logger.info(`[WebUI] Closing context and connection for deleted account #${idx}...`); + // Close context first so page is gone when _removeConnection checks + await this.serverSystem.browserManager.closeContext(idx); + // Then close WebSocket connection + this.serverSystem.connectionRegistry.closeConnectionByAuth(idx); + } + } + + // Rebalance context pool after batch delete + if (successIndices.length > 0) { + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); }); - this.serverSystem.browserManager.currentAuthIndex = -1; } if (failedIndices.length > 0) { @@ -419,7 +507,7 @@ class StatusRoutes { } }); - app.delete("/api/accounts/:index", isAuthenticated, (req, res) => { + app.delete("/api/accounts/:index", isAuthenticated, async (req, res) => { const rawIndex = req.params.index; const targetIndex = Number(rawIndex); const currentAuthIndex = this.serverSystem.requestHandler.currentAuthIndex; @@ -444,22 +532,52 @@ class StatusRoutes { }); } + // Abort any ongoing background preload task before deletion + // This prevents race conditions where background tasks continue initializing contexts + // that are about to be deleted + await this.serverSystem.browserManager.abortBackgroundPreload(); + try { + // Delete auth file authSource.removeAuth(targetIndex); - // If deleting current account, close browser connection + // Reload auth sources to update internal state immediately + authSource.reloadAuthSources(); + + // Always close context first, then connection + this.logger.info(`[WebUI] Account #${targetIndex} deleted. Closing context and connection...`); + if (targetIndex === currentAuthIndex) { - this.logger.warn( - `[WebUI] Current active account #${targetIndex} was deleted. Closing browser connection...` - ); - this.serverSystem.browserManager.closeBrowser().catch(err => { - this.logger.error(`[WebUI] Error closing browser after account deletion: ${err.message}`); - }); - // Reset current account index through browserManager - this.serverSystem.browserManager.currentAuthIndex = -1; + // Set system busy flag to prevent new requests during cleanup + const previousBusy = this.serverSystem.isSystemBusy === true; + if (!previousBusy) { + this.serverSystem.isSystemBusy = true; + } + try { + // If deleting the current account, terminate pending requests first + this.serverSystem.connectionRegistry.closeAllMessageQueues(); + // Close context first so page is gone when _removeConnection checks + await this.serverSystem.browserManager.closeContext(targetIndex); + // Then close WebSocket connection + this.serverSystem.connectionRegistry.closeConnectionByAuth(targetIndex); + } finally { + // Reset system busy flag after cleanup completes + if (!previousBusy) { + this.serverSystem.isSystemBusy = false; + } + } + } else { + // Non-current account: no need for system busy flag + await this.serverSystem.browserManager.closeContext(targetIndex); + this.serverSystem.connectionRegistry.closeConnectionByAuth(targetIndex); } - this.logger.warn( + // Rebalance context pool after delete + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); + + this.logger.info( `[WebUI] Account #${targetIndex} deleted via web interface. Previous current account: #${currentAuthIndex}` ); res.status(200).json({ @@ -531,7 +649,7 @@ class StatusRoutes { const { count } = req.body; const newCount = parseInt(count, 10); - if (!isNaN(newCount) && newCount > 0) { + if (Number.isFinite(newCount) && newCount > 0) { this.logger.setDisplayLimit(newCount); this.logger.info(`[WebUI] Log display limit updated to: ${newCount}`); res.status(200).json({ message: "settingUpdateSuccess", setting: "logMaxCount", value: newCount }); @@ -540,7 +658,7 @@ class StatusRoutes { } }); - app.post("/api/files", isAuthenticated, (req, res) => { + app.post("/api/files", isAuthenticated, async (req, res) => { const { content } = req.body; // Ignore req.body.filename - auto rename @@ -549,6 +667,11 @@ class StatusRoutes { } try { + // Abort any ongoing background preload task before upload + // This prevents race conditions where background tasks continue initializing contexts + // while we're adding a new account + await this.serverSystem.browserManager.abortBackgroundPreload(); + // Ensure directory exists const configDir = path.join(process.cwd(), "configs", "auth"); if (!fs.existsSync(configDir)) { @@ -571,6 +694,11 @@ class StatusRoutes { // Reload auth sources to pick up changes this.serverSystem.authSource.reloadAuthSources(); + // Rebalance context pool to pick up new account + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); + this.logger.info(`[WebUI] File uploaded via API: generated ${newFilename}`); res.status(200).json({ filename: newFilename, message: "File uploaded successfully" }); } catch (error) { @@ -579,6 +707,89 @@ class StatusRoutes { } }); + // Batch upload files + app.post("/api/files/batch", isAuthenticated, async (req, res) => { + const { files } = req.body; + + if (!Array.isArray(files) || files.length === 0) { + return res.status(400).json({ error: "Missing files array" }); + } + + try { + // Abort any ongoing background preload task before batch upload + // This prevents race conditions where background tasks continue initializing contexts + // while we're adding multiple new accounts + await this.serverSystem.browserManager.abortBackgroundPreload(); + + // Ensure directory exists + const configDir = path.join(process.cwd(), "configs", "auth"); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + const results = []; + + // Get starting index + const existingIndices = this.serverSystem.authSource.availableIndices || []; + let nextAuthIndex = existingIndices.length > 0 ? Math.max(...existingIndices) + 1 : 0; + + // Write all files first, track each file's result + for (let i = 0; i < files.length; i++) { + const content = files[i]; + + if (!content) { + results.push({ error: "Missing content", index: i, success: false }); + continue; + } + + try { + // If content is object, stringify it + const fileContent = typeof content === "object" ? JSON.stringify(content, null, 2) : content; + + const newFilename = `auth-${nextAuthIndex}.json`; + const filePath = path.join(configDir, newFilename); + + fs.writeFileSync(filePath, fileContent); + + results.push({ filename: newFilename, index: i, success: true }); + this.logger.info(`[WebUI] Batch upload: generated ${newFilename}`); + + nextAuthIndex++; + } catch (error) { + results.push({ error: error.message, index: i, success: false }); + this.logger.error(`[WebUI] Batch upload failed for file ${i}: ${error.message}`); + } + } + + // Only reload and rebalance once after all files are written + const successCount = results.filter(r => r.success).length; + if (successCount > 0) { + this.serverSystem.authSource.reloadAuthSources(); + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); + } + + const failureCount = results.length - successCount; + if (failureCount > 0) { + return res.status(207).json({ + message: "Batch upload partially successful", + results, + successCount, + }); + } + + res.status(200).json({ + message: "Batch upload successful", + results, + successCount, + }); + } catch (error) { + this.logger.error(`[WebUI] Batch upload failed: ${error.message}`); + res.status(500).json({ error: "Failed to upload files" }); + } + }); + app.get("/api/files/:filename", isAuthenticated, (req, res) => { const filename = req.params.filename; // Security check @@ -611,7 +822,9 @@ class StatusRoutes { const isDuplicate = canonicalIndex !== null && canonicalIndex !== index; const isRotation = rotationIndices.includes(index); - return { canonicalIndex, index, isDuplicate, isInvalid, isRotation, name }; + const hasContext = browserManager.contexts.has(index); + + return { canonicalIndex, hasContext, index, isDuplicate, isInvalid, isRotation, name }; }); const currentAuthIndex = requestHandler.currentAuthIndex; @@ -632,8 +845,9 @@ class StatusRoutes { logs: displayLogs.join("\n"), status: { accountDetails, + activeContextsCount: browserManager.contexts.size, apiKeySource: config.apiKeySource, - browserConnected: !!browserManager.browser, + browserConnected: !!this.serverSystem.connectionRegistry.getConnectionByAuth(currentAuthIndex), currentAccountName, currentAuthIndex, debugMode: LoggingService.isDebugEnabled(), @@ -650,6 +864,7 @@ class StatusRoutes { invalidIndicesRaw: invalidIndices, isSystemBusy: requestHandler.isSystemBusy, logMaxCount: limit, + maxContexts: config.maxContexts, rotationIndicesRaw: rotationIndices, streamingMode: this.serverSystem.streamingMode, usageCount, diff --git a/src/utils/ConfigLoader.js b/src/utils/ConfigLoader.js index 24ebf0f..18e71d1 100644 --- a/src/utils/ConfigLoader.js +++ b/src/utils/ConfigLoader.js @@ -30,6 +30,7 @@ class ConfigLoader { host: "0.0.0.0", httpPort: 7860, immediateSwitchStatusCodes: [429, 503], + maxContexts: 1, maxRetries: 3, retryDelay: 2000, streamingMode: "real", @@ -38,19 +39,36 @@ class ConfigLoader { }; // Environment variable overrides - if (process.env.PORT) config.httpPort = parseInt(process.env.PORT, 10) || config.httpPort; + if (process.env.PORT) { + const parsed = parseInt(process.env.PORT, 10); + config.httpPort = Number.isFinite(parsed) ? parsed : config.httpPort; + } if (process.env.HOST) config.host = process.env.HOST; if (process.env.STREAMING_MODE) config.streamingMode = process.env.STREAMING_MODE; - if (process.env.FAILURE_THRESHOLD) - config.failureThreshold = - Math.max(0, parseInt(process.env.FAILURE_THRESHOLD, 10)) ?? config.failureThreshold; - if (process.env.SWITCH_ON_USES) - config.switchOnUses = Math.max(0, parseInt(process.env.SWITCH_ON_USES, 10)) ?? config.switchOnUses; - if (process.env.MAX_RETRIES) - config.maxRetries = Math.max(1, parseInt(process.env.MAX_RETRIES, 10)) || config.maxRetries; - if (process.env.RETRY_DELAY) - config.retryDelay = Math.max(50, parseInt(process.env.RETRY_DELAY, 10)) || config.retryDelay; - if (process.env.WS_PORT) config.wsPort = parseInt(process.env.WS_PORT, 10) || config.wsPort; + if (process.env.FAILURE_THRESHOLD) { + const parsed = parseInt(process.env.FAILURE_THRESHOLD, 10); + config.failureThreshold = Number.isFinite(parsed) ? Math.max(0, parsed) : config.failureThreshold; + } + if (process.env.SWITCH_ON_USES) { + const parsed = parseInt(process.env.SWITCH_ON_USES, 10); + config.switchOnUses = Number.isFinite(parsed) ? Math.max(0, parsed) : config.switchOnUses; + } + if (process.env.MAX_RETRIES) { + const parsed = parseInt(process.env.MAX_RETRIES, 10); + config.maxRetries = Number.isFinite(parsed) ? Math.max(1, parsed) : config.maxRetries; + } + if (process.env.RETRY_DELAY) { + const parsed = parseInt(process.env.RETRY_DELAY, 10); + config.retryDelay = Number.isFinite(parsed) ? Math.max(50, parsed) : config.retryDelay; + } + if (process.env.WS_PORT) { + const parsed = parseInt(process.env.WS_PORT, 10); + config.wsPort = Number.isFinite(parsed) ? parsed : config.wsPort; + } + if (process.env.MAX_CONTEXTS) { + const parsed = parseInt(process.env.MAX_CONTEXTS, 10); + config.maxContexts = Number.isFinite(parsed) ? Math.max(0, parsed) : config.maxContexts; + } if (process.env.CAMOUFOX_EXECUTABLE_PATH) config.browserExecutablePath = process.env.CAMOUFOX_EXECUTABLE_PATH; if (process.env.API_KEYS) { config.apiKeys = process.env.API_KEYS.split(","); @@ -138,6 +156,7 @@ class ConfigLoader { this.logger.info(` Force Web Search: ${config.forceWebSearch}`); this.logger.info(` Force URL Context: ${config.forceUrlContext}`); this.logger.info(` Auto Update Auth: ${config.enableAuthUpdate}`); + this.logger.info(` Max Contexts: ${config.maxContexts === 0 ? "Unlimited" : config.maxContexts}`); this.logger.info( ` Usage-based Switch Threshold: ${ config.switchOnUses > 0 ? `Switch after every ${config.switchOnUses} requests` : "Disabled" diff --git a/src/utils/LoggingService.js b/src/utils/LoggingService.js index 0ae9a0d..21138c4 100644 --- a/src/utils/LoggingService.js +++ b/src/utils/LoggingService.js @@ -57,7 +57,7 @@ class LoggingService { */ setDisplayLimit(limit) { const newLimit = parseInt(limit, 10); - if (!isNaN(newLimit) && newLimit > 0) { + if (Number.isFinite(newLimit) && newLimit > 0) { this.displayLimit = newLimit; } } diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index 04684ae..3bb306a 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -347,6 +347,27 @@ {{ dedupedAvailableCount }} +
+ + + + + + {{ t("activeContexts") }} + + {{ activeContextsDisplay }} +
@@ -710,12 +731,17 @@ @@ -1470,6 +1496,7 @@ const { theme, setTheme } = useTheme(); const state = reactive({ accountDetails: [], + activeContextsCount: 0, apiKeySource: "", browserConnected: false, currentAuthIndex: -1, @@ -1489,6 +1516,7 @@ const state = reactive({ logMaxCount: 100, logs: t("loading"), logScrollTop: 0, + maxContexts: 1, releaseUrl: null, selectedAccounts: new Set(), // Selected account indices serviceConnected: false, @@ -1519,8 +1547,33 @@ const dedupedAvailableCount = computed(() => { return state.accountDetails.filter(acc => !acc.isDuplicate && !acc.isInvalid).length; }); +// Active contexts display (e.g., "1 / 3" or "1 / ∞") +const activeContextsDisplay = computed(() => { + const active = state.activeContextsCount; + const max = state.maxContexts; + return max === 0 ? `${active} / ∞` : `${active} / ${max}`; +}); + const isBusy = computed(() => state.isSwitchingAccount || state.isSystemBusy); +const formattedLogs = computed(() => { + if (!state.logs) return ""; + // Escape HTML first to prevent XSS (though logs should be safe, better safe than sorry) + let safeLogs = escapeHtml(state.logs); + + // Highlight [WARN] and [ERROR] at the start of lines with inline styles + safeLogs = safeLogs.replace( + /(^|\r?\n)(\[WARN\])(?=\s)/g, + '$1$2' + ); + safeLogs = safeLogs.replace( + /(^|\r?\n)(\[ERROR\])(?=\s)/g, + '$1$2' + ); + + return safeLogs; +}); + // Computed properties for batch selection const selectedCount = computed(() => state.selectedAccounts.size); const hasSelection = computed(() => state.selectedAccounts.size > 0); @@ -1567,7 +1620,14 @@ const batchDeleteAccounts = async () => { // Helper to perform batch delete const performBatchDelete = async (forceDelete = false) => { + const notification = ElNotification({ + duration: 0, + message: t("operationInProgress"), + title: t("warningTitle"), + type: "warning", + }); state.isSwitchingAccount = true; + let shouldUpdate = true; try { const res = await fetch("/api/accounts/batch", { body: JSON.stringify({ force: forceDelete, indices }), @@ -1577,7 +1637,9 @@ const batchDeleteAccounts = async () => { const data = await res.json(); if (res.status === 409 && data.requiresConfirmation) { + shouldUpdate = false; state.isSwitchingAccount = false; + notification.close(); ElMessageBox.confirm(t("warningDeleteCurrentAccount"), t("warningTitle"), { cancelButtonText: t("cancel"), confirmButtonText: t("ok"), @@ -1613,8 +1675,11 @@ const batchDeleteAccounts = async () => { } catch (err) { ElMessage.error(t("batchDeleteFailed", { error: err.message || err })); } finally { - state.isSwitchingAccount = false; - updateContent(); + if (shouldUpdate) { + state.isSwitchingAccount = false; + notification.close(); + updateContent(); + } } }; @@ -1790,6 +1855,12 @@ const deleteAccountByIndex = async targetIndex => { // Helper function to perform the actual deletion const performDelete = async (forceDelete = false) => { + const notification = ElNotification({ + duration: 0, + message: t("operationInProgress"), + title: t("warningTitle"), + type: "warning", + }); state.isSwitchingAccount = true; let shouldUpdate = true; try { @@ -1802,6 +1873,7 @@ const deleteAccountByIndex = async targetIndex => { if (res.status === 409 && data.requiresConfirmation) { shouldUpdate = false; state.isSwitchingAccount = false; + notification.close(); ElMessageBox.confirm(t("warningDeleteCurrentAccount"), t("warningTitle"), { cancelButtonText: t("cancel"), confirmButtonText: t("ok"), @@ -1828,6 +1900,7 @@ const deleteAccountByIndex = async targetIndex => { } finally { if (shouldUpdate) { state.isSwitchingAccount = false; + notification.close(); updateContent(); } } @@ -2114,6 +2187,8 @@ const updateStatus = data => { state.debugModeEnabled = isEnabled(data.status.debugMode); state.currentAuthIndex = data.status.currentAuthIndex; state.accountDetails = data.status.accountDetails || []; + state.activeContextsCount = data.status.activeContextsCount || 0; + state.maxContexts = data.status.maxContexts ?? 1; const validIndices = new Set(state.accountDetails.map(acc => acc.index)); for (const idx of state.selectedAccounts) { @@ -2189,192 +2264,285 @@ const handleFileUpload = async event => { // Reset input so same files can be selected again event.target.value = ""; - // Helper function to read file as ArrayBuffer (for zip) - const readFileAsArrayBuffer = file => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = e => resolve(e.target.result); - reader.onerror = () => reject(new Error(t("fileReadFailed"))); - reader.readAsArrayBuffer(file); - }); - - // Helper function to read file as text (for json) - const readFileAsText = file => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = e => resolve({ content: e.target.result, name: file.name }); - reader.onerror = () => reject(new Error(t("fileReadFailed"))); - reader.readAsText(file); - }); + // Show notification immediately + const notification = ElNotification({ + duration: 0, + message: t("operationInProgress"), + title: t("warningTitle"), + type: "warning", + }); - // Helper function to upload a single file - const uploadFile = async fileData => { - let parsed; - try { - parsed = JSON.parse(fileData.content); - } catch (err) { - return { error: t("invalidJson"), filename: fileData.name, success: false }; - } + // Set busy flag to disable UI during upload and rebalance + state.isSwitchingAccount = true; - try { - const res = await fetch("/api/files", { - body: JSON.stringify({ content: parsed }), - headers: { "Content-Type": "application/json" }, - method: "POST", + try { + // Helper function to read file as ArrayBuffer (for zip) + const readFileAsArrayBuffer = file => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = e => resolve(e.target.result); + reader.onerror = () => reject(new Error(t("fileReadFailed"))); + reader.readAsArrayBuffer(file); }); - if (res.ok) { - const data = await res.json(); - return { filename: data.filename || fileData.name, success: true }; - } + // Helper function to read file as text (for json) + const readFileAsText = file => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = e => resolve({ content: e.target.result, name: file.name }); + reader.onerror = () => reject(new Error(t("fileReadFailed"))); + reader.readAsText(file); + }); - let errorMsg = t("unknownError"); + // Helper function to upload a single file + const uploadFile = async fileData => { + let parsed; try { - const data = await res.json(); - if (data.error) errorMsg = data.error; - } catch (e) { - // Response is not JSON or cannot be parsed, fallback to status text or unknown error - if (res.statusText) { - errorMsg = `HTTP Error ${res.status}: ${res.statusText}`; - } else { - errorMsg = `HTTP Error ${res.status}`; - } + parsed = JSON.parse(fileData.content); + } catch (err) { + return { error: t("invalidJson"), filename: fileData.name, success: false }; } - return { error: errorMsg, filename: fileData.name, success: false }; - } catch (err) { - // Network or other fetch errors - return { error: err.message || t("networkError"), filename: fileData.name, success: false }; - } - }; - // Collect all JSON files to upload (including extracted from zip) - const jsonFilesToUpload = []; - const extractErrors = []; + try { + const res = await fetch("/api/files", { + body: JSON.stringify({ content: parsed }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); - for (const file of files) { - const lowerName = file.name.toLowerCase(); + if (res.ok) { + const data = await res.json(); + return { filename: data.filename || fileData.name, success: true }; + } - if (lowerName.endsWith(".zip")) { - // Extract JSON files from zip - try { - let arrayBuffer; + let errorMsg = t("unknownError"); try { - arrayBuffer = await readFileAsArrayBuffer(file); - } catch (readErr) { - extractErrors.push({ local: file.name, reason: readErr.message || t("fileReadFailed") }); - continue; // Skip zip processing if read failed + const data = await res.json(); + if (data.error) errorMsg = data.error; + } catch (e) { + // Response is not JSON or cannot be parsed, fallback to status text or unknown error + if (res.statusText) { + errorMsg = `HTTP Error ${res.status}: ${res.statusText}`; + } else { + errorMsg = `HTTP Error ${res.status}`; + } } + return { error: errorMsg, filename: fileData.name, success: false }; + } catch (err) { + // Network or other fetch errors + return { error: err.message || t("networkError"), filename: fileData.name, success: false }; + } + }; - const zip = await JSZip.loadAsync(arrayBuffer); - const zipEntries = Object.keys(zip.files); + // Collect all JSON files to upload (including extracted from zip) + const jsonFilesToUpload = []; + const extractErrors = []; - let foundJsonInZip = false; - for (const entryName of zipEntries) { - const entry = zip.files[entryName]; - // Skip directories and non-json files - if (entry.dir || !entryName.toLowerCase().endsWith(".json")) continue; + for (const file of files) { + const lowerName = file.name.toLowerCase(); - foundJsonInZip = true; + if (lowerName.endsWith(".zip")) { + // Extract JSON files from zip + try { + let arrayBuffer; try { - const content = await entry.async("string"); - // Use format: zipName/entryName for display - const displayName = `${file.name}/${entryName}`; - jsonFilesToUpload.push({ content, name: displayName }); - } catch (err) { - extractErrors.push({ - local: `${file.name}/${entryName}`, - reason: t("zipExtractFailed"), // Prefer localized generic error for extraction issues - }); + arrayBuffer = await readFileAsArrayBuffer(file); + } catch (readErr) { + extractErrors.push({ local: file.name, reason: readErr.message || t("fileReadFailed") }); + continue; // Skip zip processing if read failed } - } - if (!foundJsonInZip) { - extractErrors.push({ local: file.name, reason: t("zipNoJsonFiles") }); + const zip = await JSZip.loadAsync(arrayBuffer); + const zipEntries = Object.keys(zip.files); + + let foundJsonInZip = false; + for (const entryName of zipEntries) { + const entry = zip.files[entryName]; + // Skip directories and non-json files + if (entry.dir || !entryName.toLowerCase().endsWith(".json")) continue; + + foundJsonInZip = true; + try { + const content = await entry.async("string"); + // Use format: zipName/entryName for display + const displayName = `${file.name}/${entryName}`; + jsonFilesToUpload.push({ content, name: displayName }); + } catch (err) { + extractErrors.push({ + local: `${file.name}/${entryName}`, + reason: t("zipExtractFailed"), // Prefer localized generic error for extraction issues + }); + } + } + + if (!foundJsonInZip) { + extractErrors.push({ local: file.name, reason: t("zipNoJsonFiles") }); + } + } catch (err) { + // Catch any other errors during zip processing (e.g. invalid zip format) + extractErrors.push({ local: file.name, reason: t("zipExtractFailed") }); + } + } else if (lowerName.endsWith(".json")) { + // Regular JSON file + try { + const fileData = await readFileAsText(file); + jsonFilesToUpload.push(fileData); + } catch (err) { + extractErrors.push({ local: file.name, reason: err.message || t("fileReadFailed") }); } - } catch (err) { - // Catch any other errors during zip processing (e.g. invalid zip format) - extractErrors.push({ local: file.name, reason: t("zipExtractFailed") }); - } - } else if (lowerName.endsWith(".json")) { - // Regular JSON file - try { - const fileData = await readFileAsText(file); - jsonFilesToUpload.push(fileData); - } catch (err) { - extractErrors.push({ local: file.name, reason: err.message || t("fileReadFailed") }); } } - } - // Check if we have anything to process - if (jsonFilesToUpload.length === 0 && extractErrors.length === 0) { - ElMessage.warning(t("noSupportedFiles")); - return; - } + // Check if we have anything to process + if (jsonFilesToUpload.length === 0 && extractErrors.length === 0) { + ElMessage.warning(t("noSupportedFiles")); + return; + } + + // Upload all collected JSON files + const successFiles = []; + const failedFiles = [...extractErrors]; + + // Use batch upload API if multiple files, otherwise use single file upload + if (jsonFilesToUpload.length > 1) { + // Batch upload + const parsedFiles = []; + const parseErrors = []; - // Upload all collected JSON files - const successFiles = []; - const failedFiles = [...extractErrors]; + // Parse all files first + for (const fileData of jsonFilesToUpload) { + try { + const parsed = JSON.parse(fileData.content); + parsedFiles.push({ content: parsed, name: fileData.name }); + } catch (err) { + parseErrors.push({ local: fileData.name, reason: t("invalidJson") }); + } + } + + failedFiles.push(...parseErrors); - for (const fileData of jsonFilesToUpload) { - const result = await uploadFile(fileData); - if (result.success) { - successFiles.push({ local: fileData.name, saved: result.filename }); + // Upload all valid files in one batch + if (parsedFiles.length > 0) { + try { + const res = await fetch("/api/files/batch", { + body: JSON.stringify({ files: parsedFiles.map(f => f.content) }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + + if (res.ok || res.status === 207) { + const data = await res.json(); + // Process results array with proper index mapping + if (data.results && Array.isArray(data.results)) { + for (const result of data.results) { + const originalFile = parsedFiles[result.index]; + if (result.success) { + successFiles.push({ + local: originalFile?.name || `file-${result.index}`, + saved: result.filename || originalFile?.name || `file-${result.index}`, + }); + } else { + failedFiles.push({ + local: originalFile?.name || `file-${result.index}`, + reason: result.error || t("unknownError"), + }); + } + } + } + } else { + // Batch upload failed completely + let errorMsg = t("unknownError"); + try { + const data = await res.json(); + if (data.error) errorMsg = data.error; + } catch (e) { + if (res.statusText) { + errorMsg = `HTTP Error ${res.status}: ${res.statusText}`; + } else { + errorMsg = `HTTP Error ${res.status}`; + } + } + // Mark all parsed files as failed + for (const fileData of parsedFiles) { + failedFiles.push({ local: fileData.name, reason: errorMsg }); + } + } + } catch (error) { + // Network or other error - mark all parsed files as failed + // (parseErrors are already in failedFiles) + for (const fileData of parsedFiles) { + failedFiles.push({ local: fileData.name, reason: error.message || t("networkError") }); + } + } + } } else { - failedFiles.push({ local: fileData.name, reason: result.error }); + // Single file upload (use existing logic) + for (const fileData of jsonFilesToUpload) { + const result = await uploadFile(fileData); + if (result.success) { + successFiles.push({ local: fileData.name, saved: result.filename }); + } else { + failedFiles.push({ local: fileData.name, reason: result.error }); + } + } } - } - // Build notification message with file details (scrollable container) - let messageHtml = '
'; + // Build notification message with file details (scrollable container) + let messageHtml = '
'; - if (successFiles.length > 0) { - messageHtml += `
${t("fileUploadBatchSuccess")} (${successFiles.length}):
`; - messageHtml += '
    '; - for (const f of successFiles) { - messageHtml += `
  • ${escapeHtml(f.local)} → ${escapeHtml(f.saved)}
  • `; + if (successFiles.length > 0) { + messageHtml += `
    ${t("fileUploadBatchSuccess")} (${successFiles.length}):
    `; + messageHtml += '
      '; + for (const f of successFiles) { + messageHtml += `
    • ${escapeHtml(f.local)} → ${escapeHtml(f.saved)}
    • `; + } + messageHtml += "
    "; } - messageHtml += "
"; - } - if (failedFiles.length > 0) { - messageHtml += `
${t("fileUploadBatchFailed")} (${failedFiles.length}):
`; - messageHtml += '
    '; - for (const f of failedFiles) { - messageHtml += `
  • ${escapeHtml(f.local)}: ${escapeHtml(f.reason)}
  • `; + if (failedFiles.length > 0) { + messageHtml += `
    ${t("fileUploadBatchFailed")} (${failedFiles.length}):
    `; + messageHtml += '
      '; + for (const f of failedFiles) { + messageHtml += `
    • ${escapeHtml(f.local)}: ${escapeHtml(f.reason)}
    • `; + } + messageHtml += "
    "; } - messageHtml += "
"; - } - messageHtml += "
"; + messageHtml += "
"; - // Determine notification type - let notifyType = "success"; - if (failedFiles.length > 0 && successFiles.length === 0) { - notifyType = "error"; - } else if (failedFiles.length > 0) { - notifyType = "warning"; - } + // Determine notification type + let notifyType = "success"; + if (failedFiles.length > 0 && successFiles.length === 0) { + notifyType = "error"; + } else if (failedFiles.length > 0) { + notifyType = "warning"; + } - // Build title with counts - const totalProcessed = successFiles.length + failedFiles.length; - let notifyTitle; - if (totalProcessed === 1) { - notifyTitle = t("fileUploadComplete"); - } else { - notifyTitle = `${t("fileUploadBatchResult")} (✓${successFiles.length} ✗${failedFiles.length})`; - } + // Build title with counts + const totalProcessed = successFiles.length + failedFiles.length; + let notifyTitle; + if (totalProcessed === 1) { + notifyTitle = t("fileUploadComplete"); + } else { + notifyTitle = `${t("fileUploadBatchResult")} (✓${successFiles.length} ✗${failedFiles.length})`; + } - ElNotification({ - dangerouslyUseHTMLString: true, - duration: 0, - message: messageHtml, - position: "top-right", - title: notifyTitle, - type: notifyType, - }); + // Show result notification (keep open) + ElNotification({ + dangerouslyUseHTMLString: true, + duration: 0, + message: messageHtml, + position: "top-right", + title: notifyTitle, + type: notifyType, + }); - updateContent(); + updateContent(); + } finally { + // Always close notification and reset busy flag + notification.close(); + state.isSwitchingAccount = false; + } }; // Download account by index @@ -2995,6 +3163,17 @@ watchEffect(() => { cursor: not-allowed; } + &.btn-switch.is-fast { + color: #f59e0b; + border-color: #fcd34d; + } + + &.btn-switch.is-fast:hover:not(:disabled) { + border-color: @success-color; + color: @success-color; + background-color: @background-white; + } + &:disabled { opacity: 0.4; cursor: not-allowed; diff --git a/ui/locales/en.json b/ui/locales/en.json index fab1881..a04d8ea 100644 --- a/ui/locales/en.json +++ b/ui/locales/en.json @@ -16,6 +16,7 @@ "accountSwitchSuccess": "Switch successful! Account #{newIndex} activated.", "accountSwitchSuccessNext": "Switch successful! Switched to account #{newIndex}.", "actionsPanel": "Settings", + "activeContexts": "Concurrent Logins", "alreadyCurrentAccount": "This is already the current active account.", "apiKey": "API Key", "apiKeyPlaceholder": "API Key", @@ -105,6 +106,7 @@ "expand": "Expand", "fake": "Fake", "false": "Disabled", + "fastSwitch": "Fast Switch Account", "fileReadFailed": "Failed to read file", "fileUploadBatchFailed": "Failed files", "fileUploadBatchResult": "Batch upload complete", @@ -178,7 +180,7 @@ "usageCount": "Usage Count", "versionInfo": "Version Info", "viewRelease": "View Release", - "warningDeleteCurrentAccount": "You are about to delete the currently active account. The browser connection will be closed and you will need to switch to another account. Continue?", + "warningDeleteCurrentAccount": "You are about to delete the currently active account. After deletion, there will be no active account and you will need to manually switch to another account. Continue?", "warningTitle": "Warning", "zipExtractFailed": "Extract failed", "zipNoJsonFiles": "No JSON files in archive" diff --git a/ui/locales/zh.json b/ui/locales/zh.json index 4f59c17..cb25126 100644 --- a/ui/locales/zh.json +++ b/ui/locales/zh.json @@ -16,6 +16,7 @@ "accountSwitchSuccess": "切换成功!账号 {newIndex} 已激活。", "accountSwitchSuccessNext": "切换成功!已切换到账号 {newIndex}。", "actionsPanel": "设置", + "activeContexts": "同时登录的账号", "alreadyCurrentAccount": "当前已是该账号,无需切换。", "apiKey": "API 密钥", "apiKeyPlaceholder": "API 密钥", @@ -105,6 +106,7 @@ "expand": "展开", "fake": "假", "false": "已禁用", + "fastSwitch": "快速切换账号", "fileReadFailed": "文件读取失败", "fileUploadBatchFailed": "失败文件", "fileUploadBatchResult": "批量上传完成", @@ -178,7 +180,7 @@ "usageCount": "使用次数", "versionInfo": "版本信息", "viewRelease": "查看版本发布", - "warningDeleteCurrentAccount": "您即将删除当前正在使用的账号。浏览器连接将被关闭,您需要切换到其他账号。是否继续?", + "warningDeleteCurrentAccount": "您即将删除当前正在使用的账号。删除后将进入无活动账号状态,需要手动切换到其他账号。是否继续?", "warningTitle": "警告", "zipExtractFailed": "解压失败", "zipNoJsonFiles": "压缩包内无 JSON 文件"