magpie-marvis 0.1.6 → 0.1.7
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 +12 -10
- package/package.json +1 -1
- package/src/hooks/methods.mjs +139 -138
- package/src/marvis/browser.mjs +33 -29
- package/src/marvis/desktop.mjs +4 -1
- package/src/marvis/login.mjs +91 -103
package/CHANGELOG.md
CHANGED
|
@@ -3,17 +3,19 @@
|
|
|
3
3
|
> 每个版本一节,**一条一行**,说用户能感知的变化。
|
|
4
4
|
> 格式与规矩见 [UPDATING.md](./UPDATING.md)。
|
|
5
5
|
|
|
6
|
-
## 0.1.
|
|
6
|
+
## 0.1.7 — 2026-11-06
|
|
7
7
|
|
|
8
8
|
**修复**
|
|
9
9
|
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
10
|
+
- **浏览器登录「授权码已被使用过」(`-109`)**:根因是 **QQ 会把 `redirect_uri` 的查询串整个丢掉** ——
|
|
11
|
+
落地页收到的是 `?code=...&state=...`,**没有 `login_type`**,服务端因此判参数无效。
|
|
12
|
+
改成用 CDP `Fetch.requestPaused` 在**跳转前**拦下回调,自己从 URL 取 `code` 再带 `login_type` 换。
|
|
13
|
+
页面同时显示「授权完成」,用户不用对着 `-109` 的 JSON 发愣
|
|
14
|
+
- **换 token 带了鉴权头**:`marvis_oauth` 是**免鉴权**端点,带 `Ual-Access-*` 签名头会直接 400 空响应
|
|
15
|
+
- **`deviceGuid()` 永远取不到注册表**:JS 里 `\S` `\T` 是无效转义,反斜杠被吃掉,
|
|
16
|
+
`reg` 收到 `HKCUSOFTWARETencentMarvisEnv` → 「无效参数」。改用 `String.raw`
|
|
17
|
+
- **本机导入的 guid 与 token 不同源**:服务端按 `(openid, guid, token)` 三元组校验,
|
|
18
|
+
现在优先从**同一段内存**里取 `guid=`
|
|
19
|
+
- 去掉 `--disable-blink-features=AutomationControlled`(Edge 会弹「不受支持的命令行标记」警告条),
|
|
20
|
+
改用 CDP `Page.addScriptToEvaluateOnNewDocument` 抹掉 `navigator.webdriver`
|
|
19
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magpie-marvis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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",
|
package/src/hooks/methods.mjs
CHANGED
|
@@ -1,138 +1,139 @@
|
|
|
1
|
-
// ② auth.methods —— 两条登录路
|
|
2
|
-
//
|
|
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"`,整个登录就是「点一下 → 读内存 → 成功」。
|
|
10
|
-
//
|
|
11
|
-
// ⚠ `authorize()` 必须**立刻**返回 —— 真正等用户的是 `callback()`。
|
|
12
|
-
// 在这里 await 登录会和 magpie 双向死锁(HOOKS §四·陷阱1)。
|
|
13
|
-
//
|
|
14
|
-
// ⚠ 为什么不能像别的插件那样起本地 loopback 收回调:redirect_uri 是 QQ 互联里
|
|
15
|
-
// 预注册死的,改不了(详见 src/marvis/login.mjs 顶部)。所以只能「跳过去 → 读地址栏」。
|
|
16
|
-
|
|
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"
|
|
20
|
-
import { resolveAccount } from "../account-name.mjs"
|
|
21
|
-
import { importFromDesktop, desktopAvailable } from "../marvis/desktop.mjs"
|
|
22
|
-
|
|
23
|
-
/** 回调地址判据:跳到 marvis_oauth 且带 code= */
|
|
24
|
-
const CALLBACK = /marvis_client_login\/marvis_oauth\?[^#]*\bcode=/i
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* 登录成功时给宿主的字段。
|
|
28
|
-
* `accountId` 是**名字**(卡片上显示),`uid` 是身份(HOOKS §八)。
|
|
29
|
-
* ⚠ `refresh` 必须有 —— 缺了它 magpie 不会把账号写进 plugin-auth.json,
|
|
30
|
-
* 表现是「登录显示成功,但界面说无法列出账号」。
|
|
31
|
-
*/
|
|
32
|
-
export function authOf(acct) {
|
|
33
|
-
const { accountId, uid } = resolveAccount({
|
|
34
|
-
name: acct.nickname ?? "",
|
|
35
|
-
phone: "",
|
|
36
|
-
urn: acct.openid,
|
|
37
|
-
})
|
|
38
|
-
return {
|
|
39
|
-
type: "success",
|
|
40
|
-
access: acct.accessToken,
|
|
41
|
-
// token 30 天有效,refresh 会成对轮换 —— 这里存自己,refresh 钩子负责换新
|
|
42
|
-
refresh: acct.refreshToken ?? acct.accessToken,
|
|
43
|
-
expires: acct.expiresAt ?? 0,
|
|
44
|
-
...(accountId ? { accountId } : {}),
|
|
45
|
-
...(uid ? { uid } : {}),
|
|
46
|
-
// 落盘的额外字段:模型请求随时要用
|
|
47
|
-
metadata: {
|
|
48
|
-
openid: acct.openid,
|
|
49
|
-
guid: acct.guid ?? deviceGuid(),
|
|
50
|
-
loginType: acct.loginType ?? "6",
|
|
51
|
-
nickname: acct.nickname ?? "",
|
|
52
|
-
},
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** 授权页登录(QQ / 微信),走隔离浏览器 */
|
|
57
|
-
function oauthLogin(loginType, label, instructions) {
|
|
58
|
-
return {
|
|
59
|
-
type: "oauth",
|
|
60
|
-
label,
|
|
61
|
-
async authorize() {
|
|
62
|
-
const guid = deviceGuid()
|
|
63
|
-
const browser = findBrowser()
|
|
64
|
-
const url = authorizeUrl(loginType, guid)
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
url,
|
|
68
|
-
// 我们自己开隔离浏览器,magpie 不要再开系统默认浏览器
|
|
69
|
-
opened: true,
|
|
70
|
-
method: "auto",
|
|
71
|
-
instructions,
|
|
72
|
-
async callback(_url, _input, signal) {
|
|
73
|
-
if (!browser) {
|
|
74
|
-
throw new Error(
|
|
75
|
-
"没有找到可用的 Chromium 内核浏览器(Chrome / Edge / 360 / QQ 浏览器都行)。" +
|
|
76
|
-
"请装一个,或改用「从本机 Marvis 导入」。",
|
|
77
|
-
)
|
|
78
|
-
}
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"
|
|
103
|
-
"
|
|
104
|
-
|
|
105
|
-
"
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
"
|
|
110
|
-
"
|
|
111
|
-
|
|
112
|
-
"
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
1
|
+
// ② auth.methods —— 两条登录路
|
|
2
|
+
//
|
|
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"`,整个登录就是「点一下 → 读内存 → 成功」。
|
|
10
|
+
//
|
|
11
|
+
// ⚠ `authorize()` 必须**立刻**返回 —— 真正等用户的是 `callback()`。
|
|
12
|
+
// 在这里 await 登录会和 magpie 双向死锁(HOOKS §四·陷阱1)。
|
|
13
|
+
//
|
|
14
|
+
// ⚠ 为什么不能像别的插件那样起本地 loopback 收回调:redirect_uri 是 QQ 互联里
|
|
15
|
+
// 预注册死的,改不了(详见 src/marvis/login.mjs 顶部)。所以只能「跳过去 → 读地址栏」。
|
|
16
|
+
|
|
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"
|
|
20
|
+
import { resolveAccount } from "../account-name.mjs"
|
|
21
|
+
import { importFromDesktop, desktopAvailable } from "../marvis/desktop.mjs"
|
|
22
|
+
|
|
23
|
+
/** 回调地址判据:跳到 marvis_oauth 且带 code= */
|
|
24
|
+
const CALLBACK = /marvis_client_login\/marvis_oauth\?[^#]*\bcode=/i
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 登录成功时给宿主的字段。
|
|
28
|
+
* `accountId` 是**名字**(卡片上显示),`uid` 是身份(HOOKS §八)。
|
|
29
|
+
* ⚠ `refresh` 必须有 —— 缺了它 magpie 不会把账号写进 plugin-auth.json,
|
|
30
|
+
* 表现是「登录显示成功,但界面说无法列出账号」。
|
|
31
|
+
*/
|
|
32
|
+
export function authOf(acct) {
|
|
33
|
+
const { accountId, uid } = resolveAccount({
|
|
34
|
+
name: acct.nickname ?? "",
|
|
35
|
+
phone: "",
|
|
36
|
+
urn: acct.openid,
|
|
37
|
+
})
|
|
38
|
+
return {
|
|
39
|
+
type: "success",
|
|
40
|
+
access: acct.accessToken,
|
|
41
|
+
// token 30 天有效,refresh 会成对轮换 —— 这里存自己,refresh 钩子负责换新
|
|
42
|
+
refresh: acct.refreshToken ?? acct.accessToken,
|
|
43
|
+
expires: acct.expiresAt ?? 0,
|
|
44
|
+
...(accountId ? { accountId } : {}),
|
|
45
|
+
...(uid ? { uid } : {}),
|
|
46
|
+
// 落盘的额外字段:模型请求随时要用
|
|
47
|
+
metadata: {
|
|
48
|
+
openid: acct.openid,
|
|
49
|
+
guid: acct.guid ?? deviceGuid(),
|
|
50
|
+
loginType: acct.loginType ?? "6",
|
|
51
|
+
nickname: acct.nickname ?? "",
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 授权页登录(QQ / 微信),走隔离浏览器 */
|
|
57
|
+
function oauthLogin(loginType, label, instructions) {
|
|
58
|
+
return {
|
|
59
|
+
type: "oauth",
|
|
60
|
+
label,
|
|
61
|
+
async authorize() {
|
|
62
|
+
const guid = deviceGuid()
|
|
63
|
+
const browser = findBrowser()
|
|
64
|
+
const url = authorizeUrl(loginType, guid)
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
url,
|
|
68
|
+
// 我们自己开隔离浏览器,magpie 不要再开系统默认浏览器
|
|
69
|
+
opened: true,
|
|
70
|
+
method: "auto",
|
|
71
|
+
instructions,
|
|
72
|
+
async callback(_url, _input, signal) {
|
|
73
|
+
if (!browser) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
"没有找到可用的 Chromium 内核浏览器(Chrome / Edge / 360 / QQ 浏览器都行)。" +
|
|
76
|
+
"请装一个,或改用「从本机 Marvis 导入」。",
|
|
77
|
+
)
|
|
78
|
+
}
|
|
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
|
+
// 拦截时 body 为空 -> parseCallback 自己拿 code + login_type 去换
|
|
91
|
+
const acct = await parseCallback(callbackUrl, body, { loginType })
|
|
92
|
+
return authOf({ ...acct, guid, loginType: acct.loginType ?? LOGIN_TYPE[loginType] ?? "6" })
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function methods() {
|
|
100
|
+
return [
|
|
101
|
+
oauthLogin(
|
|
102
|
+
"QC",
|
|
103
|
+
"QQ 登录",
|
|
104
|
+
"1) 会打开一个**独立的**浏览器窗口(不碰你日常浏览器)\n" +
|
|
105
|
+
"2) 用你的 QQ 登录并确认授权\n" +
|
|
106
|
+
"3) 授权完成后窗口会自己关掉,回到 magpie 即可",
|
|
107
|
+
),
|
|
108
|
+
oauthLogin(
|
|
109
|
+
"WX",
|
|
110
|
+
"微信登录",
|
|
111
|
+
"1) 会打开一个**独立的**浏览器窗口(不碰你日常浏览器)\n" +
|
|
112
|
+
"2) 用微信扫码并确认\n" +
|
|
113
|
+
"3) 授权完成后窗口会自己关掉,回到 magpie 即可",
|
|
114
|
+
),
|
|
115
|
+
{
|
|
116
|
+
type: "oauth",
|
|
117
|
+
label: "从本机 Marvis 导入(需已安装并登录)",
|
|
118
|
+
async authorize() {
|
|
119
|
+
const ok = await desktopAvailable()
|
|
120
|
+
return {
|
|
121
|
+
// ⚠ 必须 opened: true + method: "auto",否则 magpie 会去开这个 url
|
|
122
|
+
// (之前给 about:blank 弹了一次「没有可打开此链接的应用」)
|
|
123
|
+
url: "",
|
|
124
|
+
opened: true,
|
|
125
|
+
method: "auto",
|
|
126
|
+
instructions: ok
|
|
127
|
+
? "已检测到本机 Marvis 客户端,正在读取登录态…"
|
|
128
|
+
: "本机没找到运行中的 Marvis 客户端。请先打开 Marvis 并登录,或改用 QQ / 微信 登录。",
|
|
129
|
+
async callback() {
|
|
130
|
+
const acct = await importFromDesktop()
|
|
131
|
+
return authOf(acct)
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { PROVIDER, CALLBACK }
|
package/src/marvis/browser.mjs
CHANGED
|
@@ -295,16 +295,21 @@ export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 6
|
|
|
295
295
|
acceptLanguage: "zh-CN,zh;q=0.9",
|
|
296
296
|
platform: "Win32",
|
|
297
297
|
}).catch(() => {})
|
|
298
|
+
// 顺手抹掉 navigator.webdriver(比 --disable-blink-features 更干净,不带警告条)
|
|
299
|
+
await send(3, "Page.addScriptToEvaluateOnNewDocument", {
|
|
300
|
+
source: "Object.defineProperty(navigator,'webdriver',{get:()=>undefined})",
|
|
301
|
+
}).catch(() => {})
|
|
298
302
|
|
|
299
|
-
|
|
303
|
+
// ⚠ 核心:**在跳转发生前**把回调请求拦下来。
|
|
304
|
+
// 实测 QQ 会把 redirect_uri 的查询串整个丢掉 —— 落地页收到的
|
|
305
|
+
// `?code=...&state=...` 里**没有 login_type**,服务端直接 `-109 RC_PARAMS_INVALID`。
|
|
306
|
+
// 所以不能靠那个页面的响应,只能自己从 URL 里取 code,再带上 login_type 换一次。
|
|
307
|
+
await send(4, "Fetch.enable", {
|
|
308
|
+
patterns: [{ urlPattern: "*marvis_client_login/marvis_oauth*", requestStage: "Request" }],
|
|
309
|
+
}).catch(() => {})
|
|
300
310
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
// 第一次消耗掉,服务端回 `-109 RC_PARAMS_INVALID`(「授权码已被使用过」)。
|
|
304
|
-
// 所以只认**第一次**跳到回调地址的那个响应体。
|
|
305
|
-
/** @type {Map<string,string>} requestId -> url */
|
|
306
|
-
const pending = new Map()
|
|
307
|
-
/** @type {{url:string, body:string} | null} */
|
|
311
|
+
let nextId = 10
|
|
312
|
+
/** @type {{url:string} | null} */
|
|
308
313
|
let hit = null
|
|
309
314
|
|
|
310
315
|
ws.addEventListener("message", (event) => {
|
|
@@ -314,34 +319,33 @@ export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 6
|
|
|
314
319
|
} catch {
|
|
315
320
|
return
|
|
316
321
|
}
|
|
317
|
-
if (msg.method === "
|
|
318
|
-
const { requestId,
|
|
319
|
-
const u = String(
|
|
320
|
-
if (u && match.test(u))
|
|
322
|
+
if (msg.method === "Fetch.requestPaused" && !hit) {
|
|
323
|
+
const { requestId, request } = msg.params ?? {}
|
|
324
|
+
const u = String(request?.url ?? "")
|
|
325
|
+
if (u && match.test(u)) {
|
|
326
|
+
hit = { url: u }
|
|
327
|
+
// 让页面显示一句「授权完成」,避免用户对着 -109 的 JSON 发愣
|
|
328
|
+
send(nextId++, "Fetch.fulfillRequest", {
|
|
329
|
+
requestId,
|
|
330
|
+
responseCode: 200,
|
|
331
|
+
responseHeaders: [{ name: "content-type", value: "text/html; charset=utf-8" }],
|
|
332
|
+
body: Buffer.from(
|
|
333
|
+
"<!doctype html><meta charset=utf-8><title>Marvis 授权完成</title>" +
|
|
334
|
+
"<body style=\"font:16px/1.6 system-ui;padding:48px;color:#111\">" +
|
|
335
|
+
"<h2>授权完成</h2><p>回到 magpie 即可,窗口会自动关闭。</p></body>",
|
|
336
|
+
"utf8",
|
|
337
|
+
).toString("base64"),
|
|
338
|
+
}).catch(() => {})
|
|
339
|
+
}
|
|
321
340
|
}
|
|
322
341
|
})
|
|
323
342
|
|
|
324
343
|
while (Date.now() < deadline) {
|
|
325
344
|
if (signal?.aborted) throw new Error("登录已取消")
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (pending.size > 0 && !hit) {
|
|
329
|
-
const [requestId, u] = [...pending.entries()][0]
|
|
330
|
-
pending.delete(requestId)
|
|
331
|
-
try {
|
|
332
|
-
const r = await send(nextId++, "Network.getResponseBody", { requestId })
|
|
333
|
-
const body = String(r?.body ?? "")
|
|
334
|
-
const decoded = r?.base64Encoded ? Buffer.from(body, "base64").toString("utf8") : body
|
|
335
|
-
hit = { url: u, body: decoded }
|
|
336
|
-
break
|
|
337
|
-
} catch {
|
|
338
|
-
/* body 还没就绪,下一轮再取 */
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
await new Promise((r) => setTimeout(r, 500))
|
|
345
|
+
if (hit) return { url: hit.url, body: "", browser: browser.name }
|
|
346
|
+
await new Promise((r) => setTimeout(r, 400))
|
|
342
347
|
}
|
|
343
348
|
|
|
344
|
-
if (hit) return { url: hit.url, body: hit.body, browser: browser.name }
|
|
345
349
|
throw new Error("登录超时:没有等到授权回调。请在打开的窗口里完成 QQ / 微信 授权。")
|
|
346
350
|
} finally {
|
|
347
351
|
try {
|
package/src/marvis/desktop.mjs
CHANGED
|
@@ -215,16 +215,19 @@ export function parseLoginState(text) {
|
|
|
215
215
|
const guid = deviceGuid()
|
|
216
216
|
|
|
217
217
|
// 形态 1:cookie 串
|
|
218
|
+
// ⚠ guid 优先从**同一段内存**里取 —— 服务端按 (openid, guid, token) 三元组校验,
|
|
219
|
+
// 注册表里的值理论上一致,但内存里的这份才是这个 token 真正绑定的那个。
|
|
218
220
|
const cookie = /openid=([0-9A-Fa-f]{16,64});\s*accesstoken=([^;\s]+);\s*(?:refreshtoken=([^;\s]+);\s*)?(?:logintype=([A-Za-z]+))?/
|
|
219
221
|
let m = cookie.exec(text)
|
|
220
222
|
if (m) {
|
|
221
223
|
const expire = /access_token_expire_time=(\d{10})/.exec(text)
|
|
224
|
+
const memGuid = /guid=([0-9a-fA-F]{32})/.exec(text)
|
|
222
225
|
return {
|
|
223
226
|
openid: m[1],
|
|
224
227
|
accessToken: m[2],
|
|
225
228
|
refreshToken: m[3] || undefined,
|
|
226
229
|
loginType: /^WX$/i.test(m[4] ?? "") ? "5" : "6",
|
|
227
|
-
guid,
|
|
230
|
+
guid: memGuid ? memGuid[1].toLowerCase() : guid,
|
|
228
231
|
expiresAt: expire ? Number(expire[1]) * 1000 : Date.now() + 30 * 24 * 3600_000,
|
|
229
232
|
}
|
|
230
233
|
}
|
package/src/marvis/login.mjs
CHANGED
|
@@ -97,29 +97,27 @@ export function extractCode(input) {
|
|
|
97
97
|
* @param {string} [state] QQ 回调带回来的 state(原样回传)
|
|
98
98
|
* @returns {Promise<{ openid: string, accessToken: string, refreshToken?: string, nickname?: string, avatar?: string, loginType: string, expiresAt?: number }>}
|
|
99
99
|
*/
|
|
100
|
-
export async function exchangeCode(code, loginType,
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
100
|
+
export async function exchangeCode(code, loginType, _guid, state) {
|
|
101
|
+
// ⚠⚠ 这个端点**不需要任何鉴权头**(它就是在发登录态)。
|
|
102
|
+
// 实测:带上 `Ual-Access-*` 签名头 -> **400 空响应**;不带 -> `200 {code:...}`。
|
|
103
|
+
// 所以千万别在这里复用 businessHeaders()。
|
|
104
|
+
//
|
|
105
|
+
// ⚠ query 里**只能有 login_type 和 code**:多一个参数服务端就 `-109 RC_PARAMS_INVALID`。
|
|
104
106
|
const params = { login_type: loginType, code }
|
|
105
107
|
if (state) params.state = String(state)
|
|
106
108
|
const query = new URLSearchParams(params).toString()
|
|
107
|
-
const timestamp = String(Date.now())
|
|
108
|
-
const nonce = String(1000 + Math.floor(Math.random() * 9000))
|
|
109
109
|
const url = `${REDIRECT}?${query}`
|
|
110
110
|
|
|
111
111
|
const res = await fetch(url, {
|
|
112
112
|
method: "GET",
|
|
113
113
|
headers: {
|
|
114
114
|
Accept: "application/json",
|
|
115
|
-
|
|
116
|
-
"
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
"Ual-Access-Signature": ualSignature(query, timestamp, nonce),
|
|
120
|
-
"Ual-Access-Guid": guid,
|
|
121
|
-
"Ual-Access-Login-Type": LOGIN_TYPE[loginType] ?? "6",
|
|
115
|
+
// 伪装成 Marvis 自己的浏览器 —— 服务端按 UA 区分客户端
|
|
116
|
+
"User-Agent":
|
|
117
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
|
118
|
+
"Marvis/1.60.3300.408 Chrome/152.0.7977.130 Electron/44.7.0 Safari/537.36",
|
|
122
119
|
},
|
|
120
|
+
signal: AbortSignal.timeout(30_000),
|
|
123
121
|
})
|
|
124
122
|
|
|
125
123
|
const text = await res.text()
|
|
@@ -128,7 +126,7 @@ export async function exchangeCode(code, loginType, guid, state) {
|
|
|
128
126
|
try {
|
|
129
127
|
body = JSON.parse(text)
|
|
130
128
|
} catch {
|
|
131
|
-
throw new Error(`登录接口返回的不是 JSON
|
|
129
|
+
throw new Error(`登录接口返回的不是 JSON(HTTP ${res.status}):${text.slice(0, 200) || "空响应"}`)
|
|
132
130
|
}
|
|
133
131
|
|
|
134
132
|
const info = body?.user_info ?? body?.data ?? null
|
|
@@ -139,24 +137,91 @@ export async function exchangeCode(code, loginType, guid, state) {
|
|
|
139
137
|
const errCode = body?.code ?? res.status
|
|
140
138
|
const hint =
|
|
141
139
|
errCode === -109
|
|
142
|
-
? "(RC_PARAMS_INVALID
|
|
140
|
+
? "(RC_PARAMS_INVALID:授权码无效或已被使用过。一个 code 只能换一次 token,请重新走一遍登录)"
|
|
143
141
|
: ""
|
|
144
142
|
throw new Error(`登录失败(code=${errCode}):${body?.msg ?? "未返回 access_token / openid"}${hint}`)
|
|
145
143
|
}
|
|
146
144
|
|
|
145
|
+
return normalize(info, accessToken, openid)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 从回调里拿到登录态。
|
|
150
|
+
*
|
|
151
|
+
* **主路径**(推荐,已实测):回调请求被 CDP 在**跳转前**拦下,我们只拿到 URL,
|
|
152
|
+
* 由自己带上 `login_type` 去换 token。原因见下方 ⚠。
|
|
153
|
+
*
|
|
154
|
+
* **兜底**:如果 URL 里取不到 code、但页面正文是一段 JSON(旧路径),也认。
|
|
155
|
+
*
|
|
156
|
+
* ⚠ 为什么不能依赖那个落地页的响应:实测 QQ 会把 `redirect_uri` 的查询串
|
|
157
|
+
* **整个丢掉** —— 落地页收到的是 `?code=...&state=...`,**没有 `login_type`**,
|
|
158
|
+
* 服务端因此返回 `-109 RC_PARAMS_INVALID`。所以必须自己取 code 再换。
|
|
159
|
+
*
|
|
160
|
+
* @param {string} callbackUrl 回调地址(含 code)
|
|
161
|
+
* @param {string} [body] 回调页正文(CDP 拦截时为空)
|
|
162
|
+
* @param {{ loginType?: string, guid?: string }} [opts]
|
|
163
|
+
*/
|
|
164
|
+
export async function parseCallback(callbackUrl, body, opts = {}) {
|
|
165
|
+
const text = String(body ?? "").trim()
|
|
166
|
+
|
|
167
|
+
// 兜底:正文就是 JSON(旧路径)
|
|
168
|
+
if (text.startsWith("{")) {
|
|
169
|
+
/** @type {any} */
|
|
170
|
+
let body0
|
|
171
|
+
try {
|
|
172
|
+
body0 = JSON.parse(text)
|
|
173
|
+
} catch {
|
|
174
|
+
body0 = null
|
|
175
|
+
}
|
|
176
|
+
if (body0) {
|
|
177
|
+
const biz = body0.code
|
|
178
|
+
const info = body0.user_info ?? body0.data ?? body0
|
|
179
|
+
const accessToken = info?.access_token ?? info?.accessToken
|
|
180
|
+
const openid = info?.openid ?? info?.openId ?? info?.open_id
|
|
181
|
+
if (typeof biz === "number" && biz !== 0) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`登录失败(code=${biz}):${body0.msg ?? "未返回用户信息"}` +
|
|
184
|
+
(biz === -109
|
|
185
|
+
? "(授权码无效或已被使用过 —— 一个 code 只能换一次 token,请重新走一遍登录)"
|
|
186
|
+
: ""),
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
if (accessToken && openid) return normalize(info, accessToken, openid)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 主路径:从 URL 取 code,自己换
|
|
194
|
+
const code = extractCode(callbackUrl)
|
|
195
|
+
if (!code) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`没能从回调地址里取出授权码(${String(callbackUrl).slice(0, 160) || "空"})。请重新走一遍登录。`,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
return exchangeCode(code, opts.loginType ?? "QC", opts.guid, undefined)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 把上游 user_info 归一成插件内部形状 */
|
|
204
|
+
function normalize(info, accessToken, openid) {
|
|
147
205
|
const expire = Number(info?.access_token_expire_time ?? info?.expires_in ?? 0)
|
|
148
206
|
return {
|
|
149
207
|
openid: String(openid),
|
|
150
208
|
accessToken: String(accessToken),
|
|
151
209
|
refreshToken: info?.refresh_token ? String(info.refresh_token) : undefined,
|
|
152
|
-
nickname: info?.nick_name ?
|
|
210
|
+
nickname: info?.nick_name ? safeDecode(info.nick_name) : undefined,
|
|
153
211
|
avatar: info?.head_img_url ?? undefined,
|
|
154
|
-
loginType:
|
|
155
|
-
// 上游给的是**到期时刻**(秒);没给就按 30 天算
|
|
212
|
+
loginType: "6",
|
|
156
213
|
expiresAt: expire > 1e9 ? expire * 1000 : Date.now() + 30 * 24 * 3600_000,
|
|
157
214
|
}
|
|
158
215
|
}
|
|
159
216
|
|
|
217
|
+
function safeDecode(s) {
|
|
218
|
+
try {
|
|
219
|
+
return decodeURIComponent(String(s))
|
|
220
|
+
} catch {
|
|
221
|
+
return String(s)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
160
225
|
/**
|
|
161
226
|
* 设备 guid。
|
|
162
227
|
*
|
|
@@ -164,104 +229,27 @@ export async function exchangeCode(code, loginType, guid, state) {
|
|
|
164
229
|
* 服务端按 `(openid, guid, token)` 三元组校验,但 guid 是**纯客户端自报**的 ——
|
|
165
230
|
* 换 guid 会 `-101 redis: nil`,所以它必须稳定,跟着 token 一起存。
|
|
166
231
|
*
|
|
167
|
-
* 没有注册表(没装 Marvis
|
|
232
|
+
* 没有注册表(没装 Marvis)时,用「本机机器名 + 用户名」派生 ——
|
|
168
233
|
* 同一台机器永远算出同一个,不会每次登录变。
|
|
169
234
|
*
|
|
170
235
|
* @returns {string} 32 位小写 hex
|
|
171
236
|
*/
|
|
172
237
|
export function deviceGuid() {
|
|
173
238
|
try {
|
|
174
|
-
// @ts-ignore —— 只在 Windows 上有
|
|
175
239
|
const { execFileSync } = require("node:child_process")
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
240
|
+
// ⚠ 必须 String.raw:JS 里 `\S` `\T` 是**无效转义**,反斜杠会被吃掉,
|
|
241
|
+
// 传给 reg 的路径变成 `HKCUSOFTWARETencentMarvisEnv` -> 「无效参数」。
|
|
242
|
+
const out = execFileSync("reg", ["query", String.raw`HKCU\SOFTWARE\Tencent\Marvis\Env`, "/v", "svid"], {
|
|
243
|
+
encoding: "utf8",
|
|
244
|
+
windowsHide: true,
|
|
245
|
+
timeout: 3000,
|
|
246
|
+
})
|
|
181
247
|
const m = /svid\s+REG_SZ\s+([0-9a-fA-F]{32})/.exec(out)
|
|
182
248
|
if (m) return m[1].toLowerCase()
|
|
183
249
|
} catch {
|
|
184
250
|
/* 没装 Marvis 或没有这个键,用派生值 */
|
|
185
251
|
}
|
|
186
|
-
// 兜底:机器名 + 用户名 + 平台,哈希成 32 hex。跨进程稳定。
|
|
187
252
|
const { hostname, userInfo } = require("node:os")
|
|
188
253
|
const seed = `${hostname()}|${userInfo?.().username ?? ""}|marvis`
|
|
189
254
|
return createHash("md5").update(seed).digest("hex")
|
|
190
255
|
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
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 }}
|
|
204
|
-
*/
|
|
205
|
-
export function parseCallback(callbackUrl, body) {
|
|
206
|
-
const text = String(body ?? "").trim()
|
|
207
|
-
|
|
208
|
-
// ① 主路径:正文就是 JSON
|
|
209
|
-
if (text.startsWith("{")) {
|
|
210
|
-
/** @type {any} */
|
|
211
|
-
let body0
|
|
212
|
-
try {
|
|
213
|
-
body0 = JSON.parse(text)
|
|
214
|
-
} catch {
|
|
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)
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// ② 兜底:从地址栏抠 code 再换
|
|
235
|
-
const code = extractCode(callbackUrl)
|
|
236
|
-
if (!code) {
|
|
237
|
-
throw new Error(
|
|
238
|
-
`回调页没有返回登录态(正文:${text.slice(0, 160) || "空"})。请重新走一遍登录。`,
|
|
239
|
-
)
|
|
240
|
-
}
|
|
241
|
-
throw new Error(
|
|
242
|
-
`回调页没有直接返回 token(正文:${text.slice(0, 120) || "空"}),` +
|
|
243
|
-
`请重新走一遍登录。`,
|
|
244
|
-
)
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** 把上游 user_info 归一成插件内部形状 */
|
|
248
|
-
function normalize(info, accessToken, openid) {
|
|
249
|
-
const expire = Number(info?.access_token_expire_time ?? info?.expires_in ?? 0)
|
|
250
|
-
return {
|
|
251
|
-
openid: String(openid),
|
|
252
|
-
accessToken: String(accessToken),
|
|
253
|
-
refreshToken: info?.refresh_token ? String(info.refresh_token) : undefined,
|
|
254
|
-
nickname: info?.nick_name ? safeDecode(info.nick_name) : undefined,
|
|
255
|
-
avatar: info?.head_img_url ?? undefined,
|
|
256
|
-
loginType: "6",
|
|
257
|
-
expiresAt: expire > 1e9 ? expire * 1000 : Date.now() + 30 * 24 * 3600_000,
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function safeDecode(s) {
|
|
262
|
-
try {
|
|
263
|
-
return decodeURIComponent(String(s))
|
|
264
|
-
} catch {
|
|
265
|
-
return String(s)
|
|
266
|
-
}
|
|
267
|
-
}
|