dsh-whale-widget 0.3.10 → 0.3.12

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
@@ -353,6 +353,10 @@ curl http://127.0.0.1:3080/dsh-whale/audio.json
353
353
  ## 常见问题
354
354
 
355
355
  - **挂件不出现**:确认安装命令成功;`dsh --profile web --dump-config` 里能看到 `dsh-whale-widget`;重启 `dsh web` 后 F5。
356
+ - **本插件所有接口(`/dsh-whale/*`)的访问校验**:默认只接受**回环地址**(`127.0.0.1` / `localhost` / `[::1]`)的请求,并拒绝跨站标记(`Sec-Fetch-Site: cross-site`)与 Origin 和 Host 不同源的请求 —— 这是防「恶意网页读写本机接口」的信任栅栏(issue #92 / #136)。如果你把 `dsh web` 放在**反向代理或局域网地址**后面,请用环境变量声明允许的 Host(逗号分隔,可带端口),否则会被 403:
357
+ ```bash
358
+ DSHW_TRUSTED_HOSTS=dsh.example.com,10.0.0.5:3080 dsh web
359
+ ```
356
360
  - **图片/音效不显示、没声音**:确认插件包内 `assets/` 完整(`DSniang1.png`、`*.mp3`、`minecraft-exp-orb.wav` 等);缺失时相关功能静默降级。
357
361
  - **余额报「未配置 DEEPSEEK_API_KEY」**:去 DSH 凭据里配置。
358
362
  - **今日已用显示 `--`**:先等一次成功的余额观测;统计从该观测时刻开始,起点之前的消费不在此区间内。
@@ -322,7 +322,12 @@ var css = [
322
322
  '.dshwv-root.dshwv-left{transform:scaleX(-1)}',
323
323
  '.dshwv-root.dshwv-dragging{cursor:grabbing;transition:none}',
324
324
  '.dshwv-body{position:absolute;left:0;top:0;width:100%;height:100%;transform-origin:50% 100%;transition:transform .22s cubic-bezier(.34,1.56,.64,1)}',
325
- '.dshwv-img{position:absolute;right:0;bottom:0;width:59.45%;height:59.45%;display:block;pointer-events:none;-webkit-user-drag:none;user-select:none;object-fit:contain;object-position:right bottom}',
325
+ // v757(issue #147):`.dshwv-img` 由 `pointer-events:none` 改为 `auto` —— 鲸鱼身体**自己接指针事件**。
326
+ // 原来靠"主文档监听 + isWhaleHit()"判定命中,而指针落在 `<iframe>`(如右侧栏 HTML 预览)上时
327
+ // 事件直接进入 iframe 自己的文档,主文档收不到 ⇒ 鲸鱼身体失联(点不动、拖不动,只有 ☰ 能用)。
328
+ // 代价是 img 的**矩形**(含透明边距)会吞掉点击 → 由 setupHitTest() 用命中图的**凸包**做 clip-path
329
+ // 裁掉透明区(凸包包含全部不透明像素,不会裁到角色本身),"点到透明处穿透到下层"的行为得以保留。
330
+ '.dshwv-img{position:absolute;right:0;bottom:0;width:59.45%;height:59.45%;display:block;pointer-events:auto;-webkit-user-drag:none;user-select:none;object-fit:contain;object-position:right bottom}',
326
331
  // v751(PR #119):光标不再写 document.body.style.cursor —— cursor 是可继承属性,写 <body> 会让 Blink
327
332
  // 失效**整棵文档树**的样式;而它在点击链路上按下/抬手各写一次,紧接着 isWhaleHit() 的
328
333
  // getBoundingClientRect() 与泡泡行测量的 getComputedStyle()/scrollWidth 会强制刷新样式+布局,
@@ -8722,8 +8727,35 @@ function visibleTopZ() {
8722
8727
  try { n = eff(getters[i]()) } catch (err) { n = 0 }
8723
8728
  if (n > top) top = n
8724
8729
  }
8730
+ // v756(issue #142):给第三方 fork / 以后的扩展模块留一个**运行时登记口** ——
8731
+ // 它们新增的浮层不必再回上游源码里插一行,只要 `window.dshwRegisterMask(el)` 登记一次,
8732
+ // 这里就会把它算进"当前可见最高层"(隐藏的会被 eff() 判成 0)。返回一个注销函数。
8733
+ try {
8734
+ var extra = dshwExtraMasks || []
8735
+ for (var k = 0; k < extra.length; k++) {
8736
+ var n2 = 0
8737
+ try { n2 = eff(extra[k]) } catch (err) { n2 = 0 }
8738
+ if (n2 > top) top = n2
8739
+ }
8740
+ } catch (err) {}
8725
8741
  return top
8726
8742
  }
8743
+ // 运行时登记的额外浮层(issue #142)。只存引用,不持有任何别的东西;登记失败的入口一律静默忽略。
8744
+ var dshwExtraMasks = []
8745
+ try {
8746
+ window.dshwRegisterMask = function (el) {
8747
+ try {
8748
+ if (!el || dshwExtraMasks.indexOf(el) >= 0) return function () {}
8749
+ dshwExtraMasks.push(el)
8750
+ return function () {
8751
+ try {
8752
+ var i = dshwExtraMasks.indexOf(el)
8753
+ if (i >= 0) dshwExtraMasks.splice(i, 1)
8754
+ } catch (err) {}
8755
+ }
8756
+ } catch (err) { return function () {} }
8757
+ }
8758
+ } catch (err) {}
8727
8759
  // 「永远在打开它的那个窗口之上」:取 本层段起点 与 当前可见最高层+10 的较大值。
8728
8760
  // 用在裁剪 / GIF / 音频裁剪 / 模块编辑器这些**既可能从主菜单(10000)打开、也可能从资源管理(20300)、
8729
8761
  // 泡泡编辑器(20500)、模型设置(29000)里打开**的窗口上 —— 固定层号在后者场景会被父窗口盖住。
@@ -12807,7 +12839,14 @@ function dshwvToast(msg) {
12807
12839
  }
12808
12840
  function configSaveFailNotice(detail) {
12809
12841
  try { console.error('[dsh-whale] 设置保存失败:', detail) } catch (err) {}
12810
- dshwvToast('⚠ 设置保存失败:' + String(detail || '').slice(0, 120) +
12842
+ // v756(issue #143):toast 是按 HTML 设计的(其余调用点传的字面量里带 `<br>`),而这里的 detail
12843
+ // 是**动态值**(服务端 JSON 回包的 error 字段 / fetch 异常消息),直接拼进 innerHTML 就是一处
12844
+ // 「动态数据进 HTML 位置」。当前来源都在本地信任边界内、构造不出真实利用,但把 provider 回包、
12845
+ // 模型名之类接进同一个 toast 时它会立刻变成真洞 —— 所以只转义这一个动态值,toast 自身的
12846
+ // `<br>` 保留。
12847
+ var safe = String(detail || '').slice(0, 120)
12848
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
12849
+ dshwvToast('⚠ 设置保存失败:' + safe +
12811
12850
  '<br>已自动重试一次。若持续失败,请检查 DSH 数据目录是否可写。')
12812
12851
  }
12813
12852
  function configPayload() {
@@ -14832,31 +14871,106 @@ function setupHitTest(url) {
14832
14871
  var dx = 610 - dw // right bottom
14833
14872
  var dy = 610 - dh
14834
14873
  ctx.drawImage(probe, dx, dy, dw, dh)
14874
+ // v756(issue #144):画布读回可能被浏览器**抹白** —— Firefox 开启「隐私保护 / resistFingerprinting」
14875
+ // 时 getImageData 会返回全透明(有些版本直接抛错)。那样 isWhaleHit 的 `data[3] > 10` 处处为假,
14876
+ // 而 hitReady 仍为 true ⇒ 走不到矩形回退分支 ⇒ **整只挂件点不动**。
14877
+ // 这里建立命中图时就扫一遍格子:一个不透明像素都取不到 → 判为命中图不可用,退回矩形判定。
14878
+ var opaque = 0
14879
+ for (var gy = 0; gy < 6; gy++) {
14880
+ for (var gx = 0; gx < 6; gx++) {
14881
+ var px = Math.min(609, Math.floor(dx + dw * (gx + 0.5) / 6))
14882
+ var py = Math.min(609, Math.floor(dy + dh * (gy + 0.5) / 6))
14883
+ if (ctx.getImageData(px, py, 1, 1).data[3] > 10) opaque++
14884
+ }
14885
+ }
14886
+ if (!opaque) { hitReady = false; hitFailed = true; applyHitClip(''); return }
14835
14887
  hitReady = true
14888
+ // v757(issue #147):命中图可用 → 用不透明区域的凸包裁掉 img 的透明边距,
14889
+ // 这样"身体自己接事件"不会把透明角落的点击也吞掉。
14890
+ applyHitClip(buildHitClipPath())
14836
14891
  } catch (err) {
14837
14892
  hitFailed = true
14893
+ applyHitClip('')
14838
14894
  }
14839
14895
  }
14840
14896
  probe.onerror = function () {
14841
14897
  // 图片加载失败:不能把整页当成鲸鱼命中区吞掉事件(会全页面点不动),
14842
14898
  // 标记失败,命中判定退回图像矩形区域。
14843
14899
  hitFailed = true
14900
+ applyHitClip('')
14844
14901
  }
14845
14902
  probe.src = url || IMG_URL
14846
14903
  } catch (err) {}
14847
14904
  }
14905
+ // 退回「图像矩形」判定:命中图不可用(加载失败 / 画布读不出来)时只认挂件图片的矩形区域,
14906
+ // 绝不把整页当成命中区(那会让全页面点不动)。
14907
+ function whaleRectHit(e) {
14908
+ try {
14909
+ var fr = img.getBoundingClientRect()
14910
+ if (!fr || fr.width <= 0 || fr.height <= 0) return false
14911
+ return e.clientX >= fr.left && e.clientX <= fr.right && e.clientY >= fr.top && e.clientY <= fr.bottom
14912
+ } catch (err) { return false }
14913
+ }
14914
+ // —— v757(issue #147):把命中图的不透明区域做成 clip-path ——
14915
+ // `img` 现在常驻 `pointer-events:auto`(压在任何 iframe 上都能接事件),所以要靠 clip-path
14916
+ // 把透明边距裁掉,保住"点到透明处穿透到下层"。用**凸包**而不是逐像素轮廓:凸包天然包含全部
14917
+ // 不透明像素(不会裁到角色本身),点数少(几十个),且左右镜像由外层的 scaleX(-1) 一起变换。
14918
+ function applyHitClip(clip) {
14919
+ try {
14920
+ img.style.clipPath = clip || ''
14921
+ img.style.webkitClipPath = clip || ''
14922
+ } catch (err) {}
14923
+ }
14924
+ function convexHull(pts) {
14925
+ try {
14926
+ var p = pts.slice().sort(function (a, b) { return a[0] - b[0] || a[1] - b[1] })
14927
+ if (p.length < 3) return null
14928
+ var cross = function (o, a, b) { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) }
14929
+ var lower = [], upper = [], i
14930
+ for (i = 0; i < p.length; i++) {
14931
+ while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p[i]) <= 0) lower.pop()
14932
+ lower.push(p[i])
14933
+ }
14934
+ for (i = p.length - 1; i >= 0; i--) {
14935
+ while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p[i]) <= 0) upper.pop()
14936
+ upper.push(p[i])
14937
+ }
14938
+ lower.pop(); upper.pop()
14939
+ return lower.concat(upper)
14940
+ } catch (err) { return null }
14941
+ }
14942
+ function buildHitClipPath() {
14943
+ try {
14944
+ var c = hitCanvas && hitCanvas.getContext ? hitCanvas.getContext('2d') : null
14945
+ if (!c) return ''
14946
+ var data = c.getImageData(0, 0, 610, 610).data // 只读一次(1.5MB),别逐行读
14947
+ var pts = []
14948
+ for (var y = 0; y < 610; y++) {
14949
+ var l = -1, r = -1
14950
+ var base = y * 610 * 4
14951
+ for (var x = 0; x < 610; x++) {
14952
+ if (data[base + x * 4 + 3] > 10) { if (l < 0) l = x; r = x }
14953
+ }
14954
+ if (l >= 0) { pts.push([l, y]); pts.push([r, y]) }
14955
+ }
14956
+ if (pts.length < 8) return ''
14957
+ var hull = convexHull(pts)
14958
+ if (!hull || hull.length < 3) return ''
14959
+ var parts = []
14960
+ for (var k = 0; k < hull.length; k++) {
14961
+ parts.push((hull[k][0] / 610 * 100).toFixed(2) + '% ' + (hull[k][1] / 610 * 100).toFixed(2) + '%')
14962
+ }
14963
+ return 'polygon(' + parts.join(', ') + ')'
14964
+ } catch (err) {
14965
+ return '' // 读不出来 → 不裁(退回矩形行为,与旧版一致)
14966
+ }
14967
+ }
14848
14968
  function isWhaleHit(e) {
14849
14969
  // 命中图未就绪/失败时:绝不默认“全屏都是鲸鱼”。
14850
14970
  // 加载中 → 返回 false(不拦截页面);加载失败 → 退回图像矩形区域,仅挂件区域可拖。
14851
14971
  if (!hitCanvas || !hitReady) {
14852
14972
  if (!hitFailed) return false
14853
- try {
14854
- var fr = img.getBoundingClientRect()
14855
- if (!fr || fr.width <= 0 || fr.height <= 0) return false
14856
- return e.clientX >= fr.left && e.clientX <= fr.right && e.clientY >= fr.top && e.clientY <= fr.bottom
14857
- } catch (err) {
14858
- return false
14859
- }
14973
+ return whaleRectHit(e)
14860
14974
  }
14861
14975
  try {
14862
14976
  var r = img.getBoundingClientRect()
@@ -14865,7 +14979,16 @@ function isWhaleHit(e) {
14865
14979
  var ly = (e.clientY - r.top) / r.height * 610
14866
14980
  if (lx < 0 || ly < 0 || lx >= 610 || ly >= 610) return false
14867
14981
  if (state.flip) lx = 610 - lx
14868
- var data = hitCanvas.getContext('2d').getImageData(Math.floor(lx), Math.floor(ly), 1, 1).data
14982
+ var data
14983
+ try {
14984
+ data = hitCanvas.getContext('2d').getImageData(Math.floor(lx), Math.floor(ly), 1, 1).data
14985
+ } catch (err) {
14986
+ // v756(issue #144):画布读不出来(隐私保护/跨域污染)→ 立刻降级为矩形判定,
14987
+ // 并且**记住**这个状态,后续调用直接走矩形,不再每次抛错。
14988
+ hitReady = false
14989
+ hitFailed = true
14990
+ return whaleRectHit(e)
14991
+ }
14869
14992
  return data[3] > 10
14870
14993
  } catch (err) {
14871
14994
  return false
@@ -15082,7 +15205,9 @@ function setWidgetCursor(v) {
15082
15205
  // 不碰页面级样式;找不到可滚动祖先时什么都不做(此时浏览器仍按默认把滚动交给页面滚动容器)。
15083
15206
  function onWhaleWheel(e) {
15084
15207
  try {
15085
- if (!widgetCursor || !e || !e.target) return
15208
+ // v757(issue #147):img 现在**常驻** pointer-events:auto,所以不再用 widgetCursor 当闸门 ——
15209
+ // 这个监听挂在 root 上,只有挂件自己的子元素接住了事件才会触发;命中就转交。
15210
+ if (!e || !e.target) return
15086
15211
  var hit = e.target
15087
15212
  if (hit !== img && !(root && root.contains && root.contains(hit))) return
15088
15213
  var prev = hit.style ? hit.style.pointerEvents : ''
package/lib/index.js CHANGED
@@ -586,28 +586,95 @@ export default {
586
586
  name: 'whale-balance-widget',
587
587
  inject: ['webServer', 'credentials', 'connection'],
588
588
  apply(ctx) {
589
- // —— 浏览器信任栅栏(对应 issue #92)——
590
- // dsh 的 connection 服务提供 requestRejection(req):Host/Origin 被伪造(DNS 重绑定)
591
- // 或未认证的请求必须被拒。官方插件(如 dsh-host-open-in-app)在自定义路由里都会先调用;
592
- // 本插件所有 /dsh-whale/* 路由同样必须走一遍,否则接口可被任意网页读写。
593
- function rejected(req, res) {
589
+ // —— 浏览器信任栅栏(issue #92 建立,issue #136 补 fail-closed)——
590
+ // dsh 的 connection 服务提供 requestRejection(req):Host/Origin 被伪造(DNS 重绑定)或未认证的
591
+ // 请求必须被拒。**但这个方法在较老的 dsh 上不存在**(#136 实测:0.1.5-rc.2/rc.3 有、0.1.0-rc.3 没有),
592
+ // 而旧实现在"服务缺失"与"栅栏自己抛异常"两条路径上都是 fail-open(放行)—— 于是 22 条 /dsh-whale/*
593
+ // 路由会**集体**失去校验:伪造 Host 可读写、普通跨站 POST 可直接写配置,而 api-models.json 还连着
594
+ // ctx.credentials(可 set/delete key)。现在改成三层:
595
+ // ① **插件自己的轻量校验**(永远先跑;正常浏览器与本地脚本都不受影响):
596
+ // Host 必须是回环权威或 DSHW_TRUSTED_HOSTS 里显式声明的,`Sec-Fetch-Site: cross-site` 一律拒,
597
+ // 带 Origin 时必须与 Host 同源;
598
+ // ② 宿主栅栏可用 → **委托**它(不重写它的语义:它里面还有一层浏览器鉴权 401),返回码照用;
599
+ // **它抛异常则按拒绝处理**(旧实现这条路径静默放行,且连 warn 都没有);
600
+ // ③ 宿主栅栏不可用 → 只靠 ①,并在**插件加载时**就打一条醒目 warn(不是等第一次请求才打)。
601
+ const TRUSTED_AUTHORITIES = String(process.env.DSHW_TRUSTED_HOSTS || '')
602
+ .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
603
+ // 只认回环:localhost / *.localhost / 127.0.0.0/8(逐段校验,防 `127.0.0.1.evil.com` 这类相似域名)/ ::1
604
+ function isLoopbackHostname(hn) {
605
+ const h = String(hn || '').toLowerCase().replace(/^\[/, '').replace(/\]$/, '')
606
+ if (!h) return false
607
+ if (h === 'localhost' || h.endsWith('.localhost')) return true
608
+ if (h === '::1') return true
609
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
610
+ if (!m) return false
611
+ if (Number(m[1]) !== 127) return false
612
+ return [m[2], m[3], m[4]].every((x) => Number(x) <= 255)
613
+ }
614
+ // 通过 → null;否则返回应当拒绝的状态码。
615
+ // opts.fenceAvailable:宿主栅栏此刻可用时,**非回环 Host 交给它判** —— 它知道 dsh 自己的
616
+ // `--trusted-host` 声明了谁;我们只在"确实没有栅栏"时才回落到 DSHW_TRUSTED_HOSTS。
617
+ // (0.3.11 把自校验放在委托之前,导致宿主已信任的权威在插件这边被 403,见 issue #136 后续的 A/B。)
618
+ function selfRejection(req, opts) {
594
619
  try {
595
- const conn = ctx.get('connection') || ctx.connection
596
- if (!conn || typeof conn.requestRejection !== 'function') {
597
- // fail-open:此刻栅栏不可用(connection 服务缺失)。inject 已声明该服务,正常不会走到这里;
598
- // 打一条 warn 便于发现"栅栏实际失效",而不是静默放行
599
- if (!rejected.warned) {
600
- rejected.warned = true
601
- try { console.warn('[whale-balance] 信任栅栏不可用:connection 服务缺失,自定义路由将放行处理') } catch (err) {}
602
- }
603
- return false
620
+ const headers = (req && req.headers) || {}
621
+ let hostUrl = null
622
+ try { hostUrl = new URL('http://' + String(headers.host || '')) } catch (err) { return 403 } // 缺 Host / 畸形 → 拒
623
+ const hostname = hostUrl.hostname
624
+ const authority = hostUrl.host
625
+ if (!isLoopbackHostname(hostname)) {
626
+ const listed = TRUSTED_AUTHORITIES.some((e) => (e.indexOf(':') >= 0 ? e === authority.toLowerCase() : e === hostname.toLowerCase()))
627
+ const fenceAvailable = !!(opts && opts.fenceAvailable)
628
+ if (!listed && !fenceAvailable) return 403
629
+ }
630
+ // 下面两条与"信任列表"无关,任何情况下都执行:跨站标记、Origin 与 Host 不同源。
631
+ const site = String(headers['sec-fetch-site'] || '').toLowerCase()
632
+ if (site === 'cross-site') return 403
633
+ const origin = headers.origin
634
+ if (typeof origin === 'string' && origin && origin !== 'null') {
635
+ let originUrl = null
636
+ try { originUrl = new URL(origin) } catch (err) { return 403 }
637
+ if (originUrl.host.toLowerCase() !== authority.toLowerCase()) return 403
604
638
  }
605
- const code = conn.requestRejection(req)
606
- if (code === undefined || code === null || code === false) return false
607
- res.statusCode = typeof code === 'number' ? code : 403
608
- res.end()
639
+ return null
640
+ } catch (err) { return 403 } // 校验器自身出错 → 拒绝(fail-closed)
641
+ }
642
+ function connectionFence() {
643
+ try {
644
+ const conn = ctx.get('connection') || ctx.connection
645
+ return conn && typeof conn.requestRejection === 'function' ? conn : null
646
+ } catch (err) { return null }
647
+ }
648
+ if (!connectionFence()) {
649
+ try {
650
+ console.warn('[whale-balance] 宿主信任栅栏不可用(connection.requestRejection 缺失):已启用插件自带的回环/同源校验,'
651
+ + '所有路由(含写接口)仍受它保护;若本机 dsh 部署在非回环地址(反代/局域网),请用环境变量 '
652
+ + 'DSHW_TRUSTED_HOSTS 声明允许的 Host(逗号分隔,可带端口)。')
653
+ } catch (err) {}
654
+ }
655
+ function rejected(req, res) {
656
+ const deny = (code) => {
657
+ try { res.statusCode = code || 403; res.end() } catch (err) {}
609
658
  return true
610
- } catch (err) { return false }
659
+ }
660
+ const conn = connectionFence()
661
+ // 先算栅栏可用性,再自校验:非回环 Host 在"栅栏可用"时交给栅栏判(它知道 --trusted-host)
662
+ const self = selfRejection(req, { fenceAvailable: !!conn })
663
+ if (self !== null) return deny(self)
664
+ if (!conn) return false // 已通过自校验;此刻没有宿主栅栏可委托(加载时已 warn)
665
+ let code
666
+ try {
667
+ code = conn.requestRejection(req)
668
+ } catch (err) {
669
+ // 旧实现这里是 `return false`:栅栏自己抛异常被当成"没被拒"而静默放行(#136 的第二条路径)
670
+ if (!rejected.warned) {
671
+ rejected.warned = true
672
+ try { console.warn('[whale-balance] 信任栅栏抛异常,已按拒绝处理:' + String((err && err.message) || err)) } catch (e2) {}
673
+ }
674
+ return deny(403)
675
+ }
676
+ if (code === undefined || code === null || code === false) return false
677
+ return deny(typeof code === 'number' ? code : 403)
611
678
  }
612
679
  // 统一注册入口:所有路由自动套上信任栅栏
613
680
  function registerRoute(route) {
@@ -985,7 +1052,7 @@ export default {
985
1052
  const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
986
1053
  for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
987
1054
  return {
988
- ok: true, version: '0.3.10', today: todayData, days7,
1055
+ ok: true, version: '0.3.12', today: todayData, days7,
989
1056
  total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
990
1057
  all: {
991
1058
  days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
@@ -2185,7 +2252,7 @@ export default {
2185
2252
  const summary = daySummary(led, todayKey())
2186
2253
  const nowSec = Math.floor(Date.now() / 1000)
2187
2254
  return {
2188
- ...visible, version: '0.3.10', isPeak: isPeakTime(nowSec),
2255
+ ...visible, version: '0.3.12', isPeak: isPeakTime(nowSec),
2189
2256
  // 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
2190
2257
  peakNextChangeAt: nextPeakChangeAt(nowSec),
2191
2258
  peakHolidays: HOLIDAY_VALLEY_LIST,
@@ -3464,6 +3531,23 @@ export default {
3464
3531
  return html + tag
3465
3532
  }))
3466
3533
 
3534
+ // —— 官方 DSH 桌面端(Electron)适配(issue #152 / #153)——
3535
+ // 桌面壳的 index.html 是**从安装包静态 dist 直接读盘**的(`dsh-app://app/`),
3536
+ // 永远不经过宿主的 renderIndex(),所以上面的 tapIndex 在桌面端不生效 —— 于是"宿主半区活着、
3537
+ // 客户端半区从未被请求"。桌面端走的是**结构化行注入**:宿主启动时 collectIndexInjections()
3538
+ // 经 IPC 把行表交给渲染层,渲染层按 kind 逐行应用(见 @deepseek-ai/dsh-host-webserver 的
3539
+ // injections.d.ts:`{ kind:'script-src'; placement:'head'|'body'; src:string }`,外部经典脚本)。
3540
+ // 这里补同一行;**两条并存**:Web 形态仍走 tapIndex,两边都只会注入一次(各自去重)。
3541
+ disposers.push(ctx.on('webserver/index-inject', (table) => {
3542
+ try {
3543
+ if (!Array.isArray(table)) return
3544
+ for (const row of table) {
3545
+ if (row && row.kind === 'script-src' && row.src === '/dsh-whale/widget.js') return
3546
+ }
3547
+ table.push({ kind: 'script-src', placement: 'body', src: '/dsh-whale/widget.js' })
3548
+ } catch (err) {}
3549
+ }))
3550
+
3467
3551
  ctx.effect(() => () => {
3468
3552
  for (const d of disposers) {
3469
3553
  try { d() } catch (err) {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",