magicrew-auth 0.3.13 → 0.3.14

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.
@@ -1,408 +1,408 @@
1
- // 自己拉起浏览器登录,再从 CDP 里把凭据读回来。
2
- //
3
- // ═══ 为什么必须自己拉浏览器 ══════════════════════════════════════════
4
- //
5
- // magicrew 没有 OAuth 回调通道(见 docs/internals.md 第九节),所以「官方页面登录
6
- // 完,插件自动拿到 token」这件事,只有一条路:**浏览器得是插件能看进去的那一个**。
7
- //
8
- // magpie 打开的浏览器是系统默认实例 —— 插件没有它的调试端口,读不到它的 localStorage。
9
- // 所以这里自己拉一个:独立 profile + `--remote-debugging-port=0`,登录完用 CDP
10
- // 在页面上下文里执行一次 localStorage 读取,拿到 jwt + organizationCode,关掉窗口。
11
- //
12
- // 这正是 authorize 返回 `opened: true` 的正当场景(HOOKS.md 陷阱 3):
13
- // 页面是我们自己开的,告诉 magpie 「别再开一次」。
14
- //
15
- // ⚠ 独立 profile 意味着用户要在这个窗口里登一次(没有他日常浏览器的 cookie)。
16
- // **每次登录一个一次性 profile,用完删** —— 为什么不能复用,见下面 newProfileDir()。
17
-
18
- import { spawn } from "node:child_process"
19
- import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"
20
- import { homedir } from "node:os"
21
- import { join } from "node:path"
22
-
23
- const BASE = () => join(homedir(), ".cache", "magpie")
24
-
25
- /**
26
- * 每次登录一个**一次性** profile 目录,登录结束就删。
27
- *
28
- * ⚠ 这里曾经刻意复用同一个目录(旧注释:`复用同一个 profile:第二次登录时里面已经有 cookie 了`),
29
- * 那是个 bug —— 现象是「添加第二个账号时,网页正确弹出、然后瞬间关闭,
30
- * magpie 里也没多出账号」:
31
- *
32
- * ① 复用目录里的 localStorage 还躺着**上一个账号**的 jwt + org;
33
- * ② 浏览器一打开,`waitForCreds` 第一轮(1.5 秒内)就读到它 → 判定「登录完成」→ 关窗;
34
- * ③ magpie 拿到的 accountId / uid 与已有账号一模一样,按契约
35
- * (同一 accountId + 同一 uid → 替换旧账号)不会新增账号。
36
- *
37
- * 一次性目录把这条路彻底堵死:读到的凭据只可能是本次登录写进去的。
38
- * 代价是每次都要在这个窗口里重新登一次 —— 独立 profile 本来也没有他日常浏览器的 cookie。
39
- */
40
- export function newProfileDir() {
41
- const d = join(
42
- BASE(),
43
- `magicrew-login-${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
44
- )
45
- mkdirSync(d, { recursive: true })
46
- return d
47
- }
48
-
49
- /**
50
- * 清残留:超过 maxAgeMs 的一次性 profile,以及历史上那个**复用**目录
51
- * (`magicrew-login-profile`,里面装着别的账号的 cookie —— 正是上面那个 bug 的来源)。
52
- * 只删本插件自己造的名字,别的一概不碰。
53
- */
54
- export function sweepProfiles({ maxAgeMs = 2 * 3600_000 } = {}) {
55
- let names = []
56
- try {
57
- names = readdirSync(BASE())
58
- } catch {
59
- return 0
60
- }
61
- let removed = 0
62
- for (const name of names) {
63
- const legacy = name === "magicrew-login-profile"
64
- if (!legacy && !/^magicrew-login-/.test(name)) continue
65
- const p = join(BASE(), name)
66
- try {
67
- // ⚠ 只动目录:同前缀的日志文件(magicrew-login-*.log)不是 profile
68
- const st = statSync(p)
69
- if (!st.isDirectory()) continue
70
- // 遗留的复用目录不管多新都删;一次性目录只在过期后删(可能正有别的账号在登)
71
- if (!legacy && Date.now() - st.mtimeMs < maxAgeMs) continue
72
- rmSync(p, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
73
- removed++
74
- } catch {}
75
- }
76
- return removed
77
- }
78
-
79
- /** 按平台找浏览器;找不到返回 null,调用方降级 */
80
- export function findBrowser() {
81
- const plat = process.platform
82
- const candidates =
83
- plat === "win32"
84
- ? [
85
- "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
86
- "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
87
- "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
88
- "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
89
- join(process.env.LOCALAPPDATA || "", "Google\\Chrome\\Application\\chrome.exe"),
90
- "C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
91
- ]
92
- : plat === "darwin"
93
- ? [
94
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
95
- "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
96
- "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
97
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
98
- ]
99
- : [
100
- "/usr/bin/google-chrome",
101
- "/usr/bin/chromium",
102
- "/usr/bin/chromium-browser",
103
- "/usr/bin/microsoft-edge",
104
- "/snap/bin/chromium",
105
- ]
106
- for (const p of candidates) {
107
- try {
108
- if (p && existsSync(p)) return p
109
- } catch {}
110
- }
111
- return null
112
- }
113
-
114
- /**
115
- * 等 Chromium 把 DevToolsActivePort 写出来。
116
- * 文件两行:第一行端口,第二行 ws 路径。`--remote-debugging-port=0` 让系统挑端口,
117
- * 避免和已有的调试端口撞车。
118
- */
119
- async function waitPort(dir, timeoutMs = 8_000) {
120
- const f = join(dir, "DevToolsActivePort")
121
- const deadline = Date.now() + timeoutMs
122
- while (Date.now() < deadline) {
123
- try {
124
- const line = readFileSync(f, "utf8").split("\n")[0].trim()
125
- const port = Number(line)
126
- if (port > 0) return port
127
- } catch {}
128
- await new Promise((r) => setTimeout(r, 250))
129
- }
130
- return 0
131
- }
132
-
133
- /**
134
- * 拉起浏览器并打开登录页。
135
- * @returns {Promise<{port:number, dir:string, child:import("node:child_process").ChildProcess} | null>}
136
- */
137
- export async function launchLoginBrowser(url) {
138
- const exe = findBrowser()
139
- if (!exe) throw new Error("本机没找到 Chrome / Edge / Bravo / Chromium")
140
-
141
- // 先扫掉上一次登录留下的临时 profile(含历史复用目录),再开一个全新的
142
- sweepProfiles()
143
- const dir = newProfileDir()
144
- // ⚠ 残留会让这次「看起来起来了、其实什么都没做」—— 一次性目录里通常没有,
145
- // 但 sweep 失败、或目录被复用时它们就是根因,清一遍几乎不花钱:
146
- // DevToolsActivePort —— 不清掉,waitPort 会立刻返回一个已失效的旧端口
147
- // SingletonLock / SingletonCookie / SingletonSocket / lockfile
148
- // —— 不清掉,新进程判定 profile 被占用,把 URL 转交给
149
- // 那个已经死掉的实例,然后自己退出
150
- const { unlinkSync } = await import("node:fs")
151
- for (const f of ["DevToolsActivePort", "SingletonLock", "SingletonCookie", "SingletonSocket", "lockfile"]) {
152
- try {
153
- unlinkSync(join(dir, f))
154
- } catch {}
155
- }
156
-
157
- let child
158
- try {
159
- child = spawn(
160
- exe,
161
- [
162
- `--user-data-dir=${dir}`,
163
- "--remote-debugging-port=0",
164
- "--no-first-run",
165
- "--no-default-browser-check",
166
- "--disable-features=Translate,MediaRouter",
167
- "--disable-background-networking",
168
- url,
169
- ],
170
- { stdio: "ignore", detached: false, windowsHide: false },
171
- )
172
- } catch (e) {
173
- throw new Error(`浏览器启动失败:${e?.message ?? e}`)
174
- }
175
-
176
- const port = await waitPort(dir)
177
- if (!port) {
178
- try {
179
- child.kill()
180
- } catch {}
181
- try {
182
- rmSync(dir, { recursive: true, force: true, maxRetries: 2, retryDelay: 150 })
183
- } catch {}
184
- throw new Error("浏览器进程起来了,但 8 秒内没写出调试端口 DevToolsActivePort(多半是 profile 被一个已退出的实例占着)")
185
- }
186
- return { port, dir, child }
187
- }
188
-
189
- // ── 最小 CDP 客户端 ───────────────────────────────────────────────────
190
-
191
- async function listPages(port) {
192
- const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5000) })
193
- return res.json()
194
- }
195
-
196
- /** 找到登录页那个 target;**必须**URL 匹配,否则返回 null 让调用方继续等 */
197
- async function pickLoginPage(port, hostFragment) {
198
- const pages = (await listPages(port)).filter((t) => t.type === "page" && t.webSocketDebuggerUrl)
199
- if (!pages.length) return null
200
- // ⚠ 不能退回 pages[0]:浏览器刚起来时那个 target 还是 about:blank,
201
- // 在它上面访问 localStorage 会抛 SecurityError,被当成「读不到」而空转。
202
- return pages.find((p) => String(p.url || "").includes(hostFragment)) ?? null
203
- }
204
-
205
- /** 在页面上下文里求值一次 */
206
- async function evaluate(port, hostFragment, expression, timeoutMs = 8000) {
207
- const page = await pickLoginPage(port, hostFragment)
208
- if (!page) throw new Error("没有可用的页面 target")
209
-
210
- const ws = new WebSocket(page.webSocketDebuggerUrl)
211
- await new Promise((resolve, reject) => {
212
- const t = setTimeout(() => reject(new Error("CDP 连接超时")), timeoutMs)
213
- ws.addEventListener("open", () => {
214
- clearTimeout(t)
215
- resolve()
216
- })
217
- ws.addEventListener("error", (e) => {
218
- clearTimeout(t)
219
- reject(new Error(`CDP 连接失败:${e?.message ?? e}`))
220
- })
221
- })
222
-
223
- try {
224
- const reply = await new Promise((resolve, reject) => {
225
- const t = setTimeout(() => reject(new Error("CDP 求值超时")), timeoutMs)
226
- ws.addEventListener("message", (ev) => {
227
- let msg
228
- try {
229
- msg = JSON.parse(ev.data)
230
- } catch {
231
- return
232
- }
233
- if (msg.id !== 1) return
234
- clearTimeout(t)
235
- if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error)))
236
- else resolve(msg.result)
237
- })
238
- ws.send(
239
- JSON.stringify({
240
- id: 1,
241
- method: "Runtime.evaluate",
242
- params: { expression, awaitPromise: true, returnByValue: true },
243
- }),
244
- )
245
- })
246
- if (reply?.exceptionDetails) return ""
247
- return reply?.result?.value ?? ""
248
- } finally {
249
- try {
250
- ws.close()
251
- } catch {}
252
- }
253
- }
254
-
255
- /** 在页面的 localStorage 里取 jwt + 组织码 —— 一次求值,两个都拿 */
256
- const READ_CREDS = `(() => {
257
- const raw = (k) => { try { return localStorage.getItem(k) || "" } catch { return "" } }
258
- const pick = (k) => {
259
- const s = raw(k)
260
- if (!s) return ""
261
- try {
262
- const o = JSON.parse(s)
263
- return typeof o === "object" && o ? String(o.value ?? "") : String(s)
264
- } catch { return s }
265
- }
266
- const obj = (k) => {
267
- try {
268
- const o = JSON.parse(raw(k))
269
- return (o && typeof o === "object" ? (o.value ?? o) : null) || null
270
- } catch { return null }
271
- }
272
- const jwt = pick("MAGIC:user:authorization")
273
- const org = pick("MAGIC:user:organizationCode")
274
- if (!jwt || !jwt.startsWith("eyJ") || !org) return ""
275
- // 卡片上要显示一个能认的名字:**手机号 → 邮箱 → 昵称 → 真名**
276
- // (昵称是用户自己填的,可以不填也可以乱填;手机号是登录凭据本身。
277
- // 服务端存的是打码版 138****8000,但仍是唯一稳定的账号标识)
278
- const ui = obj("MAGIC:user:userInfo") || {}
279
- const name = String(ui.phone || ui.email || ui.nickname || ui.real_name || "").trim()
280
- return JSON.stringify({ jwt, org, name })
281
- })()`
282
-
283
- /**
284
- * 盯着那个浏览器,直到页面里的 localStorage 出现完整凭据。
285
- *
286
- * `baseline` = 「窗口打开时就已经在 localStorage 里的那份」。传进来的话,
287
- * 只有**跟它不一样**的凭据才算登录成功。一次性 profile 下它恒为空,
288
- * 留着是第二道防线:万一哪天又改回复用目录,这条判据能挡住「读到上一个账号」。
289
- *
290
- * @param {{jwt:string, org:string}|null} [opts.baseline]
291
- * @param {(port:number, host:string, expr:string)=>Promise<string>} [opts.probe] 测试注入用
292
- * @returns {Promise<{jwt:string, org:string, name?:string} | null>}
293
- */
294
- export async function waitForCreds(
295
- session,
296
- hostFragment,
297
- { timeout = 300_000, interval = 1500, baseline = null, probe = evaluate } = {},
298
- ) {
299
- const base = baseline ?? session?.baseline ?? null
300
- const deadline = Date.now() + timeout
301
- while (Date.now() < deadline) {
302
- try {
303
- const raw = await probe(session.port, hostFragment, READ_CREDS)
304
- if (raw) {
305
- const o = JSON.parse(raw)
306
- if (o?.jwt?.startsWith("eyJ") && o?.org) {
307
- const same = base && o.jwt === base.jwt && o.org === base.org
308
- if (!same) return o
309
- }
310
- }
311
- } catch {
312
- // 页面还在加载 / target 换了 —— 下一轮再试
313
- }
314
- await new Promise((r) => setTimeout(r, interval))
315
- }
316
- return null
317
- }
318
-
319
- /**
320
- * 关掉这个浏览器窗口,然后把这一次的临时 profile 删掉。
321
- *
322
- * 两条路都要走:
323
- * ① CDP 的 `Browser.close` —— 走浏览器自己的退出流程,进程干净退出
324
- * ② 兜底 kill —— ①在 2 秒内没关掉就强杀整棵进程树
325
- * (Windows 上 kill() 只杀主进程,渲染/GPU 子进程会留着占住 profile,
326
- * 后面的 rmSync 也会因为文件锁失败)
327
- *
328
- * ⚠ profile 是**一次性**的,删掉是对的:里面只有这次登录的 cookie,
329
- * 留着只会让下一次登录读到上一个账号(见 newProfileDir 的注释)。
330
- * 删不掉不影响正确性 —— 下次 launch 会先 sweepProfiles()。
331
- */
332
- export async function closeSession(session) {
333
- if (!session) return
334
-
335
- // ① 优雅关:Browser.close 走浏览器自己的退出流程
336
- try {
337
- const r = await fetch(`http://127.0.0.1:${session.port}/json/version`, { signal: AbortSignal.timeout(3000) })
338
- const v = await r.json()
339
- if (v?.webSocketDebuggerUrl) {
340
- await new Promise((resolve) => {
341
- const ws = new WebSocket(v.webSocketDebuggerUrl)
342
- const done = () => {
343
- try {
344
- ws.close()
345
- } catch {}
346
- resolve()
347
- }
348
- const t = setTimeout(done, 2000)
349
- ws.addEventListener(
350
- "open",
351
- () => {
352
- try {
353
- ws.send(JSON.stringify({ id: 1, method: "Browser.close" }))
354
- } catch {}
355
- // 给它 600ms 自己退,退不掉就走 ②
356
- setTimeout(() => {
357
- clearTimeout(t)
358
- done()
359
- }, 600)
360
- },
361
- { once: true },
362
- )
363
- ws.addEventListener(
364
- "error",
365
- () => {
366
- clearTimeout(t)
367
- done()
368
- },
369
- { once: true },
370
- )
371
- })
372
- }
373
- } catch {}
374
-
375
- // ② 兜底:还活着就强杀整棵进程树
376
- const pid = session.child?.pid
377
- if (pid) {
378
- try {
379
- if (process.platform === "win32") {
380
- await new Promise((resolve) => {
381
- const k = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true })
382
- k.on("exit", resolve)
383
- k.on("error", resolve)
384
- setTimeout(resolve, 3000)
385
- })
386
- } else {
387
- try {
388
- session.child.kill("SIGKILL")
389
- } catch {}
390
- }
391
- } catch {}
392
- }
393
-
394
- // ③ 删掉这一次的临时 profile。文件锁通常要等子进程死透才放,
395
- // 所以给它几次机会;删不掉就算了,下次 launch 会 sweep。
396
- const dir = session.dir
397
- if (!dir) return
398
- for (let i = 0; i < 5; i++) {
399
- try {
400
- rmSync(dir, { recursive: true, force: true, maxRetries: 2, retryDelay: 150 })
401
- return
402
- } catch {
403
- await new Promise((r) => setTimeout(r, 200))
404
- }
405
- }
406
- }
407
-
408
- export const _internal = { READ_CREDS, newProfileDir, sweepProfiles, waitPort, evaluate }
1
+ // 自己拉起浏览器登录,再从 CDP 里把凭据读回来。
2
+ //
3
+ // ═══ 为什么必须自己拉浏览器 ══════════════════════════════════════════
4
+ //
5
+ // magicrew 没有 OAuth 回调通道(见 docs/internals.md 第九节),所以「官方页面登录
6
+ // 完,插件自动拿到 token」这件事,只有一条路:**浏览器得是插件能看进去的那一个**。
7
+ //
8
+ // magpie 打开的浏览器是系统默认实例 —— 插件没有它的调试端口,读不到它的 localStorage。
9
+ // 所以这里自己拉一个:独立 profile + `--remote-debugging-port=0`,登录完用 CDP
10
+ // 在页面上下文里执行一次 localStorage 读取,拿到 jwt + organizationCode,关掉窗口。
11
+ //
12
+ // 这正是 authorize 返回 `opened: true` 的正当场景(HOOKS.md 陷阱 3):
13
+ // 页面是我们自己开的,告诉 magpie 「别再开一次」。
14
+ //
15
+ // ⚠ 独立 profile 意味着用户要在这个窗口里登一次(没有他日常浏览器的 cookie)。
16
+ // **每次登录一个一次性 profile,用完删** —— 为什么不能复用,见下面 newProfileDir()。
17
+
18
+ import { spawn } from "node:child_process"
19
+ import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"
20
+ import { homedir } from "node:os"
21
+ import { join } from "node:path"
22
+
23
+ const BASE = () => join(homedir(), ".cache", "magpie")
24
+
25
+ /**
26
+ * 每次登录一个**一次性** profile 目录,登录结束就删。
27
+ *
28
+ * ⚠ 这里曾经刻意复用同一个目录(旧注释:`复用同一个 profile:第二次登录时里面已经有 cookie 了`),
29
+ * 那是个 bug —— 现象是「添加第二个账号时,网页正确弹出、然后瞬间关闭,
30
+ * magpie 里也没多出账号」:
31
+ *
32
+ * ① 复用目录里的 localStorage 还躺着**上一个账号**的 jwt + org;
33
+ * ② 浏览器一打开,`waitForCreds` 第一轮(1.5 秒内)就读到它 → 判定「登录完成」→ 关窗;
34
+ * ③ magpie 拿到的 accountId / uid 与已有账号一模一样,按契约
35
+ * (同一 accountId + 同一 uid → 替换旧账号)不会新增账号。
36
+ *
37
+ * 一次性目录把这条路彻底堵死:读到的凭据只可能是本次登录写进去的。
38
+ * 代价是每次都要在这个窗口里重新登一次 —— 独立 profile 本来也没有他日常浏览器的 cookie。
39
+ */
40
+ export function newProfileDir() {
41
+ const d = join(
42
+ BASE(),
43
+ `magicrew-login-${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
44
+ )
45
+ mkdirSync(d, { recursive: true })
46
+ return d
47
+ }
48
+
49
+ /**
50
+ * 清残留:超过 maxAgeMs 的一次性 profile,以及历史上那个**复用**目录
51
+ * (`magicrew-login-profile`,里面装着别的账号的 cookie —— 正是上面那个 bug 的来源)。
52
+ * 只删本插件自己造的名字,别的一概不碰。
53
+ */
54
+ export function sweepProfiles({ maxAgeMs = 2 * 3600_000 } = {}) {
55
+ let names = []
56
+ try {
57
+ names = readdirSync(BASE())
58
+ } catch {
59
+ return 0
60
+ }
61
+ let removed = 0
62
+ for (const name of names) {
63
+ const legacy = name === "magicrew-login-profile"
64
+ if (!legacy && !/^magicrew-login-/.test(name)) continue
65
+ const p = join(BASE(), name)
66
+ try {
67
+ // ⚠ 只动目录:同前缀的日志文件(magicrew-login-*.log)不是 profile
68
+ const st = statSync(p)
69
+ if (!st.isDirectory()) continue
70
+ // 遗留的复用目录不管多新都删;一次性目录只在过期后删(可能正有别的账号在登)
71
+ if (!legacy && Date.now() - st.mtimeMs < maxAgeMs) continue
72
+ rmSync(p, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
73
+ removed++
74
+ } catch {}
75
+ }
76
+ return removed
77
+ }
78
+
79
+ /** 按平台找浏览器;找不到返回 null,调用方降级 */
80
+ export function findBrowser() {
81
+ const plat = process.platform
82
+ const candidates =
83
+ plat === "win32"
84
+ ? [
85
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
86
+ "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
87
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
88
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
89
+ join(process.env.LOCALAPPDATA || "", "Google\\Chrome\\Application\\chrome.exe"),
90
+ "C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
91
+ ]
92
+ : plat === "darwin"
93
+ ? [
94
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
95
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
96
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
97
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
98
+ ]
99
+ : [
100
+ "/usr/bin/google-chrome",
101
+ "/usr/bin/chromium",
102
+ "/usr/bin/chromium-browser",
103
+ "/usr/bin/microsoft-edge",
104
+ "/snap/bin/chromium",
105
+ ]
106
+ for (const p of candidates) {
107
+ try {
108
+ if (p && existsSync(p)) return p
109
+ } catch {}
110
+ }
111
+ return null
112
+ }
113
+
114
+ /**
115
+ * 等 Chromium 把 DevToolsActivePort 写出来。
116
+ * 文件两行:第一行端口,第二行 ws 路径。`--remote-debugging-port=0` 让系统挑端口,
117
+ * 避免和已有的调试端口撞车。
118
+ */
119
+ async function waitPort(dir, timeoutMs = 8_000) {
120
+ const f = join(dir, "DevToolsActivePort")
121
+ const deadline = Date.now() + timeoutMs
122
+ while (Date.now() < deadline) {
123
+ try {
124
+ const line = readFileSync(f, "utf8").split("\n")[0].trim()
125
+ const port = Number(line)
126
+ if (port > 0) return port
127
+ } catch {}
128
+ await new Promise((r) => setTimeout(r, 250))
129
+ }
130
+ return 0
131
+ }
132
+
133
+ /**
134
+ * 拉起浏览器并打开登录页。
135
+ * @returns {Promise<{port:number, dir:string, child:import("node:child_process").ChildProcess} | null>}
136
+ */
137
+ export async function launchLoginBrowser(url) {
138
+ const exe = findBrowser()
139
+ if (!exe) throw new Error("本机没找到 Chrome / Edge / Bravo / Chromium")
140
+
141
+ // 先扫掉上一次登录留下的临时 profile(含历史复用目录),再开一个全新的
142
+ sweepProfiles()
143
+ const dir = newProfileDir()
144
+ // ⚠ 残留会让这次「看起来起来了、其实什么都没做」—— 一次性目录里通常没有,
145
+ // 但 sweep 失败、或目录被复用时它们就是根因,清一遍几乎不花钱:
146
+ // DevToolsActivePort —— 不清掉,waitPort 会立刻返回一个已失效的旧端口
147
+ // SingletonLock / SingletonCookie / SingletonSocket / lockfile
148
+ // —— 不清掉,新进程判定 profile 被占用,把 URL 转交给
149
+ // 那个已经死掉的实例,然后自己退出
150
+ const { unlinkSync } = await import("node:fs")
151
+ for (const f of ["DevToolsActivePort", "SingletonLock", "SingletonCookie", "SingletonSocket", "lockfile"]) {
152
+ try {
153
+ unlinkSync(join(dir, f))
154
+ } catch {}
155
+ }
156
+
157
+ let child
158
+ try {
159
+ child = spawn(
160
+ exe,
161
+ [
162
+ `--user-data-dir=${dir}`,
163
+ "--remote-debugging-port=0",
164
+ "--no-first-run",
165
+ "--no-default-browser-check",
166
+ "--disable-features=Translate,MediaRouter",
167
+ "--disable-background-networking",
168
+ url,
169
+ ],
170
+ { stdio: "ignore", detached: false, windowsHide: false },
171
+ )
172
+ } catch (e) {
173
+ throw new Error(`浏览器启动失败:${e?.message ?? e}`)
174
+ }
175
+
176
+ const port = await waitPort(dir)
177
+ if (!port) {
178
+ try {
179
+ child.kill()
180
+ } catch {}
181
+ try {
182
+ rmSync(dir, { recursive: true, force: true, maxRetries: 2, retryDelay: 150 })
183
+ } catch {}
184
+ throw new Error("浏览器进程起来了,但 8 秒内没写出调试端口 DevToolsActivePort(多半是 profile 被一个已退出的实例占着)")
185
+ }
186
+ return { port, dir, child }
187
+ }
188
+
189
+ // ── 最小 CDP 客户端 ───────────────────────────────────────────────────
190
+
191
+ async function listPages(port) {
192
+ const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5000) })
193
+ return res.json()
194
+ }
195
+
196
+ /** 找到登录页那个 target;**必须**URL 匹配,否则返回 null 让调用方继续等 */
197
+ async function pickLoginPage(port, hostFragment) {
198
+ const pages = (await listPages(port)).filter((t) => t.type === "page" && t.webSocketDebuggerUrl)
199
+ if (!pages.length) return null
200
+ // ⚠ 不能退回 pages[0]:浏览器刚起来时那个 target 还是 about:blank,
201
+ // 在它上面访问 localStorage 会抛 SecurityError,被当成「读不到」而空转。
202
+ return pages.find((p) => String(p.url || "").includes(hostFragment)) ?? null
203
+ }
204
+
205
+ /** 在页面上下文里求值一次 */
206
+ async function evaluate(port, hostFragment, expression, timeoutMs = 8000) {
207
+ const page = await pickLoginPage(port, hostFragment)
208
+ if (!page) throw new Error("没有可用的页面 target")
209
+
210
+ const ws = new WebSocket(page.webSocketDebuggerUrl)
211
+ await new Promise((resolve, reject) => {
212
+ const t = setTimeout(() => reject(new Error("CDP 连接超时")), timeoutMs)
213
+ ws.addEventListener("open", () => {
214
+ clearTimeout(t)
215
+ resolve()
216
+ })
217
+ ws.addEventListener("error", (e) => {
218
+ clearTimeout(t)
219
+ reject(new Error(`CDP 连接失败:${e?.message ?? e}`))
220
+ })
221
+ })
222
+
223
+ try {
224
+ const reply = await new Promise((resolve, reject) => {
225
+ const t = setTimeout(() => reject(new Error("CDP 求值超时")), timeoutMs)
226
+ ws.addEventListener("message", (ev) => {
227
+ let msg
228
+ try {
229
+ msg = JSON.parse(ev.data)
230
+ } catch {
231
+ return
232
+ }
233
+ if (msg.id !== 1) return
234
+ clearTimeout(t)
235
+ if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error)))
236
+ else resolve(msg.result)
237
+ })
238
+ ws.send(
239
+ JSON.stringify({
240
+ id: 1,
241
+ method: "Runtime.evaluate",
242
+ params: { expression, awaitPromise: true, returnByValue: true },
243
+ }),
244
+ )
245
+ })
246
+ if (reply?.exceptionDetails) return ""
247
+ return reply?.result?.value ?? ""
248
+ } finally {
249
+ try {
250
+ ws.close()
251
+ } catch {}
252
+ }
253
+ }
254
+
255
+ /** 在页面的 localStorage 里取 jwt + 组织码 —— 一次求值,两个都拿 */
256
+ const READ_CREDS = `(() => {
257
+ const raw = (k) => { try { return localStorage.getItem(k) || "" } catch { return "" } }
258
+ const pick = (k) => {
259
+ const s = raw(k)
260
+ if (!s) return ""
261
+ try {
262
+ const o = JSON.parse(s)
263
+ return typeof o === "object" && o ? String(o.value ?? "") : String(s)
264
+ } catch { return s }
265
+ }
266
+ const obj = (k) => {
267
+ try {
268
+ const o = JSON.parse(raw(k))
269
+ return (o && typeof o === "object" ? (o.value ?? o) : null) || null
270
+ } catch { return null }
271
+ }
272
+ const jwt = pick("MAGIC:user:authorization")
273
+ const org = pick("MAGIC:user:organizationCode")
274
+ if (!jwt || !jwt.startsWith("eyJ") || !org) return ""
275
+ // 卡片上要显示一个能认的名字:**手机号 → 邮箱 → 昵称 → 真名**
276
+ // (昵称是用户自己填的,可以不填也可以乱填;手机号是登录凭据本身。
277
+ // 服务端存的是打码版 138****8000,但仍是唯一稳定的账号标识)
278
+ const ui = obj("MAGIC:user:userInfo") || {}
279
+ const name = String(ui.phone || ui.email || ui.nickname || ui.real_name || "").trim()
280
+ return JSON.stringify({ jwt, org, name })
281
+ })()`
282
+
283
+ /**
284
+ * 盯着那个浏览器,直到页面里的 localStorage 出现完整凭据。
285
+ *
286
+ * `baseline` = 「窗口打开时就已经在 localStorage 里的那份」。传进来的话,
287
+ * 只有**跟它不一样**的凭据才算登录成功。一次性 profile 下它恒为空,
288
+ * 留着是第二道防线:万一哪天又改回复用目录,这条判据能挡住「读到上一个账号」。
289
+ *
290
+ * @param {{jwt:string, org:string}|null} [opts.baseline]
291
+ * @param {(port:number, host:string, expr:string)=>Promise<string>} [opts.probe] 测试注入用
292
+ * @returns {Promise<{jwt:string, org:string, name?:string} | null>}
293
+ */
294
+ export async function waitForCreds(
295
+ session,
296
+ hostFragment,
297
+ { timeout = 300_000, interval = 1500, baseline = null, probe = evaluate } = {},
298
+ ) {
299
+ const base = baseline ?? session?.baseline ?? null
300
+ const deadline = Date.now() + timeout
301
+ while (Date.now() < deadline) {
302
+ try {
303
+ const raw = await probe(session.port, hostFragment, READ_CREDS)
304
+ if (raw) {
305
+ const o = JSON.parse(raw)
306
+ if (o?.jwt?.startsWith("eyJ") && o?.org) {
307
+ const same = base && o.jwt === base.jwt && o.org === base.org
308
+ if (!same) return o
309
+ }
310
+ }
311
+ } catch {
312
+ // 页面还在加载 / target 换了 —— 下一轮再试
313
+ }
314
+ await new Promise((r) => setTimeout(r, interval))
315
+ }
316
+ return null
317
+ }
318
+
319
+ /**
320
+ * 关掉这个浏览器窗口,然后把这一次的临时 profile 删掉。
321
+ *
322
+ * 两条路都要走:
323
+ * ① CDP 的 `Browser.close` —— 走浏览器自己的退出流程,进程干净退出
324
+ * ② 兜底 kill —— ①在 2 秒内没关掉就强杀整棵进程树
325
+ * (Windows 上 kill() 只杀主进程,渲染/GPU 子进程会留着占住 profile,
326
+ * 后面的 rmSync 也会因为文件锁失败)
327
+ *
328
+ * ⚠ profile 是**一次性**的,删掉是对的:里面只有这次登录的 cookie,
329
+ * 留着只会让下一次登录读到上一个账号(见 newProfileDir 的注释)。
330
+ * 删不掉不影响正确性 —— 下次 launch 会先 sweepProfiles()。
331
+ */
332
+ export async function closeSession(session) {
333
+ if (!session) return
334
+
335
+ // ① 优雅关:Browser.close 走浏览器自己的退出流程
336
+ try {
337
+ const r = await fetch(`http://127.0.0.1:${session.port}/json/version`, { signal: AbortSignal.timeout(3000) })
338
+ const v = await r.json()
339
+ if (v?.webSocketDebuggerUrl) {
340
+ await new Promise((resolve) => {
341
+ const ws = new WebSocket(v.webSocketDebuggerUrl)
342
+ const done = () => {
343
+ try {
344
+ ws.close()
345
+ } catch {}
346
+ resolve()
347
+ }
348
+ const t = setTimeout(done, 2000)
349
+ ws.addEventListener(
350
+ "open",
351
+ () => {
352
+ try {
353
+ ws.send(JSON.stringify({ id: 1, method: "Browser.close" }))
354
+ } catch {}
355
+ // 给它 600ms 自己退,退不掉就走 ②
356
+ setTimeout(() => {
357
+ clearTimeout(t)
358
+ done()
359
+ }, 600)
360
+ },
361
+ { once: true },
362
+ )
363
+ ws.addEventListener(
364
+ "error",
365
+ () => {
366
+ clearTimeout(t)
367
+ done()
368
+ },
369
+ { once: true },
370
+ )
371
+ })
372
+ }
373
+ } catch {}
374
+
375
+ // ② 兜底:还活着就强杀整棵进程树
376
+ const pid = session.child?.pid
377
+ if (pid) {
378
+ try {
379
+ if (process.platform === "win32") {
380
+ await new Promise((resolve) => {
381
+ const k = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true })
382
+ k.on("exit", resolve)
383
+ k.on("error", resolve)
384
+ setTimeout(resolve, 3000)
385
+ })
386
+ } else {
387
+ try {
388
+ session.child.kill("SIGKILL")
389
+ } catch {}
390
+ }
391
+ } catch {}
392
+ }
393
+
394
+ // ③ 删掉这一次的临时 profile。文件锁通常要等子进程死透才放,
395
+ // 所以给它几次机会;删不掉就算了,下次 launch 会 sweep。
396
+ const dir = session.dir
397
+ if (!dir) return
398
+ for (let i = 0; i < 5; i++) {
399
+ try {
400
+ rmSync(dir, { recursive: true, force: true, maxRetries: 2, retryDelay: 150 })
401
+ return
402
+ } catch {
403
+ await new Promise((r) => setTimeout(r, 200))
404
+ }
405
+ }
406
+ }
407
+
408
+ export const _internal = { READ_CREDS, newProfileDir, sweepProfiles, waitPort, evaluate }