dsh-plugin-weapp-gateway 0.8.8 → 0.8.9
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/README.md +9 -0
- package/lib/client.js +92 -0
- package/lib/completion-notifications.mjs +131 -0
- package/lib/index.mjs +37 -3
- package/lib/plugin-updates.mjs +89 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
# dsh-plugin-weapp-gateway
|
|
6
6
|
|
|
7
|
+
### v0.8.9 · 插件在线更新
|
|
8
|
+
|
|
9
|
+
- 打开「设置 → 移动设备」时自动检查 npm 最新版本,并可在面板中一键安装更新。
|
|
10
|
+
- DSH 宿主支持插件管理时直接升级;旧版宿主会给出 Terminal 安装命令。构建脚本审批不会自动批准,要求重启时需手动重启 DSH。
|
|
11
|
+
- 接入小程序订阅通知:通过一次性通知票据,在对应会话轮次结束后回调通知服务,不发送任务正文。
|
|
12
|
+
|
|
7
13
|
### v0.8.8 · 实时文件事件同步
|
|
8
14
|
|
|
9
15
|
- 补齐实时事件中的 `workspace/changes` 与 `deliverables/presented` 转换,使实时帧与持久历史使用相同的会话、轮次和文件声明结构。
|
|
@@ -95,8 +101,11 @@ requireAuth: true
|
|
|
95
101
|
endpoints:
|
|
96
102
|
- wss://gateway.example.com/ws/mobile
|
|
97
103
|
- ws://192.168.1.10:3081/ws/mobile
|
|
104
|
+
notificationServiceUrl: https://dsh.ahwe.top
|
|
98
105
|
```
|
|
99
106
|
|
|
107
|
+
小程序的「订阅通知」偏好开启后,网关会在对应的 `turn/end` 回调已授权的一次性通知票据。自托管公网服务时,将 `notificationServiceUrl` 改为小程序 `access-config.js` 中相同的 HTTPS 服务根地址;不要把任务正文放进通知回调。
|
|
108
|
+
|
|
100
109
|
运行模式的优先级:**已保存的界面选择 > `gatewayMode` > 旧 `gatewayEnabled`**。没有保存选择时,旧配置 `gatewayEnabled: true` 对应常驻,false 对应关闭。
|
|
101
110
|
|
|
102
111
|
- 关闭:立即断开移动连接,重启后仍关闭。
|
package/lib/client.js
CHANGED
|
@@ -454,6 +454,11 @@ window.__ModuleLoader__.load({
|
|
|
454
454
|
}, [qr])
|
|
455
455
|
const [error, setError] = React.useState(null)
|
|
456
456
|
const [busy, setBusy] = React.useState(false)
|
|
457
|
+
const [updateInfo, setUpdateInfo] = React.useState(null)
|
|
458
|
+
const [updateChecking, setUpdateChecking] = React.useState(true)
|
|
459
|
+
const [updateBusy, setUpdateBusy] = React.useState(false)
|
|
460
|
+
const [updateNotice, setUpdateNotice] = React.useState('')
|
|
461
|
+
const [updateError, setUpdateError] = React.useState('')
|
|
457
462
|
const [refreshing, setRefreshing] = React.useState(false)
|
|
458
463
|
const [refreshNotice, setRefreshNotice] = React.useState(null)
|
|
459
464
|
const [pairingTokenCopied, setPairingTokenCopied] = React.useState(false)
|
|
@@ -540,6 +545,52 @@ window.__ModuleLoader__.load({
|
|
|
540
545
|
}
|
|
541
546
|
}
|
|
542
547
|
|
|
548
|
+
const checkForPluginUpdate = async () => {
|
|
549
|
+
setUpdateChecking(true)
|
|
550
|
+
setUpdateError('')
|
|
551
|
+
try {
|
|
552
|
+
setUpdateInfo(await request('/mgw/update-check'))
|
|
553
|
+
} catch (cause) {
|
|
554
|
+
setUpdateError(cause.message)
|
|
555
|
+
} finally {
|
|
556
|
+
setUpdateChecking(false)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const upgradePlugin = async () => {
|
|
561
|
+
if (updateBusy || !updateInfo?.updateAvailable) return
|
|
562
|
+
setUpdateBusy(true)
|
|
563
|
+
setUpdateNotice('')
|
|
564
|
+
setUpdateError('')
|
|
565
|
+
try {
|
|
566
|
+
const result = await request('/mgw/update', { method: 'POST' })
|
|
567
|
+
if (result.reason === 'plugin-manager-unavailable') {
|
|
568
|
+
setUpdateNotice(`当前 DSH 不支持面板内更新。请在 Terminal 执行:${result.command}`)
|
|
569
|
+
} else if (result.reason === 'already-current') {
|
|
570
|
+
setUpdateInfo(result)
|
|
571
|
+
setUpdateNotice('插件已是最新版本。')
|
|
572
|
+
} else if (result.installed) {
|
|
573
|
+
const application = result.installation?.application
|
|
574
|
+
const pendingBuilds = result.installation?.pendingBuilds
|
|
575
|
+
if (Array.isArray(pendingBuilds) && pendingBuilds.length > 0) {
|
|
576
|
+
setUpdateNotice(`更新等待构建脚本审核(${pendingBuilds.join('、')});未自动批准。请到 DSH 插件管理中审核。`)
|
|
577
|
+
} else if (application === 'restart-required') {
|
|
578
|
+
setUpdateInfo({ ...result, currentVersion: result.latestVersion, updateAvailable: false })
|
|
579
|
+
setUpdateNotice(`已安装 v${result.latestVersion},需要重启 DSH 后生效。`)
|
|
580
|
+
} else if (application === 'failed' || application === 'cancelled' || application === 'refused') {
|
|
581
|
+
setUpdateNotice(`安装未完成:${application}${result.installation?.error ? `(${result.installation.error})` : ''}`)
|
|
582
|
+
} else {
|
|
583
|
+
setUpdateInfo({ ...result, currentVersion: result.latestVersion, updateAvailable: false })
|
|
584
|
+
setUpdateNotice(`已安装 v${result.latestVersion}。如界面尚未更新,请重启 DSH。`)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
} catch (cause) {
|
|
588
|
+
setUpdateError(cause.message)
|
|
589
|
+
} finally {
|
|
590
|
+
setUpdateBusy(false)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
543
594
|
const runTool = async (tool) => {
|
|
544
595
|
if (toolBusy) return
|
|
545
596
|
setToolsOpen(false)
|
|
@@ -604,6 +655,13 @@ window.__ModuleLoader__.load({
|
|
|
604
655
|
React.useEffect(() => {
|
|
605
656
|
let active = true
|
|
606
657
|
refresh()
|
|
658
|
+
request('/mgw/update-check').then((data) => {
|
|
659
|
+
if (active) setUpdateInfo(data)
|
|
660
|
+
}).catch((cause) => {
|
|
661
|
+
if (active) setUpdateError(cause.message)
|
|
662
|
+
}).finally(() => {
|
|
663
|
+
if (active) setUpdateChecking(false)
|
|
664
|
+
})
|
|
607
665
|
request('/mgw/public-setup').then((data) => {
|
|
608
666
|
if (!active) return
|
|
609
667
|
setPublicSetup(data)
|
|
@@ -1019,6 +1077,40 @@ window.__ModuleLoader__.load({
|
|
|
1019
1077
|
status && !status.requireAuth
|
|
1020
1078
|
? React.createElement('p', { style: { ...styles.error, margin: '12px 0 0' } }, 'Debug 模式:本机 DSH 入口将跳过设备凭证校验;独立局域网入口仍强制鉴权。')
|
|
1021
1079
|
: null),
|
|
1080
|
+
React.createElement('section', { style: styles.card },
|
|
1081
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'flex-start' } },
|
|
1082
|
+
React.createElement('div', null,
|
|
1083
|
+
React.createElement('strong', null, '插件在线更新'),
|
|
1084
|
+
React.createElement('div', { style: { ...styles.muted, marginTop: 4 } }, updateChecking
|
|
1085
|
+
? '正在检查 npm 最新版本…'
|
|
1086
|
+
: updateInfo
|
|
1087
|
+
? `当前版本 v${updateInfo.currentVersion} · npm 最新 v${updateInfo.latestVersion}`
|
|
1088
|
+
: '暂时无法获取版本信息'),
|
|
1089
|
+
updateInfo?.updateAvailable
|
|
1090
|
+
? React.createElement('div', { style: { marginTop: 6, color: colors.success, fontWeight: 600 } }, '发现新版本,可直接升级安装。')
|
|
1091
|
+
: null,
|
|
1092
|
+
updateInfo && !updateInfo.updateAvailable
|
|
1093
|
+
? React.createElement('div', { style: { marginTop: 6, color: colors.success } }, '当前已是最新版本。')
|
|
1094
|
+
: null,
|
|
1095
|
+
updateNotice
|
|
1096
|
+
? React.createElement('div', { style: { ...styles.muted, marginTop: 6, overflowWrap: 'anywhere' }, role: 'status' }, updateNotice)
|
|
1097
|
+
: null,
|
|
1098
|
+
updateError
|
|
1099
|
+
? React.createElement('div', { style: { ...styles.muted, marginTop: 6, color: colors.danger, overflowWrap: 'anywhere' }, role: 'status' }, `检查失败:${updateError}`)
|
|
1100
|
+
: null),
|
|
1101
|
+
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minWidth: 100 } },
|
|
1102
|
+
React.createElement('button', {
|
|
1103
|
+
type: 'button',
|
|
1104
|
+
style: { ...styles.button, ...styles.primary, opacity: updateBusy || !updateInfo?.updateAvailable ? .55 : 1 },
|
|
1105
|
+
disabled: updateBusy || updateChecking || !updateInfo?.updateAvailable,
|
|
1106
|
+
onClick: upgradePlugin,
|
|
1107
|
+
}, updateBusy ? '正在升级…' : '立即升级'),
|
|
1108
|
+
React.createElement('button', {
|
|
1109
|
+
type: 'button',
|
|
1110
|
+
style: styles.button,
|
|
1111
|
+
disabled: updateChecking || updateBusy,
|
|
1112
|
+
onClick: checkForPluginUpdate,
|
|
1113
|
+
}, updateChecking ? '检查中…' : '重新检查')))),
|
|
1022
1114
|
),
|
|
1023
1115
|
React.createElement('div', { hidden: activeSection !== 'pairing' },
|
|
1024
1116
|
React.createElement('section', { style: styles.card },
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const TICKET_RE = /^[A-Za-z0-9_-]{43}$/
|
|
2
|
+
const MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000
|
|
3
|
+
|
|
4
|
+
function turnKey(event) {
|
|
5
|
+
const value = event?.data?.turn
|
|
6
|
+
if (typeof value === 'string' || typeof value === 'number') return `${typeof value}:${String(value)}`
|
|
7
|
+
return Number.isSafeInteger(event?.seq) ? `seq:${event.seq}` : null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function completionUrl(baseUrl) {
|
|
11
|
+
if (!baseUrl) return null
|
|
12
|
+
let url
|
|
13
|
+
try { url = new URL(baseUrl) } catch { throw new Error('notificationServiceUrl must be an HTTPS URL') }
|
|
14
|
+
const loopback = url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|
|
15
|
+
if ((!loopback && url.protocol !== 'https:') || url.username || url.password || url.search || url.hash) {
|
|
16
|
+
throw new Error('notificationServiceUrl must be HTTPS without credentials, query, or fragment')
|
|
17
|
+
}
|
|
18
|
+
return `${url.origin}${url.pathname.replace(/\/$/, '')}/notifications/complete`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createCompletionNotifications({ serviceUrl, fetchImpl = globalThis.fetch, now = Date.now, log = () => {} } = {}) {
|
|
22
|
+
const endpoint = completionUrl(serviceUrl)
|
|
23
|
+
const sessions = new Map()
|
|
24
|
+
if (endpoint && typeof fetchImpl !== 'function') throw new Error('fetch is required for completion notifications')
|
|
25
|
+
|
|
26
|
+
function sessionState(sessionId, create = false) {
|
|
27
|
+
let value = sessions.get(sessionId)
|
|
28
|
+
if (!value && create) {
|
|
29
|
+
value = { pending: [], active: new Map(), currentTurn: null, currentTurnAt: null }
|
|
30
|
+
sessions.set(sessionId, value)
|
|
31
|
+
}
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function prune() {
|
|
36
|
+
const cutoff = now() - MAX_AGE_MS
|
|
37
|
+
for (const [sessionId, state] of sessions) {
|
|
38
|
+
state.pending = state.pending.filter(item => item.createdAt > cutoff)
|
|
39
|
+
if (state.currentTurnAt !== null && state.currentTurnAt <= cutoff) {
|
|
40
|
+
state.currentTurn = null
|
|
41
|
+
state.currentTurnAt = null
|
|
42
|
+
}
|
|
43
|
+
for (const [key, items] of state.active) {
|
|
44
|
+
const live = items.filter(item => item.createdAt > cutoff)
|
|
45
|
+
if (live.length) state.active.set(key, live)
|
|
46
|
+
else state.active.delete(key)
|
|
47
|
+
}
|
|
48
|
+
if (!state.pending.length && !state.active.size && state.currentTurn === null) sessions.delete(sessionId)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function register(sessionId, ticket, mode = 'queue') {
|
|
53
|
+
if (!endpoint || typeof sessionId !== 'string' || !sessionId || !TICKET_RE.test(ticket || '')) return null
|
|
54
|
+
prune()
|
|
55
|
+
const state = sessionState(sessionId, true)
|
|
56
|
+
const activeCount = [...state.active.values()].reduce((sum, items) => sum + items.length, 0)
|
|
57
|
+
if (state.pending.length + activeCount >= 64) return null
|
|
58
|
+
const registration = { ticket, mode: mode === 'steer' ? 'steer' : 'queue', createdAt: now(), turn: null }
|
|
59
|
+
if (registration.mode === 'steer' && state.currentTurn !== null) {
|
|
60
|
+
registration.turn = state.currentTurn
|
|
61
|
+
const active = state.active.get(state.currentTurn) || []
|
|
62
|
+
active.push(registration)
|
|
63
|
+
state.active.set(state.currentTurn, active)
|
|
64
|
+
} else state.pending.push(registration)
|
|
65
|
+
return registration
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function discard(sessionId, registration) {
|
|
69
|
+
if (!registration) return
|
|
70
|
+
const state = sessionState(sessionId)
|
|
71
|
+
if (!state) return
|
|
72
|
+
state.pending = state.pending.filter(item => item !== registration)
|
|
73
|
+
for (const [key, items] of state.active) {
|
|
74
|
+
const remaining = items.filter(item => item !== registration)
|
|
75
|
+
if (remaining.length) state.active.set(key, remaining)
|
|
76
|
+
else state.active.delete(key)
|
|
77
|
+
}
|
|
78
|
+
if (!state.pending.length && !state.active.size && state.currentTurn === null) sessions.delete(sessionId)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function deliver(sessionId, records) {
|
|
82
|
+
if (!endpoint || !records.length) return
|
|
83
|
+
for (const record of records) {
|
|
84
|
+
let response
|
|
85
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
86
|
+
try {
|
|
87
|
+
response = await fetchImpl(endpoint, {
|
|
88
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
body: JSON.stringify({ ticket: record.ticket, sessionId, status: 'completed' }),
|
|
91
|
+
})
|
|
92
|
+
if (response.ok) break
|
|
93
|
+
} catch (_) { response = null }
|
|
94
|
+
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 300 * (attempt + 1)))
|
|
95
|
+
}
|
|
96
|
+
if (!response?.ok) log(`completion notification delivery failed for session ${sessionId}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function onSessionEvent(sessionId, event) {
|
|
101
|
+
if (!endpoint || typeof sessionId !== 'string' || !event) return
|
|
102
|
+
prune()
|
|
103
|
+
if (event.type === 'turn/start') {
|
|
104
|
+
const state = sessionState(sessionId, true)
|
|
105
|
+
const key = turnKey(event)
|
|
106
|
+
if (key === null) return
|
|
107
|
+
state.currentTurn = key
|
|
108
|
+
state.currentTurnAt = now()
|
|
109
|
+
if (!state.active.has(key)) state.active.set(key, [])
|
|
110
|
+
const next = state.pending.shift()
|
|
111
|
+
if (next) {
|
|
112
|
+
next.turn = key
|
|
113
|
+
state.active.get(key).push(next)
|
|
114
|
+
}
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
if (event.type !== 'turn/end') return
|
|
118
|
+
const state = sessionState(sessionId)
|
|
119
|
+
if (!state) return
|
|
120
|
+
const key = turnKey(event) || state.currentTurn
|
|
121
|
+
const records = key === null ? [] : state.active.get(key) || []
|
|
122
|
+
if (key !== null) state.active.delete(key)
|
|
123
|
+
if (state.currentTurn === key) { state.currentTurn = null; state.currentTurnAt = null }
|
|
124
|
+
void deliver(sessionId, records).catch(() => log(`completion notification delivery failed for session ${sessionId}`))
|
|
125
|
+
if (!state.pending.length && !state.active.size && state.currentTurn === null) sessions.delete(sessionId)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { register, discard, onSessionEvent, clear() { sessions.clear() } }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { completionUrl }
|
package/lib/index.mjs
CHANGED
|
@@ -140,7 +140,9 @@ import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
|
|
|
140
140
|
import { createRelayState, PAIRING_MODES } from './relay-state.mjs'
|
|
141
141
|
import { createRelayConnector, resolveStateDir } from './relay-connector.mjs'
|
|
142
142
|
import { createCloudflareTunnel } from './cloudflare-tunnel.mjs'
|
|
143
|
+
import { createCompletionNotifications } from './completion-notifications.mjs'
|
|
143
144
|
import { gatewayProbeHeader } from './gateway-probe.mjs'
|
|
145
|
+
import { checkPluginUpdate, installLatestPlugin } from './plugin-updates.mjs'
|
|
144
146
|
import { webRestartAvailable, prepareWebRestart } from './gateway-tools.mjs'
|
|
145
147
|
import QRCode from 'qrcode'
|
|
146
148
|
|
|
@@ -244,6 +246,7 @@ const Config = Schema.object({
|
|
|
244
246
|
lanPort: Schema.natural().min(1).max(65535).default(3081),
|
|
245
247
|
lanAdvertiseHost: Schema.string().default(''),
|
|
246
248
|
relayUrl: Schema.string().default(''),
|
|
249
|
+
notificationServiceUrl: Schema.string().default('https://dsh.ahwe.top'),
|
|
247
250
|
cloudflaredPath: Schema.string().default('cloudflared'),
|
|
248
251
|
cloudflarePort: Schema.natural().min(1).max(65535).default(3082),
|
|
249
252
|
cloudflareStateFile: Schema.string().default(''),
|
|
@@ -530,7 +533,7 @@ async function admitCommand(host, msg) {
|
|
|
530
533
|
|
|
531
534
|
// Handle one mobile -> agent message through the official host API. Returns
|
|
532
535
|
// the wire frame to send back, or null when nothing should be sent.
|
|
533
|
-
async function admitMessage(api, msg) {
|
|
536
|
+
async function admitMessage(api, msg, notificationTracker) {
|
|
534
537
|
const preset = parseAgentPreset(msg, 'message')
|
|
535
538
|
if (preset.error) return preset.error
|
|
536
539
|
if (preset.value !== undefined && typeof msg.sessionId === 'string' && msg.sessionId.trim()) {
|
|
@@ -547,6 +550,7 @@ async function admitMessage(api, msg) {
|
|
|
547
550
|
const mode = msg.mode === 'steer' ? 'steer' : 'queue'
|
|
548
551
|
|
|
549
552
|
let sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : undefined
|
|
553
|
+
let notificationRegistration = null
|
|
550
554
|
try {
|
|
551
555
|
if (sessionId === undefined) {
|
|
552
556
|
// New session: optionally place it in a workspace (at most one of
|
|
@@ -565,6 +569,10 @@ async function admitMessage(api, msg) {
|
|
|
565
569
|
log(`mobile message created new session ${sessionId} (${JSON.stringify(createPayload)})`)
|
|
566
570
|
}
|
|
567
571
|
|
|
572
|
+
// Bind an accepted one-time WeChat ticket before prompt(): turn/start may
|
|
573
|
+
// arrive before the Host API returns for queued work.
|
|
574
|
+
notificationRegistration = notificationTracker?.register(sessionId, msg.notificationTicket, mode) || null;
|
|
575
|
+
|
|
568
576
|
const resp = await api.sessions.prompt({
|
|
569
577
|
sessionId,
|
|
570
578
|
mode,
|
|
@@ -580,6 +588,7 @@ async function admitMessage(api, msg) {
|
|
|
580
588
|
...(resp.command ? { command: resp.command } : {}),
|
|
581
589
|
}
|
|
582
590
|
} catch (error) {
|
|
591
|
+
notificationTracker?.discard(sessionId, notificationRegistration)
|
|
583
592
|
const code = error && error.code ? error.code : 'internal'
|
|
584
593
|
const message = error && error.message ? error.message : String(error)
|
|
585
594
|
log(`mobile message failed: ${message}`)
|
|
@@ -2195,6 +2204,7 @@ const plugin = {
|
|
|
2195
2204
|
lanPort: 3081,
|
|
2196
2205
|
lanAdvertiseHost: '',
|
|
2197
2206
|
relayUrl: '',
|
|
2207
|
+
notificationServiceUrl: 'https://dsh.ahwe.top',
|
|
2198
2208
|
cloudflaredPath: 'cloudflared',
|
|
2199
2209
|
cloudflarePort: 3082,
|
|
2200
2210
|
cloudflareStateFile: '',
|
|
@@ -2213,6 +2223,10 @@ const plugin = {
|
|
|
2213
2223
|
if (typeof options.cloudflaredPath !== 'string' || !options.cloudflaredPath.trim()) {
|
|
2214
2224
|
throw new Error('weapp-gateway: cloudflaredPath must be a nonempty executable path')
|
|
2215
2225
|
}
|
|
2226
|
+
const completionNotifications = createCompletionNotifications({
|
|
2227
|
+
serviceUrl: options.notificationServiceUrl,
|
|
2228
|
+
log,
|
|
2229
|
+
})
|
|
2216
2230
|
let requireAuth = options.requireAuth !== false
|
|
2217
2231
|
const adminLoopbackOnly = options.adminLoopbackOnly !== false
|
|
2218
2232
|
const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
|
|
@@ -2690,6 +2704,7 @@ const plugin = {
|
|
|
2690
2704
|
|
|
2691
2705
|
// ---- device management routes (loopback-gated admin surface) ----
|
|
2692
2706
|
let restartPending = false
|
|
2707
|
+
let pluginUpdatePending = false
|
|
2693
2708
|
const disposeMgmt = webServer.register({
|
|
2694
2709
|
kind: 'prefix',
|
|
2695
2710
|
path: '/mgw',
|
|
@@ -2705,7 +2720,25 @@ const plugin = {
|
|
|
2705
2720
|
}
|
|
2706
2721
|
const url = new URL(req.url || '/', 'http://x')
|
|
2707
2722
|
const p = url.pathname
|
|
2708
|
-
if (req.method === 'GET' && p === '/mgw/
|
|
2723
|
+
if (req.method === 'GET' && p === '/mgw/update-check') {
|
|
2724
|
+
sendJson(res, 200, await checkPluginUpdate({ currentVersion: PLUGIN_VERSION }))
|
|
2725
|
+
} else if (req.method === 'POST' && p === '/mgw/update') {
|
|
2726
|
+
if (pluginUpdatePending) {
|
|
2727
|
+
sendJson(res, 409, { error: 'update-pending', message: '插件更新正在进行' })
|
|
2728
|
+
return
|
|
2729
|
+
}
|
|
2730
|
+
pluginUpdatePending = true
|
|
2731
|
+
try {
|
|
2732
|
+
const result = await installLatestPlugin({
|
|
2733
|
+
api,
|
|
2734
|
+
currentVersion: PLUGIN_VERSION,
|
|
2735
|
+
requestId: crypto.randomUUID(),
|
|
2736
|
+
})
|
|
2737
|
+
sendJson(res, 200, result)
|
|
2738
|
+
} finally {
|
|
2739
|
+
pluginUpdatePending = false
|
|
2740
|
+
}
|
|
2741
|
+
} else if (req.method === 'GET' && p === '/mgw/status') {
|
|
2709
2742
|
const profile = ctx.get?.('profileContext')
|
|
2710
2743
|
sendJson(res, 200, {
|
|
2711
2744
|
version: PLUGIN_VERSION,
|
|
@@ -3085,7 +3118,7 @@ const plugin = {
|
|
|
3085
3118
|
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
3086
3119
|
})
|
|
3087
3120
|
} else if (msg.type === 'message') {
|
|
3088
|
-
admitMessage(api, msg).then((frame) => {
|
|
3121
|
+
admitMessage(api, msg, completionNotifications).then((frame) => {
|
|
3089
3122
|
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
3090
3123
|
})
|
|
3091
3124
|
} else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
|
|
@@ -3259,6 +3292,7 @@ const plugin = {
|
|
|
3259
3292
|
|
|
3260
3293
|
const disposeEvents = ctx.on('session/event', (session, event) => {
|
|
3261
3294
|
api.observeSessionEvent(String(session.id), event)
|
|
3295
|
+
completionNotifications.onSessionEvent(String(session.id), event)
|
|
3262
3296
|
if (clients.size === 0) return
|
|
3263
3297
|
const wire = buildWireEvent(session, event)
|
|
3264
3298
|
if (!wire) return
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export const PLUGIN_PACKAGE_NAME = 'dsh-plugin-weapp-gateway'
|
|
2
|
+
const REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`
|
|
3
|
+
|
|
4
|
+
function parseVersion(value) {
|
|
5
|
+
if (typeof value !== 'string') return null
|
|
6
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value)
|
|
7
|
+
if (!match) return null
|
|
8
|
+
const prerelease = match[4]?.split('.') ?? []
|
|
9
|
+
if (prerelease.some(identifier => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0'))) return null
|
|
10
|
+
return { core: match.slice(1, 4), prerelease }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function compareNumericStrings(left, right) {
|
|
14
|
+
if (left.length !== right.length) return left.length < right.length ? -1 : 1
|
|
15
|
+
return left === right ? 0 : left < right ? -1 : 1
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function compareVersions(left, right) {
|
|
19
|
+
const a = parseVersion(left)
|
|
20
|
+
const b = parseVersion(right)
|
|
21
|
+
if (!a || !b) throw new Error('Expected valid semantic versions')
|
|
22
|
+
for (let index = 0; index < 3; index += 1) {
|
|
23
|
+
const result = compareNumericStrings(a.core[index], b.core[index])
|
|
24
|
+
if (result !== 0) return result
|
|
25
|
+
}
|
|
26
|
+
if (a.prerelease.length === 0 || b.prerelease.length === 0) {
|
|
27
|
+
if (a.prerelease.length === b.prerelease.length) return 0
|
|
28
|
+
return a.prerelease.length === 0 ? 1 : -1
|
|
29
|
+
}
|
|
30
|
+
const count = Math.min(a.prerelease.length, b.prerelease.length)
|
|
31
|
+
for (let index = 0; index < count; index += 1) {
|
|
32
|
+
const leftId = a.prerelease[index]
|
|
33
|
+
const rightId = b.prerelease[index]
|
|
34
|
+
const leftNumeric = /^\d+$/.test(leftId)
|
|
35
|
+
const rightNumeric = /^\d+$/.test(rightId)
|
|
36
|
+
if (leftNumeric && rightNumeric) {
|
|
37
|
+
const result = compareNumericStrings(leftId, rightId)
|
|
38
|
+
if (result !== 0) return result
|
|
39
|
+
} else if (leftNumeric !== rightNumeric) {
|
|
40
|
+
return leftNumeric ? -1 : 1
|
|
41
|
+
} else if (leftId !== rightId) {
|
|
42
|
+
return leftId < rightId ? -1 : 1
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length < b.prerelease.length ? -1 : 1
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function checkPluginUpdate({ currentVersion, fetchImpl = globalThis.fetch }) {
|
|
49
|
+
if (!parseVersion(currentVersion)) throw new Error('Current plugin version is invalid')
|
|
50
|
+
if (typeof fetchImpl !== 'function') throw new Error('This runtime does not support online update checks')
|
|
51
|
+
const response = await fetchImpl(REGISTRY_LATEST_URL, {
|
|
52
|
+
headers: { accept: 'application/json' },
|
|
53
|
+
signal: AbortSignal.timeout(8_000),
|
|
54
|
+
})
|
|
55
|
+
if (!response.ok) throw new Error(`npm registry returned HTTP ${response.status}`)
|
|
56
|
+
const metadata = await response.json()
|
|
57
|
+
if (metadata?.name !== PLUGIN_PACKAGE_NAME || !parseVersion(metadata?.version)) {
|
|
58
|
+
throw new Error('npm registry returned invalid plugin metadata')
|
|
59
|
+
}
|
|
60
|
+
const latestVersion = metadata.version
|
|
61
|
+
return {
|
|
62
|
+
packageName: PLUGIN_PACKAGE_NAME,
|
|
63
|
+
currentVersion,
|
|
64
|
+
latestVersion,
|
|
65
|
+
updateAvailable: compareVersions(latestVersion, currentVersion) > 0,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function installLatestPlugin({ api, currentVersion, fetchImpl = globalThis.fetch, requestId }) {
|
|
70
|
+
const update = await checkPluginUpdate({ currentVersion, fetchImpl })
|
|
71
|
+
if (!update.updateAvailable) return { ...update, installed: false, reason: 'already-current' }
|
|
72
|
+
|
|
73
|
+
const command = `dsh plugin --profile web add ${PLUGIN_PACKAGE_NAME}@${update.latestVersion}`
|
|
74
|
+
let inventory
|
|
75
|
+
try {
|
|
76
|
+
inventory = await api.plugins.inventory()
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { ...update, installed: false, reason: 'plugin-manager-unavailable', command, detail: error?.message }
|
|
79
|
+
}
|
|
80
|
+
if (inventory?.managementAvailable !== true) {
|
|
81
|
+
return { ...update, installed: false, reason: 'plugin-manager-unavailable', command }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const installation = await api.plugins.installBundle(
|
|
85
|
+
`${PLUGIN_PACKAGE_NAME}@${update.latestVersion}`,
|
|
86
|
+
{ requestId },
|
|
87
|
+
)
|
|
88
|
+
return { ...update, installed: true, installation }
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-weapp-gateway",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.9",
|
|
4
4
|
"description": "Authenticated mobile gateway for dsh 0.1.7-rc.2 with Session V4 history, live permissions, inbox-based queue sync, plugin management (DSH 0.2.0 hosts), LAN and Cloudflare Tunnel access with public endpoint health checks, device pairing, per-session Agent preset selection, relay access for the WeChat mini-program, and the official speech-input bridge (speech-input-v1)",
|
|
5
5
|
"main": "lib/index.mjs",
|
|
6
6
|
"files": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"license": "MIT",
|
|
47
47
|
"scripts": {
|
|
48
|
-
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/speech-input.test.mjs && node test/speech-official.test.mjs && node test/plugin-management.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node --test test/workspace-changes.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node --test test/public-endpoint-check.test.mjs && node test/gateway-tools.test.mjs && node test/relay-connector.test.mjs && node test/cloudflare-linux.test.mjs && node test/multi-gateway.test.mjs"
|
|
48
|
+
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/speech-input.test.mjs && node test/speech-official.test.mjs && node test/plugin-management.test.mjs && node test/plugin-updates.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/completion-notifications.test.mjs && node --test test/workspace-changes.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node --test test/public-endpoint-check.test.mjs && node test/gateway-tools.test.mjs && node test/relay-connector.test.mjs && node test/cloudflare-linux.test.mjs && node test/multi-gateway.test.mjs"
|
|
49
49
|
},
|
|
50
50
|
"bin": {
|
|
51
51
|
"dsh-plugin-weapp-gateway": "bin/setup-ip.mjs"
|