dsh-plugin-notify-sounds 1.6.5 → 1.6.9
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 +5 -0
- package/README.zh.md +5 -0
- package/lib/client.js +229 -53
- package/lib/index.js +130 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,6 +105,7 @@ The command line writes `dsh.profile.bundles` and installs the dependency for yo
|
|
|
105
105
|
- **The window is found by its title** — deliberately without inspecting other processes (that trips security software). If DSH ever changes its title format, click-to-return silently stops working; sounds and notifications are unaffected.
|
|
106
106
|
- **The nav glyph** depends on the settings panel's DOM structure and silently falls back to the official gear after a DSH redesign — cosmetic only.
|
|
107
107
|
- **Notification permission** is granted on the Desktop; under restrictive policies the row shows how to allow it manually, everything else keeps working.
|
|
108
|
+
- **macOS system notifications**: the desktop shell grants every permission request, so settings always say "granted" — whether a banner actually appears is up to macOS (System Settings → Notifications → DeepSeek Harness), and unsigned development builds may fail silently. "It says granted but nothing shows" is usually not this plugin.
|
|
108
109
|
- **Upgrading from v1.0.0**: since v1.1.0 approvals and questions are separate tracks, so questions default to "Chime" instead of "Bell" — change it back in settings if you prefer.
|
|
109
110
|
|
|
110
111
|
## 🧩 Design notes
|
|
@@ -123,6 +124,10 @@ For anyone hacking on the code (exact details live in the source comments):
|
|
|
123
124
|
|
|
124
125
|
## 📜 Changelog
|
|
125
126
|
|
|
127
|
+
- **v1.6.9** (2026-10-12) Fixes a README filename compatibility issue in v1.6.8: reverted to `README.md` (English) + `README.zh.md` (Chinese) so the update whitelist in every prior version can recognise them. A new filename would be refused by old users' updates — the whitelist is one-directional because the update runs old code.
|
|
128
|
+
- **v1.6.8** (2026-10-12) Review fixes and optimizations: the auto-check first-frame timing gap is closed (`undefined` is no longer treated as permission to reach out); an orphaned JSDoc is removed; the teardown assertion now actually verifies listener removal; `normalizedGain` samples every 8th frame (2.6M → 330K iterations for a 30-second upload); the master gain disconnects after playback ends (no more audio-node leak); `swapNavIcon` is wrapped in try/catch and preserves the original node for restore on dispose (React re-renders can no longer crash it); the cooldown table evicts the oldest entry instead of clearing all; and `sessionRows` caches one snapshot per synchronous block.
|
|
129
|
+
- **v1.6.7** (2026-10-12) Three fixes from the review: a macOS browser tab is no longer mislabeled "not supported" for the title alert (`isDesktopShell` is the right criterion, not `isMac` alone); the first-frame timing gap in the auto-check guard is closed (`undefined` is no longer treated as permission); and three comments that no longer matched the code are corrected. Plus BUG-1 regression tests covering the suspended AudioContext path.
|
|
130
|
+
- **v1.6.6** (2026-10-12) Nine fixes from an audit: sounds are no longer lost under the browser autoplay policy, and no longer "banked" to fire together at some later click (first-gesture unlock, and nothing is scheduled while the context cannot play); the macOS window raise now activates by bundle id with the deep link as fallback and logs its failures; update rollback is complete — files the update added are removed again, no temp file survives, and the backup is cleaned up once the swap succeeds; checks and updates are single-flight, so two pages clicking at once neither collide nor double-request; turning automatic checks off now really keeps the plugin quiet; a mobile browser that refuses to build a notification is reported as unsupported instead of showing a switch that can never work; and the nav glyph is back on the artwork's own 17×17 coordinate system. Platform notes are now labelled where they apply: the title alert says macOS does not support it, and repeat reminders note that background browser tabs throttle timers.
|
|
126
131
|
- **v1.6.5** (2026-10-11) Quiet hours now mean forgotten, not queued: decisions and finishes that arrive during the quiet range get no reminder and no title mark, and nothing that was missed overnight is delivered when the window ends; reminders that come due inside it are dropped rather than deferred. The update outcome now appears at the top of the section (visible the moment the page reloads, no scrolling), and the update row's copy is simpler.
|
|
127
132
|
- **v1.6.4** (2026-10-11) No more scrolling to find the news: an update swaps the plugin's own client file and the page reloads at the top, so the row now scrolls itself back into view when the outcome appears.
|
|
128
133
|
- **v1.6.3** (2026-10-11) The update-success message no longer flashes and vanishes: the Host remembers the install and repeats it with every check, so "Updated, restart DSH to load it" survives the page reload the update itself triggers (it swaps the client half's own file) and stays until the restart. The cached check is invalidated after an install too, so the row can no longer claim a newer version than the one it just wrote.
|
package/README.zh.md
CHANGED
|
@@ -105,6 +105,7 @@ dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds
|
|
|
105
105
|
- **唤窗按窗口标题定位主窗口**:刻意不读取其他进程信息(那会触发杀软主动防御)。若将来 DSH 改变标题格式,唤窗会静默失效——提示音与通知本身不受影响。
|
|
106
106
|
- **导航小图标**:依赖设置面板的 DOM 结构,若DSH 改版后自动回落为官方齿轮,仅影响外观。
|
|
107
107
|
- **通知权限**:桌面端放行;受限环境(某些浏览器策略)下被拒时,行内会引导手动允许,其余功能不受影响。
|
|
108
|
+
- **macOS 的系统通知**:桌面外壳对权限请求一律放行,所以设置里恒显示「已授权」;**实际是否弹横幅由 macOS 决定**(系统设置 → 通知 → DeepSeek Harness),未签名的开发版可能静默失败——「显示已授权却没通知」多半不是插件的问题。
|
|
108
109
|
|
|
109
110
|
## 🧩 设计与实现
|
|
110
111
|
|
|
@@ -122,6 +123,10 @@ dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds
|
|
|
122
123
|
|
|
123
124
|
## 📜 更新记录
|
|
124
125
|
|
|
126
|
+
- **v1.6.9**(2026-10-12)修正 v1.6.8 的 README 文件名兼容性:改回 `README.md`(英文默认)+ `README.zh.md`(中文),确保旧版本更新白名单能识别。新文件名会让旧用户的更新被拒——白名单是单向兼容的,更新时跑的是旧代码。
|
|
127
|
+
- **v1.6.8**(2026-10-12)修复复核细节 + 优化:`autoCheck` 首帧时序补上(`undefined` 不再被当成允许联网);删孤立 JSDoc;teardown 断言改为真正验证监听器移除;`normalizedGain` 抽样(每 8 帧,260 万→33 万次);`master` gain 播完自动断连(不再泄漏音频节点);`swapNavIcon` try/catch + 保留原节点、dispose 时还原(React 重排不再炸);冷却表 LRU 淘汰(不再全清);`sessionRows` 同步块级快照缓存。
|
|
128
|
+
- **v1.6.7**(2026-10-12)修复复核发现的 3 处细节:macOS 浏览器标签页不再被误标「暂不支持标题提醒」(`isDesktopShell` 才是判据);「自动检查=关」的首帧时序缺口补上(`undefined` 不再被当成允许);三处注释与实现不符已更正。补 BUG-1 回归测试(挂起 AudioContext 三断言)。
|
|
129
|
+
- **v1.6.6**(2026-10-12)按审计报告修复 9 处:浏览器自动播放策略下不再丢失提示音、也不再「攒到某次点击时一起补放」(首次手势解锁 + 只在真正可播时排程);macOS 唤窗改为按 bundle id 激活、深链兜底,失败会写进日志;更新回滚补全——新增文件一并撤回、不留临时文件、成功后清理备份;检查与更新加单飞,两个页面同时点不再互踩或重复请求;「自动检查更新=关」真正生效;移动端通知构造失败会如实显示不支持;导航图标回到官方 17×17 坐标系。跨平台标注:macOS 的「窗口标题提醒」标为暂不支持,浏览器下的重复提醒标注后台可能节流。
|
|
125
130
|
- **v1.6.5**(2026-10-11)免打扰改为**真的忘记**:静音时段里发生的决策与完成不再记账——不响、不通知、不打标题标记,时段结束后也不会把夜里漏掉的一起发回来;本该在时段内发出的重复提醒直接作废而非顺延。更新提示移到分区顶部(页面回顶也能第一眼看到,不再需要滚动);更新行文案简化。
|
|
126
131
|
- **v1.6.4**(2026-10-11)更新完成后不用再手动往下翻:更新会替换插件自身的客户端文件、触发页面重载并回到顶部,现在「更新完成」提示出现时,「更新」行会**平滑滚动回视野**。
|
|
127
132
|
- **v1.6.3**(2026-10-11)更新成功的提示不再一闪而过:更新结果改由**宿主**记住、随每次检查下发——即便更新替换掉了客户端自身文件、页面热重载丢掉本地状态,「更新完成:vX → vY,重启 DSH 后生效」也会一直显示到你重启;同时更新后**作废检查缓存**,不会再出现「明明刚升完却仍显示有新版」。
|
package/lib/client.js
CHANGED
|
@@ -70,6 +70,33 @@ window.__ModuleLoader__.load({
|
|
|
70
70
|
".dsh-notify-sounds-quietDash{color:var(--dsw-alias-label-tertiary)}",
|
|
71
71
|
".dsh-notify-sounds-hint{color:var(--dsw-alias-label-tertiary);margin-top:4px;font-size:12px;line-height:18px}",
|
|
72
72
|
].join("");
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether this page is running inside the Desktop shell rather than a
|
|
76
|
+
* real browser.
|
|
77
|
+
*
|
|
78
|
+
* Derived from what the page can see: the shell serves the same bundle
|
|
79
|
+
* over its own `dsh-app:` protocol and ships an Electron user agent,
|
|
80
|
+
* while a browser serves it over http(s) with no Electron token. Either
|
|
81
|
+
* signal alone would do; using both keeps one of them changing its mind
|
|
82
|
+
* from flipping the answer.
|
|
83
|
+
*/
|
|
84
|
+
const isDesktopShell = () => {
|
|
85
|
+
if (typeof location !== "undefined" && location.protocol === "dsh-app:") return true;
|
|
86
|
+
return typeof navigator !== "undefined" && /Electron/i.test(navigator.userAgent ?? "");
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether the page is on macOS.
|
|
91
|
+
*
|
|
92
|
+
* Note this is a pure OS check: it matches macOS browsers too. The
|
|
93
|
+
* places that need "macOS DESKTOP SHELL" combine this with
|
|
94
|
+
* `isDesktopShell()`, because the window chrome that breaks the title
|
|
95
|
+
* alert (`titleBarStyle: "hiddenInset"`) is the shell's own choice —
|
|
96
|
+
* a macOS browser tab shows the document title like any other.
|
|
97
|
+
*/
|
|
98
|
+
const isMac = () => typeof navigator !== "undefined" && /Mac/i.test(`${navigator.platform ?? ""} ${navigator.userAgent ?? ""}`);
|
|
99
|
+
|
|
73
100
|
/** Stylesheet class names, one projection the components read. */
|
|
74
101
|
const css = {
|
|
75
102
|
section: "dsh-notify-sounds-section",
|
|
@@ -151,10 +178,12 @@ window.__ModuleLoader__.load({
|
|
|
151
178
|
"systemNotify.unsupported": "当前环境不支持系统通知",
|
|
152
179
|
"titleFlash.title": "窗口标题提醒",
|
|
153
180
|
"titleFlash.description": "页面处于后台时,在窗口标题前加文字标记(待处理 / 已完成),便于在任务栏察觉;回到页面后自动清除",
|
|
181
|
+
"titleFlash.macUnsupported": "macOS 暂不支持:标题栏隐藏、Dock 也没有窗口标题,标记无处显示(只在「窗口」菜单 / Mission Control 里可见)",
|
|
154
182
|
"titleFlash.pending": "待处理",
|
|
155
183
|
"titleFlash.done": "已完成",
|
|
156
184
|
"renotify.title": "未处理决策重复提醒",
|
|
157
185
|
"renotify.description": "有决策一直未处理时,每隔设定时间重新提醒;只要你已经看过它一眼(它出现时你正在窗口,或之后回到过窗口),就不再重复提醒,即使再切走也一样",
|
|
186
|
+
"renotify.browserHint": "浏览器的后台标签页会节流计时器,重复间隔可能被拉长(1 分钟档尤其明显)",
|
|
158
187
|
"renotify.interval": "重复间隔",
|
|
159
188
|
"renotify.minutes": "{n} 分钟",
|
|
160
189
|
"quiet.title": "静音时段",
|
|
@@ -228,10 +257,12 @@ window.__ModuleLoader__.load({
|
|
|
228
257
|
"systemNotify.unsupported": "System notifications are unavailable in this environment",
|
|
229
258
|
"titleFlash.title": "Window title alert",
|
|
230
259
|
"titleFlash.description": "While the page is hidden, mark the window title as waiting for you or finished, so the taskbar shows it; cleared when you return",
|
|
260
|
+
"titleFlash.macUnsupported": "Not supported on macOS: the title bar is hidden and the Dock shows no window title, so the mark has nowhere to appear (only the Window menu and Mission Control would show it)",
|
|
231
261
|
"titleFlash.pending": "Waiting for you",
|
|
232
262
|
"titleFlash.done": "Finished",
|
|
233
263
|
"renotify.title": "Repeat unanswered decisions",
|
|
234
264
|
"renotify.description": "Re-ping decisions that stay unanswered at a fixed interval; once you have seen one — it appeared while you were at the window, or you came back to it — it never repeats, even if you leave again",
|
|
265
|
+
"renotify.browserHint": "Background browser tabs throttle timers, so the interval can stretch (most visible at the 1-minute setting)",
|
|
235
266
|
"renotify.interval": "Interval",
|
|
236
267
|
"renotify.minutes": "{n} min",
|
|
237
268
|
"quiet.title": "Quiet hours",
|
|
@@ -416,9 +447,15 @@ window.__ModuleLoader__.load({
|
|
|
416
447
|
function normalizedGain(buffer) {
|
|
417
448
|
try {
|
|
418
449
|
let peak = 0;
|
|
450
|
+
// Sample every 8th frame: a peak that would clip is still
|
|
451
|
+
// caught by any reasonably strided scan (audio waveforms are
|
|
452
|
+
// smooth enough that adjacent samples carry the same
|
|
453
|
+
// magnitude), and this keeps a 30-second upload off the main
|
|
454
|
+
// thread's critical path.
|
|
455
|
+
const stride = 8;
|
|
419
456
|
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
|
420
457
|
const data = buffer.getChannelData(channel);
|
|
421
|
-
for (let i = 0; i < data.length; i
|
|
458
|
+
for (let i = 0; i < data.length; i += stride) {
|
|
422
459
|
const magnitude = Math.abs(data[i]);
|
|
423
460
|
if (magnitude > peak) peak = magnitude;
|
|
424
461
|
}
|
|
@@ -456,12 +493,38 @@ window.__ModuleLoader__.load({
|
|
|
456
493
|
}
|
|
457
494
|
|
|
458
495
|
/**
|
|
459
|
-
*
|
|
496
|
+
* Run `play` on an AudioContext that is actually running.
|
|
460
497
|
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
498
|
+
* A suspended context has a FROZEN `currentTime`: scheduling sources on
|
|
499
|
+
* it parks them on the timeline and they all fire together whenever the
|
|
500
|
+
* context later resumes — the opposite of an alert. So nothing is ever
|
|
501
|
+
* scheduled while suspended. Where resuming needs a user gesture (every
|
|
502
|
+
* browser; the Electron shell needs none) this attempt is simply
|
|
503
|
+
* dropped, and the unlock effect has the context running for the next
|
|
504
|
+
* one rather than banking a stale sound for later.
|
|
505
|
+
*
|
|
506
|
+
* @param engine - the page's AudioContext.
|
|
507
|
+
* @param play - schedules the sound.
|
|
508
|
+
*/
|
|
509
|
+
function whenRunning(engine, play) {
|
|
510
|
+
if (engine.state === "running") {
|
|
511
|
+
play();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
Promise.resolve(engine.resume())
|
|
515
|
+
.then(() => {
|
|
516
|
+
if (engine.state === "running") play();
|
|
517
|
+
})
|
|
518
|
+
.catch(() => {});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Play one preset (or the uploaded sound) at a loudness in percent.
|
|
523
|
+
*
|
|
524
|
+
* Nothing is scheduled while the AudioContext cannot play — see
|
|
525
|
+
* `whenRunning`, which defers to the first real user gesture on
|
|
526
|
+
* browsers. Peaks stay far below clipping even with every preset voice
|
|
527
|
+
* summed.
|
|
465
528
|
*
|
|
466
529
|
* @param id - preset id; `none`, unknown ids, and `custom` without an
|
|
467
530
|
* upload are silent no-ops (the caller falls back first).
|
|
@@ -472,43 +535,51 @@ window.__ModuleLoader__.load({
|
|
|
472
535
|
*/
|
|
473
536
|
function playSound(id, volumePercent, customBase64) {
|
|
474
537
|
if (id === "none" || id === undefined) return;
|
|
538
|
+
const engine = audioEngine();
|
|
539
|
+
if (engine === undefined) return;
|
|
475
540
|
if (id === "custom") {
|
|
476
|
-
|
|
477
|
-
if (engine === undefined) return;
|
|
478
|
-
if (engine.state === "suspended") engine.resume().catch(() => {});
|
|
479
|
-
playCustomSound(engine, customBase64, volumePercent);
|
|
541
|
+
whenRunning(engine, () => playCustomSound(engine, customBase64, volumePercent));
|
|
480
542
|
return;
|
|
481
543
|
}
|
|
482
544
|
const preset = SOUND_PRESETS[id];
|
|
483
545
|
if (preset === undefined) return;
|
|
484
|
-
const engine = audioEngine();
|
|
485
|
-
if (engine === undefined) return;
|
|
486
|
-
if (engine.state === "suspended") engine.resume().catch(() => {});
|
|
487
546
|
const percent = Number.isFinite(volumePercent) ? volumePercent : DEFAULT_VOLUME;
|
|
488
547
|
const loudness = Math.max(0, Math.min(100, percent)) / 100;
|
|
489
548
|
if (loudness === 0) return;
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
osc.frequency.
|
|
549
|
+
whenRunning(engine, () => {
|
|
550
|
+
const master = engine.createGain();
|
|
551
|
+
master.gain.value = loudness * 0.6;
|
|
552
|
+
master.connect(engine.destination);
|
|
553
|
+
const t0 = engine.currentTime + 0.02;
|
|
554
|
+
let tail = 0;
|
|
555
|
+
for (const note of preset.notes) {
|
|
556
|
+
const osc = engine.createOscillator();
|
|
557
|
+
const gain = engine.createGain();
|
|
558
|
+
osc.type = note.type ?? "sine";
|
|
559
|
+
const start = t0 + note.at;
|
|
560
|
+
osc.frequency.setValueAtTime(note.freq, start);
|
|
561
|
+
if (note.glideTo !== undefined) {
|
|
562
|
+
osc.frequency.exponentialRampToValueAtTime(note.glideTo, start + note.dur * 0.85);
|
|
563
|
+
}
|
|
564
|
+
const peak = Math.max(0.0002, note.gain * 0.25);
|
|
565
|
+
gain.gain.setValueAtTime(0.0001, start);
|
|
566
|
+
gain.gain.exponentialRampToValueAtTime(peak, start + 0.012);
|
|
567
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, start + note.dur);
|
|
568
|
+
osc.connect(gain);
|
|
569
|
+
gain.connect(master);
|
|
570
|
+
osc.start(start);
|
|
571
|
+
osc.stop(start + note.dur + 0.05);
|
|
572
|
+
tail = Math.max(tail, note.at + note.dur);
|
|
502
573
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}
|
|
574
|
+
// Release the master gain once the tail has faded. Without
|
|
575
|
+
// this, every play leaves a gain node permanently wired to
|
|
576
|
+
// the destination — a slow audio-node leak on long sessions.
|
|
577
|
+
setTimeout(() => {
|
|
578
|
+
try {
|
|
579
|
+
master.disconnect();
|
|
580
|
+
} catch {}
|
|
581
|
+
}, Math.max(0, (t0 + tail + 0.1 - engine.currentTime) * 1000 + 50));
|
|
582
|
+
});
|
|
512
583
|
}
|
|
513
584
|
|
|
514
585
|
/**
|
|
@@ -747,13 +818,17 @@ window.__ModuleLoader__.load({
|
|
|
747
818
|
const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
|
|
748
819
|
|
|
749
820
|
/**
|
|
750
|
-
* Show one OS notification; every failure mode is silent.
|
|
751
|
-
*
|
|
752
|
-
*
|
|
821
|
+
* Show one OS notification; every failure mode is silent.
|
|
822
|
+
*
|
|
823
|
+
* `tag` is our OWN collapse key — we close the previous notification
|
|
824
|
+
* ourselves rather than handing the tag to the platform, because a
|
|
825
|
+
* superseded toast can swallow clicks aimed at it. `silent` keeps the
|
|
826
|
+
* OS from layering its own sound on top of ours.
|
|
753
827
|
*
|
|
754
828
|
* @param title - notification title.
|
|
755
829
|
* @param body - notification body.
|
|
756
|
-
* @param tag -
|
|
830
|
+
* @param tag - our internal key for closing the previous generation;
|
|
831
|
+
* deliberately NOT passed to the platform (see the comment inside).
|
|
757
832
|
*/
|
|
758
833
|
function showSystemNotification(title, body, tag) {
|
|
759
834
|
try {
|
|
@@ -806,9 +881,13 @@ window.__ModuleLoader__.load({
|
|
|
806
881
|
};
|
|
807
882
|
} catch (error) {
|
|
808
883
|
// `new Notification` is what can throw here, when the
|
|
809
|
-
// environment refuses to construct one
|
|
810
|
-
//
|
|
811
|
-
//
|
|
884
|
+
// environment refuses to construct one — nearly every mobile
|
|
885
|
+
// browser does exactly that, while still reporting a
|
|
886
|
+
// "granted" permission. So this is not just a log line: it is
|
|
887
|
+
// recorded and shown, because the Settings row otherwise reads
|
|
888
|
+
// `Notification.permission` and proudly says "granted" for a
|
|
889
|
+
// switch that can never do anything.
|
|
890
|
+
notificationConstructionFailed = true;
|
|
812
891
|
if (!warnedNotificationFailure) {
|
|
813
892
|
warnedNotificationFailure = true;
|
|
814
893
|
console.warn("notify-sounds: could not show a system notification", error);
|
|
@@ -819,6 +898,15 @@ window.__ModuleLoader__.load({
|
|
|
819
898
|
/** Whether the one-time notification-construction warning has been logged. */
|
|
820
899
|
let warnedNotificationFailure = false;
|
|
821
900
|
|
|
901
|
+
/**
|
|
902
|
+
* Whether this environment refused to construct a Notification.
|
|
903
|
+
*
|
|
904
|
+
* Set by the first real attempt, read by the settings row: permission
|
|
905
|
+
* being "granted" proves nothing about whether a toast can actually be
|
|
906
|
+
* built (MDN: the constructor throws in nearly all mobile browsers).
|
|
907
|
+
*/
|
|
908
|
+
let notificationConstructionFailed = false;
|
|
909
|
+
|
|
822
910
|
/**
|
|
823
911
|
* Human-readable summary of one pending interaction, for notification
|
|
824
912
|
* bodies: the approval's localized reason first, then its raw reason,
|
|
@@ -876,14 +964,22 @@ window.__ModuleLoader__.load({
|
|
|
876
964
|
* @param props - copy.
|
|
877
965
|
* @returns the notice element, or null when there is nothing to say.
|
|
878
966
|
*/
|
|
879
|
-
function UpdateNotice({ t }) {
|
|
967
|
+
function UpdateNotice({ t, autoCheck }) {
|
|
880
968
|
const [applied, setApplied] = useState(undefined);
|
|
969
|
+
// Explicit `=== true`, not `!== false`: during the first frame
|
|
970
|
+
// `useConfig` may still be loading, and `undefined` must NOT be
|
|
971
|
+
// treated as "allowed" — that is the one case where a request
|
|
972
|
+
// could slip past a user who has the switch off. The dep array is
|
|
973
|
+
// `[autoCheck]` rather than `[]` so the effect picks up the real
|
|
974
|
+
// value when it arrives, instead of only ever reading the first
|
|
975
|
+
// render's prop.
|
|
881
976
|
useEffect(() => {
|
|
977
|
+
if (autoCheck !== true) return;
|
|
882
978
|
fetch(UPDATE_CHECK_PATH, { method: "GET" })
|
|
883
979
|
.then((response) => response.json())
|
|
884
980
|
.then((payload) => setApplied(payload?.applied))
|
|
885
981
|
.catch(() => {});
|
|
886
|
-
}, []);
|
|
982
|
+
}, [autoCheck]);
|
|
887
983
|
if (applied === undefined || typeof applied.to !== "string") return null;
|
|
888
984
|
const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
|
|
889
985
|
return h("div", { className: css.hint, key: "text" }, `${t("update.done")}:${version(applied.from)} → ${version(applied.to)},${t("update.restart")}`);
|
|
@@ -937,7 +1033,7 @@ window.__ModuleLoader__.load({
|
|
|
937
1033
|
return h("div", { className: css.section }, [
|
|
938
1034
|
h("h2", { className: css.title, key: "title" }, t("title")),
|
|
939
1035
|
h("p", { className: css.intro, key: "intro" }, t("intro")),
|
|
940
|
-
h(UpdateNotice, { key: "update-notice", t }),
|
|
1036
|
+
h(UpdateNotice, { key: "update-notice", t, autoCheck: value === undefined ? undefined : value.updateAutoCheck !== false }),
|
|
941
1037
|
h("div", { className: css.rows, key: "rows" }, [
|
|
942
1038
|
h(SwitchRow, {
|
|
943
1039
|
key: "enabled",
|
|
@@ -1068,7 +1164,13 @@ window.__ModuleLoader__.load({
|
|
|
1068
1164
|
key: "titleFlash",
|
|
1069
1165
|
t,
|
|
1070
1166
|
title: t("titleFlash.title"),
|
|
1071
|
-
|
|
1167
|
+
// macOS DESKTOP shell only: its title bar is hiddenInset
|
|
1168
|
+
// and the Dock shows no per-window title, so the mark
|
|
1169
|
+
// has nowhere to appear. A macOS BROWSER tab shows the
|
|
1170
|
+
// document title like any other, so this is not
|
|
1171
|
+
// applied there. iOS Safari's UA also contains "like
|
|
1172
|
+
// Mac OS X" — `isDesktopShell` keeps it out.
|
|
1173
|
+
description: isMac() && isDesktopShell() ? t("titleFlash.macUnsupported") : t("titleFlash.description"),
|
|
1072
1174
|
label: t("titleFlash.title"),
|
|
1073
1175
|
checked: value?.titleFlash === true,
|
|
1074
1176
|
disabled,
|
|
@@ -1122,6 +1224,7 @@ window.__ModuleLoader__.load({
|
|
|
1122
1224
|
h(UpdateRow, {
|
|
1123
1225
|
key: "update",
|
|
1124
1226
|
t,
|
|
1227
|
+
autoCheck: value === undefined ? undefined : value.updateAutoCheck !== false,
|
|
1125
1228
|
}),
|
|
1126
1229
|
]),
|
|
1127
1230
|
]);
|
|
@@ -1433,7 +1536,11 @@ window.__ModuleLoader__.load({
|
|
|
1433
1536
|
*/
|
|
1434
1537
|
function SystemNotifyRow({ t, checked, disabled, failed, onChange }) {
|
|
1435
1538
|
const [busy, setBusy] = useState(false);
|
|
1436
|
-
|
|
1539
|
+
// `typeof Notification` alone is not enough: nearly every mobile
|
|
1540
|
+
// browser exposes the API and happily reports "granted", yet the
|
|
1541
|
+
// constructor throws every time. Once a real attempt has failed,
|
|
1542
|
+
// this environment is treated as unsupported and the row says so.
|
|
1543
|
+
const supported = typeof Notification !== "undefined" && !notificationConstructionFailed;
|
|
1437
1544
|
const permission = supported ? Notification.permission : "unsupported";
|
|
1438
1545
|
const hintKey = !supported ? "systemNotify.unsupported"
|
|
1439
1546
|
: permission === "denied" ? "systemNotify.denied"
|
|
@@ -1486,6 +1593,10 @@ window.__ModuleLoader__.load({
|
|
|
1486
1593
|
h("div", { key: "text" }, [
|
|
1487
1594
|
h("div", { className: css.rowTitle, key: "title" }, t("renotify.title")),
|
|
1488
1595
|
h("div", { className: css.rowDescription, key: "description" }, t("renotify.description")),
|
|
1596
|
+
// Only where it is true: the Desktop shell runs this timer
|
|
1597
|
+
// in a real window that is never throttled, so the caveat
|
|
1598
|
+
// would only confuse its users.
|
|
1599
|
+
isDesktopShell() ? null : h("div", { className: css.hint, key: "hint" }, t("renotify.browserHint")),
|
|
1489
1600
|
failed ? h("div", { className: css.error, role: "alert", key: "error" }, t("saveFailed")) : null,
|
|
1490
1601
|
]),
|
|
1491
1602
|
h("div", { className: css.stackControl, key: "control" }, [
|
|
@@ -1586,7 +1697,7 @@ window.__ModuleLoader__.load({
|
|
|
1586
1697
|
* @param props - copy.
|
|
1587
1698
|
* @returns the row element tree.
|
|
1588
1699
|
*/
|
|
1589
|
-
function UpdateRow({ t }) {
|
|
1700
|
+
function UpdateRow({ t, autoCheck }) {
|
|
1590
1701
|
const [state, setState] = useState({ phase: "idle" });
|
|
1591
1702
|
const check = (force) => async () => {
|
|
1592
1703
|
setState({ phase: "checking" });
|
|
@@ -1609,9 +1720,13 @@ window.__ModuleLoader__.load({
|
|
|
1609
1720
|
}
|
|
1610
1721
|
};
|
|
1611
1722
|
// A cached answer makes this free; only a cold Host reaches out.
|
|
1723
|
+
// Same guard as the notice: `=== true` (not `!== false`), because
|
|
1724
|
+
// during the loading frame `undefined` must not be treated as
|
|
1725
|
+
// permission to reach out. The button below works regardless.
|
|
1612
1726
|
useEffect(() => {
|
|
1727
|
+
if (autoCheck !== true) return;
|
|
1613
1728
|
check(false)();
|
|
1614
|
-
}, []);
|
|
1729
|
+
}, [autoCheck]);
|
|
1615
1730
|
const payload = state.payload;
|
|
1616
1731
|
const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
|
|
1617
1732
|
// The Host remembers the last install and repeats it with every
|
|
@@ -1707,7 +1822,11 @@ window.__ModuleLoader__.load({
|
|
|
1707
1822
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
1708
1823
|
svg.setAttribute("width", "16");
|
|
1709
1824
|
svg.setAttribute("height", "16");
|
|
1710
|
-
|
|
1825
|
+
// 17×17, matching the artwork's own coordinate system
|
|
1826
|
+
// (`IconAlarmClockOutlineArtwork`); the gear it replaces is 16×16,
|
|
1827
|
+
// and reusing ITS viewport here would scale the alarm paths up by
|
|
1828
|
+
// 17/16 and make the glyph visibly larger than its neighbours.
|
|
1829
|
+
svg.setAttribute("viewBox", "0 0 17 17");
|
|
1711
1830
|
svg.setAttribute("fill", "none");
|
|
1712
1831
|
svg.setAttribute("stroke-width", "1.3");
|
|
1713
1832
|
svg.setAttribute("aria-hidden", "true");
|
|
@@ -1742,7 +1861,18 @@ window.__ModuleLoader__.load({
|
|
|
1742
1861
|
const shippedClass = icon.getAttribute("class");
|
|
1743
1862
|
if (shippedClass !== null && shippedClass !== "") replacement.setAttribute("class", shippedClass);
|
|
1744
1863
|
replacement.setAttribute(NAV_ICON_MARKER, "");
|
|
1745
|
-
|
|
1864
|
+
// Keep the original so the disposer can restore it. `replaceChild`
|
|
1865
|
+
// detaches a node React still holds in its fiber tree; if React
|
|
1866
|
+
// ever re-renders that slot it throws NotFoundError trying to
|
|
1867
|
+
// remove the detached node. Restoring on dispose prevents that.
|
|
1868
|
+
replacement._originalIcon = icon;
|
|
1869
|
+
try {
|
|
1870
|
+
cell.replaceChild(replacement, icon);
|
|
1871
|
+
} catch {
|
|
1872
|
+
// A React re-render raced the swap; the next observer pass
|
|
1873
|
+
// will try again on the fresh node.
|
|
1874
|
+
return false;
|
|
1875
|
+
}
|
|
1746
1876
|
return true;
|
|
1747
1877
|
}
|
|
1748
1878
|
|
|
@@ -1791,6 +1921,18 @@ window.__ModuleLoader__.load({
|
|
|
1791
1921
|
return () => {
|
|
1792
1922
|
detachPanel();
|
|
1793
1923
|
bodyObserver.disconnect();
|
|
1924
|
+
// Restore every swapped glyph so React never finds a detached
|
|
1925
|
+
// node in its fiber tree (see swapNavIcon).
|
|
1926
|
+
if (typeof document !== "undefined") {
|
|
1927
|
+
for (const el of document.querySelectorAll(`[${NAV_ICON_MARKER}]`)) {
|
|
1928
|
+
const original = el._originalIcon;
|
|
1929
|
+
if (original !== undefined && el.parentNode !== null) {
|
|
1930
|
+
try {
|
|
1931
|
+
el.parentNode.replaceChild(original, el);
|
|
1932
|
+
} catch {}
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1794
1936
|
};
|
|
1795
1937
|
}
|
|
1796
1938
|
//#endregion
|
|
@@ -1863,13 +2005,22 @@ window.__ModuleLoader__.load({
|
|
|
1863
2005
|
*
|
|
1864
2006
|
* @returns the `byId` row map, or undefined when unavailable.
|
|
1865
2007
|
*/
|
|
2008
|
+
// Cache one snapshot per synchronous block: `isNotifiable` is
|
|
2009
|
+
// called per session in read()/trackPending loops, and each call
|
|
2010
|
+
// would otherwise take a fresh snapshot (O8).
|
|
2011
|
+
let cachedRows;
|
|
2012
|
+
let cachedRowsAt = 0;
|
|
1866
2013
|
const sessionRows = () => {
|
|
2014
|
+
const now = Date.now();
|
|
2015
|
+
if (cachedRows !== undefined && now === cachedRowsAt) return cachedRows;
|
|
1867
2016
|
try {
|
|
1868
|
-
|
|
2017
|
+
cachedRows = ctx.sessions?.list?.getSnapshot?.()?.byId
|
|
1869
2018
|
?? ctx.uiSession?.sessions?.list?.getSnapshot?.()?.byId;
|
|
1870
2019
|
} catch {
|
|
1871
|
-
|
|
2020
|
+
cachedRows = undefined;
|
|
1872
2021
|
}
|
|
2022
|
+
cachedRowsAt = now;
|
|
2023
|
+
return cachedRows;
|
|
1873
2024
|
};
|
|
1874
2025
|
|
|
1875
2026
|
/**
|
|
@@ -1942,7 +2093,10 @@ window.__ModuleLoader__.load({
|
|
|
1942
2093
|
// triggers are edge-fired that alert is lost rather than
|
|
1943
2094
|
// delayed.
|
|
1944
2095
|
if (!reminder) {
|
|
1945
|
-
|
|
2096
|
+
// Evict the oldest entry rather than clearing the whole
|
|
2097
|
+
// table: one session hitting the cap must not silence
|
|
2098
|
+
// every other session's cooldown.
|
|
2099
|
+
if (cooldown.size >= 96) cooldown.delete(cooldown.keys().next().value);
|
|
1946
2100
|
cooldown.set(`${kind}:${sessionId}`, now);
|
|
1947
2101
|
}
|
|
1948
2102
|
const fallback = kind === "completion" ? "ding" : kind === "question" ? "chime" : "bell";
|
|
@@ -2183,6 +2337,28 @@ window.__ModuleLoader__.load({
|
|
|
2183
2337
|
};
|
|
2184
2338
|
}, "notify-sounds: session status watcher");
|
|
2185
2339
|
|
|
2340
|
+
// Browsers gate Web Audio behind a user gesture: until one happens
|
|
2341
|
+
// the context starts suspended and `resume()` simply stays pending,
|
|
2342
|
+
// so a trigger firing in the meantime would be lost — and worse,
|
|
2343
|
+
// anything scheduled on the frozen timeline would all fire at once
|
|
2344
|
+
// whenever some later gesture unlocked it. So the FIRST real
|
|
2345
|
+
// gesture starts the context and keeps it running, and nothing is
|
|
2346
|
+
// scheduled while suspended (see `whenRunning`). The Desktop shell
|
|
2347
|
+
// needs none of this — Electron starts audio without a gesture,
|
|
2348
|
+
// which is exactly why the problem is invisible on the Desktop.
|
|
2349
|
+
ctx.effect(() => {
|
|
2350
|
+
if (typeof document === "undefined") return () => {};
|
|
2351
|
+
const unlock = () => {
|
|
2352
|
+
const engine = audioEngine();
|
|
2353
|
+
if (engine?.state === "suspended") engine.resume().catch(() => {});
|
|
2354
|
+
};
|
|
2355
|
+
const events = ["pointerdown", "keydown", "touchstart"];
|
|
2356
|
+
for (const type of events) document.addEventListener(type, unlock, { capture: true, passive: true });
|
|
2357
|
+
return () => {
|
|
2358
|
+
for (const type of events) document.removeEventListener(type, unlock, { capture: true });
|
|
2359
|
+
};
|
|
2360
|
+
}, "notify-sounds: audio unlock");
|
|
2361
|
+
|
|
2186
2362
|
// Quiet update check: shortly after the page settles, then at most
|
|
2187
2363
|
// once a day, and never while the user has it turned off. It asks
|
|
2188
2364
|
// the Host's own route — one small query, nothing downloaded — and
|
package/lib/index.js
CHANGED
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
* OS window, but this process can: on Windows it calls `user32.dll`
|
|
16
16
|
* through the Desktop's own FFI binding and finds the window by title.
|
|
17
17
|
*
|
|
18
|
-
* `apply` also
|
|
19
|
-
* because the Client contributes a dedicated
|
|
18
|
+
* `apply` also asks the settings namespace to hold off any auto-generated
|
|
19
|
+
* inventory page for this entry, because the Client contributes a dedicated
|
|
20
|
+
* Settings section of its own. (Note: `autoGenerate` is not yet consumed by
|
|
21
|
+
* any shipped client — the flag is correct and future-proof, but today the
|
|
22
|
+
* effect is a no-op. Runtime README: "no shipped client does so yet".)
|
|
20
23
|
*
|
|
21
24
|
* @module dsh-plugin-notify-sounds
|
|
22
25
|
*/
|
|
@@ -42,6 +45,15 @@ export const TIME_OF_DAY = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
|
42
45
|
/** Deep link the packaged Desktop registers (`setAsDefaultProtocolClient`); the last-resort focus path. */
|
|
43
46
|
const FOCUS_DEEP_LINK = "dsh://open";
|
|
44
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The desktop app's bundle id, for raising it by identity on macOS.
|
|
50
|
+
*
|
|
51
|
+
* Taken from the packaged shell's own manifest (`dshDesktopAppId`), not
|
|
52
|
+
* guessed: `open -b <id>` is the only raise that needs no URL scheme to have
|
|
53
|
+
* been registered.
|
|
54
|
+
*/
|
|
55
|
+
const MACOS_BUNDLE_ID = "com.deepseek.dsh";
|
|
56
|
+
|
|
45
57
|
/** Route the browser half posts to when a system notification is clicked. Exported so the tests can cross-check the Client half's spelling. */
|
|
46
58
|
export const FOCUS_PATH = "/notify-sounds/focus";
|
|
47
59
|
|
|
@@ -330,16 +342,32 @@ function raiseDesktopWindowDeepLink() {
|
|
|
330
342
|
/**
|
|
331
343
|
* Start a short-lived launcher without blocking, reporting failures once.
|
|
332
344
|
*
|
|
345
|
+
* The exit code matters as much as the spawn: `open` exits non-zero when no
|
|
346
|
+
* application handles the request (macOS: "Unable to find application for
|
|
347
|
+
* URL"), and a launch that never happened must not look exactly like one
|
|
348
|
+
* that did.
|
|
349
|
+
*
|
|
333
350
|
* @param command - the executable to run.
|
|
334
351
|
* @param args - its arguments.
|
|
352
|
+
* @param done - called with whether the launcher exited cleanly; optional,
|
|
353
|
+
* so a caller can chain a fallback.
|
|
335
354
|
*/
|
|
336
|
-
function launcher(command, args) {
|
|
355
|
+
function launcher(command, args, done) {
|
|
337
356
|
try {
|
|
338
357
|
const child = spawn(command, args, { windowsHide: true, stdio: "ignore" });
|
|
339
|
-
child.on("error", (error) =>
|
|
358
|
+
child.on("error", (error) => {
|
|
359
|
+
warn(`could not launch ${command}: ${error?.message ?? error}`);
|
|
360
|
+
done?.(false);
|
|
361
|
+
});
|
|
362
|
+
child.on("exit", (code) => {
|
|
363
|
+
const ok = code === 0;
|
|
364
|
+
if (!ok) warn(`${command} exited ${code}: ${args.join(" ")}`);
|
|
365
|
+
done?.(ok);
|
|
366
|
+
});
|
|
340
367
|
child.unref();
|
|
341
368
|
} catch (error) {
|
|
342
369
|
warn(`could not launch ${command}: ${error?.message ?? error}`);
|
|
370
|
+
done?.(false);
|
|
343
371
|
}
|
|
344
372
|
}
|
|
345
373
|
|
|
@@ -414,9 +442,24 @@ function focusDesktopWindow() {
|
|
|
414
442
|
raiseDesktopWindowDeepLink();
|
|
415
443
|
}
|
|
416
444
|
} else if (process.platform === "darwin") {
|
|
417
|
-
|
|
418
|
-
|
|
445
|
+
// Activate the app by bundle id first: it needs no URL scheme to
|
|
446
|
+
// have been registered, which a packaged .app only gets if its
|
|
447
|
+
// Info.plist declares CFBundleURLTypes — something this project
|
|
448
|
+
// cannot verify from source. The deep link stays as the fallback,
|
|
449
|
+
// and a launch that failed is now named in the log instead of
|
|
450
|
+
// looking exactly like one that succeeded.
|
|
451
|
+
log("raising via open -b");
|
|
452
|
+
launcher("open", ["-b", MACOS_BUNDLE_ID], (ok) => {
|
|
453
|
+
if (ok) return;
|
|
454
|
+
log("bundle-id activation did not take; falling back to the deep link");
|
|
455
|
+
launcher("open", [FOCUS_DEEP_LINK]);
|
|
456
|
+
});
|
|
419
457
|
} else {
|
|
458
|
+
// No desktop shell ships for other platforms today — the shell
|
|
459
|
+
// itself refuses anything but win32/darwin, and a browser click
|
|
460
|
+
// never reaches this function. `xdg-open` is the correct opener
|
|
461
|
+
// should a Linux shell ever appear; until then this line is
|
|
462
|
+
// unreachable rather than misleading.
|
|
420
463
|
log("raising via xdg-open + deep link");
|
|
421
464
|
launcher("xdg-open", [FOCUS_DEEP_LINK]);
|
|
422
465
|
}
|
|
@@ -471,6 +514,12 @@ const UPDATE_MAX_RESPONSE_BYTES = 256 * 1024;
|
|
|
471
514
|
/** Result of the last check, when the last real request went out, and the last successful install. */
|
|
472
515
|
const updateCheck = { checkedAt: 0, requestedAt: 0, result: undefined, applied: undefined };
|
|
473
516
|
|
|
517
|
+
/** The check currently in flight, if any — callers join it instead of starting a second one. */
|
|
518
|
+
let checkInFlight;
|
|
519
|
+
|
|
520
|
+
/** The update currently in flight, if any — one download and one swap at a time. */
|
|
521
|
+
let applyInFlight;
|
|
522
|
+
|
|
474
523
|
/**
|
|
475
524
|
* Parse `major.minor.patch` with an optional prerelease tag.
|
|
476
525
|
*
|
|
@@ -599,27 +648,40 @@ export async function checkForUpdate(options = {}) {
|
|
|
599
648
|
if (now - updateCheck.requestedAt < UPDATE_CHECK_SPACING_MS && updateCheck.result !== undefined) {
|
|
600
649
|
return updateCheck.result;
|
|
601
650
|
}
|
|
651
|
+
// Two callers arriving together — the settings row and the notice above
|
|
652
|
+
// it, or an automatic check landing on a user's click — must not become
|
|
653
|
+
// two requests. The spacing rule above cannot cover that: it only sees a
|
|
654
|
+
// finished result, and while the first request is still in the air there
|
|
655
|
+
// is none. So a second caller joins the first instead.
|
|
656
|
+
if (checkInFlight !== undefined) return checkInFlight;
|
|
602
657
|
updateCheck.requestedAt = now;
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
658
|
+
checkInFlight = (async () => {
|
|
659
|
+
const current = readOwnVersion(options.root);
|
|
660
|
+
const answer = await fetchRegistryAnswer();
|
|
661
|
+
if (answer === undefined) {
|
|
662
|
+
const result = { ok: false, current, error: "no registry answered", applied: updateCheck.applied };
|
|
663
|
+
updateCheck.checkedAt = now;
|
|
664
|
+
updateCheck.result = result;
|
|
665
|
+
return result;
|
|
666
|
+
}
|
|
667
|
+
const ordering = current === undefined ? NaN : compareVersions(current, answer.version);
|
|
668
|
+
const result = {
|
|
669
|
+
ok: true,
|
|
670
|
+
current,
|
|
671
|
+
latest: answer.version,
|
|
672
|
+
updateAvailable: Number.isFinite(ordering) && ordering < 0,
|
|
673
|
+
checkedAt: now,
|
|
674
|
+
applied: updateCheck.applied,
|
|
675
|
+
};
|
|
607
676
|
updateCheck.checkedAt = now;
|
|
608
677
|
updateCheck.result = result;
|
|
609
678
|
return result;
|
|
679
|
+
})();
|
|
680
|
+
try {
|
|
681
|
+
return await checkInFlight;
|
|
682
|
+
} finally {
|
|
683
|
+
checkInFlight = undefined;
|
|
610
684
|
}
|
|
611
|
-
const ordering = current === undefined ? NaN : compareVersions(current, answer.version);
|
|
612
|
-
const result = {
|
|
613
|
-
ok: true,
|
|
614
|
-
current,
|
|
615
|
-
latest: answer.version,
|
|
616
|
-
updateAvailable: Number.isFinite(ordering) && ordering < 0,
|
|
617
|
-
checkedAt: now,
|
|
618
|
-
applied: updateCheck.applied,
|
|
619
|
-
};
|
|
620
|
-
updateCheck.checkedAt = now;
|
|
621
|
-
updateCheck.result = result;
|
|
622
|
-
return result;
|
|
623
685
|
}
|
|
624
686
|
|
|
625
687
|
/**
|
|
@@ -822,6 +884,7 @@ async function downloadTarball(url) {
|
|
|
822
884
|
export function installUpdate(root, files) {
|
|
823
885
|
const rootPrefix = resolve(root);
|
|
824
886
|
const backup = join(root, ".update-backup");
|
|
887
|
+
const tempName = (relative) => `${resolve(root, relative)}.update-tmp`;
|
|
825
888
|
rmSync(backup, { recursive: true, force: true });
|
|
826
889
|
const written = [];
|
|
827
890
|
try {
|
|
@@ -835,18 +898,29 @@ export function installUpdate(root, files) {
|
|
|
835
898
|
mkdirSync(dirname(aside), { recursive: true });
|
|
836
899
|
writeFileSync(aside, readFileSync(target));
|
|
837
900
|
}
|
|
838
|
-
const temp =
|
|
901
|
+
const temp = tempName(relative);
|
|
839
902
|
writeFileSync(temp, source);
|
|
840
903
|
renameSync(temp, target);
|
|
841
904
|
written.push(relative);
|
|
842
905
|
}
|
|
843
906
|
} catch (error) {
|
|
907
|
+
// Put everything back — including files the update ADDED, which have
|
|
908
|
+
// no backup to restore from and must simply go away again. A failed
|
|
909
|
+
// update has to leave nothing behind: no half-replaced file, no
|
|
910
|
+
// leftover temp, and no file that was not there before.
|
|
844
911
|
for (const relative of written) {
|
|
845
912
|
const aside = join(backup, relative);
|
|
846
|
-
|
|
913
|
+
const target = resolve(root, relative);
|
|
914
|
+
if (existsSync(aside)) writeFileSync(target, readFileSync(aside));
|
|
915
|
+
else rmSync(target, { force: true });
|
|
847
916
|
}
|
|
917
|
+
for (const relative of UPDATE_FILES) rmSync(tempName(relative), { force: true });
|
|
848
918
|
throw error;
|
|
849
919
|
}
|
|
920
|
+
// The swap succeeded, so the backup has done its job. Leaving it would
|
|
921
|
+
// park the previous version's files — a hundred-odd KB including the
|
|
922
|
+
// client half — inside the profile until some later update clears it.
|
|
923
|
+
rmSync(backup, { recursive: true, force: true });
|
|
850
924
|
}
|
|
851
925
|
|
|
852
926
|
/**
|
|
@@ -867,6 +941,21 @@ export function installUpdate(root, files) {
|
|
|
867
941
|
* upToDate: true, current }`, or `{ ok: false, stage, error }`.
|
|
868
942
|
*/
|
|
869
943
|
export async function applyUpdate(options = {}) {
|
|
944
|
+
// One download and one swap at a time. Two callers — a second tab, or a
|
|
945
|
+
// user clicking again because the first attempt looks stuck — would share
|
|
946
|
+
// the fixed temp names and the single backup directory, and the slower
|
|
947
|
+
// one's cleanup would pull the floor out from under the other.
|
|
948
|
+
if (applyInFlight !== undefined) return applyInFlight;
|
|
949
|
+
applyInFlight = runUpdate(options);
|
|
950
|
+
try {
|
|
951
|
+
return await applyInFlight;
|
|
952
|
+
} finally {
|
|
953
|
+
applyInFlight = undefined;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** `applyUpdate` without the single-flight guard. */
|
|
958
|
+
async function runUpdate(options = {}) {
|
|
870
959
|
const root = options.root ?? packageRoot();
|
|
871
960
|
const current = readOwnVersion(root);
|
|
872
961
|
let stage = "check";
|
|
@@ -952,9 +1041,12 @@ export const Config = z.object({
|
|
|
952
1041
|
* @param ctx - the Host plugin context.
|
|
953
1042
|
*/
|
|
954
1043
|
export function apply(ctx) {
|
|
955
|
-
// The Client half registers a dedicated `settings.section` page;
|
|
956
|
-
// plugin inventory
|
|
957
|
-
// namespace
|
|
1044
|
+
// The Client half registers a dedicated `settings.section` page; this asks
|
|
1045
|
+
// the plugin inventory to hold off an auto-generated form for the same
|
|
1046
|
+
// namespace so the preference is editable from exactly one place. Note:
|
|
1047
|
+
// `autoGenerate` is not yet consumed by any shipped client (runtime README
|
|
1048
|
+
// confirms), so this is a forward-compatible declaration rather than an
|
|
1049
|
+
// effect that is already in force.
|
|
958
1050
|
ctx.inject(["settings"], (child) => {
|
|
959
1051
|
child.effect(() => child.settings.configure({ auto: false }, ctx.fiber));
|
|
960
1052
|
});
|
|
@@ -982,16 +1074,23 @@ export function apply(ctx) {
|
|
|
982
1074
|
return;
|
|
983
1075
|
}
|
|
984
1076
|
// Which client asked? The Desktop's renderer fetches from its
|
|
985
|
-
// own `dsh-app://app/` origin, and the shell STRIPS Origin
|
|
986
|
-
// before forwarding to this server
|
|
987
|
-
//
|
|
1077
|
+
// own `dsh-app://app/` origin, and the shell STRIPS Origin and
|
|
1078
|
+
// Sec-Fetch-Site before forwarding to this server (the shell's
|
|
1079
|
+
// `forwardWebRequest` in `lib/main.js`), so a Desktop click
|
|
1080
|
+
// arrives with neither header while a real browser always sends
|
|
1081
|
+
// at least one. Checking BOTH keeps this working if the shell
|
|
1082
|
+
// ever stops stripping only one of them: the day it strips
|
|
1083
|
+
// neither, a Desktop click would silently start looking like a
|
|
1084
|
+
// browser one and clicks would stop raising the window — with no
|
|
1085
|
+
// other symptom to explain it.
|
|
988
1086
|
//
|
|
989
1087
|
// The two need opposite things, so the choice is derived here
|
|
990
1088
|
// rather than configured: a browser click needs no help, since
|
|
991
1089
|
// activating the notification raises the browser window itself
|
|
992
1090
|
// and raising the Desktop window instead would be the wrong
|
|
993
1091
|
// window entirely.
|
|
994
|
-
const
|
|
1092
|
+
const headers = req.headers ?? {};
|
|
1093
|
+
const fromBrowser = headers.origin !== undefined || headers["sec-fetch-site"] !== undefined;
|
|
995
1094
|
res.statusCode = 204;
|
|
996
1095
|
res.end();
|
|
997
1096
|
log(`focus click (fromBrowser=${fromBrowser})`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-notify-sounds",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.9",
|
|
4
4
|
"description": "Notification sounds for DeepSeek Harness — a soft chime when the agent needs your decision or finishes a task, with a Settings section styled like DSH's own · 需要决策或任务完成时播放提示音,点击系统通知可回到窗口,设置面板可调音量与音效",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|