magicrew-auth 0.3.32 → 0.3.33
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/AGENTS.md +3 -1
- package/CHANGELOG.md +63 -0
- package/README.md +1 -1
- package/index.mjs +28 -19
- package/package.json +10 -2
- package/page/app.js +243 -0
- package/page/index.html +53 -0
- package/src/browser-login.mjs +99 -22
- package/src/hooks/loader.mjs +40 -2
- package/src/hooks/methods.mjs +56 -167
- package/src/hooks/usage.mjs +12 -0
- package/src/store.mjs +146 -5
package/page/index.html
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>超级麦吉设置</title>
|
|
6
|
+
<script src="_magpie/bridge.js"></script>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; }
|
|
9
|
+
body {
|
|
10
|
+
margin: 0; padding: 16px 18px 28px;
|
|
11
|
+
font: 13px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
|
|
12
|
+
background: var(--magpie-bg, #1b1b1d); color: var(--magpie-fg, #e6e6e6);
|
|
13
|
+
}
|
|
14
|
+
h1 { font-size: 14px; font-weight: 600; margin: 0 0 4px; }
|
|
15
|
+
.sub { opacity: .6; margin: 0 0 16px; font-size: 12px; }
|
|
16
|
+
section { border: 1px solid rgba(128,128,128,.25); border-radius: 10px; padding: 12px 14px; margin-bottom: 14px; }
|
|
17
|
+
h2 { font-size: 13px; font-weight: 600; margin: 0 0 8px; display: flex; align-items: center; gap: 8px; }
|
|
18
|
+
.now { font-size: 12px; opacity: .8; margin-bottom: 10px; }
|
|
19
|
+
.now b { font-weight: 600; opacity: 1; }
|
|
20
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
|
|
21
|
+
button.chip {
|
|
22
|
+
font: inherit; padding: 5px 10px; border-radius: 999px; cursor: pointer;
|
|
23
|
+
border: 1px solid rgba(128,128,128,.35); background: transparent; color: inherit;
|
|
24
|
+
}
|
|
25
|
+
button.chip:hover { background: rgba(128,128,128,.16); }
|
|
26
|
+
button.chip[aria-pressed="true"] { border-color: var(--magpie-accent, #6b8afd); background: rgba(107,138,253,.18); }
|
|
27
|
+
.custom { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
28
|
+
input[type="text"] {
|
|
29
|
+
font: inherit; padding: 6px 9px; border-radius: 8px; min-width: 190px;
|
|
30
|
+
border: 1px solid rgba(128,128,128,.35); background: rgba(128,128,128,.08); color: inherit;
|
|
31
|
+
}
|
|
32
|
+
button.save {
|
|
33
|
+
font: inherit; padding: 6px 12px; border-radius: 8px; cursor: pointer;
|
|
34
|
+
border: 1px solid var(--magpie-accent, #6b8afd); background: rgba(107,138,253,.22); color: inherit;
|
|
35
|
+
}
|
|
36
|
+
.hint { font-size: 12px; opacity: .55; margin-top: 8px; }
|
|
37
|
+
.lock { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-top: 10px; opacity: .85; }
|
|
38
|
+
#status { position: sticky; bottom: 0; font-size: 12px; padding: 8px 0 0; min-height: 18px; }
|
|
39
|
+
#status.ok { color: #5ec27d; }
|
|
40
|
+
#status.bad { color: #f2777a; }
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<h1>超级麦吉设置</h1>
|
|
45
|
+
<p class="sub">新账号的邀请上级在这里设置。改完立即生效,不需要重新登录。</p>
|
|
46
|
+
|
|
47
|
+
<div id="app"></div>
|
|
48
|
+
<div id="status"></div>
|
|
49
|
+
|
|
50
|
+
<script src="codes.js"></script>
|
|
51
|
+
<script src="app.js"></script>
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|
package/src/browser-login.mjs
CHANGED
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
// ⚠ 独立 profile 意味着用户要在这个窗口里登一次(没有他日常浏览器的 cookie)。
|
|
16
16
|
// **每次登录一个一次性 profile,用完删** —— 为什么不能复用,见下面 newProfileDir()。
|
|
17
17
|
|
|
18
|
-
import { spawn } from "node:child_process"
|
|
18
|
+
import { spawn, execFileSync } from "node:child_process"
|
|
19
19
|
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"
|
|
20
20
|
import { homedir } from "node:os"
|
|
21
|
-
import { join } from "node:path"
|
|
21
|
+
import { join, basename } from "node:path"
|
|
22
22
|
|
|
23
23
|
const BASE = () => join(homedir(), ".cache", "magpie")
|
|
24
24
|
|
|
@@ -264,7 +264,27 @@ async function waitPort(dir, timeoutMs = 8_000, aborted = null) {
|
|
|
264
264
|
* @param {{candidates?: {name:string,path:string}[]}} [opts] 测试注入用
|
|
265
265
|
* @returns {Promise<{port:number, dir:string, child:import("node:child_process").ChildProcess, name:string, exe:string}>}
|
|
266
266
|
*/
|
|
267
|
+
/** 一次失败后就别再拉:冷却期内直接走手动登录,免得每次点都弹一堆窗口 */
|
|
268
|
+
let cdpDownUntil = 0
|
|
269
|
+
const CDP_COOLDOWN_MS = 30 * 60_000
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 拉起一次登录窗口。
|
|
273
|
+
*
|
|
274
|
+
* ⚠ 2026-10-29 实测(用户机器,Chrome 155 + 已有一个日常 Chrome 在跑):
|
|
275
|
+
* 按原来那套参数 spawn 出去,浏览器把页面**转交给已运行实例**后自己 exit 0,
|
|
276
|
+
* 既不写 DevToolsActivePort,也不关它开出来的窗口 —— 而原代码会
|
|
277
|
+
* 「把所有候选浏览器都试一遍」,于是点一次「邮箱验证码登录」就弹好几个窗口。
|
|
278
|
+
*
|
|
279
|
+
* 参数集参照 GoogleChrome/chrome-launcher 的 DEFAULT_FLAGS(官方给自动化工具用的那套),
|
|
280
|
+
* 把扩展 / 组件更新 / 同步 / 首次运行向导 / 后台网络全关掉,
|
|
281
|
+
* 再补 `--remote-allow-origins=*`(新版 Chrome 连 CDP 的 WebSocket 要它)。
|
|
282
|
+
*/
|
|
267
283
|
export async function launchLoginBrowser(url, { candidates } = {}) {
|
|
284
|
+
// 冷却期内不再 spawn —— 本机接管不了就是接管不了,反复试只会反复弹窗
|
|
285
|
+
if (Date.now() < cdpDownUntil) {
|
|
286
|
+
throw new Error("本机浏览器不接受自动接管(刚才失败过,30 分钟内不再尝试),直接用手动登录")
|
|
287
|
+
}
|
|
268
288
|
const list = (candidates ?? browserCandidates()).filter((c) => {
|
|
269
289
|
try {
|
|
270
290
|
return c?.path && existsSync(c.path)
|
|
@@ -279,21 +299,20 @@ export async function launchLoginBrowser(url, { candidates } = {}) {
|
|
|
279
299
|
// 先扫掉上一次登录留下的临时 profile(含历史复用目录)—— 只做一次
|
|
280
300
|
sweepProfiles()
|
|
281
301
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
302
|
+
// ⚠ 只试**第一个**能用的浏览器。
|
|
303
|
+
// 每个候选失败都会留下一个收不回来的窗口(浏览器把自己 exit 掉、窗口归已运行实例),
|
|
304
|
+
// 试 N 个就弹 N 个 —— 这正是「一下子弹出很多个浏览器」的来源。
|
|
305
|
+
// 第一个不行就交给调用方走手动登录,别再用窗口堆失败。
|
|
306
|
+
const cand = list[0]
|
|
307
|
+
try {
|
|
308
|
+
return await launchOne(cand, url)
|
|
309
|
+
} catch (e) {
|
|
310
|
+
// 记下这次失败:30 分钟内不再自动拉浏览器(想提前恢复就重启 magpie)
|
|
311
|
+
cdpDownUntil = Date.now() + CDP_COOLDOWN_MS
|
|
312
|
+
throw new Error(
|
|
313
|
+
`没能自己接管浏览器(${cand.name}:${String(e?.message ?? e).slice(0, 200)})`,
|
|
314
|
+
)
|
|
292
315
|
}
|
|
293
|
-
|
|
294
|
-
throw new Error(
|
|
295
|
-
`试过 ${list.length} 个浏览器都没能打开调试通道:\n` + failures.map((f) => ` · ${f}`).join("\n"),
|
|
296
|
-
)
|
|
297
316
|
}
|
|
298
317
|
|
|
299
318
|
/**
|
|
@@ -323,10 +342,28 @@ async function launchOne(cand, url) {
|
|
|
323
342
|
[
|
|
324
343
|
`--user-data-dir=${dir}`,
|
|
325
344
|
"--remote-debugging-port=0",
|
|
345
|
+
// 新版 Chrome 连 CDP 的 WebSocket 要放行来源,否则 /json/* 能取到、WebSocket 连不上
|
|
346
|
+
"--remote-allow-origins=*",
|
|
326
347
|
"--no-first-run",
|
|
327
348
|
"--no-default-browser-check",
|
|
328
|
-
|
|
349
|
+
// ═══ 以下照抄 GoogleChrome/chrome-launcher 的 DEFAULT_FLAGS(只留跨平台、有意义的)═══
|
|
350
|
+
"--disable-features=Translate,OptimizationHints,MediaRouter,DialMediaRouteProvider,CalculateNativeWinOcclusion,InterestFeedContentSuggestions,CertificateTransparencyComponentUpdater,AutofillServerCommunication,PrivacySandboxSettings4",
|
|
351
|
+
"--disable-extensions",
|
|
352
|
+
"--disable-component-extensions-with-background-pages",
|
|
329
353
|
"--disable-background-networking",
|
|
354
|
+
"--disable-component-update",
|
|
355
|
+
"--disable-client-side-phishing-detection",
|
|
356
|
+
"--disable-sync",
|
|
357
|
+
"--metrics-recording-only",
|
|
358
|
+
"--disable-default-apps",
|
|
359
|
+
"--mute-audio",
|
|
360
|
+
"--disable-backgrounding-occluded-windows",
|
|
361
|
+
"--disable-renderer-backgrounding",
|
|
362
|
+
"--disable-background-timer-throttling",
|
|
363
|
+
"--disable-hang-monitor",
|
|
364
|
+
"--disable-prompt-on-repost",
|
|
365
|
+
"--disable-domain-reliability",
|
|
366
|
+
"--password-store=basic",
|
|
330
367
|
url,
|
|
331
368
|
],
|
|
332
369
|
// 某些国产浏览器不接受部分参数会直接退出,stdio 必须 ignore,
|
|
@@ -334,7 +371,7 @@ async function launchOne(cand, url) {
|
|
|
334
371
|
{ stdio: "ignore", detached: false, windowsHide: false },
|
|
335
372
|
)
|
|
336
373
|
} catch (e) {
|
|
337
|
-
await cleanupFailed({ dir })
|
|
374
|
+
await cleanupFailed({ dir, exe: cand.path })
|
|
338
375
|
throw new Error(`进程起不来:${e?.message ?? e}`)
|
|
339
376
|
}
|
|
340
377
|
|
|
@@ -347,7 +384,7 @@ async function launchOne(cand, url) {
|
|
|
347
384
|
|
|
348
385
|
const port = await waitPort(dir, 8_000, () => exited !== null)
|
|
349
386
|
if (!port) {
|
|
350
|
-
await cleanupFailed({ dir, child })
|
|
387
|
+
await cleanupFailed({ dir, child, exe: cand.path })
|
|
351
388
|
throw new Error(
|
|
352
389
|
exited !== null
|
|
353
390
|
? `进程启动后退出了(code ${exited}),没写出调试端口`
|
|
@@ -358,10 +395,50 @@ async function launchOne(cand, url) {
|
|
|
358
395
|
}
|
|
359
396
|
|
|
360
397
|
/** 失败收尾:杀进程 + 删目录,都不抛错 */
|
|
361
|
-
|
|
398
|
+
/**
|
|
399
|
+
* Windows:按 `--user-data-dir` 精确杀进程。
|
|
400
|
+
*
|
|
401
|
+
* ⚠ 为什么不能只 kill(child):实测浏览器会「父进程 exit 0、真正的窗口挂在另一批
|
|
402
|
+
* 子进程上」,child 早就没了,kill 落空 —— 于是每失败一次就多一个关不掉的窗口
|
|
403
|
+
* (用户看到的就是「一下子弹出很多个浏览器」)。
|
|
404
|
+
* profile 目录是一次性的、带随机后缀,按它匹配进程命令行既精确又不会误伤日常浏览器。
|
|
405
|
+
*/
|
|
406
|
+
function killByProfileDir(dir, exePath) {
|
|
407
|
+
if (process.platform !== "win32" || !dir) return 0
|
|
408
|
+
const name = basename(String(exePath || "chrome.exe")).replace(/'/g, "''")
|
|
409
|
+
const pattern = String(dir).replace(/'/g, "''")
|
|
362
410
|
try {
|
|
363
|
-
|
|
364
|
-
|
|
411
|
+
execFileSync(
|
|
412
|
+
"powershell",
|
|
413
|
+
[
|
|
414
|
+
"-NoProfile",
|
|
415
|
+
"-NonInteractive",
|
|
416
|
+
"-Command",
|
|
417
|
+
`$p = Get-CimInstance Win32_Process -Filter "Name='${name}'" | Where-Object { $_.CommandLine -like '*${pattern}*' }; ` +
|
|
418
|
+
`$p | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; $p.Count`,
|
|
419
|
+
],
|
|
420
|
+
{ stdio: "ignore", timeout: 10_000 },
|
|
421
|
+
)
|
|
422
|
+
return 1
|
|
423
|
+
} catch {
|
|
424
|
+
return 0
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function cleanupFailed({ dir, child, exe }) {
|
|
429
|
+
// 先按 profile 目录收(这一批才是真正开着窗口的进程),再补一刀 child 树
|
|
430
|
+
killByProfileDir(dir, exe)
|
|
431
|
+
try {
|
|
432
|
+
if (child?.pid && process.platform === "win32") {
|
|
433
|
+
execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" })
|
|
434
|
+
} else {
|
|
435
|
+
child?.kill()
|
|
436
|
+
}
|
|
437
|
+
} catch {
|
|
438
|
+
try {
|
|
439
|
+
child?.kill()
|
|
440
|
+
} catch {}
|
|
441
|
+
}
|
|
365
442
|
if (!dir) return
|
|
366
443
|
try {
|
|
367
444
|
rmSync(dir, { recursive: true, force: true, maxRetries: 2, retryDelay: 150 })
|
package/src/hooks/loader.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// ⚠ 不要在这里读流、不要手拼 x-api-key ——
|
|
15
15
|
// 流由 magpie 处理,头由 SDK 包自动加。见 README「最小交付面」。
|
|
16
16
|
|
|
17
|
-
import { ensureKey, accountOf, keyCandidates, adoptKey } from "../keys.mjs"
|
|
17
|
+
import { ensureKey, accountOf, keyCandidates, adoptKey, renewViaStore } from "../keys.mjs"
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* 上游「这个账号不行」的两种形态(2026-10-29 实测):
|
|
@@ -484,6 +484,28 @@ export async function rewriteCreditRefusal(res) {
|
|
|
484
484
|
})
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
/**
|
|
488
|
+
* 上游(Swow/PHP)对请求体有 ~8MB 的硬上限(实测 6MB 过、8MB 拒),
|
|
489
|
+
* 超了直接回 413 的 HTML 页 —— magpie 只能显示成难懂的
|
|
490
|
+
* 「a web page, not an API reply」。翻译成人话,并说明换账号没用。
|
|
491
|
+
*/
|
|
492
|
+
async function rewriteTooLarge(res, SHORT) {
|
|
493
|
+
if (!res || res.status !== 413) return res
|
|
494
|
+
const headers = new Headers(res.headers)
|
|
495
|
+
headers.delete("content-length")
|
|
496
|
+
headers.delete("content-encoding")
|
|
497
|
+
headers.set("content-type", "application/json")
|
|
498
|
+
const body = JSON.stringify({
|
|
499
|
+
error: {
|
|
500
|
+
message:
|
|
501
|
+
`${SHORT}: 请求体超过上游上限(约 8 MB)—— 会话上下文过大或含大截图。` +
|
|
502
|
+
`压缩上下文(如 /compact)或新开会话后重试;换账号无效。`,
|
|
503
|
+
type: "payload_too_large",
|
|
504
|
+
},
|
|
505
|
+
})
|
|
506
|
+
return new Response(body, { status: 413, statusText: "Payload Too Large", headers })
|
|
507
|
+
}
|
|
508
|
+
|
|
487
509
|
export function makeLoader(c) {
|
|
488
510
|
/** 已经写回 store 的 key —— 避免每个请求都去读一次 store */
|
|
489
511
|
const adopted = new Set()
|
|
@@ -554,7 +576,7 @@ export function makeLoader(c) {
|
|
|
554
576
|
adopted.add(cand.apiKey)
|
|
555
577
|
adoptKey(c, fresh, cand.apiKey, cand.expiresAt)
|
|
556
578
|
}
|
|
557
|
-
return rewriteCreditRefusal(res)
|
|
579
|
+
return rewriteTooLarge(await rewriteCreditRefusal(res), c.SHORT)
|
|
558
580
|
}
|
|
559
581
|
// 这把被拒:响应不再使用,显式取消 body —— 否则逐把试时
|
|
560
582
|
// 每条死连接的响应体都挂着,等 GC 才释放。
|
|
@@ -563,6 +585,22 @@ export function makeLoader(c) {
|
|
|
563
585
|
} catch {}
|
|
564
586
|
}
|
|
565
587
|
|
|
588
|
+
// 全被拒 —— 很可能是 key 被别处顶掉(同一账号在别处又登了一次,服务端换发)。
|
|
589
|
+
// 手里有 refresh_token 就续一把、再给一次机会:这是唯一能从 403 自愈的路,
|
|
590
|
+
// 否则这把 key 会一直「没过期但已失效」,直到 30 分钟 rest 结束还是它。
|
|
591
|
+
try {
|
|
592
|
+
if (accountOf(fresh) !== "default") {
|
|
593
|
+
const renewed = await renewViaStore(c, fresh)
|
|
594
|
+
if (renewed?.apiKey) {
|
|
595
|
+
const res = await sendWith(renewed.apiKey)
|
|
596
|
+
if (!(await isKeyRejected(res))) {
|
|
597
|
+
adoptKey(c, fresh, renewed.apiKey, renewed.expiresAt)
|
|
598
|
+
return rewriteTooLarge(await rewriteCreditRefusal(res), c.SHORT)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
} catch {}
|
|
603
|
+
|
|
566
604
|
// 这个账号的 key 全被拒。上游给的是 403「token无效」,magpie 会判成
|
|
567
605
|
// 「不可重试」直接抛给客户端(ZCode 显示 auth_failed / token无效)。
|
|
568
606
|
// 改写成 402:magpie 的 failure(402) 是 failCredit → 把这个账号 rest 掉、
|
package/src/hooks/methods.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from "../api.mjs"
|
|
31
31
|
import {
|
|
32
32
|
inviteToUse,
|
|
33
|
-
|
|
33
|
+
liveOrgs,
|
|
34
34
|
readSession,
|
|
35
35
|
writeSession,
|
|
36
36
|
noteLoginFailure,
|
|
@@ -38,12 +38,7 @@ import {
|
|
|
38
38
|
readKnownOrgs,
|
|
39
39
|
rollInvite,
|
|
40
40
|
readInvitePolicy,
|
|
41
|
-
writeInvitePolicy,
|
|
42
|
-
readAccountCodes,
|
|
43
41
|
} from "../store.mjs"
|
|
44
|
-
import { readFileSync } from "node:fs"
|
|
45
|
-
import { homedir } from "node:os"
|
|
46
|
-
import { join } from "node:path"
|
|
47
42
|
import { readLocalCreds } from "../local-browser.mjs"
|
|
48
43
|
import { CLIP_HINT, clipboardText, parseCreds, pollClipboard } from "../clipboard.mjs"
|
|
49
44
|
import { readVisionModel } from "../vision.mjs"
|
|
@@ -159,7 +154,44 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
|
|
|
159
154
|
* @param {object} c 站点闭包(id / SHORT / web / host)
|
|
160
155
|
* @param {"mobile"|"email"} kind
|
|
161
156
|
*/
|
|
162
|
-
|
|
157
|
+
/** org 已不在 magpie 的账号文件里(= 账号被删);文件读不到时按「不知道」处理 */
|
|
158
|
+
function orgDead(c, org) {
|
|
159
|
+
if (!org) return false
|
|
160
|
+
const live = liveOrgs(c.id)
|
|
161
|
+
return Boolean(live && live.size && !live.has(org))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 页面 options 里的邀请设置(inviteBySite[siteId]),带死号校验。
|
|
166
|
+
* · auto → 显式覆盖文件策略,走滚动 / 作者码
|
|
167
|
+
* · fixed 指向死号 → 降级成 auto(页面已提示,登录侧自动回退)
|
|
168
|
+
* · fixed/custom 活 → 用它
|
|
169
|
+
* 没设置(null)才回落文件策略。
|
|
170
|
+
*/
|
|
171
|
+
function optionInvite(c, getOpts) {
|
|
172
|
+
try {
|
|
173
|
+
const per = getOpts?.()?.inviteBySite?.[c.id]
|
|
174
|
+
if (!per || typeof per !== "object") return null
|
|
175
|
+
if (per.mode === "auto") return { mode: "auto", code: "", org: "", accountId: "" }
|
|
176
|
+
if ((per.mode === "fixed" || per.mode === "custom") && String(per.code || "").trim()) {
|
|
177
|
+
if (per.mode === "fixed" && orgDead(c, per.org)) {
|
|
178
|
+
return { mode: "auto", code: "", org: "", accountId: "" }
|
|
179
|
+
}
|
|
180
|
+
return per
|
|
181
|
+
}
|
|
182
|
+
} catch {}
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function inviteFromOptions(c, getOpts, getLock = () => true) {
|
|
187
|
+
const per = optionInvite(c, getOpts)
|
|
188
|
+
if (!per) return null
|
|
189
|
+
// 页面上的「锁定」关掉 → 页面选中的码也不作数,回到滚动 / 作者码
|
|
190
|
+
if (getLock() === false || per.mode === "auto") return inviteToUse(c.id, false)
|
|
191
|
+
return per.code
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function makeCodeMethod(c, kind, getLock = () => true, getOpts = () => ({})) {
|
|
163
195
|
const isEmail = kind === "email"
|
|
164
196
|
const what = isEmail ? "邮箱" : "手机号"
|
|
165
197
|
/** 官方登录页上那个 tab 的名字 —— 回退到浏览器时要说清楚点哪个 */
|
|
@@ -183,7 +215,7 @@ function makeCodeMethod(c, kind, getLock = () => true) {
|
|
|
183
215
|
|
|
184
216
|
return {
|
|
185
217
|
type: "oauth",
|
|
186
|
-
label: `${c.SHORT} ${what}验证码${inviteTag(c, getLock)}`,
|
|
218
|
+
label: `${c.SHORT} ${what}验证码${inviteTag(c, getLock, getOpts)}`,
|
|
187
219
|
prompts: [
|
|
188
220
|
isEmail
|
|
189
221
|
? {
|
|
@@ -214,7 +246,7 @@ function makeCodeMethod(c, kind, getLock = () => true) {
|
|
|
214
246
|
// ⚠ 无头这条路**没有**「打开带邀请码的页面」这一步,所以邀请码只能靠
|
|
215
247
|
// `redirect` 传 —— 网页表单的初始值里 redirect 就是 window.location.href,
|
|
216
248
|
// 服务端从它上面取 `?invitation=`。少了它,新注册的号就没有邀请关系。
|
|
217
|
-
const inviteCode = inviteToUse(c.id, getLock())
|
|
249
|
+
const inviteCode = inviteFromOptions(c, getOpts, getLock) ?? inviteToUse(c.id, getLock())
|
|
218
250
|
const loginUrl = inviteCode
|
|
219
251
|
? `${c.web}/login?invitation=${encodeURIComponent(inviteCode)}`
|
|
220
252
|
: `${c.web}/login`
|
|
@@ -363,57 +395,6 @@ function makeCodeMethod(c, kind, getLock = () => true) {
|
|
|
363
395
|
// `rollInvite` 顺手记下来的(每个账号每天都会跑一次签到,所以不会过期)。
|
|
364
396
|
// 这里**不能**发请求:prompts 的 options 是同步生成的。
|
|
365
397
|
|
|
366
|
-
/** org → 便于辨认的名字(手机号打码 / 邮箱),从 magpie 自己的账号文件只读 */
|
|
367
|
-
function accountLabel(siteId, org) {
|
|
368
|
-
try {
|
|
369
|
-
const raw = JSON.parse(readFileSync(join(homedir(), ".config", "magpie", "plugin-auth.json"), "utf8"))
|
|
370
|
-
for (const [k, v] of Object.entries(raw)) {
|
|
371
|
-
// 只认这个站点自己的 provider key(magicrew 不能匹配到 magicrew-intl)
|
|
372
|
-
if (k !== siteId && !String(k).startsWith(siteId + "#")) continue
|
|
373
|
-
if (String(v?.org || "") !== org) continue
|
|
374
|
-
if (v?.accountId) return String(v.accountId)
|
|
375
|
-
}
|
|
376
|
-
} catch {}
|
|
377
|
-
return org
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function inviteChoices(c, getLock = () => true) {
|
|
381
|
-
const pol = readInvitePolicy(c.id)
|
|
382
|
-
const codes = readAccountCodes(c.id)
|
|
383
|
-
const author = authorInvite(c.id)
|
|
384
|
-
const locked = getLock() !== false
|
|
385
|
-
const opts = []
|
|
386
|
-
if (author) {
|
|
387
|
-
opts.push({
|
|
388
|
-
label: "我的(作者 MYCF711):" + author,
|
|
389
|
-
value: "__author__",
|
|
390
|
-
hint: locked && pol.mode === "fixed" && pol.code === author ? "当前" : "作者码",
|
|
391
|
-
})
|
|
392
|
-
}
|
|
393
|
-
opts.push({
|
|
394
|
-
label: "自动(跟随最后一个登录 / 签到的号)",
|
|
395
|
-
value: "__auto__",
|
|
396
|
-
hint: pol.mode === "auto" ? "当前" : "会漂移",
|
|
397
|
-
})
|
|
398
|
-
for (const [org, v] of Object.entries(codes)) {
|
|
399
|
-
if (!v?.code || v.code === author) continue
|
|
400
|
-
opts.push({
|
|
401
|
-
label: accountLabel(c.id, org) + "(" + v.code + ")",
|
|
402
|
-
value: v.code,
|
|
403
|
-
hint: pol.mode === "fixed" && pol.code === v.code ? "当前固定" : org,
|
|
404
|
-
})
|
|
405
|
-
}
|
|
406
|
-
return opts
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/** 两个「只设置」入口共用的收尾 —— 把落盘结果回成一条可读的说明 */
|
|
410
|
-
function savedPolicy(c, getLock = () => true) {
|
|
411
|
-
return {
|
|
412
|
-
type: "failed",
|
|
413
|
-
error: `已保存:新号默认挂在 ${describePolicy(readInvitePolicy(c.id), getLock())}。这一步只存设置,不登录、不新增账号。`,
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
398
|
/**
|
|
418
399
|
* 面板标题后缀 —— 让「当前挂在谁名下」在选登录方式那一屏就能看见。
|
|
419
400
|
* (那张面板只能显示 methods 的 label,插件没有别的可写位置。)
|
|
@@ -421,44 +402,22 @@ function savedPolicy(c, getLock = () => true) {
|
|
|
421
402
|
* ⚠ 值在插件加载时算一次;改完设置要重启 magpie 才会刷新标题。
|
|
422
403
|
* 设置本身是立即生效的,标题只是个提示。
|
|
423
404
|
*/
|
|
424
|
-
function inviteTag(c, getLock = () => true) {
|
|
405
|
+
function inviteTag(c, getLock = () => true, getOpts = () => ({})) {
|
|
425
406
|
if (getLock() === false) return ""
|
|
407
|
+
// 页面里选过就按页面的走 —— 与登录时真正用的是同一个来源(含死号校验)
|
|
408
|
+
const per = optionInvite(c, getOpts)
|
|
409
|
+
if (per) {
|
|
410
|
+
if (per.mode === "auto") return ""
|
|
411
|
+
return "(上级 " + (per.accountId || per.org || per.code) + ")"
|
|
412
|
+
}
|
|
426
413
|
const pol = readInvitePolicy(c.id)
|
|
427
|
-
if (pol.mode === "fixed"
|
|
428
|
-
|
|
414
|
+
if ((pol.mode === "fixed" || pol.mode === "custom") && pol.code && !orgDead(c, pol.org)) {
|
|
415
|
+
return `(上级 ${pol.accountId || pol.code})`
|
|
416
|
+
}
|
|
429
417
|
return ""
|
|
430
418
|
}
|
|
431
419
|
|
|
432
|
-
function
|
|
433
|
-
if (!locked) return "自动(锁定已关闭)"
|
|
434
|
-
if (pol.mode === "fixed" && pol.code) return (pol.accountId || pol.org || "某个号") + "(" + pol.code + ")"
|
|
435
|
-
if (pol.mode === "custom" && pol.code) return "自定义码 " + pol.code
|
|
436
|
-
return "自动(跟随最后一个登录 / 签到的号)"
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const INVITE_MESSAGE = "新号挂在谁的邀请码下?(决定拉新奖励进哪个账号)"
|
|
440
|
-
const CUSTOM_KEY = "inviteCode"
|
|
441
|
-
|
|
442
|
-
/** 这次注册真正要带上的码 */
|
|
443
|
-
|
|
444
|
-
/** 选择落盘 —— 只在 authorize 里、真正要开登录页之前调 */
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* 动态提问:宿主支持 `ask`(magpie 内嵌契约里的 nextPrompt 优先用它),
|
|
448
|
-
* 所以下拉框每次都是最新的;静态 `prompts` 只作兜底。
|
|
449
|
-
*
|
|
450
|
-
* ⚠ 每次登录都会问 —— 但不能问第二次「自定义码」(选了才问),
|
|
451
|
-
* 而且上次的选择会作为默认带 hint,直接回车就用它。
|
|
452
|
-
*
|
|
453
|
-
* 契约限制(`extract-contract.py` 提取的原文):那个「用哪种方式登录」面板
|
|
454
|
-
* 只能列 `methods`,而 methods 的 type 只有 `oauth` / `api` 两种 ——
|
|
455
|
-
* **两种都会真的建一个账号**,没有「纯设置项」这种入口。
|
|
456
|
-
* 所以入口只能挂在登录流程里,做不到面板上单独一格。
|
|
457
|
-
*/
|
|
458
|
-
|
|
459
|
-
/** 静态兜底:宿主不认 `ask` 时用 */
|
|
460
|
-
|
|
461
|
-
export function makeMethods(c, getLock = () => true) {
|
|
420
|
+
export function makeMethods(c, getLock = () => true, getOpts = () => ({})) {
|
|
462
421
|
// 顺序 = magpie 登录方式列表里的顺序。
|
|
463
422
|
//
|
|
464
423
|
// ⚠ **邮箱验证码只有国际站有**,国内站不给。实测两件事:
|
|
@@ -470,18 +429,18 @@ export function makeMethods(c, getLock = () => true) {
|
|
|
470
429
|
// 只会让用户填完邮箱撞一个看不懂的错。
|
|
471
430
|
const codeMethods =
|
|
472
431
|
c.id === "magicrew-intl"
|
|
473
|
-
? [makeCodeMethod(c, "email", getLock), makeCodeMethod(c, "mobile", getLock)]
|
|
474
|
-
: [makeCodeMethod(c, "mobile", getLock)]
|
|
432
|
+
? [makeCodeMethod(c, "email", getLock, getOpts), makeCodeMethod(c, "mobile", getLock, getOpts)]
|
|
433
|
+
: [makeCodeMethod(c, "mobile", getLock, getOpts)]
|
|
475
434
|
|
|
476
435
|
return [
|
|
477
436
|
...codeMethods,
|
|
478
437
|
|
|
479
438
|
{
|
|
480
439
|
type: "oauth",
|
|
481
|
-
label: `${c.SHORT} 浏览器登录${inviteTag(c, getLock)}`,
|
|
440
|
+
label: `${c.SHORT} 浏览器登录${inviteTag(c, getLock, getOpts)}`,
|
|
482
441
|
async authorize(inputs) {
|
|
483
442
|
// 邀请码两条路都要出现:登录页的 URL,以及最终注册时服务端读到的那份
|
|
484
|
-
const inviteCode = inviteToUse(c.id, getLock())
|
|
443
|
+
const inviteCode = inviteFromOptions(c, getOpts, getLock) ?? inviteToUse(c.id, getLock())
|
|
485
444
|
const url = inviteCode
|
|
486
445
|
? `${c.web}/login?invitation=${encodeURIComponent(inviteCode)}`
|
|
487
446
|
: `${c.web}/login`
|
|
@@ -695,75 +654,5 @@ export function makeMethods(c, getLock = () => true) {
|
|
|
695
654
|
}
|
|
696
655
|
},
|
|
697
656
|
},
|
|
698
|
-
// ── ④⑤ 邀请上级(只设置,不登录)───────────────────────────────
|
|
699
|
-
//
|
|
700
|
-
// 契约限制(extract-contract.py 原文 + 2026-10-29 实测):
|
|
701
|
-
// · 这一屏只渲染 `methods`,type 只有 `oauth` / `api`
|
|
702
|
-
// · `save()` 对两者都会写一个账号;唯一不建号的出口是让 authorize
|
|
703
|
-
// 返回 `{ type: "failed" }`(所以这两个入口都只落盘 + 回「已保存」)
|
|
704
|
-
// · prompts 的控件只有 select(按钮列表)和 text(输入框)
|
|
705
|
-
//
|
|
706
|
-
// 拆成两个单步入口:一个展开账号列表(第一项就是「我的(作者 MYCF711)」),
|
|
707
|
-
// 一个填自定义码。锁定用 auth.options 的开关(provider 设置页里的真开关)。
|
|
708
|
-
{
|
|
709
|
-
type: "api",
|
|
710
|
-
keyless: true,
|
|
711
|
-
label: `${c.SHORT} 邀请上级${inviteTag(c, getLock)}`,
|
|
712
|
-
prompts: [
|
|
713
|
-
{ type: "select", key: "invite", message: INVITE_MESSAGE, options: inviteChoices(c, getLock) },
|
|
714
|
-
],
|
|
715
|
-
async authorize(inputs) {
|
|
716
|
-
const pick = String(inputs?.invite || "")
|
|
717
|
-
if (!pick) return { type: "failed", error: "没有选择 —— 保持当前设置" }
|
|
718
|
-
if (pick === "__author__") {
|
|
719
|
-
writeInvitePolicy(c.id, {
|
|
720
|
-
mode: "fixed",
|
|
721
|
-
code: authorInvite(c.id),
|
|
722
|
-
org: "author",
|
|
723
|
-
accountId: "MYCF711",
|
|
724
|
-
})
|
|
725
|
-
} else if (pick === "__auto__") {
|
|
726
|
-
// 选了自动 → 回到默认的滚动机制
|
|
727
|
-
writeInvitePolicy(c.id, { mode: "auto" })
|
|
728
|
-
} else {
|
|
729
|
-
const codes = readAccountCodes(c.id)
|
|
730
|
-
const org = Object.keys(codes).find((o) => codes[o]?.code === pick) || ""
|
|
731
|
-
writeInvitePolicy(c.id, {
|
|
732
|
-
mode: "fixed",
|
|
733
|
-
code: pick,
|
|
734
|
-
org,
|
|
735
|
-
accountId: org ? accountLabel(c.id, org) : "",
|
|
736
|
-
})
|
|
737
|
-
}
|
|
738
|
-
return savedPolicy(c, getLock)
|
|
739
|
-
},
|
|
740
|
-
},
|
|
741
|
-
|
|
742
|
-
{
|
|
743
|
-
type: "api",
|
|
744
|
-
keyless: true,
|
|
745
|
-
label: `${c.SHORT} 邀请上级:自定义码`,
|
|
746
|
-
prompts: [
|
|
747
|
-
{
|
|
748
|
-
type: "text",
|
|
749
|
-
key: CUSTOM_KEY,
|
|
750
|
-
message: "自定义邀请码(IV 开头 12 位;留空 = 保持当前)",
|
|
751
|
-
placeholder: "IVXXXXXXXXXX",
|
|
752
|
-
validate: (v) => {
|
|
753
|
-
const s = String(v || "").trim()
|
|
754
|
-
if (!s) return undefined
|
|
755
|
-
return /^IV[A-Z0-9]{10}$/i.test(s) ? undefined : "邀请码是 IV 开头的 12 位字母数字"
|
|
756
|
-
},
|
|
757
|
-
},
|
|
758
|
-
],
|
|
759
|
-
async authorize(inputs) {
|
|
760
|
-
const code = String(inputs?.[CUSTOM_KEY] || "")
|
|
761
|
-
.trim()
|
|
762
|
-
.toUpperCase()
|
|
763
|
-
if (!code) return { type: "failed", error: "没有填码 —— 保持当前设置" }
|
|
764
|
-
writeInvitePolicy(c.id, { mode: "custom", code })
|
|
765
|
-
return savedPolicy(c, getLock)
|
|
766
|
-
},
|
|
767
|
-
},
|
|
768
657
|
]
|
|
769
658
|
}
|
package/src/hooks/usage.mjs
CHANGED
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
|
|
27
27
|
import { fetchPoints, SITES } from "../api.mjs"
|
|
28
28
|
import { ensureCheckinDefault } from "../auto-maintenance.mjs"
|
|
29
|
+
import { syncInvitePage } from "../store.mjs"
|
|
30
|
+
|
|
31
|
+
// 页面清单的节流心跳:magpie 会定期轮 usage,借这一分钟一次的时机
|
|
32
|
+
// 把「有哪些账号、各自的码」同步给插件页面 —— 账号增删后页面自己跟上,
|
|
33
|
+
// 不用等下次签到 / 登录。
|
|
34
|
+
let lastPageSync = 0
|
|
29
35
|
|
|
30
36
|
// 每日登录积分:国内 +200/天、国际 +50/天(实测,见 docs/internals.md)。
|
|
31
37
|
// 今日批次的分母,只用于展示「今日还剩 / 今日额度」。
|
|
@@ -42,6 +48,12 @@ export function makeUsage(c) {
|
|
|
42
48
|
ensureCheckinDefault(c.PROVIDER)
|
|
43
49
|
} catch {}
|
|
44
50
|
}
|
|
51
|
+
if (Date.now() - lastPageSync > 60_000) {
|
|
52
|
+
lastPageSync = Date.now()
|
|
53
|
+
try {
|
|
54
|
+
syncInvitePage()
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
45
57
|
const auth = await getAuth()
|
|
46
58
|
if (!auth) return { error: "未登录", windows: [] }
|
|
47
59
|
try {
|