dsh-opencode-palette 2.0.6 → 2.0.7

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/lib/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-opencode-palette v2.0.6 — 浏览器半(构建产物,勿手改)
2
+ * dsh-opencode-palette v2.0.7 — 浏览器半(构建产物,勿手改)
3
3
  * 数据驱动管线:opencode v1.18.12 官方主题 JSON → 颜色解析 → DSH 适配注入
4
4
  * 面板「检查更新」:dsh-plugin-update 客户端入口经构建期内联(vendor)
5
5
  * 源:src/engine/* + runtime/*.mjs + npm 包 dsh-log / dsh-plugin-update 的客户端入口
@@ -9724,6 +9724,9 @@ __mods["channel"] = { CHANNEL, ENDPOINT, ROUTE_PATH, PLUGIN_ID, PHONE_PREFIX, TA
9724
9724
  * 电话名与轮询间隔由调用方从更新包的客户端入口派生后传进来(本文件不写字面量)。
9725
9725
  */
9726
9726
 
9727
+ /** 与 runtime/channel.mjs 的 PLUGIN_ID 同值:本文件是纯逻辑模块,不引运行时模块,故就地声明一次。 */
9728
+ const PLUGIN_ID = 'dsh-opencode-palette'
9729
+
9727
9730
  /** 宿主回包 → 面板要的几个事实(纯函数,便于离线核对)。 */
9728
9731
  function readSnapshot(res) {
9729
9732
  const snap = res && res.snapshot ? res.snapshot : null
@@ -9805,6 +9808,9 @@ function createUpdateController(deps) {
9805
9808
  jobMessage: null,
9806
9809
  pending: false,
9807
9810
  failure: null,
9811
+ // 弹窗外的可见结果({key, kind} 或 null):失败与「装好了但没进待重启」都走这里,
9812
+ // 否则用户点一下没动静,看不出是成功、失败还是没反应(2026-09-30 真机反馈)。
9813
+ notice: null,
9808
9814
  }
9809
9815
  let pollTimer = null
9810
9816
  const listeners = []
@@ -9821,6 +9827,14 @@ function createUpdateController(deps) {
9821
9827
  function record(level, event, fields, latencyMs) {
9822
9828
  try { log(level, event, fields) } catch (e) { /* 忽略 */ }
9823
9829
  }
9830
+ /** 回包里的错误码(宿主半判词,如 invalid-release / check-failed);取不到就退成 unknown。
9831
+ * 更新包的失败形状是 {ok:false, error:'<code>', errorKind}——error 是字符串,不是对象。 */
9832
+ function codeOf(res) {
9833
+ const error = res && res.error !== undefined && res.error !== null ? res.error : null
9834
+ if (typeof error === 'string') return error.slice(0, 40)
9835
+ const code = error && (error.code || error.message) ? String(error.code || error.message) : 'unknown'
9836
+ return code.slice(0, 40)
9837
+ }
9824
9838
  function stopPolling() {
9825
9839
  if (pollTimer !== null) {
9826
9840
  try { clearTimer(pollTimer) } catch (e) { /* 忽略 */ }
@@ -9861,7 +9875,7 @@ function createUpdateController(deps) {
9861
9875
  method: phone,
9862
9876
  kind: kind,
9863
9877
  errorHash: hash8(String((error && error.message) || error || kind)),
9864
- pluginId: 'dsh-opencode-palette',
9878
+ pluginId: PLUGIN_ID,
9865
9879
  })
9866
9880
  }
9867
9881
 
@@ -9874,7 +9888,7 @@ function createUpdateController(deps) {
9874
9888
  latencyMs: Date.now() - startedAt,
9875
9889
  ok: true,
9876
9890
  kind: String((args && args.kind) || phone),
9877
- pluginId: 'dsh-opencode-palette',
9891
+ pluginId: PLUGIN_ID,
9878
9892
  })
9879
9893
  } else {
9880
9894
  transportFail(phone, 'not-ok', (res && res.error) || 'not-ok')
@@ -9900,19 +9914,34 @@ function createUpdateController(deps) {
9900
9914
  if (!state.available) return 'failed'
9901
9915
  if (state.checking || state.installing) return 'busy'
9902
9916
  // 已查到有新版且凭证还在:直接开弹窗,不重复联网
9903
- if (state.hasNew && state.checkId) { patch({ dialogOpen: true, failure: null }); return 'new' }
9904
- patch({ checking: true, failure: null })
9917
+ if (state.hasNew && state.checkId) { patch({ dialogOpen: true, failure: null, notice: null }); return 'new' }
9918
+ patch({ checking: true, failure: null, notice: null })
9919
+ record('info', 'update.check.start', { trigger: 'manual', pluginId: PLUGIN_ID })
9920
+ const startedAt = Date.now()
9905
9921
  try {
9906
9922
  const res = await invoke(phones.updateCheck, {})
9907
9923
  patch({ checking: false })
9908
- if (!res || res.ok !== true) { return 'failed' }
9924
+ if (!res || res.ok !== true) {
9925
+ const code = codeOf(res)
9926
+ record('warn', 'update.check.fail', { trigger: 'manual', code: code, errorHash: hash8(code), pluginId: PLUGIN_ID })
9927
+ patch({ notice: { key: 'updateCheckFail', kind: 'error' } })
9928
+ return 'failed'
9929
+ }
9909
9930
  const facts = absorb(res)
9931
+ record('info', 'update.check.ok', {
9932
+ trigger: 'manual', hasNew: facts.canInstall, latest: facts.latest || '',
9933
+ latencyMs: Date.now() - startedAt, pluginId: PLUGIN_ID,
9934
+ })
9910
9935
  if (facts.canInstall) { patch({ dialogOpen: true }); return 'new' }
9911
9936
  if (facts.pending) return 'latest'
9912
9937
  return 'latest'
9913
9938
  } catch (e) {
9914
9939
  patch({ checking: false })
9915
9940
  transportFail(phones.updateCheck, 'throw', e)
9941
+ record('warn', 'update.check.fail', {
9942
+ trigger: 'manual', code: 'transport', errorHash: hash8(String((e && e.message) || e)), pluginId: PLUGIN_ID,
9943
+ })
9944
+ patch({ notice: { key: 'updateCheckFail', kind: 'error' } })
9916
9945
  return 'failed'
9917
9946
  }
9918
9947
  }
@@ -9926,16 +9955,29 @@ function createUpdateController(deps) {
9926
9955
  if (state.pending) return 'latest'
9927
9956
  if (state.hasNew && state.checkId) return 'new'
9928
9957
  patch({ checking: true, failure: null })
9958
+ record('info', 'update.check.start', { trigger: 'auto', pluginId: PLUGIN_ID })
9959
+ const startedAt = Date.now()
9929
9960
  try {
9930
9961
  const res = await invoke(phones.updateCheck, {})
9931
9962
  patch({ checking: false })
9932
- if (!res || res.ok !== true) return 'failed'
9963
+ if (!res || res.ok !== true) {
9964
+ const code = codeOf(res)
9965
+ record('warn', 'update.check.fail', { trigger: 'auto', code: code, errorHash: hash8(code), pluginId: PLUGIN_ID })
9966
+ return 'failed'
9967
+ }
9933
9968
  const facts = absorb(res)
9969
+ record('info', 'update.check.ok', {
9970
+ trigger: 'auto', hasNew: facts.canInstall, latest: facts.latest || '',
9971
+ latencyMs: Date.now() - startedAt, pluginId: PLUGIN_ID,
9972
+ })
9934
9973
  if (facts.canInstall) return 'new'
9935
9974
  return 'latest'
9936
9975
  } catch (e) {
9937
9976
  patch({ checking: false })
9938
9977
  transportFail(phones.updateCheck, 'throw', e)
9978
+ record('warn', 'update.check.fail', {
9979
+ trigger: 'auto', code: 'transport', errorHash: hash8(String((e && e.message) || e)), pluginId: PLUGIN_ID,
9980
+ })
9939
9981
  return 'failed'
9940
9982
  }
9941
9983
  }
@@ -9954,23 +9996,38 @@ function createUpdateController(deps) {
9954
9996
  async function install() {
9955
9997
  if (!state.available || state.installing) return false
9956
9998
  if (!state.checkId) return false
9957
- patch({ installing: true, failure: null })
9999
+ patch({ installing: true, failure: null, notice: null })
10000
+ record('info', 'update.install.start', { latest: String(state.latest || ''), pluginId: PLUGIN_ID })
9958
10001
  const requestId = 'req-' + String(Date.now()) + '-' + String(Math.floor(Math.random() * 100000))
9959
10002
  try {
9960
10003
  const res = await invoke(phones.updateInstall, { checkId: state.checkId, requestId: requestId })
9961
10004
  patch({ installing: false })
9962
10005
  if (res && res.ok === true) {
9963
- absorb(res)
10006
+ const facts = absorb(res)
9964
10007
  patch({ dialogOpen: false })
10008
+ record('info', 'update.install.ok', {
10009
+ latest: String((facts && facts.latest) || state.latest || ''),
10010
+ pending: !!(facts && facts.pending),
10011
+ pluginId: PLUGIN_ID,
10012
+ })
10013
+ // 待重启由常驻横幅承接;万一没进待重启,至少留一句「重启后生效」,别让用户对着没动静的按钮猜
10014
+ if (!(facts && facts.pending)) patch({ notice: { key: 'updateRestartHint', kind: 'warn' } })
9965
10015
  return true
9966
10016
  }
9967
10017
  const facts = readSnapshot(res)
10018
+ const code = codeOf(res)
9968
10019
  patch({ blocked: facts.blocked || state.blocked, failure: 'install-failed' })
10020
+ record('warn', 'update.install.fail', { code: code, errorHash: hash8(code), pluginId: PLUGIN_ID })
10021
+ patch({ notice: { key: facts.blocked ? blockedReasonKey(facts.blocked) : 'updateFailInstall', kind: 'error' } })
9969
10022
  await readStatus()
9970
10023
  return false
9971
10024
  } catch (e) {
9972
10025
  patch({ installing: false, failure: 'install-failed' })
9973
10026
  transportFail(phones.updateInstall, 'throw', e)
10027
+ record('warn', 'update.install.fail', {
10028
+ code: 'transport', errorHash: hash8(String((e && e.message) || e)), pluginId: PLUGIN_ID,
10029
+ })
10030
+ patch({ notice: { key: 'updateFailInstall', kind: 'error' } })
9974
10031
  try { await readStatus() } catch (e2) { /* 忽略 */ }
9975
10032
  return false
9976
10033
  }
@@ -9992,6 +10049,8 @@ function createUpdateController(deps) {
9992
10049
  install: install,
9993
10050
  openDialog: function () { patch({ dialogOpen: true }) },
9994
10051
  closeDialog: function () { patch({ dialogOpen: false, failure: null }) },
10052
+ /** 面板其它部件(如日志开关)也能借这行可见提示,别让它们的失败静默掉。 */
10053
+ setNotice: function (key, kind) { patch({ notice: key ? { key: key, kind: kind || 'error' } : null }) },
9995
10054
  dispose: function () { stopPolling(); listeners.length = 0 },
9996
10055
  }
9997
10056
  }
@@ -10031,7 +10090,7 @@ const STORAGE_KEY = 'dsh.opencode-palette.v2'
10031
10090
  const LEGACY_STORAGE_KEY = 'dsh.opencode-tui-theme.v2'
10032
10091
  const DEFAULT_STATE = { enabled: true, theme: 'opencode', mode: 'mono', size: 13, fontKey: 'JetBrains Mono' }
10033
10092
  // 构建时由 scripts/build-client.mjs 替换为 package.json 版本(面板底部署小字)
10034
- const PALETTE_VERSION = '2.0.6'
10093
+ const PALETTE_VERSION = '2.0.7'
10035
10094
 
10036
10095
  function getReact() {
10037
10096
  if (typeof require === 'function') { try { return require('react') } catch (e) { /* 动态版无 require */ } }
@@ -10175,6 +10234,14 @@ const I18N = {
10175
10234
  'blocked.incompatible-node': { zh: '新版要求的 Node 更高,先升级 Node', en: 'The new version needs a newer Node — upgrade it first' },
10176
10235
  'blocked.recovery-required': { zh: '上次安装被打断,重新点一次安装', en: 'The last install was interrupted — retry the install' },
10177
10236
  'blocked.unknown': { zh: '当前装不了:重开 DSH 再查一次', en: 'Cannot update right now — reopen DSH and try again' },
10237
+ // 升级已写入但没进「待重启」时的兜底提示(正常路径由常驻横幅承接)
10238
+ updateRestartHint: { zh: '升级已写入,重启 DSH 后生效', en: 'Update written — restart DSH to apply' },
10239
+ // ── 日志开关(面板头行的小开关;开的是 dsh-log 的落盘开关,排障用)──
10240
+ logSwitch: { zh: '日志', en: 'Log' },
10241
+ logOn: { zh: '开', en: 'On' },
10242
+ logOff: { zh: '关', en: 'Off' },
10243
+ logSwitchHint: { zh: '打开后把关键节点写进 <DSH_HOME>/logs/dsh-opencode-palette/(排障用;失败级别的日志恒记,不受此开关控制)', en: 'Write key nodes to <DSH_HOME>/logs/dsh-opencode-palette/ (for troubleshooting; warn/error lines are always kept)' },
10244
+ logSwitchFail: { zh: '日志开关没能写入宿主,请重开面板再试', en: 'The host did not accept the log switch — reopen the panel and retry' },
10178
10245
  }
10179
10246
 
10180
10247
  // 语言检测(回退):html[lang] 优先,回退浏览器语言
@@ -10258,8 +10325,10 @@ function createClient(slotTarget) {
10258
10325
  pollMs: CLIENT_POLL.defaultMs,
10259
10326
  log: function (level, event, fields) { try { clientLog.log(level, event, fields) } catch (e) { /* 忽略 */ } },
10260
10327
  })
10261
- // 启动时向宿主对账调试开关(以宿主为准);宿主不可用时静默,不抛错
10262
- try { clientLog.reconcileLogSwitch() } catch (e) { /* 忽略 */ }
10328
+ // 启动时向宿主对账调试开关(以宿主为准);宿主不可用时静默,不抛错。
10329
+ // 面板的日志开关复用这一次对账的结果,不重复打扰宿主(对账失败会记一条 watchdog warn)。
10330
+ let logSwitchReady = null
10331
+ try { logSwitchReady = clientLog.reconcileLogSwitch() } catch (e) { /* 忽略 */ }
10263
10332
  try { clientLog.log('info', 'host.call', { method: 'boot', latencyMs: 0, ok: true, kind: 'boot', pluginId: PLUGIN_ID }) } catch (e) { /* 忽略 */ }
10264
10333
 
10265
10334
  // ── i18n 运行时:语言跟随 DSH(官方 locale 服务为信号源,html[lang] 仅作回退)──
@@ -10864,12 +10933,52 @@ function createClient(slotTarget) {
10864
10933
  ]))
10865
10934
  : null
10866
10935
 
10936
+ // ── 日志开关(排障用的小开关)──
10937
+ // 状态以宿主为准;首帧先按本地缓存渲染,对账回来再校正。开关本体归 dsh-log 管,
10938
+ // 这里只调它的 setLogSwitch,不自己写开关文件(否则会成第二份真源)。
10939
+ const [logOn, setLogOn] = react.useState(!!(clientLog && clientLog.logSwitch && clientLog.logSwitch.enabled))
10940
+ react.useEffect(function () {
10941
+ let alive = true
10942
+ Promise.resolve(props.reconcileLog()).then(function (res) {
10943
+ if (!alive || !res) return
10944
+ if (typeof res.enabled === 'boolean') setLogOn(res.enabled)
10945
+ }, function () { /* 读不到就维持本地缓存值 */ })
10946
+ return function () { alive = false }
10947
+ }, [])
10948
+ const flipLog = function () {
10949
+ const next = !logOn
10950
+ setLogOn(next)
10951
+ Promise.resolve(props.log.setLogSwitch(next)).then(function (res) {
10952
+ const ok = !!(res && res.ok)
10953
+ if (res && typeof res.enabled === 'boolean') setLogOn(res.enabled)
10954
+ else if (!ok) setLogOn(!next)
10955
+ try {
10956
+ props.log.log('info', 'log.switch.set', {
10957
+ enabled: ok ? !!next : logOn,
10958
+ ok: ok,
10959
+ reason: ok ? 'ok' : String((res && res.error) || 'unknown').slice(0, 40),
10960
+ pluginId: PLUGIN_ID,
10961
+ })
10962
+ } catch (e) { /* 忽略 */ }
10963
+ if (!ok) props.update.setNotice('logSwitchFail')
10964
+ }, function () { setLogOn(!next) })
10965
+ }
10966
+
10867
10967
  return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 920 } }, [
10868
10968
  restartBanner,
10869
10969
  // 头行:标题 + 状态开关(一个状态一个控制)
10870
10970
  h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } }, [
10871
10971
  h('strong', null, '🎨 ' + tr('panelName')),
10872
10972
  h('div', { style: { display: 'inline-flex', alignItems: 'center', gap: 8 } }, [
10973
+ h('span', {
10974
+ key: 'log',
10975
+ onClick: flipLog,
10976
+ title: tr('logSwitchHint'),
10977
+ style: {
10978
+ fontSize: 11, cursor: 'pointer', userSelect: 'none',
10979
+ color: logOn ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-tertiary)',
10980
+ },
10981
+ }, tr('logSwitch') + ' ' + (logOn ? tr('logOn') : tr('logOff'))),
10873
10982
  h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, 'v' + PALETTE_VERSION),
10874
10983
  updateButton,
10875
10984
  h('a', {
@@ -10920,6 +11029,18 @@ function createClient(slotTarget) {
10920
11029
  ]),
10921
11030
  h('div', { style: { fontSize: 12, color: muted } },
10922
11031
  tr('subtitle')),
11032
+ // 弹窗之外的可见结果:检查/升级失败、以及「装好了但没进待重启」都在这里落地,
11033
+ // 否则用户点一下没动静,分不清是成功、失败还是没反应。
11034
+ (updState && updState.notice)
11035
+ ? h('div', {
11036
+ style: {
11037
+ fontSize: 12,
11038
+ color: updState.notice.kind === 'warn'
11039
+ ? 'var(--dsw-alias-state-warn-primary)'
11040
+ : 'var(--dsw-alias-state-error-primary)',
11041
+ },
11042
+ }, tr(updState.notice.key))
11043
+ : null,
10923
11044
  // ── 排印调节(置顶)──
10924
11045
  h('div', { style: secTitle }, [
10925
11046
  h('span', null, tr('typography')),
@@ -10989,6 +11110,11 @@ function createClient(slotTarget) {
10989
11110
  // 检查更新:控制器 + 日志器交给面板(宿主不可用时 update.available 为假,按钮不渲染)
10990
11111
  update: update,
10991
11112
  log: clientLog,
11113
+ // 日志开关的初始状态:复用启动时那次对账,别为显示一个开关再打扰一次宿主
11114
+ reconcileLog: function () {
11115
+ if (logSwitchReady && typeof logSwitchReady.then === 'function') return logSwitchReady
11116
+ try { return clientLog.reconcileLogSwitch() } catch (e) { return Promise.resolve(null) }
11117
+ },
10992
11118
  // 本机字体清单:面板打开下拉时调(要用户手势),未打开下拉不读;失败恒回退预设
10993
11119
  collectFonts: collectFontCandidates,
10994
11120
  // 面板文案(测试/调试用,走同一份双语表)
@@ -1,42 +1,87 @@
1
- {
2
- "version": 1,
3
- "pluginId": "dsh-opencode-palette",
4
- "counts": { "resident": 7, "ondemand": 0, "selfmon": 0 },
5
- "events": {
6
- "host.call": {
7
- "level": "info",
8
- "kind": "resident",
9
- "fields": ["method", "latencyMs", "ok", "kind", "pluginId"]
10
- },
11
- "host.call.fail": {
12
- "level": "warn",
13
- "kind": "resident",
14
- "fields": ["method", "kind", "errorHash", "pluginId"]
15
- },
16
- "update.install.exec": {
17
- "level": "info",
18
- "kind": "resident",
19
- "fields": ["route", "ok", "exitCode", "durationMs", "pluginId"]
20
- },
21
- "log.forward.summary": {
22
- "level": "warn",
23
- "kind": "resident",
24
- "fields": ["droppedDelta", "totalDropped", "reason", "windowMs"]
25
- },
26
- "log.switch.watchdog": {
27
- "level": "warn",
28
- "kind": "resident",
29
- "fields": ["op", "timeoutMs", "stage"]
30
- },
31
- "log.export.fail": {
32
- "level": "warn",
33
- "kind": "resident",
34
- "fields": ["op", "reason", "errorHash"]
35
- },
36
- "host.channel.fail": {
37
- "level": "error",
38
- "kind": "resident",
39
- "fields": ["stage", "path", "reason"]
40
- }
41
- }
42
- }
1
+ {
2
+ "version": 1,
3
+ "pluginId": "dsh-opencode-palette",
4
+ "counts": { "resident": 16, "ondemand": 0, "selfmon": 0 },
5
+ "events": {
6
+ "host.call": {
7
+ "level": "info",
8
+ "kind": "resident",
9
+ "fields": ["method", "latencyMs", "ok", "kind", "pluginId"]
10
+ },
11
+ "host.call.fail": {
12
+ "level": "warn",
13
+ "kind": "resident",
14
+ "fields": ["method", "kind", "errorHash", "pluginId"]
15
+ },
16
+ "update.install.exec": {
17
+ "level": "info",
18
+ "kind": "resident",
19
+ "fields": ["route", "ok", "exitCode", "durationMs", "pluginId"]
20
+ },
21
+ "update.check.start": {
22
+ "level": "info",
23
+ "kind": "resident",
24
+ "fields": ["trigger", "pluginId"]
25
+ },
26
+ "update.check.ok": {
27
+ "level": "info",
28
+ "kind": "resident",
29
+ "fields": ["trigger", "hasNew", "latest", "latencyMs", "pluginId"]
30
+ },
31
+ "update.check.fail": {
32
+ "level": "warn",
33
+ "kind": "resident",
34
+ "fields": ["trigger", "code", "errorHash", "pluginId"]
35
+ },
36
+ "update.check.retry": {
37
+ "level": "warn",
38
+ "kind": "resident",
39
+ "fields": ["attempt", "ok", "reason", "pluginId"]
40
+ },
41
+ "update.check.probe": {
42
+ "level": "warn",
43
+ "kind": "resident",
44
+ "fields": ["stage", "httpStatus", "bytes", "reason", "detail", "pluginId"]
45
+ },
46
+ "update.install.start": {
47
+ "level": "info",
48
+ "kind": "resident",
49
+ "fields": ["latest", "pluginId"]
50
+ },
51
+ "update.install.ok": {
52
+ "level": "info",
53
+ "kind": "resident",
54
+ "fields": ["latest", "pending", "pluginId"]
55
+ },
56
+ "update.install.fail": {
57
+ "level": "warn",
58
+ "kind": "resident",
59
+ "fields": ["code", "errorHash", "pluginId"]
60
+ },
61
+ "log.switch.set": {
62
+ "level": "info",
63
+ "kind": "resident",
64
+ "fields": ["enabled", "ok", "reason", "pluginId"]
65
+ },
66
+ "log.forward.summary": {
67
+ "level": "warn",
68
+ "kind": "resident",
69
+ "fields": ["droppedDelta", "totalDropped", "reason", "windowMs"]
70
+ },
71
+ "log.switch.watchdog": {
72
+ "level": "warn",
73
+ "kind": "resident",
74
+ "fields": ["op", "timeoutMs", "stage"]
75
+ },
76
+ "log.export.fail": {
77
+ "level": "warn",
78
+ "kind": "resident",
79
+ "fields": ["op", "reason", "errorHash"]
80
+ },
81
+ "host.channel.fail": {
82
+ "level": "error",
83
+ "kind": "resident",
84
+ "fields": ["stage", "path", "reason"]
85
+ }
86
+ }
87
+ }
package/lib/index.js CHANGED
@@ -26,6 +26,7 @@ import { dirname, join } from 'node:path'
26
26
  import { fileURLToPath } from 'node:url'
27
27
  import { createHostLog, registerHostLogPhones } from 'dsh-log/host'
28
28
  import { createHostUpdate } from './vendor/dsh-plugin-update/host.js'
29
+ import { buildPhoneNames, DEFAULT_REGISTRY } from './vendor/dsh-plugin-update/config.js'
29
30
  import { PLUGIN_ID, PHONE_PREFIX, ROUTE_PATH, TARGET_PACKAGE_NAME } from './channel.mjs'
30
31
 
31
32
  export const name = PLUGIN_ID
@@ -111,6 +112,189 @@ export function createLogFileService(readTextImpl, writeTextImpl, mkdirImpl, unl
111
112
  }
112
113
  }
113
114
 
115
+ // ── 查更新的自检探针与重试(2026-09-30 加;起因:面板点「检查更新」静默无反应,日志只剩一个散列)──
116
+ // 更新包把 fetch→校验链路里每一步失败都归成同一个 invalid-release(service.js 的兜底 catch),
117
+ // 线上因此只剩 8 位散列,分不清是「响应被截断」还是「tarball 指向了别的源」。
118
+ // 探针按同一条链路再走一遍,但一步一记、绝不抛错:把现象写进日志,而不是只给判词。
119
+ // 重试一次:截断、抖动这类瞬时故障本会自愈,用户不该为它再看一次失败。
120
+ /** 探针阈值:必须与更新包 service.js 里的同名常量一致(tests/update-panel.test.mjs 有门禁比对)。 */
121
+ export const PROBE_LIMITS = {
122
+ maxBytes: 256 * 1024,
123
+ integrityPattern: '^sha512-[A-Za-z0-9+/]{86}==$',
124
+ }
125
+
126
+ /** 取一小段文本进日志:够定位就行,别把整份响应写进去。 */
127
+ function probeBrief(value, limit) {
128
+ const text = value === null || value === undefined ? '' : String(value)
129
+ return text.length > limit ? text.slice(0, limit) : text
130
+ }
131
+
132
+ /**
133
+ * 按更新包的同一条链路重走一遍发布清单,把观察到的现象原样回给调用方(不抛错)。
134
+ * @returns {Promise<{stage: string, httpStatus: number|null, bytes: number, reason: string, detail: string}>}
135
+ */
136
+ export async function probeRelease(fetchImpl, options) {
137
+ const opts = options || {}
138
+ const targetName = String(opts.targetPackageName || TARGET_PACKAGE_NAME)
139
+ const registry = String(opts.registryUrl || DEFAULT_REGISTRY)
140
+ const timeoutMs = typeof opts.timeoutMs === 'number' && opts.timeoutMs > 0 ? opts.timeoutMs : 10000
141
+ const limits = opts.limits || PROBE_LIMITS
142
+ const facts = { stage: 'fetch', httpStatus: null, bytes: 0, reason: 'fetch-threw', detail: '' }
143
+
144
+ let response = null
145
+ try {
146
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
147
+ ? AbortSignal.timeout(timeoutMs)
148
+ : undefined
149
+ response = await fetchImpl(`${registry}${encodeURIComponent(targetName)}/latest`, {
150
+ headers: { accept: 'application/json' },
151
+ redirect: 'error',
152
+ signal: signal,
153
+ })
154
+ } catch (e) {
155
+ facts.detail = probeBrief((e && e.message) || e, 120)
156
+ return facts
157
+ }
158
+
159
+ facts.stage = 'status'
160
+ facts.httpStatus = response && typeof response.status === 'number' ? response.status : null
161
+ if (!response || response.ok !== true) {
162
+ facts.reason = 'not-ok'
163
+ return facts
164
+ }
165
+
166
+ facts.stage = 'body'
167
+ let text = ''
168
+ try {
169
+ text = await response.text()
170
+ } catch (e) {
171
+ facts.reason = 'body-threw'
172
+ facts.detail = probeBrief((e && e.message) || e, 120)
173
+ return facts
174
+ }
175
+ facts.bytes = Buffer.byteLength(text, 'utf8')
176
+ if (facts.bytes > limits.maxBytes) {
177
+ facts.reason = 'body-too-large'
178
+ return facts
179
+ }
180
+
181
+ facts.stage = 'json'
182
+ let value = null
183
+ try {
184
+ value = JSON.parse(text)
185
+ } catch (e) {
186
+ facts.reason = 'json-parse'
187
+ facts.detail = probeBrief(text.slice(0, 60), 60)
188
+ return facts
189
+ }
190
+
191
+ facts.stage = 'name'
192
+ if (value.name !== targetName) {
193
+ facts.reason = 'name-mismatch'
194
+ facts.detail = probeBrief(value.name, 60)
195
+ return facts
196
+ }
197
+
198
+ facts.stage = 'version'
199
+ if (!(typeof value.version === 'string' && /^\d+\.\d+\.\d+$/.test(value.version))) {
200
+ facts.reason = 'version-invalid'
201
+ facts.detail = probeBrief(value.version, 40)
202
+ return facts
203
+ }
204
+
205
+ facts.stage = 'engines'
206
+ if (value.engines && value.engines.node !== undefined && typeof value.engines.node !== 'string') {
207
+ facts.reason = 'engines-node-type'
208
+ return facts
209
+ }
210
+
211
+ facts.stage = 'tarball'
212
+ let tarball = null
213
+ try {
214
+ tarball = new URL(String(value.dist && value.dist.tarball))
215
+ } catch (e) {
216
+ facts.reason = 'tarball-url'
217
+ facts.detail = probeBrief(value.dist && value.dist.tarball, 80)
218
+ return facts
219
+ }
220
+ const wantPath = `/${targetName}/-/${targetName}-${value.version}.tgz`
221
+ if (tarball.origin !== new URL(registry).origin) {
222
+ facts.reason = 'tarball-origin'
223
+ facts.detail = probeBrief(tarball.origin, 80)
224
+ return facts
225
+ }
226
+ if (tarball.pathname !== wantPath) {
227
+ facts.reason = 'tarball-path'
228
+ facts.detail = probeBrief(tarball.pathname, 80)
229
+ return facts
230
+ }
231
+
232
+ facts.stage = 'integrity'
233
+ const integrity = value.dist && value.dist.integrity
234
+ if (typeof integrity !== 'string') {
235
+ facts.reason = 'integrity-missing'
236
+ return facts
237
+ }
238
+ if (!new RegExp(limits.integrityPattern).test(integrity)) {
239
+ facts.reason = 'integrity-shape'
240
+ facts.detail = probeBrief(integrity, 40)
241
+ return facts
242
+ }
243
+
244
+ facts.stage = 'ok'
245
+ facts.reason = 'valid'
246
+ return facts
247
+ }
248
+
249
+ /** 从更新包的回包形状里取错误码(没有就退成 unknown)。
250
+ * 更新包的失败形状是 {ok:false, error:'<code>', errorKind}——error 是字符串,不是对象。 */
251
+ function errorCodeOf(res) {
252
+ const error = res && res.error !== undefined && res.error !== null ? res.error : null
253
+ if (typeof error === 'string') return error.slice(0, 40)
254
+ const code = error && (error.code || error.message) ? String(error.code || error.message) : 'unknown'
255
+ return code.slice(0, 40)
256
+ }
257
+
258
+ /**
259
+ * 给「查更新」这条电话包一层:失败 → 重试一次 → 仍失败则跑探针并落日志。
260
+ * 只包这一条:查状态与装更新各自有明确的失败语义,重试它们只会拖长用户的等待。
261
+ */
262
+ export function wrapUpdateCheck(handler, deps) {
263
+ const input = deps || {}
264
+ const log = typeof input.log === 'function' ? input.log : function () {}
265
+ const probe = typeof input.probe === 'function' ? input.probe : null
266
+ const pluginId = input.pluginId || PLUGIN_ID
267
+ return async function (args) {
268
+ const first = await handler(args)
269
+ if (first && first.ok === true) return first
270
+ const reason = errorCodeOf(first)
271
+ log('warn', 'update.check.retry', { attempt: 1, ok: false, reason: reason, pluginId: pluginId })
272
+ let second = null
273
+ try {
274
+ second = await handler(args)
275
+ } catch (e) {
276
+ second = null
277
+ }
278
+ const recovered = !!(second && second.ok === true)
279
+ log('warn', 'update.check.retry', { attempt: 2, ok: recovered, reason: reason, pluginId: pluginId })
280
+ if (recovered) return second
281
+ if (probe !== null) {
282
+ try {
283
+ const facts = await probe()
284
+ log('warn', 'update.check.probe', {
285
+ stage: facts.stage,
286
+ httpStatus: facts.httpStatus,
287
+ bytes: facts.bytes,
288
+ reason: facts.reason,
289
+ detail: facts.detail,
290
+ pluginId: pluginId,
291
+ })
292
+ } catch (e) { /* 探针自己失败不该改变回给面板的结果 */ }
293
+ }
294
+ return second || first
295
+ }
296
+ }
297
+
114
298
  /** 宿主半装配入口:建日志库 → 建更新电话 → 合成电话表 → 注册通道。 */
115
299
  export function apply(ctx) {
116
300
  const homeDir = dshHomeDir(process.env, homedir())
@@ -144,6 +328,20 @@ export function apply(ctx) {
144
328
  )
145
329
  for (const phoneName of Object.keys(update.handlers)) registry.set(phoneName, update.handlers[phoneName])
146
330
 
331
+ // 查更新外面再包一层:失败重试一次,仍失败就跑探针把真实原因落日志。
332
+ // 电话名从更新包派生(本文件不写字面量),装更新与查状态两条保持原样。
333
+ const updateCheckPhone = buildPhoneNames(PHONE_PREFIX).updateCheck
334
+ const rawUpdateCheck = registry.get(updateCheckPhone)
335
+ if (typeof rawUpdateCheck === 'function') {
336
+ registry.set(updateCheckPhone, wrapUpdateCheck(rawUpdateCheck, {
337
+ log: function (level, event, fields) {
338
+ try { hostLog.store.log(level, event, fields) } catch (e) { /* 忽略 */ }
339
+ },
340
+ probe: function () { return probeRelease(globalThis.fetch, { targetPackageName: TARGET_PACKAGE_NAME }) },
341
+ pluginId: PLUGIN_ID,
342
+ }))
343
+ }
344
+
147
345
  const fail = function (stage, reason) {
148
346
  try { hostLog.store.log('error', 'host.channel.fail', { stage: stage, path: ROUTE_PATH, reason: String(reason).slice(0, 120) }) } catch (e) { /* 忽略 */ }
149
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-opencode-palette",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "description": "让 DeepSeek Harness 穿上 38 款经典皮肤——东京的霓虹夜色、德古拉的暗红月光、复古工坊的暖黄灯火、黑客帝国的数字雨、玫瑰松林间的风……一键换肤,即点即换,重启不丢。38 legendary skins for DeepSeek Harness — tokyonight's neon dusk, dracula's crimson moon, gruvbox's retro glow, the matrix's digital rain, rose-pine's rosewood calm… one click, instant, persisted.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",