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 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/status') {
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.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"