From 136c41423597db8ab8e212121cf79663b28ca9a3 Mon Sep 17 00:00:00 2001 From: MYCF711 Date: Fri, 13 Nov 2026 13:35:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B5=8F=E8=A7=88=E5=99=A8=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=BC=98=E5=85=88=E9=BB=98=E8=AE=A4=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E5=B9=B6=E8=A7=84=E9=81=BF=E5=A4=B8=E5=85=8B=20CDP=20?= =?UTF-8?q?=E7=9B=AE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser-login.mjs | 274 ++++++++++++++++++++++++++++------- src/session-login.mjs | 191 ++++++++++++++++++++---- tests/browser-login.test.mjs | 91 +++++++++++- tests/session-login.test.mjs | 230 +++++++++++++++++++++++++++++ 4 files changed, 702 insertions(+), 84 deletions(-) create mode 100644 tests/session-login.test.mjs diff --git a/src/browser-login.mjs b/src/browser-login.mjs index 03ac038..23dd3f3 100644 --- a/src/browser-login.mjs +++ b/src/browser-login.mjs @@ -6,7 +6,8 @@ // 设计取舍(来自 WPS 灵犀 0.4.11 的实测实现,MIT;见 NOTICE.md): // · 任意 Chromium 内核浏览器都行(Chrome / Edge / 360 / QQ / Brave / Vivaldi…), // 不止 Chrome;国产浏览器在 Windows 上很常见,漏了它们这条登录路对用户就不存在。 -// · Windows 的候选顺序:Chrome → 国产 Chromium → Edge 最后(Edge 策略最多、最容易被组织禁用)。 +// · Windows 优先读系统默认浏览器,但只接受 CDP 已验证较稳定的 Chrome / Edge / +// Chromium / Brave / Vivaldi / Opera;其余候选里 Quark 最后兜底(它的内部页会扰动 CDP 目标)。 // · 用一次性 user-data-dir,不碰用户日常浏览器配置;登录态只在本次窗口里。 // · 退出码不可靠:浏览器可能把调试参数吞掉,所以以「CDP 端口能不能连上」为准。 // @@ -21,24 +22,55 @@ import { join } from "node:path" import { createServer } from "node:net" import { execFileSync } from "node:child_process" +const WIN_CDP_STABLE = new Map([ + ["chrome.exe", "Chrome"], + ["msedge.exe", "Edge"], + ["chromium.exe", "Chromium"], + ["brave.exe", "Brave"], + ["vivaldi.exe", "Vivaldi"], + ["opera.exe", "Opera"], +]) + const WIN_EXES = [ "chrome.exe", + "msedge.exe", + "chromium.exe", + "Brave.exe", + "vivaldi.exe", + "opera.exe", "360ChromeX.exe", "360chrome.exe", "360se.exe", "QQBrowser.exe", - "Brave.exe", - "vivaldi.exe", - "opera.exe", "SogouExplorer.exe", "2345Explorer.exe", "liebao.exe", "Maxthon.exe", "UCBrowser.exe", "Quark.exe", - "msedge.exe", ] +/** + * 查一条注册表字符串;查不到返回空串。 + * stderr 必须丢弃:逐候选探测时「键不存在」是正常分支,不该刷满 magpie 的 app.log。 + */ +export function registryQuery(key, value, { exec = execFileSync, platform = process.platform } = {}) { + if (platform !== "win32") return "" + try { + const args = ["query", key, ...(value ? ["/v", value] : ["/ve"])] + const out = exec("reg", args, { + encoding: "utf8", + timeout: 5000, + windowsHide: true, + stdio: ["ignore", "pipe", "ignore"], + }) + const line = out.split(/\r?\n/).find((l) => /REG_SZ\s+/i.test(l) && (!value || l.includes(value))) + return line?.split(/\s+REG_SZ\s+/i)[1]?.trim() ?? "" + } catch { + return "" + } +} + /** Windows 注册表 App Paths 查浏览器;查不到返回空串。 */ export function registryAppPath(exe) { if (process.platform !== "win32") return "" @@ -47,23 +79,52 @@ export function registryAppPath(exe) { `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${exe}`, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${exe}`, ]) { - try { - const out = execFileSync("reg", ["query", `${root}\\${tail}`, "/ve"], { encoding: "utf8", timeout: 5000 }) - const line = out.split("\n").find((l) => /REG_SZ/i.test(l)) - const path = line?.split("REG_SZ")[1]?.trim() - if (path && existsSync(path)) return path - } catch {} + const path = registryQuery(`${root}\\${tail}`) + if (path && existsSync(path)) return path } } return "" } +/** 从 `shell\open\command` 的默认命令里抽出 exe 路径(兼容带空格与带引号路径)。 */ +function executableFromCommand(command) { + const match = command.trim().match(/^"([^"]+\.exe)"|^(.+?\.exe)(?:\s|$)/i) + return (match?.[1] ?? match?.[2] ?? "").trim() +} + +/** + * Windows 系统默认浏览器:UserChoice → Classes\\shell\open\command → exe。 + * + * 只返回 CDP 稳定白名单。夸克等浏览器即便被设为默认,也走后面的普通候选顺序, + * 避免「能开调试端口,但内部页回收登录目标」的浏览器被第一顺位选中。 + */ +export function systemDefaultBrowser({ platform = process.platform, query = registryQuery } = {}) { + if (platform !== "win32") return null + const progId = query("HKCU\\SOFTWARE\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice", "ProgId") + if (!progId) return null + for (const root of ["HKCU\\SOFTWARE\\Classes", "HKLM\\SOFTWARE\\Classes", "HKCR"]) { + const command = query(`${root}\\${progId}\\shell\\open\\command`) + if (!command) continue + const path = executableFromCommand(command) + const exe = path.split(/[\\/]/).pop()?.toLowerCase() ?? "" + const name = WIN_CDP_STABLE.get(exe) + if (name) return { name, path } + } + return null +} + +/** 从自定义 exe 路径取一个可显示的名字。 */ +function browserNameFromPath(path) { + const filename = path.split(/[\\/]/).pop() ?? path + return WIN_CDP_STABLE.get(filename.toLowerCase()) ?? filename.replace(/\.exe$/i, "") +} + /** * 候选浏览器(按优先级)。不做存在性检查,方便测试与排序。 - * @param {{platform?:string, env?:Record, lookup?:(exe:string)=>string}} [o] + * @param {{platform?:string, env?:Record, lookup?:(exe:string)=>string, defaultBrowser?:{name:string,path:string}|null}} [o] * @returns {{name:string, path:string}[]} */ -export function browserCandidates({ platform = process.platform, env = process.env, lookup = registryAppPath } = {}) { +export function browserCandidates({ platform = process.platform, env = process.env, lookup = registryAppPath, defaultBrowser = null } = {}) { const h = env.HOME || env.USERPROFILE || "" const local = env.LOCALAPPDATA || "" const roaming = env.APPDATA || "" @@ -72,55 +133,72 @@ export function browserCandidates({ platform = process.platform, env = process.e const add = (list, name, path) => { if (path) list.push({ name, path }) } + const preferred = [] + const override = env.MAGPIE_BROWSER?.trim().replace(/^"([\s\S]*)"$/g, "$1") ?? "" + if (override) add(preferred, browserNameFromPath(override), override) + if (defaultBrowser?.path) add(preferred, defaultBrowser.name, defaultBrowser.path) if (platform === "win32") { const list = [] + const quarkFallback = [] for (const exe of WIN_EXES) { const fromRegistry = lookup(exe) - if (fromRegistry) add(list, exe.replace(/\.exe$/i, ""), fromRegistry) + // 仅夸克的注册表命中延后;否则会抢在标准路径里的 Chrome/Edge 前。 + // 名字统一用白名单 canonical 名(Chrome / Edge…),不要带注册表 exe 的小写形态。 + if (fromRegistry) { + const name = WIN_CDP_STABLE.get(exe.toLowerCase()) ?? exe.replace(/\.exe$/i, "") + add(exe.toLowerCase() === "quark.exe" ? quarkFallback : list, name, fromRegistry) + } } add(list, "Chrome", join(pf, "Google", "Chrome", "Application", "chrome.exe")) add(list, "Chrome", join(pf86, "Google", "Chrome", "Application", "chrome.exe")) add(list, "Chrome", join(local, "Google", "Chrome", "Application", "chrome.exe")) add(list, "Chromium", join(local, "Chromium", "Application", "chrome.exe")) add(list, "Chromium", join(pf, "Chromium", "Application", "chrome.exe")) - add(list, "360极速浏览器X", join(local, "360ChromeX", "Chrome", "Application", "360ChromeX.exe")) - add(list, "360浏览器", join(local, "360Chrome", "Chrome", "Application", "360chrome.exe")) - add(list, "360安全浏览器", join(roaming, "secoresdk", "360se6", "Application", "360se.exe")) - add(list, "QQ浏览器", join(local, "Tencent", "QQBrowser", "Application", "QQBrowser.exe")) add(list, "Brave", join(pf, "BraveSoftware", "Brave-Browser", "Application", "brave.exe")) add(list, "Brave", join(local, "BraveSoftware", "Brave-Browser", "Application", "brave.exe")) add(list, "Vivaldi", join(local, "Vivaldi", "Application", "vivaldi.exe")) add(list, "Opera", join(local, "Programs", "Opera", "opera.exe")) add(list, "Opera", join(roaming, "Opera Software", "Opera Stable", "opera.exe")) + add(list, "Edge", join(pf86, "Microsoft", "Edge", "Application", "msedge.exe")) + add(list, "Edge", join(pf, "Microsoft", "Edge", "Application", "msedge.exe")) + add(list, "360极速浏览器X", join(local, "360ChromeX", "Chrome", "Application", "360ChromeX.exe")) + add(list, "360浏览器", join(local, "360Chrome", "Chrome", "Application", "360chrome.exe")) + add(list, "360安全浏览器", join(roaming, "secoresdk", "360se6", "Application", "360se.exe")) + add(list, "QQ浏览器", join(local, "Tencent", "QQBrowser", "Application", "QQBrowser.exe")) add(list, "搜狗浏览器", join(local, "SogouExplorer", "SogouExplorer.exe")) add(list, "2345浏览器", join(local, "2345Explorer", "2345Explorer.exe")) add(list, "猎豹浏览器", join(local, "liebao", "liebao.exe")) add(list, "Maxthon", join(roaming, "Maxthon3", "Bin", "Maxthon.exe")) add(list, "UC浏览器", join(local, "UCBrowser", "Application", "UCBrowser.exe")) + // 夸克最后兜底:它能开 CDP 端口,但内部页会注入 /json/list,并可能回收登录 page 目标。 add(list, "夸克浏览器", join(local, "Quark", "Quark.exe")) - add(list, "Edge", join(pf86, "Microsoft", "Edge", "Application", "msedge.exe")) - add(list, "Edge", join(pf, "Microsoft", "Edge", "Application", "msedge.exe")) - return dedupe(list) + return dedupe([...preferred, ...list, ...quarkFallback]) } if (platform === "darwin") { - return [ - ["Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"], - ["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"], - ["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"], - ["Vivaldi", "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi"], - ["Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"], - ].map(([name, path]) => ({ name, path })) + return dedupe([ + ...preferred, + ...[ + ["Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"], + ["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"], + ["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"], + ["Vivaldi", "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi"], + ["Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"], + ].map(([name, path]) => ({ name, path })), + ]) } - return [ - ["Chrome", join(h, ".config/google-chrome/chrome")], - ["Chromium", join(h, ".config/chromium/chrome")], - ["Brave", join(h, ".config/BraveSoftware/Brave-Browser/brave")], - ["Vivaldi", join(h, ".config/vivaldi/vivaldi")], - ["Edge", join(h, ".config/microsoft-edge/msedge")], - ].map(([name, path]) => ({ name, path })) + return dedupe([ + ...preferred, + ...[ + ["Chrome", join(h, ".config/google-chrome/chrome")], + ["Chromium", join(h, ".config/chromium/chrome")], + ["Brave", join(h, ".config/BraveSoftware/Brave-Browser/brave")], + ["Vivaldi", join(h, ".config/vivaldi/vivaldi")], + ["Edge", join(h, ".config/microsoft-edge/msedge")], + ].map(([name, path]) => ({ name, path })), + ]) } function dedupe(list) { @@ -134,8 +212,12 @@ function dedupe(list) { } /** 第一个真实存在的候选;没有返回 null。 */ -export function findBrowser({ platform = process.platform, env = process.env, exists = existsSync, lookup = registryAppPath } = {}) { - return browserCandidates({ platform, env, lookup }).find((b) => exists(b.path)) ?? null +export function findBrowser({ platform = process.platform, env = process.env, exists = existsSync, lookup = registryAppPath, defaultBrowser, query = registryQuery } = {}) { + const preferDefault = env.MAGPIE_BROWSER_PREFER_DEFAULT !== "0" && !env.MAGPIE_BROWSER?.trim() + const resolvedDefault = preferDefault + ? (defaultBrowser === undefined ? systemDefaultBrowser({ platform, query }) : defaultBrowser) + : null + return browserCandidates({ platform, env, lookup, defaultBrowser: resolvedDefault }).find((b) => exists(b.path)) ?? null } /** 从 CDP 的 cookie 列表里挑需要的名字;返回 header 字符串与命中的键。 */ @@ -178,33 +260,116 @@ async function getJson(url, timeoutMs = 5000) { return res.json() } -function cdpSend(ws, id, method, params = {}) { +/** 建连也有 finish 语义:close-only、构造器异常和超时都不能把登录流程永久挂住。 */ +function connectWebSocket(pageWsUrl, { timeoutMs = 10_000, create = (url) => new WebSocket(url) } = {}) { + return new Promise((resolve, reject) => { + let ws + let settled = false + const cleanup = () => { + clearTimeout(timer) + ws?.removeEventListener("open", onOpen) + ws?.removeEventListener("close", onClose) + ws?.removeEventListener("error", onError) + } + const finish = (callback, value) => { + if (settled) return + settled = true + cleanup() + callback(value) + } + const fail = (error) => { + finish(reject, error) + try { ws?.close() } catch {} + } + const onOpen = () => finish(resolve, ws) + const onClose = () => fail(new Error("CDP WebSocket 握手期间关闭")) + const onError = () => fail(new Error("CDP WebSocket 连不上")) + const timer = setTimeout(() => fail(new Error("CDP WebSocket 握手超时")), timeoutMs) + try { + ws = create(pageWsUrl) + ws.addEventListener("open", onOpen) + ws.addEventListener("close", onClose) + ws.addEventListener("error", onError) + } catch (error) { + fail(error) + } + }) +} + +/** + * 从 `/json/list` 选登录 page:优先登录 URL 的 origin,找不到才退回第一个 page。 + * 不能直接取首位 —— 夸克等浏览器会把 `uccd://…` 内部页插到登录页前面。 + * `fallback:false` 用于断开诊断:没有同源页就如实报目标消失,不把内部页当登录页。 + */ +export function selectPageTarget(pages, url, { fallback = true } = {}) { + const candidates = (pages ?? []).filter((p) => p?.type === "page" && p.webSocketDebuggerUrl) + if (url === "about:blank") { + const match = candidates.find((p) => p.url === "about:blank") + return match ?? (fallback ? candidates[0] : undefined) + } + let origin = "" + try { + const parsed = new URL(url) + if (parsed.protocol === "http:" || parsed.protocol === "https:") origin = parsed.origin + } catch {} + if (origin) { + const match = candidates.find((p) => { + try { + return new URL(p.url).origin === origin + } catch { + return false + } + }) + if (match) return match + } + return fallback ? candidates[0] : undefined +} + +function cdpSend(ws, id, method, params = {}, timeoutMs = 10_000) { return new Promise((resolve, reject) => { + let settled = false + const cleanup = () => { + clearTimeout(timer) + ws.removeEventListener("message", onMessage) + ws.removeEventListener("close", onClose) + ws.removeEventListener("error", onError) + } + const finish = (callback, value) => { + if (settled) return + settled = true + cleanup() + callback(value) + } const onMessage = (event) => { let msg try { msg = JSON.parse(String(event.data)) } catch { return } if (msg.id !== id) return - ws.removeEventListener("message", onMessage) - if (msg.error) reject(new Error(msg.error.message || "CDP error")) - else resolve(msg.result) + if (msg.error) finish(reject, new Error(msg.error.message || "CDP error")) + else finish(resolve, msg.result) } + const onClose = () => finish(reject, new Error(`CDP WebSocket 已关闭(${method})`)) + const onError = () => finish(reject, new Error(`CDP WebSocket 错误(${method})`)) + const timer = setTimeout(() => finish(reject, new Error(`CDP 命令超时(${method})`)), timeoutMs) ws.addEventListener("message", onMessage) - ws.send(JSON.stringify({ id, method, params })) + ws.addEventListener("close", onClose) + ws.addEventListener("error", onError) + try { + ws.send(JSON.stringify({ id, method, params })) + } catch (error) { + finish(reject, error) + } }) } async function cdpCookies(pageWsUrl) { - const ws = new WebSocket(pageWsUrl) - await new Promise((resolve, reject) => { - ws.addEventListener("open", resolve, { once: true }) - ws.addEventListener("error", () => reject(new Error("CDP WebSocket 连不上")), { once: true }) - }) + let ws try { + ws = await connectWebSocket(pageWsUrl) await cdpSend(ws, 1, "Network.enable") const result = await cdpSend(ws, 2, "Network.getAllCookies") return result?.cookies ?? [] } finally { - try { ws.close() } catch {} + try { ws?.close() } catch {} } } @@ -232,6 +397,13 @@ export async function chromiumLogin({ browser, url, cookieNames = [], timeoutMs "--disable-background-networking", url, ], { stdio: "ignore", windowsHide: false }) + let spawnError = null + child.once("error", (error) => { + spawnError = error + }) + const assertSpawned = () => { + if (spawnError) throw new Error(`受控浏览器启动失败:${spawnError.message}`) + } const cleanup = () => { try { child.kill() } catch {} @@ -242,9 +414,10 @@ export async function chromiumLogin({ browser, url, cookieNames = [], timeoutMs let pageWs = "" while (Date.now() < deadline) { if (signal?.aborted) throw new Error("登录已取消") + assertSpawned() try { const pages = await getJson(`http://127.0.0.1:${port}/json/list`, 1000) - pageWs = pages.find((p) => p.type === "page" && p.webSocketDebuggerUrl)?.webSocketDebuggerUrl ?? "" + pageWs = selectPageTarget(pages, url)?.webSocketDebuggerUrl ?? "" if (pageWs) break } catch {} await new Promise((r) => setTimeout(r, 300)) @@ -253,6 +426,7 @@ export async function chromiumLogin({ browser, url, cookieNames = [], timeoutMs while (Date.now() < deadline) { if (signal?.aborted) throw new Error("登录已取消") + assertSpawned() const cookies = await cdpCookies(pageWs).catch(() => []) const { header, found } = selectCookies(cookies, cookieNames) const ok = cookieNames.length === 0 ? header.length > 0 : cookieNames.every((n) => found[n]) @@ -265,4 +439,4 @@ export async function chromiumLogin({ browser, url, cookieNames = [], timeoutMs } } -export const _internal = { WIN_EXES, freePort, cdpCookies } +export const _internal = { WIN_EXES, freePort, cdpCookies, cdpSend, connectWebSocket } diff --git a/src/session-login.mjs b/src/session-login.mjs index ed489c4..98c02fd 100644 --- a/src/session-login.mjs +++ b/src/session-login.mjs @@ -22,7 +22,7 @@ import { createServer } from "node:net" import { homedir, tmpdir } from "node:os" import { join } from "node:path" -import { findBrowser } from "./browser-login.mjs" +import { findBrowser, selectPageTarget } from "./browser-login.mjs" /** 默认等用户登录的上限(毫秒)。 */ export const BROWSER_LOGIN_TIMEOUT_MS = 240_000 @@ -47,8 +47,57 @@ async function getJson(url, timeoutMs = 1_500) { return response.json() } -function cdpSend(ws, id, method, params = {}) { +/** 建连也有 finish 语义:close-only、构造器异常和超时都不能把登录流程永久挂住。 */ +function connectWebSocket(pageWsUrl, { timeoutMs = 10_000, create = (url) => new WebSocket(url) } = {}) { return new Promise((resolve, reject) => { + let ws + let settled = false + const cleanup = () => { + clearTimeout(timer) + ws?.removeEventListener("open", onOpen) + ws?.removeEventListener("close", onClose) + ws?.removeEventListener("error", onError) + } + const finish = (callback, value) => { + if (settled) return + settled = true + cleanup() + callback(value) + } + const fail = (error) => { + finish(reject, error) + try { ws?.close() } catch {} + } + const onOpen = () => finish(resolve, ws) + const onClose = () => fail(new Error("CDP WebSocket 握手期间关闭")) + const onError = () => fail(new Error("CDP WebSocket 连不上")) + const timer = setTimeout(() => fail(new Error("CDP WebSocket 握手超时")), timeoutMs) + try { + ws = create(pageWsUrl) + ws.addEventListener("open", onOpen) + ws.addEventListener("close", onClose) + ws.addEventListener("error", onError) + } catch (error) { + fail(error) + } + }) +} + +function cdpSend(ws, id, method, params = {}, timeoutMs = 10_000) { + return new Promise((resolve, reject) => { + let settled = false + const cleanup = () => { + clearTimeout(timer) + ws.removeEventListener("message", onMessage) + ws.removeEventListener("close", onClose) + ws.removeEventListener("error", onError) + } + const finish = (callback, value) => { + if (settled) return + settled = true + cleanup() + callback(value) + } const onMessage = (event) => { let message try { @@ -57,26 +106,31 @@ function cdpSend(ws, id, method, params = {}) { return } if (message.id !== id) return - ws.removeEventListener("message", onMessage) - if (message.error) reject(new Error(message.error.message || "CDP error")) - else resolve(message.result) + if (message.error) finish(reject, new Error(message.error.message || "CDP error")) + else finish(resolve, message.result) } + const onClose = () => finish(reject, new Error(`CDP WebSocket 已关闭(${method})`)) + const onError = () => finish(reject, new Error(`CDP WebSocket 错误(${method})`)) + const timer = setTimeout(() => finish(reject, new Error(`CDP 命令超时(${method})`)), timeoutMs) ws.addEventListener("message", onMessage) - ws.send(JSON.stringify({ id, method, params })) + ws.addEventListener("close", onClose) + ws.addEventListener("error", onError) + try { + ws.send(JSON.stringify({ id, method, params })) + } catch (error) { + finish(reject, error) + } }) } async function cdpCookies(pageWsUrl) { - const ws = new WebSocket(pageWsUrl) - await new Promise((resolve, reject) => { - ws.addEventListener("open", resolve, { once: true }) - ws.addEventListener("error", () => reject(new Error("CDP WebSocket 连不上")), { once: true }) - }) + let ws try { + ws = await connectWebSocket(pageWsUrl) return await cookiesOn(ws) } finally { try { - ws.close() + ws?.close() } catch {} } } @@ -114,6 +168,44 @@ async function cookiesOn(ws) { return result?.cookies ?? [] } +/** 原 target 有 id 时,id 消失就是目标被回收;同源新 target 不能把它伪装成普通断线。 */ +function diagnosePageTargets(pages, url, pageTargetId) { + if (pageTargetId) { + const sameTarget = (pages ?? []).some((p) => p?.id === pageTargetId && p.type === "page") + return sameTarget ? "detached" : "target-gone" + } + return selectPageTarget(pages, url, { fallback: false }) ? "detached" : "target-gone" +} + +/** browserLogin 与 startBrowserLogin 共用的断开检查;命令失败时即使 WS 仍 OPEN 也可强制查目标。 */ +async function assertBrowserConnected(handle, { force = false } = {}) { + const open = handle.ws.readyState === 1 + if (open && !force) return + const reason = await handle.diagnoseDisconnect() + // 目标仍在且 WS 仍 OPEN:这次命令失败可以是瞬时的,让外层继续轮询。 + if (reason === "detached" && open) return + if (reason === "target-gone") { + throw new Error("受控浏览器中的登录页面目标消失了(可能被浏览器回收;窗口不一定被关闭)") + } + if (reason === "detached") { + throw new Error("受控浏览器的 CDP 连接断开了,但登录页面仍在;请重试浏览器登录") + } + if (reason === "browser-closed") { + throw new Error("受控浏览器进程已退出(窗口可能已关闭,或被系统拦截)") + } + throw new Error("受控浏览器的 CDP 调试端口不可达(窗口可能仍在运行,也可能是连接被拦截)") +} + +/** 读 cookie 失败也查一次 target:夸克可能在 WS 仍 OPEN 时销毁页面目标。 */ +async function loginCookies(handle) { + try { + return await cookiesOn(handle.ws) + } catch { + await assertBrowserConnected(handle, { force: true }) + return [] + } +} + /** * 持久 profile 目录(登录态跨次保留)。 * @@ -133,13 +225,13 @@ export function profileDirFor(name) { * * @returns {Promise<{ ws: WebSocket, close: () => void, profileDir: string, persistent: boolean }>} */ -export async function openControlledBrowser({ url = "about:blank", profileDir, headless = false, browser = findBrowser() } = {}) { +export async function openControlledBrowser({ url = "about:blank", profileDir, headless = false, browser = findBrowser(), spawnImpl = spawn } = {}) { if (!browser?.path) throw new Error("没有可用的 Chromium 浏览器(Chrome / Edge / 国产 Chromium 都行)") const persistent = profileDir !== undefined const profile = persistent ? profileDir : mkdtempSync(join(tmpdir(), "magpie-session-login-")) if (persistent) mkdirSync(profile, { recursive: true }) const port = await freePort() - const child = spawn(browser.path, [ + const child = spawnImpl(browser.path, [ `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, "--no-first-run", @@ -149,6 +241,14 @@ export async function openControlledBrowser({ url = "about:blank", profileDir, h ...(headless ? ["--headless=new"] : []), url, ], { stdio: "ignore", windowsHide: headless }) + let childExited = false + let spawnError = null + child.once("exit", () => { + childExited = true + }) + child.once("error", (error) => { + spawnError = error + }) const close = () => { try { @@ -162,31 +262,66 @@ export async function openControlledBrowser({ url = "about:blank", profileDir, h } } + const assertProcessRunning = () => { + if (spawnError) { + close() + throw new Error(`受控浏览器启动失败:${spawnError.message}`) + } + if (childExited) { + close() + throw new Error("受控浏览器进程已退出(可能被安全软件拦截)") + } + } + const deadline = Date.now() + 60_000 let pageWs = "" + let pageTargetId = "" while (Date.now() < deadline && pageWs.length === 0) { + assertProcessRunning() await new Promise((resolve) => setTimeout(resolve, 300)) try { const pages = await getJson(`http://127.0.0.1:${port}/json/list`) - pageWs = pages.find((page) => page.type === "page" && page.webSocketDebuggerUrl)?.webSocketDebuggerUrl ?? "" + // 必须按登录 URL 的 origin 选目标:夸克等浏览器会把内部页插到 `/json/list` 首位。 + const target = selectPageTarget(pages, url) + pageWs = target?.webSocketDebuggerUrl ?? "" + pageTargetId = target?.id ?? "" } catch {} } if (pageWs.length === 0) { close() throw new Error("受控浏览器没有打开调试端口(浏览器可能吞掉了 --remote-debugging-port)") } - const ws = new WebSocket(pageWs) - await new Promise((resolve, reject) => { - ws.addEventListener("open", resolve, { once: true }) - ws.addEventListener("error", () => reject(new Error("CDP WebSocket 连不上")), { once: true }) - }) + let ws + try { + // 构造器也在 connectWebSocket 的 try 内;任何建连失败都回收子进程与临时 profile。 + ws = await connectWebSocket(pageWs) + } catch (error) { + close() + throw error + } // 启动参数里的 URL 不一定被采纳(尤其 headless):连上后显式导航一次, // 免得用户对着 about:blank 不知道该干什么。 if (url !== "about:blank") { await cdpSend(ws, 0, "Page.enable").catch(() => {}) await cdpSend(ws, -1, "Page.navigate", { url }).catch(() => {}) } - return { ws, close, profileDir: profile, persistent } + + /** + * WS 断开后查一次目标列表,区分「页面目标被回收」与「整个浏览器/调试端口消失」。 + * 只在失败路径调用,不给正常轮询增加额外请求。 + */ + const diagnoseDisconnect = async () => { + if (childExited) return "browser-closed" + try { + const pages = await getJson(`http://127.0.0.1:${port}/json/list`, 1000) + return diagnosePageTargets(pages, url, pageTargetId) + } catch { + // 单次超时 / JSON 错误不能证明窗口被关;只有进程 exit 才归到 browser-closed。 + return childExited ? "browser-closed" : "browser-unreachable" + } + } + + return { ws, close, profileDir: profile, persistent, diagnoseDisconnect } } /** @@ -249,10 +384,11 @@ export async function browserLogin({ try { while (Date.now() < deadline) { if (signal?.aborted) throw new Error("登录已取消") + await assertBrowserConnected(handle) if (typeof onPoll === "string" && onPoll.length > 0) { await cdpSend(handle.ws, tick++, "Runtime.evaluate", { expression: onPoll, returnByValue: true }).catch(() => {}) } - const cookies = await cookiesOn(handle.ws).catch(() => []) + const cookies = await loginCookies(handle) const cookie = sessionCookieOf(cookies, names, cookieDomains) // 没有会话就继续等;有会话但换不到 Key(匿名会话)也继续等 —— // 用户可能只是还没在窗口里点完登录。 @@ -301,9 +437,8 @@ export async function startBrowserLogin({ }) { const names = Array.isArray(cookieNames) && cookieNames.length > 0 ? cookieNames : [cookieName] const handle = await openControlledBrowser({ url, profileDir, browser }) - // 存活检查:浏览器可能因为 profile 被占用 / 被策略拦掉而**立刻退出**, - // 这时 CDP 端口谁也连不上,早点报错比让用户对着空面板等 4 分钟强。 - const closed = () => handle.ws.readyState !== 1 + // 存活检查:区分页面 target 被回收、CDP 断开、进程退出与单纯端口不可达, + // 不再把所有 readyState 非 OPEN 都误报成「用户关了窗口」。 return { close: handle.close, async wait() { @@ -312,11 +447,11 @@ export async function startBrowserLogin({ try { while (Date.now() < deadline) { if (signal?.aborted) throw new Error("登录已取消") - if (closed()) throw new Error("受控浏览器窗口被关掉了(或被系统拦下没能启动)") + await assertBrowserConnected(handle) if (typeof onPoll === "string" && onPoll.length > 0) { await cdpSend(handle.ws, pollId++, "Runtime.evaluate", { expression: onPoll, returnByValue: true }).catch(() => {}) } - const cookies = await cookiesOn(handle.ws).catch(() => []) + const cookies = await loginCookies(handle) const cookie = sessionCookieOf(cookies, names, cookieDomains) if (cookie !== undefined && await accept(cookie)) return { cookie, browser: browser.name } await new Promise((resolve) => setTimeout(resolve, BROWSER_LOGIN_POLL_MS)) @@ -329,4 +464,4 @@ export async function startBrowserLogin({ } } -export const _internal = { cdpCookies, freePort, sessionCookieOf, cookiesOn } +export const _internal = { cdpCookies, cdpSend, connectWebSocket, freePort, sessionCookieOf, cookiesOn, assertBrowserConnected, diagnosePageTargets, loginCookies } diff --git a/tests/browser-login.test.mjs b/tests/browser-login.test.mjs index 3e90cc3..e1d09d8 100644 --- a/tests/browser-login.test.mjs +++ b/tests/browser-login.test.mjs @@ -1,13 +1,21 @@ // 单测不许读开发机上的真实账号缓存(可能是「探测后全不可用 ⇒ 空表」)。 -process.env.MAGPIE_MONKEYCODE_CACHE_DIR = "C:\__magpie_test_cache__" +process.env.MAGPIE_MONKEYCODE_CACHE_DIR = "C:\\__magpie_test_cache__" // 受控浏览器登录:候选排序、pickCookie、等文件。真启动浏览器不在单测里跑。 import test from "node:test" import assert from "node:assert/strict" -import { mkdtempSync, writeFileSync } from "node:fs" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -const { browserCandidates, findBrowser, selectCookies, waitForFile } = await import("../src/browser-login.mjs") +const { + browserCandidates, + findBrowser, + registryQuery, + selectCookies, + selectPageTarget, + systemDefaultBrowser, + waitForFile, +} = await import("../src/browser-login.mjs") test("Windows 候选:注册表命中优先,Chrome 在 Edge 前,国产浏览器在列", () => { const env = { @@ -33,9 +41,76 @@ test("findBrowser 只返回真实存在的候选;找不到返回 null", () => env, lookup: () => "", exists: (p) => p === chrome, + defaultBrowser: null, }) assert.equal(found.name, "Chrome") - assert.equal(findBrowser({ platform: "win32", env, lookup: () => "", exists: () => false }), null) + assert.equal(findBrowser({ platform: "win32", env, lookup: () => "", exists: () => false, defaultBrowser: null }), null) +}) + +test("系统默认浏览器:读 UserChoice 与 open command,只接受 CDP 稳定白名单", () => { + const edgePath = "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" + const query = (key, value) => { + if (key.endsWith("UserChoice") && value === "ProgId") return "MSEdgeHTM" + if (key === "HKLM\\SOFTWARE\\Classes\\MSEdgeHTM\\shell\\open\\command") { + return `"${edgePath}" --single-argument %1` + } + return "" + } + assert.deepEqual(systemDefaultBrowser({ platform: "win32", query }), { name: "Edge", path: edgePath }) + assert.deepEqual(findBrowser({ platform: "win32", env: {}, query, lookup: () => "", exists: (p) => p === edgePath }), { + name: "Edge", + path: edgePath, + }, "findBrowser 必须自动接上 UserChoice 查询") + + const quark = (key, value) => { + if (key.endsWith("UserChoice") && value === "ProgId") return "QuarkHTM" + if (key.endsWith("shell\\open\\command")) return '"C:\\Quark\\Quark.exe" --single-argument %1' + return "" + } + assert.equal(systemDefaultBrowser({ platform: "win32", query: quark }), null) +}) + +test("findBrowser:默认浏览器最前,可禁用默认优先,MAGPIE_BROWSER 是最终逃生阀", () => { + const defaultBrowser = { name: "Edge", path: "C:\\Edge\\msedge.exe" } + const lookup = (exe) => { + if (exe === "chrome.exe") return "C:\\Chrome\\chrome.exe" + if (exe === "Quark.exe") return "C:\\Quark\\Quark.exe" + return "" + } + const base = { platform: "win32", env: {}, lookup, exists: () => true, defaultBrowser } + assert.equal(findBrowser(base).path, defaultBrowser.path) + assert.equal(findBrowser({ ...base, env: { MAGPIE_BROWSER_PREFER_DEFAULT: "0" } }).name, "Chrome") + + const override = "D:\\Custom\\browser.exe" + assert.equal(findBrowser({ ...base, env: { MAGPIE_BROWSER: override } }).path, override) + + const candidates = browserCandidates({ platform: "win32", env: {}, lookup, defaultBrowser: null }) + assert.equal(candidates.at(-1).path, "C:\\Quark\\Quark.exe", "注册表夸克也必须排在所有硬编码候选之后") +}) + +test("CDP page 选择:按登录 URL 的 origin,不用内部页首位;严格模式不兜底", () => { + const internal = { id: "internal", type: "page", url: "uccd://qwen.voice-agent/renderer/index", webSocketDebuggerUrl: "ws://internal" } + const login = { id: "login", type: "page", url: "https://baizhi.cloud/sign-in?redirect=%2Fc", webSocketDebuggerUrl: "ws://login" } + const worker = { id: "worker", type: "service_worker", url: "https://baizhi.cloud/worker.js", webSocketDebuggerUrl: "ws://worker" } + assert.equal(selectPageTarget([internal, login, worker], "https://baizhi.cloud/console/").id, "login") + assert.equal(selectPageTarget([internal], "https://baizhi.cloud/console/").id, "internal", "初始连接保留首项兜底") + assert.equal(selectPageTarget([internal], "https://baizhi.cloud/console/", { fallback: false }), undefined) + assert.equal(selectPageTarget([internal, { ...login, url: "about:blank" }], "about:blank").url, "about:blank") +}) + +test("reg 查询丢弃 stderr,避免正常未命中刷 app.log", () => { + let seen + const value = registryQuery("HKCU\\Test", "ProgId", { + platform: "win32", + exec: (file, args, options) => { + seen = { file, args, options } + return "\r\n ProgId REG_SZ MSEdgeHTM\r\n" + }, + }) + assert.equal(value, "MSEdgeHTM") + assert.equal(seen.file, "reg") + assert.deepEqual(seen.args.slice(-2), ["/v", "ProgId"]) + assert.deepEqual(seen.options.stdio, ["ignore", "pipe", "ignore"]) }) test("selectCookies 只取需要的名字,忽略空值,拼成 Cookie header", () => { @@ -57,6 +132,10 @@ test("waitForFile:文件出现返回 true,超时返回 false", async () => { const file = join(dir, "ready") const pending = waitForFile(file, 500, 20) setTimeout(() => writeFileSync(file, "1"), 50) - assert.equal(await pending, true) - assert.equal(await waitForFile(join(dir, "missing"), 30, 10), false) + try { + assert.equal(await pending, true) + assert.equal(await waitForFile(join(dir, "missing"), 30, 10), false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } }) diff --git a/tests/session-login.test.mjs b/tests/session-login.test.mjs new file mode 100644 index 0000000..4e1502d --- /dev/null +++ b/tests/session-login.test.mjs @@ -0,0 +1,230 @@ +// 浏览器会话登录的 CDP 生命周期回归;全部使用假 WebSocket,不启动真实浏览器。 +process.env.MAGPIE_MONKEYCODE_CACHE_DIR = "C:\\__magpie_test_cache__" + +import test from "node:test" +import assert from "node:assert/strict" +import { EventEmitter } from "node:events" +import { existsSync, rmSync } from "node:fs" + +import { + _internal, + browserLogin, + openControlledBrowser, + readCookiesSilently, + startBrowserLogin, +} from "../src/session-login.mjs" +import { _internal as chromiumInternal } from "../src/browser-login.mjs" + +const { assertBrowserConnected, cdpSend, connectWebSocket, diagnosePageTargets, loginCookies } = _internal + +class FakeWebSocket { + constructor() { + this.readyState = 1 + this.sent = [] + this.closeCalls = 0 + this.listeners = new Map() + } + + addEventListener(type, listener) { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()) + this.listeners.get(type).add(listener) + } + + removeEventListener(type, listener) { + this.listeners.get(type)?.delete(listener) + } + + emit(type, event = {}) { + for (const listener of [...(this.listeners.get(type) ?? [])]) listener(event) + } + + send(raw) { + this.sent.push(JSON.parse(raw)) + } + + close() { + this.closeCalls += 1 + this.readyState = 3 + } + + listenerCount(type) { + return this.listeners.get(type)?.size ?? 0 + } +} + +function assertListenersCleared(ws) { + for (const type of ["open", "message", "close", "error"]) assert.equal(ws.listenerCount(type), 0, `${type} 监听器必须清理`) +} + +test("公开登录入口与 CDP 内部接口保持连通", () => { + for (const fn of [browserLogin, openControlledBrowser, readCookiesSilently, startBrowserLogin]) { + assert.equal(typeof fn, "function") + } + assert.equal(typeof chromiumInternal.cdpSend, "function") +}) + +test("WebSocket 握手:open 返回原 socket,并清理定时器与监听器", async () => { + for (const connect of [connectWebSocket, chromiumInternal.connectWebSocket]) { + const ws = new FakeWebSocket() + const pending = connect("ws://fake", { create: () => ws }) + ws.emit("open") + assert.equal(await pending, ws) + assert.equal(ws.closeCalls, 0) + assertListenersCleared(ws) + } +}) + +test("WebSocket 握手:close-only 会拒绝、关闭 socket 并清理监听器", async () => { + for (const connect of [connectWebSocket, chromiumInternal.connectWebSocket]) { + const ws = new FakeWebSocket() + const pending = connect("ws://fake", { create: () => ws }) + const assertion = assert.rejects(pending, /握手期间关闭/) + ws.emit("close") + await assertion + assert.equal(ws.closeCalls, 1) + assertListenersCleared(ws) + } +}) + +test("WebSocket 握手:无事件超时与构造器同步抛错都会 settle", async () => { + for (const connect of [connectWebSocket, chromiumInternal.connectWebSocket]) { + const ws = new FakeWebSocket() + await assert.rejects(connect("ws://fake", { create: () => ws, timeoutMs: 5 }), /握手超时/) + assert.equal(ws.closeCalls, 1) + assertListenersCleared(ws) + await assert.rejects(connect("ws://bad", { + create: () => { + throw new Error("bad websocket url") + }, + }), /bad websocket url/) + } +}) + +test("openControlledBrowser:握手 close-only 后 kill 子进程并删除临时 profile", async () => { + const originalFetch = globalThis.fetch + const originalWebSocket = globalThis.WebSocket + const child = new EventEmitter() + child.killed = false + child.kill = () => { + child.killed = true + } + let profile = "" + class CloseOnlyWebSocket extends FakeWebSocket { + constructor() { + super() + setImmediate(() => this.emit("close")) + } + } + try { + globalThis.fetch = async () => ({ + ok: true, + json: async () => [{ id: "login", type: "page", url: "about:blank", webSocketDebuggerUrl: "ws://fake" }], + }) + globalThis.WebSocket = CloseOnlyWebSocket + await assert.rejects(openControlledBrowser({ + browser: { name: "Fake", path: "fake-browser" }, + spawnImpl: (path, args) => { + profile = args.find((arg) => arg.startsWith("--user-data-dir=")).slice("--user-data-dir=".length) + return child + }, + }), /握手期间关闭/) + assert.equal(child.killed, true) + assert.ok(profile.includes("magpie-session-login-")) + assert.equal(existsSync(profile), false, "临时 profile 必须在握手失败后删除") + } finally { + globalThis.fetch = originalFetch + globalThis.WebSocket = originalWebSocket + if (profile) rmSync(profile, { recursive: true, force: true }) + } +}) + +test("cdpSend:正常响应 settle,并清理 message/close/error 监听器", async () => { + for (const send of [cdpSend, chromiumInternal.cdpSend]) { + const ws = new FakeWebSocket() + const pending = send(ws, 7, "Network.getAllCookies") + ws.emit("message", { data: JSON.stringify({ id: 7, result: { cookies: [] } }) }) + assert.deepEqual(await pending, { cookies: [] }) + assert.deepEqual(ws.sent, [{ id: 7, method: "Network.getAllCookies", params: {} }]) + assertListenersCleared(ws) + } +}) + +test("cdpSend:目标关闭或 WebSocket 错误会拒绝,不再永久等待", async () => { + for (const send of [cdpSend, chromiumInternal.cdpSend]) { + const closed = new FakeWebSocket() + const onClosed = send(closed, 1, "Network.enable") + const closedAssertion = assert.rejects(onClosed, /CDP WebSocket 已关闭/) + closed.emit("close") + await closedAssertion + assertListenersCleared(closed) + + const failed = new FakeWebSocket() + const onFailed = send(failed, 2, "Network.getAllCookies") + const failedAssertion = assert.rejects(onFailed, /CDP WebSocket 错误/) + failed.emit("error", new Error("boom")) + await failedAssertion + assertListenersCleared(failed) + } +}) + +test("cdpSend:命令超时也会拒绝并清理,允许外层进入下一轮断开诊断", async () => { + for (const send of [cdpSend, chromiumInternal.cdpSend]) { + const ws = new FakeWebSocket() + await assert.rejects(send(ws, 2, "Network.getAllCookies", {}, 5), /CDP 命令超时/) + assertListenersCleared(ws) + } +}) + +test("断开目标诊断:同 id 跨域仍在;同源新 id 替代仍算 target-gone", () => { + const url = "https://baizhi.cloud/console/" + const oldTarget = { id: "old", type: "page", url: "https://idp.example.com/sign-in", webSocketDebuggerUrl: "ws://old" } + const replacement = { id: "new", type: "page", url: "https://baizhi.cloud/sign-in", webSocketDebuggerUrl: "ws://new" } + const internal = { id: "internal", type: "page", url: "uccd://qwen.voice-agent/renderer/index", webSocketDebuggerUrl: "ws://internal" } + + assert.equal(diagnosePageTargets([oldTarget], url, "old"), "detached", "同一 target 跨域导航不等于消失") + assert.equal(diagnosePageTargets([replacement], url, "old"), "target-gone", "原 id 消失后不能用同源新页掩盖") + assert.equal(diagnosePageTargets([replacement], url, ""), "detached", "老 CDP 没有 target id 时才按 origin 兜底") + assert.equal(diagnosePageTargets([internal], url, ""), "target-gone") +}) + +test("共享断开检查:OPEN 不查目标;其余原因映射为不同且不过度断言的错误", async () => { + let diagnosed = false + await assertBrowserConnected({ + ws: { readyState: 1 }, + diagnoseDisconnect: async () => { + diagnosed = true + return "target-gone" + }, + }) + assert.equal(diagnosed, false) + + const cases = [ + ["target-gone", /登录页面目标消失/], + ["detached", /登录页面仍在/], + ["browser-closed", /浏览器进程已退出/], + ["browser-unreachable", /调试端口不可达/], + ] + for (const [reason, pattern] of cases) { + await assert.rejects(assertBrowserConnected({ + ws: { readyState: 3 }, + diagnoseDisconnect: async () => reason, + }), pattern) + } +}) + +test("cookie 读取失败时即使 WS 仍 OPEN,也能识别 target-gone;目标仍在才继续轮询", async () => { + const goneWs = new FakeWebSocket() + goneWs.send = () => goneWs.emit("close") + await assert.rejects(loginCookies({ + ws: goneWs, + diagnoseDisconnect: async () => "target-gone", + }), /登录页面目标消失/) + + const aliveWs = new FakeWebSocket() + aliveWs.send = () => aliveWs.emit("close") + const cookies = await loginCookies({ + ws: aliveWs, + diagnoseDisconnect: async () => "detached", + }) + assert.deepEqual(cookies, [], "目标仍在且 WS OPEN 时允许瞬时命令失败后继续") +})