@game25011/dsh-notify-toast 0.0.0-stage → 0.4.2

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/lib/client.js ADDED
@@ -0,0 +1,3486 @@
1
+ /**
2
+ * dsh-notify-toast — Client half (browser bundle).
3
+ *
4
+ * Wraps a single self-contained CJS factory for the DSH module loader. Only
5
+ * `require('react')` is resolved from the host module table; everything else
6
+ * (locale dictionaries, native toast handling, Web Audio synthesis, settings
7
+ * page, multi-window leader election and the Connection RPC long-poll) lives
8
+ * inside this file.
9
+ *
10
+ * Contract: docs/INTERFACE.md §3. The Host half (lib/index.js) serves the
11
+ * `/dsh-notify-toast` Connection RPC channel; this side only ever calls
12
+ * `ctx.connection.rpc.call('/dsh-notify-toast', endpoint, payload, signal)`
13
+ * (relative URL through the page's own origin/proxy — never absolute URLs,
14
+ * ports, tokens or cookies).
15
+ *
16
+ * The bundle is defensive on purpose: a throw inside a client plugin's factory
17
+ * sits on the host's boot path, so every host-facing registration is contained
18
+ * and every event/notification/audio failure degrades silently.
19
+ */
20
+ ;(function loadClientBundle() {
21
+ const loader = typeof window !== 'undefined' && window ? window.__ModuleLoader__ : undefined
22
+ if (!loader || typeof loader.load !== 'function') return
23
+ try {
24
+ loader.load({
25
+ id: 'dsh-notify-toast',
26
+ factory(require) {
27
+ const React = require('react')
28
+ const h = React.createElement
29
+ const useState = React.useState
30
+ const useEffect = React.useEffect
31
+
32
+ // ------------------------------------------------------------------
33
+ // Constants (kept in sync with docs/INTERFACE.md §0/§2.4)
34
+ // ------------------------------------------------------------------
35
+
36
+ const PKG_ID = 'dsh-notify-toast'
37
+ /** Locale namespace / settings namespace / client module id. */
38
+ const NS = 'dsh-notify-toast'
39
+ const RPC_CHANNEL = '/dsh-notify-toast'
40
+ const VERSION = '0.4.2'
41
+
42
+ /** Multi-window leadership lease (localStorage, cross-tab visible). */
43
+ const LEADER_STORAGE_KEY = 'dsh-notify-toast:leader'
44
+ const LEADER_HEARTBEAT_MS = 5000
45
+ const LEADER_STALE_MS = 15000
46
+
47
+ /** Long-poll hold time; the Host clamps payload.wait to 1000–25000. */
48
+ const POLL_WAIT_MS = 25000
49
+ const BACKOFF_MIN_MS = 1000
50
+ const BACKOFF_MAX_MS = 15000
51
+ /**
52
+ * Banner auto-hide is owned by Windows (~5 s) and the clickable entry
53
+ * lives on in the Action Center. Closing the toast programmatically
54
+ * would remove that entry and lose later clicks, so never auto-close.
55
+ */
56
+ const TOAST_AUTO_CLOSE_MS = 0
57
+ const SAVE_DEBOUNCE_MS = 250
58
+ /** Queued events older than this are treated as stale on page load. */
59
+ const EVENT_MAX_AGE_MS = 120000
60
+
61
+ const DEFAULT_CONFIG = Object.freeze({
62
+ enabled: true,
63
+ questionEnabled: true,
64
+ approvalEnabled: true,
65
+ completionEnabled: true,
66
+ taskbarBadgeEnabled: true,
67
+ titleOnly: false,
68
+ bodyLength: 40,
69
+ sound: 'ding',
70
+ volume: 60,
71
+ mergeIntervalMs: 2500,
72
+ allDoneTitleEnabled: false,
73
+ allDoneTitle: '所有任务均已完成',
74
+ goalSummaryEnabled: true,
75
+ notificationMode: 'native-progress',
76
+ nativeDurationMs: 8000,
77
+ })
78
+
79
+ const BOOLEAN_CONFIG_KEYS = Object.freeze([
80
+ 'enabled',
81
+ 'questionEnabled',
82
+ 'approvalEnabled',
83
+ 'completionEnabled',
84
+ 'taskbarBadgeEnabled',
85
+ 'goalSummaryEnabled',
86
+ 'titleOnly',
87
+ 'allDoneTitleEnabled',
88
+ ])
89
+ const SOUND_IDS = Object.freeze(['ding', 'chime', 'bell', 'soft', 'bubble', 'none'])
90
+ const NOTIFICATION_MODES = Object.freeze(['native', 'native-progress'])
91
+ const NATIVE_DURATION_MIN_MS = 3000
92
+ const NATIVE_DURATION_MAX_MS = 30000
93
+ const ADVANCED_CONFIG_KEYS = Object.freeze(['taskbarBadgeEnabled', 'notificationMode', 'nativeDurationMs'])
94
+ const SOUND_LABEL_KEYS = Object.freeze({
95
+ ding: 'soundDing',
96
+ chime: 'soundChime',
97
+ bell: 'soundBell',
98
+ soft: 'soundSoft',
99
+ bubble: 'soundBubble',
100
+ none: 'soundNone',
101
+ })
102
+
103
+ /**
104
+ * Web Audio presets. Every note schedules sine partials with a short
105
+ * linear attack and an exponential tail; `ding` is the default because
106
+ * it is short, warm and clearly audible on laptop speakers.
107
+ */
108
+ const SOUND_PRESETS = Object.freeze({
109
+ // 叮咚 — descending B5 → F#5 bell dyad, warm partials, ~0.9 s.
110
+ ding: {
111
+ notes: [
112
+ { frequency: 987.77, at: 0, duration: 0.7, peak: 0.62, partials: [[1, 1], [2, 0.28], [3, 0.08]] },
113
+ { frequency: 739.99, at: 0.14, duration: 0.85, peak: 0.52, partials: [[1, 1], [2, 0.32], [3, 0.1]] },
114
+ ],
115
+ },
116
+ // 风铃 — E6 + B6, bell-like upper partials, ~1 s, soft attack/decay.
117
+ chime: {
118
+ notes: [
119
+ { frequency: 1318.51, at: 0, duration: 0.95, peak: 0.55, partials: [[1, 1], [2, 0.34], [2.76, 0.14], [4, 0.06]] },
120
+ { frequency: 1975.53, at: 0.07, duration: 0.9, peak: 0.4, partials: [[1, 1], [2, 0.28], [3, 0.1]] },
121
+ ],
122
+ },
123
+ // 清铃 — C6 + G6 with octave partials.
124
+ bell: {
125
+ notes: [
126
+ { frequency: 1046.5, at: 0, duration: 1.05, peak: 0.5, partials: [[1, 1], [2, 0.42], [3, 0.16], [4, 0.07]] },
127
+ { frequency: 1567.98, at: 0.035, duration: 0.98, peak: 0.36, partials: [[1, 1], [2, 0.3], [3, 0.12]] },
128
+ ],
129
+ },
130
+ // 轻点 — single short 523 Hz blip, ~0.25 s.
131
+ soft: {
132
+ notes: [
133
+ { frequency: 523.25, at: 0, duration: 0.24, peak: 0.6, partials: [[1, 1], [2, 0.12]] },
134
+ ],
135
+ },
136
+ // 弹一下 — synthesized fallback until the CC0 bubble OGG decodes.
137
+ bubble: {
138
+ notes: [
139
+ { frequency: 740, at: 0, duration: 0.12, peak: 0.58, partials: [[1, 1], [2, 0.18]] },
140
+ { frequency: 1046.5, at: 0.045, duration: 0.16, peak: 0.32, partials: [[1, 1], [2, 0.2]] },
141
+ ],
142
+ },
143
+ })
144
+
145
+ // CC0 audio asset: uisfx 0.4.0 sounds/rubber/notification.ogg.
146
+ // The source .ogg lives in assets/sounds/ for provenance; the browser
147
+ // bundle embeds it so playback needs no asset route or network request.
148
+ const SOUND_ASSETS = Object.freeze({
149
+ bubble: 'T2dnUwACAAAAAAAAAAAAAAAAAAAAAAIotXIBE09wdXNIZWFkAQE4AYC7AAAAAABPZ2dTAAAAAAAAAAAAAAAAAAABAAAASZW+VAEuT3B1c1RhZ3MGAAAAZmZtcGVnAQAAABQAAABlbmNvZGVyPUxhdmMgbGlib3B1c09nZ1MABIldAAAAAAAAAAAAAAIAAAD/TX+uGcZylY2ghZqQ6J+TlaCmpJ6hoZaAbZIDAwP4tLKUmvYOAVw487W/WTpOx6n6F+lfMvmfuDUFoextKv4B5yTWZq5byB0CvEWNbS6iPDlz8nF5OZwJwhkm5Mjpsupr09AXo6B/Ati1wqGy8rYzGVOceNQzyZqZcU6cAGbuSG2p3PZ4oGNM07fw7bb8EfG+ZxgfYD4F/PmwURGaPzIPYtx20ERfFUaIIBqn2mGgH5XiI6RBpmNcHLtJ5jJGkGIXKLVoEtCxXW9q+nqJSHMlBljESYJxTPoMBJTBvTEScWcHfWv4r+o61ezK7MMwZcX49rXi2rgeDdHcxM2IJksPLy0Xt48b6n0u1fNHx0sAEIlEe6UEmCThkjbscSJKO6EVzGuIQaKVAMs84lkgX8JTP2r6lNwiJzZxuwgydG5419PwpjQTb53bx6vbGhK/Axs7sV2PR234rWjr/KkH9NyqxLJQ0PgjC8wTr5nJpuL6Y/Duh4NjZzqx//WODBkzE/OVOD1Whuhm/kKmyxoBg9RYKvKw9oGpGVVIHSk3tdpOc6tzySs2o95I7zHL8TW18i4zfSQLMK+blS3L6kqOpJD33yIrJoPUrBuw0Zm3lkuoH009rMFJLys124wvWwBtamNpNpFmIuitO9IfbviwTbKVw1lqWYXNCuHnCiTfsp78BL5/KwFCug4Ub24Le9YlN6kcuTdFaLBUZUafYcYCdkQLeAog9fNwm7jlBjswuqgNZEG08tZEA8NU1fu0yPABn6gPq4Aog1zM51jAExAmPHt4FdemmBxMiCWjUw/wW9wHuxGn29dU8d+iYAMGegLn/puQWH5HzqcbbfixoTnccOLkCYvqU9/37BIOK+NWPQz1X4qstsqaHXovqdNPVAn37YhIz3Emu5eJjGFq6Saq5LfFC7vY2xXCum5ItROdnoOEcTKSQyYW5CFtbg57G4QlfPV+wH2yl132HzeHTBNz06/P88baV2h2ntcfulzOOVUjV+jfGCIWD+952b2jw693u7oady30kL9Dk0RdCn86npm7IY/tjT96/Wz4t/K218uOVzuIdNLGXY3uud3c4rTt3gafpyj+heJtSVrSKZbea8AxMBFFgjpPp34HVMxrWq9PeQi/IGKPVpDVOjZbtO7M9edBiyfkOwLxQ+jNejcYFKRB/1EnhtismmOKk5fu3Lb0FA2knQJ2pGC1/fqWPviY7bHRZ/22km2nImoY/MNt+L1ACqbzRm5lVTFFU6dQ1Mt4OBcfWFJeR2SUT3wdSH5IjqC9GLJ7TuveVJpfqPAGHt/bS09Gkw85tyaRFxCNHlT0WPBDsWJlU1pF/XWtm07lA+WNhu00/R0QdTUoP/3UlBAz+0EjgxPe8K0H9VNFO1uCZzaig4/wf5tH/QXdGnejpdM3jIwUbpBK5vVzW/bYzcD9M4nx8+oFbfi+I1zkCSFDfjuTs/CVP9bk7qjuCg1PjMs7umM/QMFUhJeLZebn+PN5ih1Q9p2UsVQiFatPrd97GCVkQTvIoOOZGjBcmRSV617Gmg5RVLk8+wGILHZjb063AgoaitYOCYRu8AC6NVNs9SJuM+kttekm3rEofInqTWK5EPOt+ct77XCZXtNe65TST+REmIS9bfh+hFCCkgvUCYPtdJGeiDlC6wP5xPJDEI0FTAPP+xCe5hnVAbgQgsJwENsNlAhy1ImhqcEOkls7zy8Kj4TUYR1ZumiGacKCLM4LhtdXPQxzsSg/GJXQzAqb0VVau0PEC4/bu3jauNbBD1tOXsoAZ/OXh3z/mshuF5sntvWPbWpv2TivKxiTLaQfb5G1IH9Gw9LvTeF+0mIHYYjWDxZYM/HP11G5H2oKrjnBctuFW0VYlbgZe01dznQmuuqAKBvylgqcEaMM7vWorEyCtfOU/aH1+XU3nIeRU5WR4LNM3kP+tLuBCtwCjzz4vXsIg+ywe7e/oq6+ICwr0jHJtzr6g85n96nPW/UPptpcY8WMlhBoFfZirU3f536OPWuHmTBIuPLECC6r16qvccApxzMqd5GGgZ68DcSXbNTeD1PeXjY6voq5XU3JIhLttxYOZ5nfz2luyOyeCAkHl8zwjgjAxSF1F8Ciwi3U1i3E0SyaOTLqYoVIDtZM7vb4GVcaEfaXlByt7lqUyVD4vUmGB7G1Zbqi8w7k9p2zH3+Sc0icQNHeFa8Xn4padzhMC4xVmjrDY7J7ZRB+UWlmLLdFDwDAmoN9gblDWXMGL/i6iDbsCtfKavLYcwC9bWyrk8bQxCq3UvYGLXfqQ8KlObRfs827HloBJDxr+chsgpWVvSbACrIGHID4KbDebdEG+xiRyef40ykE4zmuIpNjxVH4vXr15t4YgVnONLSWQqm09qk/MmP8p08cxxH8O7N7WY+h1h0hltmYXOtHkedcawbsK/jmuPLR8B4sDjsdRYj5NTuSVr3lut1RcZgQ2TU+Hpafom9PZlvqEGcq/NpId3OCqZdDNNsD6z6jSDs3FxgJwnzQboMzCJ+bz14+sBdx1KK/QJfOcBUstgHrIASSps1F+LpXUfi+JYHa7W7FecCqm0eDxR0gbrtZAg+EvdSE2I4Y5+khSMWmF13zBNGPxigM9n5PPOWlbrU5Lckrv2yRS9UKbh4G52XcUG3JY73sJKJJs/RLYWey0pxFWr7N5N8ahIbP2D0LFtM/d9e5vPvQHHnY55r2cLCNG6aVyq9e/EEJF0m5CMU1uwFCAGVGGrD6B4kxbt+8PuoGRrWcEF6QMMNEH1D4vhmdF4dTd+IvxSOMicpMhE2+kmQmYC0J7HXZ4edmEHsParI8okGxvAoOORu1h5hTh9LCZBGNfLb0htUW74869yhbhTX1PmByzAz7HIxKS6qHOL1KyGYMbYEWu7idSMKSR+F1p1wIpcUUXHgxU4OevvlCrK8UWCV9iqp/o0kSy3UkSo/zpEbRsCsFP2XyXeaM7P7SffMFqVTekrW268Si5/87Q0NR+L4rbajykwKCTK7aFhVbxS4DH1SuJof4XFoM+pLmKefFj7lk3F3WSscMcIHFYZqwN5mL3W1pJqGNgmNlOoBMn2ThNZ/f/szNq8zMJWjWBG+N8GXBEj2vEbiSLV6v8wOYYkgr4bYZwaOieSfia5hZkZpSaNa/3OLVVcyMliNme/52C1ETP4gRLvBLOG6adVA9NVaOwNHmo7tm1G4SSf8o1QdA/1H4viUhlPrx7QAsq9u95qxKN1bi3yWAElaNP+oTGcHwghjFWEKH2DngHYCb5j7QLR9WhCH6ELWy2Gg7ktPpN+lHfr7QhTFn18RRI/zn9iEfrubNGiyOQ3E/nlQrjJB2TIvxCHcn7GKLQwauKyEFNtE4JyUQyAGI2VFdIK8em+e6w9WYXxguh2/Kf+6YpM4crjeUXp2DWXa5WksodGDpUfi+DObYmjT1fVtHdlPHJHgUTV5LhGAS+vQ2Kn4ZrIS3lkIQgrlimTYbkRWwnmm3ynvjkNs+BoVaJRUxBSlzdxKmWDCvwPVVj4O7CWRaMcDuho8s9xAGCtNKtYVpWzvCpX9wY7LELK9STj/EU/p7a0Wm4utUxTB5jDSwA35DG5ElVeKs2fRe4vHam0YDNcnDsJSOHEowNgtkpNN/r9NeGc1R+L5qHP6H1RHEAStSkgeoondCYIFMj6cEa1pWbgrfItIb2yievL0DIUXdV2qMKX6HLNOgquRAUXLm0Frg2l4i+D1XajSXk+O/Q791QZWaq0bhlEF6kERFBUBlk8ggx+7HgBQqzs43514klaHC4l1pvbQUbMty2nLCVeBrXdrYq2Vicbi6bhZX1xDzENPMPjGBNCPEtKK1nViAolCixyd6J1H4vpjx2BxcVV5uFcBiQXojtEV0P5Ru3WHloBjlIiMg8MYJOlTwsXJtZsLO2yPG0N++nTGxj58b7cq/BLvYYwgqCzCjfbzY26aJLrHM9WYR48zU5HXgl5nZrGAmjnDxEvtAVF/akL/XVvM5rKGE4jN4J6ZaMrRREBnPjF1vQcqt2iGUR1eMbl9dmPNjkrS1t+4xLJmn4VH4vrkm1kOdckdLlYYICI9UT2EJqMSzCUoeiSlKstyrq8mGqsUXeXvVRCJGKXPdHTmXX1WDDs6DV1Qhirf824CgUdOfoTEXqyMBNGyZF7zACSEy4KmLOP1u5FyljXQ3OvuZ4fwCFv1iQm9RCA/hQylLpynzF4n+rs0zn5+sDhao0fhhi5xSiQb50OsKPr+k/TczP/4tdrNXlbNXvz8/Mj8cKtmr+0GO1cIRXkOc48cqIXiy/y+dpCcQ9CifPeKdMwM6rjuvSHqxlVsyE7eT5NrwdD3ncZyISr8P5mPlphoa0DF2yV8tazdzjkospTH4fe1lhdwCq+9sN+k1i2YkxNMnqKAOhDXLMJU4nFEy73a+h42OE1hs7GIKv/wIC/CJpaD/DtEF2GlVRyMHhm7Afy3gpkXkAgAf2ObMSA/sJFCFi5U+tj6CFwR0LNdQbtiq1awZbEnivNAu74BBz93gzwl1/OAAeOevrksAQkAEdoGggB7j4wQABpoDP6uW4Z8Fjvj//vj//vj//g==',
150
+ })
151
+
152
+ // ------------------------------------------------------------------
153
+ // Locale dictionaries (zh default, en fallback). Same key set in both.
154
+ // ------------------------------------------------------------------
155
+
156
+ const zh = {
157
+ nav: '通知',
158
+ pageTitle: '通知',
159
+ pageIntro: '窗口在后台时,用 Windows 原生通知提醒提问、授权和根会话完成;窗口聚焦时完全不打扰。',
160
+ loading: '正在加载设置…',
161
+ hostDegraded: 'Host 暂时未连接,设置会先保存在本页,连接恢复后自动写入。',
162
+ enable: '启用通知',
163
+ enableHint: '关闭后不再显示任何通知(测试通知除外)。',
164
+ questionEnabled: '提问与方案确认',
165
+ questionEnabledHint: '需要回答或确认方案时提醒。',
166
+ approvalEnabled: '授权(审批)',
167
+ approvalEnabledHint: '需要批准工具调用时提醒。',
168
+ completionEnabled: '本轮回复结束',
169
+ completionEnabledHint: '每轮根会话回复结束时提醒;这不代表整个任务已经完成,子代理任务不提醒。',
170
+ taskbarBadge: '任务栏未读角标',
171
+ taskbarBadgeHint: 'DSH 在后台时,在任务栏图标上显示有完成结果的根会话数量;同一个会话完成多次只算 1 个。1–3 显示深灰数字角标和蓝色细环,超过 3 个显示红点;回到 DSH 后自动清除。',
172
+ goalSummaryEnabled: '目标模式结束后汇总',
173
+ goalSummaryEnabledHint: '目标模式自动推进期间不逐轮提醒;目标结束、暂停或受阻后汇总为一条。',
174
+ bodyLength: '正文长度',
175
+ bodyLengthHint: '通知正文最多显示的字符数。',
176
+ characters: '字符',
177
+ titleOnly: '隐私模式(只显示标题)',
178
+ titleOnlyHint: '通知正文留空,只显示标题。',
179
+ nativeToastSetting: '同时发送 Windows 原生通知',
180
+ sound: '提示音',
181
+ soundDing: '叮咚',
182
+ soundChime: '风铃',
183
+ soundBell: '清铃',
184
+ soundSoft: '轻点',
185
+ soundBubble: '弹一下(开源音效)',
186
+ soundNone: '关闭',
187
+ previewSound: '试听',
188
+ volume: '音量',
189
+ volumeHint: '提示音音量 0–100。听不到时先点「试听」;仍无声请检查 Windows 音量合成器里本应用的音量。',
190
+ percent: '%',
191
+ mergeInterval: '合并间隔',
192
+ mergeIntervalHint: '该时间窗口内的完成通知会合并成一条。',
193
+ milliseconds: '毫秒',
194
+ allDoneTitleEnabled: '全部完成后改写标题',
195
+ allDoneTitleEnabledHint: '多个会话同时运行时,等全部会话都成功结束,把最后结束的会话标题改成下面的文案;只有一个会话运行时不生效。',
196
+ allDoneTitle: '标题文案',
197
+ allDoneTitleHint: '默认「所有任务均已完成」,最多 96 字节。',
198
+ testButton: '发送测试通知',
199
+ testing: '发送中…',
200
+ testSent: '测试通知已发送。',
201
+ testFailed: '测试通知发送失败。',
202
+ testLocalFallback: 'Host 未响应,已在本窗口直接显示测试通知。',
203
+ saving: '保存中…',
204
+ saved: '已保存',
205
+ saveFailed: '保存失败,连接恢复后会重试。',
206
+ statusTitle: '状态',
207
+ statusPermission: '通知权限',
208
+ statusFocus: '窗口焦点',
209
+ statusHost: 'Host 连接',
210
+ statusTransport: '通信链路',
211
+ statusNativeFocus: '窗口置前',
212
+ statusSettings: '设置持久化',
213
+ statusAudio: '提示音引擎',
214
+ statusNativeToast: '系统通知',
215
+ nativeToastCounts: '尝试 {attempts} · 创建 {created} · 显示 {shown} · 失败 {failed}',
216
+ nativeClicksCounts: '点击 {clicks} · 置前成功 {focusOk} · 置前失败 {focusFailed}',
217
+ nativeToastLastError: '最近错误',
218
+ nativeToastHint: '事件提醒只走 Windows 系统通知(右下角横幅,收起后保留在通知中心,点击回到对应会话)。「创建」有数但看不到横幅时,检查 Windows 设置 → 系统 → 通知 的横幅开关与专注助手。',
219
+ systemTestButton: '测试系统通知',
220
+ version: '版本',
221
+ valueGranted: '已允许',
222
+ valueDefault: '待授权',
223
+ valueDenied: '已拒绝',
224
+ valueUnsupported: '不支持',
225
+ valueFocused: '已聚焦(不打扰)',
226
+ valueUnfocused: '未聚焦(会提醒)',
227
+ valueConnected: '已连接',
228
+ valueDisconnected: '未连接',
229
+ valueTransportChannel: '官方 channel',
230
+ valueTransportRoute: '同源路由',
231
+ valueTransportNone: '未挂载',
232
+ valueAvailable: '可用',
233
+ valueUnavailable: '不可用',
234
+ valueAudioReady: '已就绪',
235
+ valueAudioSuspended: '待解锁(点一下页面)',
236
+ valueAudioUninitialized: '未初始化',
237
+ valueUnknown: '未知',
238
+ runtimeTitle: '运行状态',
239
+ statsQuestions: '提问',
240
+ statsApprovals: '授权',
241
+ statsCompletions: '完成',
242
+ statsMerged: '合并',
243
+ statsClients: '在线页面',
244
+ statsLast: '最近',
245
+ kindQuestion: '提问',
246
+ kindApproval: '授权',
247
+ kindComplete: '完成',
248
+ kindTest: '测试',
249
+ permissionAskHint: '尚未授予通知权限,点击页面任意处或下方按钮请求授权。',
250
+ permissionDeniedHint: '通知权限已被拒绝,请在系统或浏览器设置中允许通知。',
251
+ requestPermission: '请求通知权限',
252
+ notifyQuestionTitle: 'DeepSeek 想问你一个问题',
253
+ notifyApprovalTitle: 'DeepSeek 需要你的授权',
254
+ notifyCompleteTitle: 'DeepSeek 搞定了一小步',
255
+ notifyGoalPausedTitle: 'DeepSeek 推进了 {count} 步,目标暂停',
256
+ notifyGoalBlockedTitle: 'DeepSeek 推进了 {count} 步,目标受阻',
257
+ notifyCompleteMergedTitle: 'DeepSeek 一口气推进了 {count} 步',
258
+ notifyCompleteManyTitle: 'DeepSeek 火力全开,连推了 {count} 步',
259
+ notifyAllDoneTitle: '所有任务均已完成',
260
+ notificationMode: '通知显示方式',
261
+ notificationModeHint: '原生进度通知使用 Windows 系统通知;不可用时自动回退到兼容通知',
262
+ notificationModeNative: 'Windows 通知(兼容模式)',
263
+ notificationModeNativeProgress: 'Windows 通知(带进度条)',
264
+ nativeProgressCaption: '自动关闭',
265
+ nativeDuration: '通知自动移除时间(近似)',
266
+ nativeDurationHint: '3–30 秒;用于停止进度更新和通知中心保留,实际退场由 Windows 控制,可能提前或延后',
267
+ notificationDurationNotice: '进度条由插件更新;横幅实际停留时间仍受 Windows 控制',
268
+ restartForNewSettings: '当前运行的是旧版通知 Host;重启 DSH 后可使用新增设置',
269
+ seconds: '秒',
270
+ notifyTestTitle: 'DSH 通知测试',
271
+ notifyTestBody: '这是一条测试通知,点击可回到 DSH。',
272
+ fallbackBody: '点击查看',
273
+ fallbackApprovalBody: '需要批准工具调用',
274
+ }
275
+
276
+ const en = {
277
+ nav: 'Notifications',
278
+ pageTitle: 'Notifications',
279
+ pageIntro: 'Native Windows toasts for questions, approvals and root-session completion while the window is in the background. Nothing is shown or played while it is focused.',
280
+ loading: 'Loading settings…',
281
+ hostDegraded: 'The Host is unreachable; changes stay on this page and are saved once the connection recovers.',
282
+ enable: 'Enable notifications',
283
+ enableHint: 'When off, no notifications are shown except tests.',
284
+ questionEnabled: 'Questions & plan confirmation',
285
+ questionEnabledHint: 'Notify when an answer or a plan confirmation is needed.',
286
+ approvalEnabled: 'Approvals',
287
+ approvalEnabledHint: 'Notify when a tool call needs approval.',
288
+ completionEnabled: 'Reply finished',
289
+ completionEnabledHint: 'Notifies when a root-session reply finishes; this is not a claim that the whole task is done. Subagent tasks are ignored.',
290
+ taskbarBadge: 'Taskbar unread badge',
291
+ taskbarBadgeHint: 'While DSH is in the background, show the number of root sessions with finished work on the taskbar icon. Multiple completions in one session count once. Show 1–3 as a graphite numeral with a thin blue rim, and a red dot above 3; the badge clears when DSH is focused.',
292
+ goalSummaryEnabled: 'Summarize when a goal run ends',
293
+ goalSummaryEnabledHint: 'Do not notify after every automatic goal round; send one summary when the goal completes, pauses, or is blocked.',
294
+ bodyLength: 'Body length',
295
+ bodyLengthHint: 'Maximum number of characters shown in the notification body.',
296
+ characters: 'chars',
297
+ titleOnly: 'Privacy mode (title only)',
298
+ titleOnlyHint: 'Leave the notification body empty and show the title only.',
299
+ nativeToastSetting: 'Also send a Windows system toast',
300
+ sound: 'Sound',
301
+ soundDing: 'Ding-dong',
302
+ soundChime: 'Chime',
303
+ soundBell: 'Bell',
304
+ soundSoft: 'Soft',
305
+ soundBubble: 'Bubble (open-source)',
306
+ soundNone: 'Off',
307
+ previewSound: 'Preview',
308
+ volume: 'Volume',
309
+ volumeHint: 'Sound volume from 0 to 100. If you hear nothing, press Preview first; if it stays silent, check this app in the Windows volume mixer.',
310
+ percent: '%',
311
+ mergeInterval: 'Merge interval',
312
+ mergeIntervalHint: 'Completion notifications within this window are merged into one.',
313
+ milliseconds: 'ms',
314
+ allDoneTitleEnabled: 'Rename the last session when all finish',
315
+ allDoneTitleEnabledHint: 'When several sessions run together, wait until all of them end successfully, then rename the session that ended last to the text below. A single running session never triggers it.',
316
+ allDoneTitle: 'Title text',
317
+ allDoneTitleHint: 'Defaults to “所有任务均已完成”; at most 96 bytes.',
318
+ testButton: 'Send test notification',
319
+ testing: 'Sending…',
320
+ testSent: 'Test notification sent.',
321
+ testFailed: 'Could not send the test notification.',
322
+ testLocalFallback: 'The Host did not respond; the test notification was shown locally.',
323
+ saving: 'Saving…',
324
+ saved: 'Saved',
325
+ saveFailed: 'Save failed; it will be retried when the connection recovers.',
326
+ statusTitle: 'Status',
327
+ statusPermission: 'Notification permission',
328
+ statusFocus: 'Window focus',
329
+ statusHost: 'Host connection',
330
+ statusTransport: 'Transport',
331
+ statusNativeFocus: 'Window activation',
332
+ statusSettings: 'Settings persistence',
333
+ statusAudio: 'Sound engine',
334
+ statusNativeToast: 'System toast',
335
+ nativeToastCounts: 'attempts {attempts} · created {created} · shown {shown} · failed {failed}',
336
+ nativeClicksCounts: 'clicks {clicks} · focused {focusOk} · failed {focusFailed}',
337
+ nativeToastLastError: 'Last error',
338
+ nativeToastHint: 'Events are shown only as Windows system toasts (bottom-right banner, kept in the Action Center, click to open the session). If "created" grows but no banner appears, check Windows Settings → System → Notifications banners and Focus Assist.',
339
+ systemTestButton: 'Test system toast',
340
+ version: 'Version',
341
+ valueGranted: 'Granted',
342
+ valueDefault: 'Not requested',
343
+ valueDenied: 'Denied',
344
+ valueUnsupported: 'Unsupported',
345
+ valueFocused: 'Focused (silent)',
346
+ valueUnfocused: 'Unfocused (notify)',
347
+ valueConnected: 'Connected',
348
+ valueDisconnected: 'Disconnected',
349
+ valueTransportChannel: 'Official channel',
350
+ valueTransportRoute: 'Same-origin route',
351
+ valueTransportNone: 'Not mounted',
352
+ valueAvailable: 'Available',
353
+ valueUnavailable: 'Unavailable',
354
+ valueAudioReady: 'Ready',
355
+ valueAudioSuspended: 'Locked (click the page once)',
356
+ valueAudioUninitialized: 'Not started',
357
+ valueUnknown: 'Unknown',
358
+ runtimeTitle: 'Runtime',
359
+ statsQuestions: 'Questions',
360
+ statsApprovals: 'Approvals',
361
+ statsCompletions: 'Completions',
362
+ statsMerged: 'merged',
363
+ statsClients: 'Online pages',
364
+ statsLast: 'Last',
365
+ kindQuestion: 'Question',
366
+ kindApproval: 'Approval',
367
+ kindComplete: 'Completion',
368
+ kindTest: 'Test',
369
+ permissionAskHint: 'Notification permission has not been granted yet. Click anywhere on the page or the button below to request it.',
370
+ permissionDeniedHint: 'Notification permission was denied. Allow notifications in the system or browser settings.',
371
+ requestPermission: 'Request notification permission',
372
+ notifyQuestionTitle: 'DeepSeek has a question for you',
373
+ notifyApprovalTitle: 'DeepSeek needs your approval',
374
+ notifyCompleteTitle: 'DeepSeek finished a step',
375
+ notifyGoalPausedTitle: 'DeepSeek advanced {count} steps; goal paused',
376
+ notifyGoalBlockedTitle: 'DeepSeek advanced {count} steps; goal blocked',
377
+ notifyCompleteMergedTitle: 'DeepSeek pushed {count} steps in one go',
378
+ notifyCompleteManyTitle: 'DeepSeek went all out and pushed {count} steps',
379
+ notifyAllDoneTitle: 'All tasks are done',
380
+ notificationMode: 'Notification display',
381
+ notificationModeHint: 'Native progress notifications use Windows notifications; falls back to compatibility mode when unavailable',
382
+ notificationModeNative: 'Windows notification (compatibility mode)',
383
+ notificationModeNativeProgress: 'Windows notification (progress bar)',
384
+ nativeProgressCaption: 'Auto close',
385
+ nativeDuration: 'Notification auto-remove delay (approx.)',
386
+ nativeDurationHint: '3–30 seconds; stops progress updates and bounds notification-center retention. Windows controls the actual exit animation, so it may be earlier or later.',
387
+ notificationDurationNotice: 'The progress bar is updated by the plugin; Windows controls the actual banner lifetime',
388
+ restartForNewSettings: 'The notification host is older; restart DSH to use the new settings',
389
+ seconds: 'sec',
390
+ notifyTestTitle: 'DSH notification test',
391
+ notifyTestBody: 'This is a test notification. Click to return to DSH.',
392
+ fallbackBody: 'Click to view',
393
+ fallbackApprovalBody: 'A tool call needs approval',
394
+ }
395
+
396
+ // ------------------------------------------------------------------
397
+ // Pure helpers (exported through __test for the Node smoke test)
398
+ // ------------------------------------------------------------------
399
+
400
+ function warn(message, error) {
401
+ try {
402
+ if (typeof console !== 'undefined' && console && typeof console.warn === 'function') {
403
+ console.warn('[dsh-notify-toast] ' + message, error === undefined ? '' : error)
404
+ }
405
+ } catch (ignored) {
406
+ // Logging must never be the reason a notification fails.
407
+ }
408
+ }
409
+
410
+ function isRecord(value) {
411
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
412
+ }
413
+
414
+ function numberOr(value, fallback) {
415
+ const parsed = typeof value === 'number' ? value : Number(value)
416
+ return Number.isFinite(parsed) ? parsed : fallback
417
+ }
418
+
419
+ function clampInteger(value, min, max, fallback) {
420
+ const parsed = numberOr(value, NaN)
421
+ if (!Number.isFinite(parsed)) return fallback
422
+ return Math.min(max, Math.max(min, Math.round(parsed)))
423
+ }
424
+
425
+ /** Collapse any run of whitespace (incl. newlines) to a single space. */
426
+ function collapseWhitespace(value) {
427
+ if (typeof value !== 'string') return ''
428
+ return value.replace(/\s+/g, ' ').trim()
429
+ }
430
+
431
+ /**
432
+ * Truncate to `maxLength` Unicode code points (surrogate pairs count as
433
+ * one) after collapsing whitespace, appending '…' when cut.
434
+ */
435
+ function truncateText(value, maxLength) {
436
+ const text = collapseWhitespace(value)
437
+ const limit = clampInteger(maxLength, 20, 60, DEFAULT_CONFIG.bodyLength)
438
+ const codePoints = Array.from(text)
439
+ if (codePoints.length <= limit) return text
440
+ return codePoints.slice(0, limit).join('') + '…'
441
+ }
442
+
443
+ function pickBoolean(source, key, fallback) {
444
+ if (isRecord(source) && typeof source[key] === 'boolean') return source[key]
445
+ return fallback
446
+ }
447
+
448
+ function pickSound(source, fallback) {
449
+ if (isRecord(source) && typeof source.sound === 'string' && SOUND_IDS.indexOf(source.sound) >= 0) return source.sound
450
+ return fallback
451
+ }
452
+
453
+ /** UTF-8 byte length without TextEncoder (the smoke vm has no DOM globals). */
454
+ function utf8ByteLength(text) {
455
+ let bytes = 0
456
+ for (const ch of String(text)) {
457
+ const code = ch.codePointAt(0)
458
+ if (code <= 0x7f) bytes += 1
459
+ else if (code <= 0x7ff) bytes += 2
460
+ else if (code <= 0xffff) bytes += 3
461
+ else bytes += 4
462
+ }
463
+ return bytes
464
+ }
465
+
466
+ /** Clamp a user-supplied all-done title to the Host's 96-byte limit. */
467
+ function sanitizeAllDoneTitle(value) {
468
+ if (typeof value !== 'string') return undefined
469
+ const collapsed = value.replace(/\s+/g, ' ').trim()
470
+ if (collapsed.length === 0) return undefined
471
+ let out = ''
472
+ let bytes = 0
473
+ for (const ch of collapsed) {
474
+ const size = utf8ByteLength(ch)
475
+ if (bytes + size > 96) break
476
+ out += ch
477
+ bytes += size
478
+ }
479
+ return out.length > 0 ? out : undefined
480
+ }
481
+
482
+ /** Parse the host semver prefix, or null when it is not semver-shaped. */
483
+ function parseHostVersion(version) {
484
+ if (typeof version !== 'string') return null
485
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim())
486
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null
487
+ }
488
+
489
+ /** New client settings need the 0.3.0 Host schema. */
490
+ function hostSupportsAdvancedSettings(version) {
491
+ const parts = parseHostVersion(version)
492
+ if (!parts) return true
493
+ return parts[0] > 0 || (parts[0] === 0 && parts[1] >= 3)
494
+ }
495
+
496
+ /** Map the 0.2 in-app mode to the 0.3 native-progress backend. */
497
+ function normalizeNotificationMode(value) {
498
+ if (value === 'native') return 'native'
499
+ if (value === 'native-progress' || value === 'native-and-inapp') return 'native-progress'
500
+ return undefined
501
+ }
502
+
503
+ /** Merge a (possibly partial / hostile) config object over a base. */
504
+ function sanitizeConfig(raw, base) {
505
+ const source = isRecord(raw) ? raw : {}
506
+ const fallback = isRecord(base) ? base : DEFAULT_CONFIG
507
+ const config = {}
508
+ for (const key of BOOLEAN_CONFIG_KEYS) {
509
+ config[key] = pickBoolean(source, key, typeof fallback[key] === 'boolean' ? fallback[key] : DEFAULT_CONFIG[key])
510
+ }
511
+ config.bodyLength = clampInteger(source.bodyLength, 20, 60, clampInteger(fallback.bodyLength, 20, 60, DEFAULT_CONFIG.bodyLength))
512
+ config.sound = pickSound(source, typeof fallback.sound === 'string' ? fallback.sound : DEFAULT_CONFIG.sound)
513
+ config.volume = clampInteger(source.volume, 0, 100, clampInteger(fallback.volume, 0, 100, DEFAULT_CONFIG.volume))
514
+ config.mergeIntervalMs = clampInteger(source.mergeIntervalMs, 0, 10000, clampInteger(fallback.mergeIntervalMs, 0, 10000, DEFAULT_CONFIG.mergeIntervalMs))
515
+ config.allDoneTitle = sanitizeAllDoneTitle(source.allDoneTitle)
516
+ ?? sanitizeAllDoneTitle(fallback.allDoneTitle)
517
+ ?? DEFAULT_CONFIG.allDoneTitle
518
+ config.notificationMode = normalizeNotificationMode(source.notificationMode)
519
+ || normalizeNotificationMode(fallback.notificationMode)
520
+ || DEFAULT_CONFIG.notificationMode
521
+ config.nativeDurationMs = clampInteger(
522
+ source.nativeDurationMs,
523
+ NATIVE_DURATION_MIN_MS,
524
+ NATIVE_DURATION_MAX_MS,
525
+ clampInteger(fallback.nativeDurationMs, NATIVE_DURATION_MIN_MS, NATIVE_DURATION_MAX_MS, DEFAULT_CONFIG.nativeDurationMs),
526
+ )
527
+ return config
528
+ }
529
+
530
+ /** Whitelist + clamp a settings patch before it is sent to the Host. */
531
+ function sanitizePatch(raw) {
532
+ const patch = {}
533
+ if (!isRecord(raw)) return patch
534
+ for (const key of BOOLEAN_CONFIG_KEYS) {
535
+ if (typeof raw[key] === 'boolean') patch[key] = raw[key]
536
+ }
537
+ if (raw.bodyLength !== undefined) {
538
+ const value = numberOr(raw.bodyLength, NaN)
539
+ if (Number.isFinite(value)) patch.bodyLength = clampInteger(value, 20, 60, DEFAULT_CONFIG.bodyLength)
540
+ }
541
+ if (raw.sound !== undefined && typeof raw.sound === 'string' && SOUND_IDS.indexOf(raw.sound) >= 0) patch.sound = raw.sound
542
+ if (raw.volume !== undefined) {
543
+ const value = numberOr(raw.volume, NaN)
544
+ if (Number.isFinite(value)) patch.volume = clampInteger(value, 0, 100, DEFAULT_CONFIG.volume)
545
+ }
546
+ if (raw.mergeIntervalMs !== undefined) {
547
+ const value = numberOr(raw.mergeIntervalMs, NaN)
548
+ if (Number.isFinite(value)) patch.mergeIntervalMs = clampInteger(value, 0, 10000, DEFAULT_CONFIG.mergeIntervalMs)
549
+ }
550
+ if (raw.allDoneTitle !== undefined) {
551
+ const title = sanitizeAllDoneTitle(raw.allDoneTitle)
552
+ if (title !== undefined) patch.allDoneTitle = title
553
+ }
554
+ if (raw.notificationMode !== undefined) {
555
+ const mode = normalizeNotificationMode(raw.notificationMode)
556
+ if (mode) patch.notificationMode = mode
557
+ }
558
+ if (raw.nativeDurationMs !== undefined) {
559
+ const value = numberOr(raw.nativeDurationMs, NaN)
560
+ if (Number.isFinite(value)) patch.nativeDurationMs = clampInteger(value, NATIVE_DURATION_MIN_MS, NATIVE_DURATION_MAX_MS, DEFAULT_CONFIG.nativeDurationMs)
561
+ }
562
+ return patch
563
+ }
564
+
565
+ function notificationTitleKey(kind) {
566
+ if (kind === 'question') return 'notifyQuestionTitle'
567
+ if (kind === 'approval') return 'notifyApprovalTitle'
568
+ if (kind === 'complete') return 'notifyCompleteTitle'
569
+ if (kind === 'test') return 'notifyTestTitle'
570
+ return 'notifyTestTitle'
571
+ }
572
+
573
+ function buildNotificationTitle(kind, event, config, translate) {
574
+ // Backward-compatible signature: buildNotificationTitle(kind, t).
575
+ let ev = event
576
+ let cfg = config
577
+ let tr = translate
578
+ if (typeof event === 'function') {
579
+ tr = event
580
+ ev = undefined
581
+ cfg = DEFAULT_CONFIG
582
+ }
583
+ const t = typeof tr === 'function' ? tr : (key) => key
584
+ if (kind === 'complete') {
585
+ const count = ev && Number.isFinite(ev.count) ? Math.max(1, Math.floor(ev.count)) : 1
586
+ if (ev && ev.goalOutcome === 'paused') {
587
+ return formatTemplate(t('notifyGoalPausedTitle'), { count: String(count) })
588
+ }
589
+ if (ev && ev.goalOutcome === 'blocked') {
590
+ return formatTemplate(t('notifyGoalBlockedTitle'), { count: String(count) })
591
+ }
592
+ if (ev && ev.allDone === true) return t('notifyAllDoneTitle')
593
+ if (count >= 5) return formatTemplate(t('notifyCompleteManyTitle'), { count: String(count) })
594
+ if (count > 1) return formatTemplate(t('notifyCompleteMergedTitle'), { count: String(count) })
595
+ }
596
+ return t(notificationTitleKey(kind))
597
+ }
598
+
599
+ function buildApprovalBody(event, translate) {
600
+ const t = typeof translate === 'function' ? translate : (key) => key
601
+ const toolName = collapseWhitespace(isRecord(event) ? event.toolName : '')
602
+ const purpose = collapseWhitespace(isRecord(event) ? event.purpose : '')
603
+ const joined = [toolName, purpose].filter(Boolean).join(' · ')
604
+ return joined || t('fallbackApprovalBody')
605
+ }
606
+
607
+ /**
608
+ * Body rules from the contract:
609
+ * - titleOnly wins over everything (empty body);
610
+ * - approval = "toolName · purpose" (locale fallback when empty);
611
+ * - complete = truncated last reply, or the locale fallback;
612
+ * - question = truncated question text; test = fixed copy.
613
+ */
614
+ function buildNotificationBody(kind, event, config, translate) {
615
+ const t = typeof translate === 'function' ? translate : (key) => key
616
+ const settings = isRecord(config) ? config : DEFAULT_CONFIG
617
+ if (settings.titleOnly === true) return ''
618
+ if (kind === 'test') return t('notifyTestBody')
619
+ if (kind === 'approval') return buildApprovalBody(event, t)
620
+ if (kind === 'complete') {
621
+ const text = collapseWhitespace(isRecord(event) ? event.text : '')
622
+ return text ? truncateText(text, settings.bodyLength) : t('fallbackBody')
623
+ }
624
+ if (kind === 'question') return truncateText(isRecord(event) ? event.text : '', settings.bodyLength)
625
+ return ''
626
+ }
627
+
628
+ /** `document.hasFocus() && visibilityState !== 'hidden'`, computed now. */
629
+ function computeFocused(doc) {
630
+ try {
631
+ if (!doc) return true
632
+ const hasFocus = typeof doc.hasFocus === 'function' ? doc.hasFocus() : true
633
+ const visibility = typeof doc.visibilityState === 'string' ? doc.visibilityState : 'visible'
634
+ return hasFocus !== false && visibility !== 'hidden'
635
+ } catch (error) {
636
+ // Without a trustworthy answer, stay silent rather than nag.
637
+ return true
638
+ }
639
+ }
640
+
641
+ function parseLeaderLease(raw) {
642
+ try {
643
+ const value = JSON.parse(raw)
644
+ if (!isRecord(value)) return null
645
+ if (typeof value.id !== 'string' || value.id.length === 0) return null
646
+ if (typeof value.at !== 'number' || !Number.isFinite(value.at)) return null
647
+ return { id: value.id, at: value.at }
648
+ } catch (error) {
649
+ return null
650
+ }
651
+ }
652
+
653
+ function isFreshLease(lease, now) {
654
+ if (!lease) return false
655
+ const stamp = typeof now === 'number' ? now : Date.now()
656
+ return stamp - lease.at < LEADER_STALE_MS
657
+ }
658
+
659
+ function describeRpcFailure(result) {
660
+ if (!isRecord(result)) return 'no response'
661
+ const error = result.error
662
+ if (isRecord(error)) {
663
+ if (typeof error.message === 'string' && error.message) return error.message
664
+ if (typeof error.code === 'string' && error.code) return error.code
665
+ }
666
+ if (typeof error === 'string' && error) return error
667
+ return 'request failed'
668
+ }
669
+
670
+ /**
671
+ * A Connection RPC reply is doubly flagged: the transport resolves
672
+ * `{ok:true, value}` and the Host endpoint's own value is again
673
+ * `{ok:true, ...}` or `{ok:false, error}` (docs/INTERFACE.md §2.3).
674
+ * These helpers unwrap that business layer.
675
+ */
676
+ function businessValue(response) {
677
+ if (!isRecord(response) || response.ok !== true) return null
678
+ const value = response.value
679
+ if (!isRecord(value)) return null
680
+ if (value.ok === false) return null
681
+ return value
682
+ }
683
+
684
+ function businessFailure(response) {
685
+ if (!isRecord(response) || response.ok !== true) return null
686
+ const value = response.value
687
+ if (isRecord(value) && value.ok === false) return value
688
+ return null
689
+ }
690
+
691
+ function getWindow() {
692
+ try {
693
+ if (typeof window !== 'undefined' && window) return window
694
+ } catch (error) {
695
+ // ignored
696
+ }
697
+ return undefined
698
+ }
699
+
700
+ function getDocument() {
701
+ try {
702
+ if (typeof document !== 'undefined' && document) return document
703
+ } catch (error) {
704
+ // ignored
705
+ }
706
+ return undefined
707
+ }
708
+
709
+ function getNotificationCtor() {
710
+ try {
711
+ if (typeof Notification !== 'undefined' && Notification) return Notification
712
+ } catch (error) {
713
+ // ignored
714
+ }
715
+ try {
716
+ const target = getWindow()
717
+ if (target && target.Notification) return target.Notification
718
+ } catch (error) {
719
+ // ignored
720
+ }
721
+ return null
722
+ }
723
+
724
+ function readNotificationPermission() {
725
+ const Ctor = getNotificationCtor()
726
+ if (!Ctor) return 'unsupported'
727
+ try {
728
+ return typeof Ctor.permission === 'string' && Ctor.permission ? Ctor.permission : 'default'
729
+ } catch (error) {
730
+ return 'unknown'
731
+ }
732
+ }
733
+
734
+ function getAudioConstructor() {
735
+ const target = getWindow()
736
+ const candidates = []
737
+ try {
738
+ if (typeof AudioContext !== 'undefined') candidates.push(AudioContext)
739
+ } catch (error) {
740
+ // ignored
741
+ }
742
+ try {
743
+ if (typeof webkitAudioContext !== 'undefined') candidates.push(webkitAudioContext)
744
+ } catch (error) {
745
+ // ignored
746
+ }
747
+ if (target) {
748
+ candidates.push(target.AudioContext, target.webkitAudioContext)
749
+ }
750
+ for (const candidate of candidates) {
751
+ if (typeof candidate === 'function') return candidate
752
+ }
753
+ return null
754
+ }
755
+
756
+ function getLocalStorage() {
757
+ try {
758
+ if (typeof localStorage !== 'undefined' && localStorage) return localStorage
759
+ } catch (error) {
760
+ // Access can throw under hardened privacy settings; fall through.
761
+ }
762
+ try {
763
+ const target = getWindow()
764
+ if (target && target.localStorage) return target.localStorage
765
+ } catch (error) {
766
+ // ignored
767
+ }
768
+ return null
769
+ }
770
+
771
+ function createLeaderId() {
772
+ try {
773
+ const globalCrypto = typeof crypto !== 'undefined' ? crypto : undefined
774
+ if (globalCrypto && typeof globalCrypto.randomUUID === 'function') return globalCrypto.randomUUID()
775
+ const target = getWindow()
776
+ if (target && target.crypto && typeof target.crypto.randomUUID === 'function') return target.crypto.randomUUID()
777
+ } catch (error) {
778
+ // ignored
779
+ }
780
+ return 'w' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
781
+ }
782
+
783
+ function createAbortController() {
784
+ try {
785
+ if (typeof AbortController === 'function') return new AbortController()
786
+ } catch (error) {
787
+ // ignored
788
+ }
789
+ return null
790
+ }
791
+
792
+ function formatWithUnit(value, unit) {
793
+ if (!unit) return String(value)
794
+ return String(value) + ' ' + unit
795
+ }
796
+
797
+ /** Replace `{name}` placeholders with values (missing keys stay literal). */
798
+ function formatTemplate(template, values) {
799
+ if (typeof template !== 'string') return ''
800
+ if (!values || typeof values !== 'object') return template
801
+ return template.replace(/\{(\w+)\}/g, (match, key) => (Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match))
802
+ }
803
+
804
+ /** HH:mm:ss in the page's local time zone ('' for invalid input). */
805
+ function formatClockTime(value) {
806
+ const stamp = typeof value === 'number' ? value : Number(value)
807
+ if (!Number.isFinite(stamp)) return ''
808
+ try {
809
+ const date = new Date(stamp)
810
+ if (!Number.isFinite(date.getTime())) return ''
811
+ const pad = (part) => (part < 10 ? '0' + part : String(part))
812
+ return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds())
813
+ } catch (error) {
814
+ return ''
815
+ }
816
+ }
817
+
818
+ // ------------------------------------------------------------------
819
+ // Inline styles — theme tokens with safe fallbacks (dark mode ready).
820
+ // ------------------------------------------------------------------
821
+
822
+ const TOKEN = {
823
+ labelPrimary: 'var(--dsw-alias-label-primary, #1b1b1b)',
824
+ labelTertiary: 'var(--dsw-alias-label-tertiary, #666)',
825
+ border: 'var(--dsw-alias-border-l2, rgba(0,0,0,.12))',
826
+ bgLayer: 'var(--dsw-alias-bg-layer-1, #fff)',
827
+ primary: 'var(--dsw-alias-state-business-primary, #4d6bfe)',
828
+ danger: 'var(--dsw-alias-state-error-primary, #d92d20)',
829
+ }
830
+
831
+ const styles = {
832
+ page: {
833
+ display: 'flex',
834
+ flexDirection: 'column',
835
+ gap: '16px',
836
+ padding: '4px 2px 24px',
837
+ color: TOKEN.labelPrimary,
838
+ fontSize: '13px',
839
+ lineHeight: '18px',
840
+ },
841
+ header: { display: 'flex', flexDirection: 'column', gap: '6px' },
842
+ title: { margin: 0, fontSize: '18px', lineHeight: '24px', fontWeight: 600, color: TOKEN.labelPrimary },
843
+ intro: { margin: 0, fontSize: '13px', lineHeight: '20px', color: TOKEN.labelTertiary },
844
+ notice: {
845
+ padding: '10px 12px',
846
+ borderRadius: '8px',
847
+ fontSize: '13px',
848
+ lineHeight: '18px',
849
+ color: TOKEN.labelPrimary,
850
+ background: TOKEN.bgLayer,
851
+ border: '1px solid ' + TOKEN.border,
852
+ },
853
+ card: {
854
+ display: 'flex',
855
+ flexDirection: 'column',
856
+ gap: '2px',
857
+ padding: '4px 14px',
858
+ borderRadius: '10px',
859
+ background: TOKEN.bgLayer,
860
+ border: '1px solid ' + TOKEN.border,
861
+ },
862
+ row: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '16px', padding: '10px 0', minHeight: '28px' },
863
+ rowColumn: { display: 'flex', flexDirection: 'column', gap: '8px', padding: '10px 0' },
864
+ rowHeader: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '16px' },
865
+ rowText: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 },
866
+ rowLabel: { fontSize: '13px', lineHeight: '18px', fontWeight: 500, color: TOKEN.labelPrimary },
867
+ rowHint: { fontSize: '12px', lineHeight: '16px', color: TOKEN.labelTertiary },
868
+ rowValue: { fontSize: '13px', lineHeight: '18px', color: TOKEN.labelTertiary, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' },
869
+ textInput: {
870
+ width: '220px',
871
+ maxWidth: '100%',
872
+ padding: '6px 8px',
873
+ borderRadius: '6px',
874
+ border: '1px solid ' + TOKEN.border,
875
+ background: TOKEN.bgLayer,
876
+ color: TOKEN.labelPrimary,
877
+ fontSize: '13px',
878
+ lineHeight: '18px',
879
+ outline: 'none',
880
+ },
881
+ divider: { height: '1px', margin: 0, border: 'none', background: TOKEN.border },
882
+ switchOn: {
883
+ position: 'relative',
884
+ flex: '0 0 auto',
885
+ width: '38px',
886
+ height: '22px',
887
+ padding: 0,
888
+ border: 'none',
889
+ borderRadius: '11px',
890
+ background: TOKEN.primary,
891
+ cursor: 'pointer',
892
+ transition: 'background 120ms ease',
893
+ },
894
+ switchOff: {
895
+ position: 'relative',
896
+ flex: '0 0 auto',
897
+ width: '38px',
898
+ height: '22px',
899
+ padding: 0,
900
+ border: 'none',
901
+ borderRadius: '11px',
902
+ background: TOKEN.border,
903
+ cursor: 'pointer',
904
+ transition: 'background 120ms ease',
905
+ },
906
+ knobOn: { position: 'absolute', top: '2px', left: '18px', width: '18px', height: '18px', borderRadius: '9px', background: '#fff', boxShadow: '0 1px 2px rgba(0,0,0,.24)' },
907
+ knobOff: { position: 'absolute', top: '2px', left: '2px', width: '18px', height: '18px', borderRadius: '9px', background: '#fff', boxShadow: '0 1px 2px rgba(0,0,0,.24)' },
908
+ range: { width: '100%', accentColor: TOKEN.primary, cursor: 'pointer' },
909
+ select: {
910
+ minWidth: '128px',
911
+ padding: '6px 8px',
912
+ borderRadius: '8px',
913
+ border: '1px solid ' + TOKEN.border,
914
+ background: TOKEN.bgLayer,
915
+ color: TOKEN.labelPrimary,
916
+ fontSize: '13px',
917
+ lineHeight: '18px',
918
+ },
919
+ buttonPrimary: {
920
+ alignSelf: 'flex-start',
921
+ padding: '7px 14px',
922
+ borderRadius: '8px',
923
+ border: 'none',
924
+ background: TOKEN.primary,
925
+ color: '#fff',
926
+ fontSize: '13px',
927
+ lineHeight: '18px',
928
+ fontWeight: 500,
929
+ cursor: 'pointer',
930
+ },
931
+ buttonGhost: {
932
+ alignSelf: 'flex-start',
933
+ padding: '6px 12px',
934
+ borderRadius: '8px',
935
+ border: '1px solid ' + TOKEN.border,
936
+ background: TOKEN.bgLayer,
937
+ color: TOKEN.labelPrimary,
938
+ fontSize: '12px',
939
+ lineHeight: '18px',
940
+ cursor: 'pointer',
941
+ },
942
+ statusBlock: {
943
+ display: 'flex',
944
+ flexDirection: 'column',
945
+ gap: '2px',
946
+ padding: '10px 14px',
947
+ borderRadius: '10px',
948
+ border: '1px solid ' + TOKEN.border,
949
+ background: TOKEN.bgLayer,
950
+ },
951
+ statusTitle: { fontSize: '13px', fontWeight: 600, margin: '2px 0 6px', color: TOKEN.labelPrimary },
952
+ statusRow: { display: 'flex', justifyContent: 'space-between', gap: '16px', fontSize: '12px', lineHeight: '18px' },
953
+ statusLabel: { color: TOKEN.labelTertiary },
954
+ statusValue: { color: TOKEN.labelPrimary, fontVariantNumeric: 'tabular-nums', textAlign: 'right' },
955
+ okText: { fontSize: '12px', lineHeight: '18px', color: TOKEN.primary },
956
+ errorText: { fontSize: '12px', lineHeight: '18px', color: TOKEN.danger },
957
+ hint: { fontSize: '12px', lineHeight: '18px', color: TOKEN.labelTertiary },
958
+ footer: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px' },
959
+ actions: { display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' },
960
+ }
961
+
962
+ // ------------------------------------------------------------------
963
+ // Presentational primitives (plain HTML through React.createElement).
964
+ // ------------------------------------------------------------------
965
+
966
+ function SwitchRow(props) {
967
+ const checked = props.checked === true
968
+ const disabled = props.disabled === true
969
+ return h(
970
+ 'div',
971
+ { style: styles.row },
972
+ h(
973
+ 'div',
974
+ { style: styles.rowText },
975
+ h('span', { style: styles.rowLabel }, props.label),
976
+ props.hint ? h('span', { style: styles.rowHint }, props.hint) : null,
977
+ ),
978
+ h(
979
+ 'button',
980
+ {
981
+ type: 'button',
982
+ role: 'switch',
983
+ 'aria-checked': checked ? 'true' : 'false',
984
+ 'aria-label': props.label,
985
+ disabled: disabled,
986
+ onClick: () => {
987
+ if (disabled) return
988
+ if (typeof props.onChange === 'function') props.onChange(!checked)
989
+ },
990
+ style: checked ? styles.switchOn : styles.switchOff,
991
+ },
992
+ h('span', { 'aria-hidden': 'true', style: checked ? styles.knobOn : styles.knobOff }),
993
+ ),
994
+ )
995
+ }
996
+
997
+ function TextRow(props) {
998
+ const inputId = 'dsh-notify-toast-text-' + String(props.idPart || props.label)
999
+ const disabled = props.disabled === true
1000
+ return h(
1001
+ 'div',
1002
+ { style: styles.row },
1003
+ h(
1004
+ 'div',
1005
+ { style: styles.rowText },
1006
+ h('label', { style: styles.rowLabel, htmlFor: inputId }, props.label),
1007
+ props.hint ? h('span', { style: styles.rowHint }, props.hint) : null,
1008
+ ),
1009
+ h('input', {
1010
+ id: inputId,
1011
+ type: 'text',
1012
+ value: props.value == null ? '' : String(props.value),
1013
+ maxLength: props.maxLength || 60,
1014
+ 'aria-label': props.label,
1015
+ disabled: disabled,
1016
+ onChange: (event) => {
1017
+ const next = event && event.target ? event.target.value : ''
1018
+ if (!disabled && typeof props.onChange === 'function') props.onChange(next)
1019
+ },
1020
+ style: styles.textInput,
1021
+ }),
1022
+ )
1023
+ }
1024
+
1025
+ function RangeRow(props) {
1026
+ const inputId = 'dsh-notify-toast-range-' + String(props.idPart || props.label)
1027
+ return h(
1028
+ 'div',
1029
+ { style: styles.rowColumn },
1030
+ h(
1031
+ 'div',
1032
+ { style: styles.rowHeader },
1033
+ h('label', { style: styles.rowLabel, htmlFor: inputId }, props.label),
1034
+ h('span', { style: styles.rowValue }, formatWithUnit(props.value, props.unit)),
1035
+ ),
1036
+ h('input', {
1037
+ id: inputId,
1038
+ type: 'range',
1039
+ min: String(props.min),
1040
+ max: String(props.max),
1041
+ step: String(props.step || 1),
1042
+ value: String(props.value),
1043
+ 'aria-label': props.label,
1044
+ onChange: (event) => {
1045
+ const next = Number(event && event.target ? event.target.value : NaN)
1046
+ if (Number.isFinite(next) && typeof props.onChange === 'function') props.onChange(next)
1047
+ },
1048
+ style: styles.range,
1049
+ }),
1050
+ props.hint ? h('span', { style: styles.rowHint }, props.hint) : null,
1051
+ )
1052
+ }
1053
+
1054
+ function SelectRow(props) {
1055
+ const selectId = 'dsh-notify-toast-select-' + String(props.idPart || props.label)
1056
+ const options = Array.isArray(props.options) ? props.options : []
1057
+ return h(
1058
+ 'div',
1059
+ { style: styles.row },
1060
+ h(
1061
+ 'div',
1062
+ { style: styles.rowText },
1063
+ h('label', { style: styles.rowLabel, htmlFor: selectId }, props.label),
1064
+ props.hint ? h('span', { style: styles.rowHint }, props.hint) : null,
1065
+ ),
1066
+ h(
1067
+ 'select',
1068
+ {
1069
+ id: selectId,
1070
+ value: String(props.value),
1071
+ 'aria-label': props.label,
1072
+ onChange: (event) => {
1073
+ const next = event && event.target ? event.target.value : undefined
1074
+ if (typeof next === 'string' && typeof props.onChange === 'function') props.onChange(next)
1075
+ },
1076
+ style: styles.select,
1077
+ },
1078
+ options.map((option) => h('option', { key: option.value, value: option.value }, option.label)),
1079
+ ),
1080
+ )
1081
+ }
1082
+
1083
+ function StatusRow(props) {
1084
+ return h(
1085
+ 'div',
1086
+ { style: styles.statusRow },
1087
+ h('span', { style: styles.statusLabel }, props.label),
1088
+ h('span', { style: styles.statusValue }, props.value),
1089
+ )
1090
+ }
1091
+
1092
+ // ------------------------------------------------------------------
1093
+ // Settings nav icon
1094
+ //
1095
+ // The host projects only id/order/label for a `settings.section`, and
1096
+ // its nav glyph is a closed list (`models`, `agent-presets`,
1097
+ // `plugins`) with the settings gear as the fallback, so a registrant
1098
+ // has no icon field to pass. Like the plugin market, claim this
1099
+ // plugin's own nav row once the dialog mounts and draw a mask-based
1100
+ // mark in place of the gear. The marker is set only on the row whose
1101
+ // visible text equals this plugin's localized label, and the whole
1102
+ // thing lives in a ctx.effect so an unload removes marker and style.
1103
+ // ------------------------------------------------------------------
1104
+
1105
+ /** Marker attribute on this plugin's settings-nav row. */
1106
+ const NAV_ICON_MARKER = 'data-dsh-notify-toast-nav-icon'
1107
+ /** Glyph box; matches the shell's nav icon size. */
1108
+ const NAV_ICON_SIZE = 16
1109
+ /** The settings dialog's nav rows, as the shell renders them. */
1110
+ const NAV_ROW_SELECTOR = '[role="dialog"] nav button'
1111
+
1112
+ /** The bell mark as standalone SVG (pure black: a mask reads alpha). */
1113
+ function navMarkSvg() {
1114
+ return (
1115
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" ' +
1116
+ 'stroke="#000" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round">' +
1117
+ '<path d="M4.4 6.5C4.4 4.5 5.9 2.9 8 2.9s3.6 1.6 3.6 3.6v2.6l1.05 2.05' +
1118
+ 'c.18.35-.08.75-.47.75H3.82c-.39 0-.65-.4-.47-.75L4.4 9.1Z"/>' +
1119
+ '<path d="M6.55 13.3h2.9"/>' +
1120
+ '</svg>'
1121
+ )
1122
+ }
1123
+
1124
+ /** The mark as a CSS mask URL (encoded at runtime, never hand-escaped). */
1125
+ function navMaskUrl(svg) {
1126
+ const source = typeof svg === 'string' ? svg : navMarkSvg()
1127
+ return 'data:image/svg+xml,' + encodeURIComponent(source)
1128
+ }
1129
+
1130
+ /** Whether a nav row's visible text is this plugin's section label. */
1131
+ function isOwnNavRow(rowText, wantedLabel) {
1132
+ const wanted = String(wantedLabel === undefined || wantedLabel === null ? '' : wantedLabel).trim()
1133
+ if (wanted.length === 0) return false
1134
+ return String(rowText === undefined || rowText === null ? '' : rowText).trim() === wanted
1135
+ }
1136
+
1137
+ /** Stylesheet for a claimed row: hide the shell's gear, draw the mark. */
1138
+ function navIconCss(maskUrl) {
1139
+ return [
1140
+ '[' + NAV_ICON_MARKER + '] > svg { display: none; }',
1141
+ '[' + NAV_ICON_MARKER + ']::before {',
1142
+ " content: '';",
1143
+ ' flex: none;',
1144
+ ' width: ' + NAV_ICON_SIZE + 'px;',
1145
+ ' height: ' + NAV_ICON_SIZE + 'px;',
1146
+ ' background-color: currentColor;',
1147
+ ' -webkit-mask-image: url("' + maskUrl + '");',
1148
+ ' mask-image: url("' + maskUrl + '");',
1149
+ ' -webkit-mask-repeat: no-repeat;',
1150
+ ' mask-repeat: no-repeat;',
1151
+ ' -webkit-mask-position: center;',
1152
+ ' mask-position: center;',
1153
+ ' -webkit-mask-size: ' + NAV_ICON_SIZE + 'px ' + NAV_ICON_SIZE + 'px;',
1154
+ ' mask-size: ' + NAV_ICON_SIZE + 'px ' + NAV_ICON_SIZE + 'px;',
1155
+ '}',
1156
+ ].join('\n')
1157
+ }
1158
+
1159
+ /** All nav rows currently mounted; empty while the dialog is closed. */
1160
+ function queryNavRows(doc) {
1161
+ try {
1162
+ return typeof doc.querySelectorAll === 'function' ? doc.querySelectorAll(NAV_ROW_SELECTOR) : []
1163
+ } catch (error) {
1164
+ return []
1165
+ }
1166
+ }
1167
+
1168
+ /** Every marked row, for cleanup. */
1169
+ function queryMarkedRows(doc) {
1170
+ try {
1171
+ return typeof doc.querySelectorAll === 'function'
1172
+ ? doc.querySelectorAll('[' + NAV_ICON_MARKER + ']')
1173
+ : []
1174
+ } catch (error) {
1175
+ return []
1176
+ }
1177
+ }
1178
+
1179
+ /**
1180
+ * Replace this plugin's fallback gear with the bell mark.
1181
+ *
1182
+ * @param ctx - client context, for effect ownership.
1183
+ * @param resolveLabel - current localized section label, re-read on
1184
+ * every sync so a locale switch is picked up without re-registering.
1185
+ */
1186
+ function installSettingsNavIcon(ctx, resolveLabel) {
1187
+ if (typeof document === 'undefined' || !document) return
1188
+ try {
1189
+ ctx.effect(() => {
1190
+ const doc = document
1191
+ const host = doc.head || doc.body
1192
+ if (!host) return () => {}
1193
+ const tag = doc.createElement('style')
1194
+ tag.dataset.plugin = PKG_ID
1195
+ tag.dataset.pluginCss = PKG_ID + '/settings-nav-icon'
1196
+ tag.textContent = navIconCss(navMaskUrl())
1197
+ host.appendChild(tag)
1198
+
1199
+ let disposed = false
1200
+ let scheduled = false
1201
+
1202
+ const sync = () => {
1203
+ scheduled = false
1204
+ if (disposed) return
1205
+ const rows = queryNavRows(doc)
1206
+ if (rows.length === 0) {
1207
+ for (const row of queryMarkedRows(doc)) row.removeAttribute(NAV_ICON_MARKER)
1208
+ return
1209
+ }
1210
+ let wanted = ''
1211
+ try {
1212
+ wanted = resolveLabel()
1213
+ } catch (error) {
1214
+ warn('cannot resolve the settings nav label', error)
1215
+ }
1216
+ for (const row of rows) {
1217
+ if (isOwnNavRow(row.textContent, wanted)) row.setAttribute(NAV_ICON_MARKER, '')
1218
+ else row.removeAttribute(NAV_ICON_MARKER)
1219
+ }
1220
+ }
1221
+
1222
+ const schedule = () => {
1223
+ if (scheduled || disposed) return
1224
+ scheduled = true
1225
+ if (typeof queueMicrotask === 'function') queueMicrotask(sync)
1226
+ else Promise.resolve().then(sync)
1227
+ }
1228
+
1229
+ sync()
1230
+ let observer = null
1231
+ try {
1232
+ if (typeof MutationObserver === 'function' && doc.body) {
1233
+ observer = new MutationObserver(schedule)
1234
+ observer.observe(doc.body, { childList: true, subtree: true, characterData: true })
1235
+ }
1236
+ } catch (error) {
1237
+ warn('cannot observe the settings dialog for the nav icon', error)
1238
+ }
1239
+
1240
+ return () => {
1241
+ disposed = true
1242
+ try {
1243
+ if (observer) observer.disconnect()
1244
+ } catch (error) {
1245
+ // ignored
1246
+ }
1247
+ for (const row of queryMarkedRows(doc)) row.removeAttribute(NAV_ICON_MARKER)
1248
+ try {
1249
+ if (tag.parentNode) tag.parentNode.removeChild(tag)
1250
+ else if (typeof tag.remove === 'function') tag.remove()
1251
+ } catch (error) {
1252
+ // ignored
1253
+ }
1254
+ }
1255
+ }, 'dsh-notify-toast: settings nav icon')
1256
+ } catch (error) {
1257
+ warn('cannot install the settings nav icon', error)
1258
+ }
1259
+ }
1260
+
1261
+ // ------------------------------------------------------------------
1262
+ // Factory export
1263
+ // ------------------------------------------------------------------
1264
+
1265
+ function apply(ctx) {
1266
+ if (!ctx || typeof ctx !== 'object' || typeof ctx.effect !== 'function') return
1267
+
1268
+ // ---- 1. Locale dictionaries (duplicate registration degrades to no-op) ----
1269
+ try {
1270
+ ctx.effect(() => {
1271
+ try {
1272
+ const disposer = ctx.locale.register(NS, { zh, en })
1273
+ return typeof disposer === 'function' ? disposer : () => {}
1274
+ } catch (error) {
1275
+ warn('locale dictionaries were already registered', error)
1276
+ return () => {}
1277
+ }
1278
+ }, 'dsh-notify-toast: locale dictionaries')
1279
+ } catch (error) {
1280
+ warn('cannot register locale dictionaries', error)
1281
+ }
1282
+
1283
+ let t = (key) => (zh[key] !== undefined ? zh[key] : key)
1284
+ try {
1285
+ if (ctx.locale && typeof ctx.locale.bind === 'function') t = ctx.locale.bind(NS)
1286
+ } catch (error) {
1287
+ warn('cannot bind the locale translator', error)
1288
+ }
1289
+
1290
+ // ---- 2. Runtime state ----
1291
+ let disposed = false
1292
+ let abortController = null
1293
+ let hostConnected = false
1294
+ let configLoaded = false
1295
+ let cursor = 0
1296
+ let inflightSave = false
1297
+ let pendingPatch = {}
1298
+ let advancedHostUnsupported = false
1299
+ let saveTimerCancel = null
1300
+ let savedFeedbackTimerCancel = null
1301
+ let permissionRequested = false
1302
+ let audioContext = null
1303
+ let audioMaster = null
1304
+ let audioCompressor = null
1305
+ let audioBuffers = Object.create(null)
1306
+ let audioState = 'uninitialized'
1307
+ let leaderLeaseId = createLeaderId()
1308
+ let leaderActive = true
1309
+ let config = sanitizeConfig(DEFAULT_CONFIG, DEFAULT_CONFIG)
1310
+ /** Root session IDs with completed work the user has not seen yet. */
1311
+ const unreadSessions = new Set()
1312
+ let badgeSequence = 0
1313
+ let badgeLastCount = -1
1314
+
1315
+ const status = {
1316
+ hostConnected: false,
1317
+ connectionAttempted: false,
1318
+ settingsAvailable: null,
1319
+ nativeFocus: null,
1320
+ platform: null,
1321
+ transport: null,
1322
+ configSource: null,
1323
+ clients: null,
1324
+ audio: 'uninitialized',
1325
+ nativeToasts: { attempts: 0, created: 0, shown: 0, failed: 0, lastError: null },
1326
+ nativeClicks: { clicks: 0, focusOk: 0, focusFailed: 0, lastError: null, lastNative: null },
1327
+ systemTest: null,
1328
+ stats: null,
1329
+ lastEvent: null,
1330
+ version: VERSION,
1331
+ notificationPermission: readNotificationPermission(),
1332
+ focused: true,
1333
+ leader: true,
1334
+ saveState: 'idle',
1335
+ loaded: false,
1336
+ testState: 'idle',
1337
+ }
1338
+
1339
+ const listeners = new Set()
1340
+ const toasts = new Map()
1341
+ const activationSeen = new Set()
1342
+ const pendingActivations = []
1343
+ const ACTIVATION_STORAGE_PREFIX = 'dsh-notify-toast:activation:'
1344
+
1345
+ function activationStorageKey(key) {
1346
+ return ACTIVATION_STORAGE_PREFIX + key
1347
+ }
1348
+
1349
+ function activationAlreadyClaimed(key) {
1350
+ try {
1351
+ const raw = localStorage.getItem(activationStorageKey(key))
1352
+ if (!raw) return false
1353
+ const at = Number(raw)
1354
+ if (!Number.isFinite(at) || Date.now() - at > EVENT_MAX_AGE_MS) {
1355
+ localStorage.removeItem(activationStorageKey(key))
1356
+ return false
1357
+ }
1358
+ return true
1359
+ } catch (error) {
1360
+ return false
1361
+ }
1362
+ }
1363
+
1364
+ function claimActivation(key) {
1365
+ try {
1366
+ localStorage.setItem(activationStorageKey(key), String(Date.now()))
1367
+ } catch (error) {
1368
+ // localStorage can be unavailable; leader-local dedupe still applies
1369
+ }
1370
+ }
1371
+ const outstandingTimers = new Set()
1372
+
1373
+ function snapshotState() {
1374
+ return { config: config, status: Object.assign({}, status) }
1375
+ }
1376
+
1377
+ function emit() {
1378
+ if (disposed) return
1379
+ const snapshot = snapshotState()
1380
+ for (const listener of Array.from(listeners)) {
1381
+ try {
1382
+ listener(snapshot)
1383
+ } catch (error) {
1384
+ warn('state listener crashed', error)
1385
+ }
1386
+ }
1387
+ }
1388
+
1389
+ function subscribe(listener) {
1390
+ listeners.add(listener)
1391
+ return () => {
1392
+ listeners.delete(listener)
1393
+ }
1394
+ }
1395
+
1396
+ function info(message, error) {
1397
+ try {
1398
+ if (typeof console !== 'undefined' && console && typeof console.info === 'function') console.info('[dsh-notify-toast] ' + message, error === undefined ? '' : error)
1399
+ } catch (ignored) {
1400
+ // ignored
1401
+ }
1402
+ }
1403
+
1404
+ // ---- 3. Timer and delay helpers ----
1405
+
1406
+ function runSafely(callback) {
1407
+ try {
1408
+ callback()
1409
+ } catch (error) {
1410
+ warn('scheduled callback crashed', error)
1411
+ }
1412
+ }
1413
+
1414
+ function scheduleWithTimeout(callback, delay) {
1415
+ let cancelled = false
1416
+ let handle = null
1417
+ try {
1418
+ handle = setTimeout(() => {
1419
+ if (cancelled) return
1420
+ runSafely(callback)
1421
+ }, delay)
1422
+ } catch (error) {
1423
+ warn('cannot schedule a timer', error)
1424
+ return () => {}
1425
+ }
1426
+ return () => {
1427
+ cancelled = true
1428
+ try {
1429
+ clearTimeout(handle)
1430
+ } catch (error) {
1431
+ // ignored
1432
+ }
1433
+ }
1434
+ }
1435
+
1436
+ /** Lifecycle-aware timer; prefers ctx.timeout so the fiber owns it. */
1437
+ function schedule(callback, delay) {
1438
+ if (disposed) return () => {}
1439
+ let cancel = () => {}
1440
+ try {
1441
+ if (typeof ctx.timeout === 'function') {
1442
+ const disposer = ctx.timeout(() => {
1443
+ removeTimer(cancel)
1444
+ runSafely(callback)
1445
+ }, delay)
1446
+ if (typeof disposer === 'function') {
1447
+ cancel = () => {
1448
+ try {
1449
+ const result = disposer()
1450
+ if (result && typeof result.catch === 'function') result.catch(() => {})
1451
+ } catch (error) {
1452
+ // ignored
1453
+ }
1454
+ }
1455
+ } else {
1456
+ cancel = () => {}
1457
+ }
1458
+ } else {
1459
+ cancel = scheduleWithTimeout(callback, delay)
1460
+ }
1461
+ } catch (error) {
1462
+ cancel = scheduleWithTimeout(callback, delay)
1463
+ }
1464
+ outstandingTimers.add(cancel)
1465
+ return () => {
1466
+ removeTimer(cancel)
1467
+ try {
1468
+ cancel()
1469
+ } catch (error) {
1470
+ // ignored
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ function removeTimer(cancel) {
1476
+ outstandingTimers.delete(cancel)
1477
+ }
1478
+
1479
+ /** Abortable sleep used only by the connection backoff loops. */
1480
+ function delay(ms, signal) {
1481
+ return new Promise((resolve) => {
1482
+ if (disposed || (signal && signal.aborted)) {
1483
+ resolve()
1484
+ return
1485
+ }
1486
+ let settled = false
1487
+ let handle = null
1488
+ const finish = () => {
1489
+ if (settled) return
1490
+ settled = true
1491
+ try {
1492
+ if (handle !== null) clearTimeout(handle)
1493
+ } catch (error) {
1494
+ // ignored
1495
+ }
1496
+ try {
1497
+ if (signal && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', finish)
1498
+ } catch (error) {
1499
+ // ignored
1500
+ }
1501
+ resolve()
1502
+ }
1503
+ try {
1504
+ handle = setTimeout(finish, ms)
1505
+ } catch (error) {
1506
+ finish()
1507
+ return
1508
+ }
1509
+ try {
1510
+ if (signal && typeof signal.addEventListener === 'function') signal.addEventListener('abort', finish, { once: true })
1511
+ } catch (error) {
1512
+ // ignored
1513
+ }
1514
+ })
1515
+ }
1516
+
1517
+ function abortSignal() {
1518
+ try {
1519
+ return abortController && abortController.signal ? abortController.signal : undefined
1520
+ } catch (error) {
1521
+ return undefined
1522
+ }
1523
+ }
1524
+
1525
+ function isAborted() {
1526
+ try {
1527
+ return disposed || (abortController && abortController.signal && abortController.signal.aborted === true)
1528
+ } catch (error) {
1529
+ return disposed
1530
+ }
1531
+ }
1532
+
1533
+ // ---- 4. Focus tracking ----
1534
+
1535
+ function isFocused() {
1536
+ return computeFocused(getDocument())
1537
+ }
1538
+
1539
+ status.focused = isFocused()
1540
+
1541
+ try {
1542
+ ctx.effect(() => {
1543
+ const target = getWindow()
1544
+ const doc = getDocument()
1545
+ const onChange = () => {
1546
+ const next = isFocused()
1547
+ if (status.focused !== next) {
1548
+ status.focused = next
1549
+ if (next) {
1550
+ // Returning to the window is the acknowledgement that
1551
+ // clears the taskbar's unread-session badge.
1552
+ try {
1553
+ clearTaskbarBadge()
1554
+ } catch (error) {
1555
+ warn('clearing the taskbar badge failed', error)
1556
+ }
1557
+ }
1558
+ emit()
1559
+ }
1560
+ }
1561
+ const cleanups = []
1562
+ if (target && typeof target.addEventListener === 'function') {
1563
+ target.addEventListener('focus', onChange)
1564
+ target.addEventListener('blur', onChange)
1565
+ cleanups.push(() => {
1566
+ try {
1567
+ target.removeEventListener('focus', onChange)
1568
+ target.removeEventListener('blur', onChange)
1569
+ } catch (error) {
1570
+ // ignored
1571
+ }
1572
+ })
1573
+ }
1574
+ if (doc && typeof doc.addEventListener === 'function') {
1575
+ doc.addEventListener('visibilitychange', onChange)
1576
+ cleanups.push(() => {
1577
+ try {
1578
+ doc.removeEventListener('visibilitychange', onChange)
1579
+ } catch (error) {
1580
+ // ignored
1581
+ }
1582
+ })
1583
+ }
1584
+ return () => {
1585
+ for (const cleanup of cleanups) {
1586
+ try {
1587
+ cleanup()
1588
+ } catch (error) {
1589
+ // ignored
1590
+ }
1591
+ }
1592
+ }
1593
+ }, 'dsh-notify-toast: focus tracking')
1594
+ } catch (error) {
1595
+ warn('cannot install focus listeners', error)
1596
+ }
1597
+
1598
+ // ---- 5. Notification permission + first-gesture audio unlock ----
1599
+
1600
+ function ensureNotificationPermission() {
1601
+ status.notificationPermission = readNotificationPermission()
1602
+ if (status.notificationPermission !== 'default') {
1603
+ emit()
1604
+ return Promise.resolve(status.notificationPermission)
1605
+ }
1606
+ const Ctor = getNotificationCtor()
1607
+ if (!Ctor) {
1608
+ status.notificationPermission = 'unsupported'
1609
+ emit()
1610
+ return Promise.resolve('unsupported')
1611
+ }
1612
+ if (permissionRequested) return Promise.resolve(status.notificationPermission)
1613
+ permissionRequested = true
1614
+ let request = null
1615
+ try {
1616
+ if (typeof Ctor.requestPermission === 'function') request = Ctor.requestPermission()
1617
+ } catch (error) {
1618
+ warn('notification permission request threw', error)
1619
+ }
1620
+ if (request && typeof request.then === 'function') {
1621
+ return Promise.resolve(request).then(
1622
+ (value) => {
1623
+ status.notificationPermission = typeof value === 'string' ? value : readNotificationPermission()
1624
+ emit()
1625
+ return status.notificationPermission
1626
+ },
1627
+ () => {
1628
+ status.notificationPermission = readNotificationPermission()
1629
+ emit()
1630
+ return status.notificationPermission
1631
+ },
1632
+ )
1633
+ }
1634
+ status.notificationPermission = readNotificationPermission()
1635
+ emit()
1636
+ return Promise.resolve(status.notificationPermission)
1637
+ }
1638
+
1639
+ /**
1640
+ * Create/resume the audio engine inside a user-gesture handler and
1641
+ * prime the pipeline with a tiny silent burst, the reliable Web Audio
1642
+ * unlock pattern in Chromium/Electron.
1643
+ */
1644
+ function unlockAudioContext() {
1645
+ const context = ensureAudioContext()
1646
+ if (!context) return
1647
+ preloadSoundAsset(context, 'bubble')
1648
+ try {
1649
+ if (typeof context.state === 'string' && context.state === 'suspended' && typeof context.resume === 'function') {
1650
+ resumeAudioContext(context)
1651
+ } else {
1652
+ setAudioState(typeof context.state === 'string' ? context.state : 'running')
1653
+ }
1654
+ if (audioMaster && typeof context.createOscillator === 'function' && typeof context.currentTime === 'number') {
1655
+ const primer = context.createOscillator()
1656
+ const silent = context.createGain()
1657
+ silent.gain.value = 0.0001
1658
+ primer.connect(silent)
1659
+ silent.connect(audioMaster)
1660
+ const now = context.currentTime
1661
+ primer.start(now)
1662
+ primer.stop(now + 0.02)
1663
+ }
1664
+ } catch (error) {
1665
+ // Silent: audio is a nicety, never a requirement.
1666
+ }
1667
+ }
1668
+
1669
+ try {
1670
+ ctx.effect(() => {
1671
+ const target = getWindow()
1672
+ if (!target || typeof target.addEventListener !== 'function') return () => {}
1673
+ let removed = false
1674
+ const cleanup = () => {
1675
+ if (removed) return
1676
+ removed = true
1677
+ try {
1678
+ target.removeEventListener('pointerdown', onPointerDown, true)
1679
+ target.removeEventListener('keydown', onKeyDown, true)
1680
+ } catch (error) {
1681
+ // ignored
1682
+ }
1683
+ }
1684
+ const gesture = () => {
1685
+ cleanup()
1686
+ void ensureNotificationPermission()
1687
+ unlockAudioContext()
1688
+ }
1689
+ const onPointerDown = () => gesture()
1690
+ const onKeyDown = () => gesture()
1691
+ target.addEventListener('pointerdown', onPointerDown, true)
1692
+ target.addEventListener('keydown', onKeyDown, true)
1693
+ return cleanup
1694
+ }, 'dsh-notify-toast: first-gesture permission and audio unlock')
1695
+ } catch (error) {
1696
+ warn('cannot install first-gesture listeners', error)
1697
+ }
1698
+
1699
+ // ---- 6. Web Audio synthesis ----
1700
+
1701
+ /** Publish the audio-engine state to the settings page (cheap, deduped). */
1702
+ function setAudioState(next) {
1703
+ if (audioState === next) return
1704
+ audioState = next
1705
+ status.audio = next
1706
+ try {
1707
+ emit()
1708
+ } catch (error) {
1709
+ // ignored: emitting before mount is harmless
1710
+ }
1711
+ }
1712
+
1713
+ function ensureAudioContext() {
1714
+ if (disposed) return null
1715
+ if (audioContext) {
1716
+ try {
1717
+ if (audioContext.state === 'closed') {
1718
+ audioContext = null
1719
+ audioMaster = null
1720
+ audioCompressor = null
1721
+ audioBuffers = Object.create(null)
1722
+ } else {
1723
+ return audioContext
1724
+ }
1725
+ } catch (error) {
1726
+ audioContext = null
1727
+ audioMaster = null
1728
+ audioCompressor = null
1729
+ audioBuffers = Object.create(null)
1730
+ }
1731
+ }
1732
+ const Ctor = getAudioConstructor()
1733
+ if (!Ctor) return null
1734
+ try {
1735
+ audioContext = new Ctor({ latencyHint: 'interactive' })
1736
+ audioMaster = audioContext.createGain()
1737
+ audioMaster.gain.value = 0
1738
+ // A gentle compressor keeps layered partials from clipping on
1739
+ // tiny laptop speakers; guarded because some test doubles omit it.
1740
+ if (typeof audioContext.createDynamicsCompressor === 'function') {
1741
+ try {
1742
+ audioCompressor = audioContext.createDynamicsCompressor()
1743
+ audioCompressor.threshold.value = -18
1744
+ audioCompressor.knee.value = 18
1745
+ audioCompressor.ratio.value = 6
1746
+ audioCompressor.attack.value = 0.003
1747
+ audioCompressor.release.value = 0.25
1748
+ audioMaster.connect(audioCompressor)
1749
+ audioCompressor.connect(audioContext.destination)
1750
+ } catch (error) {
1751
+ audioCompressor = null
1752
+ try {
1753
+ audioMaster.connect(audioContext.destination)
1754
+ } catch (nested) {
1755
+ // ignored
1756
+ }
1757
+ }
1758
+ } else {
1759
+ audioMaster.connect(audioContext.destination)
1760
+ }
1761
+ setAudioState(typeof audioContext.state === 'string' ? audioContext.state : 'running')
1762
+ return audioContext
1763
+ } catch (error) {
1764
+ warn('cannot create AudioContext', error)
1765
+ audioContext = null
1766
+ audioMaster = null
1767
+ audioCompressor = null
1768
+ return null
1769
+ }
1770
+ }
1771
+
1772
+ function closeAudioContext() {
1773
+ const context = audioContext
1774
+ audioContext = null
1775
+ audioMaster = null
1776
+ audioCompressor = null
1777
+ audioBuffers = Object.create(null)
1778
+ if (!context) return
1779
+ try {
1780
+ const closed = context.close()
1781
+ if (closed && typeof closed.catch === 'function') closed.catch(() => {})
1782
+ } catch (error) {
1783
+ // ignored
1784
+ }
1785
+ }
1786
+
1787
+ /**
1788
+ * Resume or report the audio engine. A suspended context is normal
1789
+ * before the first user gesture; the caller still schedules the
1790
+ * sound so it plays the moment the context resumes.
1791
+ */
1792
+ function resumeAudioContext(context) {
1793
+ try {
1794
+ if (!context || typeof context.resume !== 'function') return
1795
+ const resumed = context.resume()
1796
+ if (resumed && typeof resumed.then === 'function') {
1797
+ resumed.then(
1798
+ () => {
1799
+ if (!disposed) setAudioState(typeof context.state === 'string' ? context.state : 'running')
1800
+ },
1801
+ () => {
1802
+ if (!disposed) setAudioState(typeof context.state === 'string' ? context.state : 'suspended')
1803
+ },
1804
+ )
1805
+ } else {
1806
+ setAudioState(typeof context.state === 'string' ? context.state : 'running')
1807
+ }
1808
+ } catch (error) {
1809
+ // Silent: audio is a nicety, never a requirement.
1810
+ }
1811
+ }
1812
+
1813
+ /** Schedule every oscillator of a preset; throws are contained by caller. */
1814
+ function renderSoundPreset(context, preset) {
1815
+ const origin = typeof context.currentTime === 'number' ? context.currentTime : 0
1816
+ for (const note of preset.notes) {
1817
+ const startAt = origin + (note.at || 0)
1818
+ const endAt = startAt + note.duration
1819
+ for (const partial of note.partials) {
1820
+ const oscillator = context.createOscillator()
1821
+ const envelope = context.createGain()
1822
+ oscillator.type = 'sine'
1823
+ const frequency = note.frequency * partial[0]
1824
+ if (oscillator.frequency && typeof oscillator.frequency.setValueAtTime === 'function') {
1825
+ oscillator.frequency.setValueAtTime(frequency, startAt)
1826
+ }
1827
+ const peak = Math.max(0.0001, (note.peak || 0.4) * partial[1])
1828
+ const gain = envelope.gain
1829
+ if (gain) {
1830
+ if (typeof gain.setValueAtTime === 'function') gain.setValueAtTime(0.0001, startAt)
1831
+ if (typeof gain.linearRampToValueAtTime === 'function') gain.linearRampToValueAtTime(peak, startAt + 0.012)
1832
+ if (typeof gain.exponentialRampToValueAtTime === 'function') gain.exponentialRampToValueAtTime(0.0001, endAt)
1833
+ else gain.value = peak
1834
+ }
1835
+ oscillator.connect(envelope)
1836
+ envelope.connect(audioMaster)
1837
+ oscillator.start(startAt)
1838
+ oscillator.stop(endAt + 0.05)
1839
+ }
1840
+ }
1841
+ }
1842
+
1843
+ /** Decode one embedded CC0 asset in the background. */
1844
+ function preloadSoundAsset(context, id) {
1845
+ const encoded = SOUND_ASSETS[id]
1846
+ if (!encoded || audioBuffers[id] || !context) return null
1847
+ if (typeof context.decodeAudioData !== 'function' || typeof atob !== 'function') return null
1848
+ try {
1849
+ const binary = atob(encoded)
1850
+ if (!binary) return null
1851
+ const bytes = new Uint8Array(binary.length)
1852
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
1853
+ let settled = false
1854
+ return new Promise((resolve) => {
1855
+ const finish = (buffer) => {
1856
+ if (audioContext === context && buffer) audioBuffers[id] = buffer
1857
+ if (settled) return
1858
+ settled = true
1859
+ resolve()
1860
+ }
1861
+ try {
1862
+ const decoded = context.decodeAudioData(bytes.buffer, finish, () => {
1863
+ warn('decoding the embedded notification sound failed')
1864
+ finish(null)
1865
+ })
1866
+ if (decoded && typeof decoded.then === 'function') decoded.then(finish, () => finish(null))
1867
+ } catch (error) {
1868
+ warn('preparing the embedded notification sound failed', error)
1869
+ finish(null)
1870
+ }
1871
+ })
1872
+ } catch (error) {
1873
+ warn('preparing the embedded notification sound failed', error)
1874
+ return null
1875
+ }
1876
+ }
1877
+
1878
+ /** Play a decoded buffer; false means the caller should use synth fallback. */
1879
+ function playSoundAsset(context, id) {
1880
+ const buffer = audioBuffers[id]
1881
+ if (!buffer || !audioMaster || typeof context.createBufferSource !== 'function') return false
1882
+ try {
1883
+ const source = context.createBufferSource()
1884
+ source.buffer = buffer
1885
+ source.connect(audioMaster)
1886
+ source.start(0)
1887
+ return true
1888
+ } catch (error) {
1889
+ warn('playing the embedded notification sound failed', error)
1890
+ return false
1891
+ }
1892
+ }
1893
+
1894
+ /**
1895
+ * Play the configured notification sound. Always schedules the notes
1896
+ * immediately (so a suspended context plays them on resume) and kicks
1897
+ * off `resume()` without waiting — a rejected resume no longer drops
1898
+ * the sound silently.
1899
+ */
1900
+ function playNotificationSound() {
1901
+ if (disposed) return
1902
+ const assetId = typeof SOUND_ASSETS[config.sound] === 'string' ? config.sound : ''
1903
+ const preset = SOUND_PRESETS[config.sound]
1904
+ if (!preset && !assetId) return
1905
+ const volume = clampInteger(config.volume, 0, 100, DEFAULT_CONFIG.volume)
1906
+ if (volume <= 0) return
1907
+ const context = ensureAudioContext()
1908
+ if (!context || !audioMaster) return
1909
+ try {
1910
+ // Master gain = volume/100 * 0.5: clearly audible at the default
1911
+ // 60 %, still leaves headroom for the layered partials.
1912
+ audioMaster.gain.value = (volume / 100) * 0.5
1913
+ if (assetId) preloadSoundAsset(context, assetId)
1914
+ let played = false
1915
+ if (assetId) played = playSoundAsset(context, assetId)
1916
+ if (!played && preset) {
1917
+ try {
1918
+ renderSoundPreset(context, preset)
1919
+ } catch (error) {
1920
+ warn('synthesizing the notification sound failed', error)
1921
+ }
1922
+ }
1923
+ if (typeof context.state === 'string' && context.state === 'suspended') {
1924
+ resumeAudioContext(context)
1925
+ } else {
1926
+ setAudioState(typeof context.state === 'string' ? context.state : 'running')
1927
+ }
1928
+ } catch (error) {
1929
+ warn('playing the notification sound failed', error)
1930
+ }
1931
+ }
1932
+
1933
+ /** Settings-page Preview: must stay inside the click handler. */
1934
+ function previewNotificationSound() {
1935
+ try {
1936
+ unlockAudioContext()
1937
+ const assetId = typeof SOUND_ASSETS[config.sound] === 'string' ? config.sound : ''
1938
+ const context = assetId ? ensureAudioContext() : null
1939
+ const pending = context && !audioBuffers[assetId] ? preloadSoundAsset(context, assetId) : null
1940
+ if (pending && typeof pending.then === 'function') {
1941
+ pending.then(() => {
1942
+ if (!disposed) playNotificationSound()
1943
+ })
1944
+ return
1945
+ }
1946
+ playNotificationSound()
1947
+ } catch (error) {
1948
+ warn('previewing the notification sound failed', error)
1949
+ }
1950
+ }
1951
+
1952
+ try {
1953
+ ctx.effect(() => () => closeAudioContext(), 'dsh-notify-toast: audio context')
1954
+ } catch (error) {
1955
+ warn('cannot register the audio cleanup', error)
1956
+ }
1957
+
1958
+ // ---- 7. Multi-window leadership lease ----
1959
+
1960
+ function writeLeaderLease(storage) {
1961
+ try {
1962
+ storage.setItem(LEADER_STORAGE_KEY, JSON.stringify({ id: leaderLeaseId, at: Date.now() }))
1963
+ return true
1964
+ } catch (error) {
1965
+ return false
1966
+ }
1967
+ }
1968
+
1969
+ function readLeaderLeaseFrom(storage) {
1970
+ try {
1971
+ return parseLeaderLease(storage.getItem(LEADER_STORAGE_KEY))
1972
+ } catch (error) {
1973
+ return null
1974
+ }
1975
+ }
1976
+
1977
+ function setLeaderActive(next, reason) {
1978
+ if (leaderActive === next) return
1979
+ leaderActive = next
1980
+ if (next) {
1981
+ const pending = pendingActivations.splice(0)
1982
+ for (const activation of pending) {
1983
+ if (activationSeen.has(activation.key)) continue
1984
+ if (!Number.isFinite(activation.at) || Date.now() - activation.at > EVENT_MAX_AGE_MS) continue
1985
+ if (activationAlreadyClaimed(activation.key)) continue
1986
+ claimActivation(activation.key)
1987
+ activationSeen.add(activation.key)
1988
+ try {
1989
+ handleNotificationClick('activate', activation.sessionId)
1990
+ } catch (error) {
1991
+ warn('pending activation failed', error)
1992
+ }
1993
+ }
1994
+ }
1995
+ status.leader = next
1996
+ emit()
1997
+ if (!next && reason) info('another window holds the notification lease; staying silent here')
1998
+ }
1999
+
2000
+ /**
2001
+ * Claim or renew the localStorage lease. Only the lease holder shows
2002
+ * toasts and plays sounds, so N open windows do not fire N toasts.
2003
+ * Any storage failure degrades to "this window is the leader".
2004
+ */
2005
+ function refreshLeaderLease() {
2006
+ const storage = getLocalStorage()
2007
+ if (!storage) {
2008
+ setLeaderActive(true)
2009
+ return
2010
+ }
2011
+ try {
2012
+ const lease = readLeaderLeaseFrom(storage)
2013
+ if (lease && lease.id === leaderLeaseId) {
2014
+ writeLeaderLease(storage)
2015
+ setLeaderActive(true)
2016
+ return
2017
+ }
2018
+ if (lease && isFreshLease(lease)) {
2019
+ setLeaderActive(false)
2020
+ return
2021
+ }
2022
+ // Free or stale lease: take it, then confirm nobody raced us.
2023
+ writeLeaderLease(storage)
2024
+ const confirmed = readLeaderLeaseFrom(storage)
2025
+ setLeaderActive(!confirmed || confirmed.id === leaderLeaseId)
2026
+ } catch (error) {
2027
+ setLeaderActive(true)
2028
+ }
2029
+ }
2030
+
2031
+ function releaseLeaderLease() {
2032
+ if (!leaderActive) return
2033
+ const storage = getLocalStorage()
2034
+ if (!storage) return
2035
+ try {
2036
+ const lease = readLeaderLeaseFrom(storage)
2037
+ if (lease && lease.id === leaderLeaseId) storage.removeItem(LEADER_STORAGE_KEY)
2038
+ } catch (error) {
2039
+ // ignored
2040
+ }
2041
+ }
2042
+
2043
+ try {
2044
+ ctx.effect(() => {
2045
+ try {
2046
+ refreshLeaderLease()
2047
+ } catch (error) {
2048
+ warn('cannot read the leader lease', error)
2049
+ }
2050
+ const target = getWindow()
2051
+ const onStorage = (event) => {
2052
+ try {
2053
+ const key = event && typeof event.key === 'string' ? event.key : null
2054
+ if (key === null || key === LEADER_STORAGE_KEY) refreshLeaderLease()
2055
+ } catch (error) {
2056
+ // ignored
2057
+ }
2058
+ }
2059
+ if (target && typeof target.addEventListener === 'function') target.addEventListener('storage', onStorage)
2060
+ const heartbeat = setInterval(() => {
2061
+ if (disposed) return
2062
+ try {
2063
+ refreshLeaderLease()
2064
+ } catch (error) {
2065
+ warn('leader heartbeat failed', error)
2066
+ }
2067
+ }, LEADER_HEARTBEAT_MS)
2068
+ return () => {
2069
+ try {
2070
+ if (target && typeof target.removeEventListener === 'function') target.removeEventListener('storage', onStorage)
2071
+ } catch (error) {
2072
+ // ignored
2073
+ }
2074
+ try {
2075
+ clearInterval(heartbeat)
2076
+ } catch (error) {
2077
+ // ignored
2078
+ }
2079
+ releaseLeaderLease()
2080
+ }
2081
+ }, 'dsh-notify-toast: leader lease')
2082
+ } catch (error) {
2083
+ warn('cannot install the leader lease', error)
2084
+ }
2085
+
2086
+ // ---- 8. Native toast display ----
2087
+ //
2088
+ // Windows toasts are created with the Web Notification API
2089
+ // (Electron routes them to the OS). Two environments can swallow
2090
+ // them: a denied/unsupported permission, and Windows itself (banner
2091
+ // disabled, Focus Assist). The settings page exposes diagnostics.
2092
+ // Events go native by default; notificationMode selects the native
2093
+ // progress path or the compatibility path.
2094
+
2095
+ function noteNativeToastFailure(reason) {
2096
+ status.nativeToasts.failed += 1
2097
+ status.nativeToasts.lastError = String(reason === undefined ? 'unknown' : reason).slice(0, 240)
2098
+ try {
2099
+ emit()
2100
+ } catch (error) {
2101
+ // ignored
2102
+ }
2103
+ }
2104
+
2105
+ function cleanupNotification(kind, notification) {
2106
+ const record = toasts.get(kind)
2107
+ if (!record || (notification && record.notification !== notification)) return
2108
+ toasts.delete(kind)
2109
+ if (record.cancelTimer) {
2110
+ try {
2111
+ record.cancelTimer()
2112
+ } catch (error) {
2113
+ // ignored
2114
+ }
2115
+ }
2116
+ }
2117
+
2118
+ function closeNotification(kind) {
2119
+ const record = toasts.get(kind)
2120
+ if (!record) return
2121
+ toasts.delete(kind)
2122
+ if (record.cancelTimer) {
2123
+ try {
2124
+ record.cancelTimer()
2125
+ } catch (error) {
2126
+ // ignored
2127
+ }
2128
+ }
2129
+ try {
2130
+ record.notification.close()
2131
+ } catch (error) {
2132
+ // ignored
2133
+ }
2134
+ }
2135
+
2136
+ function closeAllNotifications() {
2137
+ for (const kind of Array.from(toasts.keys())) closeNotification(kind)
2138
+ }
2139
+
2140
+ /**
2141
+ * Bring DSH back to the foreground when a toast is clicked. The Host
2142
+ * `focus` endpoint raises the Electron window with user32
2143
+ * (SetForegroundWindow); this function retries it a few times and
2144
+ * records counters in the settings page, because a renderer can
2145
+ * otherwise only call `window.focus()` (which does not raise the OS
2146
+ * window).
2147
+ */
2148
+ function handleNotificationClick(kind, sessionId, notification) {
2149
+ const payload = {}
2150
+ if (typeof sessionId === 'string' && sessionId.length > 0) payload.sessionId = sessionId
2151
+ const clicks = status.nativeClicks
2152
+ clicks.clicks += 1
2153
+ try {
2154
+ emit()
2155
+ } catch (error) {
2156
+ // ignored
2157
+ }
2158
+
2159
+ // Page-level focus first; the Host call below owns the OS raise.
2160
+ try {
2161
+ const target = getWindow()
2162
+ if (target && typeof target.focus === 'function') target.focus()
2163
+ } catch (error) {
2164
+ // ignored
2165
+ }
2166
+
2167
+ // Navigate immediately: the window may already be visible.
2168
+ if (typeof sessionId === 'string' && sessionId.length > 0) {
2169
+ try {
2170
+ const workspace = typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : undefined
2171
+ if (workspace && typeof workspace.openSession === 'function') workspace.openSession(sessionId)
2172
+ } catch (error) {
2173
+ warn('opening the session failed', error)
2174
+ }
2175
+ }
2176
+
2177
+ const attemptFocus = (attempt) => {
2178
+ let task
2179
+ try {
2180
+ task = rpcCall('focus', payload)
2181
+ } catch (error) {
2182
+ clicks.focusFailed += 1
2183
+ clicks.lastError = error && error.message ? error.message : String(error)
2184
+ try {
2185
+ emit()
2186
+ } catch (ignored) {
2187
+ /* ignored */
2188
+ }
2189
+ return
2190
+ }
2191
+ Promise.resolve(task).then(
2192
+ (response) => {
2193
+ const value = businessValue(response)
2194
+ if (value && value.ok === true) {
2195
+ clicks.focusOk += 1
2196
+ clicks.lastNative = value.native === true
2197
+ try {
2198
+ emit()
2199
+ } catch (ignored) {
2200
+ /* ignored */
2201
+ }
2202
+ if (value.native !== true && attempt < 3) {
2203
+ schedule(() => attemptFocus(attempt + 1), 250)
2204
+ }
2205
+ } else {
2206
+ clicks.focusFailed += 1
2207
+ clicks.lastError = describeRpcFailure(businessFailure(response) || (isRecord(response) ? response.value : response))
2208
+ try {
2209
+ emit()
2210
+ } catch (ignored) {
2211
+ /* ignored */
2212
+ }
2213
+ if (attempt < 3) schedule(() => attemptFocus(attempt + 1), 300)
2214
+ }
2215
+ },
2216
+ (error) => {
2217
+ clicks.focusFailed += 1
2218
+ clicks.lastError = error && error.message ? error.message : String(error)
2219
+ try {
2220
+ emit()
2221
+ } catch (ignored) {
2222
+ /* ignored */
2223
+ }
2224
+ if (attempt < 3) schedule(() => attemptFocus(attempt + 1), 400)
2225
+ },
2226
+ )
2227
+ }
2228
+ attemptFocus(1)
2229
+
2230
+ // The user interacted with a Web Notification fallback: retire its
2231
+ // record. Native toast records are owned by the Host manager.
2232
+ if (notification) {
2233
+ try {
2234
+ if (typeof notification.close === 'function') notification.close()
2235
+ } catch (error) {
2236
+ // ignored
2237
+ }
2238
+ cleanupNotification(kind, notification)
2239
+ }
2240
+ }
2241
+
2242
+ /**
2243
+ * Show one native toast. Returns false when the environment cannot
2244
+ * show one (no Notification API / permission denied / constructor
2245
+ * throw); the caller then uses the compatibility Web Notification path. Never passes
2246
+ * `tag`: the plugin keeps its own close-previous map so repeated
2247
+ * kinds replace each other without platform dedupe surprises.
2248
+ */
2249
+ /** Ask the Host to show a Windows toast with a native progress bar. */
2250
+ async function showNativeProgressNotification(options) {
2251
+ try {
2252
+ const response = await rpcCall('native-notify', {
2253
+ kind: options.kind,
2254
+ title: options.title,
2255
+ body: options.body,
2256
+ sessionId: options.sessionId,
2257
+ durationMs: options.durationMs,
2258
+ progressLabel: options.progressLabel,
2259
+ }, abortSignal())
2260
+ const value = businessValue(response)
2261
+ if (!value || value.ok !== true) return { ok: false, reason: value && value.reason ? value.reason : 'helper-failed' }
2262
+ return { ok: true, superseded: value.superseded === true }
2263
+ } catch (error) {
2264
+ warn('native progress notification failed; falling back to Web Notification', error)
2265
+ return { ok: false, reason: 'rpc-failed' }
2266
+ }
2267
+ }
2268
+
2269
+ function showNativeNotification(options) {
2270
+ status.nativeToasts.attempts += 1
2271
+ const Ctor = getNotificationCtor()
2272
+ if (!Ctor) {
2273
+ noteNativeToastFailure('Notification API unavailable')
2274
+ return false
2275
+ }
2276
+ let permission = 'granted'
2277
+ try {
2278
+ permission = typeof Ctor.permission === 'string' ? Ctor.permission : 'granted'
2279
+ } catch (error) {
2280
+ permission = 'granted'
2281
+ }
2282
+ if (permission === 'denied') {
2283
+ noteNativeToastFailure('notification permission denied')
2284
+ return false
2285
+ }
2286
+ if (permission === 'default' && typeof Ctor.requestPermission === 'function') {
2287
+ // Electron's desktop shell grants non-media permissions; asking
2288
+ // here (best-effort) can turn a 'default' origin into 'granted'.
2289
+ try {
2290
+ const asked = Ctor.requestPermission()
2291
+ if (asked && typeof asked.catch === 'function') asked.catch(() => {})
2292
+ } catch (error) {
2293
+ // ignored: the constructor attempt below still reports the truth
2294
+ }
2295
+ }
2296
+ closeNotification(options.kind)
2297
+ let notification = null
2298
+ try {
2299
+ notification = new Ctor(options.title, { body: options.body || '', silent: true })
2300
+ } catch (error) {
2301
+ noteNativeToastFailure(error && error.message ? error.message : error)
2302
+ warn('cannot create the native notification', error)
2303
+ return false
2304
+ }
2305
+ status.nativeToasts.created += 1
2306
+ const record = { notification: notification, cancelTimer: null }
2307
+ toasts.set(options.kind, record)
2308
+ if (TOAST_AUTO_CLOSE_MS > 0) {
2309
+ record.cancelTimer = schedule(() => {
2310
+ closeNotification(options.kind)
2311
+ }, TOAST_AUTO_CLOSE_MS)
2312
+ }
2313
+ let clickHandled = false
2314
+ const onClick = () => {
2315
+ if (clickHandled) return
2316
+ clickHandled = true
2317
+ handleNotificationClick(options.kind, options.sessionId, notification)
2318
+ }
2319
+ try {
2320
+ notification.onshow = () => {
2321
+ status.nativeToasts.shown += 1
2322
+ try {
2323
+ emit()
2324
+ } catch (error) {
2325
+ // ignored
2326
+ }
2327
+ }
2328
+ notification.onclick = onClick
2329
+ // Electron forwards toast activation through both the property
2330
+ // and the event listener; listen to both (deduped above) because
2331
+ // only one of them fires depending on the platform path.
2332
+ if (typeof notification.addEventListener === 'function') {
2333
+ try {
2334
+ notification.addEventListener('click', onClick)
2335
+ } catch (error) {
2336
+ // ignored
2337
+ }
2338
+ }
2339
+ notification.onclose = () => cleanupNotification(options.kind, notification)
2340
+ notification.onerror = (event) => {
2341
+ const message = event && (event.message || (event.error && event.error.message))
2342
+ noteNativeToastFailure(message || 'notification error event')
2343
+ cleanupNotification(options.kind, notification)
2344
+ }
2345
+ } catch (error) {
2346
+ warn('cannot attach notification handlers', error)
2347
+ }
2348
+ try {
2349
+ emit()
2350
+ } catch (error) {
2351
+ // ignored
2352
+ }
2353
+ return true
2354
+ }
2355
+
2356
+ /** Direct native-notification probe used by the settings page. */
2357
+ function testSystemNotification() {
2358
+ const Ctor = getNotificationCtor()
2359
+ if (!Ctor) {
2360
+ status.systemTest = 'Notification API unavailable (系统不支持)'
2361
+ emit()
2362
+ return
2363
+ }
2364
+ let permission = 'unknown'
2365
+ try {
2366
+ permission = typeof Ctor.permission === 'string' ? Ctor.permission : 'unknown'
2367
+ } catch (error) {
2368
+ permission = 'unknown'
2369
+ }
2370
+ try {
2371
+ const probe = new Ctor('DSH 系统通知测试', {
2372
+ body: '这条通知由插件直接创建;点击可回到 DSH。',
2373
+ silent: true,
2374
+ })
2375
+ try {
2376
+ probe.onerror = () => {
2377
+ status.systemTest = 'permission=' + permission + ' | 系统报告失败(可能被 Windows 通知设置拦截)'
2378
+ emit()
2379
+ }
2380
+ // The probe is also a click-to-focus test: clicking it must
2381
+ // restore/foreground DSH exactly like a real notification.
2382
+ const probeClick = () => handleNotificationClick('test', undefined, probe)
2383
+ probe.onclick = probeClick
2384
+ if (typeof probe.addEventListener === 'function') {
2385
+ try {
2386
+ probe.addEventListener('click', probeClick)
2387
+ } catch (error) {
2388
+ // ignored
2389
+ }
2390
+ }
2391
+ } catch (error) {
2392
+ // ignored
2393
+ }
2394
+ status.nativeToasts.attempts += 1
2395
+ status.nativeToasts.created += 1
2396
+ status.systemTest = 'permission=' + permission + ' | 已创建(若看不到横幅,见下方说明)'
2397
+ } catch (error) {
2398
+ noteNativeToastFailure(error && error.message ? error.message : error)
2399
+ status.systemTest = 'permission=' + permission + ' | 构造失败: ' + (error && error.message ? error.message : String(error))
2400
+ }
2401
+ emit()
2402
+ }
2403
+
2404
+ // ---- 9. Connection RPC ----
2405
+
2406
+ /**
2407
+ * Invoke a Connection RPC endpoint. The service resolves through the
2408
+ * page's own origin/proxy and returns `{ok:true,value}` or
2409
+ * `{ok:false,error}`; a rejected promise means transport failure.
2410
+ */
2411
+ function rpcCall(endpoint, payload, signal) {
2412
+ return new Promise((resolve, reject) => {
2413
+ let result = null
2414
+ try {
2415
+ const connection = ctx.connection
2416
+ const rpc = connection && connection.rpc
2417
+ if (!rpc || typeof rpc.call !== 'function') {
2418
+ reject(new Error('connection.rpc is unavailable'))
2419
+ return
2420
+ }
2421
+ result = rpc.call(RPC_CHANNEL, endpoint, payload || {}, signal)
2422
+ } catch (error) {
2423
+ reject(error)
2424
+ return
2425
+ }
2426
+ Promise.resolve(result).then((value) => {
2427
+ if (isRecord(value) && typeof value.ok === 'boolean') resolve(value)
2428
+ // Defensive: accept a raw value from a Host that skips the envelope.
2429
+ else resolve({ ok: true, value: value })
2430
+ }, reject)
2431
+ })
2432
+ }
2433
+
2434
+ function setHostConnected(connected) {
2435
+ const changed = hostConnected !== connected
2436
+ hostConnected = connected
2437
+ status.hostConnected = connected
2438
+ status.connectionAttempted = true
2439
+ if (changed && connected) {
2440
+ // A recovered connection also flushes settings that failed to save.
2441
+ if (status.saveState === 'failed' || Object.keys(pendingPatch).length > 0) {
2442
+ if (!inflightSave) scheduleFlushSave(0)
2443
+ }
2444
+ }
2445
+ if (changed) emit()
2446
+ }
2447
+
2448
+ /** Absorb a `config` / `save` business value into local state. */
2449
+ function absorbHostValue(value, replaceConfig) {
2450
+ if (!isRecord(value) || value.ok === false) return
2451
+ if (replaceConfig === true && isRecord(value.config)) config = sanitizeConfig(value.config, config)
2452
+ const remoteStatus = isRecord(value.status) ? value.status : null
2453
+ if (remoteStatus) {
2454
+ if (typeof remoteStatus.settingsAvailable === 'boolean') status.settingsAvailable = remoteStatus.settingsAvailable
2455
+ if (typeof remoteStatus.nativeFocus === 'boolean') status.nativeFocus = remoteStatus.nativeFocus
2456
+ if (typeof remoteStatus.platform === 'string') status.platform = remoteStatus.platform
2457
+ if (typeof remoteStatus.transport === 'string') status.transport = remoteStatus.transport
2458
+ if (typeof remoteStatus.configSource === 'string') status.configSource = remoteStatus.configSource
2459
+ if (typeof remoteStatus.clients === 'number') status.clients = remoteStatus.clients
2460
+ // Optional in-process detection counters: display-only, all fields
2461
+ // defensive so an older Host without them simply hides the block.
2462
+ if (isRecord(remoteStatus.stats)) {
2463
+ status.stats = {
2464
+ questions: numberOr(remoteStatus.stats.questions, 0),
2465
+ approvals: numberOr(remoteStatus.stats.approvals, 0),
2466
+ completions: numberOr(remoteStatus.stats.completions, 0),
2467
+ merged: numberOr(remoteStatus.stats.merged, 0),
2468
+ lastEventAt: typeof remoteStatus.stats.lastEventAt === 'number' ? remoteStatus.stats.lastEventAt : null,
2469
+ }
2470
+ }
2471
+ if (remoteStatus.lastEvent === null) {
2472
+ status.lastEvent = null
2473
+ } else if (isRecord(remoteStatus.lastEvent)) {
2474
+ status.lastEvent = {
2475
+ kind: typeof remoteStatus.lastEvent.kind === 'string' ? remoteStatus.lastEvent.kind : '',
2476
+ at: typeof remoteStatus.lastEvent.at === 'number' ? remoteStatus.lastEvent.at : null,
2477
+ }
2478
+ }
2479
+ }
2480
+ if (typeof value.version === 'string') status.version = value.version
2481
+ status.loaded = true
2482
+ }
2483
+
2484
+ /**
2485
+ * Re-read `config` when the settings page mounts (and after a save
2486
+ * failure) so the displayed runtime counters are current. Never clears
2487
+ * optimistic local edits that are still on their way to the Host.
2488
+ */
2489
+ function refreshConfig() {
2490
+ if (disposed) return
2491
+ rpcCall('config', {}, abortSignal()).then(
2492
+ (response) => {
2493
+ if (disposed) return
2494
+ const value = businessValue(response)
2495
+ if (!value) return
2496
+ const canReplaceConfig = !inflightSave && Object.keys(pendingPatch).length === 0
2497
+ absorbHostValue(value, canReplaceConfig)
2498
+ setHostConnected(true)
2499
+ emit()
2500
+ },
2501
+ (error) => {
2502
+ if (!disposed) warn('refreshing the runtime status failed', error)
2503
+ },
2504
+ )
2505
+ }
2506
+
2507
+ async function loadConfigPhase() {
2508
+ let backoff = BACKOFF_MIN_MS
2509
+ while (!disposed) {
2510
+ try {
2511
+ const response = await rpcCall('config', {}, abortSignal())
2512
+ if (isAborted()) return
2513
+ const value = businessValue(response)
2514
+ if (!value) {
2515
+ throw new Error('config failed: ' + describeRpcFailure(isRecord(response) ? response.value : response))
2516
+ }
2517
+ absorbHostValue(value, true)
2518
+ configLoaded = true
2519
+ setHostConnected(true)
2520
+ emit()
2521
+ return
2522
+ } catch (error) {
2523
+ if (isAborted()) return
2524
+ setHostConnected(false)
2525
+ emit()
2526
+ warn('loading the notification config failed; retrying', error)
2527
+ await delay(backoff, abortSignal())
2528
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS)
2529
+ }
2530
+ }
2531
+ }
2532
+
2533
+ function applyPollValue(value) {
2534
+ if (!isRecord(value)) return
2535
+ const events = Array.isArray(value.events) ? value.events : []
2536
+ if (typeof value.next === 'number' && Number.isFinite(value.next)) {
2537
+ cursor = value.next
2538
+ } else if (events.length > 0) {
2539
+ // Defensive cursor advance for a Host that omits `next`: never
2540
+ // re-deliver the same batch on the following poll.
2541
+ let highestId = null
2542
+ for (const event of events) {
2543
+ if (isRecord(event) && typeof event.id === 'number' && Number.isFinite(event.id)) {
2544
+ highestId = highestId === null ? event.id : Math.max(highestId, event.id)
2545
+ }
2546
+ }
2547
+ if (highestId !== null) cursor = Math.max(cursor, highestId + 1)
2548
+ }
2549
+ if (!events.length) return
2550
+ for (const event of events) {
2551
+ try {
2552
+ handleEvent(event)
2553
+ } catch (error) {
2554
+ warn('handling a notification event failed', error)
2555
+ }
2556
+ }
2557
+ }
2558
+
2559
+ async function pollLoop() {
2560
+ let backoff = BACKOFF_MIN_MS
2561
+ while (!disposed) {
2562
+ try {
2563
+ const response = await rpcCall('poll', { after: cursor, wait: POLL_WAIT_MS }, abortSignal())
2564
+ if (isAborted()) return
2565
+ const value = businessValue(response)
2566
+ if (!value) {
2567
+ throw new Error('poll failed: ' + describeRpcFailure(isRecord(response) ? response.value : response))
2568
+ }
2569
+ backoff = BACKOFF_MIN_MS
2570
+ setHostConnected(true)
2571
+ applyPollValue(value)
2572
+ } catch (error) {
2573
+ if (isAborted()) return
2574
+ setHostConnected(false)
2575
+ warn('notification poll failed; backing off', error)
2576
+ await delay(backoff, abortSignal())
2577
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS)
2578
+ }
2579
+ }
2580
+ }
2581
+
2582
+ async function runConnectionLoop() {
2583
+ await loadConfigPhase()
2584
+ if (disposed) return
2585
+ await pollLoop()
2586
+ }
2587
+
2588
+ function startConnection() {
2589
+ abortController = createAbortController()
2590
+ runConnectionLoop().catch((error) => {
2591
+ if (!disposed) warn('connection loop stopped unexpectedly', error)
2592
+ })
2593
+ }
2594
+
2595
+ // ---- 10. Notification event processing ----
2596
+
2597
+ function isFreshEvent(event) {
2598
+ if (!isRecord(event)) return true
2599
+ if (typeof event.at !== 'number' || !Number.isFinite(event.at)) return true
2600
+ return Date.now() - event.at <= EVENT_MAX_AGE_MS
2601
+ }
2602
+
2603
+ function isEventKind(kind) {
2604
+ return kind === 'question' || kind === 'approval' || kind === 'complete' || kind === 'test' || kind === 'activate'
2605
+ }
2606
+
2607
+ function shouldPlaySound() {
2608
+ if (!leaderActive) return false
2609
+ if (!config || config.sound === 'none') return false
2610
+ return clampInteger(config.volume, 0, 100, 0) > 0
2611
+ }
2612
+
2613
+ /**
2614
+ * Tell the Host how many distinct root sessions have unread
2615
+ * completed work. The Host owns the Windows taskbar overlay; this
2616
+ * client only decides *when* the badge is meaningful.
2617
+ */
2618
+ function postTaskbarBadge(count) {
2619
+ const seq = ++badgeSequence
2620
+ badgeLastCount = count
2621
+ rpcCall('taskbar-badge', { count: count }, abortSignal()).then(
2622
+ (response) => {
2623
+ if (seq !== badgeSequence || disposed) return
2624
+ const value = businessValue(response)
2625
+ if (!value) {
2626
+ badgeLastCount = -1
2627
+ return
2628
+ }
2629
+ if (typeof value.count === 'number') badgeLastCount = value.count
2630
+ },
2631
+ () => {
2632
+ if (seq === badgeSequence) badgeLastCount = -1
2633
+ },
2634
+ )
2635
+ }
2636
+
2637
+ /** Reconcile the taskbar badge with the current config and unread set. */
2638
+ function syncTaskbarBadge() {
2639
+ if (disposed) return
2640
+ if (config.taskbarBadgeEnabled !== true) unreadSessions.clear()
2641
+ const count = config.taskbarBadgeEnabled === true ? unreadSessions.size : 0
2642
+ if (count === badgeLastCount) return
2643
+ postTaskbarBadge(count)
2644
+ }
2645
+
2646
+ /** Drop the unread set and clear the badge after focus/click. */
2647
+ function clearTaskbarBadge() {
2648
+ if (disposed) return
2649
+ if (unreadSessions.size === 0 && badgeLastCount === 0) return
2650
+ unreadSessions.clear()
2651
+ postTaskbarBadge(0)
2652
+ }
2653
+
2654
+ /** Returns true when the event was turned into a toast/sound. */
2655
+ function handleEvent(event) {
2656
+ if (!isRecord(event)) return false
2657
+ const kind = typeof event.kind === 'string' ? event.kind : ''
2658
+ if (!isEventKind(kind)) return false
2659
+ // A page that was closed when the event fired should not replay old
2660
+ // toasts/activations from the Host's ring buffer on load.
2661
+ if (!isFreshEvent(event)) return false
2662
+ if (kind === 'activate') {
2663
+ const sessionId = typeof event.sessionId === 'string' && event.sessionId.length > 0 ? event.sessionId : undefined
2664
+ if (!sessionId) return false
2665
+ const activationKey = String(event.id === undefined ? '' : event.id) + '|' + sessionId
2666
+ if (activationSeen.has(activationKey)) return true
2667
+ if (!leaderActive) {
2668
+ // A non-leader window may consume the event first. Keep it
2669
+ // until this window acquires the lease, then open once.
2670
+ if (!pendingActivations.some((entry) => entry.key === activationKey)) {
2671
+ pendingActivations.push({ key: activationKey, sessionId, at: Date.now() })
2672
+ while (pendingActivations.length > 20) pendingActivations.shift()
2673
+ }
2674
+ return true
2675
+ }
2676
+ if (activationAlreadyClaimed(activationKey)) return true
2677
+ claimActivation(activationKey)
2678
+ activationSeen.add(activationKey)
2679
+ while (activationSeen.size > 200) {
2680
+ const first = activationSeen.values().next()
2681
+ if (first.done) break
2682
+ activationSeen.delete(first.value)
2683
+ }
2684
+ handleNotificationClick('activate', sessionId)
2685
+ return true
2686
+ }
2687
+ const isTest = kind === 'test'
2688
+
2689
+ if (!isTest) {
2690
+ if (config.enabled !== true) return false
2691
+ if (kind === 'question' && config.questionEnabled !== true) return false
2692
+ if (kind === 'approval' && config.approvalEnabled !== true) return false
2693
+ if (kind === 'complete' && config.completionEnabled !== true) return false
2694
+ // Focus is evaluated at event time, never cached at boot.
2695
+ if (isFocused()) return false
2696
+ }
2697
+
2698
+ // Only the lease holder speaks for the profile; other windows stay
2699
+ // silent so one event never becomes N native toasts.
2700
+ if (!leaderActive) return false
2701
+
2702
+ const sessionId = typeof event.sessionId === 'string' && event.sessionId.length > 0 ? event.sessionId : undefined
2703
+ if (!isTest && kind === 'complete' && config.taskbarBadgeEnabled === true) {
2704
+ // Scheme B: one unread unit per root session, not per turn.
2705
+ // A merged completion carries every distinct session in
2706
+ // event.sessionIds; a single completion carries sessionId.
2707
+ const badgeSessions = Array.isArray(event.sessionIds) && event.sessionIds.length > 0
2708
+ ? event.sessionIds
2709
+ : [sessionId || ('event:' + String(event.id === undefined ? 'unknown' : event.id))]
2710
+ for (const badgeSession of badgeSessions) {
2711
+ if (typeof badgeSession === 'string' && badgeSession.length > 0) unreadSessions.add(badgeSession)
2712
+ }
2713
+ syncTaskbarBadge()
2714
+ }
2715
+ const title = buildNotificationTitle(kind, event, config, t)
2716
+ const body = buildNotificationBody(kind, event, config, t)
2717
+ const notificationOptions = { kind: kind, title: title, body: body, sessionId: sessionId }
2718
+ const playSoundIfAllowed = () => {
2719
+ if (!disposed && shouldPlaySound()) playNotificationSound()
2720
+ }
2721
+ if (config.notificationMode === 'native-progress') {
2722
+ // Play the sound only after the helper reports that Windows
2723
+ // accepted the toast, so popup and sound stay together.
2724
+ showNativeProgressNotification({
2725
+ ...notificationOptions,
2726
+ durationMs: config.nativeDurationMs,
2727
+ progressLabel: t('nativeProgressCaption'),
2728
+ }).then((result) => {
2729
+ if (!result || result.superseded) return
2730
+ if (result.ok) {
2731
+ playSoundIfAllowed()
2732
+ return
2733
+ }
2734
+ if (disposed || (!isTest && isFocused()) || !leaderActive) return
2735
+ showNativeNotification(notificationOptions)
2736
+ playSoundIfAllowed()
2737
+ })
2738
+ } else {
2739
+ showNativeNotification(notificationOptions)
2740
+ playSoundIfAllowed()
2741
+ }
2742
+ return true
2743
+ }
2744
+
2745
+ // ---- 11. Settings persistence ----
2746
+
2747
+ function mergePendingPatch(patch) {
2748
+ for (const key of Object.keys(patch)) {
2749
+ if (!Object.prototype.hasOwnProperty.call(pendingPatch, key)) pendingPatch[key] = patch[key]
2750
+ }
2751
+ }
2752
+
2753
+ function scheduleFlushSave(delayMs) {
2754
+ if (disposed) return
2755
+ if (saveTimerCancel) {
2756
+ try {
2757
+ saveTimerCancel()
2758
+ } catch (error) {
2759
+ // ignored
2760
+ }
2761
+ saveTimerCancel = null
2762
+ }
2763
+ saveTimerCancel = schedule(() => {
2764
+ saveTimerCancel = null
2765
+ flushSave()
2766
+ }, delayMs)
2767
+ }
2768
+
2769
+ function updateConfig(patch) {
2770
+ if (disposed) return
2771
+ const sanitized = sanitizePatch(patch)
2772
+ const keys = Object.keys(sanitized)
2773
+ if (!keys.length) return
2774
+ config = sanitizeConfig(Object.assign({}, config, sanitized), config)
2775
+ for (const key of keys) pendingPatch[key] = config[key]
2776
+ emit()
2777
+ if (Object.prototype.hasOwnProperty.call(sanitized, 'taskbarBadgeEnabled')) {
2778
+ if (config.taskbarBadgeEnabled === true) syncTaskbarBadge()
2779
+ else clearTaskbarBadge()
2780
+ }
2781
+ scheduleFlushSave(SAVE_DEBOUNCE_MS)
2782
+ }
2783
+
2784
+ function flushSave() {
2785
+ if (disposed || inflightSave) return
2786
+ const patch = pendingPatch
2787
+ pendingPatch = {}
2788
+ if (!Object.keys(patch).length) return
2789
+ inflightSave = true
2790
+ status.saveState = 'saving'
2791
+ emit()
2792
+ rpcCall('save', { patch: patch }, abortSignal()).then(
2793
+ (response) => {
2794
+ inflightSave = false
2795
+ if (disposed) return
2796
+ const value = businessValue(response)
2797
+ if (value) {
2798
+ setHostConnected(true)
2799
+ absorbHostValue(value, Object.keys(pendingPatch).length === 0)
2800
+ status.saveState = 'saved'
2801
+ emit()
2802
+ if (savedFeedbackTimerCancel) savedFeedbackTimerCancel()
2803
+ savedFeedbackTimerCancel = schedule(() => {
2804
+ if (status.saveState === 'saved') {
2805
+ status.saveState = 'idle'
2806
+ emit()
2807
+ }
2808
+ }, 4000)
2809
+ if (Object.keys(pendingPatch).length > 0) scheduleFlushSave(SAVE_DEBOUNCE_MS)
2810
+ } else {
2811
+ // A Host-side rejection (invalid patch / settings-unavailable)
2812
+ // arrives as a transport success with a failing business value.
2813
+ const failure = businessFailure(response)
2814
+ const errorCode = failure && isRecord(failure.error) && typeof failure.error.code === 'string' ? failure.error.code : ''
2815
+ if (errorCode === 'unknown-field') {
2816
+ // Old Host schema: drop only the new fields, keep old settings
2817
+ // retryable, and hide the new controls behind a restart note.
2818
+ advancedHostUnsupported = true
2819
+ let removedAdvanced = false
2820
+ for (const key of ADVANCED_CONFIG_KEYS) {
2821
+ if (Object.prototype.hasOwnProperty.call(patch, key)) {
2822
+ delete patch[key]
2823
+ removedAdvanced = true
2824
+ }
2825
+ }
2826
+ if (Object.keys(patch).length > 0) {
2827
+ mergePendingPatch(patch)
2828
+ // Retry the old fields on their own; the failed mixed
2829
+ // patch must not strand unrelated settings.
2830
+ if (removedAdvanced) scheduleFlushSave(SAVE_DEBOUNCE_MS)
2831
+ }
2832
+ status.saveState = 'failed'
2833
+ emit()
2834
+ warn('the Host does not support the new notification settings; restart DSH to enable them')
2835
+ } else {
2836
+ mergePendingPatch(patch)
2837
+ status.saveState = 'failed'
2838
+ emit()
2839
+ warn('the Host rejected the settings patch: ' + describeRpcFailure(failure || (isRecord(response) ? response.value : response)))
2840
+ }
2841
+ }
2842
+ },
2843
+ (error) => {
2844
+ inflightSave = false
2845
+ if (disposed) return
2846
+ mergePendingPatch(patch)
2847
+ status.saveState = 'failed'
2848
+ hostConnected = false
2849
+ status.hostConnected = false
2850
+ status.connectionAttempted = true
2851
+ emit()
2852
+ warn('saving settings failed', error)
2853
+ },
2854
+ )
2855
+ }
2856
+
2857
+ // ---- 12. Test notification ----
2858
+
2859
+ function showLocalTestFallback() {
2860
+ const title = buildNotificationTitle('test', t)
2861
+ const body = config.titleOnly === true ? '' : buildNotificationBody('test', {}, config, t)
2862
+ showNativeNotification({ kind: 'test', title: title, body: body, sessionId: undefined })
2863
+ if (shouldPlaySound()) playNotificationSound()
2864
+ }
2865
+
2866
+ function runTestNotification() {
2867
+ ensureNotificationPermission()
2868
+ return rpcCall('test', {}, abortSignal()).then(
2869
+ (response) => {
2870
+ if (disposed) return false
2871
+ const value = businessValue(response)
2872
+ if (value) {
2873
+ setHostConnected(true)
2874
+ status.testState = 'sent'
2875
+ emit()
2876
+ if (savedFeedbackTimerCancel) savedFeedbackTimerCancel()
2877
+ savedFeedbackTimerCancel = schedule(() => {
2878
+ if (status.testState === 'sent') {
2879
+ status.testState = 'idle'
2880
+ emit()
2881
+ }
2882
+ }, 5000)
2883
+ return true
2884
+ }
2885
+ throw new Error(describeRpcFailure(businessFailure(response) || (isRecord(response) ? response.value : response)))
2886
+ },
2887
+ (error) => {
2888
+ throw error
2889
+ },
2890
+ ).catch((error) => {
2891
+ if (disposed) return false
2892
+ warn('test notification request failed; showing a local fallback', error)
2893
+ status.testState = 'failed'
2894
+ emit()
2895
+ showLocalTestFallback()
2896
+ return false
2897
+ })
2898
+ }
2899
+
2900
+ function triggerTestNotification() {
2901
+ if (disposed) return
2902
+ if (status.testState === 'sending') return
2903
+ // This click is the user gesture: unlock the audio engine now, so
2904
+ // the sound that follows the Host round-trip is audible.
2905
+ try {
2906
+ unlockAudioContext()
2907
+ } catch (error) {
2908
+ // ignored: audio is best-effort
2909
+ }
2910
+ status.testState = 'sending'
2911
+ emit()
2912
+ void runTestNotification()
2913
+ }
2914
+
2915
+ // ---- 13. Runtime teardown (registered last so it unwinds first) ----
2916
+
2917
+ try {
2918
+ ctx.effect(() => () => {
2919
+ try {
2920
+ clearTaskbarBadge()
2921
+ } catch (error) {
2922
+ // best-effort teardown
2923
+ }
2924
+ disposed = true
2925
+ try {
2926
+ if (abortController) abortController.abort()
2927
+ } catch (error) {
2928
+ // ignored
2929
+ }
2930
+ abortController = null
2931
+ if (saveTimerCancel) {
2932
+ try {
2933
+ saveTimerCancel()
2934
+ } catch (error) {
2935
+ // ignored
2936
+ }
2937
+ saveTimerCancel = null
2938
+ }
2939
+ if (savedFeedbackTimerCancel) {
2940
+ try {
2941
+ savedFeedbackTimerCancel()
2942
+ } catch (error) {
2943
+ // ignored
2944
+ }
2945
+ savedFeedbackTimerCancel = null
2946
+ }
2947
+ for (const cancel of Array.from(outstandingTimers)) {
2948
+ try {
2949
+ cancel()
2950
+ } catch (error) {
2951
+ // ignored
2952
+ }
2953
+ }
2954
+ outstandingTimers.clear()
2955
+ closeAllNotifications()
2956
+ closeAudioContext()
2957
+ releaseLeaderLease()
2958
+ listeners.clear()
2959
+ pendingPatch = {}
2960
+ configLoaded = false
2961
+ }, 'dsh-notify-toast: runtime teardown')
2962
+ } catch (error) {
2963
+ warn('cannot register the runtime teardown', error)
2964
+ }
2965
+
2966
+ // ---- 14. Settings page ----
2967
+
2968
+ function useRuntimeSnapshot() {
2969
+ const [snapshot, setSnapshot] = useState(snapshotState)
2970
+ useEffect(() => {
2971
+ let alive = true
2972
+ const off = subscribe((next) => {
2973
+ if (alive) setSnapshot(next)
2974
+ })
2975
+ // A locale switch must re-render mounted section copy (the slot
2976
+ // shell only re-reads the nav label thunk).
2977
+ let offLocale = null
2978
+ try {
2979
+ if (ctx.locale && typeof ctx.locale.subscribe === 'function') {
2980
+ offLocale = ctx.locale.subscribe(() => {
2981
+ if (alive) setSnapshot(snapshotState())
2982
+ })
2983
+ }
2984
+ } catch (error) {
2985
+ warn('cannot subscribe to locale changes', error)
2986
+ }
2987
+ // Repair a frame lost between render and subscription.
2988
+ setSnapshot(snapshotState())
2989
+ return () => {
2990
+ alive = false
2991
+ try {
2992
+ off()
2993
+ } catch (error) {
2994
+ // ignored
2995
+ }
2996
+ try {
2997
+ if (typeof offLocale === 'function') offLocale()
2998
+ } catch (error) {
2999
+ // ignored
3000
+ }
3001
+ }
3002
+ }, [])
3003
+ return snapshot
3004
+ }
3005
+
3006
+ function permissionLabel(permission) {
3007
+ if (permission === 'granted') return t('valueGranted')
3008
+ if (permission === 'denied') return t('valueDenied')
3009
+ if (permission === 'unsupported') return t('valueUnsupported')
3010
+ if (permission === 'default') return t('valueDefault')
3011
+ return t('valueUnknown')
3012
+ }
3013
+
3014
+ function booleanLabel(value, trueKey, falseKey) {
3015
+ if (value === true) return t(trueKey)
3016
+ if (value === false) return t(falseKey)
3017
+ return t('valueUnknown')
3018
+ }
3019
+
3020
+ function renderSettingsView() {
3021
+ const view = useRuntimeSnapshot()
3022
+ const settings = view.config
3023
+ const info = view.status
3024
+ const advancedSupported = hostSupportsAdvancedSettings(info.version) && !advancedHostUnsupported
3025
+ const nativeInfo = isRecord(info.nativeToasts)
3026
+ ? info.nativeToasts
3027
+ : { attempts: 0, created: 0, shown: 0, failed: 0, lastError: null }
3028
+ const nativeClicks = isRecord(info.nativeClicks)
3029
+ ? info.nativeClicks
3030
+ : { clicks: 0, focusOk: 0, focusFailed: 0, lastError: null }
3031
+
3032
+ // Refresh the Host's optional runtime counters whenever the page
3033
+ // mounts, so opening Settings is itself a health check.
3034
+ useEffect(() => {
3035
+ refreshConfig()
3036
+ }, [])
3037
+
3038
+ const saveText =
3039
+ info.saveState === 'saving'
3040
+ ? t('saving')
3041
+ : info.saveState === 'saved'
3042
+ ? t('saved')
3043
+ : info.saveState === 'failed'
3044
+ ? t('saveFailed')
3045
+ : ''
3046
+
3047
+ const transportLabel =
3048
+ info.transport === 'channel'
3049
+ ? t('valueTransportChannel')
3050
+ : info.transport === 'route'
3051
+ ? t('valueTransportRoute')
3052
+ : t('valueTransportNone')
3053
+ const audioLabel =
3054
+ info.audio === 'running'
3055
+ ? t('valueAudioReady')
3056
+ : info.audio === 'suspended'
3057
+ ? t('valueAudioSuspended')
3058
+ : t('valueAudioUninitialized')
3059
+ const statusRows = [
3060
+ { key: 'permission', label: t('statusPermission'), value: permissionLabel(info.notificationPermission) },
3061
+ { key: 'focus', label: t('statusFocus'), value: info.focused ? t('valueFocused') : t('valueUnfocused') },
3062
+ { key: 'host', label: t('statusHost'), value: info.hostConnected ? t('valueConnected') : t('valueDisconnected') },
3063
+ { key: 'transport', label: t('statusTransport'), value: transportLabel },
3064
+ { key: 'audio', label: t('statusAudio'), value: audioLabel },
3065
+ {
3066
+ key: 'native',
3067
+ label: t('statusNativeFocus'),
3068
+ value: booleanLabel(info.nativeFocus, 'valueAvailable', 'valueUnavailable'),
3069
+ },
3070
+ {
3071
+ key: 'settings',
3072
+ label: t('statusSettings'),
3073
+ value: booleanLabel(info.settingsAvailable, 'valueAvailable', 'valueUnavailable'),
3074
+ },
3075
+ ]
3076
+
3077
+ const soundOptions = SOUND_IDS.map((id) => ({ value: id, label: t(SOUND_LABEL_KEYS[id]) }))
3078
+
3079
+ // Optional detection counters from the Host (`status.stats`). Older
3080
+ // Hosts omit them; the block then simply does not render.
3081
+ const runtimeBlock = isRecord(info.stats)
3082
+ ? (() => {
3083
+ const stats = info.stats
3084
+ const lastEvent = isRecord(info.lastEvent) ? info.lastEvent : null
3085
+ const lastKindKey =
3086
+ lastEvent && lastEvent.kind === 'question'
3087
+ ? 'kindQuestion'
3088
+ : lastEvent && lastEvent.kind === 'approval'
3089
+ ? 'kindApproval'
3090
+ : lastEvent && lastEvent.kind === 'complete'
3091
+ ? 'kindComplete'
3092
+ : lastEvent && lastEvent.kind === 'test'
3093
+ ? 'kindTest'
3094
+ : ''
3095
+ const lastAt = lastEvent && typeof lastEvent.at === 'number' ? lastEvent.at : stats.lastEventAt
3096
+ const lastValue = lastKindKey
3097
+ ? t(lastKindKey) + (Number.isFinite(lastAt) ? ' ' + formatClockTime(lastAt) : '')
3098
+ : t('valueUnknown')
3099
+ const merged = numberOr(stats.merged, 0)
3100
+ return h(
3101
+ 'div',
3102
+ { key: 'runtime', style: styles.statusBlock },
3103
+ h('div', { style: styles.statusTitle }, t('runtimeTitle')),
3104
+ h(StatusRow, {
3105
+ label: t('statsQuestions') + ' / ' + t('statsApprovals') + ' / ' + t('statsCompletions'),
3106
+ value:
3107
+ String(numberOr(stats.questions, 0)) +
3108
+ ' / ' +
3109
+ String(numberOr(stats.approvals, 0)) +
3110
+ ' / ' +
3111
+ String(numberOr(stats.completions, 0)) +
3112
+ (merged > 0 ? ' (' + t('statsMerged') + ' ' + String(merged) + ')' : ''),
3113
+ }),
3114
+ h(StatusRow, { label: t('statsClients'), value: String(numberOr(info.clients, 0)) }),
3115
+ h(StatusRow, { label: t('statsLast'), value: lastValue }),
3116
+ )
3117
+ })()
3118
+ : null
3119
+
3120
+ const permissionHint =
3121
+ info.notificationPermission === 'denied'
3122
+ ? h('div', { key: 'permission-denied', style: styles.notice }, t('permissionDeniedHint'))
3123
+ : info.notificationPermission === 'default'
3124
+ ? h(
3125
+ 'div',
3126
+ { key: 'permission-request', style: styles.notice },
3127
+ h('span', { style: styles.hint }, t('permissionAskHint')),
3128
+ h(
3129
+ 'button',
3130
+ {
3131
+ type: 'button',
3132
+ style: Object.assign({}, styles.buttonGhost, { marginTop: '8px' }),
3133
+ onClick: () => {
3134
+ void ensureNotificationPermission()
3135
+ },
3136
+ },
3137
+ t('requestPermission'),
3138
+ ),
3139
+ )
3140
+ : null
3141
+
3142
+ const testFeedback =
3143
+ info.testState === 'sent'
3144
+ ? h('span', { style: styles.okText }, t('testSent'))
3145
+ : info.testState === 'failed'
3146
+ ? h('span', { style: styles.errorText }, t('testFailed') + ' ' + t('testLocalFallback'))
3147
+ : null
3148
+
3149
+ return h(
3150
+ 'div',
3151
+ { style: styles.page },
3152
+ h(
3153
+ 'div',
3154
+ { style: styles.header },
3155
+ h('h2', { style: styles.title }, t('pageTitle')),
3156
+ h('p', { style: styles.intro }, t('pageIntro')),
3157
+ ),
3158
+ !info.hostConnected
3159
+ ? h('div', { style: styles.notice }, info.connectionAttempted ? t('hostDegraded') : t('loading'))
3160
+ : null,
3161
+ h(
3162
+ 'div',
3163
+ { style: styles.card },
3164
+ h(SwitchRow, {
3165
+ label: t('enable'),
3166
+ hint: t('enableHint'),
3167
+ checked: settings.enabled,
3168
+ onChange: (next) => updateConfig({ enabled: next }),
3169
+ }),
3170
+ h('hr', { style: styles.divider }),
3171
+ h(SwitchRow, {
3172
+ label: t('questionEnabled'),
3173
+ hint: t('questionEnabledHint'),
3174
+ checked: settings.questionEnabled,
3175
+ onChange: (next) => updateConfig({ questionEnabled: next }),
3176
+ }),
3177
+ h('hr', { style: styles.divider }),
3178
+ h(SwitchRow, {
3179
+ label: t('approvalEnabled'),
3180
+ hint: t('approvalEnabledHint'),
3181
+ checked: settings.approvalEnabled,
3182
+ onChange: (next) => updateConfig({ approvalEnabled: next }),
3183
+ }),
3184
+ h('hr', { style: styles.divider }),
3185
+ h(SwitchRow, {
3186
+ label: t('completionEnabled'),
3187
+ hint: t('completionEnabledHint'),
3188
+ checked: settings.completionEnabled,
3189
+ onChange: (next) => updateConfig({ completionEnabled: next }),
3190
+ }),
3191
+ advancedSupported
3192
+ ? h(SwitchRow, {
3193
+ label: t('goalSummaryEnabled'),
3194
+ hint: t('goalSummaryEnabledHint'),
3195
+ checked: settings.goalSummaryEnabled,
3196
+ onChange: (next) => updateConfig({ goalSummaryEnabled: next }),
3197
+ })
3198
+ : null,
3199
+ advancedSupported
3200
+ ? h(SwitchRow, {
3201
+ label: t('taskbarBadge'),
3202
+ hint: t('taskbarBadgeHint'),
3203
+ checked: settings.taskbarBadgeEnabled,
3204
+ onChange: (next) => updateConfig({ taskbarBadgeEnabled: next }),
3205
+ })
3206
+ : null,
3207
+ h('hr', { style: styles.divider }),
3208
+ h(SwitchRow, {
3209
+ label: t('allDoneTitleEnabled'),
3210
+ hint: t('allDoneTitleEnabledHint'),
3211
+ checked: settings.allDoneTitleEnabled,
3212
+ onChange: (next) => updateConfig({ allDoneTitleEnabled: next }),
3213
+ }),
3214
+ h(TextRow, {
3215
+ idPart: 'all-done-title',
3216
+ label: t('allDoneTitle'),
3217
+ hint: t('allDoneTitleHint'),
3218
+ value: settings.allDoneTitle,
3219
+ maxLength: 60,
3220
+ disabled: !settings.allDoneTitleEnabled,
3221
+ onChange: (next) => updateConfig({ allDoneTitle: next }),
3222
+ }),
3223
+ h('hr', { style: styles.divider }),
3224
+ advancedSupported
3225
+ ? h(
3226
+ 'div',
3227
+ {},
3228
+ h(SelectRow, {
3229
+ idPart: 'notification-mode',
3230
+ label: t('notificationMode'),
3231
+ hint: t('notificationModeHint'),
3232
+ value: normalizeNotificationMode(settings.notificationMode) || 'native-progress',
3233
+ options: [
3234
+ { value: 'native-progress', label: t('notificationModeNativeProgress') },
3235
+ { value: 'native', label: t('notificationModeNative') },
3236
+ ],
3237
+ onChange: (next) => updateConfig({ notificationMode: next }),
3238
+ }),
3239
+ h('div', { style: styles.hint }, t('notificationDurationNotice')),
3240
+ h(RangeRow, {
3241
+ idPart: 'native-duration',
3242
+ label: t('nativeDuration'),
3243
+ hint: t('nativeDurationHint'),
3244
+ value: Math.round(numberOr(settings.nativeDurationMs, DEFAULT_CONFIG.nativeDurationMs) / 1000),
3245
+ min: 3,
3246
+ max: 30,
3247
+ step: 1,
3248
+ unit: t('seconds'),
3249
+ onChange: (next) => updateConfig({ nativeDurationMs: next * 1000 }),
3250
+ }),
3251
+ )
3252
+ : h('div', { style: styles.hint }, t('restartForNewSettings')),
3253
+ h('hr', { style: styles.divider }),
3254
+ h(RangeRow, {
3255
+ idPart: 'body-length',
3256
+ label: t('bodyLength'),
3257
+ hint: t('bodyLengthHint'),
3258
+ value: settings.bodyLength,
3259
+ min: 20,
3260
+ max: 60,
3261
+ step: 1,
3262
+ unit: t('characters'),
3263
+ onChange: (next) => updateConfig({ bodyLength: next }),
3264
+ }),
3265
+ h('hr', { style: styles.divider }),
3266
+ h(SwitchRow, {
3267
+ label: t('titleOnly'),
3268
+ hint: t('titleOnlyHint'),
3269
+ checked: settings.titleOnly,
3270
+ onChange: (next) => updateConfig({ titleOnly: next }),
3271
+ }),
3272
+ h('hr', { style: styles.divider }),
3273
+ h(SelectRow, {
3274
+ idPart: 'sound',
3275
+ label: t('sound'),
3276
+ value: settings.sound,
3277
+ options: soundOptions,
3278
+ onChange: (next) => {
3279
+ updateConfig({ sound: next })
3280
+ // The change event is a user gesture: preview right away.
3281
+ previewNotificationSound()
3282
+ },
3283
+ }),
3284
+ h(
3285
+ 'div',
3286
+ { style: { display: 'flex', justifyContent: 'flex-end', marginTop: '-6px' } },
3287
+ h(
3288
+ 'button',
3289
+ {
3290
+ type: 'button',
3291
+ style: styles.buttonGhost,
3292
+ onClick: () => previewNotificationSound(),
3293
+ },
3294
+ t('previewSound'),
3295
+ ),
3296
+ ),
3297
+ h('hr', { style: styles.divider }),
3298
+ h(RangeRow, {
3299
+ idPart: 'volume',
3300
+ label: t('volume'),
3301
+ hint: t('volumeHint'),
3302
+ value: settings.volume,
3303
+ min: 0,
3304
+ max: 100,
3305
+ step: 1,
3306
+ unit: t('percent'),
3307
+ onChange: (next) => updateConfig({ volume: next }),
3308
+ }),
3309
+ h('hr', { style: styles.divider }),
3310
+ h(RangeRow, {
3311
+ idPart: 'merge-interval',
3312
+ label: t('mergeInterval'),
3313
+ hint: t('mergeIntervalHint'),
3314
+ value: settings.mergeIntervalMs,
3315
+ min: 0,
3316
+ max: 10000,
3317
+ step: 500,
3318
+ unit: t('milliseconds'),
3319
+ onChange: (next) => updateConfig({ mergeIntervalMs: next }),
3320
+ }),
3321
+ ),
3322
+ h(
3323
+ 'div',
3324
+ { style: styles.card },
3325
+ h(
3326
+ 'div',
3327
+ { style: styles.actions },
3328
+ h(
3329
+ 'button',
3330
+ {
3331
+ type: 'button',
3332
+ style: styles.buttonPrimary,
3333
+ disabled: info.testState === 'sending',
3334
+ onClick: () => triggerTestNotification(),
3335
+ },
3336
+ info.testState === 'sending' ? t('testing') : t('testButton'),
3337
+ ),
3338
+ testFeedback,
3339
+ h(
3340
+ 'button',
3341
+ {
3342
+ type: 'button',
3343
+ style: styles.buttonGhost,
3344
+ onClick: () => testSystemNotification(),
3345
+ },
3346
+ t('systemTestButton'),
3347
+ ),
3348
+ ),
3349
+ ),
3350
+ permissionHint,
3351
+ h(
3352
+ 'div',
3353
+ { style: styles.statusBlock },
3354
+ h('div', { style: styles.statusTitle }, t('statusNativeToast')),
3355
+ h('div', { style: styles.hint }, formatTemplate(t('nativeToastCounts'), nativeInfo)),
3356
+ h('div', { style: styles.hint }, formatTemplate(t('nativeClicksCounts'), nativeClicks)),
3357
+ nativeInfo.lastError
3358
+ ? h('div', { style: styles.errorText }, t('nativeToastLastError') + ': ' + nativeInfo.lastError)
3359
+ : null,
3360
+ nativeClicks.lastError
3361
+ ? h('div', { style: styles.errorText }, t('nativeToastLastError') + ': ' + nativeClicks.lastError)
3362
+ : null,
3363
+ info.systemTest ? h('div', { style: styles.hint }, info.systemTest) : null,
3364
+ h('div', { style: styles.hint }, t('nativeToastHint')),
3365
+ ),
3366
+ h(
3367
+ 'div',
3368
+ { style: styles.statusBlock },
3369
+ h('div', { style: styles.statusTitle }, t('statusTitle')),
3370
+ statusRows.map((row) => h(StatusRow, { key: row.key, label: row.label, value: row.value })),
3371
+ h(StatusRow, { label: t('version'), value: info.version || VERSION }),
3372
+ saveText ? h('div', { style: info.saveState === 'failed' ? styles.errorText : styles.hint }, saveText) : null,
3373
+ ),
3374
+ runtimeBlock,
3375
+ )
3376
+ }
3377
+
3378
+ // The settings.section entry: id/order/label/locale/inject exactly as
3379
+ // the slot contract expects; `inject` hands the component the bound
3380
+ // translator, and the component subscribes to locale through the
3381
+ // service so a language switch re-renders mounted copy.
3382
+ if (ctx.slots && typeof ctx.slots.inject === 'function' && typeof ctx.slots.register === 'function') {
3383
+ try {
3384
+ ctx.slots.inject('settings.section', () => {
3385
+ try {
3386
+ return ctx.slots.register(
3387
+ {
3388
+ name: 'settings.section',
3389
+ id: PKG_ID,
3390
+ order: 42,
3391
+ label: () => t('nav'),
3392
+ locale: NS,
3393
+ inject: () => ({ t }),
3394
+ },
3395
+ function SettingsView() {
3396
+ return renderSettingsView()
3397
+ },
3398
+ )
3399
+ } catch (error) {
3400
+ warn('settings section registration failed', error)
3401
+ return () => {}
3402
+ }
3403
+ })
3404
+ } catch (error) {
3405
+ warn('cannot wait for the settings.section slot', error)
3406
+ }
3407
+ }
3408
+
3409
+ // The shell projects no icon field for a section (see the nav-icon
3410
+ // block above); claim this plugin's row so it does not wear the gear.
3411
+ installSettingsNavIcon(ctx, () => t('nav'))
3412
+
3413
+ // ---- 15. Start ----
3414
+ status.focused = isFocused()
3415
+ emit()
3416
+ // A previous page instance may have left a badge on the taskbar.
3417
+ // Clear it on boot; the next unfocused completion will set it again.
3418
+ try {
3419
+ clearTaskbarBadge()
3420
+ } catch (error) {
3421
+ warn('clearing the initial taskbar badge failed', error)
3422
+ }
3423
+ startConnection()
3424
+ }
3425
+
3426
+ // Test hook for test/client-smoke.mjs: pure helpers only, never used by
3427
+ // the app itself, and non-enumerable so it stays out of the module's
3428
+ // visible surface.
3429
+ const injected = ['slots', 'locale', 'connection']
3430
+ const exportsObject = {
3431
+ inject: injected,
3432
+ apply: apply,
3433
+ }
3434
+ try {
3435
+ Object.defineProperty(exportsObject, '__test', {
3436
+ enumerable: false,
3437
+ configurable: false,
3438
+ writable: false,
3439
+ value: {
3440
+ PKG_ID: PKG_ID,
3441
+ NS: NS,
3442
+ RPC_CHANNEL: RPC_CHANNEL,
3443
+ VERSION: VERSION,
3444
+ LEADER_STORAGE_KEY: LEADER_STORAGE_KEY,
3445
+ DEFAULT_CONFIG: DEFAULT_CONFIG,
3446
+ SOUND_IDS: SOUND_IDS,
3447
+ SOUND_PRESETS: SOUND_PRESETS,
3448
+ POLL_WAIT_MS: POLL_WAIT_MS,
3449
+ collapseWhitespace: collapseWhitespace,
3450
+ truncateText: truncateText,
3451
+ sanitizeConfig: sanitizeConfig,
3452
+ sanitizePatch: sanitizePatch,
3453
+ notificationTitleKey: notificationTitleKey,
3454
+ buildNotificationTitle: buildNotificationTitle,
3455
+ buildNotificationBody: buildNotificationBody,
3456
+ computeFocused: computeFocused,
3457
+ parseLeaderLease: parseLeaderLease,
3458
+ isFreshLease: isFreshLease,
3459
+ NAV_ICON_MARKER: NAV_ICON_MARKER,
3460
+ NAV_ICON_SIZE: NAV_ICON_SIZE,
3461
+ NAV_ROW_SELECTOR: NAV_ROW_SELECTOR,
3462
+ navMarkSvg: navMarkSvg,
3463
+ navMaskUrl: navMaskUrl,
3464
+ isOwnNavRow: isOwnNavRow,
3465
+ navIconCss: navIconCss,
3466
+ installSettingsNavIcon: installSettingsNavIcon,
3467
+ zh: zh,
3468
+ en: en,
3469
+ },
3470
+ })
3471
+ } catch (error) {
3472
+ warn('cannot define the test hook', error)
3473
+ }
3474
+ return exportsObject
3475
+ },
3476
+ })
3477
+ } catch (error) {
3478
+ try {
3479
+ if (typeof console !== 'undefined' && console && typeof console.warn === 'function') {
3480
+ console.warn('[dsh-notify-toast] client bundle load failed:', error)
3481
+ }
3482
+ } catch (ignored) {
3483
+ // Nothing left to contain.
3484
+ }
3485
+ }
3486
+ })()