magicrew-auth 0.3.5 → 0.3.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 +193 -159
- package/README.md +16 -2
- package/docs/internals.md +713 -658
- package/docs/npm-publish.md +77 -0
- package/package.json +1 -1
- package/src/browser-login.mjs +119 -33
- package/src/hooks/methods.mjs +51 -1
- package/src/local-browser.mjs +163 -0
- package/src/store.mjs +37 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# 发 npm:`magicrew-auth`
|
|
2
|
+
|
|
3
|
+
> 线上:https://www.npmjs.com/package/magicrew-auth
|
|
4
|
+
> 首次发布 2026-10-27,`0.3.5`。**这个包名已经是我们的,之后只增版本,不再改名。**
|
|
5
|
+
|
|
6
|
+
## 一条命令
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm run publish:npm # 真发
|
|
10
|
+
npm run publish:npm -- --dry-run # 预演,看包名与打包面,不上传
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
脚本是 `publish-npm.mjs`,做的事只有一件:**发布那一下把 `name` 换成 `magicrew-auth`,跑完还原。**
|
|
14
|
+
|
|
15
|
+
## 为什么必须换名
|
|
16
|
+
|
|
17
|
+
registry 拒绝含大写字母的**新**包名,而 `package.json` 的 `name` 要同时喂两个地方:
|
|
18
|
+
|
|
19
|
+
| 谁读 | 读哪份 | 决定什么 |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `bun`(`magpie plugin add github:…`) | 仓库里的 `package.json` | 依赖键 → **卡片标题 `Magicrew`** |
|
|
22
|
+
| `npm publish` | 同一份 | npm 上的**包名** → 必须小写 `magicrew-auth` |
|
|
23
|
+
|
|
24
|
+
所以 npm 装的卡片标题是 `magicrew-auth`,git 装的卡片标题是 `Magicrew` —— 功能一样,只是标题。
|
|
25
|
+
FAQ 里对用户写过这条,别把它当 bug 修。
|
|
26
|
+
|
|
27
|
+
还原是硬要求:脚本在 `finally` 之外还挂了 `exit` / `SIGINT` 钩子。实测跑完
|
|
28
|
+
`sha256sum package.json` 与跑前逐字节一致。
|
|
29
|
+
|
|
30
|
+
## 发之前的三条
|
|
31
|
+
|
|
32
|
+
1. **bump `version`** —— 同版本号 publish 会被 registry 拒。
|
|
33
|
+
2. **`npm view magicrew-auth version` 看现在线上的版本**,别发重复。
|
|
34
|
+
3. README 会随包发出去,成为 magpie 卡片正文 —— **npm 用户的卡片只有发版才更新**
|
|
35
|
+
(git 源用户 push 即见)。所以 README 改完要跟着一个版本走。
|
|
36
|
+
|
|
37
|
+
## 发完之后会看到 `0.0.0-stage`,别慌
|
|
38
|
+
|
|
39
|
+
registry 上会先多出一个占位版本:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{ "version": "0.0.0-stage", "stub": true,
|
|
43
|
+
"description": "Temporary package placeholder for staged publishing" }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`dist-tags.latest` 可能先指向它,几秒后指向真版本;`npm stage list` 显示「没有 staged 版本」。
|
|
47
|
+
判据:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
curl -s https://registry.npmjs.org/magicrew-auth | python -c "import json,sys;d=json.load(sys.stdin);print(d['dist-tags'])"
|
|
51
|
+
# {'latest': '0.3.5'}
|
|
52
|
+
curl -s -o /dev/null -w '%{http_code}\n' https://registry.npmjs.org/magicrew-auth/0.3.5
|
|
53
|
+
# 200
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 验收(2026-10-27 实测)
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
npm view magicrew-auth version → 0.3.5
|
|
60
|
+
npm view magicrew-auth dist-tags → { latest: '0.3.5' }
|
|
61
|
+
npm i magicrew-auth && node -e "import('magicrew-auth')"
|
|
62
|
+
→ exports: MagicrewAuthPlugin, MagicrewIntlAuthPlugin, SITES, _internal
|
|
63
|
+
tarball 26 files / 118.5 kB,README 是当期版本
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## ⚠ npm 搜索搜不到,不等于包有问题
|
|
67
|
+
|
|
68
|
+
magpie 的发现搜索是 `/-/v1/search?text=<query> opencode`。实测:
|
|
69
|
+
|
|
70
|
+
| 查询 | 结果 |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `text=magicrew` | total 1,命中 |
|
|
73
|
+
| `text=magicrew opencode` | total 18500,**前 250 条里没有它** |
|
|
74
|
+
| `text=letsmagic` | total 1,命中 |
|
|
75
|
+
|
|
76
|
+
零下载的新包在混合查询里会被 opencode 生态的高下载包淹没。**能保证被发现的路径是 GitHub topic**
|
|
77
|
+
(仓库已带 `magpie-plugin` / `magpie` / `opencode` → Discover → Unofficial)。搜索排名随下载量积累会好转。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magicrew-auth",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "超级麦吉 / MagiCrew for magpie and OpenCode — one package, two providers: letsmagic.cn (国内) and magicrew.ai (international). Password or web-JWT sign-in, the account's own live model list, points balance, newcomer rewards, invite codes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/src/browser-login.mjs
CHANGED
|
@@ -13,22 +13,69 @@
|
|
|
13
13
|
// 页面是我们自己开的,告诉 magpie 「别再开一次」。
|
|
14
14
|
//
|
|
15
15
|
// ⚠ 独立 profile 意味着用户要在这个窗口里登一次(没有他日常浏览器的 cookie)。
|
|
16
|
-
//
|
|
16
|
+
// **每次登录一个一次性 profile,用完删** —— 为什么不能复用,见下面 newProfileDir()。
|
|
17
17
|
|
|
18
18
|
import { spawn } from "node:child_process"
|
|
19
|
-
import { existsSync, readFileSync, mkdirSync } from "node:fs"
|
|
19
|
+
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"
|
|
20
20
|
import { homedir } from "node:os"
|
|
21
21
|
import { join } from "node:path"
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 })
|
|
29
46
|
return d
|
|
30
47
|
}
|
|
31
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
|
+
|
|
32
79
|
/** 按平台找浏览器;找不到返回 null,调用方降级 */
|
|
33
80
|
export function findBrowser() {
|
|
34
81
|
const plat = process.platform
|
|
@@ -91,8 +138,11 @@ export async function launchLoginBrowser(url) {
|
|
|
91
138
|
const exe = findBrowser()
|
|
92
139
|
if (!exe) throw new Error("本机没找到 Chrome / Edge / Bravo / Chromium")
|
|
93
140
|
|
|
94
|
-
|
|
95
|
-
|
|
141
|
+
// 先扫掉上一次登录留下的临时 profile(含历史复用目录),再开一个全新的
|
|
142
|
+
sweepProfiles()
|
|
143
|
+
const dir = newProfileDir()
|
|
144
|
+
// ⚠ 残留会让这次「看起来起来了、其实什么都没做」—— 一次性目录里通常没有,
|
|
145
|
+
// 但 sweep 失败、或目录被复用时它们就是根因,清一遍几乎不花钱:
|
|
96
146
|
// DevToolsActivePort —— 不清掉,waitPort 会立刻返回一个已失效的旧端口
|
|
97
147
|
// SingletonLock / SingletonCookie / SingletonSocket / lockfile
|
|
98
148
|
// —— 不清掉,新进程判定 profile 被占用,把 URL 转交给
|
|
@@ -128,6 +178,9 @@ export async function launchLoginBrowser(url) {
|
|
|
128
178
|
try {
|
|
129
179
|
child.kill()
|
|
130
180
|
} catch {}
|
|
181
|
+
try {
|
|
182
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 2, retryDelay: 150 })
|
|
183
|
+
} catch {}
|
|
131
184
|
throw new Error("浏览器进程起来了,但 8 秒内没写出调试端口 DevToolsActivePort(多半是 profile 被一个已退出的实例占着)")
|
|
132
185
|
}
|
|
133
186
|
return { port, dir, child }
|
|
@@ -229,16 +282,31 @@ const READ_CREDS = `(() => {
|
|
|
229
282
|
|
|
230
283
|
/**
|
|
231
284
|
* 盯着那个浏览器,直到页面里的 localStorage 出现完整凭据。
|
|
232
|
-
*
|
|
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>}
|
|
233
293
|
*/
|
|
234
|
-
export async function waitForCreds(
|
|
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
|
|
235
300
|
const deadline = Date.now() + timeout
|
|
236
301
|
while (Date.now() < deadline) {
|
|
237
302
|
try {
|
|
238
|
-
const raw = await
|
|
303
|
+
const raw = await probe(session.port, hostFragment, READ_CREDS)
|
|
239
304
|
if (raw) {
|
|
240
305
|
const o = JSON.parse(raw)
|
|
241
|
-
if (o?.jwt?.startsWith("eyJ") && o?.org)
|
|
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
|
+
}
|
|
242
310
|
}
|
|
243
311
|
} catch {
|
|
244
312
|
// 页面还在加载 / target 换了 —— 下一轮再试
|
|
@@ -249,13 +317,17 @@ export async function waitForCreds(session, hostFragment, { timeout = 300_000, i
|
|
|
249
317
|
}
|
|
250
318
|
|
|
251
319
|
/**
|
|
252
|
-
*
|
|
320
|
+
* 关掉这个浏览器窗口,然后把这一次的临时 profile 删掉。
|
|
253
321
|
*
|
|
254
322
|
* 两条路都要走:
|
|
255
|
-
* ① CDP 的 `Browser.close` ——
|
|
256
|
-
* (profile 里没有落盘的 cookie,下次登录还得再来一次)
|
|
323
|
+
* ① CDP 的 `Browser.close` —— 走浏览器自己的退出流程,进程干净退出
|
|
257
324
|
* ② 兜底 kill —— ①在 2 秒内没关掉就强杀整棵进程树
|
|
258
|
-
* (Windows 上 kill() 只杀主进程,渲染/GPU 子进程会留着占住 profile
|
|
325
|
+
* (Windows 上 kill() 只杀主进程,渲染/GPU 子进程会留着占住 profile,
|
|
326
|
+
* 后面的 rmSync 也会因为文件锁失败)
|
|
327
|
+
*
|
|
328
|
+
* ⚠ profile 是**一次性**的,删掉是对的:里面只有这次登录的 cookie,
|
|
329
|
+
* 留着只会让下一次登录读到上一个账号(见 newProfileDir 的注释)。
|
|
330
|
+
* 删不掉不影响正确性 —— 下次 launch 会先 sweepProfiles()。
|
|
259
331
|
*/
|
|
260
332
|
export async function closeSession(session) {
|
|
261
333
|
if (!session) return
|
|
@@ -302,21 +374,35 @@ export async function closeSession(session) {
|
|
|
302
374
|
|
|
303
375
|
// ② 兜底:还活着就强杀整棵进程树
|
|
304
376
|
const pid = session.child?.pid
|
|
305
|
-
if (
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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))
|
|
318
404
|
}
|
|
319
|
-
}
|
|
405
|
+
}
|
|
320
406
|
}
|
|
321
407
|
|
|
322
|
-
export const _internal = { READ_CREDS,
|
|
408
|
+
export const _internal = { READ_CREDS, newProfileDir, sweepProfiles, waitPort, evaluate }
|
package/src/hooks/methods.mjs
CHANGED
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
fetchWho,
|
|
19
19
|
fetchOwnInviteCode,
|
|
20
20
|
} from "../api.mjs"
|
|
21
|
-
import { inviteToUse, readSession, writeSession, noteLoginFailure, rollInvite } from "../store.mjs"
|
|
21
|
+
import { inviteToUse, readSession, writeSession, noteLoginFailure, noteOrg, readKnownOrgs, rollInvite } from "../store.mjs"
|
|
22
|
+
import { readLocalCreds } from "../local-browser.mjs"
|
|
22
23
|
import { CLIP_HINT, clipboardText, parseCreds, pollClipboard } from "../clipboard.mjs"
|
|
23
24
|
|
|
24
25
|
export function makeMethods(c) {
|
|
@@ -34,6 +35,53 @@ export function makeMethods(c) {
|
|
|
34
35
|
: `${c.web}/login`
|
|
35
36
|
const hostFragment = new URL(c.web).hostname
|
|
36
37
|
|
|
38
|
+
// ⓿ 本机浏览器里已经有这个站点的登录态 → 直接接上,不开窗口。
|
|
39
|
+
//
|
|
40
|
+
// 这是「有的人网页登录了、插件里却没多出账号」的正解:用户平时那个浏览器
|
|
41
|
+
// 登录完,凭据就已经落在它的 localStorage 里了 —— 读文件就能拿到,
|
|
42
|
+
// 不用再让他在这个窗口里重登一次。
|
|
43
|
+
//
|
|
44
|
+
// ⚠ 只在这个账号**还没加过**时自动接:否则用户点登录是想加新账号,
|
|
45
|
+
// 自动接上的会是同一个,magpie 侧(同 accountId + 同 uid)只会替换旧账号。
|
|
46
|
+
const local = readLocalCreds(hostFragment)
|
|
47
|
+
if (local && !readKnownOrgs(c.id).includes(local.org)) {
|
|
48
|
+
return {
|
|
49
|
+
url,
|
|
50
|
+
opened: true, // 凭据来自本机浏览器,我们没开页面 —— 别让 magpie 再开一个
|
|
51
|
+
instructions: `已从本机 ${local.browser} 读到 ${c.SHORT} 的登录态,正在验证…`,
|
|
52
|
+
method: "auto",
|
|
53
|
+
async callback() {
|
|
54
|
+
let r
|
|
55
|
+
try {
|
|
56
|
+
// 请求放在 callback 里:authorize 必须立刻返回,而且没被调用时不该打网络
|
|
57
|
+
await fetchPoints(local.jwt, local.org, c.host)
|
|
58
|
+
r = await issueGatewayKey(local.jwt, local.org, c.host)
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// 本机那把失效了:记进「加过」名单,下次点登录就走窗口
|
|
61
|
+
noteOrg(c.id, local.org)
|
|
62
|
+
return {
|
|
63
|
+
type: "failed",
|
|
64
|
+
error: `本机 ${local.browser} 里的登录态已失效(${String(e?.message ?? e)})。再点一次登录会打开窗口;或者先去那个浏览器里重新登录。`,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
writeSession(c.id, local.jwt, local.org)
|
|
68
|
+
noteOrg(c.id, local.org)
|
|
69
|
+
rollInvite(c.id, local.jwt, local.org, c.host).catch(() => {})
|
|
70
|
+
const who = await fetchWho(local.jwt, local.org, c.host).catch(() => null)
|
|
71
|
+
return {
|
|
72
|
+
type: "success",
|
|
73
|
+
access: r.apiKey,
|
|
74
|
+
refresh: r.refreshToken,
|
|
75
|
+
expires: Date.now() + 2 * 3600_000,
|
|
76
|
+
accountId: who?.name || local.org,
|
|
77
|
+
uid: local.org,
|
|
78
|
+
jwt: local.jwt,
|
|
79
|
+
org: local.org,
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
37
85
|
// 浏览器登录是**可选能力**,按需加载:这个模块用 child_process /
|
|
38
86
|
// WebSocket / 端口探测,环境依赖最重。静态 import 一旦失败,
|
|
39
87
|
// 整个插件都加载不起来 —— 连「登录态」那条备选也没了。
|
|
@@ -97,6 +145,7 @@ export function makeMethods(c) {
|
|
|
97
145
|
await fetchPoints(got.jwt, got.org, c.host)
|
|
98
146
|
const key = await issueGatewayKey(got.jwt, got.org, c.host)
|
|
99
147
|
writeSession(c.id, got.jwt, got.org)
|
|
148
|
+
noteOrg(c.id, got.org)
|
|
100
149
|
// 滚邀请码:把这个账号自己的码存成下一次用的
|
|
101
150
|
rollInvite(c.id, got.jwt, got.org, c.host).catch(() => {})
|
|
102
151
|
return {
|
|
@@ -170,6 +219,7 @@ export function makeMethods(c) {
|
|
|
170
219
|
return { type: "failed", error: String(e?.message ?? e) }
|
|
171
220
|
}
|
|
172
221
|
writeSession(c.id, jwt, org)
|
|
222
|
+
noteOrg(c.id, org)
|
|
173
223
|
rollInvite(c.id, jwt, org, c.host).catch(() => {})
|
|
174
224
|
// 卡片上显示手机号/邮箱/昵称,取不到就退回组织码
|
|
175
225
|
const who = await fetchWho(jwt, org, c.host).catch(() => null)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// 从**本机浏览器的 localStorage** 里读 magicrew 登录态 —— 不开窗口、不让用户粘任何东西。
|
|
2
|
+
//
|
|
3
|
+
// ═══ 为什么需要这条路 ═══════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// magicrew 没有 OAuth 回调通道(见 docs/internals.md 第九节),所以「用户在网页上
|
|
6
|
+
// 登录成功」这件事,插件原本只能靠「自己起的那个浏览器」知道。于是有一个反复被报的
|
|
7
|
+
// 现象:**用户在自己浏览器里登好了,插件里却没有多出账号** —— 因为那是另一个浏览器。
|
|
8
|
+
//
|
|
9
|
+
// 但凭据其实就躺在本地:Chromium 系(Edge / Chrome / Brave)把 localStorage 明文写在
|
|
10
|
+
//
|
|
11
|
+
// <User Data>/<Default|Profile N>/Local Storage/leveldb/*.log|*.ldb
|
|
12
|
+
//
|
|
13
|
+
// 里,key 是 `_<origin>\x00\x01<name>`,值是 JSON。直接读文件就行,不用 CDP、不用开窗口。
|
|
14
|
+
//
|
|
15
|
+
// 读法:把 leveldb 目录按 latin1 拼成文本 → 以站点 host 出现的位置开一个窗口 →
|
|
16
|
+
// 在同一个窗口里找 `"key":"authorization"` 与 `"key":"organizationCode2?"`。
|
|
17
|
+
// LevelDB 按 key 排序,同一 origin 的记录挨在一起,所以窗口内配对是可靠的
|
|
18
|
+
// (实测本机 Edge:`www.letsmagic.cn` 命中、`www.magicrew.ai` 不命中,不会串)。
|
|
19
|
+
//
|
|
20
|
+
// 成本:本机 Edge 27 MB / 34 ms。全部浏览器扫一遍通常 < 300 ms。
|
|
21
|
+
//
|
|
22
|
+
// ⚠ 只读。不写文件、不改浏览器数据、不碰 cookie。
|
|
23
|
+
|
|
24
|
+
import { homedir } from "node:os"
|
|
25
|
+
import { readdirSync, readFileSync } from "node:fs"
|
|
26
|
+
import { join } from "node:path"
|
|
27
|
+
|
|
28
|
+
/** 同一个 origin 的若干条记录(theme / i18n / userInfo / authorization …)挤在一起,
|
|
29
|
+
* 2000 字节足够覆盖到 organizationCode;太小会漏、太大可能跨到另一个站点 */
|
|
30
|
+
const WINDOW = 2000
|
|
31
|
+
|
|
32
|
+
/** 各平台的浏览器 User Data 根目录;不存在就跳过 */
|
|
33
|
+
function roots() {
|
|
34
|
+
const h = homedir()
|
|
35
|
+
if (process.platform === "win32") {
|
|
36
|
+
const local = process.env.LOCALAPPDATA || join(h, "AppData", "Local")
|
|
37
|
+
return [
|
|
38
|
+
["Edge", join(local, "Microsoft", "Edge", "User Data")],
|
|
39
|
+
["Chrome", join(local, "Google", "Chrome", "User Data")],
|
|
40
|
+
["Brave", join(local, "BraveSoftware", "Brave-Browser", "User Data")],
|
|
41
|
+
["Chromium", join(local, "Chromium", "User Data")],
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
if (process.platform === "darwin") {
|
|
45
|
+
return [
|
|
46
|
+
["Chrome", join(h, "Library/Application Support/Google/Chrome")],
|
|
47
|
+
["Edge", join(h, "Library/Application Support/Microsoft Edge")],
|
|
48
|
+
["Brave", join(h, "Library/Application Support/BraveSoftware/Brave-Browser")],
|
|
49
|
+
["Chromium", join(h, "Library/Application Support/Chromium")],
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
return [
|
|
53
|
+
["Chrome", join(h, ".config/google-chrome")],
|
|
54
|
+
["Chromium", join(h, ".config/chromium")],
|
|
55
|
+
["Edge", join(h, ".config/microsoft-edge")],
|
|
56
|
+
["Brave", join(h, ".config/BraveSoftware/Brave-Browser")],
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function profilesOf(root) {
|
|
61
|
+
try {
|
|
62
|
+
return readdirSync(root).filter((n) => n === "Default" || /^Profile \d+$/.test(n))
|
|
63
|
+
} catch {
|
|
64
|
+
return []
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function leveldbText(root, profile) {
|
|
69
|
+
const dir = join(root, profile, "Local Storage", "leveldb")
|
|
70
|
+
let files
|
|
71
|
+
try {
|
|
72
|
+
files = readdirSync(dir)
|
|
73
|
+
} catch {
|
|
74
|
+
return ""
|
|
75
|
+
}
|
|
76
|
+
let text = ""
|
|
77
|
+
for (const f of files) {
|
|
78
|
+
try {
|
|
79
|
+
text += readFileSync(join(dir, f)).toString("latin1")
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
return text
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** jwt 里的 iat / exp;解不出来返回 0 */
|
|
86
|
+
function jwtTimes(jwt) {
|
|
87
|
+
try {
|
|
88
|
+
const body = JSON.parse(Buffer.from(String(jwt).split(".")[1] || "", "base64url").toString("utf8"))
|
|
89
|
+
return { iat: Number(body.iat) || 0, exp: Number(body.exp) || 0 }
|
|
90
|
+
} catch {
|
|
91
|
+
return { iat: 0, exp: 0 }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** LevelDB 里一条记录的前缀:`_https://<origin>\x00\x01<name>` */
|
|
96
|
+
const ORIGIN_RE = /_https?:\/\/([^\x00\x01"\\]{4,80})\x00\x01/g
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 在 host 每一次出现的位置开窗,取窗口内同一 origin 的 jwt × org 组合。
|
|
100
|
+
*
|
|
101
|
+
* ⚠ 必须按 origin 截断:两个站点的记录可能相邻(用户两个都登录过),
|
|
102
|
+
* 2000 字节的窗口会把国际站的 org 配到国内站的 jwt 上。
|
|
103
|
+
* 截断点 = 「下一个**不同站点**的 origin」,同站点的多条记录不截断。
|
|
104
|
+
*/
|
|
105
|
+
function credsNear(text, host, win = WINDOW) {
|
|
106
|
+
const bare = host.replace(/^www\./, "")
|
|
107
|
+
const origins = []
|
|
108
|
+
for (const m of text.matchAll(ORIGIN_RE)) origins.push({ host: m[1], at: m.index })
|
|
109
|
+
|
|
110
|
+
const out = []
|
|
111
|
+
let at = -1
|
|
112
|
+
while ((at = text.indexOf(host, at + 1)) !== -1) {
|
|
113
|
+
let stop = Math.min(text.length, at + win)
|
|
114
|
+
for (const o of origins) {
|
|
115
|
+
if (o.at > at && !o.host.includes(bare)) {
|
|
116
|
+
stop = o.at
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const w = text.slice(at, stop)
|
|
121
|
+
// 每把 jwt 配**它自己后面**最近的那个组织码:
|
|
122
|
+
// LevelDB 按 key 排序(authorization < organizationCode),同一批写入的挨在一起;
|
|
123
|
+
// 取「窗口内第一个 org」会在同一站点存过多把凭据时配错。
|
|
124
|
+
for (const m of w.matchAll(/"key":"authorization","value":"(eyJ[^"]+)"/g)) {
|
|
125
|
+
const tail = w.slice(m.index)
|
|
126
|
+
const org = (tail.match(/"key":"organizationCode2?","value":"([^"]+)"/) || [])[1]
|
|
127
|
+
if (org) out.push({ jwt: m[1], org })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 读本机浏览器里属于这个站点的登录态。
|
|
135
|
+
*
|
|
136
|
+
* @param {string} hostname 例如 `www.letsmagic.cn`
|
|
137
|
+
* @returns {{jwt:string, org:string, browser:string, profile:string, host:string, iat:number} | null}
|
|
138
|
+
* 取 iat 最新、且未过期的那一把;读不到返回 null(调用方降级到窗口登录)。
|
|
139
|
+
*/
|
|
140
|
+
export function readLocalCreds(hostname, { now = Date.now() } = {}) {
|
|
141
|
+
const bare = String(hostname || "").trim().replace(/^www\./, "")
|
|
142
|
+
if (!bare) return null
|
|
143
|
+
const hosts = [hostname, bare, `www.${bare}`].filter((v, i, a) => v && a.indexOf(v) === i)
|
|
144
|
+
|
|
145
|
+
let best = null
|
|
146
|
+
for (const [browser, root] of roots()) {
|
|
147
|
+
for (const profile of profilesOf(root)) {
|
|
148
|
+
const text = leveldbText(root, profile)
|
|
149
|
+
if (!text) continue
|
|
150
|
+
for (const host of hosts) {
|
|
151
|
+
for (const c of credsNear(text, host)) {
|
|
152
|
+
const { iat, exp } = jwtTimes(c.jwt)
|
|
153
|
+
if (!iat) continue
|
|
154
|
+
if (exp && exp * 1000 < now) continue
|
|
155
|
+
if (!best || iat > best.iat) best = { ...c, iat, browser, profile, host }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return best
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const _internal = { roots, profilesOf, leveldbText, credsNear, jwtTimes, WINDOW }
|
package/src/store.mjs
CHANGED
|
@@ -163,6 +163,40 @@ function writeSession(siteId, jwt, org) {
|
|
|
163
163
|
} catch {}
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// ═══ 加过哪些账号(只记 org)═══════════════════════════════════════════
|
|
167
|
+
//
|
|
168
|
+
// 唯一的用途:决定「本机浏览器里读到的登录态要不要自动接上」。
|
|
169
|
+
//
|
|
170
|
+
// 用户点「登录」多数是想加**新**账号;如果本机浏览器里那把属于已经加过的账号,
|
|
171
|
+
// 自动接上只会得到「同一个账号」(magpie 侧同 accountId+uid → 替换,不新增),
|
|
172
|
+
// 看起来又像「登录了却没加账号」。所以已经在名单里的 org,一律改走窗口登录。
|
|
173
|
+
//
|
|
174
|
+
// 记不上也无所谓:最坏是自动接上一个重复账号,magpie 会替换它。
|
|
175
|
+
|
|
176
|
+
function orgsPath(siteId) {
|
|
177
|
+
return join(cacheDir(), `magicrew-orgs-${siteId}.json`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readKnownOrgs(siteId) {
|
|
181
|
+
try {
|
|
182
|
+
const v = JSON.parse(readFileSync(orgsPath(siteId), "utf8"))
|
|
183
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x) : []
|
|
184
|
+
} catch {
|
|
185
|
+
return []
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function noteOrg(siteId, org) {
|
|
190
|
+
if (!org || typeof org !== "string") return
|
|
191
|
+
try {
|
|
192
|
+
const list = readKnownOrgs(siteId)
|
|
193
|
+
if (list.includes(org)) return
|
|
194
|
+
list.push(org)
|
|
195
|
+
const f = orgsPath(siteId)
|
|
196
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
197
|
+
writeFileSync(f, JSON.stringify(list.slice(-50)))
|
|
198
|
+
} catch {}
|
|
199
|
+
}
|
|
166
200
|
|
|
167
201
|
export {
|
|
168
202
|
AUTHOR_INVITE,
|
|
@@ -172,7 +206,10 @@ export {
|
|
|
172
206
|
inviteToUse,
|
|
173
207
|
loginLogPath,
|
|
174
208
|
noteLoginFailure,
|
|
209
|
+
noteOrg,
|
|
210
|
+
orgsPath,
|
|
175
211
|
readInvite,
|
|
212
|
+
readKnownOrgs,
|
|
176
213
|
readSession,
|
|
177
214
|
readSnapshot,
|
|
178
215
|
sessionPath,
|