magpie-marvis 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,9 +3,23 @@
3
3
  > 每个版本一节,**一条一行**,说用户能感知的变化。
4
4
  > 格式与规矩见 [UPDATING.md](./UPDATING.md)。
5
5
 
6
- ## 0.1.4 — 2026-11-06
6
+ ## 0.1.5 — 2026-11-06
7
7
 
8
- **变更**
8
+ **修复**
9
9
 
10
- - 图标换成 **Marvis 官方图标**(`resources/icons/icon.png`,128×128 透明 PNG)—— 插件列表与供应商卡片两处同时生效
10
+ - **浏览器登录失效(`-109 RC_PARAMS_INVALID`)**:换 token 时往 query 里多加了一个 `guid`,
11
+ 而 QQ 校验 `redirect_uri` 逐字一致 —— 多一个参数就判无效。现在只发 `login_type` + `code`
12
+ - **改成直接读回调页**:授权后浏览器跳到 `marvis_oauth` 时,服务端响应体**就是** token JSON。
13
+ 原来又发一次请求去换 = 用第二个 code,必然 `-109`。现在只读页面正文,不重发
14
+ - **本机导入弹 OS 对话框**:method 少了 `opened: true` + `method: "auto"`,
15
+ magpie 会去打开 `about:blank`;补上后整个登录就是「点一下 → 读内存 → 成功」
16
+ - **本机导入 PowerShell 语法错误**(`MissingEndParenthesisInMethodCall`):C# 代码经
17
+ `-Command` 参数传递时引号被命令行解析吃掉。改成落临时 `.cs` + `.ps1`,走 `-File`
18
+
19
+ **新增**
20
+
21
+ - **隔离浏览器登录**:自己起一次性 Chromium(不复用日常浏览器 profile),
22
+ 带 `--disable-blink-features=AutomationControlled` / `--exclude-switches=enable-automation` /
23
+ 正常 Chrome UA + 补齐 `sec-ch-ua` client hints —— 去自动化痕迹、补指纹,防风控拦截
24
+ - 浏览器探测结果进程内缓存(注册表查询同步阻塞,否则 `authorize()` 会慢到 1 秒以上)
11
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-marvis",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "magpie / OpenCode provider plugin for Tencent Marvis: its DeepSeek V4 Pro, Kimi K2.5, Claude Opus/Sonnet 4.6, GPT-5.4 and Hunyuan models through the Marvis model gateway — no API key to paste, sign in with QQ or WeChat.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -1,20 +1,28 @@
1
1
  // ② auth.methods —— 两条登录路
2
2
  //
3
- // A. QQ / 微信 授权页(默认):打开浏览器 → 用户在 QQ/微信里确认 →
4
- // 浏览器跳到 Marvis 的 redirect_uri 并显示一段 JSON → 用户把 `code=XXX` 粘回来。
5
- // B. 从本机 Marvis 导入:装了客户端且登录过的人,一条命令读出来,不用走浏览器。
3
+ // A. QQ / 微信 授权(默认):自己起一个**隔离浏览器**(一次性 profile、伪装正常 Chrome、
4
+ // 去掉自动化痕迹),用户在窗口里授权,插件从 CDP 抓回调 URL 里的 `code`。
5
+ // → `{ url, opened: true, method: "auto", callback }`
6
+ // `opened: true` 让 magpie **不要**再开系统默认浏览器(否则弹两个窗口)。
7
+ //
8
+ // B. 从本机 Marvis 导入:装了客户端且登录过的人,直接读进程内存里的登录态。
9
+ // → 同样 `opened: true` + `method: "auto"`,整个登录就是「点一下 → 读内存 → 成功」。
6
10
  //
7
11
  // ⚠ `authorize()` 必须**立刻**返回 —— 真正等用户的是 `callback()`。
8
12
  // 在这里 await 登录会和 magpie 双向死锁(HOOKS §四·陷阱1)。
9
13
  //
10
- // ⚠ 为什么不能像别的插件那样起本地 loopback:redirect_uri 是 QQ 互联里
11
- // 预注册死的,改不了(详见 src/marvis/login.mjs 顶部)。
14
+ // ⚠ 为什么不能像别的插件那样起本地 loopback 收回调:redirect_uri 是 QQ 互联里
15
+ // 预注册死的,改不了(详见 src/marvis/login.mjs 顶部)。所以只能「跳过去 → 读地址栏」。
12
16
 
13
- import { PROVIDER } from "../context.mjs"
14
- import { authorizeUrl, exchangeCode, extractCode, deviceGuid } from "../marvis/login.mjs"
17
+ import { PROVIDER, LOGIN_TYPE } from "../context.mjs"
18
+ import { authorizeUrl, parseCallback, deviceGuid } from "../marvis/login.mjs"
19
+ import { openIsolatedLogin, findBrowser } from "../marvis/browser.mjs"
15
20
  import { resolveAccount } from "../account-name.mjs"
16
21
  import { importFromDesktop, desktopAvailable } from "../marvis/desktop.mjs"
17
22
 
23
+ /** 回调地址判据:跳到 marvis_oauth 且带 code= */
24
+ const CALLBACK = /marvis_client_login\/marvis_oauth\?[^#]*\bcode=/i
25
+
18
26
  /**
19
27
  * 登录成功时给宿主的字段。
20
28
  * `accountId` 是**名字**(卡片上显示),`uid` 是身份(HOOKS §八)。
@@ -45,26 +53,42 @@ export function authOf(acct) {
45
53
  }
46
54
  }
47
55
 
48
- /** 授权页登录(QQ / 微信) */
56
+ /** 授权页登录(QQ / 微信),走隔离浏览器 */
49
57
  function oauthLogin(loginType, label, instructions) {
50
58
  return {
51
59
  type: "oauth",
52
60
  label,
53
61
  async authorize() {
54
62
  const guid = deviceGuid()
63
+ const browser = findBrowser()
64
+ const url = authorizeUrl(loginType, guid)
65
+
55
66
  return {
56
- url: authorizeUrl(loginType, guid),
67
+ url,
68
+ // 我们自己开隔离浏览器,magpie 不要再开系统默认浏览器
69
+ opened: true,
70
+ method: "auto",
57
71
  instructions,
58
- // 用户在浏览器里确认后,地址栏会变成
59
- // https://yybadaccess.3g.qq.com/marvis_client_login/marvis_oauth?login_type=QC&code=XXX
60
- // 把整条 URL(或只 code)粘回来即可。
61
- async callback(_url, input) {
62
- const code = extractCode(input) ?? extractCode(_url)
63
- if (!code) {
64
- throw new Error("没识别出授权码。请把浏览器地址栏里的整条 URL 贴进来(形如 ...?login_type=QC&code=XXXX)")
72
+ async callback(_url, _input, signal) {
73
+ if (!browser) {
74
+ throw new Error(
75
+ "没有找到可用的 Chromium 内核浏览器(Chrome / Edge / 360 / QQ 浏览器都行)。" +
76
+ "请装一个,或改用「从本机 Marvis 导入」。",
77
+ )
65
78
  }
66
- const acct = await exchangeCode(code, loginType, guid)
67
- return authOf({ ...acct, guid })
79
+ // 起隔离浏览器 → 等它跳到回调地址
80
+ // ⚠ 回调页**本身就是换 token 的响应**(服务端直接吐 JSON),
81
+ // 所以这里读页面正文,**不要再发一次请求** —— 再发就是第二个 code,必然 -109。
82
+ const { url: callbackUrl, body } = await openIsolatedLogin({
83
+ browser,
84
+ url,
85
+ match: CALLBACK,
86
+ timeoutMs: 5 * 60_000,
87
+ signal,
88
+ })
89
+
90
+ const acct = parseCallback(callbackUrl, body)
91
+ return authOf({ ...acct, guid, loginType: acct.loginType ?? LOGIN_TYPE[loginType] ?? "6" })
68
92
  },
69
93
  }
70
94
  },
@@ -76,16 +100,16 @@ export function methods() {
76
100
  oauthLogin(
77
101
  "QC",
78
102
  "QQ 登录",
79
- "1) 浏览器会打开 QQ 授权页,用你的 QQ 登录并确认授权\n" +
80
- "2) 授权后页面会显示一段 JSON(或跳到 marvis_client_login 地址)\n" +
81
- "3) 把**地址栏里的整条 URL** 粘贴回来(含 code=…)",
103
+ "1) 会打开一个**独立的**浏览器窗口(不碰你日常浏览器)\n" +
104
+ "2) 用你的 QQ 登录并确认授权\n" +
105
+ "3) 授权完成后窗口会自己关掉,回到 magpie 即可",
82
106
  ),
83
107
  oauthLogin(
84
108
  "WX",
85
109
  "微信登录",
86
- "1) 浏览器会打开微信扫码页,用微信扫码并确认\n" +
87
- "2) 扫码后页面会显示一段 JSON(或跳到 marvis_client_login 地址)\n" +
88
- "3) 把**地址栏里的整条 URL** 粘贴回来(含 code=…)",
110
+ "1) 会打开一个**独立的**浏览器窗口(不碰你日常浏览器)\n" +
111
+ "2) 用微信扫码并确认\n" +
112
+ "3) 授权完成后窗口会自己关掉,回到 magpie 即可",
89
113
  ),
90
114
  {
91
115
  type: "oauth",
@@ -93,10 +117,14 @@ export function methods() {
93
117
  async authorize() {
94
118
  const ok = await desktopAvailable()
95
119
  return {
96
- url: ok ? "about:blank" : "about:blank",
120
+ // ⚠ 必须 opened: true + method: "auto",否则 magpie 会去开这个 url
121
+ // (之前给 about:blank 弹了一次「没有可打开此链接的应用」)
122
+ url: "",
123
+ opened: true,
124
+ method: "auto",
97
125
  instructions: ok
98
126
  ? "已检测到本机 Marvis 客户端,正在读取登录态…"
99
- : "本机没找到 Marvis 客户端(需要安装并登录过)。请改用上面的 QQ / 微信登录。",
127
+ : "本机没找到运行中的 Marvis 客户端。请先打开 Marvis 并登录,或改用 QQ / 微信 登录。",
100
128
  async callback() {
101
129
  const acct = await importFromDesktop()
102
130
  return authOf(acct)
@@ -107,4 +135,4 @@ export function methods() {
107
135
  ]
108
136
  }
109
137
 
110
- export { PROVIDER }
138
+ export { PROVIDER, CALLBACK }
@@ -0,0 +1,347 @@
1
+ // 隔离浏览器登录 —— 自己起一个一次性 Chromium,用户在里面登录,从 CDP 抓回调 URL
2
+ //
3
+ // 为什么不用系统默认浏览器:QQ / 微信 的授权页对「刚打开的、没有历史的」窗口更宽容。
4
+ // 更重要的是**隔离**:一次性 user-data-dir,不碰用户日常浏览器的 Cookie / 扩展 / 同步,
5
+ // 登录态只在本次窗口里,关掉即销毁。
6
+ //
7
+ // ⚠ 伪装浏览器身份(防风控拦截):
8
+ // · `--disable-blink-features=AutomationControlled` —— 去掉 `navigator.webdriver`
9
+ // · 不传 `--enable-automation` / `--headless` —— 有头窗口,且不带自动化提示条
10
+ // · `Network.setUserAgentOverride` 把 UA 换成正常 Chrome,并补齐
11
+ // `Accept-Language` / `sec-ch-ua` —— 只改 UA 不改 client hints 会被指纹比对出来
12
+ // · 一次性 profile,每次都是「全新机器」,不会因为历史 Cookie 被判定异常
13
+ //
14
+ // 设计取舍来自 WPS 灵犀 0.4.11 的实测实现(MIT;见 NOTICE.md):
15
+ // · 任意 Chromium 内核浏览器都行(Chrome / Edge / 360 / QQ / Brave…),不止 Chrome;
16
+ // 国产浏览器在 Windows 上很常见,漏了它们这条登录路对用户就不存在。
17
+ // · Windows 的候选顺序:Chrome → 国产 Chromium → Edge 最后(Edge 策略最多、最容易被组织禁用)。
18
+ // · 退出码不可靠:浏览器可能把调试参数吞掉,所以以「CDP 端口能不能连上」为准。
19
+ //
20
+ // ⚠ 只读页面 URL,不读 Cookie、不注入脚本;窗口关闭后临时 profile 会被删掉。
21
+ // ⚠ 本模块不依赖任何第三方包;Node 22+ 的全局 WebSocket 足够。
22
+
23
+ import { spawn } from "node:child_process"
24
+ import { existsSync, mkdtempSync, rmSync } from "node:fs"
25
+ import { tmpdir } from "node:os"
26
+ import { join } from "node:path"
27
+ import { createServer } from "node:net"
28
+ import { execFileSync } from "node:child_process"
29
+
30
+ const WIN_EXES = [
31
+ "chrome.exe",
32
+ "360ChromeX.exe",
33
+ "360chrome.exe",
34
+ "360se.exe",
35
+ "QQBrowser.exe",
36
+ "Brave.exe",
37
+ "vivaldi.exe",
38
+ "opera.exe",
39
+ "SogouExplorer.exe",
40
+ "2345Explorer.exe",
41
+ "liebao.exe",
42
+ "Maxthon.exe",
43
+ "UCBrowser.exe",
44
+ "Quark.exe",
45
+ "msedge.exe",
46
+ ]
47
+
48
+ /** 正常 Chrome 的 UA —— 不是 headless、不是 Electron、不带 Marvis 字样 */
49
+ const CHROME_UA =
50
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " +
51
+ "Chrome/152.0.0.0 Safari/537.36"
52
+
53
+ /** Windows 注册表 App Paths 查浏览器;查不到返回空串。 */
54
+ export function registryAppPath(exe) {
55
+ if (process.platform !== "win32") return ""
56
+ for (const root of ["HKCU", "HKLM"]) {
57
+ for (const tail of [
58
+ `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${exe}`,
59
+ `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${exe}`,
60
+ ]) {
61
+ try {
62
+ const out = execFileSync("reg", ["query", `${root}\\${tail}`, "/ve"], {
63
+ encoding: "utf8",
64
+ timeout: 5000,
65
+ stdio: ["ignore", "pipe", "ignore"],
66
+ })
67
+ const line = out.split("\n").find((l) => /REG_SZ/i.test(l))
68
+ const path = line?.split("REG_SZ")[1]?.trim()
69
+ if (path && existsSync(path)) return path
70
+ } catch {
71
+ /* 没有这个键 */
72
+ }
73
+ }
74
+ }
75
+ return ""
76
+ }
77
+
78
+ /**
79
+ * 候选浏览器(按优先级)。不做存在性检查,方便测试与排序。
80
+ * @param {{platform?:string, env?:Record<string,string>, lookup?:(exe:string)=>string}} [o]
81
+ * @returns {{name:string, path:string}[]}
82
+ */
83
+ export function browserCandidates({ platform = process.platform, env = process.env, lookup = registryAppPath } = {}) {
84
+ const h = env.HOME || env.USERPROFILE || ""
85
+ const local = env.LOCALAPPDATA || ""
86
+ const roaming = env.APPDATA || ""
87
+ const pf = env.PROGRAMFILES || ""
88
+ const pf86 = env["PROGRAMFILES(X86)"] || env["ProgramFiles(x86)"] || ""
89
+ const add = (list, name, path) => {
90
+ if (path) list.push({ name, path })
91
+ }
92
+
93
+ if (platform === "win32") {
94
+ const list = []
95
+ for (const exe of WIN_EXES) {
96
+ const fromRegistry = lookup(exe)
97
+ if (fromRegistry) add(list, exe.replace(/\.exe$/i, ""), fromRegistry)
98
+ }
99
+ add(list, "Chrome", join(pf, "Google", "Chrome", "Application", "chrome.exe"))
100
+ add(list, "Chrome", join(pf86, "Google", "Chrome", "Application", "chrome.exe"))
101
+ add(list, "Chrome", join(local, "Google", "Chrome", "Application", "chrome.exe"))
102
+ add(list, "Chromium", join(local, "Chromium", "Application", "chrome.exe"))
103
+ add(list, "Chromium", join(pf, "Chromium", "Application", "chrome.exe"))
104
+ add(list, "360极速浏览器X", join(local, "360ChromeX", "Chrome", "Application", "360ChromeX.exe"))
105
+ add(list, "360浏览器", join(local, "360Chrome", "Chrome", "Application", "360chrome.exe"))
106
+ add(list, "360安全浏览器", join(roaming, "secoresdk", "360se6", "Application", "360se.exe"))
107
+ add(list, "QQ浏览器", join(local, "Tencent", "QQBrowser", "Application", "QQBrowser.exe"))
108
+ add(list, "Brave", join(pf, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"))
109
+ add(list, "Brave", join(local, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"))
110
+ add(list, "Vivaldi", join(local, "Vivaldi", "Application", "vivaldi.exe"))
111
+ add(list, "Opera", join(local, "Programs", "Opera", "opera.exe"))
112
+ add(list, "Opera", join(roaming, "Opera Software", "Opera Stable", "opera.exe"))
113
+ add(list, "搜狗浏览器", join(local, "SogouExplorer", "SogouExplorer.exe"))
114
+ add(list, "2345浏览器", join(local, "2345Explorer", "2345Explorer.exe"))
115
+ add(list, "猎豹浏览器", join(local, "liebao", "liebao.exe"))
116
+ add(list, "Maxthon", join(roaming, "Maxthon3", "Bin", "Maxthon.exe"))
117
+ add(list, "UC浏览器", join(local, "UCBrowser", "Application", "UCBrowser.exe"))
118
+ add(list, "夸克浏览器", join(local, "Quark", "Quark.exe"))
119
+ add(list, "Edge", join(pf86, "Microsoft", "Edge", "Application", "msedge.exe"))
120
+ add(list, "Edge", join(pf, "Microsoft", "Edge", "Application", "msedge.exe"))
121
+ return dedupe(list)
122
+ }
123
+
124
+ if (platform === "darwin") {
125
+ return [
126
+ ["Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
127
+ ["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"],
128
+ ["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
129
+ ["Vivaldi", "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi"],
130
+ ["Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
131
+ ].map(([name, path]) => ({ name, path }))
132
+ }
133
+
134
+ return [
135
+ ["Chrome", join(h, ".config/google-chrome/chrome")],
136
+ ["Chromium", join(h, ".config/chromium/chrome")],
137
+ ["Brave", join(h, ".config/BraveSoftware/Brave-Browser/brave")],
138
+ ["Vivaldi", join(h, ".config/vivaldi/vivaldi")],
139
+ ["Edge", join(h, ".config/microsoft-edge/msedge")],
140
+ ].map(([name, path]) => ({ name, path }))
141
+ }
142
+
143
+ function dedupe(list) {
144
+ const seen = new Set()
145
+ return list.filter((x) => {
146
+ const key = x.path.toLowerCase()
147
+ if (seen.has(key)) return false
148
+ seen.add(key)
149
+ return true
150
+ })
151
+ }
152
+
153
+ /** 缓存:注册表查询要跑十几次 `reg query`,不缓存会让 `authorize()` 慢到 1 秒以上 */
154
+ let browserCache = null
155
+ let browserCached = false
156
+
157
+ /**
158
+ * 第一个真实存在的候选;没有返回 null。
159
+ *
160
+ * ⚠ 结果**进程内缓存**:`authorize()` 必须立刻返回(HOOKS 四·陷阱 1),
161
+ * 而注册表查询是同步阻塞的 —— 每次登录都重查十几遍会让 UI 卡住。
162
+ */
163
+ export function findBrowser({ platform = process.platform, env = process.env, exists = existsSync, lookup = registryAppPath } = {}) {
164
+ if (browserCached) return browserCache
165
+ browserCache = browserCandidates({ platform, env, lookup }).find((b) => exists(b.path)) ?? null
166
+ browserCached = true
167
+ return browserCache
168
+ }
169
+
170
+ /** 测试用:清掉浏览器缓存 */
171
+ export function resetBrowserCache() {
172
+ browserCache = null
173
+ browserCached = false
174
+ }
175
+
176
+ async function freePort() {
177
+ return new Promise((resolve, reject) => {
178
+ const srv = createServer()
179
+ srv.unref()
180
+ srv.on("error", reject)
181
+ srv.listen(0, "127.0.0.1", () => {
182
+ const port = srv.address().port
183
+ srv.close(() => resolve(port))
184
+ })
185
+ })
186
+ }
187
+
188
+ async function getJson(url, timeoutMs = 3000) {
189
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
190
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
191
+ return res.json()
192
+ }
193
+
194
+ /**
195
+ * 打开受控浏览器,等它跳到匹配 `match` 的地址,返回那个地址。
196
+ *
197
+ * @param {object} o
198
+ * @param {{name:string,path:string}} o.browser findBrowser() 的返回值
199
+ * @param {string} o.url 登录页
200
+ * @param {RegExp} o.match 回调地址的判据
201
+ * @param {number} [o.timeoutMs] 默认 5 分钟
202
+ * @param {AbortSignal} [o.signal]
203
+ * @returns {Promise<{url:string, body:string, browser:string}>} 回调页面的地址与正文
204
+ */
205
+ export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 60_000, signal }) {
206
+ if (!browser?.path) throw new Error("没有找到可用的 Chromium 内核浏览器(Chrome / Edge / 360 / QQ 浏览器都行)")
207
+
208
+ const profile = mkdtempSync(join(tmpdir(), "magpie-marvis-login-"))
209
+ const port = await freePort()
210
+ const child = spawn(
211
+ browser.path,
212
+ [
213
+ `--remote-debugging-port=${port}`,
214
+ `--user-data-dir=${profile}`,
215
+ // 一次性全新 profile:没有历史 Cookie / 扩展 / 同步
216
+ "--no-first-run",
217
+ "--no-default-browser-check",
218
+ "--disable-extensions",
219
+ "--disable-background-networking",
220
+ "--disable-sync",
221
+ // 去掉自动化痕迹(这是防风控的关键,不是可选项)
222
+ "--disable-blink-features=AutomationControlled",
223
+ "--exclude-switches=enable-automation",
224
+ `--user-agent=${CHROME_UA}`,
225
+ "--lang=zh-CN",
226
+ url,
227
+ ],
228
+ { stdio: "ignore", windowsHide: false },
229
+ )
230
+
231
+ const cleanup = () => {
232
+ try {
233
+ child.kill()
234
+ } catch {
235
+ /* 已经退出 */
236
+ }
237
+ try {
238
+ rmSync(profile, { recursive: true, force: true })
239
+ } catch {
240
+ /* 临时目录删不掉不影响主流程 */
241
+ }
242
+ }
243
+
244
+ const deadline = Date.now() + timeoutMs
245
+ try {
246
+ let pageWs = ""
247
+ while (Date.now() < deadline) {
248
+ if (signal?.aborted) throw new Error("登录已取消")
249
+ try {
250
+ const pages = await getJson(`http://127.0.0.1:${port}/json/list`, 1000)
251
+ pageWs = pages.find((p) => p.type === "page" && p.webSocketDebuggerUrl)?.webSocketDebuggerUrl ?? ""
252
+ if (pageWs) break
253
+ } catch {
254
+ /* 浏览器还没起好 */
255
+ }
256
+ await new Promise((r) => setTimeout(r, 300))
257
+ }
258
+ if (!pageWs) {
259
+ throw new Error(
260
+ "受控浏览器没有打开调试端口(可能是浏览器吞掉了 --remote-debugging-port,或安全软件拦截)。" +
261
+ "请换一个 Chromium 内核浏览器,或改用「从本机 Marvis 导入」。",
262
+ )
263
+ }
264
+
265
+ const ws = new WebSocket(pageWs)
266
+ await new Promise((resolve, reject) => {
267
+ ws.addEventListener("open", resolve, { once: true })
268
+ ws.addEventListener("error", () => reject(new Error("CDP WebSocket 连不上")), { once: true })
269
+ })
270
+
271
+ /** 发一条 CDP 命令并等结果 */
272
+ const send = (id, method, params = {}) =>
273
+ new Promise((resolve, reject) => {
274
+ const onMessage = (event) => {
275
+ let msg
276
+ try {
277
+ msg = JSON.parse(String(event.data))
278
+ } catch {
279
+ return
280
+ }
281
+ if (msg.id !== id) return
282
+ ws.removeEventListener("message", onMessage)
283
+ if (msg.error) reject(new Error(msg.error.message || "CDP error"))
284
+ else resolve(msg.result)
285
+ }
286
+ ws.addEventListener("message", onMessage)
287
+ ws.send(JSON.stringify({ id, method, params }))
288
+ })
289
+
290
+ try {
291
+ // 补齐 client hints —— 只改 UA 不改 sec-ch-ua 会被指纹比对出来
292
+ await send(1, "Network.enable").catch(() => {})
293
+ await send(2, "Network.setUserAgentOverride", {
294
+ userAgent: CHROME_UA,
295
+ acceptLanguage: "zh-CN,zh;q=0.9",
296
+ platform: "Win32",
297
+ }).catch(() => {})
298
+
299
+ let nextId = 10
300
+ while (Date.now() < deadline) {
301
+ if (signal?.aborted) throw new Error("登录已取消")
302
+ let href = ""
303
+ try {
304
+ const r = await send(nextId++, "Runtime.evaluate", {
305
+ expression: "location.href",
306
+ returnByValue: true,
307
+ })
308
+ href = String(r?.result?.value ?? "")
309
+ } catch {
310
+ /* 页面正在跳转,下一轮再看 */
311
+ }
312
+ if (href && match.test(href)) {
313
+ // ⚠ 关键:回调页**本身就是换 token 的响应**(服务端直接吐 JSON)。
314
+ // 再发一次请求 = 用第二个 code,必然 -109。所以这里只读页面内容,不重发。
315
+ // 页面可能还在渲染,多读几次直到拿到非空正文。
316
+ let body = ""
317
+ for (let i = 0; i < 15; i++) {
318
+ try {
319
+ const r = await send(nextId++, "Runtime.evaluate", {
320
+ expression: "document.body ? document.body.innerText : ''",
321
+ returnByValue: true,
322
+ })
323
+ body = String(r?.result?.value ?? "")
324
+ if (body.trim()) break
325
+ } catch {
326
+ /* 页面还在导航 */
327
+ }
328
+ await new Promise((r) => setTimeout(r, 400))
329
+ }
330
+ return { url: href, body, browser: browser.name }
331
+ }
332
+ await new Promise((r) => setTimeout(r, 800))
333
+ }
334
+ throw new Error("登录超时:没有等到授权回调。请在打开的窗口里完成 QQ / 微信 授权。")
335
+ } finally {
336
+ try {
337
+ ws.close()
338
+ } catch {
339
+ /* 已关闭 */
340
+ }
341
+ }
342
+ } finally {
343
+ cleanup()
344
+ }
345
+ }
346
+
347
+ export const _internal = { WIN_EXES, CHROME_UA, freePort, dedupe, resetBrowserCache }
@@ -5,10 +5,17 @@
5
5
  // 密钥攻不出来。但客户端跑着的时候,明文就在进程内存里 —— 直接读。
6
6
  //
7
7
  // ⚠ 只读,不写、不注入、不 hook。用 `ReadProcessMemory` 扫可读区段。
8
- // ⚠ 只在 Windows 上有;其它平台返回「不可用」,让用户走浏览器登录。
8
+ // ⚠ 只在 Windows 上有;其它平台抛「不可用」,让用户走浏览器登录。
9
9
  // ⚠ **不需要管理员权限**(同用户进程即可);跨用户会失败并给出明确错误。
10
+ //
11
+ // ⚠⚠ 为什么把 C# 和 PS 都落成临时文件:`powershell -Command <长脚本>` 会被
12
+ // Windows 命令行参数解析吃掉引号(C# 里的 `""` 空字符串必然炸成
13
+ // `MissingEndParenthesisInMethodCall`)。`-File` 走文件,不经过参数解析。
10
14
 
11
15
  import { execFileSync } from "node:child_process"
16
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs"
17
+ import { tmpdir } from "node:os"
18
+ import { join } from "node:path"
12
19
  import { deviceGuid } from "./login.mjs"
13
20
 
14
21
  /** 探查本机是否有 Marvis 客户端在跑 */
@@ -25,12 +32,92 @@ function findPid(name = "MarvisAgent.exe") {
25
32
  const out = execFileSync("tasklist", ["/FI", `IMAGENAME eq ${name}`, "/FO", "CSV", "/NH"], {
26
33
  encoding: "utf8",
27
34
  windowsHide: true,
28
- timeout: 5000,
35
+ timeout: 10_000,
29
36
  })
30
37
  const m = /"([^"]+)","(\d+)"/.exec(out)
31
38
  return m ? Number(m[2]) : 0
32
39
  }
33
40
 
41
+ /** C# 扫描器 —— 写成独立 .cs,由 Add-Type -Path 编译 */
42
+ const CS = String.raw`
43
+ using System;
44
+ using System.Text;
45
+ using System.Runtime.InteropServices;
46
+
47
+ public class MarvisMemScan
48
+ {
49
+ [DllImport("kernel32.dll", SetLastError = true)]
50
+ public static extern IntPtr OpenProcess(int access, bool inherit, int pid);
51
+
52
+ [DllImport("kernel32.dll", SetLastError = true)]
53
+ public static extern bool ReadProcessMemory(IntPtr h, IntPtr addr, byte[] buf, int size, out IntPtr read);
54
+
55
+ [DllImport("kernel32.dll", SetLastError = true)]
56
+ public static extern int VirtualQueryEx(IntPtr h, IntPtr addr, ref MBI mbi, int size);
57
+
58
+ [DllImport("kernel32.dll", SetLastError = true)]
59
+ public static extern bool CloseHandle(IntPtr h);
60
+
61
+ [StructLayout(LayoutKind.Sequential)]
62
+ public struct MBI
63
+ {
64
+ public IntPtr BaseAddress;
65
+ public IntPtr AllocationBase;
66
+ public uint AllocationProtect;
67
+ public uint Pad1;
68
+ public IntPtr RegionSize;
69
+ public uint State;
70
+ public uint Protect;
71
+ public uint Type;
72
+ public uint Pad2;
73
+ }
74
+
75
+ private static bool Readable(uint p)
76
+ {
77
+ switch (p)
78
+ {
79
+ case 0x02: case 0x04: case 0x06: case 0x08:
80
+ case 0x20: case 0x40: case 0x80:
81
+ case 0x42: case 0x44: case 0x60: case 0x62: case 0x64:
82
+ return true;
83
+ default:
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /// 扫一遍,把命中 needle 的区段整段拼起来返回(供上层用正则抠字段)
89
+ public static string Scan(int pid, string needle)
90
+ {
91
+ IntPtr h = OpenProcess(0x0410, false, pid);
92
+ if (h == IntPtr.Zero) return string.Empty;
93
+
94
+ StringBuilder sb = new StringBuilder();
95
+ long addr = 0;
96
+ MBI mbi = new MBI();
97
+ const long MAX_REGION = 64L * 1024 * 1024;
98
+
99
+ while (VirtualQueryEx(h, new IntPtr(addr), ref mbi, Marshal.SizeOf(typeof(MBI))) != 0)
100
+ {
101
+ long size = (long)mbi.RegionSize;
102
+ if (mbi.State == 0x1000 && size > 0 && size < MAX_REGION && Readable(mbi.Protect))
103
+ {
104
+ byte[] buf = new byte[size];
105
+ IntPtr read;
106
+ if (ReadProcessMemory(h, new IntPtr(addr), buf, (int)size, out read))
107
+ {
108
+ string s = Encoding.UTF8.GetString(buf, 0, (int)read);
109
+ if (s.IndexOf(needle, StringComparison.Ordinal) >= 0) sb.Append(s);
110
+ }
111
+ }
112
+ addr += size;
113
+ if (addr > 0x7FFFFFFFFFFFL) break;
114
+ }
115
+ CloseHandle(h);
116
+ return sb.ToString();
117
+ }
118
+ }
119
+ `
120
+
34
121
  /**
35
122
  * 从进程内存里读出登录态。
36
123
  *
@@ -39,7 +126,7 @@ function findPid(name = "MarvisAgent.exe") {
39
126
  * 2. 结构化对象 `{"openid":"...","accessToken":"...","refreshToken":"..."}`
40
127
  * 优先用 1(字段最全,带 logintype 和到期时刻)。
41
128
  *
42
- * @returns {Promise<{ openid: string, accessToken: string, refreshToken?: string, loginType: string, guid: string, nickname?: string, expiresAt?: number }>}
129
+ * @returns {Promise<{ openid: string, accessToken: string, refreshToken?: string, loginType: string, guid: string, expiresAt?: number }>}
43
130
  */
44
131
  export async function importFromDesktop() {
45
132
  if (process.platform !== "win32") {
@@ -49,103 +136,73 @@ export async function importFromDesktop() {
49
136
  if (!pid) {
50
137
  throw new Error("没找到运行中的 MarvisAgent.exe。请先打开 Marvis 客户端并登录,或改用 QQ / 微信 登录。")
51
138
  }
52
- const text = await readProcessStrings(pid)
53
- const acct = parseLoginState(text)
54
- if (!acct) {
55
- throw new Error(
56
- "读到了 MarvisAgent 进程,但里面没有登录态。请在 Marvis 客户端里登录一次再试,或改用 QQ / 微信 登录。",
139
+
140
+ const dir = mkdtempSync(join(tmpdir(), "magpie-marvis-scan-"))
141
+ try {
142
+ writeFileSync(join(dir, "Scan.cs"), CS, "utf8")
143
+ // 只输出命中区段的 base64,避免编码/长度问题
144
+ writeFileSync(
145
+ join(dir, "run.ps1"),
146
+ [
147
+ "$ErrorActionPreference = 'Stop'",
148
+ "Add-Type -Path (Join-Path $PSScriptRoot 'Scan.cs') -Language CSharp",
149
+ `$text = [MarvisMemScan]::Scan(${pid}, 'accesstoken=')`,
150
+ "if ([string]::IsNullOrEmpty($text)) { $text = [MarvisMemScan]::Scan(" + pid + ", 'accessToken') }",
151
+ "$b = [System.Text.Encoding]::UTF8.GetBytes($text)",
152
+ "[Console]::Out.Write([Convert]::ToBase64String($b))",
153
+ "",
154
+ ].join("\r\n"),
155
+ "utf8",
57
156
  )
58
- }
59
- return acct
60
- }
61
157
 
62
- /**
63
- * 扫进程可读内存,返回拼起来的文本。
64
- * 只取长度 < 64MiB 的已提交可读区段,避免爆内存。
65
- * @param {number} pid
66
- * @returns {Promise<string>}
67
- */
68
- async function readProcessStrings(pid) {
69
- // 用 PowerShell 做(不引第三方依赖,也不需要编译)。C# 片段走 Add-Type。
70
- const script = `
71
- $sig = @"
72
- using System;
73
- using System.Text;
74
- using System.Runtime.InteropServices;
75
- public class MS {
76
- [DllImport("kernel32.dll")] public static extern IntPtr OpenProcess(int a, bool b, int c);
77
- [DllImport("kernel32.dll")] public static extern bool ReadProcessMemory(IntPtr h, IntPtr a, byte[] b, int s, out IntPtr r);
78
- [DllImport("kernel32.dll")] public static extern int VirtualQueryEx(IntPtr h, IntPtr a, ref MBI m, int s);
79
- [DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);
80
- [StructLayout(LayoutKind.Sequential)]
81
- public struct MBI {
82
- public IntPtr BaseAddress, AllocationBase;
83
- public uint AllocationProtect, __a1;
84
- public IntPtr RegionSize;
85
- public uint State, Protect, Type, __a2;
86
- }
87
- public static string Scan(int pid, string needle) {
88
- var h = OpenProcess(0x0410, false, pid);
89
- if (h == IntPtr.Zero) return "";
90
- var sb = new StringBuilder();
91
- long addr = 0; var mbi = new MBI();
92
- var nb = Encoding.UTF8.GetBytes(needle);
93
- while (VirtualQueryEx(h, (IntPtr)addr, ref mbi, Marshal.SizeOf(mbi)) != 0) {
94
- long size = (long)mbi.RegionSize;
95
- if (mbi.State == 0x1000 && size > 0 && size < 67108864 &&
96
- (mbi.Protect == 0x02 || mbi.Protect == 0x04 || mbi.Protect == 0x06 ||
97
- mbi.Protect == 0x08 || mbi.Protect == 0x20 || mbi.Protect == 0x40 ||
98
- mbi.Protect == 0x80 || mbi.Protect == 0x42 || mbi.Protect == 0x44 ||
99
- mbi.Protect == 0x60 || mbi.Protect == 0x62 || mbi.Protect == 0x64)) {
100
- var buf = new byte[size];
101
- IntPtr read;
102
- if (ReadProcessMemory(h, (IntPtr)addr, buf, (int)size, out read)) {
103
- var s = Encoding.UTF8.GetString(buf, 0, (int)read);
104
- if (s.Contains(needle)) sb.Append(s);
105
- }
106
- }
107
- addr += size;
108
- if (addr > 0x7fffffffffffL) break;
158
+ const b64 = execFileSync(
159
+ "powershell",
160
+ ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", join(dir, "run.ps1")],
161
+ { encoding: "utf8", windowsHide: true, timeout: 180_000, maxBuffer: 128 * 1024 * 1024 },
162
+ ).trim()
163
+
164
+ const text = b64 ? Buffer.from(b64, "base64").toString("utf8") : ""
165
+ const acct = parseLoginState(text)
166
+ if (!acct) {
167
+ throw new Error(
168
+ "读到了 MarvisAgent 进程,但里面没有登录态。请在 Marvis 客户端里登录一次再试,或改用 QQ / 微信 登录。",
169
+ )
170
+ }
171
+ return acct
172
+ } finally {
173
+ try {
174
+ rmSync(dir, { recursive: true, force: true })
175
+ } catch {
176
+ /* 临时目录删不掉不影响主流程 */
109
177
  }
110
- CloseHandle(h);
111
- return sb.ToString();
112
178
  }
113
179
  }
114
- "@
115
- Add-Type -TypeDefinition $sig -Language CSharp
116
- [MS]::Scan(${pid}, "accesstoken=")
117
- [MS]::Scan(${pid}, "\\"accessToken\\":")
118
- `;
119
- const out = execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], {
120
- encoding: "utf8",
121
- windowsHide: true,
122
- timeout: 180_000,
123
- maxBuffer: 64 * 1024 * 1024,
124
- })
125
- return out
126
- }
127
180
 
128
181
  /**
129
182
  * 从内存文本里抠登录态。
130
183
  * @param {string} text
131
184
  */
132
185
  export function parseLoginState(text) {
186
+ if (!text) return null
187
+ const guid = deviceGuid()
188
+
133
189
  // 形态 1:cookie 串
134
- const cookie = /openid=([0-9A-Fa-f]{16,64});\s*accesstoken=([^;\s]+);\s*(?:refreshtoken=([^;\s]+);\s*)?logintype=([A-Za-z]+)/
190
+ const cookie = /openid=([0-9A-Fa-f]{16,64});\s*accesstoken=([^;\s]+);\s*(?:refreshtoken=([^;\s]+);\s*)?(?:logintype=([A-Za-z]+))?/
135
191
  let m = cookie.exec(text)
136
192
  if (m) {
137
- const expire = /access_token_expire_time=(\d+)/.exec(text)
193
+ const expire = /access_token_expire_time=(\d{10})/.exec(text)
138
194
  return {
139
195
  openid: m[1],
140
196
  accessToken: m[2],
141
197
  refreshToken: m[3] || undefined,
142
- loginType: /^WX$/i.test(m[4]) ? "5" : "6",
143
- guid: deviceGuid(),
198
+ loginType: /^WX$/i.test(m[4] ?? "") ? "5" : "6",
199
+ guid,
144
200
  expiresAt: expire ? Number(expire[1]) * 1000 : Date.now() + 30 * 24 * 3600_000,
145
201
  }
146
202
  }
147
- // 形态 2:JSON 对象
148
- const json = /"openid"\s*:\s*"([0-9A-Fa-f]{16,64})"[^}]{0,400}?"accessToken"\s*:\s*"([^"]+)"/
203
+
204
+ // 形态 2:`"openid":"...","accessToken":"..."` 结构化串
205
+ const json = /"openid"\s*:\s*"([0-9A-Fa-f]{16,64})"[\s\S]{0,600}?"accessToken"\s*:\s*"([^"]+)"/
149
206
  m = json.exec(text)
150
207
  if (m) {
151
208
  const rt = /"refreshToken"\s*:\s*"([^"]+)"/.exec(text)
@@ -154,7 +211,7 @@ export function parseLoginState(text) {
154
211
  accessToken: m[2],
155
212
  refreshToken: rt?.[1],
156
213
  loginType: "6",
157
- guid: deviceGuid(),
214
+ guid,
158
215
  expiresAt: Date.now() + 30 * 24 * 3600_000,
159
216
  }
160
217
  }
@@ -1,180 +1,267 @@
1
- // Marvis 登录 —— QQ / 微信 OAuth 授权码换 token
2
- //
3
- // 上游只给两种登录:QQ(QC)和微信(WX)。都是标准 OAuth2 授权码。
4
- //
5
- // ⚠ `redirect_uri` **改不了** —— 它固定在 Marvis 服务端
6
- // (`https://yybadaccess.3g.qq.com/marvis_client_login/marvis_oauth?login_type=QC`),
7
- // 是 QQ 互联里预注册过的。所以不能像别的插件那样起本地 loopback 收回调。
8
- //
9
- // 实际流程:插件打开授权页 → 用户在 QQ/微信里确认 → 浏览器跳到那个 redirect_uri,
10
- // 页面直接吐一段 JSON → 用户把地址栏里的 `code=XXX`(或整段 JSON)粘回来。
11
- // `ask()` 支持这种两步登录(HOOKS 四·陷阱 5:`prompts` 数组串不起来,必须用 `ask()`)。
12
-
13
- import { BASE, QQ_APP_ID, WX_APP_ID, BUSINESS_ID, LOGIN_TYPE } from "../context.mjs"
14
- import { ualSignature } from "./protocol.mjs"
15
- import { createHash } from "node:crypto"
16
- import { createRequire } from "node:module"
17
-
18
- const require = createRequire(import.meta.url)
19
-
20
- /** redirect_uri —— 与 Marvis 客户端逐字一致 */
21
- const REDIRECT = `${BASE}/marvis_client_login/marvis_oauth`
1
+ // Marvis 登录 —— QQ / 微信 OAuth 授权码换 token
2
+ //
3
+ // 上游只给两种登录:QQ(QC)和微信(WX)。都是标准 OAuth2 授权码。
4
+ //
5
+ // ⚠ `redirect_uri` **改不了** —— 它固定在 Marvis 服务端
6
+ // (`https://yybadaccess.3g.qq.com/marvis_client_login/marvis_oauth?login_type=QC`),
7
+ // 是 QQ 互联里预注册过的。所以不能像别的插件那样起本地 loopback 收回调。
8
+ //
9
+ // 实际流程:插件打开授权页 → 用户在 QQ/微信里确认 → 浏览器跳到那个 redirect_uri,
10
+ // 页面直接吐一段 JSON → 用户把地址栏里的 `code=XXX`(或整段 JSON)粘回来。
11
+ // `ask()` 支持这种两步登录(HOOKS 四·陷阱 5:`prompts` 数组串不起来,必须用 `ask()`)。
12
+
13
+ import { BASE, QQ_APP_ID, WX_APP_ID, BUSINESS_ID, LOGIN_TYPE } from "../context.mjs"
14
+ import { ualSignature } from "./protocol.mjs"
15
+ import { createHash } from "node:crypto"
16
+ import { createRequire } from "node:module"
17
+
18
+ const require = createRequire(import.meta.url)
19
+
20
+ /** redirect_uri —— 与 Marvis 客户端逐字一致 */
21
+ const REDIRECT = `${BASE}/marvis_client_login/marvis_oauth`
22
+
23
+ /**
24
+ * 拼授权页地址。
25
+ * @param {"QC" | "WX"} loginType
26
+ * @param {string} guid 设备 guid(微信的 state 就是它)
27
+ * @returns {string}
28
+ */
29
+ export function authorizeUrl(loginType, guid) {
30
+ if (loginType === "WX") {
31
+ const p = new URLSearchParams({
32
+ appid: WX_APP_ID,
33
+ redirect_uri: REDIRECT,
34
+ response_type: "code",
35
+ scope: "snsapi_login",
36
+ state: guid,
37
+ })
38
+ return `https://open.weixin.qq.com/connect/qrconnect?${p}`
39
+ }
40
+ // QQ:which=Login&display=pc 是 Marvis 客户端用的参数,别省
41
+ const p = new URLSearchParams({
42
+ which: "Login",
43
+ display: "pc",
44
+ response_type: "code",
45
+ client_id: QQ_APP_ID,
46
+ redirect_uri: REDIRECT,
47
+ state: String(Math.floor(Math.random() * 1e9)),
48
+ })
49
+ return `https://graph.qq.com/oauth2.0/authorize?${p}`
50
+ }
51
+
52
+ /**
53
+ * 从用户粘贴的内容里抠出授权码。
54
+ * 支持:纯 code / 完整回调 URL / 整段 JSON 里的 code 字段。
55
+ * @param {string} input
56
+ * @returns {string | null}
57
+ */
58
+ export function extractCode(input) {
59
+ const s = String(input ?? "").trim()
60
+ if (!s) return null
61
+ // 完整 URL
62
+ try {
63
+ const u = new URL(s)
64
+ const c = u.searchParams.get("code")
65
+ if (c) return c
66
+ } catch {
67
+ /* 不是 URL,往下走 */
68
+ }
69
+ // JSON
70
+ if (s.startsWith("{")) {
71
+ try {
72
+ const j = JSON.parse(s)
73
+ const c = j?.code ?? j?.user_info?.code ?? j?.data?.code
74
+ // 注意:失败响应的 code 是数字错误码,成功时才是字符串
75
+ if (typeof c === "string" && c.length > 4) return c
76
+ } catch {
77
+ /* 不是 JSON */
78
+ }
79
+ }
80
+ // 只有 code=xxx
81
+ const m = /(?:^|[?&])code=([^&\s]+)/.exec(s)
82
+ if (m) return decodeURIComponent(m[1])
83
+ // 裸 code:Marvis 的 code 是长串字母数字
84
+ if (/^[A-Za-z0-9_-]{8,}$/.test(s)) return s
85
+ return null
86
+ }
87
+
88
+ /**
89
+ * 用授权码换 token。
90
+ *
91
+ * 这个端点**自己带签名**(`md5(query+timestamp+accessKey+nonce)`),
92
+ * 不需要登录态 —— 它就是在发登录态。
93
+ *
94
+ * @param {string} code
95
+ * @param {"QC" | "WX"} loginType
96
+ * @param {string} guid 只用于 `Ual-Access-Guid` 请求头
97
+ * @param {string} [state] QQ 回调带回来的 state(原样回传)
98
+ * @returns {Promise<{ openid: string, accessToken: string, refreshToken?: string, nickname?: string, avatar?: string, loginType: string, expiresAt?: number }>}
99
+ */
100
+ export async function exchangeCode(code, loginType, guid, state) {
101
+ // ⚠ query 必须**逐字**等于 QQ 回调时的 redirect_uri 查询串 ——
102
+ // QQ 在换 token 时校验 `redirect_uri` 一致性,多一个参数(比如 guid)就 `-109 RC_PARAMS_INVALID`。
103
+ // guid 只在 authorize 的 state 里传递,不进这里。
104
+ const params = { login_type: loginType, code }
105
+ if (state) params.state = String(state)
106
+ const query = new URLSearchParams(params).toString()
107
+ const timestamp = String(Date.now())
108
+ const nonce = String(1000 + Math.floor(Math.random() * 9000))
109
+ const url = `${REDIRECT}?${query}`
110
+
111
+ const res = await fetch(url, {
112
+ method: "GET",
113
+ headers: {
114
+ Accept: "application/json",
115
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Marvis/1.60.3300.408 Chrome/152.0.7977.130 Electron/44.7.0 Safari/537.36",
116
+ "Ual-Access-Businessid": BUSINESS_ID,
117
+ "Ual-Access-Timestamp": timestamp,
118
+ "Ual-Access-Nonce": nonce,
119
+ "Ual-Access-Signature": ualSignature(query, timestamp, nonce),
120
+ "Ual-Access-Guid": guid,
121
+ "Ual-Access-Login-Type": LOGIN_TYPE[loginType] ?? "6",
122
+ },
123
+ })
124
+
125
+ const text = await res.text()
126
+ /** @type {any} */
127
+ let body
128
+ try {
129
+ body = JSON.parse(text)
130
+ } catch {
131
+ throw new Error(`登录接口返回的不是 JSON(${res.status}):${text.slice(0, 200)}`)
132
+ }
133
+
134
+ const info = body?.user_info ?? body?.data ?? null
135
+ const accessToken = info?.access_token ?? info?.accessToken ?? body?.access_token
136
+ const openid = info?.openid ?? info?.openId ?? info?.open_id
137
+
138
+ if (!accessToken || !openid) {
139
+ const errCode = body?.code ?? res.status
140
+ const hint =
141
+ errCode === -109
142
+ ? "(RC_PARAMS_INVALID:授权码无效或已被使用过。每次登录都会拿到一个新 code,一个 code 只能换一次 token,请重新走一遍登录)"
143
+ : ""
144
+ throw new Error(`登录失败(code=${errCode}):${body?.msg ?? "未返回 access_token / openid"}${hint}`)
145
+ }
146
+
147
+ const expire = Number(info?.access_token_expire_time ?? info?.expires_in ?? 0)
148
+ return {
149
+ openid: String(openid),
150
+ accessToken: String(accessToken),
151
+ refreshToken: info?.refresh_token ? String(info.refresh_token) : undefined,
152
+ nickname: info?.nick_name ? decodeURIComponent(info.nick_name) : undefined,
153
+ avatar: info?.head_img_url ?? undefined,
154
+ loginType: LOGIN_TYPE[loginType] ?? "6",
155
+ // 上游给的是**到期时刻**(秒);没给就按 30 天算
156
+ expiresAt: expire > 1e9 ? expire * 1000 : Date.now() + 30 * 24 * 3600_000,
157
+ }
158
+ }
159
+
160
+ /**
161
+ * 设备 guid。
162
+ *
163
+ * 来源:注册表 `HKCU\SOFTWARE\Tencent\Marvis\Env\svid`。
164
+ * 服务端按 `(openid, guid, token)` 三元组校验,但 guid 是**纯客户端自报**的 ——
165
+ * 换 guid 会 `-101 redis: nil`,所以它必须稳定,跟着 token 一起存。
166
+ *
167
+ * 没有注册表(没装 Marvis)时,用「本机机器 id 派生」的稳定值兜底 ——
168
+ * 同一台机器永远算出同一个,不会每次登录变。
169
+ *
170
+ * @returns {string} 32 位小写 hex
171
+ */
172
+ export function deviceGuid() {
173
+ try {
174
+ // @ts-ignore —— 只在 Windows 上有
175
+ const { execFileSync } = require("node:child_process")
176
+ const out = execFileSync(
177
+ "reg",
178
+ ["query", "HKCU\\SOFTWARE\\Tencent\\Marvis\\Env", "/v", "svid"],
179
+ { encoding: "utf8", windowsHide: true, timeout: 3000 },
180
+ )
181
+ const m = /svid\s+REG_SZ\s+([0-9a-fA-F]{32})/.exec(out)
182
+ if (m) return m[1].toLowerCase()
183
+ } catch {
184
+ /* 没装 Marvis 或没有这个键,用派生值 */
185
+ }
186
+ // 兜底:机器名 + 用户名 + 平台,哈希成 32 hex。跨进程稳定。
187
+ const { hostname, userInfo } = require("node:os")
188
+ const seed = `${hostname()}|${userInfo?.().username ?? ""}|marvis`
189
+ return createHash("md5").update(seed).digest("hex")
190
+ }
22
191
 
23
192
  /**
24
- * 拼授权页地址。
25
- * @param {"QC" | "WX"} loginType
26
- * @param {string} guid 设备 guid(微信的 state 就是它)
27
- * @returns {string}
193
+ * 从回调页里解析登录态。
194
+ *
195
+ * **主路径**:回调页本身就是换 token 的响应,正文是一段 JSON:
196
+ * `{"code":0,"msg":"","user_info":{"openid":"...","access_token":"mv_..."},"ext_info":{...}}`
197
+ * 所以直接读正文,**不要重发请求** —— 重发就是用第二个 code,必然 `-109 RC_PARAMS_INVALID`。
198
+ *
199
+ * **兜底**:正文取不到(页面被拦截 / 白屏)时,从地址栏抠 code 再换一次。
200
+ *
201
+ * @param {string} callbackUrl 回调页地址
202
+ * @param {string} [body] 回调页正文
203
+ * @returns {{ openid: string, accessToken: string, refreshToken?: string, nickname?: string, avatar?: string, loginType: string, expiresAt?: number }}
28
204
  */
29
- export function authorizeUrl(loginType, guid) {
30
- if (loginType === "WX") {
31
- const p = new URLSearchParams({
32
- appid: WX_APP_ID,
33
- redirect_uri: REDIRECT,
34
- response_type: "code",
35
- scope: "snsapi_login",
36
- state: guid,
37
- })
38
- return `https://open.weixin.qq.com/connect/qrconnect?${p}`
39
- }
40
- // QQ:which=Login&display=pc 是 Marvis 客户端用的参数,别省
41
- const p = new URLSearchParams({
42
- which: "Login",
43
- display: "pc",
44
- response_type: "code",
45
- client_id: QQ_APP_ID,
46
- redirect_uri: REDIRECT,
47
- state: String(Math.floor(Math.random() * 1e9)),
48
- })
49
- return `https://graph.qq.com/oauth2.0/authorize?${p}`
50
- }
205
+ export function parseCallback(callbackUrl, body) {
206
+ const text = String(body ?? "").trim()
51
207
 
52
- /**
53
- * 从用户粘贴的内容里抠出授权码。
54
- * 支持:纯 code / 完整回调 URL / 整段 JSON 里的 code 字段。
55
- * @param {string} input
56
- * @returns {string | null}
57
- */
58
- export function extractCode(input) {
59
- const s = String(input ?? "").trim()
60
- if (!s) return null
61
- // 完整 URL
62
- try {
63
- const u = new URL(s)
64
- const c = u.searchParams.get("code")
65
- if (c) return c
66
- } catch {
67
- /* 不是 URL,往下走 */
68
- }
69
- // JSON
70
- if (s.startsWith("{")) {
208
+ // ① 主路径:正文就是 JSON
209
+ if (text.startsWith("{")) {
210
+ /** @type {any} */
211
+ let body0
71
212
  try {
72
- const j = JSON.parse(s)
73
- const c = j?.code ?? j?.user_info?.code ?? j?.data?.code
74
- // 注意:失败响应的 code 是数字错误码,成功时才是字符串
75
- if (typeof c === "string" && c.length > 4) return c
213
+ body0 = JSON.parse(text)
76
214
  } catch {
77
- /* 不是 JSON */
215
+ body0 = null
216
+ }
217
+ if (body0) {
218
+ const biz = body0.code
219
+ if (typeof biz === "number" && biz !== 0) {
220
+ throw new Error(
221
+ `登录失败(code=${biz}):${body0.msg ?? "未返回用户信息"}` +
222
+ (biz === -109
223
+ ? "(授权码无效或已被使用过 —— 一个 code 只能换一次 token,请重新走一遍登录)"
224
+ : ""),
225
+ )
226
+ }
227
+ const info = body0.user_info ?? body0.data ?? body0
228
+ const accessToken = info?.access_token ?? info?.accessToken
229
+ const openid = info?.openid ?? info?.openId ?? info?.open_id
230
+ if (accessToken && openid) return normalize(info, accessToken, openid)
78
231
  }
79
232
  }
80
- // 只有 code=xxx
81
- const m = /(?:^|[?&])code=([^&\s]+)/.exec(s)
82
- if (m) return decodeURIComponent(m[1])
83
- // 裸 code:Marvis 的 code 是长串字母数字
84
- if (/^[A-Za-z0-9_-]{8,}$/.test(s)) return s
85
- return null
86
- }
87
-
88
- /**
89
- * 用授权码换 token。
90
- *
91
- * 这个端点**自己带签名**(`md5(query+timestamp+accessKey+nonce)`),
92
- * 不需要登录态 —— 它就是在发登录态。
93
- *
94
- * @param {string} code
95
- * @param {"QC" | "WX"} loginType
96
- * @param {string} guid
97
- * @returns {Promise<{ openid: string, accessToken: string, refreshToken?: string, nickname?: string, avatar?: string, loginType: string, expiresAt?: number }>}
98
- */
99
- export async function exchangeCode(code, loginType, guid) {
100
- const query = new URLSearchParams({ login_type: loginType, code, guid }).toString()
101
- const timestamp = String(Date.now())
102
- const nonce = String(1000 + Math.floor(Math.random() * 9000))
103
- const url = `${REDIRECT}?${query}`
104
-
105
- const res = await fetch(url, {
106
- method: "GET",
107
- headers: {
108
- Accept: "application/json",
109
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Marvis/1.60.3300.408 Chrome/152.0.7977.130 Electron/44.7.0 Safari/537.36",
110
- "Ual-Access-Businessid": BUSINESS_ID,
111
- "Ual-Access-Timestamp": timestamp,
112
- "Ual-Access-Nonce": nonce,
113
- "Ual-Access-Signature": ualSignature(query, timestamp, nonce),
114
- "Ual-Access-Guid": guid,
115
- "Ual-Access-Login-Type": LOGIN_TYPE[loginType] ?? "6",
116
- },
117
- })
118
-
119
- const text = await res.text()
120
- /** @type {any} */
121
- let body
122
- try {
123
- body = JSON.parse(text)
124
- } catch {
125
- throw new Error(`登录接口返回的不是 JSON(${res.status}):${text.slice(0, 200)}`)
126
- }
127
-
128
- const info = body?.user_info ?? body?.data ?? null
129
- const accessToken = info?.access_token ?? info?.accessToken ?? body?.access_token
130
- const openid = info?.openid ?? info?.openId ?? info?.open_id
131
233
 
132
- if (!accessToken || !openid) {
133
- const code = body?.code ?? res.status
134
- throw new Error(`登录失败(code=${code}):${body?.msg ?? "未返回 access_token / openid"}`)
234
+ // ② 兜底:从地址栏抠 code 再换
235
+ const code = extractCode(callbackUrl)
236
+ if (!code) {
237
+ throw new Error(
238
+ `回调页没有返回登录态(正文:${text.slice(0, 160) || "空"})。请重新走一遍登录。`,
239
+ )
135
240
  }
241
+ throw new Error(
242
+ `回调页没有直接返回 token(正文:${text.slice(0, 120) || "空"}),` +
243
+ `请重新走一遍登录。`,
244
+ )
245
+ }
136
246
 
247
+ /** 把上游 user_info 归一成插件内部形状 */
248
+ function normalize(info, accessToken, openid) {
137
249
  const expire = Number(info?.access_token_expire_time ?? info?.expires_in ?? 0)
138
250
  return {
139
251
  openid: String(openid),
140
252
  accessToken: String(accessToken),
141
253
  refreshToken: info?.refresh_token ? String(info.refresh_token) : undefined,
142
- nickname: info?.nick_name ? decodeURIComponent(info.nick_name) : undefined,
254
+ nickname: info?.nick_name ? safeDecode(info.nick_name) : undefined,
143
255
  avatar: info?.head_img_url ?? undefined,
144
- loginType: LOGIN_TYPE[loginType] ?? "6",
145
- // 上游给的是**到期时刻**(秒);没给就按 30 天算
256
+ loginType: "6",
146
257
  expiresAt: expire > 1e9 ? expire * 1000 : Date.now() + 30 * 24 * 3600_000,
147
258
  }
148
259
  }
149
260
 
150
- /**
151
- * 设备 guid。
152
- *
153
- * 来源:注册表 `HKCU\SOFTWARE\Tencent\Marvis\Env\svid`。
154
- * 服务端按 `(openid, guid, token)` 三元组校验,但 guid 是**纯客户端自报**的 ——
155
- * 换 guid 会 `-101 redis: nil`,所以它必须稳定,跟着 token 一起存。
156
- *
157
- * 没有注册表(没装 Marvis)时,用「本机机器 id 派生」的稳定值兜底 ——
158
- * 同一台机器永远算出同一个,不会每次登录变。
159
- *
160
- * @returns {string} 32 位小写 hex
161
- */
162
- export function deviceGuid() {
261
+ function safeDecode(s) {
163
262
  try {
164
- // @ts-ignore —— 只在 Windows 上有
165
- const { execFileSync } = require("node:child_process")
166
- const out = execFileSync(
167
- "reg",
168
- ["query", "HKCU\\SOFTWARE\\Tencent\\Marvis\\Env", "/v", "svid"],
169
- { encoding: "utf8", windowsHide: true, timeout: 3000 },
170
- )
171
- const m = /svid\s+REG_SZ\s+([0-9a-fA-F]{32})/.exec(out)
172
- if (m) return m[1].toLowerCase()
263
+ return decodeURIComponent(String(s))
173
264
  } catch {
174
- /* 没装 Marvis 或没有这个键,用派生值 */
265
+ return String(s)
175
266
  }
176
- // 兜底:机器名 + 用户名 + 平台,哈希成 32 hex。跨进程稳定。
177
- const { hostname, userInfo } = require("node:os")
178
- const seed = `${hostname()}|${userInfo?.().username ?? ""}|marvis`
179
- return createHash("md5").update(seed).digest("hex")
180
267
  }