magicrew-auth 0.3.13 → 0.3.15
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 +111 -0
- package/README.md +19 -5
- package/docs/internals.md +86 -12
- package/package.json +1 -1
- package/src/api.mjs +25 -0
- package/src/browser-login.mjs +228 -46
- package/src/clipboard.mjs +106 -106
- package/src/hooks/loader.mjs +93 -93
- package/src/hooks/methods.mjs +2 -2
- package/src/hooks/models.mjs +17 -6
- package/src/icons.mjs +24 -24
- package/src/local-browser.mjs +19 -0
- package/src/model-context.mjs +100 -100
- package/src/tasks.mjs +22 -10
package/src/browser-login.mjs
CHANGED
|
@@ -76,36 +76,141 @@ export function sweepProfiles({ maxAgeMs = 2 * 3600_000 } = {}) {
|
|
|
76
76
|
return removed
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
79
|
+
/**
|
|
80
|
+
* 候选浏览器表 —— 按平台给,**按优先级排**。
|
|
81
|
+
*
|
|
82
|
+
* ⚠ 顺序有意义:靠前的先试。选序的道理不是「谁更有名」,而是
|
|
83
|
+
* **谁更干净、更不会被系统策略拦住调试端口**:
|
|
84
|
+
*
|
|
85
|
+
* ① Chrome / Chromium 自己装的,没有企业策略、没有强制登录、没有侧边栏
|
|
86
|
+
* ② 国产 Chromium 系 用户日常在用的那个,CDP 实测一样能用
|
|
87
|
+
* ③ Edge Windows 自带,**放最后**:
|
|
88
|
+
* · 企业/组策略常禁用远程调试,起不来的概率最高
|
|
89
|
+
* · 会被系统账号强制接管、带侧边栏与首次运行引导
|
|
90
|
+
* · 用户明确反馈「讨厌 Edge」,没有理由优先选它
|
|
91
|
+
*
|
|
92
|
+
* 实测(2026-10-28,本机):
|
|
93
|
+
* Chromium 154.0.8037.93 (scoop) 起 CDP 650ms,页面就绪 ~2s,读凭据正常
|
|
94
|
+
* Edge 122.0.2365.106 同上可用,但按上面的理由排在后面
|
|
95
|
+
* 360ChromeX (Chrome 132 内核) 同上可用
|
|
96
|
+
*
|
|
97
|
+
* ⚠⚠ Edge 这里曾经还有一个真 bug:写死的文件名是 `msedge.exe`,
|
|
98
|
+
* 而**有些**机器上微软落盘的是 **`Microsoft Edge.exe`**(带空格、带大写),
|
|
99
|
+
* 另一些机器的同一版本只有 `msedge.exe`。落盘名随安装方式/版本变,
|
|
100
|
+
* 写死任何一个都必有一类机器命中不到。
|
|
101
|
+
* 于是 existsSync 一路 miss → findBrowser() 返回 null → 永远走「降级」:
|
|
102
|
+
* 系统浏览器打开 + 让用户去 Console 粘一行。用户看到的现象就是
|
|
103
|
+
* **「网页已经登好、跳到 agent 界面了,magpie 里还在转圈等」**。
|
|
104
|
+
* 判据在 ~/.cache/magpie/magicrew-login-<site>.log:
|
|
105
|
+
* launch: 本机没找到 Chrome / Edge / Bravo / Chromium
|
|
106
|
+
*
|
|
107
|
+
* ⇒ 两个名字都留着(不同安装方式/版本落盘的名字不一样),谁在就用谁。
|
|
108
|
+
*
|
|
109
|
+
* @param {string} [platform] 默认 process.platform(测试注入用)
|
|
110
|
+
* @param {Record<string,string>} [env] 默认 process.env(测试注入用)
|
|
111
|
+
* @returns {{name:string, path:string}[]}
|
|
112
|
+
*/
|
|
113
|
+
export function browserCandidates(platform = process.platform, env = process.env) {
|
|
114
|
+
const e = env || {}
|
|
115
|
+
if (platform === "win32") {
|
|
116
|
+
const local = e.LOCALAPPDATA || ""
|
|
117
|
+
const roaming = e.APPDATA || ""
|
|
118
|
+
const home = e.USERPROFILE || e.HOME || ""
|
|
119
|
+
const pf = e.PROGRAMFILES || "C:\\Program Files"
|
|
120
|
+
const pf86 = e["PROGRAMFILES(X86)"] || e["ProgramFiles(x86)"] || "C:\\Program Files (x86)"
|
|
121
|
+
/** 一个浏览器常有 64/32 位两个安装根,按序都给 */
|
|
122
|
+
const under = (root, rest) => `${root}\\${rest}`
|
|
123
|
+
|
|
124
|
+
return [
|
|
125
|
+
// ═══ ① Chrome / Chromium —— 首选:纯净、没策略、没强制登录 ═══════
|
|
126
|
+
{ name: "Chrome", path: under(pf, "Google\\Chrome\\Application\\chrome.exe") },
|
|
127
|
+
{ name: "Chrome", path: under(pf86, "Google\\Chrome\\Application\\chrome.exe") },
|
|
128
|
+
{ name: "Chrome", path: under(local, "Google\\Chrome\\Application\\chrome.exe") },
|
|
129
|
+
|
|
130
|
+
// Chromium:scoop 安装的(本机就是这个,154 版,实测最顺)
|
|
131
|
+
{ name: "Chromium", path: under(home, "scoop\\apps\\chromium\\current\\chrome.exe") },
|
|
132
|
+
{ name: "Chromium", path: under(local, "Chromium\\Application\\chrome.exe") },
|
|
133
|
+
{ name: "Chromium", path: under(pf, "Chromium\\Application\\chrome.exe") },
|
|
134
|
+
{ name: "Chromium", path: under(pf86, "Chromium\\Application\\chrome.exe") },
|
|
135
|
+
// 便携版常见的解压目录
|
|
136
|
+
{ name: "Chromium", path: under(home, "Chromium\\chrome.exe") },
|
|
137
|
+
|
|
138
|
+
{ name: "Brave", path: under(pf, "BraveSoftware\\Brave-Browser\\Application\\brave.exe") },
|
|
139
|
+
{ name: "Brave", path: under(pf86, "BraveSoftware\\Brave-Browser\\Application\\brave.exe") },
|
|
140
|
+
{ name: "Brave", path: under(local, "BraveSoftware\\Brave-Browser\\Application\\brave.exe") },
|
|
141
|
+
|
|
142
|
+
{ name: "Vivaldi", path: under(local, "Vivaldi\\Application\\vivaldi.exe") },
|
|
143
|
+
{ name: "Vivaldi", path: under(pf, "Vivaldi\\Application\\vivaldi.exe") },
|
|
144
|
+
{ name: "Opera", path: under(local, "Programs\\Opera\\opera.exe") },
|
|
145
|
+
{ name: "Opera", path: under(roaming, "Opera Software\\Opera Stable\\opera.exe") },
|
|
146
|
+
|
|
147
|
+
// ═══ ② 国产 Chromium 系(都是 Chromium 内核,CDP 一样能用)═════
|
|
148
|
+
// 360 极速浏览器 X —— 实测 Chrome 132 内核,CDP 完全可用
|
|
149
|
+
{ name: "360极速浏览器X", path: under(local, "360ChromeX\\Chrome\\Application\\360ChromeX.exe") },
|
|
150
|
+
{ name: "360浏览器", path: under(local, "360Chrome\\Chrome\\Application\\360chrome.exe") },
|
|
151
|
+
{ name: "360浏览器", path: under(local, "360Chrome\\Chrome\\Application\\360Chrome.exe") },
|
|
152
|
+
{ name: "360安全浏览器", path: under(roaming, "secoresdk\\360se6\\Application\\360se.exe") },
|
|
153
|
+
{ name: "360安全浏览器", path: under(local, "360se\\Application\\360se.exe") },
|
|
154
|
+
{ name: "QQ浏览器", path: under(local, "Tencent\\QQBrowser\\Application\\QQBrowser.exe") },
|
|
155
|
+
{ name: "QQ浏览器", path: under(pf, "Tencent\\QQBrowser\\QQBrowser.exe") },
|
|
156
|
+
{ name: "搜狗浏览器", path: under(local, "SogouExplorer\\SogouExplorer.exe") },
|
|
157
|
+
{ name: "搜狗浏览器", path: under(pf, "SogouExplorer\\SogouExplorer.exe") },
|
|
158
|
+
{ name: "2345浏览器", path: under(local, "2345Explorer\\2345Explorer.exe") },
|
|
159
|
+
{ name: "猎豹浏览器", path: under(local, "liebao\\liebao.exe") },
|
|
160
|
+
{ name: "傲游浏览器", path: under(roaming, "Maxthon3\\Bin\\Maxthon.exe") },
|
|
161
|
+
{ name: "UC浏览器", path: under(local, "UCBrowser\\Application\\UCBrowser.exe") },
|
|
162
|
+
{ name: "夸克浏览器", path: under(local, "Quark\\Quark.exe") },
|
|
163
|
+
|
|
164
|
+
// ═══ ③ Edge —— 最后:策略最多、最容易被拦住、用户也最不想用它 ═══
|
|
165
|
+
// ⚠ 真实文件名是 "Microsoft Edge.exe";msedge.exe 也留着以防别的装法
|
|
166
|
+
{ name: "Edge", path: under(pf86, "Microsoft\\Edge\\Application\\Microsoft Edge.exe") },
|
|
167
|
+
{ name: "Edge", path: under(pf, "Microsoft\\Edge\\Application\\Microsoft Edge.exe") },
|
|
168
|
+
{ name: "Edge", path: under(pf86, "Microsoft\\Edge\\Application\\msedge.exe") },
|
|
169
|
+
{ name: "Edge", path: under(pf, "Microsoft\\Edge\\Application\\msedge.exe") },
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (platform === "darwin") {
|
|
174
|
+
const h = e.HOME || ""
|
|
175
|
+
return [
|
|
176
|
+
{ name: "Chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
|
|
177
|
+
{ name: "Chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium" },
|
|
178
|
+
{ name: "Brave", path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" },
|
|
179
|
+
{ name: "Vivaldi", path: "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi" },
|
|
180
|
+
{ name: "Chrome", path: `${h}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` },
|
|
181
|
+
{ name: "Chromium", path: `${h}/Applications/Chromium.app/Contents/MacOS/Chromium` },
|
|
182
|
+
{ name: "Brave", path: `${h}/Applications/Brave Browser.app/Contents/MacOS/Brave Browser` },
|
|
183
|
+
// Edge 同样放最后
|
|
184
|
+
{ name: "Edge", path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
|
|
185
|
+
{ name: "Edge", path: `${h}/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge` },
|
|
186
|
+
]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return [
|
|
190
|
+
{ name: "Chrome", path: "/usr/bin/google-chrome" },
|
|
191
|
+
{ name: "Chrome", path: "/usr/bin/google-chrome-stable" },
|
|
192
|
+
{ name: "Chromium", path: "/usr/bin/chromium" },
|
|
193
|
+
{ name: "Chromium", path: "/usr/bin/chromium-browser" },
|
|
194
|
+
{ name: "Chromium", path: "/snap/bin/chromium" },
|
|
195
|
+
{ name: "Brave", path: "/usr/bin/brave-browser" },
|
|
196
|
+
{ name: "Vivaldi", path: "/usr/bin/vivaldi" },
|
|
197
|
+
// Edge 同样放最后
|
|
198
|
+
{ name: "Edge", path: "/usr/bin/microsoft-edge" },
|
|
199
|
+
{ name: "Edge", path: "/usr/bin/microsoft-edge-stable" },
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 挑一个**真实存在**的浏览器;一个都没有返回 null(调用方降级)。
|
|
205
|
+
*
|
|
206
|
+
* `candidates` 只在测试里注入;生产走 browserCandidates()。
|
|
207
|
+
* @returns {{name:string, path:string} | null}
|
|
208
|
+
*/
|
|
209
|
+
export function findBrowser({ candidates } = {}) {
|
|
210
|
+
const list = candidates ?? browserCandidates()
|
|
211
|
+
for (const c of list) {
|
|
107
212
|
try {
|
|
108
|
-
if (
|
|
213
|
+
if (c?.path && existsSync(c.path)) return c
|
|
109
214
|
} catch {}
|
|
110
215
|
}
|
|
111
216
|
return null
|
|
@@ -115,8 +220,11 @@ export function findBrowser() {
|
|
|
115
220
|
* 等 Chromium 把 DevToolsActivePort 写出来。
|
|
116
221
|
* 文件两行:第一行端口,第二行 ws 路径。`--remote-debugging-port=0` 让系统挑端口,
|
|
117
222
|
* 避免和已有的调试端口撞车。
|
|
223
|
+
*
|
|
224
|
+
* @param {() => boolean} [aborted] 返回 true 就立刻放弃(比如进程已经退出了),
|
|
225
|
+
* 免得为一个死掉的进程白等满 8 秒
|
|
118
226
|
*/
|
|
119
|
-
async function waitPort(dir, timeoutMs = 8_000) {
|
|
227
|
+
async function waitPort(dir, timeoutMs = 8_000, aborted = null) {
|
|
120
228
|
const f = join(dir, "DevToolsActivePort")
|
|
121
229
|
const deadline = Date.now() + timeoutMs
|
|
122
230
|
while (Date.now() < deadline) {
|
|
@@ -125,6 +233,16 @@ async function waitPort(dir, timeoutMs = 8_000) {
|
|
|
125
233
|
const port = Number(line)
|
|
126
234
|
if (port > 0) return port
|
|
127
235
|
} catch {}
|
|
236
|
+
// 文件还没出现时先看进程是不是已经死了 —— 死了就没必要再等
|
|
237
|
+
if (aborted?.()) {
|
|
238
|
+
// 再给一次机会:进程退出瞬间可能刚好写完
|
|
239
|
+
try {
|
|
240
|
+
const line = readFileSync(f, "utf8").split("\n")[0].trim()
|
|
241
|
+
const port = Number(line)
|
|
242
|
+
if (port > 0) return port
|
|
243
|
+
} catch {}
|
|
244
|
+
return 0
|
|
245
|
+
}
|
|
128
246
|
await new Promise((r) => setTimeout(r, 250))
|
|
129
247
|
}
|
|
130
248
|
return 0
|
|
@@ -132,15 +250,59 @@ async function waitPort(dir, timeoutMs = 8_000) {
|
|
|
132
250
|
|
|
133
251
|
/**
|
|
134
252
|
* 拉起浏览器并打开登录页。
|
|
135
|
-
*
|
|
253
|
+
*
|
|
254
|
+
* ⚠ **逐个候选试,不是找到第一个存在的就认定它能用。**
|
|
255
|
+
*
|
|
256
|
+
* 候选表里装了 ≠ 能起 CDP:可能是别的内核、可能被策略禁用调试端口、
|
|
257
|
+
* 可能启动就崩。老实现只挑「第一个 existsSync 为 true 的」,
|
|
258
|
+
* 一旦它起不来就是彻底失败(用户看到的还是「一直等」)。
|
|
259
|
+
*
|
|
260
|
+
* 现在:存在的一个个试,**第一个真写出 DevToolsActivePort 的**才算数。
|
|
261
|
+
* 全都不行才抛错 —— 抛的错里带上每个候选失败的原因,排障不用猜。
|
|
262
|
+
*
|
|
263
|
+
* @param {string} url
|
|
264
|
+
* @param {{candidates?: {name:string,path:string}[]}} [opts] 测试注入用
|
|
265
|
+
* @returns {Promise<{port:number, dir:string, child:import("node:child_process").ChildProcess, name:string, exe:string}>}
|
|
136
266
|
*/
|
|
137
|
-
export async function launchLoginBrowser(url) {
|
|
138
|
-
const
|
|
139
|
-
|
|
267
|
+
export async function launchLoginBrowser(url, { candidates } = {}) {
|
|
268
|
+
const list = (candidates ?? browserCandidates()).filter((c) => {
|
|
269
|
+
try {
|
|
270
|
+
return c?.path && existsSync(c.path)
|
|
271
|
+
} catch {
|
|
272
|
+
return false
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
if (!list.length) {
|
|
276
|
+
throw new Error("本机没找到 Chrome / Edge / 360 / QQ 等 Chromium 内核浏览器")
|
|
277
|
+
}
|
|
140
278
|
|
|
141
|
-
// 先扫掉上一次登录留下的临时 profile
|
|
279
|
+
// 先扫掉上一次登录留下的临时 profile(含历史复用目录)—— 只做一次
|
|
142
280
|
sweepProfiles()
|
|
281
|
+
|
|
282
|
+
const failures = []
|
|
283
|
+
for (const cand of list) {
|
|
284
|
+
let session = null
|
|
285
|
+
try {
|
|
286
|
+
session = await launchOne(cand, url)
|
|
287
|
+
return session
|
|
288
|
+
} catch (e) {
|
|
289
|
+
failures.push(`${cand.name}:${String(e?.message ?? e).slice(0, 160)}`)
|
|
290
|
+
// launchOne 自己已经收拾过失败的进程和目录,这里只管继续试下一个
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
throw new Error(
|
|
295
|
+
`试过 ${list.length} 个浏览器都没能打开调试通道:\n` + failures.map((f) => ` · ${f}`).join("\n"),
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 用**指定**的一个浏览器起一次登录窗口。
|
|
301
|
+
* 失败时保证:进程已杀、目录已删(不会给后面留垃圾)。
|
|
302
|
+
*/
|
|
303
|
+
async function launchOne(cand, url) {
|
|
143
304
|
const dir = newProfileDir()
|
|
305
|
+
|
|
144
306
|
// ⚠ 残留会让这次「看起来起来了、其实什么都没做」—— 一次性目录里通常没有,
|
|
145
307
|
// 但 sweep 失败、或目录被复用时它们就是根因,清一遍几乎不花钱:
|
|
146
308
|
// DevToolsActivePort —— 不清掉,waitPort 会立刻返回一个已失效的旧端口
|
|
@@ -157,7 +319,7 @@ export async function launchLoginBrowser(url) {
|
|
|
157
319
|
let child
|
|
158
320
|
try {
|
|
159
321
|
child = spawn(
|
|
160
|
-
|
|
322
|
+
cand.path,
|
|
161
323
|
[
|
|
162
324
|
`--user-data-dir=${dir}`,
|
|
163
325
|
"--remote-debugging-port=0",
|
|
@@ -167,23 +329,43 @@ export async function launchLoginBrowser(url) {
|
|
|
167
329
|
"--disable-background-networking",
|
|
168
330
|
url,
|
|
169
331
|
],
|
|
332
|
+
// 某些国产浏览器不接受部分参数会直接退出,stdio 必须 ignore,
|
|
333
|
+
// 否则它往 stdout 写的吐槽会污染 magpie 的日志
|
|
170
334
|
{ stdio: "ignore", detached: false, windowsHide: false },
|
|
171
335
|
)
|
|
172
336
|
} catch (e) {
|
|
173
|
-
|
|
337
|
+
await cleanupFailed({ dir })
|
|
338
|
+
throw new Error(`进程起不来:${e?.message ?? e}`)
|
|
174
339
|
}
|
|
175
340
|
|
|
176
|
-
|
|
341
|
+
// 进程若直接退出(参数不认 / 已被别的实例接管),waitPort 会白等 8 秒。
|
|
342
|
+
// 监听 exit 提前退出等待,把真实原因带回去。
|
|
343
|
+
let exited = null
|
|
344
|
+
child?.on?.("exit", (code) => {
|
|
345
|
+
exited = code
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
const port = await waitPort(dir, 8_000, () => exited !== null)
|
|
177
349
|
if (!port) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
throw new Error("浏览器进程起来了,但 8 秒内没写出调试端口 DevToolsActivePort(多半是 profile 被一个已退出的实例占着)")
|
|
350
|
+
await cleanupFailed({ dir, child })
|
|
351
|
+
throw new Error(
|
|
352
|
+
exited !== null
|
|
353
|
+
? `进程启动后退出了(code ${exited}),没写出调试端口`
|
|
354
|
+
: "8 秒内没写出调试端口 DevToolsActivePort",
|
|
355
|
+
)
|
|
185
356
|
}
|
|
186
|
-
return { port, dir, child }
|
|
357
|
+
return { port, dir, child, name: cand.name, exe: cand.path }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 失败收尾:杀进程 + 删目录,都不抛错 */
|
|
361
|
+
async function cleanupFailed({ dir, child }) {
|
|
362
|
+
try {
|
|
363
|
+
child?.kill()
|
|
364
|
+
} catch {}
|
|
365
|
+
if (!dir) return
|
|
366
|
+
try {
|
|
367
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 2, retryDelay: 150 })
|
|
368
|
+
} catch {}
|
|
187
369
|
}
|
|
188
370
|
|
|
189
371
|
// ── 最小 CDP 客户端 ───────────────────────────────────────────────────
|
package/src/clipboard.mjs
CHANGED
|
@@ -1,108 +1,108 @@
|
|
|
1
|
-
// 剪贴板 —— 浏览器登录的降级通道
|
|
2
|
-
//
|
|
3
|
-
// 场景:本机没有 Chrome / Edge,或者自己拉起来的那个没读出凭据。
|
|
4
|
-
// 这时让用户在官方登录页的 Console 里粘一行,把 jwt + org 送进剪贴板,
|
|
5
|
-
// 插件读回来 —— 不用他去 DevTools 里翻两个字段。
|
|
6
|
-
//
|
|
7
|
-
// ⚠ authorize() 里不能 await 读剪贴板(起 powershell 要几百毫秒,它必须立刻返回);
|
|
8
|
-
// 把 promise 递到 callback 才落地。
|
|
9
|
-
|
|
10
|
-
import { execFile as execFileCb } from "node:child_process"
|
|
11
|
-
import { promisify } from "node:util"
|
|
12
|
-
|
|
13
|
-
const execFile = promisify(execFileCb)
|
|
14
|
-
|
|
15
|
-
// ═══ 浏览器登录:为什么只能「开页面 + 接剪贴板」═══════════════════════
|
|
16
|
-
//
|
|
17
|
-
// 想要的那种「点一下 → 跳官网 → 用 Google / 邮箱 / 手机登 → 插件自己拿到凭证」
|
|
18
|
-
// magicrew **没有这条路**,不是没找到实现方法,是接口不存在:
|
|
19
|
-
//
|
|
20
|
-
// ① 它的登录类型只有 13 种,全是「拿凭据换 token」的:
|
|
21
|
-
// phone_password / phone_captcha / email / DingTalk / DingTalkAvoid /
|
|
22
|
-
// wecom / Lark / LarkRedirect / wechat_official_account / wechat_app /
|
|
23
|
-
// dingtalk_app / redirect / apple_login / google_login / feishu_oauth
|
|
24
|
-
// (dtyq/magic `frontend/magic-web/src/types/login.ts` 的 LoginType 枚举,
|
|
25
|
-
// 注释写明「登录方式(与API强绑定,禁止修改)」)
|
|
26
|
-
// —— 没有设备码、没有插件授权、没有一次性 ticket。
|
|
27
|
-
//
|
|
28
|
-
// ② WorkBuddy 那种做法在它这里是空的:它没有
|
|
29
|
-
// POST /v2/plugin/auth/state (开登录会话)
|
|
30
|
-
// GET /v2/plugin/auth/token (轮询拿 token)
|
|
31
|
-
// 这套 `/plugin/auth/*` 协议。
|
|
32
|
-
//
|
|
33
|
-
// ③ 也试过 `/api/v1/auth/status`、`/api/v1/auth/environment`、
|
|
34
|
-
// `/api/v1/aggregation/login-data` —— status 要 authorization(3103),
|
|
35
|
-
// environment 回 `{"login_code":null}` 且没有配套的兑现接口。
|
|
36
|
-
//
|
|
37
|
-
// ④ 网页登录成功后 token 只在 www.magicrew.ai 的 localStorage 里,
|
|
38
|
-
// 插件读不到别的源的存储;`redirect` 字段也不能收任意回调地址。
|
|
39
|
-
//
|
|
40
|
-
// ⇒ 所以能做的最短路径是:**用系统浏览器打开官方登录页(官方登录方式一样不少),
|
|
41
|
-
// 登录完粘一行 Console 命令把两个值送进剪贴板,插件自己读回来。**
|
|
42
|
-
// 用户不用在 magpie 里输手机号密码,也不用去 DevTools 里翻两个字段。
|
|
43
|
-
//
|
|
44
|
-
// 剪贴板读法(三平台):
|
|
45
|
-
// win32 powershell Get-Clipboard -Raw
|
|
46
|
-
// darwin pbpaste
|
|
47
|
-
// linux xclip -selection clipboard -o
|
|
48
|
-
const CLIP_HINT = [
|
|
49
|
-
"浏览器已打开官方登录页 —— 用你平时的方式登进去(Google / 邮箱 / 手机号都行)。",
|
|
50
|
-
"登录完成后回到那个页面按 F12 → Console,粘这一行回车:",
|
|
51
|
-
`copy(JSON.stringify({jwt:(()=>{try{return JSON.parse(localStorage["MAGIC:user:authorization"]).value}catch{return localStorage["MAGIC:user:authorization"]}})(),org:(()=>{try{return JSON.parse(localStorage["MAGIC:user:organizationCode"]).value}catch{return localStorage["MAGIC:user:organizationCode"]}})()}))`,
|
|
52
|
-
"插件会自己从剪贴板读,读到就完成 —— 不用回来点任何按钮。",
|
|
53
|
-
].join("\n")
|
|
54
|
-
|
|
55
|
-
async function clipboardText() {
|
|
56
|
-
try {
|
|
57
|
-
const { stdout } = await execFile(CLIP_CMD[process.platform]?.[0] ?? "true", CLIP_CMD[process.platform]?.slice(1) ?? [], {
|
|
58
|
-
timeout: 6000,
|
|
59
|
-
windowsHide: true,
|
|
60
|
-
maxBuffer: 1 << 20,
|
|
61
|
-
})
|
|
62
|
-
return String(stdout ?? "")
|
|
63
|
-
} catch {
|
|
64
|
-
return ""
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const CLIP_CMD = {
|
|
69
|
-
win32: ["powershell", "-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"],
|
|
70
|
-
darwin: ["pbpaste"],
|
|
71
|
-
linux: ["xclip", "-selection", "clipboard", "-o"],
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** 剪贴板里的内容 → { jwt, org },认不出返回 null */
|
|
75
|
-
function parseCreds(txt) {
|
|
76
|
-
const s = String(txt || "").trim()
|
|
77
|
-
if (!s) return null
|
|
78
|
-
// ① Console 那行给的 JSON
|
|
79
|
-
try {
|
|
80
|
-
const o = JSON.parse(s)
|
|
81
|
-
if (typeof o?.jwt === "string" && o.jwt.startsWith("eyJ")) {
|
|
82
|
-
return { jwt: o.jwt, org: String(o?.org || "").trim() }
|
|
83
|
-
}
|
|
84
|
-
} catch {}
|
|
85
|
-
// ② 只粘了 JWT(组织码空着,后面会报错让人补)
|
|
86
|
-
if (s.startsWith("eyJ") && s.split(".").length === 3) return { jwt: s, org: "" }
|
|
87
|
-
return null
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* 盯着剪贴板,直到出现能认的凭证。
|
|
92
|
-
* @param skip 打开登录页之前剪贴板里就有的内容 —— 不能拿它当结果
|
|
93
|
-
*/
|
|
94
|
-
async function pollClipboard({ skip = "", timeout = 300_000, interval = 1500 } = {}) {
|
|
95
|
-
const deadline = Date.now() + timeout
|
|
96
|
-
while (Date.now() < deadline) {
|
|
97
|
-
const txt = (await clipboardText()).trim()
|
|
98
|
-
if (txt && txt !== skip) {
|
|
99
|
-
const got = parseCreds(txt)
|
|
100
|
-
if (got) return got
|
|
101
|
-
}
|
|
102
|
-
await new Promise((r) => setTimeout(r, interval))
|
|
103
|
-
}
|
|
104
|
-
return null
|
|
105
|
-
}
|
|
106
|
-
|
|
1
|
+
// 剪贴板 —— 浏览器登录的降级通道
|
|
2
|
+
//
|
|
3
|
+
// 场景:本机没有 Chrome / Edge,或者自己拉起来的那个没读出凭据。
|
|
4
|
+
// 这时让用户在官方登录页的 Console 里粘一行,把 jwt + org 送进剪贴板,
|
|
5
|
+
// 插件读回来 —— 不用他去 DevTools 里翻两个字段。
|
|
6
|
+
//
|
|
7
|
+
// ⚠ authorize() 里不能 await 读剪贴板(起 powershell 要几百毫秒,它必须立刻返回);
|
|
8
|
+
// 把 promise 递到 callback 才落地。
|
|
9
|
+
|
|
10
|
+
import { execFile as execFileCb } from "node:child_process"
|
|
11
|
+
import { promisify } from "node:util"
|
|
12
|
+
|
|
13
|
+
const execFile = promisify(execFileCb)
|
|
14
|
+
|
|
15
|
+
// ═══ 浏览器登录:为什么只能「开页面 + 接剪贴板」═══════════════════════
|
|
16
|
+
//
|
|
17
|
+
// 想要的那种「点一下 → 跳官网 → 用 Google / 邮箱 / 手机登 → 插件自己拿到凭证」
|
|
18
|
+
// magicrew **没有这条路**,不是没找到实现方法,是接口不存在:
|
|
19
|
+
//
|
|
20
|
+
// ① 它的登录类型只有 13 种,全是「拿凭据换 token」的:
|
|
21
|
+
// phone_password / phone_captcha / email / DingTalk / DingTalkAvoid /
|
|
22
|
+
// wecom / Lark / LarkRedirect / wechat_official_account / wechat_app /
|
|
23
|
+
// dingtalk_app / redirect / apple_login / google_login / feishu_oauth
|
|
24
|
+
// (dtyq/magic `frontend/magic-web/src/types/login.ts` 的 LoginType 枚举,
|
|
25
|
+
// 注释写明「登录方式(与API强绑定,禁止修改)」)
|
|
26
|
+
// —— 没有设备码、没有插件授权、没有一次性 ticket。
|
|
27
|
+
//
|
|
28
|
+
// ② WorkBuddy 那种做法在它这里是空的:它没有
|
|
29
|
+
// POST /v2/plugin/auth/state (开登录会话)
|
|
30
|
+
// GET /v2/plugin/auth/token (轮询拿 token)
|
|
31
|
+
// 这套 `/plugin/auth/*` 协议。
|
|
32
|
+
//
|
|
33
|
+
// ③ 也试过 `/api/v1/auth/status`、`/api/v1/auth/environment`、
|
|
34
|
+
// `/api/v1/aggregation/login-data` —— status 要 authorization(3103),
|
|
35
|
+
// environment 回 `{"login_code":null}` 且没有配套的兑现接口。
|
|
36
|
+
//
|
|
37
|
+
// ④ 网页登录成功后 token 只在 www.magicrew.ai 的 localStorage 里,
|
|
38
|
+
// 插件读不到别的源的存储;`redirect` 字段也不能收任意回调地址。
|
|
39
|
+
//
|
|
40
|
+
// ⇒ 所以能做的最短路径是:**用系统浏览器打开官方登录页(官方登录方式一样不少),
|
|
41
|
+
// 登录完粘一行 Console 命令把两个值送进剪贴板,插件自己读回来。**
|
|
42
|
+
// 用户不用在 magpie 里输手机号密码,也不用去 DevTools 里翻两个字段。
|
|
43
|
+
//
|
|
44
|
+
// 剪贴板读法(三平台):
|
|
45
|
+
// win32 powershell Get-Clipboard -Raw
|
|
46
|
+
// darwin pbpaste
|
|
47
|
+
// linux xclip -selection clipboard -o
|
|
48
|
+
const CLIP_HINT = [
|
|
49
|
+
"浏览器已打开官方登录页 —— 用你平时的方式登进去(Google / 邮箱 / 手机号都行)。",
|
|
50
|
+
"登录完成后回到那个页面按 F12 → Console,粘这一行回车:",
|
|
51
|
+
`copy(JSON.stringify({jwt:(()=>{try{return JSON.parse(localStorage["MAGIC:user:authorization"]).value}catch{return localStorage["MAGIC:user:authorization"]}})(),org:(()=>{try{return JSON.parse(localStorage["MAGIC:user:organizationCode"]).value}catch{return localStorage["MAGIC:user:organizationCode"]}})()}))`,
|
|
52
|
+
"插件会自己从剪贴板读,读到就完成 —— 不用回来点任何按钮。",
|
|
53
|
+
].join("\n")
|
|
54
|
+
|
|
55
|
+
async function clipboardText() {
|
|
56
|
+
try {
|
|
57
|
+
const { stdout } = await execFile(CLIP_CMD[process.platform]?.[0] ?? "true", CLIP_CMD[process.platform]?.slice(1) ?? [], {
|
|
58
|
+
timeout: 6000,
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
maxBuffer: 1 << 20,
|
|
61
|
+
})
|
|
62
|
+
return String(stdout ?? "")
|
|
63
|
+
} catch {
|
|
64
|
+
return ""
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const CLIP_CMD = {
|
|
69
|
+
win32: ["powershell", "-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"],
|
|
70
|
+
darwin: ["pbpaste"],
|
|
71
|
+
linux: ["xclip", "-selection", "clipboard", "-o"],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 剪贴板里的内容 → { jwt, org },认不出返回 null */
|
|
75
|
+
function parseCreds(txt) {
|
|
76
|
+
const s = String(txt || "").trim()
|
|
77
|
+
if (!s) return null
|
|
78
|
+
// ① Console 那行给的 JSON
|
|
79
|
+
try {
|
|
80
|
+
const o = JSON.parse(s)
|
|
81
|
+
if (typeof o?.jwt === "string" && o.jwt.startsWith("eyJ")) {
|
|
82
|
+
return { jwt: o.jwt, org: String(o?.org || "").trim() }
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
// ② 只粘了 JWT(组织码空着,后面会报错让人补)
|
|
86
|
+
if (s.startsWith("eyJ") && s.split(".").length === 3) return { jwt: s, org: "" }
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 盯着剪贴板,直到出现能认的凭证。
|
|
92
|
+
* @param skip 打开登录页之前剪贴板里就有的内容 —— 不能拿它当结果
|
|
93
|
+
*/
|
|
94
|
+
async function pollClipboard({ skip = "", timeout = 300_000, interval = 1500 } = {}) {
|
|
95
|
+
const deadline = Date.now() + timeout
|
|
96
|
+
while (Date.now() < deadline) {
|
|
97
|
+
const txt = (await clipboardText()).trim()
|
|
98
|
+
if (txt && txt !== skip) {
|
|
99
|
+
const got = parseCreds(txt)
|
|
100
|
+
if (got) return got
|
|
101
|
+
}
|
|
102
|
+
await new Promise((r) => setTimeout(r, interval))
|
|
103
|
+
}
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
107
|
|
|
108
108
|
export { CLIP_HINT, clipboardText, parseCreds, pollClipboard }
|