sintbot 1.0.16 → 1.3.0
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.en.json +99 -0
- package/CHANGELOG.json +99 -0
- package/README.md +20 -158
- package/README.zh-CN.md +20 -166
- package/THIRD-PARTY-NOTICES.md +394 -0
- package/bin/sintbot-v2.js +363 -0
- package/docs/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/install.md +61 -167
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/zh-CN/install.md +61 -165
- package/install.sh +84 -0
- package/package.json +73 -37
- package/src/agent/claudecode.js +149 -108
- package/src/agent/codexapp.js +458 -0
- package/src/agent/codexcode.js +210 -107
- package/src/agent/errtag.js +34 -0
- package/src/agent/privatecore.js +790 -0
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +10 -8
- package/src/agent/winexec.js +35 -5
- package/src/c2install.js +117 -0
- package/src/config.js +2 -1
- package/src/core/c2cardeffectbridge.js +51 -0
- package/src/core/c2cardterminalpath.js +216 -0
- package/src/core/c2durableingress.js +148 -0
- package/src/core/c2durablejournal.js +1560 -0
- package/src/core/c2durableruntime.js +2361 -0
- package/src/core/c2eventidentity.js +90 -0
- package/src/core/c2globalfault.js +401 -0
- package/src/core/c2localeffectexecutor.js +193 -0
- package/src/core/c2payloadhandles.js +333 -0
- package/src/core/c2persistrelation.js +179 -0
- package/src/core/c2persiststate.js +342 -0
- package/src/core/c2platformrecoverysender.js +76 -0
- package/src/core/c2recoverygate.js +385 -0
- package/src/core/c2runnerinput.js +63 -0
- package/src/core/c2servicestartgate.js +103 -0
- package/src/core/c2sessionbridge.js +72 -0
- package/src/core/c2sourcequarantine.js +30 -0
- package/src/core/c2startuprecovery.js +105 -0
- package/src/core/c2successorbridge.js +151 -0
- package/src/core/c2terminaleffectbridge.js +154 -0
- package/src/core/c2terminalprune.js +108 -0
- package/src/core/c2turnprojection.js +177 -0
- package/src/core/enginecheckpoint.js +211 -0
- package/src/core/engineeffectcursor.js +394 -0
- package/src/core/enginestepcoordinator.js +183 -0
- package/src/core/store.js +52 -8
- package/src/core/v2engine.js +1469 -0
- package/src/platform/feishu.js +324 -67
- package/src/plugins.js +1 -9
- package/assets/design-styles/acid-pop.md +0 -155
- package/assets/design-styles/archive-index.md +0 -161
- package/assets/design-styles/art-deco.md +0 -164
- package/assets/design-styles/cinematic-noir.md +0 -178
- package/assets/design-styles/editorial.md +0 -121
- package/assets/design-styles/extra/README.md +0 -23
- package/assets/design-styles/extra/constructivism.md +0 -146
- package/assets/design-styles/extra/neo-brutalism.md +0 -175
- package/assets/design-styles/extra/new-wave.md +0 -166
- package/assets/design-styles/extra/nordic-material.md +0 -162
- package/assets/design-styles/extra/paris-couture.md +0 -162
- package/assets/design-styles/extra/tropical-modern.md +0 -168
- package/assets/design-styles/extra/ulm-functional.md +0 -182
- package/assets/design-styles/extra/wa-shikimen.md +0 -160
- package/assets/design-styles/extra/y2k-chrome.md +0 -172
- package/assets/design-styles/geometric-pop.md +0 -122
- package/assets/design-styles/hand-drawn.md +0 -117
- package/assets/design-styles/islamic-geometry.md +0 -155
- package/assets/design-styles/liquid-glass.md +0 -183
- package/assets/design-styles/neo-chinese.md +0 -136
- package/assets/design-styles/precision-mono.md +0 -156
- package/assets/design-styles/quiet-luxury.md +0 -163
- package/assets/design-styles/riso-print.md +0 -116
- package/assets/design-styles/soft-friendly.md +0 -174
- package/assets/design-styles/swiss-grid.md +0 -156
- package/assets/design-styles/tech-dark.md +0 -182
- package/assets/favicon.svg +0 -6
- package/assets/logo.svg +0 -24
- package/bin/sbot.js +0 -4716
- package/docs/slack-app-manifest.yaml +0 -104
- package/sbot.config.example.json +0 -235
- package/src/agent/askonce.js +0 -76
- package/src/agent/claudemodels.js +0 -60
- package/src/agent/claudestatus.js +0 -74
- package/src/agent/claudeusage.js +0 -200
- package/src/agent/codexstatus.js +0 -181
- package/src/agent/imagen.js +0 -458
- package/src/agent/quotaerr.js +0 -44
- package/src/agent/transienterr.js +0 -32
- package/src/cdp.js +0 -254
- package/src/cli-secret.js +0 -36
- package/src/clilang.js +0 -31
- package/src/core/cron.js +0 -182
- package/src/core/cronfire.js +0 -120
- package/src/core/cronrun.js +0 -116
- package/src/core/engine.js +0 -2758
- package/src/core/interfaces.js +0 -114
- package/src/core/issues.js +0 -185
- package/src/core/ledger.js +0 -30
- package/src/core/opsalert.js +0 -214
- package/src/core/pending.js +0 -123
- package/src/core/proxy.js +0 -424
- package/src/core/quotaalert.js +0 -79
- package/src/core/quotaerr.js +0 -55
- package/src/core/scheduler.js +0 -59
- package/src/design.js +0 -5161
- package/src/gitrepo.js +0 -150
- package/src/health.js +0 -353
- package/src/installdeps.js +0 -72
- package/src/leftover.js +0 -174
- package/src/platform/boardnotice.js +0 -77
- package/src/platform/boardops.js +0 -36
- package/src/platform/boardwatch.js +0 -447
- package/src/platform/cli.js +0 -45
- package/src/platform/slack.js +0 -1289
- package/src/platform/takeovercard.js +0 -37
- package/src/platform/wecom.js +0 -938
- package/src/platform/wecomcli.js +0 -128
- package/src/platform/wecomgate.js +0 -108
- package/src/platform/wecomname.js +0 -43
- package/src/platform/wecompoll.js +0 -241
- package/src/preview.js +0 -581
- package/src/projectmemory.js +0 -175
- package/src/service.js +0 -317
- package/src/ui/auth.js +0 -31
- package/src/ui/browser.html +0 -392
- package/src/ui/browserroutes.js +0 -234
- package/src/ui/console.html +0 -5526
- package/src/ui/dashboard.js +0 -168
- package/src/ui/feishulogin.js +0 -136
- package/src/ui/server.js +0 -2314
- package/src/ui/sessions.js +0 -78
- package/src/ui/takeover.js +0 -108
- package/src/updater.js +0 -243
- package/src/upgrade.js +0 -35
- package/src/usage.js +0 -144
package/src/ui/browserroutes.js
DELETED
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
// 控制台「接管浏览器」的接口面(/api/browser/*)—— 与 server.js 的路由分开放,server 只做鉴权裁决与页面分发。
|
|
3
|
-
//
|
|
4
|
-
// 一屏:
|
|
5
|
-
// GET /api/browser/targets bot 浏览器现有标签页(+ 浏览器活着没有);持票绑定了标签页的人只看到那一页
|
|
6
|
-
// POST /api/browser/targets {url} 新开一个标签页(管理员 / 本机;持票的人不许 —— 票不是「随便开网页」的许可)
|
|
7
|
-
// POST /api/browser/targets/activate 把某个标签页切到前台(机器旁边的人看得到)
|
|
8
|
-
// POST /api/browser/link {minutes,tab} 签一条一次性接管链接(管理员或本机环回;可用 tab 把票绑在某个标签页上)
|
|
9
|
-
// POST /api/browser/link/attach {id,card} 给票挂上群里那张卡(cardId / chatId / 标题):兑换 / 过期时 daemon 翻卡
|
|
10
|
-
// POST /api/browser/claim {k} 用票换 cookie(页面上点「开始接管」才调;票走 URL 片段 ⇒ 不进任何服务端日志)
|
|
11
|
-
// GET /api/browser/stream?id=&w=&q= 截屏推流(SSE 形状,用 fetch 流式读 —— 令牌走请求头,不进 URL)
|
|
12
|
-
// POST /api/browser/input {id,events} 鼠标 / 键盘事件按序下发
|
|
13
|
-
// POST /api/browser/end 接管者主动结束(会话作废、cookie 清掉)
|
|
14
|
-
//
|
|
15
|
-
// 与浏览器之间:每个标签页**一条** CDP 会话 + **一路**截屏推流,看的人是订阅者(fan-out):首个订阅者开流、最后一个走了才停流,
|
|
16
|
-
// 一帧要等所有订阅者都写完才 ack(背压按最慢的那个人算)—— 否则两人同看,一人离开另一人画面就冻住(对抗审查 🟡2)。
|
|
17
|
-
// 没人看也没人输入 30 秒后关会话。浏览器没起来 / 标签页关了 ⇒ 接口回 ok:false + 一句人话,页面照常显示,不炸。
|
|
18
|
-
// 审计:签票、用票、每次推流结束(帧数 / 秒数 / 输入次数)各一条进 ~/.sbot/audit.jsonl;帧内容与输入内容不落盘。
|
|
19
|
-
|
|
20
|
-
const IDLE_CLOSE_MS = 30_000 // 没人看也没人输入 30 秒才关 CDP 会话:切标签页 / 断线重连都在这窗口内,免得反复建连
|
|
21
|
-
const MAX_EVENTS = 200 // 一次输入请求最多 200 个事件:接管页 33ms 合并一次移动,正常一批只有几条;更多的是失控重放
|
|
22
|
-
|
|
23
|
-
function createBrowserRoutes({ readRaw, audit, log = () => {}, cdp = require('../cdp'), takeover, sendJSON, body, publicBase = () => '', lanBase = () => '', localBase = () => '', secureFor = () => false } = {}) {
|
|
24
|
-
const registry = new Map() // targetId -> { session, target, refs, idle, cast: null | { subs:Set, stop, params } }
|
|
25
|
-
const pendingAcquire = new Map() // targetId -> Promise(entry):两个人同时点同一标签页,只建一条会话(对抗审查 🟡2)
|
|
26
|
-
const inputCount = new Map() // `${who}|${sid}|${targetId}` -> n(推流结束时随审计带走)
|
|
27
|
-
const sidStreams = new Map() // 接管会话 sid -> Set<结束这路推流的函数>:会话作废 / 到期时把它的推流一起断掉
|
|
28
|
-
|
|
29
|
-
const cfgBrowser = () => { try { return (readRaw().browser || {}) } catch { return {} } }
|
|
30
|
-
const cdpUrl = () => String(cfgBrowser().cdpUrl || cdp.DEFAULT_CDP_URL).replace(/\/+$/, '')
|
|
31
|
-
const ttlMs = () => { const m = Number(cfgBrowser().takeoverMinutes); return m > 0 ? m * 60_000 : takeover.DEFAULT_TTL_MS }
|
|
32
|
-
// 到页面 / 群里的错误一律说人话:连不上是最常见的一种;其余原样给一句短的,但不带协议名与端点路径(cdp.js 已按此措辞)
|
|
33
|
-
const human = (e) => { const m = String((e && e.message) || e || ''); if (/ECONNREFUSED|超时|ETIMEDOUT/.test(m)) return '浏览器没连上(它可能没启动)'; return m ? `浏览器出错了:${m.slice(0, 120)}` : '浏览器出错了' }
|
|
34
|
-
|
|
35
|
-
async function acquire(id) {
|
|
36
|
-
const e = registry.get(id)
|
|
37
|
-
if (e && e.session.connected) { e.refs++; if (e.idle) { clearTimeout(e.idle); e.idle = null }; return e }
|
|
38
|
-
if (pendingAcquire.has(id)) { const entry = await pendingAcquire.get(id); entry.refs++; return entry }
|
|
39
|
-
const p = (async () => {
|
|
40
|
-
const targets = await cdp.listTargets(cdpUrl())
|
|
41
|
-
const t = targets.find((x) => x.id === id)
|
|
42
|
-
if (!t) throw new Error('这个标签页已经不在了')
|
|
43
|
-
const session = new cdp.CdpSession(t.wsUrl)
|
|
44
|
-
await session.connect()
|
|
45
|
-
const entry = { session, refs: 1, idle: null, target: t, cast: null }
|
|
46
|
-
registry.set(id, entry)
|
|
47
|
-
session.onClose(() => { if (registry.get(id) === entry) registry.delete(id); if (entry.cast) for (const s of entry.cast.subs) { try { s.end() } catch {} } })
|
|
48
|
-
return entry
|
|
49
|
-
})()
|
|
50
|
-
pendingAcquire.set(id, p)
|
|
51
|
-
try { return await p } finally { pendingAcquire.delete(id) }
|
|
52
|
-
}
|
|
53
|
-
function release(id) {
|
|
54
|
-
const e = registry.get(id); if (!e) return
|
|
55
|
-
e.refs = Math.max(0, e.refs - 1)
|
|
56
|
-
if (e.refs === 0 && !e.idle) e.idle = setTimeout(() => { registry.delete(id); e.session.close() }, IDLE_CLOSE_MS).unref()
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// 订阅推流:首个订阅者开流(参数取他的),后来的先补一张按自己尺寸缩的首帧再一起收;最后一个走了才停流。
|
|
60
|
-
async function subscribe(entry, sub, params) {
|
|
61
|
-
if (!entry.cast) {
|
|
62
|
-
const cast = { subs: new Set([sub]), params, stop: async () => {} }
|
|
63
|
-
entry.cast = cast
|
|
64
|
-
// 一帧要等**所有**订阅者写完才 ack(startScreencast 在 onFrame 之后才 ack)—— 慢的那个人决定帧率,谁都不会被丢下
|
|
65
|
-
cast.stop = await cdp.startScreencast(entry.session, params, async (f) => { await Promise.all([...cast.subs].map((s) => s.write(f))) })
|
|
66
|
-
return
|
|
67
|
-
}
|
|
68
|
-
entry.cast.subs.add(sub)
|
|
69
|
-
try { await cdp.seedFrame(entry.session, params, (f) => sub.write(f)) } catch { /* 补不上就等真帧 */ }
|
|
70
|
-
}
|
|
71
|
-
async function unsubscribe(entry, sub) {
|
|
72
|
-
const cast = entry.cast; if (!cast) return
|
|
73
|
-
cast.subs.delete(sub)
|
|
74
|
-
if (cast.subs.size === 0) { entry.cast = null; try { await cast.stop() } catch {} }
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ctx:{ p, authedAdmin, takeoverSid, takeoverTarget, takeoverExp, localMint, ip, xff }。返回 true = 已处理
|
|
78
|
-
async function handle(req, res, url, ctx) {
|
|
79
|
-
const { p } = ctx
|
|
80
|
-
// sendJSON 不返回值,这里统一回 true 表示「已处理」—— 否则 server 会继续往下匹配,最后撞出 headers already sent
|
|
81
|
-
const json = (code, obj) => { sendJSON(res, code, obj); return true }
|
|
82
|
-
const who = ctx.authedAdmin ? 'admin' : (ctx.takeoverSid ? 'takeover' : (ctx.localMint ? 'local' : ''))
|
|
83
|
-
const isTakeover = who === 'takeover'
|
|
84
|
-
const bound = isTakeover ? String(ctx.takeoverTarget || '') : ''
|
|
85
|
-
// 持票绑定了标签页的人,只能碰那一页(对抗审查 🟡8:票的权限面不该比「登这一个站」大)
|
|
86
|
-
const denyOther = (id) => bound && String(id) !== bound
|
|
87
|
-
|
|
88
|
-
if (p === '/api/browser/targets' && req.method === 'GET') {
|
|
89
|
-
try {
|
|
90
|
-
const [v, targets] = await Promise.all([cdp.versionInfo(cdpUrl()), cdp.listTargets(cdpUrl())])
|
|
91
|
-
const list = (bound ? targets.filter((t) => t.id === bound) : targets).map((t) => ({ id: t.id, title: t.title, url: t.url }))
|
|
92
|
-
return json(200, { ok: true, browser: (v && v.Browser) || '', targets: list, mode: who, bound: bound || null })
|
|
93
|
-
} catch (e) { return json(200, { ok: false, error: human(e), targets: [], mode: who, bound: bound || null }) }
|
|
94
|
-
}
|
|
95
|
-
if (p === '/api/browser/targets' && req.method === 'POST') {
|
|
96
|
-
if (isTakeover) return json(403, { error: '接管链接只能操作机器人已经打开的页面,不能新开网址' })
|
|
97
|
-
const { url: u } = await body(req)
|
|
98
|
-
const target = String(u || '').trim()
|
|
99
|
-
if (!/^(https?:\/\/|about:blank$)/i.test(target)) return json(400, { error: '要打开的地址得是 http(s):// 开头' })
|
|
100
|
-
try { const t = await cdp.newTarget(cdpUrl(), target); return json(200, { ok: true, id: t && t.id, url: t && t.url }) }
|
|
101
|
-
catch (e) { return json(502, { error: human(e) }) }
|
|
102
|
-
}
|
|
103
|
-
if (p === '/api/browser/targets/activate' && req.method === 'POST') {
|
|
104
|
-
const { id } = await body(req)
|
|
105
|
-
if (!id) return json(400, { error: '缺 id' })
|
|
106
|
-
if (denyOther(id)) return json(403, { error: '这条接管链接只对应一个页面' })
|
|
107
|
-
try { await cdp.activateTarget(cdpUrl(), String(id)); return json(200, { ok: true }) }
|
|
108
|
-
catch (e) { return json(502, { error: human(e) }) }
|
|
109
|
-
}
|
|
110
|
-
if (p === '/api/browser/link' && req.method === 'POST') {
|
|
111
|
-
// 谁能签:管理员(已登录)或本机环回(群 agent)。接管者自己(只有 cookie)到不了这里 —— server 的门只给 /api/browser/link 放行前两种
|
|
112
|
-
if (!ctx.authedAdmin && !ctx.localMint) return json(403, { error: '只有管理员或本机能生成接管链接' })
|
|
113
|
-
const { minutes, tab } = await body(req)
|
|
114
|
-
const ttl = Number(minutes) > 0 ? Number(minutes) * 60_000 : ttlMs()
|
|
115
|
-
// tab:网址 / 标题片段 ⇒ 把票绑在那个标签页上(agent 知道自己卡在哪个页面,不知道 id)。给了却没匹配上要说清,别静默签一张全开的票
|
|
116
|
-
let targetId = ''; let hit = null
|
|
117
|
-
const want = String(tab || '').trim().toLowerCase()
|
|
118
|
-
if (want) {
|
|
119
|
-
let targets; try { targets = await cdp.listTargets(cdpUrl()) } catch (e) { return json(502, { error: human(e) }) }
|
|
120
|
-
// id 比较也走小写:want 已经小写化,而 Chrome 的标签页 id 是大写十六进制 —— 严格相等等于按 id 永远绑不上(2026-09-15 实测只能用网址片段)
|
|
121
|
-
hit = targets.find((t) => String(t.id).toLowerCase() === want) || targets.find((t) => `${t.url} ${t.title}`.toLowerCase().includes(want))
|
|
122
|
-
if (!hit) return json(400, { error: `浏览器里没有匹配「${String(tab).slice(0, 60)}」的标签页` })
|
|
123
|
-
targetId = hit.id
|
|
124
|
-
}
|
|
125
|
-
const m = takeover.mint({ by: who, ttl, targetId })
|
|
126
|
-
// 链接的根:公网 > 局域网 > 只绑本机时退成 127.0.0.1(那条链接只有这台电脑自己打得开 —— 要说清,别让 agent 把死链接发进群)
|
|
127
|
-
const reach = publicBase() ? 'public' : (lanBase() ? 'lan' : 'local')
|
|
128
|
-
const base = publicBase() || lanBase() || localBase()
|
|
129
|
-
if (!base) return json(500, { error: '算不出控制台地址' })
|
|
130
|
-
audit.append({ kind: 'takeover-mint', by: who, ip: ctx.ip, exp: m.exp, target: targetId || null, reach, ts: Date.now() })
|
|
131
|
-
return json(200, { ok: true, id: m.id, url: takeover.takeoverUrl(base, m.token), exp: m.exp, minutes: Math.round(m.ttlMs / 60_000), public: reach === 'public', reach, target: targetId || null, pageTitle: hit ? hit.title : '', pageUrl: hit ? hit.url : '' })
|
|
132
|
-
}
|
|
133
|
-
if (p === '/api/browser/link/attach' && req.method === 'POST') {
|
|
134
|
-
if (!ctx.authedAdmin && !ctx.localMint) return json(403, { error: '只有管理员或本机能操作' })
|
|
135
|
-
const { id, card } = await body(req)
|
|
136
|
-
if (!id || !card || typeof card !== 'object') return json(400, { error: '缺 id 或 card' })
|
|
137
|
-
const ok = takeover.attach(String(id), { cardId: String(card.cardId || ''), chatId: String(card.chatId || ''), pageTitle: String(card.pageTitle || ''), pageUrl: String(card.pageUrl || ''), minutes: Number(card.minutes) || 10 })
|
|
138
|
-
return json(ok ? 200 : 404, ok ? { ok: true } : { error: '这张票不存在或已经用掉了' })
|
|
139
|
-
}
|
|
140
|
-
if (p === '/api/browser/claim' && req.method === 'POST') {
|
|
141
|
-
// 票换 cookie。只有页面上点了「开始接管」才会到这里 —— 链接预览 / 安全扫描那种裸 GET 抓不走票(对抗审查 🟡6)
|
|
142
|
-
const { k } = await body(req)
|
|
143
|
-
const got = takeover.consume(String(k || ''), { ip: ctx.ip })
|
|
144
|
-
audit.append({ kind: 'takeover-use', ok: !!got, ip: ctx.ip, xff: ctx.xff, ts: Date.now() })
|
|
145
|
-
if (!got) return json(410, { error: '这条接管链接已失效 —— 它只能用一次,而且只在发出后的几分钟内有效。请让机器人重新发一条。' })
|
|
146
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Set-Cookie': takeover.setCookieHeader(got.sid, { maxAgeSec: (got.exp - Date.now()) / 1000, secure: secureFor(req) }), 'Cache-Control': 'no-store' })
|
|
147
|
-
res.end(JSON.stringify({ ok: true, exp: got.exp, target: got.targetId || null })); return true
|
|
148
|
-
}
|
|
149
|
-
if (p === '/api/browser/stream' && req.method === 'GET') {
|
|
150
|
-
const id = String(url.searchParams.get('id') || '')
|
|
151
|
-
if (!id) return json(400, { error: '缺 id' })
|
|
152
|
-
if (denyOther(id)) return json(403, { error: '这条接管链接只对应一个页面' })
|
|
153
|
-
// 宽度 240~1600 / 质量 20~90:下限保证还看得清,上限挡住把画面调成 4K 原图那种能把隧道塞死的请求
|
|
154
|
-
const w = Math.min(1600, Math.max(240, Number(url.searchParams.get('w')) || 640))
|
|
155
|
-
const q = Math.min(90, Math.max(20, Number(url.searchParams.get('q')) || 45))
|
|
156
|
-
const t0 = Date.now(); let frames = 0; let closed = false; let started = false
|
|
157
|
-
const sub = {
|
|
158
|
-
// 写不动就等 drain 再回;socket 已销毁则立刻回(否则一帧 base64 挂在 promise 上永远不放)
|
|
159
|
-
write: (f) => new Promise((resolve) => {
|
|
160
|
-
if (res.destroyed || closed) return resolve()
|
|
161
|
-
frames++
|
|
162
|
-
const ok = res.write(`event: frame\ndata: ${JSON.stringify({ b64: f.data, w: f.meta.deviceWidth || 0, h: f.meta.deviceHeight || 0, seed: !!f.meta.seed })}\n\n`)
|
|
163
|
-
if (ok) return resolve()
|
|
164
|
-
const done = () => { res.off('drain', done); res.off('close', done); resolve() }
|
|
165
|
-
res.on('drain', done); res.on('close', done)
|
|
166
|
-
}),
|
|
167
|
-
end: () => { try { res.end() } catch {} },
|
|
168
|
-
}
|
|
169
|
-
// 接管者的推流跟着会话走:自己点「结束」/ 管理员「退出所有设备」/ 到期 ⇒ 推流当场断,页面重连撞 401 才翻成「已到期」。
|
|
170
|
-
// 不断的话会话作废了画面还在流 —— 输入早已 401,但一张 10 分钟的票能一直看下去(真机:服务端结束后页面仍停在接管态)
|
|
171
|
-
// ⚠ 要在 acquire 之前登记:CDP 忙时(agent 正在同一标签页截图)建会话能等好几秒,这窗口里收到「结束」却没人能断它 ⇒ 之后照流到票到期(真机撞过)
|
|
172
|
-
let expTimer = null
|
|
173
|
-
if (ctx.takeoverSid) {
|
|
174
|
-
if (!sidStreams.has(ctx.takeoverSid)) sidStreams.set(ctx.takeoverSid, new Set())
|
|
175
|
-
sidStreams.get(ctx.takeoverSid).add(sub.end)
|
|
176
|
-
if (ctx.takeoverExp > 0) expTimer = setTimeout(sub.end, Math.max(0, ctx.takeoverExp - Date.now())).unref()
|
|
177
|
-
}
|
|
178
|
-
const unlist = () => { if (expTimer) clearTimeout(expTimer); if (ctx.takeoverSid && sidStreams.has(ctx.takeoverSid)) { const s = sidStreams.get(ctx.takeoverSid); s.delete(sub.end); if (!s.size) sidStreams.delete(ctx.takeoverSid) } }
|
|
179
|
-
let entry
|
|
180
|
-
try { entry = await acquire(id) } catch (e) { unlist(); return res.writableEnded ? true : json(502, { error: human(e) }) }
|
|
181
|
-
// 建会话期间这一路已经被结束(会话作废)或客户端走了 ⇒ close 事件早发过、finish 还没挂 ⇒ 这里自己放掉引用,否则这条 CDP 会话永远不闲置关闭
|
|
182
|
-
if (res.writableEnded || res.destroyed) { unlist(); release(id); return true }
|
|
183
|
-
// ⚠ 拿到会话就先挂 close:开流那几百毫秒里客户端掐断,晚挂的话这一路永远不释放(对抗审查 🟡1)
|
|
184
|
-
const writeEv = (ev, obj) => { if (!res.destroyed && !closed) { try { res.write(`event: ${ev}\ndata: ${JSON.stringify(obj)}\n\n`) } catch {} } }
|
|
185
|
-
const offNav = entry.session.on('Page.frameNavigated', (pr) => { const f = pr && pr.frame; if (f && !f.parentId) writeEv('meta', { id, url: f.url || '' }) })
|
|
186
|
-
const finish = async () => {
|
|
187
|
-
if (closed) return; closed = true
|
|
188
|
-
offNav(); unlist()
|
|
189
|
-
if (started) { try { await unsubscribe(entry, sub) } catch {} }
|
|
190
|
-
release(id)
|
|
191
|
-
const key = `${who}|${ctx.takeoverSid || ''}|${id}`; const inputs = inputCount.get(key) || 0; inputCount.delete(key)
|
|
192
|
-
audit.append({ kind: 'takeover-stream', by: who, target: id, url: entry.target.url, frames, inputs, secs: Math.round((Date.now() - t0) / 1000), ip: ctx.ip, ts: Date.now() })
|
|
193
|
-
}
|
|
194
|
-
req.on('close', finish)
|
|
195
|
-
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
|
|
196
|
-
writeEv('meta', { id, url: entry.target.url, title: entry.target.title, mode: who })
|
|
197
|
-
try {
|
|
198
|
-
await subscribe(entry, sub, { maxWidth: w, quality: q })
|
|
199
|
-
started = true
|
|
200
|
-
} catch (e) { writeEv('error', { error: human(e) }); sub.end() }
|
|
201
|
-
// 开流期间客户端已经走了 ⇒ close 事件早就发过、那时 started 还是 false ⇒ 这里补一次退订
|
|
202
|
-
if (closed && started) { try { await unsubscribe(entry, sub) } catch {} }
|
|
203
|
-
return true
|
|
204
|
-
}
|
|
205
|
-
if (p === '/api/browser/input' && req.method === 'POST') {
|
|
206
|
-
const { id, events } = await body(req)
|
|
207
|
-
if (!id) return json(400, { error: '缺 id' })
|
|
208
|
-
if (denyOther(id)) return json(403, { error: '这条接管链接只对应一个页面' })
|
|
209
|
-
if (!Array.isArray(events) || !events.length) return json(400, { error: '缺 events' })
|
|
210
|
-
let entry
|
|
211
|
-
try { entry = await acquire(String(id)) } catch (e) { return json(502, { error: human(e) }) }
|
|
212
|
-
try {
|
|
213
|
-
const r = await cdp.dispatchInputs(entry.session, events, { max: MAX_EVENTS })
|
|
214
|
-
const key = `${who}|${ctx.takeoverSid || ''}|${id}`; inputCount.set(key, (inputCount.get(key) || 0) + r.sent)
|
|
215
|
-
return json(200, { ok: true, ...r })
|
|
216
|
-
} finally { release(String(id)) }
|
|
217
|
-
}
|
|
218
|
-
if (p === '/api/browser/end' && req.method === 'POST') {
|
|
219
|
-
if (ctx.takeoverSid) { takeover.revokeSession(ctx.takeoverSid); endTakeoverStreams(ctx.takeoverSid); audit.append({ kind: 'takeover-end', ip: ctx.ip, ts: Date.now() }) }
|
|
220
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Set-Cookie': takeover.clearCookieHeader({ secure: secureFor(req) }) })
|
|
221
|
-
res.end(JSON.stringify({ ok: true })); return true
|
|
222
|
-
}
|
|
223
|
-
return false
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// 断掉接管者的推流:指定 sid 只断它的;不指定 = 全部(「退出所有设备」)。管理员自己的推流不在这张表里,不受影响
|
|
227
|
-
function endTakeoverStreams(sid = '') { let n = 0; for (const [k, s] of sidStreams) { if (sid && k !== sid) continue; for (const end of [...s]) { n++; try { end() } catch {} } } return n }
|
|
228
|
-
|
|
229
|
-
function close() { for (const [id, e] of registry) { try { if (e.idle) clearTimeout(e.idle); e.session.close() } catch {} registry.delete(id) } }
|
|
230
|
-
|
|
231
|
-
return { handle, close, endTakeoverStreams, cdpUrl, _registry: registry }
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
module.exports = { createBrowserRoutes, IDLE_CLOSE_MS, MAX_EVENTS }
|