sintbot 1.0.15 → 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.
Files changed (136) hide show
  1. package/CHANGELOG.en.json +118 -0
  2. package/CHANGELOG.json +118 -0
  3. package/README.md +20 -156
  4. package/README.zh-CN.md +20 -164
  5. package/THIRD-PARTY-NOTICES.md +394 -0
  6. package/bin/sintbot-v2.js +363 -0
  7. package/docs/AI-ASSISTED-INSTALL.md +19 -0
  8. package/docs/install.md +93 -0
  9. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +19 -0
  10. package/docs/zh-CN/install.md +93 -0
  11. package/install.sh +84 -0
  12. package/package.json +74 -34
  13. package/src/agent/claudecode.js +149 -108
  14. package/src/agent/codexapp.js +458 -0
  15. package/src/agent/codexcode.js +210 -107
  16. package/src/agent/errtag.js +34 -0
  17. package/src/agent/privatecore.js +790 -0
  18. package/src/agent/privatecore.wasm +0 -0
  19. package/src/agent/runner.js +10 -8
  20. package/src/agent/winexec.js +35 -5
  21. package/src/c2install.js +117 -0
  22. package/src/config.js +2 -1
  23. package/src/core/c2cardeffectbridge.js +51 -0
  24. package/src/core/c2cardterminalpath.js +216 -0
  25. package/src/core/c2durableingress.js +148 -0
  26. package/src/core/c2durablejournal.js +1560 -0
  27. package/src/core/c2durableruntime.js +2361 -0
  28. package/src/core/c2eventidentity.js +90 -0
  29. package/src/core/c2globalfault.js +401 -0
  30. package/src/core/c2localeffectexecutor.js +193 -0
  31. package/src/core/c2payloadhandles.js +333 -0
  32. package/src/core/c2persistrelation.js +179 -0
  33. package/src/core/c2persiststate.js +342 -0
  34. package/src/core/c2platformrecoverysender.js +76 -0
  35. package/src/core/c2recoverygate.js +385 -0
  36. package/src/core/c2runnerinput.js +63 -0
  37. package/src/core/c2servicestartgate.js +103 -0
  38. package/src/core/c2sessionbridge.js +72 -0
  39. package/src/core/c2sourcequarantine.js +30 -0
  40. package/src/core/c2startuprecovery.js +105 -0
  41. package/src/core/c2successorbridge.js +151 -0
  42. package/src/core/c2terminaleffectbridge.js +154 -0
  43. package/src/core/c2terminalprune.js +108 -0
  44. package/src/core/c2turnprojection.js +177 -0
  45. package/src/core/enginecheckpoint.js +211 -0
  46. package/src/core/engineeffectcursor.js +394 -0
  47. package/src/core/enginestepcoordinator.js +183 -0
  48. package/src/core/store.js +52 -8
  49. package/src/core/v2engine.js +1469 -0
  50. package/src/platform/feishu.js +324 -67
  51. package/src/plugins.js +1 -9
  52. package/assets/design-styles/acid-pop.md +0 -155
  53. package/assets/design-styles/archive-index.md +0 -161
  54. package/assets/design-styles/art-deco.md +0 -164
  55. package/assets/design-styles/cinematic-noir.md +0 -178
  56. package/assets/design-styles/editorial.md +0 -121
  57. package/assets/design-styles/extra/README.md +0 -23
  58. package/assets/design-styles/extra/constructivism.md +0 -146
  59. package/assets/design-styles/extra/neo-brutalism.md +0 -175
  60. package/assets/design-styles/extra/new-wave.md +0 -166
  61. package/assets/design-styles/extra/nordic-material.md +0 -162
  62. package/assets/design-styles/extra/paris-couture.md +0 -162
  63. package/assets/design-styles/extra/tropical-modern.md +0 -168
  64. package/assets/design-styles/extra/ulm-functional.md +0 -182
  65. package/assets/design-styles/extra/wa-shikimen.md +0 -160
  66. package/assets/design-styles/extra/y2k-chrome.md +0 -172
  67. package/assets/design-styles/geometric-pop.md +0 -122
  68. package/assets/design-styles/hand-drawn.md +0 -117
  69. package/assets/design-styles/islamic-geometry.md +0 -155
  70. package/assets/design-styles/liquid-glass.md +0 -183
  71. package/assets/design-styles/neo-chinese.md +0 -136
  72. package/assets/design-styles/precision-mono.md +0 -156
  73. package/assets/design-styles/quiet-luxury.md +0 -163
  74. package/assets/design-styles/riso-print.md +0 -116
  75. package/assets/design-styles/soft-friendly.md +0 -174
  76. package/assets/design-styles/swiss-grid.md +0 -156
  77. package/assets/design-styles/tech-dark.md +0 -182
  78. package/assets/favicon.svg +0 -6
  79. package/assets/logo.svg +0 -24
  80. package/bin/sbot.js +0 -4711
  81. package/docs/slack-app-manifest.yaml +0 -104
  82. package/sbot.config.example.json +0 -235
  83. package/src/agent/askonce.js +0 -76
  84. package/src/agent/claudemodels.js +0 -60
  85. package/src/agent/claudestatus.js +0 -74
  86. package/src/agent/claudeusage.js +0 -200
  87. package/src/agent/codexstatus.js +0 -181
  88. package/src/agent/imagen.js +0 -458
  89. package/src/agent/quotaerr.js +0 -44
  90. package/src/agent/transienterr.js +0 -32
  91. package/src/cdp.js +0 -254
  92. package/src/clilang.js +0 -31
  93. package/src/core/cron.js +0 -182
  94. package/src/core/cronfire.js +0 -120
  95. package/src/core/cronrun.js +0 -116
  96. package/src/core/engine.js +0 -2762
  97. package/src/core/interfaces.js +0 -114
  98. package/src/core/issues.js +0 -185
  99. package/src/core/ledger.js +0 -30
  100. package/src/core/opsalert.js +0 -214
  101. package/src/core/pending.js +0 -123
  102. package/src/core/proxy.js +0 -424
  103. package/src/core/quotaalert.js +0 -79
  104. package/src/core/quotaerr.js +0 -55
  105. package/src/core/scheduler.js +0 -59
  106. package/src/design.js +0 -5161
  107. package/src/gitrepo.js +0 -150
  108. package/src/health.js +0 -353
  109. package/src/installdeps.js +0 -72
  110. package/src/leftover.js +0 -174
  111. package/src/platform/boardnotice.js +0 -77
  112. package/src/platform/boardops.js +0 -36
  113. package/src/platform/boardwatch.js +0 -447
  114. package/src/platform/cli.js +0 -45
  115. package/src/platform/slack.js +0 -1289
  116. package/src/platform/takeovercard.js +0 -37
  117. package/src/platform/wecom.js +0 -938
  118. package/src/platform/wecomcli.js +0 -128
  119. package/src/platform/wecomgate.js +0 -108
  120. package/src/platform/wecomname.js +0 -43
  121. package/src/platform/wecompoll.js +0 -241
  122. package/src/preview.js +0 -581
  123. package/src/projectmemory.js +0 -175
  124. package/src/service.js +0 -317
  125. package/src/ui/auth.js +0 -31
  126. package/src/ui/browser.html +0 -392
  127. package/src/ui/browserroutes.js +0 -234
  128. package/src/ui/console.html +0 -5494
  129. package/src/ui/dashboard.js +0 -168
  130. package/src/ui/feishulogin.js +0 -136
  131. package/src/ui/server.js +0 -2314
  132. package/src/ui/sessions.js +0 -78
  133. package/src/ui/takeover.js +0 -108
  134. package/src/updater.js +0 -243
  135. package/src/upgrade.js +0 -35
  136. package/src/usage.js +0 -144
@@ -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 }