sintbot 1.0.13 → 1.0.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.en.json +34 -0
- package/CHANGELOG.json +34 -0
- package/bin/sbot.js +28 -6
- package/package.json +1 -1
- package/src/service.js +76 -6
- package/src/ui/console.html +161 -18
- package/src/ui/dashboard.js +4 -1
- package/src/ui/server.js +137 -6
- package/src/updater.js +243 -0
package/CHANGELOG.en.json
CHANGED
|
@@ -1,4 +1,38 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.0.15",
|
|
4
|
+
"date": "2026-10-30",
|
|
5
|
+
"name": "Clearer console settings",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "Software updates and history together",
|
|
9
|
+
"d": "Update controls and version history now share one card at the bottom of Settings. A small dot appears by the version number when a newer stable release is available."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "A wider Bot & network card",
|
|
13
|
+
"d": "The card spans the full settings grid, with the bot name and proxy arranged side by side. Background proxy details can be expanded when needed."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"version": "1.0.14",
|
|
19
|
+
"date": "2026-10-30",
|
|
20
|
+
"name": "Steadier service installs and better console controls",
|
|
21
|
+
"items": [
|
|
22
|
+
{
|
|
23
|
+
"t": "More reliable service reinstalls",
|
|
24
|
+
"d": "A reinstall now waits for the old background service to fully unload before starting the new one, reducing cases where the service is left unloaded."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"t": "Software updates from the console",
|
|
28
|
+
"d": "Stable releases are checked at startup and daily, with a manual check available too. An administrator confirms an upgrade, which waits for every group task and queue to clear before installing the exact version."
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"t": "Manage console access in Settings",
|
|
32
|
+
"d": "Set a password for LAN access and toggle LAN binding or public Feishu sign-in. Turning off public sign-in revokes existing Feishu sessions; a failed binding change restores the previous settings."
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
2
36
|
{
|
|
3
37
|
"version": "1.0.13",
|
|
4
38
|
"name": "An even shorter install: almost nothing to answer",
|
package/CHANGELOG.json
CHANGED
|
@@ -1,4 +1,38 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.0.15",
|
|
4
|
+
"date": "2026-10-30",
|
|
5
|
+
"name": "控制台设置布局更清晰",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "软件更新与版本历史合并",
|
|
9
|
+
"d": "合并为一张卡片并移到设置页底部;检测到新版时,版本号旁会显示一个提示点。"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "机器人与网络卡片横跨整行",
|
|
13
|
+
"d": "机器人的名字和网络代理并排显示,后台网络详情可以展开查看。"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"version": "1.0.14",
|
|
19
|
+
"date": "2026-10-30",
|
|
20
|
+
"name": "服务重装与控制台管理更完善",
|
|
21
|
+
"items": [
|
|
22
|
+
{
|
|
23
|
+
"t": "服务重装更稳定",
|
|
24
|
+
"d": "重装时会等旧后台服务彻底退出后再启动,减少服务卡在未加载状态的情况。"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"t": "控制台可管理软件更新",
|
|
28
|
+
"d": "启动时及每天自动检查稳定版,也可手动检查。发现新版后由管理员确认,等所有群任务和队列空闲后再安装指定版本。"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"t": "访问地址可在控制台管理",
|
|
32
|
+
"d": "可以设置局域网访问密码、切换局域网监听和公网飞书登录。关闭公网登录会让现有飞书登录失效;切换监听失败时会恢复原设置。"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
2
36
|
{
|
|
3
37
|
"version": "1.0.13",
|
|
4
38
|
"date": "2026-10-28",
|
package/bin/sbot.js
CHANGED
|
@@ -61,6 +61,7 @@ const feishulogin = require('../src/ui/feishulogin') // 公网地址归一(ui.pu
|
|
|
61
61
|
const { buildTakeoverCard } = require('../src/platform/takeovercard') // 「接管浏览器」的群内卡片(四态:等人来 / 接管中 / 已结束 / 已过期)
|
|
62
62
|
const cdp = require('../src/cdp') // 接管卡片上的页面截图:CLI 直接连常驻浏览器截一张
|
|
63
63
|
const { hashPassword } = require('../src/ui/auth')
|
|
64
|
+
const updateFeature = require('../src/updater')
|
|
64
65
|
|
|
65
66
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
66
67
|
const cyan = (s) => `\x1b[36m${s}\x1b[0m`
|
|
@@ -487,7 +488,7 @@ async function cmdStart(rest) {
|
|
|
487
488
|
const plat = platformsConfigured(cfg)
|
|
488
489
|
if (!plat.feishu && !plat.wecom && !plat.slack) {
|
|
489
490
|
// 带 --ui(后台服务就是这么起的):只开控制台,等人在「设置」里接平台 —— 1.0.12 起没平台也装服务,装完就有地方可去
|
|
490
|
-
if (wantUi) return serveSetupConsole(cfg, { port: Number(values['ui-port']) || 4600, host: values['ui-host'] || '127.0.0.1', noOpen })
|
|
491
|
+
if (wantUi) return serveSetupConsole(cfg, { port: Number(values['ui-port']) || 4600, host: (cfg.ui && cfg.ui.host) || values['ui-host'] || '127.0.0.1', noOpen })
|
|
491
492
|
if (cfg.wecom && (cfg.wecom.botId || cfg.wecom.secret)) return die('sbot.config.json 的 wecom 块缺 ' + (cfg.wecom.botId ? 'secret' : 'botId') + '(机器人 API 配置页「使用长连接」处)')
|
|
492
493
|
return die('没有配置任何聊天平台 — 在 sbot.config.json 里加 "feishu" 块(appId/appSecret)、"wecom" 块(botId/secret)或 "slack" 块(botToken/appToken),配几个接几个')
|
|
493
494
|
}
|
|
@@ -524,6 +525,7 @@ async function cmdStart(rest) {
|
|
|
524
525
|
const log = (m) => process.stderr.write(gray(`[${logStamp()}] · ` + m) + '\n')
|
|
525
526
|
if (tightened.length) log(`状态目录权限已收紧(目录 700、文件 600):${tightened.length} 项`)
|
|
526
527
|
let ui = null
|
|
528
|
+
let updaterManager = null
|
|
527
529
|
let platform = null // set below; the UI (created first) reads WS health through this ref(多平台路由器,src/platform/multi.js)
|
|
528
530
|
// 把一条消息交给引擎:**合成消息**(控制台插话 / 定时任务 / 续做 / 画布确认)没有真实适配器,按 chatId 查出它所属平台的
|
|
529
531
|
// 适配器再喂 —— 引擎读的 skillCmd / features / chatLabel 是适配器上的,喂路由器会让企微群的清单按飞书口径写。
|
|
@@ -709,7 +711,7 @@ async function cmdStart(rest) {
|
|
|
709
711
|
const reloadCfg = () => {
|
|
710
712
|
try {
|
|
711
713
|
const c = loadConfig(values.config); applyProxyEnv(c); engine.reload(c)
|
|
712
|
-
if (ui) engine.publicUrl = feishulogin.normalizePublicUrl((c.ui || {}).publicUrl) //
|
|
714
|
+
if (ui) engine.publicUrl = (c.ui || {}).publicEnabled === false ? null : feishulogin.normalizePublicUrl((c.ui || {}).publicUrl) // 公网入口关闭时不再向群内清单宣传
|
|
713
715
|
log('config reloaded (hot)')
|
|
714
716
|
} catch (e) { log('reload failed: ' + (e && e.message)) }
|
|
715
717
|
}
|
|
@@ -720,7 +722,8 @@ async function cmdStart(rest) {
|
|
|
720
722
|
if (wantUi) {
|
|
721
723
|
const configPath = cfg._path || path.join(process.cwd(), 'sbot.config.json')
|
|
722
724
|
const uiPort = Number(values['ui-port']) || 4600
|
|
723
|
-
|
|
725
|
+
// 控制台设置页可持久切换绑定地址;保存的 host 优先于服务单元里的旧参数。
|
|
726
|
+
const uiHost = (cfg.ui && cfg.ui.host) || values['ui-host'] || '127.0.0.1'
|
|
724
727
|
const uiLoopback = isLoopbackHost(uiHost)
|
|
725
728
|
// Advertise the console address in the agent's self-knowledge manifest (群项目基础能力).
|
|
726
729
|
// For LAN binding, use the first LAN IP so group members get a reachable URL, not 127.0.0.1.
|
|
@@ -729,9 +732,20 @@ async function cmdStart(rest) {
|
|
|
729
732
|
engine.uiLocalUrl = `http://127.0.0.1:${uiPort}`
|
|
730
733
|
// 公网地址(v0.11.72 起的入口):群里 /help 与清单给人的是它,不是局域网 IP —— 出了局域网 LAN 地址
|
|
731
734
|
// 打不开、还要密码,而公网入口只认飞书登录。归一判据与控制台「飞书登录」就绪判据同源(feishulogin)。
|
|
732
|
-
engine.publicUrl = feishulogin.normalizePublicUrl((cfg.ui || {}).publicUrl)
|
|
735
|
+
engine.publicUrl = (cfg.ui || {}).publicEnabled === false ? null : feishulogin.normalizePublicUrl((cfg.ui || {}).publicUrl)
|
|
733
736
|
// Fail-closed: LAN exposure requires a console password (set via `sbot ui --set-password`).
|
|
734
737
|
if (!uiLoopback && !((cfg.ui || {}).auth)) die(`控制台对外(--ui-host ${uiHost})需先设密码:sintbot ui --set-password`)
|
|
738
|
+
const service = require('../src/service')
|
|
739
|
+
updaterManager = updateFeature.createUpdater({
|
|
740
|
+
currentVersion: require('../package.json').version,
|
|
741
|
+
stateFile: path.join(os.homedir(), '.sbot', 'update.json'),
|
|
742
|
+
getLiveState: () => engine.liveState(),
|
|
743
|
+
fetchImpl: require('../src/core/proxy').getProxyFetch() || globalThis.fetch,
|
|
744
|
+
getManagedService: () => { const st = service.status(); return !!st.running && st.pid === process.pid },
|
|
745
|
+
restartManagedService: () => service.restartAsync(),
|
|
746
|
+
setAnnouncementBaseline: (version) => store.setLastVersion(version),
|
|
747
|
+
log,
|
|
748
|
+
})
|
|
735
749
|
// 接管卡片翻状态:有人兑换 ⇒「正在接管中」;点「结束」/ 到时 ⇒「接管已结束」;票过期没人来 ⇒「已过期」。boardClient 在下面才定义,hook 只在运行时才调,不踩 TDZ。
|
|
736
750
|
const flipTakeoverCard = (state) => (meta) => {
|
|
737
751
|
if (!meta || !meta.cardId || !boardClient) return
|
|
@@ -748,6 +762,13 @@ async function cmdStart(rest) {
|
|
|
748
762
|
getLiveState: () => engine.liveState(),
|
|
749
763
|
getConnHealth: () => (platform && platform.connHealth ? platform.connHealth() : null), // 各平台连接健康度({ feishu, wecom } 字典,路由器出)
|
|
750
764
|
onConfigChange: reloadCfg,
|
|
765
|
+
onHostChange: (boundHost, address) => {
|
|
766
|
+
const port = address && typeof address === 'object' ? address.port : uiPort
|
|
767
|
+
const loopback = isLoopbackHost(boundHost)
|
|
768
|
+
engine.uiUrl = `http://${loopback ? '127.0.0.1' : (lanIPv4()[0] || '127.0.0.1')}:${port}`
|
|
769
|
+
engine.uiLocalUrl = `http://127.0.0.1:${port}`
|
|
770
|
+
},
|
|
771
|
+
updater: updaterManager,
|
|
751
772
|
control: {
|
|
752
773
|
setProvider: (chatId, name) => store.setProvider(chatId, name), // 实时切模型(下一轮生效)
|
|
753
774
|
// 某群**实际在用**的模型(临时切换到备用时是备用,与 /status · 账本同口径)—— 控制台改 provider 型号时判「受影响群」
|
|
@@ -803,6 +824,7 @@ async function cmdStart(rest) {
|
|
|
803
824
|
}
|
|
804
825
|
process.stderr.write('\n')
|
|
805
826
|
if (!noOpen) tryOpen(url)
|
|
827
|
+
if (updaterManager) void updaterManager.start().catch((e) => log('update check: ' + (e && e.message)))
|
|
806
828
|
})
|
|
807
829
|
}
|
|
808
830
|
// 配了哪个平台就建哪个适配器;路由器按群分发(连接状态事件带 platform 名,控制台一行一个平台)。
|
|
@@ -1267,9 +1289,9 @@ async function cmdService(rest) {
|
|
|
1267
1289
|
const configPath = findConfigPath(values.config)
|
|
1268
1290
|
if (!configPath) return die('没找到配置文件 —— 先跑 sintbot setup(或 --config 指定)')
|
|
1269
1291
|
// 控制台对外又没设密码:daemon 一启动就会拒绝退出,装成服务就是每 15 秒拉起一次的死循环 ⇒ 装之前就拦
|
|
1270
|
-
const uiHost = values['ui-host']
|
|
1271
1292
|
let raw
|
|
1272
1293
|
try { raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) } catch (e) { return die(`读不了配置 ${configPath}:${e.message}`) }
|
|
1294
|
+
const uiHost = values['ui-host'] || ((raw.ui || {}).host)
|
|
1273
1295
|
if (uiHost && !isLoopbackHost(uiHost) && !((raw.ui || {}).auth)) return die(`控制台对外(--ui-host ${uiHost})要先设密码:sintbot ui --set-password${values.config ? ' --config ' + values.config : ''} —— 否则服务一启动就退出、被反复拉起`)
|
|
1274
1296
|
// 一个聊天平台都没接也照装(1.0.12 起):服务先只开控制台,在「设置」里接好平台后自己重启连上(cmdStart 的配置模式)。
|
|
1275
1297
|
// 1.0.10–1.0.11 在这里拦下(退出码 3),结果装完没有任何地方可去 —— 控制台也打不开
|
|
@@ -1974,7 +1996,7 @@ async function cmdUi(rest) {
|
|
|
1974
1996
|
|
|
1975
1997
|
applyProxyEnv(cfg)
|
|
1976
1998
|
const port = Number(values.port) || 4600
|
|
1977
|
-
const host = values.host || '127.0.0.1'
|
|
1999
|
+
const host = values.host || (cfg.ui && cfg.ui.host) || '127.0.0.1'
|
|
1978
2000
|
const loopback = isLoopbackHost(host)
|
|
1979
2001
|
// Fail-closed: never expose the operator console to the LAN without a password.
|
|
1980
2002
|
if (!loopback) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sintbot",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.15",
|
|
4
4
|
"description": "Drive headless Claude Code (or Codex) from Slack / Feishu / WeCom group chats — one group, one project, swappable model brain. A standalone daemon, not a Claude Code plugin.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sbot": "bin/sbot.js",
|
package/src/service.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
const fs = require('node:fs')
|
|
21
21
|
const os = require('node:os')
|
|
22
22
|
const path = require('node:path')
|
|
23
|
-
const { execFileSync } = require('node:child_process')
|
|
23
|
+
const { execFileSync, execFile } = require('node:child_process')
|
|
24
24
|
|
|
25
25
|
const LABEL = 'com.sbot.daemon' // launchd 标签(与早期手写 plist 同名 ⇒ 装新版直接替换旧的)
|
|
26
26
|
const UNIT = 'sbot.service' // systemd --user 单元名
|
|
@@ -176,6 +176,35 @@ function run(bin, args, opts = {}) {
|
|
|
176
176
|
|
|
177
177
|
const uid = () => (typeof process.getuid === 'function' ? process.getuid() : 0)
|
|
178
178
|
|
|
179
|
+
// launchctl bootout may return before the old job has fully left the GUI domain.
|
|
180
|
+
// Keep the wait synchronous and strictly bounded: service install is a CLI action,
|
|
181
|
+
// and a failed unload must never race straight into bootstrap.
|
|
182
|
+
function sleepSync(ms) {
|
|
183
|
+
if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function launchdServiceMissing(message) {
|
|
187
|
+
return /could not find service|service[^\n]*not found|no such service|not found in domain/i.test(String(message || ''))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function launchdBootstrapRace(result) {
|
|
191
|
+
return result && (result.code === 5 || /bootstrap failed:\s*5|input\/output error/i.test(String(result.err || '')))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// { ok:true, waitedMs } when unloaded; otherwise a bounded, actionable failure.
|
|
195
|
+
function waitForLaunchdUnload(target, { runCommand = run, wait = sleepSync, timeoutMs = 2000, intervalMs = 100 } = {}) {
|
|
196
|
+
const tries = Math.floor(Math.max(0, timeoutMs) / Math.max(1, intervalMs)) + 1
|
|
197
|
+
for (let i = 0; i < tries; i++) {
|
|
198
|
+
const r = runCommand('launchctl', ['print', target])
|
|
199
|
+
if (!r.ok) {
|
|
200
|
+
if (launchdServiceMissing(r.err)) return { ok: true, waitedMs: i * intervalMs }
|
|
201
|
+
return { ok: false, waitedMs: i * intervalMs, err: r.err || 'launchctl print 失败,无法确认服务状态' }
|
|
202
|
+
}
|
|
203
|
+
if (i < tries - 1) wait(intervalMs)
|
|
204
|
+
}
|
|
205
|
+
return { ok: false, waitedMs: (tries - 1) * intervalMs, err: `等待 launchd 卸载超时(${timeoutMs}ms),未尝试重新加载` }
|
|
206
|
+
}
|
|
207
|
+
|
|
179
208
|
// 状态(只读):{ installed, running, pid, detail }
|
|
180
209
|
function status({ platform = process.platform, home = os.homedir() } = {}) {
|
|
181
210
|
const file = unitPath(platform, home)
|
|
@@ -197,7 +226,7 @@ function status({ platform = process.platform, home = os.homedir() } = {}) {
|
|
|
197
226
|
}
|
|
198
227
|
|
|
199
228
|
// 写单元文件(已有且内容不同 ⇒ 先备份)并加载。返回 { file, backup, steps: [{cmd, ok, err}] }
|
|
200
|
-
function install(spec, { platform = process.platform, home = os.homedir() } = {}) {
|
|
229
|
+
function install(spec, { platform = process.platform, home = os.homedir(), runCommand = run, wait = sleepSync, launchdTimeoutMs = 2000, launchdPollMs = 100 } = {}) {
|
|
201
230
|
const file = unitPath(platform, home)
|
|
202
231
|
if (!file) throw new Error('这个平台不支持自动安装服务')
|
|
203
232
|
const text = render(platform, spec)
|
|
@@ -212,10 +241,38 @@ function install(spec, { platform = process.platform, home = os.homedir() } = {}
|
|
|
212
241
|
// 单元文件里写了环境变量的值(多半是 key)⇒ 只给自己读写;没写就是普通权限
|
|
213
242
|
if (Object.keys(spec.env || {}).length) fs.chmodSync(file, 0o600)
|
|
214
243
|
const steps = []
|
|
215
|
-
const step = (bin, args, tolerate) => { const r =
|
|
244
|
+
const step = (bin, args, tolerate) => { const r = runCommand(bin, args); steps.push({ cmd: [bin, ...args].join(' '), ok: r.ok || !!tolerate, err: r.ok ? '' : r.err }); return r }
|
|
216
245
|
if (platform === 'darwin') {
|
|
217
|
-
|
|
218
|
-
|
|
246
|
+
const target = `gui/${uid()}/${LABEL}`
|
|
247
|
+
const bootoutArgs = ['bootout', target]
|
|
248
|
+
const out = runCommand('launchctl', bootoutArgs)
|
|
249
|
+
const absentBefore = !out.ok && launchdServiceMissing(out.err)
|
|
250
|
+
steps.push({ cmd: ['launchctl', ...bootoutArgs].join(' '), ok: out.ok || absentBefore, err: out.ok || absentBefore ? '' : out.err })
|
|
251
|
+
// A missing job is normal on first install; any other bootout failure is not.
|
|
252
|
+
if (!out.ok && !absentBefore) return { file, backup, steps }
|
|
253
|
+
const unloaded = waitForLaunchdUnload(target, { runCommand, wait, timeoutMs: launchdTimeoutMs, intervalMs: launchdPollMs })
|
|
254
|
+
steps.push({ cmd: 'launchctl wait for bootout', ok: unloaded.ok, err: unloaded.ok ? '' : unloaded.err })
|
|
255
|
+
if (!unloaded.ok) return { file, backup, steps }
|
|
256
|
+
|
|
257
|
+
const bootstrapArgs = ['bootstrap', `gui/${uid()}`, file]
|
|
258
|
+
const first = runCommand('launchctl', bootstrapArgs)
|
|
259
|
+
if (first.ok) steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: true, err: '' })
|
|
260
|
+
else if (launchdBootstrapRace(first)) {
|
|
261
|
+
// A race may report EIO even though launchd accepted the job. Since the
|
|
262
|
+
// previous job was confirmed absent, a now-loaded job is the new one.
|
|
263
|
+
const after = runCommand('launchctl', ['print', target])
|
|
264
|
+
if (after.ok) steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: true, err: '' })
|
|
265
|
+
else if (launchdServiceMissing(after.err)) {
|
|
266
|
+
wait(100)
|
|
267
|
+
const retry = runCommand('launchctl', bootstrapArgs)
|
|
268
|
+
if (retry.ok) steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: true, err: '' })
|
|
269
|
+
else {
|
|
270
|
+
const afterRetry = runCommand('launchctl', ['print', target])
|
|
271
|
+
const loaded = afterRetry.ok
|
|
272
|
+
steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: loaded, err: loaded ? '' : retry.err })
|
|
273
|
+
}
|
|
274
|
+
} else steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: false, err: after.err || first.err })
|
|
275
|
+
} else steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: false, err: first.err })
|
|
219
276
|
} else {
|
|
220
277
|
step('systemctl', ['--user', 'daemon-reload'])
|
|
221
278
|
step('systemctl', ['--user', 'enable', UNIT])
|
|
@@ -230,6 +287,19 @@ function restart({ platform = process.platform } = {}) {
|
|
|
230
287
|
return { ok: false, err: '这个平台不支持' }
|
|
231
288
|
}
|
|
232
289
|
|
|
290
|
+
// Async form for a daemon restarting itself after an in-place package upgrade.
|
|
291
|
+
// systemctl restart waits for the old process to exit, so the daemon must keep
|
|
292
|
+
// its event loop free to handle SIGTERM while this command is in flight.
|
|
293
|
+
function restartAsync({ platform = process.platform } = {}) {
|
|
294
|
+
let bin, args
|
|
295
|
+
if (platform === 'darwin') { bin = 'launchctl'; args = ['kickstart', '-k', `gui/${uid()}/${LABEL}`] }
|
|
296
|
+
else if (platform === 'linux') { bin = 'systemctl'; args = ['--user', 'restart', UNIT] }
|
|
297
|
+
else return Promise.resolve({ ok: false, err: '这个平台不支持' })
|
|
298
|
+
return new Promise((resolve) => execFile(bin, args, { encoding: 'utf8', windowsHide: true }, (e, stdout, stderr) => {
|
|
299
|
+
resolve(e ? { ok: false, out: String(stdout || ''), err: String(stderr || e.message || e).trim(), code: e.code ?? e.status } : { ok: true, out: String(stdout || '') })
|
|
300
|
+
}))
|
|
301
|
+
}
|
|
302
|
+
|
|
233
303
|
function uninstall({ platform = process.platform, home = os.homedir() } = {}) {
|
|
234
304
|
const file = unitPath(platform, home)
|
|
235
305
|
const steps = []
|
|
@@ -244,4 +314,4 @@ function uninstall({ platform = process.platform, home = os.homedir() } = {}) {
|
|
|
244
314
|
return { file, removed, steps }
|
|
245
315
|
}
|
|
246
316
|
|
|
247
|
-
module.exports = { LABEL, UNIT, SUPERVISED_ENV, xmlEsc, sdQuote, buildPath, versionedPath, stableNodeBin, envRefs, serviceSpec, launchdPlist, systemdUnit, unitPath, render, windowsHint, status, install, restart, uninstall }
|
|
317
|
+
module.exports = { LABEL, UNIT, SUPERVISED_ENV, xmlEsc, sdQuote, buildPath, versionedPath, stableNodeBin, envRefs, serviceSpec, launchdPlist, systemdUnit, unitPath, render, windowsHint, status, install, restart, restartAsync, uninstall, launchdServiceMissing, launchdBootstrapRace, waitForLaunchdUnload }
|
package/src/ui/console.html
CHANGED
|
@@ -242,6 +242,18 @@ body::after{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;opac
|
|
|
242
242
|
.mark .ver{font-family:var(--mono);font-size:10px;font-weight:600;letter-spacing:.3px;color:var(--ember-soft);
|
|
243
243
|
border:1px solid rgba(var(--ember-rgb),.32);border-radius:6px;padding:1px 7px;align-self:center;white-space:nowrap}
|
|
244
244
|
.mark .ver:empty{display:none}
|
|
245
|
+
.mark .ver-update{appearance:none;display:inline-flex;align-items:center;justify-content:center;align-self:center;width:14px;height:16px;margin:0 -4px;padding:0;
|
|
246
|
+
border:0;border-radius:50%;background:transparent;cursor:pointer}
|
|
247
|
+
.mark .ver-update[hidden]{display:none}
|
|
248
|
+
.mark .ver-update:focus-visible{outline:2px solid var(--focus);outline-offset:1px}
|
|
249
|
+
.mark .update-dot{width:5px;height:5px;border-radius:50%;flex:none;background:var(--ember-soft);box-shadow:0 0 0 2px rgba(var(--ember-rgb),.13);
|
|
250
|
+
animation:update-dot-flash 1.4s steps(1,end) infinite;transition:transform .15s,box-shadow .15s}
|
|
251
|
+
.mark .ver-update:hover .update-dot{transform:scale(1.25);box-shadow:0 0 0 3px rgba(var(--ember-rgb),.18)}
|
|
252
|
+
@keyframes update-dot-flash{
|
|
253
|
+
0%,64%,100%{opacity:1;box-shadow:0 0 0 2px rgba(var(--ember-rgb),.13)}
|
|
254
|
+
65%,99%{opacity:.28;box-shadow:0 0 0 0 rgba(var(--ember-rgb),0)}
|
|
255
|
+
}
|
|
256
|
+
@media(prefers-reduced-motion:reduce){.mark .update-dot{animation:none}}
|
|
245
257
|
.top .spacer{flex:1}
|
|
246
258
|
.daemon{display:flex;align-items:center;gap:9px;font-family:var(--sans);font-size:12px;font-weight:500;color:var(--dim)}
|
|
247
259
|
/* 顶栏服务器时钟:整体一枚 chip 胶囊(与全站 .chip 同语言)——「服务器 · 时区 · 时:分:秒」。
|
|
@@ -585,7 +597,22 @@ label.f{display:block;font-family:var(--sans);font-size:11.5px;font-weight:600;l
|
|
|
585
597
|
|
|
586
598
|
h2.sec{font-family:var(--sans);font-size:14px;letter-spacing:.2px;color:var(--ink);margin:34px 2px 14px;font-weight:700}
|
|
587
599
|
/* 版本历史(设置页):时间线式;当前版本行 ember 高亮 */
|
|
588
|
-
#verhistBox{max-height:
|
|
600
|
+
#verhistBox{max-height:240px;overflow-y:auto}
|
|
601
|
+
.update-history{margin-top:18px;padding-top:15px;border-top:1px solid var(--line)}
|
|
602
|
+
.update-history .subhead{font-size:12px;font-weight:650;color:var(--dim);margin-bottom:5px}
|
|
603
|
+
#botNetCard{container-type:inline-size;align-self:start;grid-column:1/-1}
|
|
604
|
+
#botNetCard .botnet-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
|
|
605
|
+
#botNetCard .botnet-fields .field{min-width:0;margin:0}
|
|
606
|
+
#botNetCard .botnet-extra{margin-top:11px;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--inset-bg)}
|
|
607
|
+
#botNetCard .botnet-extra>summary{list-style:none;cursor:pointer;color:var(--faint);font-size:11px;user-select:none}
|
|
608
|
+
#botNetCard .botnet-extra>summary::-webkit-details-marker{display:none}
|
|
609
|
+
#botNetCard .botnet-extra>summary::before{content:'›';display:inline-block;margin-right:7px;color:var(--ember);transition:transform .15s}
|
|
610
|
+
#botNetCard .botnet-extra[open]>summary{color:var(--dim);margin-bottom:5px}
|
|
611
|
+
#botNetCard .botnet-extra[open]>summary::before{transform:rotate(90deg)}
|
|
612
|
+
#botNetCard .botnet-extra .hint{margin-top:3px;overflow-wrap:anywhere}
|
|
613
|
+
#botNetCard .botnet-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:14px;padding-top:13px;border-top:1px solid var(--line)}
|
|
614
|
+
#botNetCard .botnet-actions .hint{flex:1 1 240px;min-width:0;margin:0}
|
|
615
|
+
@container (max-width:440px){#botNetCard .botnet-fields{grid-template-columns:minmax(0,1fr)}}
|
|
589
616
|
.vh-row{padding:12px 2px;border-bottom:1px solid var(--line)}
|
|
590
617
|
.vh-row:last-child{border:none}
|
|
591
618
|
.vh-head{display:flex;align-items:baseline;gap:10px}
|
|
@@ -783,6 +810,7 @@ button.btn.nsmini{padding:1px 9px;font-size:11px;font-weight:500;border-radius:7
|
|
|
783
810
|
/* 超窄(手机竖屏):静态的版本徽章让位给动态的 daemon 状态文字 */
|
|
784
811
|
@media(max-width:430px){
|
|
785
812
|
.mark .ver{display:none}
|
|
813
|
+
.mark .ver-update:not([hidden]){width:12px;height:12px;margin-left:-2px;margin-right:-4px}
|
|
786
814
|
.fxapp{display:none} /* 手机竖屏:顶栏只留运行状态 */
|
|
787
815
|
/* nav → edge-to-edge scroll strip if it ever overflows; .navsp(flex:1) pins the 试跑 icon
|
|
788
816
|
right while the text tabs fit (compact icon ⇒ they do at ≤500px), else it collapses and scrolls */
|
|
@@ -840,7 +868,7 @@ d.dataset.theme=t;if(l!=='en'&&l!=='zh')l=/^zh/i.test((navigator.language||''))?
|
|
|
840
868
|
<div class="top">
|
|
841
869
|
<!-- 品牌字标不进翻译层(data-i18n-skip):「Sint」「Bot」是两个文本节点,英文界面下翻译层会把恰好等于某条译文的
|
|
842
870
|
英文反查回中文(i18nZhOrig),「机器人 → Bot」一加,切回中文时左上角就成了「Sint机器人」(10-24 发版前撞到)。 -->
|
|
843
|
-
<div class="mark"><a class="word" href="https://sintbot.com" target="_blank" rel="noopener" title="SintBot 官网" style="color:inherit;text-decoration:none"><span data-i18n-skip>Sint<i class="trace" aria-hidden="true"></i>Bot</span></a><span class="ver">__SBOT_VERSION__</span><small>控制台</small></div>
|
|
871
|
+
<div class="mark"><a class="word" href="https://sintbot.com" target="_blank" rel="noopener" title="SintBot 官网" style="color:inherit;text-decoration:none"><span data-i18n-skip>Sint<i class="trace" aria-hidden="true"></i>Bot</span></a><span class="ver">__SBOT_VERSION__</span><button class="ver-update" id="topUpdateBadge" type="button" hidden title="发现新版本,打开软件更新" aria-label="发现新版本,打开软件更新"><span class="update-dot" aria-hidden="true"></span></button><small>控制台</small></div>
|
|
844
872
|
<div class="spacer"></div>
|
|
845
873
|
<div class="topright">
|
|
846
874
|
<!-- 当前连着的飞书应用(名称 + 图标)。左边 .mark 是产品品牌、固定不变;这块是「这台机器
|
|
@@ -1164,6 +1192,7 @@ function inboxHtml(){
|
|
|
1164
1192
|
`<a class="iconbtn sm" href="/browser" target="_blank" rel="noopener" title="打开接管页" style="flex:none">${IC.ext}</a>`,
|
|
1165
1193
|
(it.pageTitle||it.pageUrl)?full(esc(it.pageTitle||it.pageUrl)):'');
|
|
1166
1194
|
if(it.kind==='issues')return line('反馈','--warn',`${it.count} 条反馈待处理`,go('ovIssues','滚到反馈本'));
|
|
1195
|
+
if(it.kind==='update')return line('更新','--ember',`发现新版 v${esc(it.to)}`,go('updateCard','打开版本更新'));
|
|
1167
1196
|
if(it.kind==='leftovers')return line('进程','--warn',`${it.count} 个后台进程还在跑`,go('ovLeftovers','滚到后台进程'));
|
|
1168
1197
|
if(it.kind==='preview')return line('预览','--fail','预览在服务整个项目目录',go('ovPreviews','滚到预览服务'));
|
|
1169
1198
|
// 每个检查项单独一个节点:英文界面按整段文字逐个翻译
|
|
@@ -1175,7 +1204,7 @@ function inboxHtml(){
|
|
|
1175
1204
|
<div class="card ovgroups" style="margin-bottom:22px">${items.map(row).join('')}</div>`;
|
|
1176
1205
|
}
|
|
1177
1206
|
function bindInbox(){
|
|
1178
|
-
$$('#overview [data-ibgo]').forEach(b=>b.onclick=()=>{const el=$('#'+b.dataset.ibgo);if(el)el.scrollIntoView({behavior:'smooth',block:'start'});});
|
|
1207
|
+
$$('#overview [data-ibgo]').forEach(b=>b.onclick=()=>{if(b.dataset.ibgo==='updateCard'){$$('nav button').forEach(x=>x.classList.toggle('active',x.dataset.tab==='settings'));$$('.tab').forEach(t=>t.classList.toggle('on',t.id==='settings'));}const el=$('#'+b.dataset.ibgo);if(el)requestAnimationFrame(()=>el.scrollIntoView({behavior:'smooth',block:'start'}));});
|
|
1179
1208
|
$$('#overview [data-ibdis]').forEach(b=>b.onclick=async()=>{
|
|
1180
1209
|
try{const r=await api('/api/inbox/dismiss','POST',{key:b.dataset.ibdis});S.inbox=r.inbox||[];paintInbox();toast('已忽略','ok');}catch(e){toast(e.message,'err');}});
|
|
1181
1210
|
}
|
|
@@ -1312,7 +1341,7 @@ async function autoRefresh(){
|
|
|
1312
1341
|
if(sig!==lastStateSig){
|
|
1313
1342
|
lastStateSig=sig;
|
|
1314
1343
|
renderOverview(); tabSig.overview=sig; // 纯展示,直接重画
|
|
1315
|
-
updateDaemon();updateTopFxApp();
|
|
1344
|
+
updateDaemon();updateTopFxApp();updateTopUpdateBadge(); // 顶栏状态在页签之外,得随状态一起显式更新
|
|
1316
1345
|
}else{updateDaemon();updateOverviewLive();updateConnLive();}
|
|
1317
1346
|
}catch{/* 断网/401 交给 SSE 那条自愈路径,这里静默 */}
|
|
1318
1347
|
}
|
|
@@ -3215,6 +3244,7 @@ async function pgSend(){
|
|
|
3215
3244
|
/* ── settings ── */
|
|
3216
3245
|
function renderSettings(){
|
|
3217
3246
|
const a=S.agent,fe=S.feishu,px=S.proxy,net=S.net||{};
|
|
3247
|
+
const upd=S.update||null;
|
|
3218
3248
|
// 项目根目录:控制台**只读**(产品决定 —— 它是围栏的策略,登上控制台就能改等于围栏只剩一次点击)。
|
|
3219
3249
|
// 这里只把现状说清:每条根在不在、配置里被忽略的值、哪些群在根外;改要走安装向导或机器上的配置文件。
|
|
3220
3250
|
const pri=S.projectsRootsInfo||{roots:[],dropped:[],outside:[],configured:null,fromConfig:false};
|
|
@@ -3235,6 +3265,14 @@ function renderSettings(){
|
|
|
3235
3265
|
<div class="hint" style="margin-top:9px">${net.lan
|
|
3236
3266
|
?`<b style="color:var(--risk)">已对局域网开放</b> · ${net.authOn?'需密码':'<b style="color:var(--fail)">未设密码(异常)</b>'} · HTTP 明文,仅可信网络用(最稳走 SSH 隧道)。`
|
|
3237
3267
|
:'仅本机可访问 · 要对外开放:先设访问密码,再把绑定地址改为 0.0.0.0。'}</div>
|
|
3268
|
+
<div class="inline" style="gap:14px;margin-top:13px;flex-wrap:wrap">
|
|
3269
|
+
<label class="inline" style="gap:6px;cursor:pointer"><input type="checkbox" id="netLan" ${net.lan?'checked':''} style="width:auto"/> <span class="dim">局域网访问</span></label>
|
|
3270
|
+
<label class="inline" style="gap:6px;cursor:pointer"><input type="checkbox" id="netPublic" ${net.publicEnabled?'checked':''} style="width:auto"/> <span class="dim">公网飞书登录</span></label>
|
|
3271
|
+
</div>
|
|
3272
|
+
${!net.authOn?`<div class="field" style="margin:10px 0 0;max-width:320px"><label class="f">访问密码</label><input id="netPass" type="password" autocomplete="new-password" placeholder="局域网开启时设置,至少 6 位"/><div class="hint" style="margin-top:4px">只保存 scrypt 哈希;开启局域网前必填</div></div>`:''}
|
|
3273
|
+
<div class="hint" style="margin-top:7px">${net.publicEnabled?(net.publicReady?'公网登录已启用':'公网登录待配置'): '公网登录已关闭'}${!net.publicReady&&net.feishuLogin&&net.feishuLogin.why?` · ${esc(net.feishuLogin.why)}`:''} · 开关只控制本程序入口,不会改动反向代理或隧道。</div>
|
|
3274
|
+
<button class="btn primary" id="netSave" style="margin-top:12px">保存访问设置</button>
|
|
3275
|
+
<div class="hint" style="margin-top:7px">局域网监听按当前端口即时切换;关闭后只允许本机访问。系统防火墙可能仍会拦截。</div>
|
|
3238
3276
|
${INJECTED_TOKEN?'':'<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap"><button class="btn" id="logoutBtn" title="作废这次登录;其它设备上的登录不受影响">退出登录</button><button class="btn" id="logoutAllBtn" title="作废这台机器签发过的全部登录(所有设备都要重新登录),手机丢了用这个">退出所有设备</button></div>'}
|
|
3239
3277
|
${prSection}
|
|
3240
3278
|
</div>`;
|
|
@@ -3251,17 +3289,38 @@ function renderSettings(){
|
|
|
3251
3289
|
// 代理给连 Claude / ChatGPT 用,国内模型端点自动直连(服务端按已配的模型生成直连名单)
|
|
3252
3290
|
const envPx=S.envProxy||'';
|
|
3253
3291
|
const botCard=`<div class="card" id="botNetCard"><div class="lbl">机器人与网络</div>
|
|
3254
|
-
<div class="
|
|
3255
|
-
<div class="
|
|
3256
|
-
|
|
3257
|
-
<div class="
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3292
|
+
<div class="botnet-fields">
|
|
3293
|
+
<div class="field"><label class="f">机器人名字</label><input id="bBrand" placeholder="SintBot" value="${esc(S.brand||'')}"/>
|
|
3294
|
+
<div class="hint" style="margin-top:5px">自我介绍、升级播报里的自称 · 留空 = SintBot</div></div>
|
|
3295
|
+
<div class="field"><label class="f">网络代理</label><input id="pxHttp" placeholder="http://127.0.0.1:7890" value="${esc((px&&px.http)||'')}"/>
|
|
3296
|
+
<div class="hint" style="margin-top:5px">连不上 Claude / ChatGPT 时填 · 国内模型端点自动直连 · 留空 = 不用代理</div></div>
|
|
3297
|
+
</div>
|
|
3298
|
+
${envPx||(px&&px.noProxy)?`<details class="botnet-extra"><summary>后台网络状态</summary>
|
|
3299
|
+
${envPx?`<div class="hint">后台服务的环境里已有代理:<span class="mono">${esc(envPx)}</span></div>`:''}
|
|
3300
|
+
${px&&px.noProxy?`<div class="hint">直连:<span class="mono">${esc(px.noProxy)}</span></div>`:''}
|
|
3301
|
+
</details>`:''}
|
|
3302
|
+
<div class="botnet-actions"><button class="btn primary" id="bSave">保存</button>
|
|
3303
|
+
<div class="hint">代理第一次填,新的对话轮次就生效;改已有的地址要重启后台服务(sintbot service restart)。</div></div>
|
|
3304
|
+
</div>`;
|
|
3305
|
+
const updateJob=upd&&upd.job;
|
|
3306
|
+
const updateBusy=!!(updateJob&&['queued','installing','restarting','restart-needed'].includes(updateJob.state));
|
|
3307
|
+
const updateText=!upd?'后台版本检查不可用':updateJob?esc(updateJob.message||updateJob.state):upd.available?`检测到新版本 v${esc(upd.latestVersion)} · 当前 v${esc(upd.currentVersion)}`:upd.checkedAt?`当前为最新版本 v${esc(upd.currentVersion)}`:'尚未检查新版';
|
|
3308
|
+
const updateCard=`<div class="card" style="grid-column:1/-1" id="updateCard"><div class="lbl">软件更新</div>
|
|
3309
|
+
<div id="updateStatus" class="hint" style="margin:0 0 12px;color:var(--ink)">${updateText}</div>
|
|
3310
|
+
${upd&&upd.available?`<div class="hint" style="margin:-4px 0 12px">版本差异与发布说明:<a href="https://github.com/sintlab/sintbot/releases/tag/v${encodeURIComponent(upd.latestVersion)}" target="_blank" rel="noopener" style="color:var(--ember-soft)">查看 v${esc(upd.latestVersion)} 发布说明</a></div>`:''}
|
|
3311
|
+
<div class="inline" style="gap:14px;flex-wrap:wrap;margin-bottom:12px">
|
|
3312
|
+
<span class="dim">自动检查稳定版(启动时及每天)</span>
|
|
3313
|
+
<label class="inline" style="gap:6px;cursor:pointer"><input type="checkbox" id="updAnnounce" ${upd&&upd.announce!==false?'checked':''} style="width:auto"/> <span class="dim">升级后向群里播报(记住选择)</span></label>
|
|
3314
|
+
</div>
|
|
3315
|
+
<div class="inline" style="gap:8px;flex-wrap:wrap">
|
|
3316
|
+
<button class="btn" id="updCheck" ${!upd?'disabled':''}>检查新版</button>
|
|
3317
|
+
<button class="btn primary" id="updInstall" ${!upd||!upd.available||updateBusy?'disabled':''}>现在升级</button>
|
|
3318
|
+
<button class="btn" id="updIgnore" ${!upd||!upd.available?'disabled':''}>忽略此版本</button>
|
|
3319
|
+
</div>
|
|
3320
|
+
<div class="hint" style="margin-top:10px">只安装检测到的稳定版本;所有群任务与队列清空后才会安装。需要可写的全局安装目录,不会提权;后台服务会自动重启,前台运行需手动重启。</div>
|
|
3321
|
+
<div class="update-history"><div class="subhead">版本历史</div>
|
|
3322
|
+
<div id="verhistBox"><div class="empty">加载中…</div></div></div>
|
|
3262
3323
|
</div>`;
|
|
3263
|
-
const verhistCard=`<div class="card" style="grid-column:1/-1" id="verhistCard"><div class="lbl">版本历史</div>
|
|
3264
|
-
<div id="verhistBox"><div class="empty">加载中…</div></div></div>`;
|
|
3265
3324
|
$('#settings').innerHTML=`<div class="grid cols2 stagger">
|
|
3266
3325
|
${netCard}
|
|
3267
3326
|
<div class="card"><div class="lbl">运行限制</div>
|
|
@@ -3285,12 +3344,40 @@ function renderSettings(){
|
|
|
3285
3344
|
${fxSlackCard(S.slack||null)}
|
|
3286
3345
|
${fxWecomCard(S.wecom||null)}
|
|
3287
3346
|
${aboutCard}
|
|
3288
|
-
${
|
|
3347
|
+
${updateCard}
|
|
3289
3348
|
</div>`;
|
|
3349
|
+
updateTopUpdateBadge();
|
|
3290
3350
|
loadChangelog();
|
|
3291
3351
|
$('#bSave').onclick=async()=>{const brand=$('#bBrand').value.trim(),http=$('#pxHttp').value.trim();
|
|
3292
3352
|
if(http&&!/^https?:\/\/\S+$/i.test(http)){toast('代理地址要像 http://127.0.0.1:7890','err');return;}
|
|
3293
3353
|
try{await api('/api/agent','POST',{brand});S=await api('/api/proxy','POST',{enabled:!!http,http});toast('已保存','ok');renderSettings();}catch(e){toast(e.message,'err');}};
|
|
3354
|
+
$('#netSave').onclick=async()=>{
|
|
3355
|
+
const lanEnabled=$('#netLan').checked,publicEnabled=$('#netPublic').checked;
|
|
3356
|
+
const patch={lanEnabled,publicEnabled};
|
|
3357
|
+
const pw=$('#netPass');if(pw&&pw.value)patch.password=pw.value;
|
|
3358
|
+
if(lanEnabled&&!net.lan&&!net.authOn&&(!patch.password||patch.password.length<6)){toast('开启局域网访问前要设置至少 6 位访问密码','err');return;}
|
|
3359
|
+
if(lanEnabled!==!!net.lan&&!confirm(lanEnabled?'开启后,同一可信局域网内可访问登录页;继续?':'关闭后,远程设备将无法访问控制台;继续?'))return;
|
|
3360
|
+
if(publicEnabled!==!!net.publicEnabled&&!confirm(publicEnabled?'启用公网飞书登录入口?这不会修改反向代理或隧道。':'关闭公网入口并让现有飞书登录退出?'))return;
|
|
3361
|
+
const b=$('#netSave');b.disabled=true;
|
|
3362
|
+
try{const r=await api('/api/ui/network','POST',patch);
|
|
3363
|
+
if(r.rebinding){toast('访问地址正在切换;页面即将重载','ok');setTimeout(()=>location.reload(),1200);return;}
|
|
3364
|
+
S=r;toast('访问设置已保存','ok');renderSettings();
|
|
3365
|
+
}catch(e){toast(e.message,'err');b.disabled=false;}
|
|
3366
|
+
};
|
|
3367
|
+
const saveUpdatePrefs=async(patch)=>{try{S=await api('/api/ui/update-settings','POST',patch);renderSettings();}catch(e){toast(e.message,'err');}};
|
|
3368
|
+
$('#updAnnounce').onchange=()=>saveUpdatePrefs({announce:$('#updAnnounce').checked});
|
|
3369
|
+
$('#updCheck').onclick=async()=>{const b=$('#updCheck');b.disabled=true;b.textContent='检查中…';
|
|
3370
|
+
try{await api('/api/update/check','POST',{});S=await api('/api/state');await refreshInbox();renderSettings();renderOverview();toast('版本检查完成','ok');}
|
|
3371
|
+
catch(e){toast(e.message,'err');b.disabled=false;b.textContent='检查新版';}};
|
|
3372
|
+
$('#updInstall').onclick=async()=>{const u=S.update||{},version=u.latestVersion;if(!version)return;
|
|
3373
|
+
const announce=$('#updAnnounce').checked;
|
|
3374
|
+
if(!confirm(`将从 v${u.currentVersion} 升级到 v${version}。${announce?'安装完成后会向未暂停的群播报;':'本次不向群播报。'}所有群任务结束后才开始安装,后台服务会重启。继续?`))return;
|
|
3375
|
+
const b=$('#updInstall');b.disabled=true;
|
|
3376
|
+
try{const r=await api('/api/update/start','POST',{version,announce});S.update={...r,announce};await refreshInbox();renderSettings();renderOverview();toast('更新已排队,群任务空闲后开始','ok');}
|
|
3377
|
+
catch(e){toast(e.message,'err');b.disabled=false;}};
|
|
3378
|
+
$('#updIgnore').onclick=async()=>{const version=(S.update||{}).latestVersion;if(!version)return;
|
|
3379
|
+
try{const r=await api('/api/update/ignore','POST',{version});S.update={...r,announce:$('#updAnnounce').checked};await refreshInbox();renderSettings();renderOverview();toast('已忽略此版本','ok');}
|
|
3380
|
+
catch(e){toast(e.message,'err');}};
|
|
3294
3381
|
// 只开着控制台的守护进程(还没接平台):第一次接上平台时说一句接下来会发生什么(服务托管的会自己重启连上)
|
|
3295
3382
|
{const pl=S.platforms||{};if(S.setupMode&&(pl.feishu||pl.slack||pl.wecom)&&!window._sbRestartToast){window._sbRestartToast=1;toast(S.setupMode.supervised?'已接上聊天平台 —— 后台服务几秒后自动重启并连上':'已接上聊天平台 —— 在终端里重启 sintbot start --ui 连上','ok');}}
|
|
3296
3383
|
bindWecomCard();
|
|
@@ -3649,6 +3736,16 @@ const splitList=s=>s.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean);
|
|
|
3649
3736
|
// 「开始使用」卡里的「去某页」按钮:委托到 document,重绘不用重新绑
|
|
3650
3737
|
document.addEventListener('click',(e)=>{const b=e.target.closest&&e.target.closest('[data-gotab]');if(!b)return;const t=document.querySelector('[data-tab="'+b.dataset.gotab+'"]');if(t)t.click();});
|
|
3651
3738
|
function renderAll(){renderOverview();renderProviders();renderGroups();renderPlayground();renderSettings();updateDaemon();updateTopFxApp();}
|
|
3739
|
+
function updateTopUpdateBadge(){
|
|
3740
|
+
const badge=$('#topUpdateBadge');if(!badge)return;
|
|
3741
|
+
badge.hidden=!(S&&S.update&&S.update.available);
|
|
3742
|
+
}
|
|
3743
|
+
(()=>{const badge=$('#topUpdateBadge');if(!badge)return;badge.onclick=()=>{
|
|
3744
|
+
const nav=$('nav [data-tab="settings"]');if(nav)nav.click();
|
|
3745
|
+
const card=$('#updateCard');if(!card)return;
|
|
3746
|
+
requestAnimationFrame(()=>{card.scrollIntoView({behavior:'smooth',block:'start'});
|
|
3747
|
+
card.style.boxShadow='0 0 0 2px rgba(var(--ember-rgb),.45)';setTimeout(()=>card.style.boxShadow='',1400);});
|
|
3748
|
+
};})();
|
|
3652
3749
|
// 顶栏应用胶囊。数据同「设置」那张身份条(同一份落盘缓存),没取过就整块不显示 —— 顶栏不适合
|
|
3653
3750
|
// 放「未获取」这类待办文案。头像 JS 注入并留 🤖 兜底(远端图,控制台可能开在断外网的机器上)。
|
|
3654
3751
|
let topFxAvSrc='';
|
|
@@ -3771,6 +3868,7 @@ const I18N_EN=[
|
|
|
3771
3868
|
["机器人名字","Bot name"],
|
|
3772
3869
|
["自我介绍、升级播报里的自称 · 留空 = SintBot","How it refers to itself in introductions and upgrade notices · empty = SintBot"],
|
|
3773
3870
|
["网络代理","Network proxy"],
|
|
3871
|
+
["后台网络状态","Background network details"],
|
|
3774
3872
|
["连不上 Claude / ChatGPT 时填 · 国内模型端点自动直连 · 留空 = 不用代理","Set this if Claude / ChatGPT can't be reached · China model endpoints connect directly · empty = no proxy"],
|
|
3775
3873
|
["后台服务的环境里已有代理:","The background service already has a proxy in its environment: "],
|
|
3776
3874
|
["直连:","Direct: "],
|
|
@@ -4440,6 +4538,39 @@ const I18N_EN=[
|
|
|
4440
4538
|
[";改后即时生效,不用重启。","; changes apply immediately, no restart needed."],
|
|
4441
4539
|
["公网","Public"],
|
|
4442
4540
|
["局域网","LAN"],
|
|
4541
|
+
["局域网访问","LAN access"],
|
|
4542
|
+
["公网飞书登录","Public Feishu sign-in"],
|
|
4543
|
+
["访问密码","Access password"],
|
|
4544
|
+
["局域网开启时设置,至少 6 位","Set when enabling LAN access; at least 6 characters"],
|
|
4545
|
+
["只保存 scrypt 哈希;开启局域网前必填","Only a scrypt hash is stored; required before enabling LAN access"],
|
|
4546
|
+
["公网登录已启用","Public sign-in is enabled"],
|
|
4547
|
+
["公网登录待配置","Public sign-in needs configuration"],
|
|
4548
|
+
["公网登录已关闭","Public sign-in is off"],
|
|
4549
|
+
["保存访问设置","Save access settings"],
|
|
4550
|
+
["开关只控制本程序入口,不会改动反向代理或隧道。","These switches control this app only; they do not change the reverse proxy or tunnel."],
|
|
4551
|
+
["局域网监听按当前端口即时切换;关闭后只允许本机访问。系统防火墙可能仍会拦截。","LAN binding changes immediately on the current port; when off, only this machine can connect. The system firewall may still block access."],
|
|
4552
|
+
["软件更新","Software updates"],
|
|
4553
|
+
["发现新版本,打开软件更新","New version available; open software updates"],
|
|
4554
|
+
["后台版本检查不可用","Version checks are unavailable in this console"],
|
|
4555
|
+
["自动检查稳定版(启动时及每天)","Automatically checks for stable releases at startup and daily"],
|
|
4556
|
+
["升级后向群里播报(记住选择)","Announce upgrades in groups (remember this choice)"],
|
|
4557
|
+
["检查新版","Check for updates"],
|
|
4558
|
+
["现在升级","Upgrade now"],
|
|
4559
|
+
["忽略此版本","Ignore this version"],
|
|
4560
|
+
["版本差异与发布说明:","Changes and release notes: "],
|
|
4561
|
+
["尚未检查新版","No version check has run yet"],
|
|
4562
|
+
["当前为最新版本","This is the latest version"],
|
|
4563
|
+
["检测到新版本","A new version is available"],
|
|
4564
|
+
["更新已排队,群任务空闲后开始","Update queued; installation will start when all group tasks are idle"],
|
|
4565
|
+
["已忽略此版本","This version was ignored"],
|
|
4566
|
+
["版本检查完成","Version check complete"],
|
|
4567
|
+
["访问地址正在切换;页面即将重载","Changing the access address; this page will reload"],
|
|
4568
|
+
["访问设置已保存","Access settings saved"],
|
|
4569
|
+
["开启局域网访问前要设置至少 6 位访问密码","Set an access password of at least 6 characters before enabling LAN access"],
|
|
4570
|
+
["开启后,同一可信局域网内可访问登录页;继续?","Enabling lets devices on the trusted LAN reach the sign-in page. Continue?"],
|
|
4571
|
+
["关闭后,远程设备将无法访问控制台;继续?","Remote devices will lose console access. Continue?"],
|
|
4572
|
+
["启用公网飞书登录入口?这不会修改反向代理或隧道。","Enable public Feishu sign-in? This will not change the reverse proxy or tunnel."],
|
|
4573
|
+
["关闭公网入口并让现有飞书登录退出?","Disable public access and sign out existing Feishu sessions?"],
|
|
4443
4574
|
["访问地址","Access URLs"],
|
|
4444
4575
|
["已对外,未检测到局域网 IPv4","Open to the network, but no LAN IPv4 was detected"],
|
|
4445
4576
|
["已对局域网开放","Open on the LAN"],
|
|
@@ -5046,6 +5177,18 @@ const I18N_EN_RX=[
|
|
|
5046
5177
|
[/^✕ 加载失败:([\s\S]+)$/,"✕ Failed to load: $1"],
|
|
5047
5178
|
[/^加载失败:([\s\S]+)$/,"Failed to load: $1"],
|
|
5048
5179
|
[/^登录失败 \(HTTP (\d+)\)$/,"Sign-in failed (HTTP $1)"],
|
|
5180
|
+
[/^检测到新版本 v([\d.]+) · 当前 v([\d.]+)$/,"New version v$1 · current v$2"],
|
|
5181
|
+
[/^当前为最新版本 v([\d.]+)$/,"v$1 is the latest version"],
|
|
5182
|
+
[/^等待所有群任务结束后更新$/,"Waiting for all group tasks to finish"],
|
|
5183
|
+
[/^正在检查安装位置并安装指定版本$/,"Checking the install location and installing the selected version"],
|
|
5184
|
+
[/^已安装,正在重启后台服务$/,"Installed; restarting the background service"],
|
|
5185
|
+
[/^已安装新版;后台服务未能自动重启,请手动重启$/,"The new version is installed, but the background service did not restart; restart it manually"],
|
|
5186
|
+
[/^已安装新版;当前不是受托管服务,请手动重启 SintBot$/,"The new version is installed; restart SintBot manually"],
|
|
5187
|
+
[/^更新完成并已重启$/,"Update completed and restarted"],
|
|
5188
|
+
[/^更新后未能自动重启,请手动重启服务$/,"The update could not restart automatically; restart the service manually"],
|
|
5189
|
+
[/^等待群任务结束超时;可在任务空闲后重新发起$/,"Timed out waiting for group tasks; retry when they are idle"],
|
|
5190
|
+
[/^当前运行的是开发目录或非全局副本;请在终端手动更新$/,"This is a development or non-global install; update it from a terminal"],
|
|
5191
|
+
[/^将从 v([\d.]+) 升级到 v([\d.]+)。([\s\S]*)$/,(a,from,to,what)=>`Upgrade from v${from} to v${to}. ${what.replace('所有群任务结束后才开始安装,后台服务会重启。','Installation waits until all group tasks finish, then restarts the background service.').replace('本次不向群播报。','This update will not be announced in groups.').replace('安装完成后会向未暂停的群播报;','After installation, it will be announced in active groups;')}`],
|
|
5049
5192
|
];
|
|
5050
5193
|
/* I18N_DICT_END */
|
|
5051
5194
|
|
|
@@ -5163,7 +5306,7 @@ updateLangBtn();applyI18n(document.body); // 首屏静态 HTML 先翻一遍,
|
|
|
5163
5306
|
/* ══════════ 界面语言 · 完 ════════════════════════════════════════════════════ */
|
|
5164
5307
|
|
|
5165
5308
|
/* 版本历史:CHANGELOG.json 经 /api/changelog 一次拉取;当前版本(=顶栏徽章)标「当前」。
|
|
5166
|
-
顶栏版本徽章可点 →
|
|
5309
|
+
顶栏版本徽章可点 → 跳设置页并滚动到软件更新与版本历史卡片(短暂高亮指位)。
|
|
5167
5310
|
英文界面另拉一份英文本(/api/changelog-en,按 version 对应):正文是数据不是界面文案,词典翻不了它
|
|
5168
5311
|
⇒ 有英文用英文、没有的版本(与条数对不上的条目)原样回退中文;日期与「当前」徽章两语言共用一套。 */
|
|
5169
5312
|
let CHANGELOG=null,CHANGELOG_EN=null;
|
|
@@ -5184,7 +5327,7 @@ async function loadChangelog(){
|
|
|
5184
5327
|
v.onclick=()=>{if(!S)return;
|
|
5185
5328
|
$$('nav button').forEach(x=>x.classList.toggle('active',x.dataset.tab==='settings'));
|
|
5186
5329
|
$$('.tab').forEach(t=>t.classList.toggle('on',t.id==='settings'));
|
|
5187
|
-
const c=$('#
|
|
5330
|
+
const c=$('#updateCard');if(c){c.scrollIntoView({behavior:'smooth',block:'start'});
|
|
5188
5331
|
c.style.boxShadow='0 0 0 2px rgba(var(--ember-rgb),.45)';setTimeout(()=>c.style.boxShadow='',1400);}};})();
|
|
5189
5332
|
|
|
5190
5333
|
/* ── 自绘下拉组件(见 .csel CSS):原生 <select> 的弹出列表是 OS 渲染、CSS 改不动 —— 保留 select
|
|
@@ -5282,7 +5425,7 @@ function startLive(){
|
|
|
5282
5425
|
// recover whatever SSE dropped while we were away (SSE has no replay of its own);
|
|
5283
5426
|
// 重连(非首连)还拉一次全量 state —— 断流期间的 live/conn 变化就地补齐,不整页重渲染。
|
|
5284
5427
|
es.addEventListener('hello',()=>{$('#conn').classList.add('live');syncActivity(true);
|
|
5285
|
-
if(++helloN>1)api('/api/state').then(r=>{S=r;updateDaemon();updateOverviewLive();updateConnLive();startClock();}).catch(()=>{});});
|
|
5428
|
+
if(++helloN>1)api('/api/state').then(r=>{S=r;updateDaemon();updateTopUpdateBadge();updateOverviewLive();updateConnLive();startClock();}).catch(()=>{});});
|
|
5286
5429
|
es.addEventListener('tick',ev=>{try{const d=JSON.parse(ev.data);if(S){if(d.live)S.live=d.live;if('conn' in d)S.conn=d.conn;}updateDaemon();updateOverviewLive();updateConnLive();}catch{}});
|
|
5287
5430
|
startAutoRefresh();
|
|
5288
5431
|
es.addEventListener('conn',ev=>{try{const c=JSON.parse(ev.data);if(S){S.conn=Object.assign({},connDict(S.conn),{[c.platform||'feishu']:c});updateConnLive();}}catch{}}); // 连接状态变化即时推(不等心跳);事件带 platform 名,落进字典对应槽
|
package/src/ui/dashboard.js
CHANGED
|
@@ -101,8 +101,11 @@ function failedTurns(rows, now) {
|
|
|
101
101
|
// leftovers 残留的后台进程(同上)
|
|
102
102
|
// preview 服务整个项目目录的预览(全部代码都在局域网上可读)
|
|
103
103
|
// health 健康检查没通过的项(一行汇总,排最前:它是「现在就坏着」,没有发生时刻可排)
|
|
104
|
-
function buildInbox({ ledgerRows = [], issues = null, previews = [], leftovers = null, health = null, takeovers = [], groups = {}, dismissed = new Set(), now = Date.now() } = {}) {
|
|
104
|
+
function buildInbox({ ledgerRows = [], issues = null, previews = [], leftovers = null, health = null, update = null, takeovers = [], groups = {}, dismissed = new Set(), now = Date.now() } = {}) {
|
|
105
105
|
const items = []
|
|
106
|
+
if (update && update.available && update.latestVersion) {
|
|
107
|
+
items.push({ key: `update:${update.latestVersion}`, kind: 'update', from: update.currentVersion || '', to: update.latestVersion, ts: update.checkedAt || now, dismissable: true })
|
|
108
|
+
}
|
|
106
109
|
for (const f of failedTurns(ledgerRows, now)) {
|
|
107
110
|
items.push({ key: `failed:${f.chatId}:${f.ts}`, kind: 'failed', chatId: f.chatId, group: groupLabel(groups, f.chatId), ts: f.ts,
|
|
108
111
|
sender: f.sender || '', text: f.text || '', err: f.err ? String(f.err) : '', dismissable: true })
|
package/src/ui/server.js
CHANGED
|
@@ -32,7 +32,7 @@ const { createIssues, summarize, fingerprint } = require('../core/issues') //
|
|
|
32
32
|
const leftover = require('../leftover') // 遗留进程:对话轮次结束后仍在跑的后台 shell —— 同一条道理,那条每 5 秒打线上接口的循环跑了 35 天就是因为没处看见(v0.11.108)
|
|
33
33
|
const { withFileLock } = require('../core/store') // 反馈本改状态要与 CLI/群命令共用同一把跨进程锁
|
|
34
34
|
const { redact } = require('../core/redact') // 出站脱敏:预览停止通知里含 agent 提供的 label
|
|
35
|
-
const { verifyPassword } = require('./auth')
|
|
35
|
+
const { hashPassword, verifyPassword } = require('./auth')
|
|
36
36
|
const feishulogin = require('./feishulogin') // 控制台「用飞书登录」:OAuth 授权码流程的纯逻辑(公网入口;只放行管理员名单)
|
|
37
37
|
const { createSessionStore } = require('./sessions') // 登录会话落盘 + 30 天过期(守护进程重启不再把人登出)
|
|
38
38
|
const { listWecomSessions, wecomAuthShow } = require('../platform/wecom')
|
|
@@ -208,7 +208,7 @@ function bindNoticeText(workdir, platform = 'feishu', cmd = '/help') {
|
|
|
208
208
|
return `✅ 本群已绑定项目「${path.basename(String(workdir || '')) || '(未命名)'}」\n${platform === 'wecom' ? '@我 派单(只有 @ 我的消息我才收得到)' : '直接发消息或 @我 派单'};${cmd} 查看命令`
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
function createUiServer({ configPath, getLiveState = null, getConnHealth = null, onConfigChange = null, control = null, host = '127.0.0.1', log = () => {}, issuesPath = null, sessionsPath = null, auditPath = null, ledgerPath = null, inboxDismissPath = null, fetchImpl = null, cdpImpl = null, onTakeoverClaim = null, onTakeoverExpire = null, onTakeoverEnd = null, takeoverNow = null, wecomExec = null, verifyFile = null, setupMode = null } = {}) {
|
|
211
|
+
function createUiServer({ configPath, getLiveState = null, getConnHealth = null, onConfigChange = null, onHostChange = null, control = null, updater = null, host = '127.0.0.1', log = () => {}, issuesPath = null, sessionsPath = null, auditPath = null, ledgerPath = null, inboxDismissPath = null, fetchImpl = null, cdpImpl = null, onTakeoverClaim = null, onTakeoverExpire = null, onTakeoverEnd = null, takeoverNow = null, wecomExec = null, verifyFile = null, setupMode = null } = {}) {
|
|
212
212
|
// wecomExec:企业微信命令行工具的执行器注入(只为可测;生产走默认 execFile)
|
|
213
213
|
// verifyFile:凭据验证记录的落点注入(只为可测;生产 = ~/.sbot/verify.json)
|
|
214
214
|
const vopts = verifyFile ? { file: verifyFile } : undefined
|
|
@@ -257,14 +257,18 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
257
257
|
const publicUrl = feishulogin.normalizePublicUrl(ui.publicUrl)
|
|
258
258
|
const appId = resolveRef(fe.appId), appSecret = resolveRef(fe.appSecret), admins = adminList(fe)
|
|
259
259
|
const r = feishulogin.feishuLoginReadiness({ publicUrl, appId, appSecret, admins })
|
|
260
|
-
|
|
260
|
+
const enabled = ui.publicEnabled !== false // 未出现这个字段的旧配置保持原行为
|
|
261
|
+
return { ready: enabled && r.ready, configured: r.ready, enabled, why: !enabled ? '公网登录已关闭' : r.why,
|
|
262
|
+
publicUrl: publicUrl || '', redirectUri: publicUrl ? feishulogin.redirectUri(publicUrl) : '', appId, appSecret, admins }
|
|
261
263
|
}
|
|
262
264
|
// Host 白名单:环回时只认 loopback(防 DNS-rebinding);对外(--host 非环回)额外放行私网段
|
|
263
265
|
// IPv4(10./192.168./172.16-31./169.254.)。攻击者域名不匹配私网 IP 模式 → rebinding 仍挡住。
|
|
264
|
-
|
|
265
|
-
|
|
266
|
+
let activeHost = String(host)
|
|
267
|
+
let loopbackBind = /^(127\.0\.0\.1|localhost|::1)$/i.test(activeHost)
|
|
268
|
+
const hostPattern = () => loopbackBind
|
|
266
269
|
? /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
|
|
267
270
|
: /^(127\.0\.0\.1|localhost|\[::1\]|10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){2}|169\.254(\.\d{1,3}){2})(:\d+)?$/i
|
|
271
|
+
let HOST_OK = hostPattern()
|
|
268
272
|
const authed = (req, url) => { const t = req.headers['x-sbot-token'] || url.searchParams.get('token'); return (!!t && t === token) || sessions.has(t) }
|
|
269
273
|
// 是否已配访问密码(配了 ⇒ 页面不发 token,走登录)。config 读坏时的裁决见 authConfiguredFrom。
|
|
270
274
|
const authConfigured = () => authConfiguredFrom(() => (readRaw().ui || {}).auth, loopbackBind)
|
|
@@ -329,9 +333,11 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
329
333
|
// 「需要你处理」:从 state 各区取数(反馈本 / 预览 / 残留进程 / 健康)+ 账本 + 接管票,不再各算一遍
|
|
330
334
|
const buildInboxFor = ({ issues, previews, leftovers, health }, raw) => {
|
|
331
335
|
try {
|
|
336
|
+
const update = updater ? { ...updater.status(), announce: !raw || !raw.ui || raw.ui.announceUpdates !== false } : null
|
|
332
337
|
return dashboard.buildInbox({
|
|
333
338
|
ledgerRows: ledgerTail.rows(),
|
|
334
339
|
issues, previews, leftovers, health,
|
|
340
|
+
update,
|
|
335
341
|
takeovers: takeoverStore.pending(),
|
|
336
342
|
groups: (raw && raw.groups) || {},
|
|
337
343
|
dismissed: inboxDismiss.set(),
|
|
@@ -606,14 +612,18 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
606
612
|
const port = addr && typeof addr === 'object' ? addr.port : null
|
|
607
613
|
return {
|
|
608
614
|
lan: !loopbackBind,
|
|
615
|
+
host: activeHost,
|
|
609
616
|
port,
|
|
610
617
|
local: port ? `http://127.0.0.1:${port}` : null,
|
|
611
618
|
lanUrls: (!loopbackBind && port) ? lanIPv4().map((ip) => `http://${ip}:${port}`) : [],
|
|
612
619
|
authOn: authConfigured(),
|
|
613
620
|
publicUrl: fli.publicUrl, // 公网地址(飞书登录回调的根;设置页可填)
|
|
621
|
+
publicEnabled: fli.enabled,
|
|
622
|
+
publicReady: fli.configured,
|
|
614
623
|
feishuLogin: { ready: fli.ready, why: fli.why, redirectUri: fli.redirectUri },
|
|
615
624
|
}
|
|
616
625
|
})(),
|
|
626
|
+
update: updater ? { ...updater.status(), announce: !raw.ui || raw.ui.announceUpdates !== false } : null,
|
|
617
627
|
}
|
|
618
628
|
state.inbox = buildInboxFor(state, raw)
|
|
619
629
|
return state
|
|
@@ -806,6 +816,31 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
806
816
|
if (p === '/api/changelog' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelog() })
|
|
807
817
|
// 同一份版本历史的英文本(界面切英文时才拉;缺的版本前端按 version 回退中文)
|
|
808
818
|
if (p === '/api/changelog-en' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelogEn() })
|
|
819
|
+
if (p === '/api/update/check' && req.method === 'POST') {
|
|
820
|
+
if (!updater) return sendJSON(res, 503, { error: '版本检查暂不可用' })
|
|
821
|
+
return sendJSON(res, 200, await updater.check())
|
|
822
|
+
}
|
|
823
|
+
if (p === '/api/update/start' && req.method === 'POST') {
|
|
824
|
+
if (!updater) return sendJSON(res, 503, { error: '版本升级暂不可用' })
|
|
825
|
+
const { version, announce } = await body(req)
|
|
826
|
+
try { return sendJSON(res, 202, updater.startUpgrade({ version, announce })) }
|
|
827
|
+
catch (e) { return sendJSON(res, 409, { error: e.message, update: updater.status() }) }
|
|
828
|
+
}
|
|
829
|
+
if (p === '/api/update/ignore' && req.method === 'POST') {
|
|
830
|
+
if (!updater) return sendJSON(res, 503, { error: '版本升级暂不可用' })
|
|
831
|
+
const { version } = await body(req)
|
|
832
|
+
try { return sendJSON(res, 200, updater.ignore(version)) }
|
|
833
|
+
catch (e) { return sendJSON(res, 400, { error: e.message }) }
|
|
834
|
+
}
|
|
835
|
+
if (p === '/api/ui/update-settings' && req.method === 'POST') {
|
|
836
|
+
const patch = await body(req)
|
|
837
|
+
if (typeof patch.announce !== 'boolean') return sendJSON(res, 400, { error: '请提供升级播报偏好' })
|
|
838
|
+
const raw = readRaw(); raw.ui = raw.ui || {}
|
|
839
|
+
raw.ui.announceUpdates = patch.announce
|
|
840
|
+
delete raw.ui.autoUpdateCheck
|
|
841
|
+
writeRaw(raw)
|
|
842
|
+
return sendJSON(res, 200, await buildState())
|
|
843
|
+
}
|
|
809
844
|
// codex 可用模型(codex 面板「模型」下拉):app-server model/list 实时探测(当前
|
|
810
845
|
// 账号真实可用集,含各模型支持的推理强度),失败回退内置预设;探测要 spawn 进程
|
|
811
846
|
// +触网 → 10 分钟缓存。
|
|
@@ -1058,6 +1093,67 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
1058
1093
|
if (t) { const n = feishulogin.normalizePublicUrl(t); if (!n) return sendJSON(res, 400, { error: '公网地址要写成 https://域名(可带端口),不带路径' }); raw.ui.publicUrl = n } else delete raw.ui.publicUrl
|
|
1059
1094
|
writeRaw(raw); return sendJSON(res, 200, await buildState())
|
|
1060
1095
|
}
|
|
1096
|
+
if (p === '/api/ui/network' && req.method === 'POST') {
|
|
1097
|
+
const patch = await body(req); const raw = readRaw(); raw.ui = raw.ui || {}
|
|
1098
|
+
const previousHost = activeHost
|
|
1099
|
+
let nextHost = activeHost
|
|
1100
|
+
if (typeof patch.lanEnabled === 'boolean') {
|
|
1101
|
+
if (patch.lanEnabled && patch.password) {
|
|
1102
|
+
const password = String(patch.password)
|
|
1103
|
+
if (password.length < 6) return sendJSON(res, 400, { error: '访问密码至少 6 位' })
|
|
1104
|
+
raw.ui.auth = hashPassword(password)
|
|
1105
|
+
}
|
|
1106
|
+
if (patch.lanEnabled && !raw.ui.auth) return sendJSON(res, 400, { error: '开启局域网访问前要先设置访问密码' })
|
|
1107
|
+
if (patch.lanEnabled !== !loopbackBind) {
|
|
1108
|
+
nextHost = patch.lanEnabled ? '0.0.0.0' : '127.0.0.1'
|
|
1109
|
+
raw.ui.host = nextHost
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (typeof patch.publicEnabled === 'boolean') {
|
|
1113
|
+
const wasEnabled = raw.ui.publicEnabled !== false
|
|
1114
|
+
if (patch.publicEnabled) {
|
|
1115
|
+
const fe = raw.feishu || {}; const publicUrl = feishulogin.normalizePublicUrl(raw.ui.publicUrl)
|
|
1116
|
+
const ready = feishulogin.feishuLoginReadiness({ publicUrl, appId: resolveRef(fe.appId), appSecret: resolveRef(fe.appSecret), admins: adminList(fe) })
|
|
1117
|
+
if (!ready.ready) return sendJSON(res, 400, { error: `公网登录尚未就绪:${ready.why}` })
|
|
1118
|
+
}
|
|
1119
|
+
raw.ui.publicEnabled = patch.publicEnabled
|
|
1120
|
+
if (wasEnabled && !patch.publicEnabled) {
|
|
1121
|
+
const n = sessions.revokeWhere((session) => session && session.via === 'feishu')
|
|
1122
|
+
if (n) audit.append({ kind: 'ui-public-access-revoked', sessions: n, ts: Date.now() })
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
writeRaw(raw)
|
|
1126
|
+
audit.append({ kind: 'ui-network-settings', lan: nextHost !== '127.0.0.1', publicEnabled: raw.ui.publicEnabled !== false,
|
|
1127
|
+
hostChanged: nextHost !== previousHost, ts: Date.now() })
|
|
1128
|
+
if (nextHost !== previousHost) {
|
|
1129
|
+
const responseSocket = res.socket
|
|
1130
|
+
let rebindStarted = false
|
|
1131
|
+
const startRebind = () => {
|
|
1132
|
+
if (rebindStarted) return
|
|
1133
|
+
rebindStarted = true
|
|
1134
|
+
clearTimeout(rebindFallback)
|
|
1135
|
+
setImmediate(() => rebindHost(nextHost).catch((e) => {
|
|
1136
|
+
try {
|
|
1137
|
+
const latest = readRaw(); latest.ui = latest.ui || {}; latest.ui.host = previousHost
|
|
1138
|
+
writeRaw(latest)
|
|
1139
|
+
} catch (rollbackErr) { log('ui host rollback failed: ' + (rollbackErr && rollbackErr.message)) }
|
|
1140
|
+
audit.append({ kind: 'ui-network-rebind', ok: false, from: nextHost, to: previousHost, ts: Date.now() })
|
|
1141
|
+
log('ui host change failed: ' + (e && e.message))
|
|
1142
|
+
}))
|
|
1143
|
+
}
|
|
1144
|
+
// Wait for the switching response's connection to close before closing
|
|
1145
|
+
// the listener; a fixed delay can still reset the response under load.
|
|
1146
|
+
const rebindFallback = setTimeout(startRebind, 1000)
|
|
1147
|
+
if (rebindFallback.unref) rebindFallback.unref()
|
|
1148
|
+
if (responseSocket && !responseSocket.destroyed) responseSocket.once('close', startRebind)
|
|
1149
|
+
res.writeHead(202, { 'Content-Type': 'application/json; charset=utf-8', Connection: 'close' })
|
|
1150
|
+
res.end(JSON.stringify({ ok: true, rebinding: true, lan: nextHost !== '127.0.0.1' }), () => {
|
|
1151
|
+
if (!responseSocket || responseSocket.destroyed) startRebind()
|
|
1152
|
+
})
|
|
1153
|
+
return
|
|
1154
|
+
}
|
|
1155
|
+
return sendJSON(res, 200, await buildState())
|
|
1156
|
+
}
|
|
1061
1157
|
if (p === '/api/feishu' && req.method === 'POST') {
|
|
1062
1158
|
const { appId, appSecret, botOpenId, adminOpenId, adminName, onlyWhenMentioned } = await body(req); const raw = readRaw()
|
|
1063
1159
|
raw.feishu = raw.feishu || { onlyWhenMentioned: false }
|
|
@@ -2152,6 +2248,40 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
2152
2248
|
}
|
|
2153
2249
|
})
|
|
2154
2250
|
|
|
2251
|
+
function setActiveHost(value) {
|
|
2252
|
+
const changed = activeHost !== String(value)
|
|
2253
|
+
activeHost = String(value)
|
|
2254
|
+
loopbackBind = /^(127\.0\.0\.1|localhost|::1)$/i.test(activeHost)
|
|
2255
|
+
HOST_OK = hostPattern()
|
|
2256
|
+
if (changed && onHostChange) { try { onHostChange(activeHost, server.address()) } catch (e) { log('ui host callback: ' + (e && e.message)) } }
|
|
2257
|
+
}
|
|
2258
|
+
function listenAgain(bindHost, port) {
|
|
2259
|
+
return new Promise((resolve, reject) => {
|
|
2260
|
+
const onError = (e) => { server.removeListener('listening', onListening); reject(e) }
|
|
2261
|
+
const onListening = () => { server.removeListener('error', onError); setActiveHost(bindHost); resolve() }
|
|
2262
|
+
server.once('error', onError)
|
|
2263
|
+
server.once('listening', onListening)
|
|
2264
|
+
server.listen(port, bindHost)
|
|
2265
|
+
})
|
|
2266
|
+
}
|
|
2267
|
+
async function rebindHost(nextHost) {
|
|
2268
|
+
if (!['127.0.0.1', '0.0.0.0'].includes(nextHost)) throw new Error('只支持本机或全部局域网接口')
|
|
2269
|
+
const address = server.address()
|
|
2270
|
+
if (!address || typeof address !== 'object') throw new Error('控制台尚未监听端口')
|
|
2271
|
+
const oldHost = activeHost
|
|
2272
|
+
if (nextHost === oldHost) return
|
|
2273
|
+
for (const client of sseClients) { try { client.end() } catch {} }
|
|
2274
|
+
sseClients.clear()
|
|
2275
|
+
const stopped = new Promise((resolve, reject) => server.close((e) => e ? reject(e) : resolve()))
|
|
2276
|
+
if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
|
|
2277
|
+
await stopped
|
|
2278
|
+
try { await listenAgain(nextHost, address.port) }
|
|
2279
|
+
catch (e) {
|
|
2280
|
+
try { await listenAgain(oldHost, address.port) } catch (restoreErr) { log('ui host restore listen failed: ' + (restoreErr && restoreErr.message)) }
|
|
2281
|
+
throw e
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2155
2285
|
// Broadcast live state + heartbeat over SSE (cheap; no health probe here).
|
|
2156
2286
|
const tick = setInterval(() => {
|
|
2157
2287
|
if (!sseClients.size) return
|
|
@@ -2164,6 +2294,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
2164
2294
|
return {
|
|
2165
2295
|
server,
|
|
2166
2296
|
listen(port, host, cb) { server.listen(port, host, cb) },
|
|
2297
|
+
rebind: rebindHost,
|
|
2167
2298
|
// let a daemon push turn events into the console's activity feed
|
|
2168
2299
|
pushEvent(type, data) {
|
|
2169
2300
|
if (type === 'turn') recordActivity(data) // also buffer it for replay (下一步 ②)
|
|
@@ -2175,7 +2306,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
2175
2306
|
}
|
|
2176
2307
|
for (const c of sseClients) { try { c.write(msg) } catch { sseClients.delete(c) } }
|
|
2177
2308
|
},
|
|
2178
|
-
close() { clearInterval(tick); for (const c of sseClients) { try { c.end() } catch {} } try { browserRoutes.close() } catch {} server.close() },
|
|
2309
|
+
close() { clearInterval(tick); for (const c of sseClients) { try { c.end() } catch {} } try { browserRoutes.close() } catch {} server.close(); if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); if (updater) updater.close() },
|
|
2179
2310
|
_browserRoutes: browserRoutes, // 测试探 CDP 会话注册表用
|
|
2180
2311
|
}
|
|
2181
2312
|
}
|
package/src/updater.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Stable-version checks and guarded in-place upgrades for the global package.
|
|
2
|
+
// Checking is read-only. Installation is explicit, exact-version only, waits for
|
|
3
|
+
// every group queue to drain, and never escalates privileges.
|
|
4
|
+
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const { execFile } = require('node:child_process')
|
|
8
|
+
const { promisify } = require('node:util')
|
|
9
|
+
const { atomicWriteJSON } = require('./core/store')
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile)
|
|
12
|
+
const PACKAGE = 'sintbot'
|
|
13
|
+
const REGISTRIES = [
|
|
14
|
+
{ url: 'https://registry.npmjs.org/sintbot/latest', source: 'npm' },
|
|
15
|
+
{ url: 'https://registry.npmmirror.com/sintbot/latest', source: 'mirror' },
|
|
16
|
+
]
|
|
17
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
18
|
+
const CHECK_TIMEOUT_MS = 10_000
|
|
19
|
+
const QUEUE_TIMEOUT_MS = 24 * 60 * 60 * 1000
|
|
20
|
+
|
|
21
|
+
function stableVersion(value) {
|
|
22
|
+
const s = String(value || '').trim().replace(/^v/, '')
|
|
23
|
+
return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(s) ? s : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function compareVersion(a, b) {
|
|
27
|
+
const av = stableVersion(a); const bv = stableVersion(b)
|
|
28
|
+
if (!av || !bv) return 0
|
|
29
|
+
const aa = av.split('.').map(BigInt); const bb = bv.split('.').map(BigInt)
|
|
30
|
+
for (let i = 0; i < 3; i++) if (aa[i] !== bb[i]) return aa[i] > bb[i] ? 1 : -1
|
|
31
|
+
return 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasPendingWork(live) {
|
|
35
|
+
// Updating while the engine is missing or its snapshot is incomplete is unsafe:
|
|
36
|
+
// a busy group could be hidden by a failed state read. Treat unknown as busy.
|
|
37
|
+
if (!live || live.running !== true || !Number.isFinite(Number(live.queue)) || !live.groups || typeof live.groups !== 'object') return true
|
|
38
|
+
if (Number(live.queue) > 0) return true
|
|
39
|
+
return Object.values(live.groups).some((g) => !g || g.busy || !Number.isFinite(Number(g.queue)) || Number(g.queue) > 0)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fetchVersion({ fetchImpl = globalThis.fetch, url, timeoutMs = CHECK_TIMEOUT_MS } = {}) {
|
|
43
|
+
const ac = new AbortController()
|
|
44
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: ac.signal, redirect: 'error' })
|
|
47
|
+
if (!res || !res.ok) throw new Error(`HTTP ${res && res.status || 'error'}`)
|
|
48
|
+
const data = await res.json()
|
|
49
|
+
const version = stableVersion(data && (data.version || (data['dist-tags'] && data['dist-tags'].latest)))
|
|
50
|
+
if (!version) throw new Error('registry returned no stable version')
|
|
51
|
+
return version
|
|
52
|
+
} finally { clearTimeout(timer) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readState(file) {
|
|
56
|
+
try {
|
|
57
|
+
const v = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
58
|
+
return v && typeof v === 'object' ? v : {}
|
|
59
|
+
} catch { return {} }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createUpdater({
|
|
63
|
+
currentVersion,
|
|
64
|
+
stateFile,
|
|
65
|
+
getLiveState = () => null,
|
|
66
|
+
fetchImpl = globalThis.fetch,
|
|
67
|
+
registries = REGISTRIES,
|
|
68
|
+
now = Date.now,
|
|
69
|
+
waitMs = 5000,
|
|
70
|
+
queueTimeoutMs = QUEUE_TIMEOUT_MS,
|
|
71
|
+
checkIntervalMs = CHECK_INTERVAL_MS,
|
|
72
|
+
isGlobalInstall = defaultGlobalInstall,
|
|
73
|
+
installExact = defaultInstallExact,
|
|
74
|
+
readInstalledVersion = defaultReadInstalledVersion,
|
|
75
|
+
getManagedService = () => false,
|
|
76
|
+
restartManagedService = async () => ({ ok: false }),
|
|
77
|
+
setAnnouncementBaseline = () => {},
|
|
78
|
+
log = () => {},
|
|
79
|
+
} = {}) {
|
|
80
|
+
const file = stateFile || path.join(require('node:os').homedir(), '.sbot', 'update.json')
|
|
81
|
+
const current = stableVersion(currentVersion) || '0.0.0'
|
|
82
|
+
let data = readState(file)
|
|
83
|
+
let queueTimer = null
|
|
84
|
+
let checking = null
|
|
85
|
+
let closed = false
|
|
86
|
+
|
|
87
|
+
// If a previous daemon was replaced during the restart step, the new package
|
|
88
|
+
// version is the evidence that installation and restart both completed.
|
|
89
|
+
if (data.job && data.job.state === 'restarting') {
|
|
90
|
+
data.job = data.job.to === current
|
|
91
|
+
? { ...data.job, state: 'done', message: '更新完成并已重启', updatedAt: now() }
|
|
92
|
+
: { ...data.job, state: 'failed', message: '更新后未能自动重启,请手动重启服务', updatedAt: now() }
|
|
93
|
+
persist()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function persist() { try { atomicWriteJSON(file, data, { mode: 0o600 }) } catch (e) { log('update state: ' + (e && e.message)) } }
|
|
97
|
+
function status() {
|
|
98
|
+
const latest = stableVersion(data.latestVersion)
|
|
99
|
+
return {
|
|
100
|
+
currentVersion: current,
|
|
101
|
+
latestVersion: latest,
|
|
102
|
+
checkedAt: Number(data.checkedAt) || null,
|
|
103
|
+
source: data.source || '',
|
|
104
|
+
ignoredVersion: data.ignoredVersion || '',
|
|
105
|
+
available: !!latest && compareVersion(latest, current) > 0 && data.ignoredVersion !== latest,
|
|
106
|
+
job: data.job ? { ...data.job } : null,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function check() {
|
|
111
|
+
if (closed) return status()
|
|
112
|
+
if (checking) return checking
|
|
113
|
+
checking = (async () => {
|
|
114
|
+
let lastError = null
|
|
115
|
+
for (const r of registries) {
|
|
116
|
+
try {
|
|
117
|
+
const version = await fetchVersion({ fetchImpl, url: r.url })
|
|
118
|
+
data.latestVersion = version
|
|
119
|
+
data.checkedAt = now()
|
|
120
|
+
data.source = r.source || ''
|
|
121
|
+
if (data.ignoredVersion && data.ignoredVersion !== version) data.ignoredVersion = ''
|
|
122
|
+
persist()
|
|
123
|
+
return status()
|
|
124
|
+
} catch (e) { lastError = e }
|
|
125
|
+
}
|
|
126
|
+
// Network errors are deliberately quiet and never erase the last success.
|
|
127
|
+
if (lastError) log('update check unavailable: ' + (lastError && lastError.message || 'network error'))
|
|
128
|
+
return status()
|
|
129
|
+
})().finally(() => { checking = null })
|
|
130
|
+
return checking
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ignore(version) {
|
|
134
|
+
const v = stableVersion(version)
|
|
135
|
+
if (!v || v !== stableVersion(data.latestVersion)) throw new Error('只能忽略当前检测到的版本')
|
|
136
|
+
data.ignoredVersion = v
|
|
137
|
+
persist()
|
|
138
|
+
return status()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function startUpgrade({ version, announce = true } = {}) {
|
|
142
|
+
const v = stableVersion(version)
|
|
143
|
+
if (!v || v !== stableVersion(data.latestVersion) || compareVersion(v, current) <= 0) throw new Error('版本信息已变化,请先重新检查')
|
|
144
|
+
if (data.job && ['queued', 'installing', 'restarting', 'restart-needed'].includes(data.job.state)) throw new Error('已有更新需要完成或重启后再试')
|
|
145
|
+
data.job = { id: `${now()}-${Math.random().toString(36).slice(2, 8)}`, state: 'queued', from: current, to: v,
|
|
146
|
+
announce: announce !== false, createdAt: now(), updatedAt: now(), message: '等待所有群任务结束后更新' }
|
|
147
|
+
persist()
|
|
148
|
+
void processQueue()
|
|
149
|
+
return status()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function scheduleQueueCheck() {
|
|
153
|
+
if (closed || queueTimer || !data.job || data.job.state !== 'queued') return
|
|
154
|
+
queueTimer = setTimeout(() => { queueTimer = null; void processQueue() }, waitMs)
|
|
155
|
+
if (queueTimer.unref) queueTimer.unref()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function processQueue() {
|
|
159
|
+
const job = data.job
|
|
160
|
+
if (closed || !job || job.state !== 'queued') return
|
|
161
|
+
if (now() - job.createdAt > queueTimeoutMs) {
|
|
162
|
+
data.job = { ...job, state: 'failed', message: '等待群任务结束超时;可在任务空闲后重新发起', updatedAt: now() }
|
|
163
|
+
persist(); return
|
|
164
|
+
}
|
|
165
|
+
let live
|
|
166
|
+
try { live = getLiveState() }
|
|
167
|
+
catch (e) { log('update queue: live state unavailable: ' + (e && e.message)); return scheduleQueueCheck() }
|
|
168
|
+
if (hasPendingWork(live)) return scheduleQueueCheck()
|
|
169
|
+
if (queueTimer) { clearInterval(queueTimer); queueTimer = null }
|
|
170
|
+
data.job = { ...job, state: 'installing', message: '正在检查安装位置并安装指定版本', updatedAt: now() }
|
|
171
|
+
persist()
|
|
172
|
+
try {
|
|
173
|
+
const checkResult = await isGlobalInstall()
|
|
174
|
+
if (!checkResult || !checkResult.ok) throw new Error((checkResult && checkResult.reason) || '当前运行位置不是可写的全局安装')
|
|
175
|
+
const result = await installExact(PACKAGE, job.to, checkResult)
|
|
176
|
+
if (!result || result.ok === false) throw new Error((result && result.reason) || '安装失败;检查包管理器日志后可手动重试')
|
|
177
|
+
const installed = await readInstalledVersion(checkResult)
|
|
178
|
+
if (stableVersion(installed) !== job.to) throw new Error('安装完成后的版本核对不符;请手动检查后重启')
|
|
179
|
+
if (!job.announce) { try { setAnnouncementBaseline(job.to) } catch (e) { log('update announcement baseline: ' + (e && e.message)) } }
|
|
180
|
+
const managed = await getManagedService()
|
|
181
|
+
if (managed) {
|
|
182
|
+
data.job = { ...job, state: 'restarting', message: '已安装,正在重启后台服务', updatedAt: now() }
|
|
183
|
+
persist()
|
|
184
|
+
const restarted = await restartManagedService()
|
|
185
|
+
if (restarted && restarted.ok) return status()
|
|
186
|
+
data.job = { ...job, state: 'restart-needed', message: '已安装新版;后台服务未能自动重启,请手动重启', updatedAt: now() }
|
|
187
|
+
} else {
|
|
188
|
+
data.job = { ...job, state: 'restart-needed', message: '已安装新版;当前不是受托管服务,请手动重启 SintBot', updatedAt: now() }
|
|
189
|
+
}
|
|
190
|
+
persist()
|
|
191
|
+
} catch (e) {
|
|
192
|
+
data.job = { ...job, state: 'failed', message: String(e && e.message || e).slice(0, 300), updatedAt: now() }
|
|
193
|
+
persist()
|
|
194
|
+
}
|
|
195
|
+
return status()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (data.job && data.job.state === 'queued') void processQueue()
|
|
199
|
+
const checkTimer = setInterval(() => { void check() }, checkIntervalMs)
|
|
200
|
+
if (checkTimer.unref) checkTimer.unref()
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
status,
|
|
204
|
+
check,
|
|
205
|
+
ignore,
|
|
206
|
+
startUpgrade,
|
|
207
|
+
start() { return check() },
|
|
208
|
+
close() { closed = true; clearInterval(checkTimer); if (queueTimer) clearTimeout(queueTimer); queueTimer = null },
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function defaultGlobalInstall() {
|
|
213
|
+
const { execFileSync } = require('node:child_process')
|
|
214
|
+
const rootOut = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] }).trim()
|
|
215
|
+
const globalRoot = fs.realpathSync(rootOut)
|
|
216
|
+
const globalPackage = path.join(globalRoot, PACKAGE)
|
|
217
|
+
const runningPackage = fs.realpathSync(path.resolve(__dirname, '..'))
|
|
218
|
+
if (runningPackage !== fs.realpathSync(globalPackage)) return { ok: false, reason: '当前运行的是开发目录或非全局副本;请在终端手动更新' }
|
|
219
|
+
fs.accessSync(globalRoot, fs.constants.W_OK | fs.constants.X_OK)
|
|
220
|
+
fs.accessSync(path.dirname(globalPackage), fs.constants.W_OK | fs.constants.X_OK)
|
|
221
|
+
return { ok: true, globalRoot, globalPackage }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function defaultInstallExact(name, version) {
|
|
225
|
+
const env = {}
|
|
226
|
+
const allowed = ['PATH', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP', 'SystemRoot', 'PATHEXT',
|
|
227
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'LANG', 'LC_ALL']
|
|
228
|
+
for (const k of allowed) if (process.env[k] !== undefined) env[k] = process.env[k]
|
|
229
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
230
|
+
try {
|
|
231
|
+
await execFileAsync(npm, ['install', '--global', `${name}@${version}`, '--omit=dev', '--no-audit', '--no-fund'],
|
|
232
|
+
{ timeout: 10 * 60 * 1000, maxBuffer: 2 * 1024 * 1024, windowsHide: true, env })
|
|
233
|
+
return { ok: true }
|
|
234
|
+
} catch (e) {
|
|
235
|
+
return { ok: false, reason: `安装失败(退出码 ${e && e.code != null ? e.code : '未知'});未提权,也未改用其他版本` }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function defaultReadInstalledVersion(info) {
|
|
240
|
+
try { return JSON.parse(fs.readFileSync(path.join(info.globalPackage, 'package.json'), 'utf8')).version || null } catch { return null }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = { PACKAGE, REGISTRIES, CHECK_INTERVAL_MS, stableVersion, compareVersion, hasPendingWork, fetchVersion, createUpdater, defaultGlobalInstall, defaultInstallExact, defaultReadInstalledVersion }
|