dsh-opencode-palette 2.0.5 → 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/README.md CHANGED
@@ -45,7 +45,7 @@ dsh plugin --profile web add dsh-opencode-palette
45
45
 
46
46
  <h2 align="center"><sub>EXTENSIONS</sub><br>What the opencode themes come with</h2>
47
47
 
48
- **Plenty of fonts.** Typography is its own dimension: apply it to all text or code only, 11–18 px, your pick of code font — orthogonal to color, kept across restarts.
48
+ **Plenty of fonts.** Typography is its own dimension: apply it to all text or code only, 11–24 px, your pick of code font — orthogonal to color, kept across restarts.
49
49
 
50
50
  **A panel you can find.** 38 entries grouped by color family, searchable, and following your DSH interface language (中文 / English).
51
51
 
package/lib/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-opencode-palette v2.0.5 — 浏览器半(构建产物,勿手改)
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 的客户端入口
@@ -1525,24 +1525,32 @@ function buildTypographyCss(typography) {
1525
1525
  const bodyFont = mode === 'mono' ? codeFont : SANS_STACK
1526
1526
  const lh = size + 9
1527
1527
  const small = size - 1
1528
+ // 标题阶梯跟随 size(size=13 时恰好还原旧固定值 16/15/14,避免默认视觉漂移):
1529
+ // H1 = size+3 / lh+2,H2 = size+2 / lh,H3 = size+1 / lh-1
1530
+ const h1 = size + 3
1531
+ const h1lh = lh + 2
1532
+ const h2 = size + 2
1533
+ const h2lh = lh
1534
+ const h3 = size + 1
1535
+ const h3lh = lh - 1
1528
1536
  return [FONT_FACE_CSS,
1529
1537
  'body,body[data-ds-dark-theme]{',
1530
1538
  '--dsw-font-family:' + bodyFont + ';',
1531
1539
  '--ds-font-family-code:' + codeFont + ';',
1532
- // F4 新卡片字号(DSH 0.1.7 CodeCard .card/.body 只读此变量;size=13 默认恰好还原上游 11px/19px,跟随 11–18 档)
1540
+ // F4 新卡片字号(DSH 0.1.7 CodeCard .card/.body 只读此变量;size=13 默认恰好还原上游 11px/19px,跟随 11–24 档)
1533
1541
  '--dsw-font-markdown-code-block:' + (size - 2) + 'px/' + (size + 6) + 'px var(--ds-font-family-code);',
1534
1542
  '--dsw-font-markdown-base:' + size + 'px/' + lh + 'px var(--dsw-font-family);',
1535
1543
  '--dsw-font-markdown-base-font-size:' + size + 'px;',
1536
1544
  '--dsw-font-markdown-base-line-height:' + lh + 'px;',
1537
- '--dsw-font-markdown-h1:700 16px/24px var(--dsw-font-family);',
1538
- '--dsw-font-markdown-h1-font-size:16px;',
1539
- '--dsw-font-markdown-h1-line-height:24px;',
1540
- '--dsw-font-markdown-h2:700 15px/22px var(--dsw-font-family);',
1541
- '--dsw-font-markdown-h2-font-size:15px;',
1542
- '--dsw-font-markdown-h2-line-height:22px;',
1543
- '--dsw-font-markdown-h3:600 14px/21px var(--dsw-font-family);',
1544
- '--dsw-font-markdown-h3-font-size:14px;',
1545
- '--dsw-font-markdown-h3-line-height:21px;',
1545
+ '--dsw-font-markdown-h1:700 ' + h1 + 'px/' + h1lh + 'px var(--dsw-font-family);',
1546
+ '--dsw-font-markdown-h1-font-size:' + h1 + 'px;',
1547
+ '--dsw-font-markdown-h1-line-height:' + h1lh + 'px;',
1548
+ '--dsw-font-markdown-h2:700 ' + h2 + 'px/' + h2lh + 'px var(--dsw-font-family);',
1549
+ '--dsw-font-markdown-h2-font-size:' + h2 + 'px;',
1550
+ '--dsw-font-markdown-h2-line-height:' + h2lh + 'px;',
1551
+ '--dsw-font-markdown-h3:600 ' + h3 + 'px/' + h3lh + 'px var(--dsw-font-family);',
1552
+ '--dsw-font-markdown-h3-font-size:' + h3 + 'px;',
1553
+ '--dsw-font-markdown-h3-line-height:' + h3lh + 'px;',
1546
1554
  '--dsw-font-markdown-small:' + small + 'px/' + (small + 8) + 'px var(--dsw-font-family);',
1547
1555
  '--dsw-font-markdown-small-font-size:' + small + 'px;',
1548
1556
  '--dsw-font-markdown-small-line-height:' + (small + 8) + 'px;',
@@ -9716,6 +9724,9 @@ __mods["channel"] = { CHANNEL, ENDPOINT, ROUTE_PATH, PLUGIN_ID, PHONE_PREFIX, TA
9716
9724
  * 电话名与轮询间隔由调用方从更新包的客户端入口派生后传进来(本文件不写字面量)。
9717
9725
  */
9718
9726
 
9727
+ /** 与 runtime/channel.mjs 的 PLUGIN_ID 同值:本文件是纯逻辑模块,不引运行时模块,故就地声明一次。 */
9728
+ const PLUGIN_ID = 'dsh-opencode-palette'
9729
+
9719
9730
  /** 宿主回包 → 面板要的几个事实(纯函数,便于离线核对)。 */
9720
9731
  function readSnapshot(res) {
9721
9732
  const snap = res && res.snapshot ? res.snapshot : null
@@ -9797,6 +9808,9 @@ function createUpdateController(deps) {
9797
9808
  jobMessage: null,
9798
9809
  pending: false,
9799
9810
  failure: null,
9811
+ // 弹窗外的可见结果({key, kind} 或 null):失败与「装好了但没进待重启」都走这里,
9812
+ // 否则用户点一下没动静,看不出是成功、失败还是没反应(2026-09-30 真机反馈)。
9813
+ notice: null,
9800
9814
  }
9801
9815
  let pollTimer = null
9802
9816
  const listeners = []
@@ -9813,6 +9827,14 @@ function createUpdateController(deps) {
9813
9827
  function record(level, event, fields, latencyMs) {
9814
9828
  try { log(level, event, fields) } catch (e) { /* 忽略 */ }
9815
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
+ }
9816
9838
  function stopPolling() {
9817
9839
  if (pollTimer !== null) {
9818
9840
  try { clearTimer(pollTimer) } catch (e) { /* 忽略 */ }
@@ -9853,7 +9875,7 @@ function createUpdateController(deps) {
9853
9875
  method: phone,
9854
9876
  kind: kind,
9855
9877
  errorHash: hash8(String((error && error.message) || error || kind)),
9856
- pluginId: 'dsh-opencode-palette',
9878
+ pluginId: PLUGIN_ID,
9857
9879
  })
9858
9880
  }
9859
9881
 
@@ -9866,7 +9888,7 @@ function createUpdateController(deps) {
9866
9888
  latencyMs: Date.now() - startedAt,
9867
9889
  ok: true,
9868
9890
  kind: String((args && args.kind) || phone),
9869
- pluginId: 'dsh-opencode-palette',
9891
+ pluginId: PLUGIN_ID,
9870
9892
  })
9871
9893
  } else {
9872
9894
  transportFail(phone, 'not-ok', (res && res.error) || 'not-ok')
@@ -9892,19 +9914,34 @@ function createUpdateController(deps) {
9892
9914
  if (!state.available) return 'failed'
9893
9915
  if (state.checking || state.installing) return 'busy'
9894
9916
  // 已查到有新版且凭证还在:直接开弹窗,不重复联网
9895
- if (state.hasNew && state.checkId) { patch({ dialogOpen: true, failure: null }); return 'new' }
9896
- 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()
9897
9921
  try {
9898
9922
  const res = await invoke(phones.updateCheck, {})
9899
9923
  patch({ checking: false })
9900
- 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
+ }
9901
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
+ })
9902
9935
  if (facts.canInstall) { patch({ dialogOpen: true }); return 'new' }
9903
9936
  if (facts.pending) return 'latest'
9904
9937
  return 'latest'
9905
9938
  } catch (e) {
9906
9939
  patch({ checking: false })
9907
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' } })
9908
9945
  return 'failed'
9909
9946
  }
9910
9947
  }
@@ -9918,16 +9955,29 @@ function createUpdateController(deps) {
9918
9955
  if (state.pending) return 'latest'
9919
9956
  if (state.hasNew && state.checkId) return 'new'
9920
9957
  patch({ checking: true, failure: null })
9958
+ record('info', 'update.check.start', { trigger: 'auto', pluginId: PLUGIN_ID })
9959
+ const startedAt = Date.now()
9921
9960
  try {
9922
9961
  const res = await invoke(phones.updateCheck, {})
9923
9962
  patch({ checking: false })
9924
- 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
+ }
9925
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
+ })
9926
9973
  if (facts.canInstall) return 'new'
9927
9974
  return 'latest'
9928
9975
  } catch (e) {
9929
9976
  patch({ checking: false })
9930
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
+ })
9931
9981
  return 'failed'
9932
9982
  }
9933
9983
  }
@@ -9946,23 +9996,38 @@ function createUpdateController(deps) {
9946
9996
  async function install() {
9947
9997
  if (!state.available || state.installing) return false
9948
9998
  if (!state.checkId) return false
9949
- 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 })
9950
10001
  const requestId = 'req-' + String(Date.now()) + '-' + String(Math.floor(Math.random() * 100000))
9951
10002
  try {
9952
10003
  const res = await invoke(phones.updateInstall, { checkId: state.checkId, requestId: requestId })
9953
10004
  patch({ installing: false })
9954
10005
  if (res && res.ok === true) {
9955
- absorb(res)
10006
+ const facts = absorb(res)
9956
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' } })
9957
10015
  return true
9958
10016
  }
9959
10017
  const facts = readSnapshot(res)
10018
+ const code = codeOf(res)
9960
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' } })
9961
10022
  await readStatus()
9962
10023
  return false
9963
10024
  } catch (e) {
9964
10025
  patch({ installing: false, failure: 'install-failed' })
9965
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' } })
9966
10031
  try { await readStatus() } catch (e2) { /* 忽略 */ }
9967
10032
  return false
9968
10033
  }
@@ -9984,6 +10049,8 @@ function createUpdateController(deps) {
9984
10049
  install: install,
9985
10050
  openDialog: function () { patch({ dialogOpen: true }) },
9986
10051
  closeDialog: function () { patch({ dialogOpen: false, failure: null }) },
10052
+ /** 面板其它部件(如日志开关)也能借这行可见提示,别让它们的失败静默掉。 */
10053
+ setNotice: function (key, kind) { patch({ notice: key ? { key: key, kind: kind || 'error' } : null }) },
9987
10054
  dispose: function () { stopPolling(); listeners.length = 0 },
9988
10055
  }
9989
10056
  }
@@ -10023,7 +10090,7 @@ const STORAGE_KEY = 'dsh.opencode-palette.v2'
10023
10090
  const LEGACY_STORAGE_KEY = 'dsh.opencode-tui-theme.v2'
10024
10091
  const DEFAULT_STATE = { enabled: true, theme: 'opencode', mode: 'mono', size: 13, fontKey: 'JetBrains Mono' }
10025
10092
  // 构建时由 scripts/build-client.mjs 替换为 package.json 版本(面板底部署小字)
10026
- const PALETTE_VERSION = '2.0.5'
10093
+ const PALETTE_VERSION = '2.0.7'
10027
10094
 
10028
10095
  function getReact() {
10029
10096
  if (typeof require === 'function') { try { return require('react') } catch (e) { /* 动态版无 require */ } }
@@ -10167,6 +10234,14 @@ const I18N = {
10167
10234
  'blocked.incompatible-node': { zh: '新版要求的 Node 更高,先升级 Node', en: 'The new version needs a newer Node — upgrade it first' },
10168
10235
  'blocked.recovery-required': { zh: '上次安装被打断,重新点一次安装', en: 'The last install was interrupted — retry the install' },
10169
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' },
10170
10245
  }
10171
10246
 
10172
10247
  // 语言检测(回退):html[lang] 优先,回退浏览器语言
@@ -10250,8 +10325,10 @@ function createClient(slotTarget) {
10250
10325
  pollMs: CLIENT_POLL.defaultMs,
10251
10326
  log: function (level, event, fields) { try { clientLog.log(level, event, fields) } catch (e) { /* 忽略 */ } },
10252
10327
  })
10253
- // 启动时向宿主对账调试开关(以宿主为准);宿主不可用时静默,不抛错
10254
- try { clientLog.reconcileLogSwitch() } catch (e) { /* 忽略 */ }
10328
+ // 启动时向宿主对账调试开关(以宿主为准);宿主不可用时静默,不抛错。
10329
+ // 面板的日志开关复用这一次对账的结果,不重复打扰宿主(对账失败会记一条 watchdog warn)。
10330
+ let logSwitchReady = null
10331
+ try { logSwitchReady = clientLog.reconcileLogSwitch() } catch (e) { /* 忽略 */ }
10255
10332
  try { clientLog.log('info', 'host.call', { method: 'boot', latencyMs: 0, ok: true, kind: 'boot', pluginId: PLUGIN_ID }) } catch (e) { /* 忽略 */ }
10256
10333
 
10257
10334
  // ── i18n 运行时:语言跟随 DSH(官方 locale 服务为信号源,html[lang] 仅作回退)──
@@ -10560,6 +10637,36 @@ function createClient(slotTarget) {
10560
10637
  }, tr('fontRetry')) : null,
10561
10638
  ])
10562
10639
  : null
10640
+ // 分组标题:非点击的分区线(替代旧“本机字体 · 90”文本分隔)。
10641
+ // 文本用主题色 var(--dsw-alias-brand-primary)(随当前 opencode 主题走 primary),
10642
+ // 数量做成描边徽章 + 中间细分割线,不再用“·”拼接字符串。
10643
+ const secHeader = function (key, label, count) {
10644
+ const badge = (count === null || count === undefined) ? null : h('span', {
10645
+ key: 'n',
10646
+ style: {
10647
+ fontSize: 11, lineHeight: '16px', padding: '0 7px', borderRadius: 999,
10648
+ border: '1px solid var(--dsw-alias-brand-primary)',
10649
+ color: 'var(--dsw-alias-brand-primary)', background: 'transparent', flex: 'none',
10650
+ },
10651
+ }, String(count))
10652
+ return h('div', {
10653
+ key: key,
10654
+ style: {
10655
+ display: 'flex', alignItems: 'center', gap: 8,
10656
+ padding: '10px 10px 4px', cursor: 'default', userSelect: 'none',
10657
+ },
10658
+ }, [
10659
+ h('span', {
10660
+ key: 't',
10661
+ style: {
10662
+ fontSize: 11, fontWeight: 600, letterSpacing: '.08em', whiteSpace: 'nowrap',
10663
+ color: 'var(--dsw-alias-brand-primary)',
10664
+ },
10665
+ }, label),
10666
+ h('span', { key: 'l', style: { flex: '1 1 auto', height: 1, background: 'var(--dsw-alias-border-l1)', borderRadius: 1 } }),
10667
+ badge,
10668
+ ])
10669
+ }
10563
10670
  const menuItem = function (key, label, onClick) {
10564
10671
  return h('div', {
10565
10672
  key: key,
@@ -10576,11 +10683,11 @@ function createClient(slotTarget) {
10576
10683
  const locals = all.slice(0, 400)
10577
10684
  const rows = []
10578
10685
  if (presets.length > 0) {
10579
- rows.push(menuItem('sec-presets', tr('fontPresets'), function () {}))
10686
+ rows.push(secHeader('sec-presets', tr('fontPresets'), presets.length))
10580
10687
  for (const k of presets) rows.push(fontItem(k))
10581
10688
  }
10582
10689
  if (locals.length > 0) {
10583
- rows.push(menuItem('sec-locals', tr('fontLocals') + ' · ' + all.length, function () {}))
10690
+ rows.push(secHeader('sec-locals', tr('fontLocals'), all.length))
10584
10691
  for (const k of locals) rows.push(fontItem(k))
10585
10692
  }
10586
10693
  if (rows.length === 0) rows.push(menuItem('no-match', tr('fontNoMatch'), function () {}))
@@ -10826,32 +10933,78 @@ function createClient(slotTarget) {
10826
10933
  ]))
10827
10934
  : null
10828
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
+
10829
10967
  return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 920 } }, [
10830
10968
  restartBanner,
10831
10969
  // 头行:标题 + 状态开关(一个状态一个控制)
10832
10970
  h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } }, [
10833
10971
  h('strong', null, '🎨 ' + tr('panelName')),
10834
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'))),
10835
10982
  h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, 'v' + PALETTE_VERSION),
10836
10983
  updateButton,
10837
10984
  h('a', {
10838
10985
  href: 'https://github.com/FeatherHunter/dsh-opencode-palette',
10839
10986
  target: '_blank', rel: 'noopener noreferrer',
10840
10987
  title: '你的 ⭐是我夜空中最亮的星 🌹',
10841
- style: { color: muted, display: 'inline-flex', cursor: 'pointer', fontSize: 15, lineHeight: 1, textDecoration: 'none' },
10842
- }, '🌟'),
10843
- // ISSUE 入口:消息气泡形态(信息图标认不出「提需求」,气泡才读得出是反馈)
10988
+ style: { display: 'inline-flex', cursor: 'pointer', lineHeight: 1, textDecoration: 'none' },
10989
+ }, h('svg', {
10990
+ width: 15, height: 15, viewBox: '0 0 24 24',
10991
+ style: { display: 'block' },
10992
+ }, h('path', {
10993
+ d: 'M12 2.5l2.92 6.14 6.58.6-4.93 4.4 1.42 6.46L12 16.77l-5.99 3.33 1.42-6.46-4.93-4.4 6.58-.6L12 2.5z',
10994
+ fill: '#FFC53D', stroke: '#B45309', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
10995
+ }))),
10996
+ // ISSUE 入口:彩色消息气泡(信息图标认不出「提需求」,气泡才读得出是反馈;浅底深底都可见的定值配色)
10844
10997
  h('a', {
10845
10998
  href: 'https://github.com/FeatherHunter/dsh-opencode-palette/issues',
10846
10999
  target: '_blank', rel: 'noopener noreferrer',
10847
11000
  title: '任何功能需求、故障、建议、意见都可以提ISSUE',
10848
- style: { color: muted, display: 'inline-flex', cursor: 'pointer' },
11001
+ style: { display: 'inline-flex', cursor: 'pointer' },
10849
11002
  }, h('svg', {
10850
- width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none',
10851
- stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
11003
+ width: 14, height: 14, viewBox: '0 0 24 24',
10852
11004
  style: { display: 'block' },
10853
11005
  }, h('path', {
10854
11006
  d: 'M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z',
11007
+ fill: '#7DD3FC', stroke: '#0369A1', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
10855
11008
  }))),
10856
11009
  h('span', { style: { color: st.enabled ? 'var(--dsw-alias-state-success-primary)' : muted, fontSize: 12 } },
10857
11010
  st.enabled ? tr('enabled') : tr('disabled')),
@@ -10876,6 +11029,18 @@ function createClient(slotTarget) {
10876
11029
  ]),
10877
11030
  h('div', { style: { fontSize: 12, color: muted } },
10878
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,
10879
11044
  // ── 排印调节(置顶)──
10880
11045
  h('div', { style: secTitle }, [
10881
11046
  h('span', null, tr('typography')),
@@ -10887,7 +11052,7 @@ function createClient(slotTarget) {
10887
11052
  ], function (v) { props.refresh(v, st.size, st.fontKey); setUi(props.getState()) }),
10888
11053
  dd(sizeOpen, setSizeOpen, sizeRef,
10889
11054
  h('span', null, tr('fontSize') + ' ' + st.size + 'px'),
10890
- [11, 12, 13, 14, 15, 16, 17, 18].map(function (s) {
11055
+ [11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24].map(function (s) {
10891
11056
  const on = s === st.size
10892
11057
  return h('div', {
10893
11058
  key: String(s),
@@ -10945,6 +11110,11 @@ function createClient(slotTarget) {
10945
11110
  // 检查更新:控制器 + 日志器交给面板(宿主不可用时 update.available 为假,按钮不渲染)
10946
11111
  update: update,
10947
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
+ },
10948
11118
  // 本机字体清单:面板打开下拉时调(要用户手势),未打开下拉不读;失败恒回退预设
10949
11119
  collectFonts: collectFontCandidates,
10950
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.5",
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",
@@ -36,7 +36,7 @@
36
36
  "dsh-log": "0.2.1"
37
37
  },
38
38
  "engines": {
39
- "dsh": ">=0.1.0-rc.6 <0.2.0-0"
39
+ "dsh": ">=0.2.0-rc.1"
40
40
  },
41
41
  "dsh": {
42
42
  "bundle": {