dsh-plugin-notify-sounds 1.6.4 → 1.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +180 -0
- package/README.md +179 -175
- package/lib/client.js +291 -73
- package/lib/index.js +130 -31
- package/package.json +2 -2
- package/README.zh.md +0 -175
package/lib/client.js
CHANGED
|
@@ -70,6 +70,33 @@ window.__ModuleLoader__.load({
|
|
|
70
70
|
".dsh-notify-sounds-quietDash{color:var(--dsw-alias-label-tertiary)}",
|
|
71
71
|
".dsh-notify-sounds-hint{color:var(--dsw-alias-label-tertiary);margin-top:4px;font-size:12px;line-height:18px}",
|
|
72
72
|
].join("");
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether this page is running inside the Desktop shell rather than a
|
|
76
|
+
* real browser.
|
|
77
|
+
*
|
|
78
|
+
* Derived from what the page can see: the shell serves the same bundle
|
|
79
|
+
* over its own `dsh-app:` protocol and ships an Electron user agent,
|
|
80
|
+
* while a browser serves it over http(s) with no Electron token. Either
|
|
81
|
+
* signal alone would do; using both keeps one of them changing its mind
|
|
82
|
+
* from flipping the answer.
|
|
83
|
+
*/
|
|
84
|
+
const isDesktopShell = () => {
|
|
85
|
+
if (typeof location !== "undefined" && location.protocol === "dsh-app:") return true;
|
|
86
|
+
return typeof navigator !== "undefined" && /Electron/i.test(navigator.userAgent ?? "");
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether the page is on macOS.
|
|
91
|
+
*
|
|
92
|
+
* Note this is a pure OS check: it matches macOS browsers too. The
|
|
93
|
+
* places that need "macOS DESKTOP SHELL" combine this with
|
|
94
|
+
* `isDesktopShell()`, because the window chrome that breaks the title
|
|
95
|
+
* alert (`titleBarStyle: "hiddenInset"`) is the shell's own choice —
|
|
96
|
+
* a macOS browser tab shows the document title like any other.
|
|
97
|
+
*/
|
|
98
|
+
const isMac = () => typeof navigator !== "undefined" && /Mac/i.test(`${navigator.platform ?? ""} ${navigator.userAgent ?? ""}`);
|
|
99
|
+
|
|
73
100
|
/** Stylesheet class names, one projection the components read. */
|
|
74
101
|
const css = {
|
|
75
102
|
section: "dsh-notify-sounds-section",
|
|
@@ -151,10 +178,12 @@ window.__ModuleLoader__.load({
|
|
|
151
178
|
"systemNotify.unsupported": "当前环境不支持系统通知",
|
|
152
179
|
"titleFlash.title": "窗口标题提醒",
|
|
153
180
|
"titleFlash.description": "页面处于后台时,在窗口标题前加文字标记(待处理 / 已完成),便于在任务栏察觉;回到页面后自动清除",
|
|
181
|
+
"titleFlash.macUnsupported": "macOS 暂不支持:标题栏隐藏、Dock 也没有窗口标题,标记无处显示(只在「窗口」菜单 / Mission Control 里可见)",
|
|
154
182
|
"titleFlash.pending": "待处理",
|
|
155
183
|
"titleFlash.done": "已完成",
|
|
156
184
|
"renotify.title": "未处理决策重复提醒",
|
|
157
185
|
"renotify.description": "有决策一直未处理时,每隔设定时间重新提醒;只要你已经看过它一眼(它出现时你正在窗口,或之后回到过窗口),就不再重复提醒,即使再切走也一样",
|
|
186
|
+
"renotify.browserHint": "浏览器的后台标签页会节流计时器,重复间隔可能被拉长(1 分钟档尤其明显)",
|
|
158
187
|
"renotify.interval": "重复间隔",
|
|
159
188
|
"renotify.minutes": "{n} 分钟",
|
|
160
189
|
"quiet.title": "静音时段",
|
|
@@ -179,7 +208,7 @@ window.__ModuleLoader__.load({
|
|
|
179
208
|
"notify.completion.title": "任务完成",
|
|
180
209
|
"notify.completion.body": "智能体已完成当前任务",
|
|
181
210
|
"update.title": "更新",
|
|
182
|
-
"update.description": "从 npm
|
|
211
|
+
"update.description": "从 npm 检查本插件的新版本,发现新版可一键安装",
|
|
183
212
|
"update.auto": "自动检查更新",
|
|
184
213
|
"update.auto.description": "每天最多静默检查一次,发现新版只在下方标记,不弹通知",
|
|
185
214
|
"update.check": "检查更新",
|
|
@@ -228,10 +257,12 @@ window.__ModuleLoader__.load({
|
|
|
228
257
|
"systemNotify.unsupported": "System notifications are unavailable in this environment",
|
|
229
258
|
"titleFlash.title": "Window title alert",
|
|
230
259
|
"titleFlash.description": "While the page is hidden, mark the window title as waiting for you or finished, so the taskbar shows it; cleared when you return",
|
|
260
|
+
"titleFlash.macUnsupported": "Not supported on macOS: the title bar is hidden and the Dock shows no window title, so the mark has nowhere to appear (only the Window menu and Mission Control would show it)",
|
|
231
261
|
"titleFlash.pending": "Waiting for you",
|
|
232
262
|
"titleFlash.done": "Finished",
|
|
233
263
|
"renotify.title": "Repeat unanswered decisions",
|
|
234
264
|
"renotify.description": "Re-ping decisions that stay unanswered at a fixed interval; once you have seen one — it appeared while you were at the window, or you came back to it — it never repeats, even if you leave again",
|
|
265
|
+
"renotify.browserHint": "Background browser tabs throttle timers, so the interval can stretch (most visible at the 1-minute setting)",
|
|
235
266
|
"renotify.interval": "Interval",
|
|
236
267
|
"renotify.minutes": "{n} min",
|
|
237
268
|
"quiet.title": "Quiet hours",
|
|
@@ -256,7 +287,7 @@ window.__ModuleLoader__.load({
|
|
|
256
287
|
"notify.completion.title": "Task finished",
|
|
257
288
|
"notify.completion.body": "The agent has finished the current task",
|
|
258
289
|
"update.title": "Updates",
|
|
259
|
-
"update.description": "Check npm for a newer version of this plugin
|
|
290
|
+
"update.description": "Check npm for a newer version of this plugin and install it in one click",
|
|
260
291
|
"update.auto": "Check for updates automatically",
|
|
261
292
|
"update.auto.description": "At most one quiet check a day; a newer version is only marked below, never announced",
|
|
262
293
|
"update.check": "Check now",
|
|
@@ -416,9 +447,15 @@ window.__ModuleLoader__.load({
|
|
|
416
447
|
function normalizedGain(buffer) {
|
|
417
448
|
try {
|
|
418
449
|
let peak = 0;
|
|
450
|
+
// Sample every 8th frame: a peak that would clip is still
|
|
451
|
+
// caught by any reasonably strided scan (audio waveforms are
|
|
452
|
+
// smooth enough that adjacent samples carry the same
|
|
453
|
+
// magnitude), and this keeps a 30-second upload off the main
|
|
454
|
+
// thread's critical path.
|
|
455
|
+
const stride = 8;
|
|
419
456
|
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
|
420
457
|
const data = buffer.getChannelData(channel);
|
|
421
|
-
for (let i = 0; i < data.length; i
|
|
458
|
+
for (let i = 0; i < data.length; i += stride) {
|
|
422
459
|
const magnitude = Math.abs(data[i]);
|
|
423
460
|
if (magnitude > peak) peak = magnitude;
|
|
424
461
|
}
|
|
@@ -456,12 +493,38 @@ window.__ModuleLoader__.load({
|
|
|
456
493
|
}
|
|
457
494
|
|
|
458
495
|
/**
|
|
459
|
-
*
|
|
496
|
+
* Run `play` on an AudioContext that is actually running.
|
|
497
|
+
*
|
|
498
|
+
* A suspended context has a FROZEN `currentTime`: scheduling sources on
|
|
499
|
+
* it parks them on the timeline and they all fire together whenever the
|
|
500
|
+
* context later resumes — the opposite of an alert. So nothing is ever
|
|
501
|
+
* scheduled while suspended. Where resuming needs a user gesture (every
|
|
502
|
+
* browser; the Electron shell needs none) this attempt is simply
|
|
503
|
+
* dropped, and the unlock effect has the context running for the next
|
|
504
|
+
* one rather than banking a stale sound for later.
|
|
505
|
+
*
|
|
506
|
+
* @param engine - the page's AudioContext.
|
|
507
|
+
* @param play - schedules the sound.
|
|
508
|
+
*/
|
|
509
|
+
function whenRunning(engine, play) {
|
|
510
|
+
if (engine.state === "running") {
|
|
511
|
+
play();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
Promise.resolve(engine.resume())
|
|
515
|
+
.then(() => {
|
|
516
|
+
if (engine.state === "running") play();
|
|
517
|
+
})
|
|
518
|
+
.catch(() => {});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Play one preset (or the uploaded sound) at a loudness in percent.
|
|
460
523
|
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
524
|
+
* Nothing is scheduled while the AudioContext cannot play — see
|
|
525
|
+
* `whenRunning`, which defers to the first real user gesture on
|
|
526
|
+
* browsers. Peaks stay far below clipping even with every preset voice
|
|
527
|
+
* summed.
|
|
465
528
|
*
|
|
466
529
|
* @param id - preset id; `none`, unknown ids, and `custom` without an
|
|
467
530
|
* upload are silent no-ops (the caller falls back first).
|
|
@@ -472,43 +535,51 @@ window.__ModuleLoader__.load({
|
|
|
472
535
|
*/
|
|
473
536
|
function playSound(id, volumePercent, customBase64) {
|
|
474
537
|
if (id === "none" || id === undefined) return;
|
|
538
|
+
const engine = audioEngine();
|
|
539
|
+
if (engine === undefined) return;
|
|
475
540
|
if (id === "custom") {
|
|
476
|
-
|
|
477
|
-
if (engine === undefined) return;
|
|
478
|
-
if (engine.state === "suspended") engine.resume().catch(() => {});
|
|
479
|
-
playCustomSound(engine, customBase64, volumePercent);
|
|
541
|
+
whenRunning(engine, () => playCustomSound(engine, customBase64, volumePercent));
|
|
480
542
|
return;
|
|
481
543
|
}
|
|
482
544
|
const preset = SOUND_PRESETS[id];
|
|
483
545
|
if (preset === undefined) return;
|
|
484
|
-
const engine = audioEngine();
|
|
485
|
-
if (engine === undefined) return;
|
|
486
|
-
if (engine.state === "suspended") engine.resume().catch(() => {});
|
|
487
546
|
const percent = Number.isFinite(volumePercent) ? volumePercent : DEFAULT_VOLUME;
|
|
488
547
|
const loudness = Math.max(0, Math.min(100, percent)) / 100;
|
|
489
548
|
if (loudness === 0) return;
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
osc.frequency.
|
|
549
|
+
whenRunning(engine, () => {
|
|
550
|
+
const master = engine.createGain();
|
|
551
|
+
master.gain.value = loudness * 0.6;
|
|
552
|
+
master.connect(engine.destination);
|
|
553
|
+
const t0 = engine.currentTime + 0.02;
|
|
554
|
+
let tail = 0;
|
|
555
|
+
for (const note of preset.notes) {
|
|
556
|
+
const osc = engine.createOscillator();
|
|
557
|
+
const gain = engine.createGain();
|
|
558
|
+
osc.type = note.type ?? "sine";
|
|
559
|
+
const start = t0 + note.at;
|
|
560
|
+
osc.frequency.setValueAtTime(note.freq, start);
|
|
561
|
+
if (note.glideTo !== undefined) {
|
|
562
|
+
osc.frequency.exponentialRampToValueAtTime(note.glideTo, start + note.dur * 0.85);
|
|
563
|
+
}
|
|
564
|
+
const peak = Math.max(0.0002, note.gain * 0.25);
|
|
565
|
+
gain.gain.setValueAtTime(0.0001, start);
|
|
566
|
+
gain.gain.exponentialRampToValueAtTime(peak, start + 0.012);
|
|
567
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, start + note.dur);
|
|
568
|
+
osc.connect(gain);
|
|
569
|
+
gain.connect(master);
|
|
570
|
+
osc.start(start);
|
|
571
|
+
osc.stop(start + note.dur + 0.05);
|
|
572
|
+
tail = Math.max(tail, note.at + note.dur);
|
|
502
573
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}
|
|
574
|
+
// Release the master gain once the tail has faded. Without
|
|
575
|
+
// this, every play leaves a gain node permanently wired to
|
|
576
|
+
// the destination — a slow audio-node leak on long sessions.
|
|
577
|
+
setTimeout(() => {
|
|
578
|
+
try {
|
|
579
|
+
master.disconnect();
|
|
580
|
+
} catch {}
|
|
581
|
+
}, Math.max(0, (t0 + tail + 0.1 - engine.currentTime) * 1000 + 50));
|
|
582
|
+
});
|
|
512
583
|
}
|
|
513
584
|
|
|
514
585
|
/**
|
|
@@ -747,13 +818,17 @@ window.__ModuleLoader__.load({
|
|
|
747
818
|
const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
|
|
748
819
|
|
|
749
820
|
/**
|
|
750
|
-
* Show one OS notification; every failure mode is silent.
|
|
751
|
-
*
|
|
752
|
-
*
|
|
821
|
+
* Show one OS notification; every failure mode is silent.
|
|
822
|
+
*
|
|
823
|
+
* `tag` is our OWN collapse key — we close the previous notification
|
|
824
|
+
* ourselves rather than handing the tag to the platform, because a
|
|
825
|
+
* superseded toast can swallow clicks aimed at it. `silent` keeps the
|
|
826
|
+
* OS from layering its own sound on top of ours.
|
|
753
827
|
*
|
|
754
828
|
* @param title - notification title.
|
|
755
829
|
* @param body - notification body.
|
|
756
|
-
* @param tag -
|
|
830
|
+
* @param tag - our internal key for closing the previous generation;
|
|
831
|
+
* deliberately NOT passed to the platform (see the comment inside).
|
|
757
832
|
*/
|
|
758
833
|
function showSystemNotification(title, body, tag) {
|
|
759
834
|
try {
|
|
@@ -806,9 +881,13 @@ window.__ModuleLoader__.load({
|
|
|
806
881
|
};
|
|
807
882
|
} catch (error) {
|
|
808
883
|
// `new Notification` is what can throw here, when the
|
|
809
|
-
// environment refuses to construct one
|
|
810
|
-
//
|
|
811
|
-
//
|
|
884
|
+
// environment refuses to construct one — nearly every mobile
|
|
885
|
+
// browser does exactly that, while still reporting a
|
|
886
|
+
// "granted" permission. So this is not just a log line: it is
|
|
887
|
+
// recorded and shown, because the Settings row otherwise reads
|
|
888
|
+
// `Notification.permission` and proudly says "granted" for a
|
|
889
|
+
// switch that can never do anything.
|
|
890
|
+
notificationConstructionFailed = true;
|
|
812
891
|
if (!warnedNotificationFailure) {
|
|
813
892
|
warnedNotificationFailure = true;
|
|
814
893
|
console.warn("notify-sounds: could not show a system notification", error);
|
|
@@ -819,6 +898,15 @@ window.__ModuleLoader__.load({
|
|
|
819
898
|
/** Whether the one-time notification-construction warning has been logged. */
|
|
820
899
|
let warnedNotificationFailure = false;
|
|
821
900
|
|
|
901
|
+
/**
|
|
902
|
+
* Whether this environment refused to construct a Notification.
|
|
903
|
+
*
|
|
904
|
+
* Set by the first real attempt, read by the settings row: permission
|
|
905
|
+
* being "granted" proves nothing about whether a toast can actually be
|
|
906
|
+
* built (MDN: the constructor throws in nearly all mobile browsers).
|
|
907
|
+
*/
|
|
908
|
+
let notificationConstructionFailed = false;
|
|
909
|
+
|
|
822
910
|
/**
|
|
823
911
|
* Human-readable summary of one pending interaction, for notification
|
|
824
912
|
* bodies: the approval's localized reason first, then its raw reason,
|
|
@@ -859,6 +947,44 @@ window.__ModuleLoader__.load({
|
|
|
859
947
|
* player, and the locale seat.
|
|
860
948
|
* @returns the Settings section element tree.
|
|
861
949
|
*/
|
|
950
|
+
/**
|
|
951
|
+
* The one-line notice directly under the section intro: what the last
|
|
952
|
+
* update did, shown where the page lands.
|
|
953
|
+
*
|
|
954
|
+
* An update swaps the plugin's own client file, so DSH reloads this
|
|
955
|
+
* half and the section is rebuilt from the top — anything anchored at
|
|
956
|
+
* the bottom of the row list is somewhere the user then has to scroll
|
|
957
|
+
* to find, and the scroll itself makes the page look like it jumped.
|
|
958
|
+
* The outcome therefore lives on the Host (it survives the reload) and
|
|
959
|
+
* this notice repeats it at the top, without moving anything, until
|
|
960
|
+
* DSH restarts — which is exactly as long as "restart to load it" is
|
|
961
|
+
* true. Nothing is shown otherwise, so the section is unchanged for
|
|
962
|
+
* everyone who did not just update.
|
|
963
|
+
*
|
|
964
|
+
* @param props - copy.
|
|
965
|
+
* @returns the notice element, or null when there is nothing to say.
|
|
966
|
+
*/
|
|
967
|
+
function UpdateNotice({ t, autoCheck }) {
|
|
968
|
+
const [applied, setApplied] = useState(undefined);
|
|
969
|
+
// Explicit `=== true`, not `!== false`: during the first frame
|
|
970
|
+
// `useConfig` may still be loading, and `undefined` must NOT be
|
|
971
|
+
// treated as "allowed" — that is the one case where a request
|
|
972
|
+
// could slip past a user who has the switch off. The dep array is
|
|
973
|
+
// `[autoCheck]` rather than `[]` so the effect picks up the real
|
|
974
|
+
// value when it arrives, instead of only ever reading the first
|
|
975
|
+
// render's prop.
|
|
976
|
+
useEffect(() => {
|
|
977
|
+
if (autoCheck !== true) return;
|
|
978
|
+
fetch(UPDATE_CHECK_PATH, { method: "GET" })
|
|
979
|
+
.then((response) => response.json())
|
|
980
|
+
.then((payload) => setApplied(payload?.applied))
|
|
981
|
+
.catch(() => {});
|
|
982
|
+
}, [autoCheck]);
|
|
983
|
+
if (applied === undefined || typeof applied.to !== "string") return null;
|
|
984
|
+
const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
|
|
985
|
+
return h("div", { className: css.hint, key: "text" }, `${t("update.done")}:${version(applied.from)} → ${version(applied.to)},${t("update.restart")}`);
|
|
986
|
+
}
|
|
987
|
+
|
|
862
988
|
function NotifySoundsSection({ useConfig, setField, preview, t }) {
|
|
863
989
|
const state = useConfig((snapshot) => snapshot);
|
|
864
990
|
const value = state.status === "ready" ? state.value : undefined;
|
|
@@ -907,6 +1033,7 @@ window.__ModuleLoader__.load({
|
|
|
907
1033
|
return h("div", { className: css.section }, [
|
|
908
1034
|
h("h2", { className: css.title, key: "title" }, t("title")),
|
|
909
1035
|
h("p", { className: css.intro, key: "intro" }, t("intro")),
|
|
1036
|
+
h(UpdateNotice, { key: "update-notice", t, autoCheck: value === undefined ? undefined : value.updateAutoCheck !== false }),
|
|
910
1037
|
h("div", { className: css.rows, key: "rows" }, [
|
|
911
1038
|
h(SwitchRow, {
|
|
912
1039
|
key: "enabled",
|
|
@@ -1037,7 +1164,13 @@ window.__ModuleLoader__.load({
|
|
|
1037
1164
|
key: "titleFlash",
|
|
1038
1165
|
t,
|
|
1039
1166
|
title: t("titleFlash.title"),
|
|
1040
|
-
|
|
1167
|
+
// macOS DESKTOP shell only: its title bar is hiddenInset
|
|
1168
|
+
// and the Dock shows no per-window title, so the mark
|
|
1169
|
+
// has nowhere to appear. A macOS BROWSER tab shows the
|
|
1170
|
+
// document title like any other, so this is not
|
|
1171
|
+
// applied there. iOS Safari's UA also contains "like
|
|
1172
|
+
// Mac OS X" — `isDesktopShell` keeps it out.
|
|
1173
|
+
description: isMac() && isDesktopShell() ? t("titleFlash.macUnsupported") : t("titleFlash.description"),
|
|
1041
1174
|
label: t("titleFlash.title"),
|
|
1042
1175
|
checked: value?.titleFlash === true,
|
|
1043
1176
|
disabled,
|
|
@@ -1091,6 +1224,7 @@ window.__ModuleLoader__.load({
|
|
|
1091
1224
|
h(UpdateRow, {
|
|
1092
1225
|
key: "update",
|
|
1093
1226
|
t,
|
|
1227
|
+
autoCheck: value === undefined ? undefined : value.updateAutoCheck !== false,
|
|
1094
1228
|
}),
|
|
1095
1229
|
]),
|
|
1096
1230
|
]);
|
|
@@ -1402,7 +1536,11 @@ window.__ModuleLoader__.load({
|
|
|
1402
1536
|
*/
|
|
1403
1537
|
function SystemNotifyRow({ t, checked, disabled, failed, onChange }) {
|
|
1404
1538
|
const [busy, setBusy] = useState(false);
|
|
1405
|
-
|
|
1539
|
+
// `typeof Notification` alone is not enough: nearly every mobile
|
|
1540
|
+
// browser exposes the API and happily reports "granted", yet the
|
|
1541
|
+
// constructor throws every time. Once a real attempt has failed,
|
|
1542
|
+
// this environment is treated as unsupported and the row says so.
|
|
1543
|
+
const supported = typeof Notification !== "undefined" && !notificationConstructionFailed;
|
|
1406
1544
|
const permission = supported ? Notification.permission : "unsupported";
|
|
1407
1545
|
const hintKey = !supported ? "systemNotify.unsupported"
|
|
1408
1546
|
: permission === "denied" ? "systemNotify.denied"
|
|
@@ -1455,6 +1593,10 @@ window.__ModuleLoader__.load({
|
|
|
1455
1593
|
h("div", { key: "text" }, [
|
|
1456
1594
|
h("div", { className: css.rowTitle, key: "title" }, t("renotify.title")),
|
|
1457
1595
|
h("div", { className: css.rowDescription, key: "description" }, t("renotify.description")),
|
|
1596
|
+
// Only where it is true: the Desktop shell runs this timer
|
|
1597
|
+
// in a real window that is never throttled, so the caveat
|
|
1598
|
+
// would only confuse its users.
|
|
1599
|
+
isDesktopShell() ? null : h("div", { className: css.hint, key: "hint" }, t("renotify.browserHint")),
|
|
1458
1600
|
failed ? h("div", { className: css.error, role: "alert", key: "error" }, t("saveFailed")) : null,
|
|
1459
1601
|
]),
|
|
1460
1602
|
h("div", { className: css.stackControl, key: "control" }, [
|
|
@@ -1555,7 +1697,7 @@ window.__ModuleLoader__.load({
|
|
|
1555
1697
|
* @param props - copy.
|
|
1556
1698
|
* @returns the row element tree.
|
|
1557
1699
|
*/
|
|
1558
|
-
function UpdateRow({ t }) {
|
|
1700
|
+
function UpdateRow({ t, autoCheck }) {
|
|
1559
1701
|
const [state, setState] = useState({ phase: "idle" });
|
|
1560
1702
|
const check = (force) => async () => {
|
|
1561
1703
|
setState({ phase: "checking" });
|
|
@@ -1578,9 +1720,13 @@ window.__ModuleLoader__.load({
|
|
|
1578
1720
|
}
|
|
1579
1721
|
};
|
|
1580
1722
|
// A cached answer makes this free; only a cold Host reaches out.
|
|
1723
|
+
// Same guard as the notice: `=== true` (not `!== false`), because
|
|
1724
|
+
// during the loading frame `undefined` must not be treated as
|
|
1725
|
+
// permission to reach out. The button below works regardless.
|
|
1581
1726
|
useEffect(() => {
|
|
1727
|
+
if (autoCheck !== true) return;
|
|
1582
1728
|
check(false)();
|
|
1583
|
-
}, []);
|
|
1729
|
+
}, [autoCheck]);
|
|
1584
1730
|
const payload = state.payload;
|
|
1585
1731
|
const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
|
|
1586
1732
|
// The Host remembers the last install and repeats it with every
|
|
@@ -1605,15 +1751,7 @@ window.__ModuleLoader__.load({
|
|
|
1605
1751
|
detail = t("update.applyFailed", { stage: String(state.result?.stage ?? "?") });
|
|
1606
1752
|
}
|
|
1607
1753
|
const busy = state.phase === "checking" || state.phase === "updating";
|
|
1608
|
-
|
|
1609
|
-
// the settings section is rebuilt from the top, so the news would
|
|
1610
|
-
// land somewhere the user has to scroll to find. Bring the row
|
|
1611
|
-
// back into view instead — once per outcome, not on every render.
|
|
1612
|
-
const rowRef = useRef(null);
|
|
1613
|
-
useEffect(() => {
|
|
1614
|
-
if (appliedDone) rowRef.current?.scrollIntoView?.({ block: "center", behavior: "smooth" });
|
|
1615
|
-
}, [appliedDone]);
|
|
1616
|
-
return h("div", { className: css.row, ref: rowRef }, [
|
|
1754
|
+
return h("div", { className: css.row }, [
|
|
1617
1755
|
h("div", { key: "text" }, [
|
|
1618
1756
|
h("div", { className: css.rowTitle, key: "title" }, t("update.title")),
|
|
1619
1757
|
h("div", { className: css.rowDescription, key: "description" }, t("update.description")),
|
|
@@ -1684,7 +1822,11 @@ window.__ModuleLoader__.load({
|
|
|
1684
1822
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
1685
1823
|
svg.setAttribute("width", "16");
|
|
1686
1824
|
svg.setAttribute("height", "16");
|
|
1687
|
-
|
|
1825
|
+
// 17×17, matching the artwork's own coordinate system
|
|
1826
|
+
// (`IconAlarmClockOutlineArtwork`); the gear it replaces is 16×16,
|
|
1827
|
+
// and reusing ITS viewport here would scale the alarm paths up by
|
|
1828
|
+
// 17/16 and make the glyph visibly larger than its neighbours.
|
|
1829
|
+
svg.setAttribute("viewBox", "0 0 17 17");
|
|
1688
1830
|
svg.setAttribute("fill", "none");
|
|
1689
1831
|
svg.setAttribute("stroke-width", "1.3");
|
|
1690
1832
|
svg.setAttribute("aria-hidden", "true");
|
|
@@ -1719,7 +1861,18 @@ window.__ModuleLoader__.load({
|
|
|
1719
1861
|
const shippedClass = icon.getAttribute("class");
|
|
1720
1862
|
if (shippedClass !== null && shippedClass !== "") replacement.setAttribute("class", shippedClass);
|
|
1721
1863
|
replacement.setAttribute(NAV_ICON_MARKER, "");
|
|
1722
|
-
|
|
1864
|
+
// Keep the original so the disposer can restore it. `replaceChild`
|
|
1865
|
+
// detaches a node React still holds in its fiber tree; if React
|
|
1866
|
+
// ever re-renders that slot it throws NotFoundError trying to
|
|
1867
|
+
// remove the detached node. Restoring on dispose prevents that.
|
|
1868
|
+
replacement._originalIcon = icon;
|
|
1869
|
+
try {
|
|
1870
|
+
cell.replaceChild(replacement, icon);
|
|
1871
|
+
} catch {
|
|
1872
|
+
// A React re-render raced the swap; the next observer pass
|
|
1873
|
+
// will try again on the fresh node.
|
|
1874
|
+
return false;
|
|
1875
|
+
}
|
|
1723
1876
|
return true;
|
|
1724
1877
|
}
|
|
1725
1878
|
|
|
@@ -1768,6 +1921,18 @@ window.__ModuleLoader__.load({
|
|
|
1768
1921
|
return () => {
|
|
1769
1922
|
detachPanel();
|
|
1770
1923
|
bodyObserver.disconnect();
|
|
1924
|
+
// Restore every swapped glyph so React never finds a detached
|
|
1925
|
+
// node in its fiber tree (see swapNavIcon).
|
|
1926
|
+
if (typeof document !== "undefined") {
|
|
1927
|
+
for (const el of document.querySelectorAll(`[${NAV_ICON_MARKER}]`)) {
|
|
1928
|
+
const original = el._originalIcon;
|
|
1929
|
+
if (original !== undefined && el.parentNode !== null) {
|
|
1930
|
+
try {
|
|
1931
|
+
el.parentNode.replaceChild(original, el);
|
|
1932
|
+
} catch {}
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1771
1936
|
};
|
|
1772
1937
|
}
|
|
1773
1938
|
//#endregion
|
|
@@ -1840,13 +2005,22 @@ window.__ModuleLoader__.load({
|
|
|
1840
2005
|
*
|
|
1841
2006
|
* @returns the `byId` row map, or undefined when unavailable.
|
|
1842
2007
|
*/
|
|
2008
|
+
// Cache one snapshot per synchronous block: `isNotifiable` is
|
|
2009
|
+
// called per session in read()/trackPending loops, and each call
|
|
2010
|
+
// would otherwise take a fresh snapshot (O8).
|
|
2011
|
+
let cachedRows;
|
|
2012
|
+
let cachedRowsAt = 0;
|
|
1843
2013
|
const sessionRows = () => {
|
|
2014
|
+
const now = Date.now();
|
|
2015
|
+
if (cachedRows !== undefined && now === cachedRowsAt) return cachedRows;
|
|
1844
2016
|
try {
|
|
1845
|
-
|
|
2017
|
+
cachedRows = ctx.sessions?.list?.getSnapshot?.()?.byId
|
|
1846
2018
|
?? ctx.uiSession?.sessions?.list?.getSnapshot?.()?.byId;
|
|
1847
2019
|
} catch {
|
|
1848
|
-
|
|
2020
|
+
cachedRows = undefined;
|
|
1849
2021
|
}
|
|
2022
|
+
cachedRowsAt = now;
|
|
2023
|
+
return cachedRows;
|
|
1850
2024
|
};
|
|
1851
2025
|
|
|
1852
2026
|
/**
|
|
@@ -1919,7 +2093,10 @@ window.__ModuleLoader__.load({
|
|
|
1919
2093
|
// triggers are edge-fired that alert is lost rather than
|
|
1920
2094
|
// delayed.
|
|
1921
2095
|
if (!reminder) {
|
|
1922
|
-
|
|
2096
|
+
// Evict the oldest entry rather than clearing the whole
|
|
2097
|
+
// table: one session hitting the cap must not silence
|
|
2098
|
+
// every other session's cooldown.
|
|
2099
|
+
if (cooldown.size >= 96) cooldown.delete(cooldown.keys().next().value);
|
|
1923
2100
|
cooldown.set(`${kind}:${sessionId}`, now);
|
|
1924
2101
|
}
|
|
1925
2102
|
const fallback = kind === "completion" ? "ding" : kind === "question" ? "chime" : "bell";
|
|
@@ -1973,6 +2150,11 @@ window.__ModuleLoader__.load({
|
|
|
1973
2150
|
&& typeof document !== "undefined"
|
|
1974
2151
|
&& document.visibilityState !== "visible";
|
|
1975
2152
|
};
|
|
2153
|
+
/** Whether quiet hours are in effect at this moment. */
|
|
2154
|
+
const quietNow = () => {
|
|
2155
|
+
const value = form.getSnapshot().value;
|
|
2156
|
+
return value?.quietHours === true && inQuietHours(new Date(), value?.quietStart, value?.quietEnd);
|
|
2157
|
+
};
|
|
1976
2158
|
/** Whether any session is currently running. */
|
|
1977
2159
|
const anyRunning = () => {
|
|
1978
2160
|
for (const [sessionId, status] of source.getSnapshot()) {
|
|
@@ -1992,7 +2174,12 @@ window.__ModuleLoader__.load({
|
|
|
1992
2174
|
*/
|
|
1993
2175
|
const wantedTitleState = () => {
|
|
1994
2176
|
if (!titleFlashActive()) return undefined;
|
|
1995
|
-
|
|
2177
|
+
for (const record of pending.values()) {
|
|
2178
|
+
// A decision that arrived during quiet hours is
|
|
2179
|
+
// forgotten, not queued: no mark shows up for it when
|
|
2180
|
+
// the quiet window ends.
|
|
2181
|
+
if (record.quiet !== true) return "pending";
|
|
2182
|
+
}
|
|
1996
2183
|
if (anyRunning()) return undefined;
|
|
1997
2184
|
return unreadCompletion ? "done" : undefined;
|
|
1998
2185
|
};
|
|
@@ -2022,6 +2209,11 @@ window.__ModuleLoader__.load({
|
|
|
2022
2209
|
// already been SEEN, and a seen decision never gets
|
|
2023
2210
|
// a repeat reminder (see the reminder timer).
|
|
2024
2211
|
seen: pageIsWatched(),
|
|
2212
|
+
// Quiet hours mean the event is forgotten, not
|
|
2213
|
+
// queued: a decision that appears during them gets
|
|
2214
|
+
// no reminder and no title mark, and nothing about
|
|
2215
|
+
// it is delivered when the quiet window ends.
|
|
2216
|
+
quiet: quietNow(),
|
|
2025
2217
|
});
|
|
2026
2218
|
}
|
|
2027
2219
|
pending.clear();
|
|
@@ -2049,11 +2241,12 @@ window.__ModuleLoader__.load({
|
|
|
2049
2241
|
notify(status.pendingInteraction.kind === "question" ? "question" : "decision", status.pendingInteraction, sessionId);
|
|
2050
2242
|
}
|
|
2051
2243
|
if (before !== undefined && before.running === true && status.running === false) {
|
|
2052
|
-
// A finish that happens while the user is away is
|
|
2053
|
-
// one they watch happen is not
|
|
2054
|
-
//
|
|
2055
|
-
//
|
|
2056
|
-
|
|
2244
|
+
// A finish that happens while the user is away is
|
|
2245
|
+
// news; one they watch happen is not — and one
|
|
2246
|
+
// that happens during quiet hours is forgotten
|
|
2247
|
+
// rather than kept for later, so nothing about it
|
|
2248
|
+
// shows up when the quiet window ends.
|
|
2249
|
+
if (!pageIsWatched() && !quietNow()) unreadCompletion = true;
|
|
2057
2250
|
notify("completion", undefined, sessionId);
|
|
2058
2251
|
}
|
|
2059
2252
|
}
|
|
@@ -2109,8 +2302,9 @@ window.__ModuleLoader__.load({
|
|
|
2109
2302
|
const watching = pageIsWatched();
|
|
2110
2303
|
for (const [sessionId, record] of pending) {
|
|
2111
2304
|
// Seen once, done forever: leaving again does not
|
|
2112
|
-
// re-arm this decision for another round.
|
|
2113
|
-
|
|
2305
|
+
// re-arm this decision for another round. A decision
|
|
2306
|
+
// from quiet hours is done too — it was forgotten.
|
|
2307
|
+
if (record.seen || record.quiet === true) continue;
|
|
2114
2308
|
if (watching) {
|
|
2115
2309
|
// Watched right now counts as seen as well (the
|
|
2116
2310
|
// visibility event normally records it first), and a
|
|
@@ -2120,10 +2314,12 @@ window.__ModuleLoader__.load({
|
|
|
2120
2314
|
}
|
|
2121
2315
|
const last = record.lastPing !== 0 ? record.lastPing : record.since;
|
|
2122
2316
|
if (now - last >= intervalMs) {
|
|
2123
|
-
//
|
|
2124
|
-
//
|
|
2125
|
-
//
|
|
2126
|
-
|
|
2317
|
+
// A reminder that cannot fire right now is dropped,
|
|
2318
|
+
// not queued: quiet hours must not end with a burst
|
|
2319
|
+
// of everything that was missed overnight. The
|
|
2320
|
+
// cadence simply continues afterwards.
|
|
2321
|
+
notify(record.kind, record.interaction, sessionId, { reminder: true });
|
|
2322
|
+
record.lastPing = now;
|
|
2127
2323
|
}
|
|
2128
2324
|
}
|
|
2129
2325
|
}, RENOTIFY_CHECK_MS);
|
|
@@ -2141,6 +2337,28 @@ window.__ModuleLoader__.load({
|
|
|
2141
2337
|
};
|
|
2142
2338
|
}, "notify-sounds: session status watcher");
|
|
2143
2339
|
|
|
2340
|
+
// Browsers gate Web Audio behind a user gesture: until one happens
|
|
2341
|
+
// the context starts suspended and `resume()` simply stays pending,
|
|
2342
|
+
// so a trigger firing in the meantime would be lost — and worse,
|
|
2343
|
+
// anything scheduled on the frozen timeline would all fire at once
|
|
2344
|
+
// whenever some later gesture unlocked it. So the FIRST real
|
|
2345
|
+
// gesture starts the context and keeps it running, and nothing is
|
|
2346
|
+
// scheduled while suspended (see `whenRunning`). The Desktop shell
|
|
2347
|
+
// needs none of this — Electron starts audio without a gesture,
|
|
2348
|
+
// which is exactly why the problem is invisible on the Desktop.
|
|
2349
|
+
ctx.effect(() => {
|
|
2350
|
+
if (typeof document === "undefined") return () => {};
|
|
2351
|
+
const unlock = () => {
|
|
2352
|
+
const engine = audioEngine();
|
|
2353
|
+
if (engine?.state === "suspended") engine.resume().catch(() => {});
|
|
2354
|
+
};
|
|
2355
|
+
const events = ["pointerdown", "keydown", "touchstart"];
|
|
2356
|
+
for (const type of events) document.addEventListener(type, unlock, { capture: true, passive: true });
|
|
2357
|
+
return () => {
|
|
2358
|
+
for (const type of events) document.removeEventListener(type, unlock, { capture: true });
|
|
2359
|
+
};
|
|
2360
|
+
}, "notify-sounds: audio unlock");
|
|
2361
|
+
|
|
2144
2362
|
// Quiet update check: shortly after the page settles, then at most
|
|
2145
2363
|
// once a day, and never while the user has it turned off. It asks
|
|
2146
2364
|
// the Host's own route — one small query, nothing downloaded — and
|