@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/AI-DISCLOSURE.md +29 -0
- package/CHANGELOG.md +87 -0
- package/LICENSE +22 -0
- package/README.en.md +290 -0
- package/README.md +308 -3
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/assets/sounds/bubble.ogg +0 -0
- package/cordis.patch.yml +5 -0
- package/docs/INTERFACE.md +180 -0
- package/docs/RELEASING.md +282 -0
- package/docs/TEST-REPORT.md +183 -0
- package/docs/assets/native-toast.png +0 -0
- package/dsh-plugin.json +14 -0
- package/icon.svg +17 -0
- package/lib/all-done.js +126 -0
- package/lib/client.js +3486 -0
- package/lib/config.js +420 -0
- package/lib/goal-summary.js +438 -0
- package/lib/index.js +1411 -0
- package/lib/native-focus.js +350 -0
- package/lib/native-toast-helper.cs +206 -0
- package/lib/native-toast.js +372 -0
- package/lib/native-toast.ps1 +72 -0
- package/lib/taskbar-badge-helper.cs +126 -0
- package/lib/taskbar-badge-main.cs +43 -0
- package/lib/taskbar-badge.js +347 -0
- package/lib/taskbar-badge.ps1 +59 -0
- package/lib/text.js +174 -0
- package/locale/en.json +17 -0
- package/locale/zh.json +17 -0
- package/package.json +76 -6
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
|
+
})()
|