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 +4 -0
- package/assets/whale-widget.js +136 -11
- package/lib/index.js +105 -21
- package/package.json +1 -1
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
|
- **今日已用显示 `--`**:先等一次成功的余额观测;统计从该观测时刻开始,起点之前的消费不在此区间内。
|
package/assets/whale-widget.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
// ——
|
|
590
|
-
// dsh 的 connection 服务提供 requestRejection(req):Host/Origin 被伪造(DNS
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
|
|
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
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
return
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
}
|
|
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.
|
|
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.
|
|
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