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 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
  - **今日已用显示 `--`**:先等一次成功的余额观测;统计从该观测时刻开始,起点之前的消费不在此区间内。
@@ -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
- dshwvAudioBuffers[url] = fetch(url, { cache: 'force-cache' })
114
- .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer() })
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
- dshwvAudioBuffer(el._url).then(function (buf) {
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
- url = '/dsh-whale/sound/' + (parts[2] === 'release' ? 'release' : 'press') + '.mp3?set=' + parts[1]
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(['/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId)]) // v745:先预热
1243
- var relOnly = dshwvSound('/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId))
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
- var press = dshwvSound('/dsh-whale/sound/press.mp3?set=' + encodeURIComponent(groupId))
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
- '/dsh-whale/sound/press.mp3?set=' + encodeURIComponent(groupId),
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('/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId))
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
- var cand = [
8574
- bubbleMask, bubbleItemMask, moduleMask, moduleNamePromptMask,
8575
- cropMask, gifMask, audioCropMask, audioEditMask, resMaskEl,
8576
- confirmMask, snapMask, usageMask, usageMoreMask,
8577
- qeditEl, dshwvTplHelpEl, dshwvHintEl,
8578
- apiModelMaskEl, accountingMask, window.__dshwRemindMask
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 < cand.length; i++) {
8594
- var n = eff(cand[i])
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
- try { if (el && el.style) el.style.zIndex = String(Math.max(base, Math.round(visibleTopZ()) + 10)) } catch (err) {}
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
- var vTop = visibleTopZ()
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
- var s = document.documentElement.style
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
- dshwvToast('⚠ 设置保存失败:' + String(detail || '').slice(0, 120) +
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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
- soundOn = next > 0
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 pEmpty = audioGroupSlotEmpty(soundSet, 'press')
12895
- var rEmpty = audioGroupSlotEmpty(soundSet, 'release')
12896
- if (pEmpty) { pressAudio = null } else {
12897
- pressAudio = dshwvSound('/dsh-whale/sound/press.mp3?set=' + soundSet)
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 (rEmpty) { releaseAudio = null } else {
12902
- releaseAudio = dshwvSound('/dsh-whale/sound/release.mp3?set=' + soundSet)
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
- dshwvWarm([
12909
- pEmpty ? '' : '/dsh-whale/sound/press.mp3?set=' + soundSet,
12910
- rEmpty ? '' : '/dsh-whale/sound/release.mp3?set=' + soundSet,
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
- try {
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 = hitCanvas.getContext('2d').getImageData(Math.floor(lx), Math.floor(ly), 1, 1).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 !== widgetCursor) {
14877
- widgetCursor = v
14878
- try { document.body.style.cursor = v } catch (err) {}
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) { setWidgetCursor('grabbing'); return }
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
- soundOn = soundVol > 0
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
- // —— 浏览器信任栅栏(对应 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
+ function selfRejection(req) {
594
616
  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
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
- 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()
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
- } catch (err) { return false }
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.9', today: todayData, days7,
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.9', isPeak: isPeakTime(nowSec),
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
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",