dsh-whale-widget 0.3.9 → 0.3.11
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 +358 -54
- package/lib/index.js +87 -24
- 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
|
@@ -93,6 +93,32 @@ window.__dshWhaleInit = true
|
|
|
93
93
|
// 衔接时机由 RELEASE_LEAD_MS 决定(0 = 正好接上;30/50 = 轻微交叠)—— 改这个数字即可按耳朵微调,
|
|
94
94
|
// 不用动任何逻辑。
|
|
95
95
|
var dshwvAudioCtx = null
|
|
96
|
+
// v753(issue #135):**running 状态的 AudioContext 会让系统一直挂着 PreventUserIdleSystemSleep** ——
|
|
97
|
+
// macOS 上表现为"只要页面开着就不会空闲睡眠",而且与**有没有出声无关**(context 一 running 就持有播放流)。
|
|
98
|
+
// 所以空闲 DSHW_AUDIO_IDLE_MS 之后主动 suspend();下次出声前 dshwvAudio() 里的 resume() 会自动恢复。
|
|
99
|
+
// 为什么用 suspend() 而不是 close():close() 会把 context 彻底销毁,而**任务结束音不是手势触发的**,
|
|
100
|
+
// 销毁后它再也响不出来(被手势解锁过的 context 再 resume 不需要新手势,挂起是安全的)。
|
|
101
|
+
var DSHW_AUDIO_IDLE_MS = 60000 // 静默多久交还系统睡眠(用户选定:1 分钟)
|
|
102
|
+
var dshwvAudioSuspendT = null
|
|
103
|
+
function dshwvAudioSuspendNow() {
|
|
104
|
+
try {
|
|
105
|
+
if (dshwvAudioSuspendT) { clearTimeout(dshwvAudioSuspendT); dshwvAudioSuspendT = null }
|
|
106
|
+
if (dshwvAudioCtx && dshwvAudioCtx.state === 'running') dshwvAudioCtx.suspend()
|
|
107
|
+
} catch (err) {}
|
|
108
|
+
}
|
|
109
|
+
function dshwvAudioIdleArm() {
|
|
110
|
+
try {
|
|
111
|
+
if (dshwvAudioSuspendT) clearTimeout(dshwvAudioSuspendT)
|
|
112
|
+
dshwvAudioSuspendT = setTimeout(function () { dshwvAudioSuspendT = null; dshwvAudioSuspendNow() }, DSHW_AUDIO_IDLE_MS)
|
|
113
|
+
} catch (err) {}
|
|
114
|
+
}
|
|
115
|
+
// 音效是否被**显式**关掉(v753 的显式开关)。这里刻意用 typeof 保护:
|
|
116
|
+
// 本文件经常被探针按片段切出来单独 eval(例如 _v752-check 只切「音频垫片 + applySoundSet」),
|
|
117
|
+
// 那些沙箱里不一定声明了 soundOn —— typeof 对未声明的标识符是安全的,裸引用会直接抛
|
|
118
|
+
// ReferenceError 把整段沙箱打断。真机上 soundOn 一定存在,行为不受影响。
|
|
119
|
+
function dshwvSoundOff() {
|
|
120
|
+
try { return typeof soundOn !== 'undefined' && soundOn === false } catch (err) { return false }
|
|
121
|
+
}
|
|
96
122
|
function dshwvAudio() {
|
|
97
123
|
try {
|
|
98
124
|
if (!dshwvAudioCtx) {
|
|
@@ -101,24 +127,47 @@ function dshwvAudio() {
|
|
|
101
127
|
dshwvAudioCtx = new AC({ latencyHint: 'interactive' })
|
|
102
128
|
}
|
|
103
129
|
if (dshwvAudioCtx.state === 'suspended') { try { dshwvAudioCtx.resume() } catch (err) {} }
|
|
130
|
+
dshwvAudioIdleArm() // 每次(重新)进入 running 都重新计时:静默满 1 分钟就挂起
|
|
104
131
|
return dshwvAudioCtx
|
|
105
132
|
} catch (err) { return null }
|
|
106
133
|
}
|
|
107
134
|
var dshwvAudioBuffers = {} // url -> Promise<AudioBuffer>(同一片段不重复下载/解码)
|
|
108
135
|
var dshwvAudioDecoded = {} // url -> AudioBuffer(解码完成后**同步可读**:起播走同步路径的关键)
|
|
136
|
+
// v752:音频失败**必须看得见**。原来所有 fetch/decode 失败都被 `.catch(function(){})` 吞掉,
|
|
137
|
+
// 于是"没声音"在控制台里一点痕迹都没有,只能靠猜。现在每个 URL 只 warn 一次,
|
|
138
|
+
// 并把最后一次错误留在 dshwvAudioLastErr 里。
|
|
139
|
+
var dshwvAudioLastErr = null
|
|
140
|
+
var dshwvAudioWarned = {}
|
|
141
|
+
function dshwvAudioWarn(url, err) {
|
|
142
|
+
var msg = String((err && err.message) || err || 'unknown')
|
|
143
|
+
dshwvAudioLastErr = { url: String(url || ''), error: msg, at: new Date().toISOString() }
|
|
144
|
+
if (dshwvAudioWarned[url]) return
|
|
145
|
+
dshwvAudioWarned[url] = 1
|
|
146
|
+
try { console.warn('[小鲸鱼] 音频加载/解码失败:' + url + ' → ' + msg) } catch (e) {}
|
|
147
|
+
}
|
|
109
148
|
function nowMs() { try { return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now() } catch (err) { return Date.now() } }
|
|
110
149
|
function dshwvAudioBuffer(url) {
|
|
111
150
|
if (!url) return Promise.reject(new Error('empty url'))
|
|
112
151
|
if (!dshwvAudioBuffers[url]) {
|
|
113
|
-
|
|
114
|
-
|
|
152
|
+
// v752:改 no-store。原来用 force-cache —— 万一某个中间层把一次空的 204 缓存住,
|
|
153
|
+
// 之后每次点按都会复用它(表现就是"永久没声音,重启也没用")。我们本来就有内存解码缓存,
|
|
154
|
+
// 不会因此重复下载。
|
|
155
|
+
dshwvAudioBuffers[url] = fetch(url, { cache: 'no-store' })
|
|
156
|
+
.then(function (r) {
|
|
157
|
+
// 注意:204 也满足 r.ok(2xx),必须单独挡掉,否则 0 字节会被送进 decodeAudioData
|
|
158
|
+
// → 抛 EncodingError("Unable to decode audio data")→ 只剩静音。
|
|
159
|
+
if (r.status === 204) throw new Error('HTTP 204(无内容:该音效槽留空、或音效组未找到)')
|
|
160
|
+
if (!r.ok) throw new Error('HTTP ' + r.status)
|
|
161
|
+
return r.arrayBuffer()
|
|
162
|
+
})
|
|
115
163
|
.then(function (raw) {
|
|
164
|
+
if (!raw || raw.byteLength < 100) throw new Error('音频响应只有 ' + (raw ? raw.byteLength : 0) + ' 字节(不是有效音频)')
|
|
116
165
|
var c = dshwvAudio()
|
|
117
166
|
if (!c) throw new Error('no audio context')
|
|
118
167
|
return new Promise(function (res, rej) { c.decodeAudioData(raw, res, rej) })
|
|
119
168
|
})
|
|
120
169
|
.then(function (buf) { dshwvAudioDecoded[url] = buf; return buf })
|
|
121
|
-
.catch(function (err) { delete dshwvAudioBuffers[url]; delete dshwvAudioDecoded[url]; throw err })
|
|
170
|
+
.catch(function (err) { delete dshwvAudioBuffers[url]; delete dshwvAudioDecoded[url]; dshwvAudioWarn(url, err); throw err })
|
|
122
171
|
}
|
|
123
172
|
return dshwvAudioBuffers[url]
|
|
124
173
|
}
|
|
@@ -126,6 +175,9 @@ function dshwvAudioBuffer(url) {
|
|
|
126
175
|
// 不预热 → 第一次点按要现 fetch+decodeAudioData,表现就是"按下音慢半拍、中间衔接发飘"。
|
|
127
176
|
// URL 级缓存保证同一片段只解一次;失败静默吞掉(真正播放时还会自己重试一次)。
|
|
128
177
|
function dshwvWarm(urls) {
|
|
178
|
+
// v753(issue #135):音效关掉时**连预热都不做** —— 只跳过下面那句 dshwvAudio() 是不够的,
|
|
179
|
+
// 因为预解码走到 dshwvAudioBuffer() 里还会再调一次 dshwvAudio(),context 照样被建起来。
|
|
180
|
+
if (dshwvSoundOff()) return
|
|
129
181
|
try { dshwvAudio() } catch (err) {}
|
|
130
182
|
for (var i = 0; i < (urls || []).length; i++) {
|
|
131
183
|
var u = urls[i]
|
|
@@ -187,9 +239,15 @@ function dshwvSound(url) {
|
|
|
187
239
|
el._playingSince = nowMs() + delay * 1000
|
|
188
240
|
var dur = Math.max(0.001, buf.duration)
|
|
189
241
|
src.start(delay > 0 ? c.currentTime + delay : 0, Math.max(0, el._offset) % dur)
|
|
190
|
-
} catch (err) {
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// v749:起播抛异常(例如 AudioContext 已关闭、offset 越界)以前被完全吞掉 → 表现为"没声音但无任何报错"
|
|
244
|
+
dshwvAudioWarn(el && el._url, err)
|
|
245
|
+
}
|
|
191
246
|
}
|
|
192
247
|
function startSound(delaySec) {
|
|
248
|
+
// v753(issue #135):音效开关关掉 = 全静音(含编辑器里的试听),也**不碰** AudioContext ——
|
|
249
|
+
// 否则一次试听就会把 context 转成 running、系统断言照挂。
|
|
250
|
+
if (dshwvSoundOff()) return Promise.resolve()
|
|
193
251
|
var c = dshwvAudio()
|
|
194
252
|
if (!c || !el._url) return Promise.resolve()
|
|
195
253
|
var token = (el._token = (el._token || 0) + 1)
|
|
@@ -197,27 +255,47 @@ function dshwvSound(url) {
|
|
|
197
255
|
// ④ 同步起播:缓冲区已预热(dshwvWarm)过 → 直接在当前任务里 start(),不再等 Promise
|
|
198
256
|
var cached = dshwvAudioDecoded[el._url]
|
|
199
257
|
if (cached) { beginWithBuffer(c, cached, delay); return Promise.resolve() }
|
|
200
|
-
|
|
258
|
+
var tryUrl = el._url
|
|
259
|
+
dshwvAudioBuffer(tryUrl).then(function (buf) {
|
|
201
260
|
if (token !== el._token) return // 期间被重播/暂停/换源 → 丢弃这次
|
|
202
261
|
beginWithBuffer(c, buf, delay)
|
|
203
|
-
}).catch(function () {
|
|
262
|
+
}).catch(function () {
|
|
263
|
+
// v752:首选路由取不到音频时,自动换另一条路由重试一次(两条互为备胎)。
|
|
264
|
+
// 只在失败路径上跑,不影响正常点按的同步起播时序。
|
|
265
|
+
if (!el._alt || el._url !== tryUrl || token !== el._token) return
|
|
266
|
+
var alt = el._alt
|
|
267
|
+
el._alt = '' // 只回退一次,避免两条路由来回打转
|
|
268
|
+
el._url = alt
|
|
269
|
+
try { dshwvWarm([alt]) } catch (e) {} // 后台预热,下次点按就能走同步路径
|
|
270
|
+
try { startSound(delay) } catch (e) {}
|
|
271
|
+
})
|
|
204
272
|
return Promise.resolve()
|
|
205
273
|
}
|
|
206
274
|
el.play = function () { return startSound(0) }
|
|
207
275
|
el.playAt = function (delaySec) { return startSound(delaySec) }
|
|
208
276
|
el.pause = function () { dshwvSoundStop(el) }
|
|
277
|
+
// v752:备用路由(首选失败时自动切过去);由 applySoundSet/playTaskEndGroupClick 填
|
|
278
|
+
el._alt = ''
|
|
209
279
|
return el
|
|
210
280
|
}
|
|
281
|
+
|
|
211
282
|
// 自动播放策略:AudioContext 初始是 suspended,要有一次用户手势才能出声;任务结束音不是手势触发的,
|
|
212
283
|
// 所以挂一次性解锁(首次点击/按键后移除)。
|
|
213
284
|
try {
|
|
214
285
|
var dshwvAudioUnlock = function () {
|
|
286
|
+
// v753(issue #135):音效关掉时**不预解锁** —— 否则一次普通点击就会把 context 转成 running,
|
|
287
|
+
// 断言照挂。注意这里**不摘监听**:之后重新打开开关,下一次点击仍能完成解锁。
|
|
288
|
+
if (dshwvSoundOff()) return
|
|
215
289
|
dshwvAudio()
|
|
216
290
|
try { document.removeEventListener('pointerdown', dshwvAudioUnlock, true) } catch (err) {}
|
|
217
291
|
try { document.removeEventListener('keydown', dshwvAudioUnlock, true) } catch (err) {}
|
|
218
292
|
}
|
|
219
293
|
document.addEventListener('pointerdown', dshwvAudioUnlock, true)
|
|
220
294
|
document.addEventListener('keydown', dshwvAudioUnlock, true)
|
|
295
|
+
// v753(issue #135):页面被隐藏(切标签 / 最小化)时立刻挂起,直接覆盖"开着过夜"这个场景
|
|
296
|
+
document.addEventListener('visibilitychange', function () {
|
|
297
|
+
try { if (document.hidden) dshwvAudioSuspendNow() } catch (err) {}
|
|
298
|
+
})
|
|
221
299
|
} catch (err) {}
|
|
222
300
|
|
|
223
301
|
var MIN_SCALE = 0.6
|
|
@@ -245,10 +323,29 @@ var css = [
|
|
|
245
323
|
'.dshwv-root.dshwv-dragging{cursor:grabbing;transition:none}',
|
|
246
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)}',
|
|
247
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}',
|
|
326
|
+
// v751(PR #119):光标不再写 document.body.style.cursor —— cursor 是可继承属性,写 <body> 会让 Blink
|
|
327
|
+
// 失效**整棵文档树**的样式;而它在点击链路上按下/抬手各写一次,紧接着 isWhaleHit() 的
|
|
328
|
+
// getBoundingClientRect() 与泡泡行测量的 getComputedStyle()/scrollWidth 会强制刷新样式+布局,
|
|
329
|
+
// 于是"整页样式重算"被算进了这一次点击(长会话里表现为桌宠点了卡一下、松手音延迟、跑马灯不动)。
|
|
330
|
+
// 现在光标由挂件自己的类承担:命中鲸鱼不透明像素时让 .dshwv-img 接过指针并显示 grab / grabbing。
|
|
331
|
+
// 鲸鱼区域本来就在吞指针事件(onDocPointerDown 的 isWhaleHit),这不是新增拦截;
|
|
332
|
+
// 新增影响的只有滚轮,由 onWhaleWheel 转交给指针下方真正可滚动的容器。
|
|
333
|
+
'.dshwv-root.dshwv-cursor-grab .dshwv-img{pointer-events:auto;cursor:grab}',
|
|
334
|
+
'.dshwv-root.dshwv-cursor-grabbing .dshwv-img{pointer-events:auto;cursor:grabbing}',
|
|
335
|
+
// 拖动期间握点相对挂件固定,让整个盒接过指针以保持 grabbing(类由按下时加、endDrag 摘)
|
|
336
|
+
'.dshwv-root.dshwv-dragging .dshwv-body{pointer-events:auto;cursor:grabbing}',
|
|
248
337
|
'.dshwv-pop{position:absolute;left:0;top:0;width:100%;aspect-ratio:1026/700;pointer-events:none;z-index:1;--dshw-u:calc(var(--dshw-base) / 1026)}',
|
|
249
338
|
// 纵深防御:泡泡容器必须透明,形状由内部 SVG 绘制;用 !important 压掉外部
|
|
250
339
|
// 插件“类名子串匹配”选择器(如 aqua 的 [class*=bubble])注入的玻璃/边框样式
|
|
251
340
|
'html .dshwv-pop, html .dshwv-pop svg{background:transparent !important;border:0 !important;border-radius:0 !important;backdrop-filter:none !important;-webkit-backdrop-filter:none !important}',
|
|
341
|
+
// 纵深防御(issue #133):宿主皮肤/主题会按**几何特征**(position:fixed + z-index≥10 +
|
|
342
|
+
// 尺寸≥120×80 + 非原生弹窗角色)把挂件方块误判成"独立插件窗",然后注入
|
|
343
|
+
// backdrop-filter / 背景 / 边框 / 伪元素毛玻璃,把用户壁纸糊掉(实测 375×375 方块里是雾面)。
|
|
344
|
+
// 挂件是透明精灵图,根节点上任何"画底"的通道都不该存在,所以这里全部钉死为透明 ——
|
|
345
|
+
// 我们自己的 .dshwv-root 本来就只有 left/top/width/height/transform,没有背景/边框/阴影/filter,
|
|
346
|
+
// 也没有用 ::before/::after,所以这是零行为变化的加固。
|
|
347
|
+
'html .dshwv-root,body .dshwv-root{background:transparent !important;background-image:none !important;border:0 !important;outline:0 !important;box-shadow:none !important;backdrop-filter:none !important;-webkit-backdrop-filter:none !important;filter:none !important}',
|
|
348
|
+
'html .dshwv-root::before,html .dshwv-root::after{content:none !important;background:transparent !important;background-image:none !important;box-shadow:none !important;backdrop-filter:none !important;-webkit-backdrop-filter:none !important}',
|
|
252
349
|
'.dshwv-pop svg{display:block;width:100%;height:100%;pointer-events:none}',
|
|
253
350
|
'.dshwv-pop svg path,.dshwv-pop svg ellipse{pointer-events:none;cursor:pointer}',
|
|
254
351
|
'.dshwv-pop.dshwv-pop-open svg path,.dshwv-pop.dshwv-pop-open svg ellipse{pointer-events:visiblePainted}',
|
|
@@ -1213,14 +1310,19 @@ function playTaskEndSound() {
|
|
|
1213
1310
|
if (!usageSet || !usageSet.taskEnd || !usageSet.taskEnd.on || soundOn === false) return
|
|
1214
1311
|
var sel = usageSet.taskEnd.sel || taskEndSel.value || ''
|
|
1215
1312
|
var url = ''
|
|
1313
|
+
var altUrl = ''
|
|
1216
1314
|
if (sel.indexOf('grp:') === 0) { playTaskEndGroupClick(sel.slice(4)); return }
|
|
1217
1315
|
if (sel.indexOf('frag:') === 0) url = '/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(sel.slice(5))
|
|
1218
1316
|
else if (sel.indexOf('preset:') === 0) {
|
|
1317
|
+
// v752:预设片段也改走片段路由(与本体按压同一套选择逻辑 + 备用路由)
|
|
1219
1318
|
var parts = sel.split(':')
|
|
1220
|
-
|
|
1319
|
+
var su = soundSlotUrls(parts[2] === 'release' ? 'release' : 'press', parts[1])
|
|
1320
|
+
url = su.url
|
|
1321
|
+
altUrl = su.alt || ''
|
|
1221
1322
|
}
|
|
1222
1323
|
if (!url) return
|
|
1223
1324
|
var a = dshwvSound(url)
|
|
1325
|
+
if (altUrl) a._alt = altUrl
|
|
1224
1326
|
try { a.volume = Number(soundVol) || 0.9 } catch (err) {}
|
|
1225
1327
|
a.play().catch(function () {})
|
|
1226
1328
|
} catch (err) {}
|
|
@@ -1236,11 +1338,17 @@ function playTaskEndGroupClick(groupId) {
|
|
|
1236
1338
|
var releaseEmpty = !!(g && g.release === '')
|
|
1237
1339
|
if (pressEmpty && releaseEmpty) return
|
|
1238
1340
|
var vol = Number(soundVol) || 0.9
|
|
1341
|
+
// v752:任务结束音同样改走片段路由(与本体按压同一套 URL 选择 + 备用路由逻辑)——
|
|
1342
|
+
// 老路由 /dsh-whale/sound/*.mp3?set=… 在部分环境会被本机那层东西拦成空的 204,
|
|
1343
|
+
// 不改的话"点按有声、但每轮结束音没声"会变成同一个问题的另一半。
|
|
1344
|
+
var pu = soundSlotUrls('press', groupId)
|
|
1345
|
+
var ru = soundSlotUrls('release', groupId)
|
|
1239
1346
|
// 按压留空:无按下音,直接播松开(模拟按下即松开的完整点按);松开留空:只播按压
|
|
1240
1347
|
if (pressEmpty) {
|
|
1241
|
-
if (!releaseEmpty) {
|
|
1242
|
-
dshwvWarm([
|
|
1243
|
-
var relOnly = dshwvSound(
|
|
1348
|
+
if (!releaseEmpty && ru.url) {
|
|
1349
|
+
dshwvWarm([ru.url]) // v745:先预热
|
|
1350
|
+
var relOnly = dshwvSound(ru.url)
|
|
1351
|
+
relOnly._alt = ru.alt || ''
|
|
1244
1352
|
try { relOnly.volume = vol } catch (err) {}
|
|
1245
1353
|
relOnly.currentTime = 0
|
|
1246
1354
|
var pr = relOnly.play()
|
|
@@ -1248,20 +1356,20 @@ function playTaskEndGroupClick(groupId) {
|
|
|
1248
1356
|
}
|
|
1249
1357
|
return
|
|
1250
1358
|
}
|
|
1251
|
-
|
|
1359
|
+
if (!pu.url) return
|
|
1360
|
+
var press = dshwvSound(pu.url)
|
|
1361
|
+
press._alt = pu.alt || ''
|
|
1252
1362
|
try { press.volume = vol } catch (err) {}
|
|
1253
1363
|
// v745:菜单里的"点一下试听"同样先预热解码,否则第一次听有明显延迟
|
|
1254
|
-
dshwvWarm([
|
|
1255
|
-
|
|
1256
|
-
releaseEmpty ? '' : '/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId),
|
|
1257
|
-
])
|
|
1258
|
-
if (releaseEmpty) {
|
|
1364
|
+
dshwvWarm([pu.url, releaseEmpty ? '' : ru.url])
|
|
1365
|
+
if (releaseEmpty || !ru.url) {
|
|
1259
1366
|
press.currentTime = 0
|
|
1260
1367
|
var pp = press.play()
|
|
1261
1368
|
if (pp && pp.catch) pp.catch(function () {})
|
|
1262
1369
|
return
|
|
1263
1370
|
}
|
|
1264
|
-
var release = dshwvSound(
|
|
1371
|
+
var release = dshwvSound(ru.url)
|
|
1372
|
+
release._alt = ru.alt || ''
|
|
1265
1373
|
try { release.volume = vol } catch (err) {}
|
|
1266
1374
|
var relPlayed = false
|
|
1267
1375
|
function playRel() {
|
|
@@ -1335,8 +1443,17 @@ var volPct = document.createElement('span')
|
|
|
1335
1443
|
volPct.className = 'dshwv-volpct'
|
|
1336
1444
|
volPct.textContent = '90%'
|
|
1337
1445
|
volInput.addEventListener('input', function () { setVol(volInput.value) })
|
|
1446
|
+
// v753(issue #135):显式「音效开关」,放在「音量」右边。滑块是 flex:1、开关是 flex:0 0 auto(16px),
|
|
1447
|
+
// 所以「滑块 + 开关」合起来仍占原来滑块那一条宽度(缩掉的正好是开关与行间距的宽度),行总宽不变。
|
|
1448
|
+
var soundToggle = document.createElement('input')
|
|
1449
|
+
soundToggle.type = 'checkbox'
|
|
1450
|
+
soundToggle.className = 'dshwv-check'
|
|
1451
|
+
soundToggle.checked = true
|
|
1452
|
+
soundToggle.title = '音效总开关:关掉后不出声(含音效组试听),并立刻挂起音频上下文、把系统睡眠交还给你'
|
|
1453
|
+
soundToggle.addEventListener('change', function () { setSoundOn(soundToggle.checked) })
|
|
1338
1454
|
var row3 = menuRow()
|
|
1339
1455
|
row3.appendChild(menuLabel('音量'))
|
|
1456
|
+
row3.appendChild(soundToggle)
|
|
1340
1457
|
row3.appendChild(volInput)
|
|
1341
1458
|
row3.appendChild(volPct)
|
|
1342
1459
|
var row6 = menuRow()
|
|
@@ -8570,12 +8687,22 @@ function visibleTopZ() {
|
|
|
8570
8687
|
var top = 20500
|
|
8571
8688
|
// 注意:这里必须列全 —— 少一个浮层,dshwLayerUp/下拉/提示就会低估"当前最高层"而被盖住。
|
|
8572
8689
|
// toast 刻意不列入(它是永远的最顶层,不该让别的层去追它)。
|
|
8573
|
-
|
|
8574
|
-
|
|
8575
|
-
|
|
8576
|
-
|
|
8577
|
-
|
|
8578
|
-
|
|
8690
|
+
// v750(issue #131 的教训):每个候选写成**独立取值函数**并各自 try/catch。
|
|
8691
|
+
// 0.3.8 曾在这里写了一个并不存在的 `usageMask` —— 数组字面量在**构造时**就抛 ReferenceError,
|
|
8692
|
+
// 于是 visibleTopZ() 每次调用都失败、又被调用方的空 catch 吞掉:自绘下拉的层级停在下拉样式表里的
|
|
8693
|
+
// 60(被父窗口整层盖住,表现为"点了没反应"),dshwLayerUp() 也一起静默失效。
|
|
8694
|
+
// 现在单个名字写错最多只少算那一层,不会让整条层级链失效;`_z-audit-check.mjs` 会核对
|
|
8695
|
+
// 候选表里的每个标识符都真的声明过(防止同样的错再犯)。
|
|
8696
|
+
var getters = [
|
|
8697
|
+
function () { return bubbleMask }, function () { return bubbleItemMask },
|
|
8698
|
+
function () { return moduleMask }, function () { return moduleNamePromptMask },
|
|
8699
|
+
function () { return cropMask }, function () { return gifMask },
|
|
8700
|
+
function () { return audioCropMask }, function () { return audioEditMask },
|
|
8701
|
+
function () { return resMaskEl }, function () { return confirmMask },
|
|
8702
|
+
function () { return snapMask }, function () { return usageMoreMask },
|
|
8703
|
+
function () { return qeditEl }, function () { return dshwvTplHelpEl },
|
|
8704
|
+
function () { return dshwvHintEl }, function () { return apiModelMaskEl },
|
|
8705
|
+
function () { return accountingMask }, function () { return window.__dshwRemindMask }
|
|
8579
8706
|
]
|
|
8580
8707
|
function eff(el) {
|
|
8581
8708
|
try {
|
|
@@ -8590,17 +8717,47 @@ function visibleTopZ() {
|
|
|
8590
8717
|
return isFinite(n) ? n : 0
|
|
8591
8718
|
} catch (err) { return 0 }
|
|
8592
8719
|
}
|
|
8593
|
-
for (var i = 0; i <
|
|
8594
|
-
var n =
|
|
8720
|
+
for (var i = 0; i < getters.length; i++) {
|
|
8721
|
+
var n = 0
|
|
8722
|
+
try { n = eff(getters[i]()) } catch (err) { n = 0 }
|
|
8595
8723
|
if (n > top) top = n
|
|
8596
8724
|
}
|
|
8725
|
+
// v756(issue #142):给第三方 fork / 以后的扩展模块留一个**运行时登记口** ——
|
|
8726
|
+
// 它们新增的浮层不必再回上游源码里插一行,只要 `window.dshwRegisterMask(el)` 登记一次,
|
|
8727
|
+
// 这里就会把它算进"当前可见最高层"(隐藏的会被 eff() 判成 0)。返回一个注销函数。
|
|
8728
|
+
try {
|
|
8729
|
+
var extra = dshwExtraMasks || []
|
|
8730
|
+
for (var k = 0; k < extra.length; k++) {
|
|
8731
|
+
var n2 = 0
|
|
8732
|
+
try { n2 = eff(extra[k]) } catch (err) { n2 = 0 }
|
|
8733
|
+
if (n2 > top) top = n2
|
|
8734
|
+
}
|
|
8735
|
+
} catch (err) {}
|
|
8597
8736
|
return top
|
|
8598
8737
|
}
|
|
8738
|
+
// 运行时登记的额外浮层(issue #142)。只存引用,不持有任何别的东西;登记失败的入口一律静默忽略。
|
|
8739
|
+
var dshwExtraMasks = []
|
|
8740
|
+
try {
|
|
8741
|
+
window.dshwRegisterMask = function (el) {
|
|
8742
|
+
try {
|
|
8743
|
+
if (!el || dshwExtraMasks.indexOf(el) >= 0) return function () {}
|
|
8744
|
+
dshwExtraMasks.push(el)
|
|
8745
|
+
return function () {
|
|
8746
|
+
try {
|
|
8747
|
+
var i = dshwExtraMasks.indexOf(el)
|
|
8748
|
+
if (i >= 0) dshwExtraMasks.splice(i, 1)
|
|
8749
|
+
} catch (err) {}
|
|
8750
|
+
}
|
|
8751
|
+
} catch (err) { return function () {} }
|
|
8752
|
+
}
|
|
8753
|
+
} catch (err) {}
|
|
8599
8754
|
// 「永远在打开它的那个窗口之上」:取 本层段起点 与 当前可见最高层+10 的较大值。
|
|
8600
8755
|
// 用在裁剪 / GIF / 音频裁剪 / 模块编辑器这些**既可能从主菜单(10000)打开、也可能从资源管理(20300)、
|
|
8601
8756
|
// 泡泡编辑器(20500)、模型设置(29000)里打开**的窗口上 —— 固定层号在后者场景会被父窗口盖住。
|
|
8602
8757
|
function dshwLayerUp(el, base) {
|
|
8603
|
-
|
|
8758
|
+
var z = base
|
|
8759
|
+
try { z = Math.max(base, Math.round(visibleTopZ()) + 10) } catch (err) { z = base }
|
|
8760
|
+
try { if (el && el.style) el.style.zIndex = String(z) } catch (err) {}
|
|
8604
8761
|
return el
|
|
8605
8762
|
}
|
|
8606
8763
|
// 打开主要编辑器前清理可能残留的临时层级(提醒会话遗留的 moduleMask/qedit 提升与样式)
|
|
@@ -8648,7 +8805,10 @@ function dshwDropOpen(menuEl, anchorEl) {
|
|
|
8648
8805
|
menuEl.style.left = Math.round(left) + 'px'
|
|
8649
8806
|
menuEl.style.top = Math.round(r.bottom + 2) + 'px'
|
|
8650
8807
|
// 下拉层级:高于当前所有可见弹窗/窗口(兜底不低于 26010)
|
|
8651
|
-
|
|
8808
|
+
// v750(issue #131):visibleTopZ() 单独 try/catch —— 算不出最高层也必须把层级抬上去,
|
|
8809
|
+
// 并且打一条 warn(原先异常被外层空 catch 吞掉,表现为"下拉点了没反应"且毫无线索)
|
|
8810
|
+
var vTop = 20500
|
|
8811
|
+
try { vTop = visibleTopZ() } catch (err) { try { console.warn('[dsh-whale] visibleTopZ 失败,下拉改用兜底层级:', err) } catch (e2) {} }
|
|
8652
8812
|
menuEl.style.zIndex = String(Math.max(26010, Math.round(vTop) + 10))
|
|
8653
8813
|
} catch (err) {}
|
|
8654
8814
|
}
|
|
@@ -10374,7 +10534,12 @@ function measureBubbleCenter() {
|
|
|
10374
10534
|
dshwCenterX = cx
|
|
10375
10535
|
dshwCenterY = cy
|
|
10376
10536
|
try {
|
|
10377
|
-
|
|
10537
|
+
// v751(PR #119):写到挂件自己的 root 上,**不写 document.documentElement**。
|
|
10538
|
+
// 未注册的自定义属性写在 <html> 上会让 Blink 保守失效整棵子树样式(PR 实测 12k 节点会话
|
|
10539
|
+
// 一次约 160ms),而本函数在初始化、rAF、load 以及每次窗口 resize 都会跑。
|
|
10540
|
+
// 消费这两个变量的 .dshwv-text/.dshwv-gif 都在 root 内(继承即可);
|
|
10541
|
+
// 编辑器预览在 root 之外,所以在 bubblePreviewInto() 里补写了两行。
|
|
10542
|
+
var s = root.style
|
|
10378
10543
|
s.setProperty('--dshw-vx', cx + '%')
|
|
10379
10544
|
s.setProperty('--dshw-vy', cy + '%')
|
|
10380
10545
|
} catch (err) {}
|
|
@@ -12141,6 +12306,12 @@ function bubblePreviewInto(container, mods, widthPx) {
|
|
|
12141
12306
|
container.style.transform = 'none'
|
|
12142
12307
|
container.style.transformOrigin = ''
|
|
12143
12308
|
container.style.setProperty('--dshw-u', (W / 1026) + 'px')
|
|
12309
|
+
// v751(PR #119):预览节点在挂件 root 之外,拿不到 root 上的 --dshw-vx/--dshw-vy(以前写在 <html> 上才吃得到),
|
|
12310
|
+
// 这里补一份,保证编辑器里的预览与真实泡泡同排版。
|
|
12311
|
+
try {
|
|
12312
|
+
container.style.setProperty('--dshw-vx', dshwCenterX + '%')
|
|
12313
|
+
container.style.setProperty('--dshw-vy', dshwCenterY + '%')
|
|
12314
|
+
} catch (err) {}
|
|
12144
12315
|
// 视觉右移 10px:用左右 margin 的非对称(右侧少让),避免溢出撑出横向滚动条
|
|
12145
12316
|
var halfGap = Math.max(0, (hostW - W) / 2)
|
|
12146
12317
|
var shiftR = Math.min(10, Math.max(0, Math.round(halfGap)))
|
|
@@ -12663,7 +12834,14 @@ function dshwvToast(msg) {
|
|
|
12663
12834
|
}
|
|
12664
12835
|
function configSaveFailNotice(detail) {
|
|
12665
12836
|
try { console.error('[dsh-whale] 设置保存失败:', detail) } catch (err) {}
|
|
12666
|
-
|
|
12837
|
+
// v756(issue #143):toast 是按 HTML 设计的(其余调用点传的字面量里带 `<br>`),而这里的 detail
|
|
12838
|
+
// 是**动态值**(服务端 JSON 回包的 error 字段 / fetch 异常消息),直接拼进 innerHTML 就是一处
|
|
12839
|
+
// 「动态数据进 HTML 位置」。当前来源都在本地信任边界内、构造不出真实利用,但把 provider 回包、
|
|
12840
|
+
// 模型名之类接进同一个 toast 时它会立刻变成真洞 —— 所以只转义这一个动态值,toast 自身的
|
|
12841
|
+
// `<br>` 保留。
|
|
12842
|
+
var safe = String(detail || '').slice(0, 120)
|
|
12843
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
12844
|
+
dshwvToast('⚠ 设置保存失败:' + safe +
|
|
12667
12845
|
'<br>已自动重试一次。若持续失败,请检查 DSH 数据目录是否可写。')
|
|
12668
12846
|
}
|
|
12669
12847
|
function configPayload() {
|
|
@@ -12861,7 +13039,9 @@ function setScale(v) {
|
|
|
12861
13039
|
function setVol(v) {
|
|
12862
13040
|
var next = Math.round(Math.min(1, Math.max(0, Number(v))) * 100) / 100
|
|
12863
13041
|
soundVol = next
|
|
12864
|
-
|
|
13042
|
+
// v753(issue #135):**不再由音量派生 soundOn**。原来"拉到 0 就等于关音效"是个没写明的隐含行为,
|
|
13043
|
+
// 既让想临时静音的人被迫牺牲原来的音量值,也让"音效开关"这件事在界面上无迹可寻。
|
|
13044
|
+
// 现在开关自己说了算(见 setSoundOn),音量只表示音量。
|
|
12865
13045
|
volInput.value = String(next)
|
|
12866
13046
|
volPct.textContent = Math.round(next * 100) + '%'
|
|
12867
13047
|
try {
|
|
@@ -12870,6 +13050,22 @@ function setVol(v) {
|
|
|
12870
13050
|
} catch (err) {}
|
|
12871
13051
|
saveConfig()
|
|
12872
13052
|
}
|
|
13053
|
+
// v753(issue #135):显式音效总开关(菜单「音量」右边那个勾选框)。
|
|
13054
|
+
// 它同时管三件事:① 出声与否(播放路径都看 soundOn);② 关掉时**立刻挂起** AudioContext,
|
|
13055
|
+
// 把系统睡眠交还给用户,而不是等 1 分钟空闲;③ 重新打开时立刻预热,恢复"点按即响"的跟手度。
|
|
13056
|
+
function setSoundOn(v) {
|
|
13057
|
+
soundOn = v !== false
|
|
13058
|
+
try { soundToggle.checked = soundOn } catch (err) {}
|
|
13059
|
+
if (!soundOn) {
|
|
13060
|
+
dshwvAudioSuspendNow()
|
|
13061
|
+
} else {
|
|
13062
|
+
try { applySoundSet() } catch (err) {}
|
|
13063
|
+
}
|
|
13064
|
+
try {
|
|
13065
|
+
var p = saveConfig()
|
|
13066
|
+
if (p && typeof p.then === 'function') p.catch(function () {})
|
|
13067
|
+
} catch (err) {}
|
|
13068
|
+
}
|
|
12873
13069
|
function setSoundSet(v) {
|
|
12874
13070
|
// 支持预设组(duck/fx1)和自定义组 id
|
|
12875
13071
|
soundSet = typeof v === 'string' && v ? v : 'duck'
|
|
@@ -12884,6 +13080,28 @@ var pressing = false
|
|
|
12884
13080
|
var pressEnded = false
|
|
12885
13081
|
var releasePlayed = false
|
|
12886
13082
|
// v745:不再需要 releaseTimer —— 点按时由 playReleaseAt() 在**音频线程**排期(见 pressUp)
|
|
13083
|
+
// —— v752:本体按压/松开音改走「片段路由」——
|
|
13084
|
+
// 起因:issue 里那台机器上 /dsh-whale/sound/press.mp3?set=… 会被本机的一层东西(代理/安全软件)
|
|
13085
|
+
// 拦成**空的 204**(响应头里没有 Date、还多出 pragma: no-cache,不是本进程发出的),
|
|
13086
|
+
// 0 字节送进 decodeAudioData 就抛 EncodingError → 只剩静音;
|
|
13087
|
+
// 而同一台机器上 /dsh-whale/audio-fragment.wav?id=<片段> 是 200 且能解码。
|
|
13088
|
+
// 两边的字节是**同一个文件**(预设 ya1→assets/Ya1.mp3;自定义组→它引用的那个片段),
|
|
13089
|
+
// 所以音色、时长、衔接时序都不变;同时少一层"组→片段"的宿主侧间接。
|
|
13090
|
+
// 返回 { url, alt, empty }:url=首选(片段路由)、alt=兜底(老的声音组路由,失败时自动切换)、
|
|
13091
|
+
// empty=true 表示该槽显式留空('' = 该事件静音,按设计不出声)。
|
|
13092
|
+
function soundSlotUrls(slot, groupId) {
|
|
13093
|
+
var gid = String(groupId || soundSet || 'duck')
|
|
13094
|
+
if (audioGroupSlotEmpty(gid, slot)) return { url: '', alt: '', empty: true }
|
|
13095
|
+
var frag = ''
|
|
13096
|
+
for (var i = 0; i < audioGroups.length; i++) {
|
|
13097
|
+
var g = audioGroups[i]
|
|
13098
|
+
if (g && g.id === gid) { frag = String(g[slot] || ''); break }
|
|
13099
|
+
}
|
|
13100
|
+
var legacy = '/dsh-whale/sound/' + (slot === 'press' ? 'press' : 'release') + '.mp3?set=' + encodeURIComponent(gid)
|
|
13101
|
+
// 组信息还没到(audio.json 未返回)或找不到组 → 与旧行为完全一致,走老路由
|
|
13102
|
+
if (!frag) return { url: legacy, alt: '', empty: false }
|
|
13103
|
+
return { url: '/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(frag), alt: legacy, empty: false }
|
|
13104
|
+
}
|
|
12887
13105
|
function applySoundSet() {
|
|
12888
13106
|
try {
|
|
12889
13107
|
// v729:切音效组 / 开关音效时把本轮播放状态一并复位,
|
|
@@ -12891,24 +13109,25 @@ function applySoundSet() {
|
|
|
12891
13109
|
pressEnded = false
|
|
12892
13110
|
releasePlayed = false
|
|
12893
13111
|
// 槽位显式留空(该事件静音)时,对应音频元素置空;playPress/playRelease 已判空
|
|
12894
|
-
var
|
|
12895
|
-
var
|
|
12896
|
-
if (
|
|
12897
|
-
pressAudio = dshwvSound(
|
|
13112
|
+
var pu = soundSlotUrls('press')
|
|
13113
|
+
var ru = soundSlotUrls('release')
|
|
13114
|
+
if (pu.empty) { pressAudio = null } else {
|
|
13115
|
+
pressAudio = dshwvSound(pu.url)
|
|
13116
|
+
pressAudio._alt = pu.alt || ''
|
|
12898
13117
|
pressAudio.preload = 'auto'
|
|
12899
13118
|
pressAudio.volume = soundVol
|
|
12900
13119
|
}
|
|
12901
|
-
if (
|
|
12902
|
-
releaseAudio = dshwvSound(
|
|
13120
|
+
if (ru.empty) { releaseAudio = null } else {
|
|
13121
|
+
releaseAudio = dshwvSound(ru.url)
|
|
13122
|
+
releaseAudio._alt = ru.alt || ''
|
|
12903
13123
|
releaseAudio.preload = 'auto'
|
|
12904
13124
|
releaseAudio.volume = soundVol
|
|
12905
13125
|
}
|
|
12906
13126
|
// v745:把这组的按压/松开音**预取+预解码**(Web Audio 下 preload='auto' 不解码)。
|
|
12907
13127
|
// 预热过之后,起播走 dshwvSound 的同步路径(pointerdown 同一任务里 start),手感才贴手。
|
|
12908
|
-
|
|
12909
|
-
|
|
12910
|
-
|
|
12911
|
-
])
|
|
13128
|
+
// ⚠️ 不变式:这里预热的 URL 必须与上面建元素用的 URL **逐字符相同**(同一个变量,不再各写一遍字面量)——
|
|
13129
|
+
// 一旦"预热 A、播放 B",点按就会退回异步解码路径,手感立刻变钝(0.3.3 那次"变钝"的根因)。
|
|
13130
|
+
dshwvWarm([pu.empty ? '' : pu.url, ru.empty ? '' : ru.url])
|
|
12912
13131
|
} catch (err) {}
|
|
12913
13132
|
}
|
|
12914
13133
|
function playPress() {
|
|
@@ -14431,6 +14650,12 @@ function hideAudioCrop() {
|
|
|
14431
14650
|
audioCropBuffer = null
|
|
14432
14651
|
audioCropTarget = null
|
|
14433
14652
|
audioCropFileBase = ''
|
|
14653
|
+
// v753(issue #135):裁剪面板自己的 AudioContext 关闭面板就销毁 —— 它不复用,
|
|
14654
|
+
// 留着同样是"一条一直开着的系统音频流"(重新打开面板时导入音频会重新创建,见上面的 if (!audioCropCtx))。
|
|
14655
|
+
try {
|
|
14656
|
+
if (audioCropCtx) audioCropCtx.close()
|
|
14657
|
+
} catch (err) {}
|
|
14658
|
+
audioCropCtx = null
|
|
14434
14659
|
try { audioCropName.value = '' } catch (err) {}
|
|
14435
14660
|
updateAudioCropOkState()
|
|
14436
14661
|
}
|
|
@@ -14641,6 +14866,19 @@ function setupHitTest(url) {
|
|
|
14641
14866
|
var dx = 610 - dw // right bottom
|
|
14642
14867
|
var dy = 610 - dh
|
|
14643
14868
|
ctx.drawImage(probe, dx, dy, dw, dh)
|
|
14869
|
+
// v756(issue #144):画布读回可能被浏览器**抹白** —— Firefox 开启「隐私保护 / resistFingerprinting」
|
|
14870
|
+
// 时 getImageData 会返回全透明(有些版本直接抛错)。那样 isWhaleHit 的 `data[3] > 10` 处处为假,
|
|
14871
|
+
// 而 hitReady 仍为 true ⇒ 走不到矩形回退分支 ⇒ **整只挂件点不动**。
|
|
14872
|
+
// 这里建立命中图时就扫一遍格子:一个不透明像素都取不到 → 判为命中图不可用,退回矩形判定。
|
|
14873
|
+
var opaque = 0
|
|
14874
|
+
for (var gy = 0; gy < 6; gy++) {
|
|
14875
|
+
for (var gx = 0; gx < 6; gx++) {
|
|
14876
|
+
var px = Math.min(609, Math.floor(dx + dw * (gx + 0.5) / 6))
|
|
14877
|
+
var py = Math.min(609, Math.floor(dy + dh * (gy + 0.5) / 6))
|
|
14878
|
+
if (ctx.getImageData(px, py, 1, 1).data[3] > 10) opaque++
|
|
14879
|
+
}
|
|
14880
|
+
}
|
|
14881
|
+
if (!opaque) { hitReady = false; hitFailed = true; return }
|
|
14644
14882
|
hitReady = true
|
|
14645
14883
|
} catch (err) {
|
|
14646
14884
|
hitFailed = true
|
|
@@ -14654,18 +14892,21 @@ function setupHitTest(url) {
|
|
|
14654
14892
|
probe.src = url || IMG_URL
|
|
14655
14893
|
} catch (err) {}
|
|
14656
14894
|
}
|
|
14895
|
+
// 退回「图像矩形」判定:命中图不可用(加载失败 / 画布读不出来)时只认挂件图片的矩形区域,
|
|
14896
|
+
// 绝不把整页当成命中区(那会让全页面点不动)。
|
|
14897
|
+
function whaleRectHit(e) {
|
|
14898
|
+
try {
|
|
14899
|
+
var fr = img.getBoundingClientRect()
|
|
14900
|
+
if (!fr || fr.width <= 0 || fr.height <= 0) return false
|
|
14901
|
+
return e.clientX >= fr.left && e.clientX <= fr.right && e.clientY >= fr.top && e.clientY <= fr.bottom
|
|
14902
|
+
} catch (err) { return false }
|
|
14903
|
+
}
|
|
14657
14904
|
function isWhaleHit(e) {
|
|
14658
14905
|
// 命中图未就绪/失败时:绝不默认“全屏都是鲸鱼”。
|
|
14659
14906
|
// 加载中 → 返回 false(不拦截页面);加载失败 → 退回图像矩形区域,仅挂件区域可拖。
|
|
14660
14907
|
if (!hitCanvas || !hitReady) {
|
|
14661
14908
|
if (!hitFailed) return false
|
|
14662
|
-
|
|
14663
|
-
var fr = img.getBoundingClientRect()
|
|
14664
|
-
if (!fr || fr.width <= 0 || fr.height <= 0) return false
|
|
14665
|
-
return e.clientX >= fr.left && e.clientX <= fr.right && e.clientY >= fr.top && e.clientY <= fr.bottom
|
|
14666
|
-
} catch (err) {
|
|
14667
|
-
return false
|
|
14668
|
-
}
|
|
14909
|
+
return whaleRectHit(e)
|
|
14669
14910
|
}
|
|
14670
14911
|
try {
|
|
14671
14912
|
var r = img.getBoundingClientRect()
|
|
@@ -14674,7 +14915,16 @@ function isWhaleHit(e) {
|
|
|
14674
14915
|
var ly = (e.clientY - r.top) / r.height * 610
|
|
14675
14916
|
if (lx < 0 || ly < 0 || lx >= 610 || ly >= 610) return false
|
|
14676
14917
|
if (state.flip) lx = 610 - lx
|
|
14677
|
-
var data
|
|
14918
|
+
var data
|
|
14919
|
+
try {
|
|
14920
|
+
data = hitCanvas.getContext('2d').getImageData(Math.floor(lx), Math.floor(ly), 1, 1).data
|
|
14921
|
+
} catch (err) {
|
|
14922
|
+
// v756(issue #144):画布读不出来(隐私保护/跨域污染)→ 立刻降级为矩形判定,
|
|
14923
|
+
// 并且**记住**这个状态,后续调用直接走矩形,不再每次抛错。
|
|
14924
|
+
hitReady = false
|
|
14925
|
+
hitFailed = true
|
|
14926
|
+
return whaleRectHit(e)
|
|
14927
|
+
}
|
|
14678
14928
|
return data[3] > 10
|
|
14679
14929
|
} catch (err) {
|
|
14680
14930
|
return false
|
|
@@ -14873,13 +15123,61 @@ document.addEventListener('touchstart', onDocTouchStart, { capture: true, passiv
|
|
|
14873
15123
|
|
|
14874
15124
|
var widgetCursor = ''
|
|
14875
15125
|
function setWidgetCursor(v) {
|
|
14876
|
-
if (v
|
|
14877
|
-
|
|
14878
|
-
|
|
14879
|
-
|
|
15126
|
+
if (v === widgetCursor) return
|
|
15127
|
+
widgetCursor = v
|
|
15128
|
+
// v751(PR #119):光标不写 document.body.style.cursor —— cursor 是可继承属性,写 <body> 会让 Blink
|
|
15129
|
+
// 失效整棵文档树的样式;而本函数在点击链路上按下/抬手各写一次,紧接着 isWhaleHit() 的
|
|
15130
|
+
// getBoundingClientRect() 与泡泡行测量的 getComputedStyle()/scrollWidth 会强制刷新样式+布局 ——
|
|
15131
|
+
// 整页重算就被算进了点击里。实测(见 PR #119):5.7k 节点会话约 35ms/次、13.4k 节点约 179ms/次,
|
|
15132
|
+
// 而同样一次写入只落在挂件自己子树上约 1.3ms。所以改成切换挂件自己的类,光标由 CSS 承担。
|
|
15133
|
+
// 两个类必须互斥切换(只 add 不摘旧类会让光标一直停在 grabbing)。
|
|
15134
|
+
try {
|
|
15135
|
+
root.classList.toggle('dshwv-cursor-grab', v === 'grab')
|
|
15136
|
+
root.classList.toggle('dshwv-cursor-grabbing', v === 'grabbing')
|
|
15137
|
+
} catch (err) {}
|
|
15138
|
+
}
|
|
15139
|
+
// 鲸鱼图接过指针期间(见上面的 dshwv-cursor-* 类),滚轮要转交给「指针下方真正可滚动的容器」,
|
|
15140
|
+
// 否则桌宠会变成一块滚不动的实心区域。只做一次临时让位取元素,全程只写挂件自己的内联样式,
|
|
15141
|
+
// 不碰页面级样式;找不到可滚动祖先时什么都不做(此时浏览器仍按默认把滚动交给页面滚动容器)。
|
|
15142
|
+
function onWhaleWheel(e) {
|
|
15143
|
+
try {
|
|
15144
|
+
if (!widgetCursor || !e || !e.target) return
|
|
15145
|
+
var hit = e.target
|
|
15146
|
+
if (hit !== img && !(root && root.contains && root.contains(hit))) return
|
|
15147
|
+
var prev = hit.style ? hit.style.pointerEvents : ''
|
|
15148
|
+
var under = null
|
|
15149
|
+
try {
|
|
15150
|
+
if (hit.style) hit.style.pointerEvents = 'none' // 临时让开,取出指针下方真正的页面元素
|
|
15151
|
+
under = document.elementFromPoint(e.clientX, e.clientY)
|
|
15152
|
+
} catch (err) {}
|
|
15153
|
+
try { if (hit.style) hit.style.pointerEvents = prev } catch (err) {}
|
|
15154
|
+
var sc = under
|
|
15155
|
+
for (var hop = 0; sc && sc !== document.body && sc !== document.documentElement && hop < 12; hop++) {
|
|
15156
|
+
var st = null
|
|
15157
|
+
try { st = window.getComputedStyle(sc) } catch (err) {}
|
|
15158
|
+
if (st && (st.overflowY === 'auto' || st.overflowY === 'scroll' || st.overflowY === 'overlay') &&
|
|
15159
|
+
sc.scrollHeight > sc.clientHeight + 1) break
|
|
15160
|
+
sc = sc.parentElement
|
|
15161
|
+
}
|
|
15162
|
+
if (sc && sc !== document.body && sc !== document.documentElement) {
|
|
15163
|
+
var k = (e.deltaMode === 1) ? 16 : 1 // 1 = 行模式,按 16px 折算
|
|
15164
|
+
if (e.deltaY) sc.scrollTop += e.deltaY * k
|
|
15165
|
+
if (e.deltaX) sc.scrollLeft += e.deltaX * k
|
|
15166
|
+
e.preventDefault()
|
|
15167
|
+
}
|
|
15168
|
+
} catch (err) {}
|
|
14880
15169
|
}
|
|
15170
|
+
try { root.addEventListener('wheel', onWhaleWheel, { passive: false }) } catch (err) {}
|
|
14881
15171
|
function onDocPointerMoveCursor(e) {
|
|
14882
|
-
if (drag && drag.active) {
|
|
15172
|
+
if (drag && drag.active) {
|
|
15173
|
+
// v751(PR #119):按钮已经松开却还在"拖动中" = 这一次 pointerup 丢了(例如松手时指针在窗口外):
|
|
15174
|
+
// 不能继续强推 grabbing(光标会一直卡在"抓紧"),顺手补一次 endDrag 收尾,避免挂件继续黏着鼠标。
|
|
15175
|
+
// 鼠标/触摸/笔拖动期间 e.buttons 都是 1,buttons===0 只可能意味着真的松手了。
|
|
15176
|
+
// endDrag 内部还有 drag.moved 判定,所以这里传 clickAllowed=true 不会误触发点按。
|
|
15177
|
+
if (!e.buttons) { try { endDrag(e, true) } catch (err) {} ; return }
|
|
15178
|
+
setWidgetCursor('grabbing')
|
|
15179
|
+
return
|
|
15180
|
+
}
|
|
14883
15181
|
var el = null
|
|
14884
15182
|
try { el = document.elementFromPoint(e.clientX, e.clientY) } catch (err) {}
|
|
14885
15183
|
if (el && el.closest && (el.closest('.dshwv-pop') || el.closest('.dshwv-menu') || el.closest('.dshwv-menu-btn') || el.closest('.dshwv-rolelist') || el.closest('.dshwv-cropmask') || el.closest('.dshwv-confirmmask') || el.closest('.dshwv-audiolist') || el.closest('.dshwv-audiomask') || el.closest('.dshwv-snapmask') || el.closest('.dshwv-bubmask') || el.closest('.dshwv-qedit') || el.closest('.dshwv-usagepanel') || el.closest('.dshwv-usage-mask') || el.closest('.dshwv-resmask') || el.closest('.dshwv-custmenu') || el.closest('.dshwv-custbtn'))) {
|
|
@@ -15042,7 +15340,8 @@ fetch(SIZE_URL, { cache: 'no-store' })
|
|
|
15042
15340
|
}
|
|
15043
15341
|
if (d && typeof d.vol === 'number') {
|
|
15044
15342
|
soundVol = d.vol
|
|
15045
|
-
|
|
15343
|
+
// v753(issue #135):旧配置没有 sound 字段 → 沿用"音量>0 即开"的老语义;有新字段就以它为准
|
|
15344
|
+
if (typeof d.sound !== 'boolean') soundOn = soundVol > 0
|
|
15046
15345
|
volInput.value = String(soundVol)
|
|
15047
15346
|
volPct.textContent = Math.round(soundVol * 100) + '%'
|
|
15048
15347
|
try {
|
|
@@ -15050,6 +15349,11 @@ fetch(SIZE_URL, { cache: 'no-store' })
|
|
|
15050
15349
|
if (releaseAudio) releaseAudio.volume = soundVol
|
|
15051
15350
|
} catch (err) {}
|
|
15052
15351
|
}
|
|
15352
|
+
// v753:音效总开关(configPayload 一直在写 sound 字段,但以前加载时被"音量>0"覆盖掉了 → 白存)
|
|
15353
|
+
if (d && typeof d.sound === 'boolean') {
|
|
15354
|
+
soundOn = d.sound
|
|
15355
|
+
try { soundToggle.checked = soundOn } catch (err) {}
|
|
15356
|
+
}
|
|
15053
15357
|
if (d && typeof d.soundSet === 'string' && d.soundSet) {
|
|
15054
15358
|
// 支持预设组(duck/fx1)和自定义组 id;loadAudio() 会校验自定义组是否存在
|
|
15055
15359
|
soundSet = d.soundSet
|
package/lib/index.js
CHANGED
|
@@ -586,28 +586,89 @@ 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
|
+
function selfRejection(req) {
|
|
594
616
|
try {
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
617
|
+
const headers = (req && req.headers) || {}
|
|
618
|
+
let hostUrl = null
|
|
619
|
+
try { hostUrl = new URL('http://' + String(headers.host || '')) } catch (err) { return 403 } // 缺 Host / 畸形 → 拒
|
|
620
|
+
const hostname = hostUrl.hostname
|
|
621
|
+
const authority = hostUrl.host
|
|
622
|
+
if (!isLoopbackHostname(hostname)) {
|
|
623
|
+
const listed = TRUSTED_AUTHORITIES.some((e) => (e.indexOf(':') >= 0 ? e === authority.toLowerCase() : e === hostname.toLowerCase()))
|
|
624
|
+
if (!listed) return 403
|
|
625
|
+
}
|
|
626
|
+
const site = String(headers['sec-fetch-site'] || '').toLowerCase()
|
|
627
|
+
if (site === 'cross-site') return 403
|
|
628
|
+
const origin = headers.origin
|
|
629
|
+
if (typeof origin === 'string' && origin && origin !== 'null') {
|
|
630
|
+
let originUrl = null
|
|
631
|
+
try { originUrl = new URL(origin) } catch (err) { return 403 }
|
|
632
|
+
if (originUrl.host.toLowerCase() !== authority.toLowerCase()) return 403
|
|
604
633
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
634
|
+
return null
|
|
635
|
+
} catch (err) { return 403 } // 校验器自身出错 → 拒绝(fail-closed)
|
|
636
|
+
}
|
|
637
|
+
function connectionFence() {
|
|
638
|
+
try {
|
|
639
|
+
const conn = ctx.get('connection') || ctx.connection
|
|
640
|
+
return conn && typeof conn.requestRejection === 'function' ? conn : null
|
|
641
|
+
} catch (err) { return null }
|
|
642
|
+
}
|
|
643
|
+
if (!connectionFence()) {
|
|
644
|
+
try {
|
|
645
|
+
console.warn('[whale-balance] 宿主信任栅栏不可用(connection.requestRejection 缺失):已启用插件自带的回环/同源校验,'
|
|
646
|
+
+ '所有路由(含写接口)仍受它保护;若本机 dsh 部署在非回环地址(反代/局域网),请用环境变量 '
|
|
647
|
+
+ 'DSHW_TRUSTED_HOSTS 声明允许的 Host(逗号分隔,可带端口)。')
|
|
648
|
+
} catch (err) {}
|
|
649
|
+
}
|
|
650
|
+
function rejected(req, res) {
|
|
651
|
+
const deny = (code) => {
|
|
652
|
+
try { res.statusCode = code || 403; res.end() } catch (err) {}
|
|
609
653
|
return true
|
|
610
|
-
}
|
|
654
|
+
}
|
|
655
|
+
const self = selfRejection(req)
|
|
656
|
+
if (self !== null) return deny(self)
|
|
657
|
+
const conn = connectionFence()
|
|
658
|
+
if (!conn) return false // ① 已通过;此刻没有宿主栅栏可委托(加载时已 warn)
|
|
659
|
+
let code
|
|
660
|
+
try {
|
|
661
|
+
code = conn.requestRejection(req)
|
|
662
|
+
} catch (err) {
|
|
663
|
+
// 旧实现这里是 `return false`:栅栏自己抛异常被当成"没被拒"而静默放行(#136 的第二条路径)
|
|
664
|
+
if (!rejected.warned) {
|
|
665
|
+
rejected.warned = true
|
|
666
|
+
try { console.warn('[whale-balance] 信任栅栏抛异常,已按拒绝处理:' + String((err && err.message) || err)) } catch (e2) {}
|
|
667
|
+
}
|
|
668
|
+
return deny(403)
|
|
669
|
+
}
|
|
670
|
+
if (code === undefined || code === null || code === false) return false
|
|
671
|
+
return deny(typeof code === 'number' ? code : 403)
|
|
611
672
|
}
|
|
612
673
|
// 统一注册入口:所有路由自动套上信任栅栏
|
|
613
674
|
function registerRoute(route) {
|
|
@@ -985,7 +1046,7 @@ export default {
|
|
|
985
1046
|
const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
|
|
986
1047
|
for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
|
|
987
1048
|
return {
|
|
988
|
-
ok: true, version: '0.3.
|
|
1049
|
+
ok: true, version: '0.3.11', today: todayData, days7,
|
|
989
1050
|
total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
|
|
990
1051
|
all: {
|
|
991
1052
|
days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
|
|
@@ -2185,7 +2246,7 @@ export default {
|
|
|
2185
2246
|
const summary = daySummary(led, todayKey())
|
|
2186
2247
|
const nowSec = Math.floor(Date.now() / 1000)
|
|
2187
2248
|
return {
|
|
2188
|
-
...visible, version: '0.3.
|
|
2249
|
+
...visible, version: '0.3.11', isPeak: isPeakTime(nowSec),
|
|
2189
2250
|
// 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
|
|
2190
2251
|
peakNextChangeAt: nextPeakChangeAt(nowSec),
|
|
2191
2252
|
peakHolidays: HOLIDAY_VALLEY_LIST,
|
|
@@ -3167,7 +3228,9 @@ export default {
|
|
|
3167
3228
|
function serveSound(req, res, candidates) {
|
|
3168
3229
|
const bytes = loadSound(candidates)
|
|
3169
3230
|
if (!bytes) {
|
|
3170
|
-
|
|
3231
|
+
// v752:404 也带 no-store。原来只有 200 分支有缓存头,失败的响应可能被浏览器/中间层
|
|
3232
|
+
// 缓存住,一旦缓存就会变成"永久没声音、刷新也没用"。
|
|
3233
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
3171
3234
|
res.end('sound unavailable')
|
|
3172
3235
|
return
|
|
3173
3236
|
}
|
|
@@ -3188,7 +3251,7 @@ export default {
|
|
|
3188
3251
|
const setName = soundSetFromUrl(req.url)
|
|
3189
3252
|
if (setName && !SOUND_SETS[setName]) {
|
|
3190
3253
|
const fragId = groupFragmentId(setName, 'press')
|
|
3191
|
-
if (fragId === '') { res.writeHead(204); res.end(); return } //
|
|
3254
|
+
if (fragId === '') { res.writeHead(204, { 'Cache-Control': 'no-store' }); res.end(); return } // 留空槽:该事件静音(v752:补 no-store,空响应不允许被缓存)
|
|
3192
3255
|
const bytes = loadAudioFragmentBytes(fragId)
|
|
3193
3256
|
if (bytes) {
|
|
3194
3257
|
res.writeHead(200, {
|
|
@@ -3212,7 +3275,7 @@ export default {
|
|
|
3212
3275
|
const setName = soundSetFromUrl(req.url)
|
|
3213
3276
|
if (setName && !SOUND_SETS[setName]) {
|
|
3214
3277
|
const fragId = groupFragmentId(setName, 'release')
|
|
3215
|
-
if (fragId === '') { res.writeHead(204); res.end(); return } //
|
|
3278
|
+
if (fragId === '') { res.writeHead(204, { 'Cache-Control': 'no-store' }); res.end(); return } // 留空槽:该事件静音(v752:补 no-store,空响应不允许被缓存)
|
|
3216
3279
|
const bytes = loadAudioFragmentBytes(fragId)
|
|
3217
3280
|
if (bytes) {
|
|
3218
3281
|
res.writeHead(200, {
|
package/package.json
CHANGED