dsh-remote-plugin 0.7.0-rc.1 → 0.7.0-rc.3
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/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +229 -5
- package/index.mjs +31 -9
- package/package.json +1 -1
- package/public/admin.js +13 -11
- package/public/announcements.json +78 -17
- package/public/app.js +165 -23
- package/public/index.html +36 -2
- package/public/plugin.js +10 -7
- package/public/styles.css +6 -0
- package/public/update.json +4 -4
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -85,6 +85,7 @@ const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remot
|
|
|
85
85
|
const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
|
|
86
86
|
const DEVICE_KEYS_FILE = process.env.DSH_REMOTE_DEVICE_KEYS || path.join(os.homedir(), '.dsh-remote', 'device-keys.json')
|
|
87
87
|
const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(), '.dsh-remote', 'workbench.json')
|
|
88
|
+
const HANDOFF_FILE = process.env.DSH_REMOTE_HANDOFF || path.join(os.homedir(), '.dsh-remote', 'handoff.json')
|
|
88
89
|
const STARTED_AT = Date.now()
|
|
89
90
|
const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
|
|
90
91
|
const SYSTEMCTL = String(process.env.DSH_REMOTE_SYSTEMCTL || 'systemctl').trim() || 'systemctl'
|
|
@@ -298,21 +299,43 @@ const FS_PREVIEW_EXTENSIONS = new Set([
|
|
|
298
299
|
|
|
299
300
|
// ---------- token ----------
|
|
300
301
|
function loadToken() {
|
|
301
|
-
if (process.env.TOKEN) return process.env.TOKEN
|
|
302
|
+
if (process.env.DSH_REMOTE_TOKEN || process.env.TOKEN) return process.env.DSH_REMOTE_TOKEN || process.env.TOKEN
|
|
302
303
|
try {
|
|
303
304
|
const t = fs.readFileSync(TOKEN_FILE, 'utf8').trim()
|
|
304
305
|
if (t) return t
|
|
305
|
-
} catch {
|
|
306
|
+
} catch (err) {
|
|
307
|
+
if (err.code !== 'ENOENT') throw new Error(`无法读取令牌文件 ${TOKEN_FILE}: ${err.message}`)
|
|
308
|
+
}
|
|
306
309
|
const token = crypto.randomBytes(24).toString('base64url')
|
|
307
310
|
try {
|
|
308
311
|
fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true })
|
|
309
312
|
fs.writeFileSync(TOKEN_FILE, token + '\n', { mode: 0o600 })
|
|
310
|
-
|
|
313
|
+
if (fs.readFileSync(TOKEN_FILE, 'utf8').trim() !== token) throw new Error('令牌写入后校验失败')
|
|
314
|
+
} catch (err) {
|
|
315
|
+
throw new Error(`无法持久化令牌文件 ${TOKEN_FILE}: ${err.message}`)
|
|
316
|
+
}
|
|
311
317
|
return token
|
|
312
318
|
}
|
|
313
319
|
|
|
314
|
-
const TOKEN_FROM_ENV = !!process.env.TOKEN
|
|
320
|
+
const TOKEN_FROM_ENV = !!(process.env.DSH_REMOTE_TOKEN || process.env.TOKEN)
|
|
315
321
|
let TOKEN = loadToken()
|
|
322
|
+
// 仅恢复缺失文件,绝不覆盖另一个进程或管理员已写入的令牌。
|
|
323
|
+
function restoreMissingTokenFile() {
|
|
324
|
+
if (TOKEN_FROM_ENV) return
|
|
325
|
+
try {
|
|
326
|
+
fs.lstatSync(TOKEN_FILE)
|
|
327
|
+
return
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (err.code !== 'ENOENT') return
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true })
|
|
333
|
+
fs.writeFileSync(TOKEN_FILE, TOKEN + '\n', { mode: 0o600, flag: 'wx' })
|
|
334
|
+
console.warn('[token] 已恢复缺失的令牌文件')
|
|
335
|
+
} catch (err) {
|
|
336
|
+
if (err.code !== 'EEXIST') console.error(`[token] 恢复令牌文件失败: ${err.message}`)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
316
339
|
const WS_TICKET_TTL_MS = durationEnv('GATEWAY_WS_TICKET_TTL_MS', 90000, 10000, 10 * 60 * 1000)
|
|
317
340
|
const wsTickets = new Map()
|
|
318
341
|
|
|
@@ -1457,7 +1480,10 @@ async function detectUpstreamApiFlavor(force = false) {
|
|
|
1457
1480
|
}
|
|
1458
1481
|
} catch (error) {
|
|
1459
1482
|
upstreamApiFlavorCheckedAt = Date.now()
|
|
1460
|
-
|
|
1483
|
+
// 网络级失败(上游未启动/cookie 未就绪)不改变 flavor:
|
|
1484
|
+
// 把 unknown 定格成 legacy 会让新版 DSH 被永久当旧服务器探测,
|
|
1485
|
+
// events 双流从此连不上(websocket error 循环)。保持 unknown,
|
|
1486
|
+
// 下一轮 recheck 再探;已有明确 flavor 的也不因瞬断回退。
|
|
1461
1487
|
recordCompatibility('protocol-probe-failed', { detail: error?.message || error })
|
|
1462
1488
|
} finally {
|
|
1463
1489
|
upstreamApiFlavorProbe = null
|
|
@@ -1953,6 +1979,11 @@ function legacyPush(kind, payload, rpcId = crypto.randomUUID()) {
|
|
|
1953
1979
|
|
|
1954
1980
|
function openModernSessionStream(ws, sessionId) {
|
|
1955
1981
|
if (!sessionId || ws.readyState !== 1) return
|
|
1982
|
+
// subagent 来源的会话在 DSH 侧只接受带 durable parent address 的 follow,
|
|
1983
|
+
// 直接按 session 地址 open 会被拒(session/agent-busy),错误帧还会污染事件流;
|
|
1984
|
+
// 这些会话的投影变化由 control/workspaces 基线推送覆盖,无需单独 follow。
|
|
1985
|
+
const summary = modernState.sessions.get(sessionId)
|
|
1986
|
+
if (summary?.origin === 'subagent') return
|
|
1956
1987
|
const streamId = 'session:' + sessionId
|
|
1957
1988
|
ws.send(JSON.stringify({
|
|
1958
1989
|
type: 'open', streamId, endpoint: 'session/follow',
|
|
@@ -3949,6 +3980,127 @@ function workbenchPathInfo(rawPath) {
|
|
|
3949
3980
|
return { path: checked.abs }
|
|
3950
3981
|
}
|
|
3951
3982
|
|
|
3983
|
+
/* ---------- /handoff 跨端接续指针 ----------
|
|
3984
|
+
* 单条记录:最近一次"正在查看的会话"。双端客户端离开会话时 PUT,
|
|
3985
|
+
* 进入会话列表时 GET,发现是另一台设备写的就展示"在 xx 上打开过"接续卡片。
|
|
3986
|
+
* 只存指针(sessionId + 设备名),不存消息内容——消息仍由 DSH 实时通道下发。
|
|
3987
|
+
* 支持多服务器共用一个网关:key 是上游会话所属服务器没有区分需求,
|
|
3988
|
+
* 这里按"该 token 能看到的同一个 DSH"即单工作台语义,只存一份。
|
|
3989
|
+
*/
|
|
3990
|
+
const HANDOFF_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
3991
|
+
let handoffMemory = null
|
|
3992
|
+
|
|
3993
|
+
function handoffLoad() {
|
|
3994
|
+
if (handoffMemory) return handoffMemory
|
|
3995
|
+
try {
|
|
3996
|
+
const raw = JSON.parse(fs.readFileSync(HANDOFF_FILE, 'utf8'))
|
|
3997
|
+
if (raw && typeof raw === 'object' && typeof raw.sessionId === 'string' && raw.sessionId) {
|
|
3998
|
+
handoffMemory = {
|
|
3999
|
+
sessionId: String(raw.sessionId).slice(0, 128),
|
|
4000
|
+
title: String(raw.title || '').slice(0, 200),
|
|
4001
|
+
device: String(raw.device || '').slice(0, 80),
|
|
4002
|
+
clientId: String(raw.clientId || '').slice(0, 96),
|
|
4003
|
+
at: Number(raw.at) || 0
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
} catch {}
|
|
4007
|
+
return handoffMemory
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
function handoffSave(record) {
|
|
4011
|
+
handoffMemory = record
|
|
4012
|
+
try {
|
|
4013
|
+
fs.mkdirSync(path.dirname(HANDOFF_FILE), { recursive: true })
|
|
4014
|
+
fs.writeFileSync(HANDOFF_FILE, JSON.stringify(record, null, 2) + '\n')
|
|
4015
|
+
} catch {}
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
function handoffFresh() {
|
|
4019
|
+
const rec = handoffLoad()
|
|
4020
|
+
if (!rec) return null
|
|
4021
|
+
if (!rec.at || Date.now() - rec.at > HANDOFF_MAX_AGE_MS) return null
|
|
4022
|
+
return rec
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
function serveHandoff(req, res, url) {
|
|
4026
|
+
cors(res)
|
|
4027
|
+
if (req.method === 'OPTIONS') {
|
|
4028
|
+
res.writeHead(204)
|
|
4029
|
+
res.end()
|
|
4030
|
+
return
|
|
4031
|
+
}
|
|
4032
|
+
if (req.method !== 'GET' && req.method !== 'PUT' && req.method !== 'DELETE') {
|
|
4033
|
+
res.writeHead(405, { allow: 'GET, PUT, DELETE' })
|
|
4034
|
+
res.end()
|
|
4035
|
+
return
|
|
4036
|
+
}
|
|
4037
|
+
if (!authorized(req, url)) {
|
|
4038
|
+
authFailures++
|
|
4039
|
+
touchDevice(req, { failedAuth: true })
|
|
4040
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
4041
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
4042
|
+
return
|
|
4043
|
+
}
|
|
4044
|
+
touchDevice(req)
|
|
4045
|
+
|
|
4046
|
+
if (req.method === 'GET') {
|
|
4047
|
+
const rec = handoffFresh()
|
|
4048
|
+
fsJson(res, 200, { ok: true, handoff: rec })
|
|
4049
|
+
return
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
if (req.method === 'DELETE') {
|
|
4053
|
+
handoffMemory = null
|
|
4054
|
+
try { fs.rmSync(HANDOFF_FILE, { force: true }) } catch {}
|
|
4055
|
+
fsJson(res, 200, { ok: true })
|
|
4056
|
+
return
|
|
4057
|
+
}
|
|
4058
|
+
|
|
4059
|
+
// PUT:读 JSON 体,字段白名单 + 截断,4KB 上限
|
|
4060
|
+
let body = ''
|
|
4061
|
+
let done = false
|
|
4062
|
+
req.on('data', chunk => {
|
|
4063
|
+
if (done) return
|
|
4064
|
+
body += chunk
|
|
4065
|
+
if (Buffer.byteLength(body) > 4096) {
|
|
4066
|
+
done = true
|
|
4067
|
+
fsJson(res, 413, { error: 'payload too large' })
|
|
4068
|
+
req.destroy()
|
|
4069
|
+
}
|
|
4070
|
+
})
|
|
4071
|
+
req.on('end', () => {
|
|
4072
|
+
if (done || res.headersSent) return
|
|
4073
|
+
done = true
|
|
4074
|
+
let payload
|
|
4075
|
+
try {
|
|
4076
|
+
payload = JSON.parse(body || '{}')
|
|
4077
|
+
} catch {
|
|
4078
|
+
fsJson(res, 400, { error: 'invalid json' })
|
|
4079
|
+
return
|
|
4080
|
+
}
|
|
4081
|
+
// JSON 允许 null/数组/标量:后续读 payload.sessionId 会在 end 回调里抛
|
|
4082
|
+
// TypeError(外层 try/catch 接不住,请求悬挂无响应,2026-09-20 评审复现)。
|
|
4083
|
+
// 先校验为普通对象,不合规直接 400。
|
|
4084
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
4085
|
+
fsJson(res, 400, { error: 'object body required' })
|
|
4086
|
+
return
|
|
4087
|
+
}
|
|
4088
|
+
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : ''
|
|
4089
|
+
if (!sessionId) {
|
|
4090
|
+
fsJson(res, 400, { error: 'sessionId required' })
|
|
4091
|
+
return
|
|
4092
|
+
}
|
|
4093
|
+
handoffSave({
|
|
4094
|
+
sessionId: sessionId.slice(0, 128),
|
|
4095
|
+
title: String(payload.title || '').slice(0, 200),
|
|
4096
|
+
device: String(payload.device || '').slice(0, 80),
|
|
4097
|
+
clientId: String(payload.clientId || '').slice(0, 96),
|
|
4098
|
+
at: Date.now()
|
|
4099
|
+
})
|
|
4100
|
+
fsJson(res, 200, { ok: true })
|
|
4101
|
+
})
|
|
4102
|
+
}
|
|
4103
|
+
|
|
3952
4104
|
function loadWorkbench() {
|
|
3953
4105
|
try {
|
|
3954
4106
|
const raw = JSON.parse(fs.readFileSync(WORKBENCH_FILE, 'utf8'))
|
|
@@ -4265,6 +4417,7 @@ function proxyApi(req, res, url) {
|
|
|
4265
4417
|
|
|
4266
4418
|
// ---------- 其它 ----------
|
|
4267
4419
|
async function serveHealth(req, res, url) {
|
|
4420
|
+
restoreMissingTokenFile()
|
|
4268
4421
|
const eventHealth = Object.fromEntries(Object.entries(eventCollectorState).map(([kind, state]) => [kind, {
|
|
4269
4422
|
connected: state.connected,
|
|
4270
4423
|
lastEventAt: state.lastEventAt,
|
|
@@ -4346,6 +4499,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
4346
4499
|
if (url.pathname === '/workbench' || url.pathname.startsWith('/workbench/')) return serveWorkbench(req, res, url)
|
|
4347
4500
|
if (url.pathname === '/diagnostics') return serveDiagnostics(req, res, url)
|
|
4348
4501
|
if (url.pathname === '/feedback') return serveFeedback(req, res, url)
|
|
4502
|
+
if (url.pathname === '/handoff') return serveHandoff(req, res, url)
|
|
4349
4503
|
if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
|
|
4350
4504
|
if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
|
|
4351
4505
|
if (url.pathname === '/api/ws-ticket') return serveWsTicket(req, res, url)
|
|
@@ -4394,6 +4548,41 @@ function wsPingFrame(masked) {
|
|
|
4394
4548
|
return Buffer.concat([Buffer.from([0x89, 0x80]), mask])
|
|
4395
4549
|
}
|
|
4396
4550
|
|
|
4551
|
+
/**
|
|
4552
|
+
* 从缓冲头部解析一个完整客户端帧(RFC6455 §5.2)。
|
|
4553
|
+
* 返回 null 表示数据不够、需要等下一个 chunk;解析成功返回
|
|
4554
|
+
* { fin, opcode, payload(已解掩码), total(帧总长) }。
|
|
4555
|
+
* 客户端帧必须带掩码位(§5.1),无掩码直接按协议错误返回 null 并由调用方清缓冲。
|
|
4556
|
+
* 只支持 ≤125 载荷的控制帧上限约束交由调用方(Ping 载荷协议上限 125);
|
|
4557
|
+
* 长度用 126/127 扩展格式完整解析,保证数据帧边界正确跳过。
|
|
4558
|
+
*/
|
|
4559
|
+
function wsParseClientFrame(buf) {
|
|
4560
|
+
if (buf.length < 2) return null
|
|
4561
|
+
const fin = (buf[0] & 0x80) !== 0
|
|
4562
|
+
const opcode = buf[0] & 0x0f
|
|
4563
|
+
const masked = (buf[1] & 0x80) !== 0
|
|
4564
|
+
let len = buf[1] & 0x7f
|
|
4565
|
+
let off = 2
|
|
4566
|
+
if (len === 126) {
|
|
4567
|
+
if (buf.length < 4) return null
|
|
4568
|
+
len = buf.readUInt16BE(2)
|
|
4569
|
+
off = 4
|
|
4570
|
+
} else if (len === 127) {
|
|
4571
|
+
if (buf.length < 10) return null
|
|
4572
|
+
const big = buf.readBigUInt64BE(2)
|
|
4573
|
+
if (big > BigInt(1 << 20)) return 'oversize'
|
|
4574
|
+
len = Number(big)
|
|
4575
|
+
off = 10
|
|
4576
|
+
}
|
|
4577
|
+
if (!masked) return 'unmasked'
|
|
4578
|
+
if (buf.length < off + 4 + len) return null
|
|
4579
|
+
const mask = buf.subarray(off, off + 4)
|
|
4580
|
+
const payload = Buffer.alloc(len)
|
|
4581
|
+
const src = buf.subarray(off + 4, off + 4 + len)
|
|
4582
|
+
for (let i = 0; i < len; i++) payload[i] = src[i] ^ mask[i & 3]
|
|
4583
|
+
return { fin, opcode, payload, total: off + 4 + len }
|
|
4584
|
+
}
|
|
4585
|
+
|
|
4397
4586
|
/**
|
|
4398
4587
|
* 原始 TCP 透传也要维护 WebSocket 控制帧活性:
|
|
4399
4588
|
* - 浏览器侧收到网关的未掩码 Ping 后会自动回 Pong;
|
|
@@ -4498,6 +4687,41 @@ function acceptCollectorClient(req, socket, head, kind, device) {
|
|
|
4498
4687
|
socket.setNoDelay(true)
|
|
4499
4688
|
collectorClients[kind].add(socket)
|
|
4500
4689
|
eventCollectorState[kind].clients = collectorClients[kind].size
|
|
4690
|
+
// 客户端(鸿蒙 lws 等严格 RFC 实现)会发掩码 Ping 并等待 Pong;
|
|
4691
|
+
// 网关必须按 RFC6455 回 Pong(0x8A),且服务端→客户端方向不带掩码位
|
|
4692
|
+
// (RFC6455 §5.1:客户端帧必须掩码,服务端帧必须不掩码,违规即断链)。
|
|
4693
|
+
// TCP 不保证帧边界与 data 事件对齐,逐字节扫描当前 chunk 会把跨包帧、
|
|
4694
|
+
// 其他帧的掩码/载荷误认成帧头(2026-09-20 评审复现:掩码后拆包回出错误
|
|
4695
|
+
// Pong 8a03010203)。这里按 RFC6455 组帧:维护 per-socket 缓冲,帧头
|
|
4696
|
+
// (含 126/127 扩展长度)与载荷凑齐才应答,整帧消费,控制帧间数据帧安全跳过。
|
|
4697
|
+
let rx = Buffer.alloc(0)
|
|
4698
|
+
socket.on('data', (chunk) => {
|
|
4699
|
+
rx = rx.length ? Buffer.concat([rx, chunk]) : chunk
|
|
4700
|
+
while (true) {
|
|
4701
|
+
let frame
|
|
4702
|
+
try {
|
|
4703
|
+
frame = wsParseClientFrame(rx)
|
|
4704
|
+
} catch {
|
|
4705
|
+
rx = Buffer.alloc(0)
|
|
4706
|
+
break
|
|
4707
|
+
}
|
|
4708
|
+
if (!frame) break
|
|
4709
|
+
if (typeof frame === 'string') { rx = Buffer.alloc(0); break }
|
|
4710
|
+
rx = rx.subarray(frame.total)
|
|
4711
|
+
if (frame.payload.length > 125) continue
|
|
4712
|
+
if (frame.fin === false) continue
|
|
4713
|
+
// 仅应答 Ping(RFC6455 §5.5 控制帧载荷上限 125);Pong(0xA)消费后丢弃,
|
|
4714
|
+
// 其余 opcode(含数据帧/分片)跳过
|
|
4715
|
+
if (frame.opcode === 0x9) {
|
|
4716
|
+
const pong = Buffer.alloc(2 + frame.payload.length)
|
|
4717
|
+
pong[0] = 0x8a
|
|
4718
|
+
pong[1] = frame.payload.length
|
|
4719
|
+
frame.payload.copy(pong, 2)
|
|
4720
|
+
try { socket.write(pong) } catch {}
|
|
4721
|
+
}
|
|
4722
|
+
}
|
|
4723
|
+
if (rx.length > 1 << 20) rx = Buffer.alloc(0)
|
|
4724
|
+
})
|
|
4501
4725
|
for (const raw of collectorReplay[kind].values()) {
|
|
4502
4726
|
if (socket.destroyed || !socket.writable) break
|
|
4503
4727
|
try { socket.write(encodeWsText(raw)) } catch { break }
|
package/index.mjs
CHANGED
|
@@ -45,13 +45,23 @@ function readGatewayPort() {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
function gatewayBase() {
|
|
48
|
-
return (process.env.DSH_REMOTE_GATEWAY ||
|
|
48
|
+
return (process.env.DSH_REMOTE_GATEWAY || upstreamUrlForListener({ host: readGatewayHost(), port: readGatewayPort() })).replace(/\/+$/, '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readGatewayHost() {
|
|
52
|
+
if (process.env.DSH_REMOTE_GATEWAY_HOST) return process.env.DSH_REMOTE_GATEWAY_HOST
|
|
53
|
+
if (process.env.HOST) return process.env.HOST
|
|
54
|
+
try {
|
|
55
|
+
const host = readFileSync(`${homedir()}/.dsh-remote/gateway-host`, 'utf8').trim()
|
|
56
|
+
if (host) return host
|
|
57
|
+
} catch {}
|
|
58
|
+
return '0.0.0.0'
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
function gatewayToken() {
|
|
52
|
-
if (process.env.DSH_REMOTE_TOKEN) return process.env.DSH_REMOTE_TOKEN
|
|
62
|
+
if (process.env.DSH_REMOTE_TOKEN || process.env.TOKEN) return process.env.DSH_REMOTE_TOKEN || process.env.TOKEN
|
|
53
63
|
try {
|
|
54
|
-
return readFileSync(`${homedir()}/.dsh-remote/token`, 'utf8').trim() || ''
|
|
64
|
+
return readFileSync(process.env.TOKEN_FILE || `${homedir()}/.dsh-remote/token`, 'utf8').trim() || ''
|
|
55
65
|
} catch {
|
|
56
66
|
return ''
|
|
57
67
|
}
|
|
@@ -247,7 +257,8 @@ function gatewaySystemdEnvArgs() {
|
|
|
247
257
|
/** 127.0.0.1 端口占用预检: 能连上=被占用, 连接被拒/超时=可用。 */
|
|
248
258
|
function portInUse(port) {
|
|
249
259
|
return new Promise((resolvePromise) => {
|
|
250
|
-
const
|
|
260
|
+
const host = new URL(upstreamUrlForListener({ host: readGatewayHost(), port })).hostname.replace(/^\[|\]$/g, '')
|
|
261
|
+
const sock = net.connect({ host, port: Number(port) })
|
|
251
262
|
let done = false
|
|
252
263
|
const finish = (used) => {
|
|
253
264
|
if (done) return
|
|
@@ -344,10 +355,16 @@ function setGatewayEnabled(on) {
|
|
|
344
355
|
}
|
|
345
356
|
|
|
346
357
|
/** 启动随插件分发的 gateway.cjs; 已运行则直接返回。 */
|
|
358
|
+
const GATEWAY_AUTH_HINT = '网关正在运行,但管理认证不可用。请检查 TOKEN_FILE、TOKEN / DSH_REMOTE_TOKEN 与文件权限;若旧网关无法恢复令牌,请在主机上确认进程归属后手动重启网关,再刷新此页。'
|
|
359
|
+
|
|
347
360
|
async function startGateway() {
|
|
348
361
|
const upstream = upstreamUrlForListener(dshListen)
|
|
349
362
|
const health = await gatewayRunning()
|
|
350
363
|
if (health.running) {
|
|
364
|
+
const auth = await proxyGateway('/admin/api/state', 'GET', '')
|
|
365
|
+
if (!auth || auth.status !== 200 || auth.json.ok !== true) {
|
|
366
|
+
return { ok: false, running: true, started: false, error: GATEWAY_AUTH_HINT }
|
|
367
|
+
}
|
|
351
368
|
setGatewayEnabled(true)
|
|
352
369
|
return { ok: true, running: true, started: false }
|
|
353
370
|
}
|
|
@@ -356,6 +373,7 @@ async function startGateway() {
|
|
|
356
373
|
return { ok: false, running: false, error: '插件包缺少 gateway.cjs, 请升级插件' }
|
|
357
374
|
}
|
|
358
375
|
const port = readGatewayPort()
|
|
376
|
+
const host = readGatewayHost()
|
|
359
377
|
if (await portInUse(port)) {
|
|
360
378
|
logGateway(`端口 ${port} 已被占用, 拒绝启动`)
|
|
361
379
|
return { ok: false, running: false, error: `端口 ${port} 已被占用,请在插件页修改网关端口后重试` }
|
|
@@ -369,7 +387,7 @@ async function startGateway() {
|
|
|
369
387
|
sysd = (await runExit('systemd-run', [
|
|
370
388
|
'--user', '--unit=dsh-remote-gateway', '--service-type=exec',
|
|
371
389
|
...gatewaySystemdEnvArgs(),
|
|
372
|
-
'--setenv=PORT=' + port, '--setenv=HOST=
|
|
390
|
+
'--setenv=PORT=' + port, '--setenv=HOST=' + host, '--setenv=DSH_UPSTREAM=' + upstream,
|
|
373
391
|
'--', process.execPath, script,
|
|
374
392
|
])) === 0
|
|
375
393
|
} catch {}
|
|
@@ -390,7 +408,7 @@ async function startGateway() {
|
|
|
390
408
|
cwd: dirname(script),
|
|
391
409
|
detached: true,
|
|
392
410
|
stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'],
|
|
393
|
-
env: { ...process.env, PORT: port, DSH_UPSTREAM: upstream },
|
|
411
|
+
env: { ...process.env, PORT: port, HOST: host, DSH_UPSTREAM: upstream },
|
|
394
412
|
})
|
|
395
413
|
child.unref()
|
|
396
414
|
writeGatewayPid(child.pid)
|
|
@@ -451,7 +469,7 @@ function ensureGateway() {
|
|
|
451
469
|
/** 通过网关自身的 /admin/api/shutdown 优雅停止(不管它当初是谁拉起的); 并写入 off 防自愈拉起。 */
|
|
452
470
|
async function stopGateway() {
|
|
453
471
|
const health = await gatewayRunning()
|
|
454
|
-
if (!await killGateway(health)) return { ok: false, running: health.running, error: '
|
|
472
|
+
if (!await killGateway(health)) return { ok: false, running: health.running, error: health.running ? GATEWAY_AUTH_HINT : '网关未运行' }
|
|
455
473
|
setGatewayEnabled(false)
|
|
456
474
|
return { ok: true, running: false, bye: true }
|
|
457
475
|
}
|
|
@@ -661,9 +679,10 @@ async function serveStatic(req, res, ctx) {
|
|
|
661
679
|
// 管理控制台数据: 优先代理本地网关(设备监控/更新检查完整), 网关不可用回退插件状态
|
|
662
680
|
if (pathname === `${MOUNT}/admin/api/state`) {
|
|
663
681
|
void ensureGateway() // 自愈: 开关为 on 而网关没起来时, 后台拉起, 下个轮询即可见网关
|
|
682
|
+
const health = await gatewayRunning() // 新网关可在健康探测时恢复缺失的令牌文件
|
|
664
683
|
const localToken = gatewayToken()
|
|
665
684
|
const proxied = await proxyGateway('/admin/api/state', 'GET', '')
|
|
666
|
-
if (proxied
|
|
685
|
+
if (proxied?.status === 200 && proxied.json.ok === true) {
|
|
667
686
|
// 主机端 DSH 面板本身已登录本机用户, 管理页无需令牌门禁;
|
|
668
687
|
// 把真实网关令牌一并返回, 抽屉里直接显示并允许复制(供手机 App 使用)。
|
|
669
688
|
sendJson(res, proxied.status, { ...proxied.json, token: localToken, mode: 'gateway', via: 'gateway', gatewayInstalled })
|
|
@@ -672,8 +691,11 @@ async function serveStatic(req, res, ctx) {
|
|
|
672
691
|
sendJson(res, 200, {
|
|
673
692
|
ok: true,
|
|
674
693
|
mode: 'plugin',
|
|
694
|
+
gatewayRunning: health.running,
|
|
695
|
+
gatewayAuthError: health.running ? GATEWAY_AUTH_HINT : '',
|
|
696
|
+
gatewayVersion: health.version || '',
|
|
675
697
|
version,
|
|
676
|
-
token: localToken || '',
|
|
698
|
+
token: health.running ? '' : localToken || '',
|
|
677
699
|
gatewayInstalled,
|
|
678
700
|
platform: process.platform,
|
|
679
701
|
hostname: hostname(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.7.0-rc.
|
|
3
|
+
"version": "0.7.0-rc.3",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/public/admin.js
CHANGED
|
@@ -447,11 +447,11 @@ function render(st) {
|
|
|
447
447
|
lastState = st
|
|
448
448
|
const isPlugin = st.mode === 'plugin'
|
|
449
449
|
const isGateway = st.mode === 'gateway'
|
|
450
|
-
shownToken = st.token || token
|
|
451
|
-
$('conn-badge').textContent = t(isPlugin ? 'badge.embedded' : isGateway ? 'badge.gateway' : 'badge.connected')
|
|
450
|
+
shownToken = st.gatewayAuthError ? '' : st.token || token
|
|
451
|
+
$('conn-badge').textContent = t(st.gatewayRunning ? 'badge.gateway' : isPlugin ? 'badge.embedded' : isGateway ? 'badge.gateway' : 'badge.connected')
|
|
452
452
|
$('conn-badge').className = 'conn-badge ' + (isPlugin || isGateway ? 'on' : 'off')
|
|
453
|
-
$('conn-badge').title = t(isGateway ? 'badge.gateway.title' : 'badge.gatewayDown')
|
|
454
|
-
$('token-full').textContent = shownToken || t(isPlugin ? 'token.pluginNoGateway' : 'token.unavailable')
|
|
453
|
+
$('conn-badge').title = st.gatewayAuthError || t(isGateway ? 'badge.gateway.title' : 'badge.gatewayDown')
|
|
454
|
+
$('token-full').textContent = shownToken || t(st.gatewayAuthError ? 'token.unavailable' : isPlugin ? 'token.pluginNoGateway' : 'token.unavailable')
|
|
455
455
|
// 主机端插件模式: 显示真实令牌(复制可用), 只隐藏退出按钮; 令牌门禁本身不存在
|
|
456
456
|
$('btn-copy').classList.toggle('hidden', !shownToken)
|
|
457
457
|
$('btn-logout').classList.toggle('hidden', pluginMode)
|
|
@@ -463,7 +463,7 @@ function render(st) {
|
|
|
463
463
|
renderQr(st)
|
|
464
464
|
renderDoctor(st)
|
|
465
465
|
// 网关开关: 仅插件内嵌页提供, 网关运行/停止两种状态
|
|
466
|
-
gatewayRunning = isGateway
|
|
466
|
+
gatewayRunning = isGateway || st.gatewayRunning === true
|
|
467
467
|
$('btn-gateway').classList.toggle('hidden', !pluginMode)
|
|
468
468
|
$('btn-gateway').textContent = gatewayBusy
|
|
469
469
|
? t(gatewayRunning ? 'stopping' : 'starting')
|
|
@@ -478,15 +478,15 @@ function render(st) {
|
|
|
478
478
|
const upOk = st.upstream.reachable
|
|
479
479
|
const hero = $('admin-hero')
|
|
480
480
|
if (hero) {
|
|
481
|
-
const heroState = isGateway ? (upOk ? 'running' : 'attention') : isPlugin ? 'plugin' : 'offline'
|
|
481
|
+
const heroState = st.gatewayAuthError ? 'attention' : isGateway ? (upOk ? 'running' : 'attention') : isPlugin ? 'plugin' : 'offline'
|
|
482
482
|
hero.className = 'admin-hero ' + heroState
|
|
483
483
|
const titleKey = heroState === 'running' ? 'hero.running' : heroState === 'attention' ? 'hero.attention' : heroState === 'plugin' ? 'hero.plugin' : 'hero.offline'
|
|
484
484
|
const descKey = heroState === 'running' ? 'hero.runningDesc' : heroState === 'attention' ? 'hero.attentionDesc' : heroState === 'plugin' ? 'hero.pluginDesc' : 'hero.offlineDesc'
|
|
485
485
|
$('admin-hero-title').textContent = t(titleKey)
|
|
486
486
|
$('admin-hero-desc').textContent = heroState === 'running'
|
|
487
487
|
? t(descKey, { online: st.onlineCount || 0, requests: st.totalRequests || 0 })
|
|
488
|
-
: t(descKey)
|
|
489
|
-
$('admin-hero-status').textContent = isGateway ? (upOk ? t('stat.reachable') : t('stat.unreachable')) : t(isPlugin ? 'badge.embedded' : 'badge.gatewayDown')
|
|
488
|
+
: st.gatewayAuthError || t(descKey)
|
|
489
|
+
$('admin-hero-status').textContent = st.gatewayAuthError ? t('hero.attention') : isGateway ? (upOk ? t('stat.reachable') : t('stat.unreachable')) : t(isPlugin ? 'badge.embedded' : 'badge.gatewayDown')
|
|
490
490
|
const action = $('admin-hero-action')
|
|
491
491
|
if (action) {
|
|
492
492
|
const actionKey = heroState === 'plugin' ? 'hero.startGateway' : heroState === 'offline' ? 'hero.copyToken' : 'hero.openDevices'
|
|
@@ -506,14 +506,16 @@ function render(st) {
|
|
|
506
506
|
<div class="stat-card"><div class="v">${st.authFailures}</div><div class="k">${t('stat.authFailures')}</div></div>
|
|
507
507
|
<div class="stat-card"><div class="v">${fmtUptime(st.uptimeSec)}</div><div class="k">${t('stat.uptime', { host: st.host, port: st.port })}</div></div>`
|
|
508
508
|
|
|
509
|
-
$('device-summary').textContent = isPlugin
|
|
509
|
+
$('device-summary').textContent = st.gatewayAuthError || (isPlugin
|
|
510
510
|
? t(st.gatewayInstalled ? 'device.installedNotRunning' : 'device.noGatewayBinary')
|
|
511
|
-
: t('device.ipRefresh', { n: st.devices.length })
|
|
511
|
+
: t('device.ipRefresh', { n: st.devices.length }))
|
|
512
512
|
if (isPlugin && !st.devices.length) {
|
|
513
513
|
$('device-rows').innerHTML = ''
|
|
514
514
|
const rel = 'https://github.com/Blank-not-black/dsh-Remote/releases/latest/download/'
|
|
515
515
|
const apkBtn = `<a class="mini-btn" href="${rel}dsh-remote.apk" target="_blank" rel="noopener">${t('device.downloadApp')}</a>`
|
|
516
|
-
if (
|
|
516
|
+
if (st.gatewayAuthError) {
|
|
517
|
+
$('device-empty').textContent = st.gatewayAuthError
|
|
518
|
+
} else if (!st.gatewayInstalled) {
|
|
517
519
|
// 只有插件包真的没有内置网关程序时, 才引导下载网关
|
|
518
520
|
const isWin = /windows|win32/i.test(navigator.userAgent)
|
|
519
521
|
const gwAsset = isWin ? 'dsh-remote-win-x64.exe' : 'dsh-remote-linux-x64'
|
|
@@ -43,11 +43,26 @@
|
|
|
43
43
|
"id": "workbench-retention-2026-08",
|
|
44
44
|
"question": "后续应该如何处理工作台功能?",
|
|
45
45
|
"options": [
|
|
46
|
-
{
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
{
|
|
46
|
+
{
|
|
47
|
+
"id": "keep-improve",
|
|
48
|
+
"label": "保留并继续优化"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "keep-current",
|
|
52
|
+
"label": "保留现有功能即可"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "merge-workspaces",
|
|
56
|
+
"label": "合并到工作区功能"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "remove-gradually",
|
|
60
|
+
"label": "可以逐步移除"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "not-used",
|
|
64
|
+
"label": "尚未使用,暂时无法判断"
|
|
65
|
+
}
|
|
51
66
|
]
|
|
52
67
|
}
|
|
53
68
|
},
|
|
@@ -62,9 +77,18 @@
|
|
|
62
77
|
"id": "remote-plugin-demand-2026-08",
|
|
63
78
|
"question": "你更需要哪类远程插件能力?",
|
|
64
79
|
"options": [
|
|
65
|
-
{
|
|
66
|
-
|
|
67
|
-
|
|
80
|
+
{
|
|
81
|
+
"id": "use-plugin-features",
|
|
82
|
+
"label": "远程使用插件内具体功能"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"id": "manage-plugins-only",
|
|
86
|
+
"label": "只需要远程安装、更新、卸载插件"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"id": "no-remote-plugin",
|
|
90
|
+
"label": "没有远程使用或管理插件的需求"
|
|
91
|
+
}
|
|
68
92
|
]
|
|
69
93
|
}
|
|
70
94
|
},
|
|
@@ -97,15 +121,42 @@
|
|
|
97
121
|
"id": "meeting-asr-brand-2026-08",
|
|
98
122
|
"question": "你的手机品牌是什么?",
|
|
99
123
|
"options": [
|
|
100
|
-
{
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
{
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
{
|
|
124
|
+
{
|
|
125
|
+
"id": "xiaomi-redmi",
|
|
126
|
+
"label": "小米 / Redmi"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"id": "huawei",
|
|
130
|
+
"label": "华为"
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"id": "honor",
|
|
134
|
+
"label": "荣耀"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"id": "oppo-oneplus",
|
|
138
|
+
"label": "OPPO / 一加"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"id": "vivo-iqoo",
|
|
142
|
+
"label": "vivo / iQOO"
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"id": "samsung",
|
|
146
|
+
"label": "三星"
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"id": "meizu",
|
|
150
|
+
"label": "魅族"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"id": "google-pixel",
|
|
154
|
+
"label": "Google Pixel"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"id": "other",
|
|
158
|
+
"label": "其他品牌"
|
|
159
|
+
}
|
|
109
160
|
]
|
|
110
161
|
}
|
|
111
162
|
},
|
|
@@ -116,6 +167,16 @@
|
|
|
116
167
|
"minVersion": "",
|
|
117
168
|
"maxVersion": "",
|
|
118
169
|
"publishedAt": "2026-08-31T12:38:43+08:00"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"id": "2026-09-19-0.7.0-rc.1-plugin-center",
|
|
173
|
+
"title": "0.7.0-rc.1 测试版发布:插件中心上线,欢迎体验反馈",
|
|
174
|
+
"content": "大家好,dsh-Remote 0.7.0-rc.1 测试版现已发布!此前征集的插件管理需求,在这个版本中有了第一步落地。\n\n本次主要新增「插件中心」,手机端和桌面端都可以从「设置 → 插件中心」进入:\n\n- 查看当前 DSH 环境中已安装的插件、版本和状态;\n- 在「发现插件」中搜索 npm 插件目录,查看详情并安装指定版本;\n- 更新、卸载插件,修改插件的启用或停用配置;\n- 查看操作进度和日志,主机侧会保存操作记录并备份相关配置。\n\n体验前请同步更新主机侧的 DSH Remote 插件与手机 App,使用独立网关的用户也请更新网关。插件启用或停用后,需要重启 DSH 才会生效。当前插件发现与管理能力仍在持续完善中,不代表所有第三方插件都已完成兼容性验证。\n\n欢迎愿意参与测试的朋友下载更新,体验后到 QQ 交流群反馈!无论是安装或更新失败、界面问题、兼容性情况,还是你希望加入的插件与功能,都欢迎告诉我们。反馈时可以附上设备与系统版本、DSH/Remote 版本、插件名称、操作步骤,以及相关截图或错误日志,方便我们复现和改进。\n\nQQ 用户交流群:1106138825\n\n这是 RC 测试版,欢迎大家一起帮助完善 0.7.0。感谢支持与反馈!",
|
|
175
|
+
"minVersion": "",
|
|
176
|
+
"maxVersion": "",
|
|
177
|
+
"publishedAt": "2026-09-19T08:03:06+08:00",
|
|
178
|
+
"actionUrl": "https://github.com/Blank-not-black/dsh-Remote/releases/tag/v0.7.0-rc.1",
|
|
179
|
+
"actionText": "下载 0.7.0-rc.1 测试版"
|
|
119
180
|
}
|
|
120
181
|
]
|
|
121
182
|
}
|
package/public/app.js
CHANGED
|
@@ -132,6 +132,7 @@ const CUSTOM_SELECT_TITLES = {
|
|
|
132
132
|
'session-sort': 'sessions.sortLabel',
|
|
133
133
|
'fs-workspace': 'workspace.select',
|
|
134
134
|
'mobile-enter-action': 'settings.mobileEnterTitle',
|
|
135
|
+
'busy-send-mode': 'settings.busySendTitle',
|
|
135
136
|
'bg-interval': 'settings.bgIntervalTitle',
|
|
136
137
|
'new-session-workspace': 'workspace.select'
|
|
137
138
|
}
|
|
@@ -606,7 +607,9 @@ async function rpc(method, payload = {}, timeoutMs = 45000) {
|
|
|
606
607
|
if (!full?.result) throw new Error(t('err.badResponse'))
|
|
607
608
|
if (!full.result.ok) {
|
|
608
609
|
const err = full.result.error || {}
|
|
609
|
-
|
|
610
|
+
const error = new Error(err.message || t('err.dshError'))
|
|
611
|
+
error.rpcRejected = true
|
|
612
|
+
throw error
|
|
610
613
|
}
|
|
611
614
|
return full.result.value
|
|
612
615
|
}
|
|
@@ -2416,6 +2419,7 @@ function renderSessionSub() {
|
|
|
2416
2419
|
|
|
2417
2420
|
/** 顶栏状态: 运行中=蓝色流动渐变, 中断/出错=橙红渐变, 空闲=原样式 */
|
|
2418
2421
|
function updateSessionStatus() {
|
|
2422
|
+
renderComposerSend()
|
|
2419
2423
|
const s = state.byId.get(state.current)
|
|
2420
2424
|
const head = $('session-head')
|
|
2421
2425
|
if (!head) return
|
|
@@ -3248,33 +3252,153 @@ async function sendSessionText(text) {
|
|
|
3248
3252
|
return sendSessionContent(text, [])
|
|
3249
3253
|
}
|
|
3250
3254
|
|
|
3251
|
-
|
|
3255
|
+
let composerSending = false
|
|
3256
|
+
const composerSendFeedback = new Map()
|
|
3257
|
+
const steeringInFlight = new Set()
|
|
3258
|
+
function composerContext() { return `${state.server}\0${state.current}` }
|
|
3259
|
+
function steerSendingEnabled() { return LS.get('steerSendingEnabled', '0') === '1' }
|
|
3260
|
+
function composerIsBusy() {
|
|
3261
|
+
return !!state.byId.get(state.current)?.running || (state.queues[state.current] || []).some(item => item.placement === 'queued')
|
|
3262
|
+
}
|
|
3263
|
+
function composerSendMode(alternate = false) {
|
|
3264
|
+
if (!steerSendingEnabled() || !composerIsBusy()) return 'queue'
|
|
3265
|
+
const preferred = LS.get('busySendMode', 'queue') === 'steer' ? 'steer' : 'queue'
|
|
3266
|
+
return alternate ? (preferred === 'queue' ? 'steer' : 'queue') : preferred
|
|
3267
|
+
}
|
|
3268
|
+
function renderComposerSend() {
|
|
3269
|
+
const enabled = steerSendingEnabled() && composerIsBusy()
|
|
3270
|
+
const mode = composerSendMode()
|
|
3271
|
+
for (const id of ['btn-send', 'btn-fs-send']) {
|
|
3272
|
+
const button = $(id)
|
|
3273
|
+
if (!button) continue
|
|
3274
|
+
if (composerSending || !button.classList.contains('send-hold-ready')) button.textContent = t(composerSending ? 'send.submitting' : enabled ? `send.${mode}Button` : 'composer.send')
|
|
3275
|
+
button.disabled = composerSending
|
|
3276
|
+
button.classList.toggle('steer-mode', enabled && mode === 'steer')
|
|
3277
|
+
button.title = t(enabled ? 'send.holdHint' : 'composer.send')
|
|
3278
|
+
button.setAttribute('aria-label', button.textContent)
|
|
3279
|
+
}
|
|
3280
|
+
const feedback = composerSendFeedback.get(composerContext())
|
|
3281
|
+
const node = $('composer-send-status')
|
|
3282
|
+
if (node) {
|
|
3283
|
+
node.textContent = feedback ? t(feedback.key, { msg: feedback.message || '' }) : enabled ? t('send.holdHint') : ''
|
|
3284
|
+
node.classList.toggle('hidden', !node.textContent)
|
|
3285
|
+
node.dataset.phase = feedback?.phase || 'hint'
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
function setComposerSendFeedback(context, key, phase, message = '') {
|
|
3289
|
+
composerSendFeedback.set(context, { key, phase, message })
|
|
3290
|
+
if (composerSendFeedback.size > 100) composerSendFeedback.delete(composerSendFeedback.keys().next().value)
|
|
3291
|
+
renderComposerSend()
|
|
3292
|
+
}
|
|
3293
|
+
// No preflight or automatic retry: an ambiguous timeout may already have delivered the message.
|
|
3294
|
+
async function submitSteer(method, payload, context) {
|
|
3295
|
+
if (steeringInFlight.has(context)) {
|
|
3296
|
+
if (composerContext() === context) toast(t('send.steerPending'), 'err')
|
|
3297
|
+
return null
|
|
3298
|
+
}
|
|
3299
|
+
steeringInFlight.add(context)
|
|
3300
|
+
setComposerSendFeedback(context, 'send.steerPending', 'pending')
|
|
3301
|
+
const slow = setTimeout(() => setComposerSendFeedback(context, 'send.steerSlow', 'pending'), 3000)
|
|
3302
|
+
try {
|
|
3303
|
+
const result = await rpc(method, payload)
|
|
3304
|
+
if (!result?.accepted) {
|
|
3305
|
+
setComposerSendFeedback(context, 'send.steerUnconfirmed', 'unknown')
|
|
3306
|
+
return result
|
|
3307
|
+
}
|
|
3308
|
+
setComposerSendFeedback(context, 'send.steerAccepted', 'accepted')
|
|
3309
|
+
if (composerContext() === context) toast(t('send.steerAccepted'), 'ok')
|
|
3310
|
+
return result
|
|
3311
|
+
} catch (error) {
|
|
3312
|
+
setComposerSendFeedback(context, error.rpcRejected || error.message === 'AUTH' ? 'send.steerRejected' : 'send.steerUnconfirmed', error.rpcRejected ? 'error' : 'unknown', error.message)
|
|
3313
|
+
if (error.message === 'AUTH' && composerContext() === context) authFailure()
|
|
3314
|
+
return null
|
|
3315
|
+
} finally { clearTimeout(slow); steeringInFlight.delete(context) }
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
function bindComposerSend(button) {
|
|
3319
|
+
let press = null
|
|
3320
|
+
let suppressClickUntil = 0
|
|
3321
|
+
const cancel = () => {
|
|
3322
|
+
if (!press) return
|
|
3323
|
+
clearTimeout(press.timer)
|
|
3324
|
+
press = null
|
|
3325
|
+
suppressClickUntil = Date.now() + 1000
|
|
3326
|
+
button.classList.remove('send-hold-ready')
|
|
3327
|
+
renderComposerSend()
|
|
3328
|
+
}
|
|
3329
|
+
button.addEventListener('pointerdown', event => {
|
|
3330
|
+
if (event.button !== 0 || event.isPrimary === false || composerSending || !steerSendingEnabled() || !composerIsBusy()) return
|
|
3331
|
+
cancel()
|
|
3332
|
+
suppressClickUntil = 0
|
|
3333
|
+
press = { id: event.pointerId, x: event.clientX, y: event.clientY, context: composerContext(), mode: composerSendMode(), alternate: composerSendMode(true), ready: false }
|
|
3334
|
+
press.timer = setTimeout(() => {
|
|
3335
|
+
if (!press || composerSending || composerContext() !== press.context) { cancel(); return }
|
|
3336
|
+
press.ready = true
|
|
3337
|
+
button.classList.add('send-hold-ready')
|
|
3338
|
+
button.textContent = t(press.alternate === 'steer' ? 'send.releaseSteer' : 'send.releaseQueue')
|
|
3339
|
+
}, 450)
|
|
3340
|
+
button.setPointerCapture?.(event.pointerId)
|
|
3341
|
+
})
|
|
3342
|
+
button.addEventListener('pointermove', event => {
|
|
3343
|
+
if (press && Math.hypot(event.clientX - press.x, event.clientY - press.y) > 12) cancel()
|
|
3344
|
+
})
|
|
3345
|
+
button.addEventListener('pointerup', event => {
|
|
3346
|
+
if (!press || event.pointerId !== press.id) return
|
|
3347
|
+
const intent = press
|
|
3348
|
+
cancel()
|
|
3349
|
+
event.preventDefault()
|
|
3350
|
+
if (intent.context === composerContext() && steerSendingEnabled()) void sendMessage(intent.ready ? intent.alternate : intent.mode)
|
|
3351
|
+
})
|
|
3352
|
+
for (const name of ['pointercancel', 'lostpointercapture', 'blur']) button.addEventListener(name, cancel)
|
|
3353
|
+
button.addEventListener('contextmenu', event => { if (press || Date.now() < suppressClickUntil) event.preventDefault() })
|
|
3354
|
+
button.addEventListener('keydown', () => { cancel(); suppressClickUntil = 0 })
|
|
3355
|
+
button.addEventListener('click', () => { if (Date.now() >= suppressClickUntil) void sendMessage() })
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
async function sendSessionContent(text, images, requestedMode = 'queue') {
|
|
3252
3359
|
const clean = String(text || '').trim()
|
|
3253
|
-
if ((!clean && !images.length) || !state.current) return false
|
|
3360
|
+
if ((!clean && !images.length) || !state.current || composerSending) return false
|
|
3254
3361
|
const sessionId = state.current
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3362
|
+
const context = composerContext()
|
|
3363
|
+
const mode = requestedMode === 'steer' && steerSendingEnabled() && composerIsBusy() ? 'steer' : 'queue'
|
|
3364
|
+
if (mode === 'steer' && clean.startsWith('/')) {
|
|
3365
|
+
setComposerSendFeedback(context, 'send.steerSlash', 'error')
|
|
3366
|
+
return false
|
|
3258
3367
|
}
|
|
3368
|
+
composerSending = true
|
|
3259
3369
|
const buttons = [$('btn-send'), $('btn-fs-send')].filter(Boolean)
|
|
3260
3370
|
buttons.forEach(button => { button.disabled = true })
|
|
3261
3371
|
state.pendingPrompts.add(sessionId)
|
|
3262
3372
|
try {
|
|
3373
|
+
if (mode === 'steer') setComposerSendFeedback(context, 'send.steerPending', 'pending')
|
|
3374
|
+
else { composerSendFeedback.delete(context); renderComposerSend() }
|
|
3375
|
+
if (images.length === 0 && clean && await runSlashCommand(clean)) {
|
|
3376
|
+
state.sessionActivity.add(sessionId)
|
|
3377
|
+
return true
|
|
3378
|
+
}
|
|
3263
3379
|
const content = [...await encodeComposerImagesFor(images)]
|
|
3264
3380
|
if (clean) content.push({ type: 'text', text: clean })
|
|
3381
|
+
if (composerContext() !== context) {
|
|
3382
|
+
setComposerSendFeedback(context, 'send.contextChanged', 'error')
|
|
3383
|
+
return false
|
|
3384
|
+
}
|
|
3265
3385
|
setSessionRecovery('resuming')
|
|
3266
|
-
const
|
|
3386
|
+
const payload = {
|
|
3267
3387
|
sessionId,
|
|
3268
|
-
mode
|
|
3388
|
+
mode,
|
|
3269
3389
|
content
|
|
3270
|
-
}
|
|
3390
|
+
}
|
|
3391
|
+
const v = mode === 'steer'
|
|
3392
|
+
? await submitSteer('session.prompt', payload, context)
|
|
3393
|
+
: await safeRpc('session.prompt', payload, t('send.failed'))
|
|
3394
|
+
if (composerContext() !== context) return !!v?.accepted
|
|
3271
3395
|
if (v?.accepted) {
|
|
3272
3396
|
state.sessionActivity.add(sessionId)
|
|
3273
|
-
if (
|
|
3397
|
+
if (composerContext() === context) {
|
|
3274
3398
|
setSessionRecovery('ready')
|
|
3275
3399
|
noteSessionTurnTime(sessionId, Date.now())
|
|
3276
3400
|
renderSessions()
|
|
3277
|
-
toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
|
|
3401
|
+
if (mode !== 'steer') toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
|
|
3278
3402
|
}
|
|
3279
3403
|
return true
|
|
3280
3404
|
}
|
|
@@ -3286,14 +3410,17 @@ async function sendSessionContent(text, images) {
|
|
|
3286
3410
|
if (state.current === sessionId) setSessionRecovery('error')
|
|
3287
3411
|
return false
|
|
3288
3412
|
} catch (e) {
|
|
3289
|
-
if (
|
|
3413
|
+
if (mode === 'steer') setComposerSendFeedback(context, 'send.steerRejected', 'error', e?.message || String(e))
|
|
3414
|
+
if (composerContext() === context) {
|
|
3290
3415
|
setSessionRecovery('error', e?.message)
|
|
3291
3416
|
toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
|
|
3292
3417
|
}
|
|
3293
3418
|
return false
|
|
3294
3419
|
} finally {
|
|
3295
3420
|
state.pendingPrompts.delete(sessionId)
|
|
3421
|
+
composerSending = false
|
|
3296
3422
|
buttons.forEach(button => { button.disabled = false })
|
|
3423
|
+
renderComposerSend()
|
|
3297
3424
|
}
|
|
3298
3425
|
}
|
|
3299
3426
|
|
|
@@ -3304,16 +3431,18 @@ async function encodeComposerImagesFor(images) {
|
|
|
3304
3431
|
})))
|
|
3305
3432
|
}
|
|
3306
3433
|
|
|
3307
|
-
async function sendMessage() {
|
|
3434
|
+
async function sendMessage(requestedMode) {
|
|
3308
3435
|
const input = $('composer-input')
|
|
3309
|
-
const
|
|
3436
|
+
const originalText = input.value
|
|
3437
|
+
const text = originalText.trim()
|
|
3438
|
+
const context = composerContext()
|
|
3310
3439
|
const images = state.composerImages.slice()
|
|
3311
3440
|
if ((!text && !images.length) || !state.current) return
|
|
3312
3441
|
if (images.length && text.startsWith('/')) { toast(t('composer.imageSlashUnsupported'), 'err'); return }
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
autosize(input)
|
|
3316
|
-
clearComposerImages()
|
|
3442
|
+
const mode = typeof requestedMode === 'string' ? requestedMode : composerSendMode()
|
|
3443
|
+
if (await sendSessionContent(text, images, mode) && composerContext() === context) {
|
|
3444
|
+
if (input.value === originalText) { input.value = ''; autosize(input) }
|
|
3445
|
+
if (state.composerImages.length === images.length && images.every((item, i) => item === state.composerImages[i])) clearComposerImages()
|
|
3317
3446
|
}
|
|
3318
3447
|
}
|
|
3319
3448
|
|
|
@@ -3842,20 +3971,21 @@ function queuePreview(item) {
|
|
|
3842
3971
|
}
|
|
3843
3972
|
async function steerQueueItem(itemId) {
|
|
3844
3973
|
const sessionId = state.current
|
|
3974
|
+
const context = composerContext()
|
|
3845
3975
|
const key = `${sessionId}:${itemId}`
|
|
3846
3976
|
const s = state.byId.get(sessionId)
|
|
3847
3977
|
if (!sessionId || !s?.running || state.queueSteering[key]) return
|
|
3848
3978
|
state.queueSteering[key] = true
|
|
3849
3979
|
renderQueue()
|
|
3850
3980
|
try {
|
|
3851
|
-
|
|
3852
|
-
if (v?.accepted) toast(t('queue.steerSubmitted'), 'ok')
|
|
3981
|
+
await submitSteer('session.updateQueue', { sessionId, itemId, action: { kind: 'steer' } }, context)
|
|
3853
3982
|
} finally {
|
|
3854
3983
|
delete state.queueSteering[key]
|
|
3855
3984
|
renderQueue()
|
|
3856
3985
|
}
|
|
3857
3986
|
}
|
|
3858
3987
|
function renderQueue() {
|
|
3988
|
+
renderComposerSend()
|
|
3859
3989
|
const s = state.byId.get(state.current)
|
|
3860
3990
|
if (!s) return
|
|
3861
3991
|
const items = (state.queues[state.current] || []).filter(item => item?.placement === 'queued')
|
|
@@ -6960,8 +7090,8 @@ function bindUi() {
|
|
|
6960
7090
|
$('rename-confirm').addEventListener('click', confirmRenameSession)
|
|
6961
7091
|
$('rename-session-input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.isComposing) confirmRenameSession() })
|
|
6962
7092
|
$('modal-rename').addEventListener('click', e => { if (e.target === $('modal-rename')) closeRenameSession() })
|
|
6963
|
-
$('btn-send')
|
|
6964
|
-
$('btn-fs-send')
|
|
7093
|
+
bindComposerSend($('btn-send'))
|
|
7094
|
+
bindComposerSend($('btn-fs-send'))
|
|
6965
7095
|
$('btn-plus').addEventListener('click', toggleComposerMenu)
|
|
6966
7096
|
$('btn-image').addEventListener('click', toggleComposerImageMenu)
|
|
6967
7097
|
$('composer-image-menu').addEventListener('click', (e) => {
|
|
@@ -7139,11 +7269,23 @@ function bindUi() {
|
|
|
7139
7269
|
})
|
|
7140
7270
|
$('btn-reset').addEventListener('click', () => {
|
|
7141
7271
|
if (!confirm(t('settings.confirmReset'))) return
|
|
7142
|
-
LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY); LS.del(ANNOUNCEMENT_VOTES_KEY)
|
|
7272
|
+
LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del('steerSendingEnabled'); LS.del('busySendMode'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY); LS.del(ANNOUNCEMENT_VOTES_KEY)
|
|
7143
7273
|
if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
|
|
7144
7274
|
location.reload()
|
|
7145
7275
|
})
|
|
7146
7276
|
$('mobile-enter-action').value = mobileEnterAction()
|
|
7277
|
+
$('opt-steer-send').checked = steerSendingEnabled()
|
|
7278
|
+
$('busy-send-mode').value = LS.get('busySendMode', 'queue') === 'steer' ? 'steer' : 'queue'
|
|
7279
|
+
$('busy-send-mode').disabled = !steerSendingEnabled()
|
|
7280
|
+
$('opt-steer-send').addEventListener('change', event => {
|
|
7281
|
+
LS.set('steerSendingEnabled', event.target.checked ? '1' : '0')
|
|
7282
|
+
$('busy-send-mode').disabled = !event.target.checked
|
|
7283
|
+
renderComposerSend()
|
|
7284
|
+
})
|
|
7285
|
+
$('busy-send-mode').addEventListener('change', event => {
|
|
7286
|
+
LS.set('busySendMode', event.target.value === 'steer' ? 'steer' : 'queue')
|
|
7287
|
+
renderComposerSend()
|
|
7288
|
+
})
|
|
7147
7289
|
$('mobile-enter-action').addEventListener('change', (e) => {
|
|
7148
7290
|
const action = e.target.value === 'send' ? 'send' : 'newline'
|
|
7149
7291
|
LS.set('mobileEnterAction', action)
|
package/public/index.html
CHANGED
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
<button id="btn-rename-session" class="icon-btn hidden" data-i18n-title="session.rename" data-i18n-aria="session.rename">✎</button>
|
|
114
114
|
<button id="btn-archive-session" class="mini-btn hidden" data-i18n="session.archive">归档</button>
|
|
115
115
|
<button id="btn-stats" class="icon-btn" data-i18n-title="stats.title" data-i18n-aria="stats.title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 19V9M12 19V5M19 19v-7M3 19h18"/></svg></button>
|
|
116
|
-
<button id="btn-fs-send" class="session-send-btn hidden" type="button"
|
|
116
|
+
<button id="btn-fs-send" class="session-send-btn hidden" type="button">发送</button>
|
|
117
117
|
<button id="btn-cancel" class="danger-btn hidden" data-i18n="session.stop">停止</button>
|
|
118
118
|
</div>
|
|
119
119
|
|
|
@@ -173,6 +173,7 @@
|
|
|
173
173
|
<button type="button" class="composer-image-option" data-image-source="CAMERA" role="menuitem"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8.5h3l1.5-2h7L17 8.5h3v10H4z"/><circle cx="12" cy="13.5" r="3.2"/></svg><span data-i18n="composer.takePhoto">拍照</span></button>
|
|
174
174
|
<button type="button" class="composer-image-option" data-image-source="PHOTOS" role="menuitem"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="4" width="17" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.2"/><path d="m5 17 4.5-4.5 3 3 2-2L19 17"/></svg><span data-i18n="composer.choosePhoto">相册</span></button>
|
|
175
175
|
</div>
|
|
176
|
+
<div id="composer-send-status" class="composer-send-status hidden" role="status" aria-live="polite" aria-atomic="true"></div>
|
|
176
177
|
<div id="composer-attachments" class="composer-attachments hidden"></div>
|
|
177
178
|
<div class="composer">
|
|
178
179
|
<button id="btn-plus" class="plus-btn" data-i18n-aria="a11y.more" data-i18n-title="a11y.moreTitle">+</button>
|
|
@@ -183,7 +184,7 @@
|
|
|
183
184
|
</button>
|
|
184
185
|
</div>
|
|
185
186
|
<button id="btn-image" class="composer-image-btn" type="button" data-i18n-title="composer.addImage" data-i18n-aria="composer.addImage"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="4" width="17" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.2"/><path d="m5 17 4.5-4.5 3 3 2-2L19 17"/></svg></button>
|
|
186
|
-
<button id="btn-send" class="send-btn"
|
|
187
|
+
<button id="btn-send" class="send-btn" type="button">发送</button>
|
|
187
188
|
</div>
|
|
188
189
|
<input id="composer-camera-input" class="hidden" type="file" accept="image/*" capture="environment">
|
|
189
190
|
<input id="composer-gallery-input" class="hidden" type="file" accept="image/*" multiple>
|
|
@@ -375,6 +376,17 @@
|
|
|
375
376
|
<option value="send" data-i18n="settings.mobileEnterSend">回车发送</option>
|
|
376
377
|
</select>
|
|
377
378
|
</div>
|
|
379
|
+
<div class="setting-row">
|
|
380
|
+
<div><div class="setting-name" data-i18n="settings.steerSendTitle">启用长按插队发送</div><div class="setting-desc" data-i18n="settings.steerSendDesc">繁忙时长按发送切换方式,松开发送;移动手指取消。默认关闭。</div></div>
|
|
381
|
+
<label class="switch"><input type="checkbox" id="opt-steer-send" data-i18n-aria="settings.steerSendTitle"><span class="slider"></span></label>
|
|
382
|
+
</div>
|
|
383
|
+
<div class="setting-row">
|
|
384
|
+
<div><div class="setting-name" data-i18n="settings.busySendTitle">繁忙时默认发送方式</div><div class="setting-desc" data-i18n="settings.busySendDesc">单击和回车使用默认方式,长按使用另一种;空闲时正常发送。</div></div>
|
|
385
|
+
<select id="busy-send-mode" class="setting-select" data-i18n-aria="settings.busySendTitle">
|
|
386
|
+
<option value="queue" data-i18n="send.queueButton">排队发送</option>
|
|
387
|
+
<option value="steer" data-i18n="send.steerButton">插队发送</option>
|
|
388
|
+
</select>
|
|
389
|
+
</div>
|
|
378
390
|
<div class="setting-row">
|
|
379
391
|
<div><div class="setting-name" data-i18n="presets.title">预设提示词</div><div class="setting-desc" data-i18n="presets.desc">常用提示词,对话界面一键插入(最多 20 条)</div></div>
|
|
380
392
|
<button id="btn-preset-add" class="mini-btn" data-i18n="presets.add">新增</button>
|
|
@@ -907,6 +919,17 @@
|
|
|
907
919
|
<script>
|
|
908
920
|
window.APP_STR = {
|
|
909
921
|
zh: {
|
|
922
|
+
'settings.steerSendTitle': '启用长按插队发送', 'settings.steerSendDesc': '繁忙时长按发送切换方式,松开发送;移动手指取消。默认关闭。',
|
|
923
|
+
'settings.busySendTitle': '繁忙时默认发送方式', 'settings.busySendDesc': '单击和回车使用默认方式,长按使用另一种;空闲时正常发送。',
|
|
924
|
+
'send.queueButton': '排队发送', 'send.steerButton': '插队发送', 'send.submitting': '提交中…',
|
|
925
|
+
'send.holdHint': '长按切换发送方式,松开发送;移动取消', 'send.releaseSteer': '松开插队', 'send.releaseQueue': '松开排队',
|
|
926
|
+
'send.steerPending': '正在提交插队请求,尚未确认;请勿认为当前任务已打断。',
|
|
927
|
+
'send.steerSlow': '仍在等待 DSH 确认插队,请勿重复发送;当前任务可能仍在执行。',
|
|
928
|
+
'send.steerAccepted': 'DSH 已接受插队请求;不代表当前工具已中断。',
|
|
929
|
+
'send.steerUnconfirmed': '插队结果未确认,消息可能已送达。请先检查会话与队列,勿直接重复发送。',
|
|
930
|
+
'send.steerRejected': '插队未成功:{msg}。未自动改为排队。',
|
|
931
|
+
'send.steerSlash': '斜杠命令不支持插队发送,请使用排队发送执行命令。',
|
|
932
|
+
'send.contextChanged': '已切换会话或服务器,本次消息未发送。',
|
|
910
933
|
'a11y.hostAdmin': '主机管理', 'a11y.refresh': '刷新', 'a11y.more': '更多操作', 'a11y.moreTitle': '指令/权限/模型',
|
|
911
934
|
'conn.on': '已连接', 'conn.off': '未连接', 'conn.reconnecting': '连接中断,正在重连…',
|
|
912
935
|
'conn.reconnectIn': '重连中 {n}s', 'conn.offline': '离线', 'conn.failed': '连接失败',
|
|
@@ -1139,6 +1162,17 @@
|
|
|
1139
1162
|
'time.justNow': '刚刚', 'time.minAgo': ' 分钟前', 'time.hourAgo': ' 小时前'
|
|
1140
1163
|
},
|
|
1141
1164
|
en: {
|
|
1165
|
+
'settings.steerSendTitle': 'Enable hold-to-steer sending', 'settings.steerSendDesc': 'While busy, hold to switch modes and release to send. Move to cancel. Off by default.',
|
|
1166
|
+
'settings.busySendTitle': 'Default sending mode while busy', 'settings.busySendDesc': 'Click and Enter use the default; hold uses the other mode. Normal sending when idle.',
|
|
1167
|
+
'send.queueButton': 'Queue', 'send.steerButton': 'Steer', 'send.submitting': 'Submitting…',
|
|
1168
|
+
'send.holdHint': 'Hold to switch modes; release to send, move to cancel', 'send.releaseSteer': 'Release to steer', 'send.releaseQueue': 'Release to queue',
|
|
1169
|
+
'send.steerPending': 'Submitting steering request, not yet confirmed. The current task may still be running.',
|
|
1170
|
+
'send.steerSlow': 'Still waiting for DSH to confirm. Do not resend; the current task may still be running.',
|
|
1171
|
+
'send.steerAccepted': 'DSH accepted the steering request. The current tool may still be running.',
|
|
1172
|
+
'send.steerUnconfirmed': 'Steering is unconfirmed; the message may have arrived. Check the conversation and queue before resending.',
|
|
1173
|
+
'send.steerRejected': 'Steering failed: {msg}. Not automatically queued.',
|
|
1174
|
+
'send.steerSlash': 'Slash commands cannot be sent as steering. Use queue mode to execute the command.',
|
|
1175
|
+
'send.contextChanged': 'Conversation or server changed; this message was not sent.',
|
|
1142
1176
|
'a11y.hostAdmin': 'Host admin', 'a11y.refresh': 'Refresh', 'a11y.more': 'More actions', 'a11y.moreTitle': 'Commands / Permissions / Models',
|
|
1143
1177
|
'conn.on': 'Connected', 'conn.off': 'Offline', 'conn.reconnecting': 'Connection lost, reconnecting…',
|
|
1144
1178
|
'conn.reconnectIn': 'Reconnecting {n}s', 'conn.offline': 'Offline', 'conn.failed': 'Connection failed',
|
package/public/plugin.js
CHANGED
|
@@ -81,8 +81,9 @@ function setBusy(value) {
|
|
|
81
81
|
|
|
82
82
|
function render(st) {
|
|
83
83
|
latest = st
|
|
84
|
-
const gateway = st.mode === 'gateway'
|
|
85
|
-
const
|
|
84
|
+
const gateway = st.mode === 'gateway' || st.gatewayRunning === true
|
|
85
|
+
const authError = st.gatewayAuthError || ''
|
|
86
|
+
const healthy = gateway && !authError && st.upstream?.reachable !== false
|
|
86
87
|
const installed = st.gatewayInstalled !== false
|
|
87
88
|
const hero = $('plugin-hero')
|
|
88
89
|
hero.classList.toggle('ok', healthy)
|
|
@@ -92,18 +93,18 @@ function render(st) {
|
|
|
92
93
|
text('plugin-status', healthy ? '系统运行正常' : gateway ? '网关需要关注' : installed ? '网关待启动' : 'DSH 已连接')
|
|
93
94
|
text('plugin-status-desc', healthy
|
|
94
95
|
? `${st.onlineCount || 0} 台设备在线 · 最近请求 ${st.totalRequests || 0} 次`
|
|
95
|
-
: gateway ? 'DSH 上游暂时不可达,请打开控制台诊断' : installed ? '本地网关尚未运行,启动后即可远程连接' : '插件已连接 DSH,但未检测到网关程序')
|
|
96
|
+
: authError || (gateway ? 'DSH 上游暂时不可达,请打开控制台诊断' : installed ? '本地网关尚未运行,启动后即可远程连接' : '插件已连接 DSH,但未检测到网关程序'))
|
|
96
97
|
text('plugin-status-meta', healthy ? '本地服务可用' : gateway ? '需要查看诊断' : installed ? '本地服务未启动' : '仅 DSH 内嵌模式')
|
|
97
98
|
|
|
98
99
|
const primary = $('plugin-primary')
|
|
99
|
-
primary.textContent =
|
|
100
|
-
primary.dataset.action =
|
|
100
|
+
primary.textContent = gateway ? '打开控制台' : installed ? '启动网关' : '查看控制台'
|
|
101
|
+
primary.dataset.action = gateway ? 'console' : installed ? 'start' : 'console'
|
|
101
102
|
text('plugin-toggle-label', gateway ? '停止网关' : '启动网关')
|
|
102
103
|
$('plugin-toggle-icon')?.setAttribute('data-morph-state', gateway ? 'open' : 'closed')
|
|
103
104
|
$('plugin-toggle').classList.toggle('hidden', !installed)
|
|
104
105
|
|
|
105
106
|
text('plugin-version', st.version ? 'v' + st.version : '—')
|
|
106
|
-
text('plugin-gateway-version', gateway ? (
|
|
107
|
+
text('plugin-gateway-version', gateway ? ('v' + (st.gatewayVersion || st.version || '未知')) : '未运行')
|
|
107
108
|
text('plugin-devices', `${st.onlineCount || 0} / ${st.deviceCount || 0}`)
|
|
108
109
|
text('plugin-host', (st.lanIPs || []).find(x => x && x !== '127.0.0.1') || st.host || '127.0.0.1')
|
|
109
110
|
if (!gateway) {
|
|
@@ -134,7 +135,7 @@ async function load() {
|
|
|
134
135
|
async function toggleGateway() {
|
|
135
136
|
if (busy) return
|
|
136
137
|
setBusy(true)
|
|
137
|
-
const action = latest?.mode === 'gateway' ? 'stop' : 'start'
|
|
138
|
+
const action = latest?.mode === 'gateway' || latest?.gatewayRunning === true ? 'stop' : 'start'
|
|
138
139
|
try {
|
|
139
140
|
const res = await fetch(`${API}/gateway`, {
|
|
140
141
|
method: 'POST',
|
|
@@ -142,6 +143,8 @@ async function toggleGateway() {
|
|
|
142
143
|
body: JSON.stringify({ action }),
|
|
143
144
|
})
|
|
144
145
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
146
|
+
const out = await res.json()
|
|
147
|
+
if (!out.ok) throw new Error(out.error || '网关操作未完成')
|
|
145
148
|
await new Promise(resolve => setTimeout(resolve, 650))
|
|
146
149
|
await load()
|
|
147
150
|
} catch (e) {
|
package/public/styles.css
CHANGED
|
@@ -1319,3 +1319,9 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
|
|
|
1319
1319
|
.update-download-progress { margin-top: 12px; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
|
1320
1320
|
.update-download-progress progress { display: block; width: 100%; height: 10px; margin: 7px 0; accent-color: var(--dsr-accent, #2563eb); }
|
|
1321
1321
|
#update-download-detail { color: var(--dsr-text-muted); font-variant-numeric: tabular-nums; }
|
|
1322
|
+
/* Sending intent remains visible while waiting for a steering acknowledgement. */
|
|
1323
|
+
.composer-send-status { padding: 6px 12px; font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
|
|
1324
|
+
.composer-send-status[data-phase="pending"], .composer-send-status[data-phase="unknown"], .composer-send-status[data-phase="error"] { color: var(--dsr-accent-strong); font-weight: 600; }
|
|
1325
|
+
#btn-send, #btn-fs-send { touch-action: none; user-select: none; -webkit-touch-callout: none; }
|
|
1326
|
+
#btn-send.steer-mode, #btn-fs-send.steer-mode { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
1327
|
+
.send-hold-ready { box-shadow: 0 0 0 3px var(--dsr-accent-strong); }
|
package/public/update.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.7.0-rc.
|
|
2
|
+
"version": "0.7.0-rc.3",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"sha256": "
|
|
5
|
-
"releasedAt": "2026-09-
|
|
6
|
-
"notes": "0.7.0-rc.
|
|
4
|
+
"sha256": "482490d9801a9e5c947fe02e3c7636b39c7089db8b38f55d732a963d848ee3fa",
|
|
5
|
+
"releasedAt": "2026-09-20T09:53:01.965Z",
|
|
6
|
+
"notes": "0.7.0-rc.3:手机端新增可选长按插队发送,默认关闭,可设置繁忙时默认排队或插队;直接提交插队请求,等待、超时与已接受状态分别提示,避免误认为工具已中断,失败时保留草稿。合入鸿蒙客户端、平板分栏与服务卡片源码,完善跨端接续接口;修复 WebSocket Ping 跨包组帧及接续接口非法输入处理。保留 rc.2 令牌恢复、认证状态和 IPv6 监听修复。本次提供 Android APK 与网关,未提供签名鸿蒙 HAP;鸿蒙大文件哈希、断点续传、快速切换服务器及 Android/Linux/fnOS 真机场景仍待验收。",
|
|
7
7
|
"history": [
|
|
8
8
|
{
|
|
9
9
|
"version": "0.6.26",
|
package/public/version.json
CHANGED