dsh-plugin-notify-sounds 1.6.8 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -110
- package/README.zh.md +188 -0
- package/lib/client.js +344 -63
- package/lib/index.js +339 -37
- package/package.json +2 -3
- package/README.en.md +0 -180
package/lib/client.js
CHANGED
|
@@ -97,6 +97,18 @@ window.__ModuleLoader__.load({
|
|
|
97
97
|
*/
|
|
98
98
|
const isMac = () => typeof navigator !== "undefined" && /Mac/i.test(`${navigator.platform ?? ""} ${navigator.userAgent ?? ""}`);
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Whether the window-title mark has anywhere to appear.
|
|
102
|
+
*
|
|
103
|
+
* Only the macOS Desktop shell fails this, for the reason above. The
|
|
104
|
+
* row's switch is DISABLED rather than merely re-described: an enabled
|
|
105
|
+
* switch that the user can turn on and that then never does anything is
|
|
106
|
+
* worse than one that says it is not available yet — the old copy read
|
|
107
|
+
* like a working feature with a caveat, so people turned it on and
|
|
108
|
+
* waited.
|
|
109
|
+
*/
|
|
110
|
+
const titleFlashUnavailable = () => isMac() && isDesktopShell();
|
|
111
|
+
|
|
100
112
|
/** Stylesheet class names, one projection the components read. */
|
|
101
113
|
const css = {
|
|
102
114
|
section: "dsh-notify-sounds-section",
|
|
@@ -178,7 +190,7 @@ window.__ModuleLoader__.load({
|
|
|
178
190
|
"systemNotify.unsupported": "当前环境不支持系统通知",
|
|
179
191
|
"titleFlash.title": "窗口标题提醒",
|
|
180
192
|
"titleFlash.description": "页面处于后台时,在窗口标题前加文字标记(待处理 / 已完成),便于在任务栏察觉;回到页面后自动清除",
|
|
181
|
-
"titleFlash.macUnsupported": "macOS
|
|
193
|
+
"titleFlash.macUnsupported": "macOS 暂不支持,开关已禁用:标题栏隐藏、Dock 也没有窗口标题,标记无处显示(只在「窗口」菜单 / Mission Control 里可见)",
|
|
182
194
|
"titleFlash.pending": "待处理",
|
|
183
195
|
"titleFlash.done": "已完成",
|
|
184
196
|
"renotify.title": "未处理决策重复提醒",
|
|
@@ -220,9 +232,17 @@ window.__ModuleLoader__.load({
|
|
|
220
232
|
"update.failed": "检查失败(网络不可用)",
|
|
221
233
|
"update.apply": "立即更新",
|
|
222
234
|
"update.updating": "更新中…",
|
|
223
|
-
"update.
|
|
224
|
-
"update.restart": "重启 DSH 后生效",
|
|
235
|
+
"update.doneDetail": "更新完成:{from} → {to},重启 DSH 后生效",
|
|
225
236
|
"update.applyFailed": "更新失败({stage}),已恢复原状",
|
|
237
|
+
"update.stage.check": "检查",
|
|
238
|
+
"update.stage.download": "下载",
|
|
239
|
+
"update.stage.verify": "校验",
|
|
240
|
+
"update.stage.unpack": "解包",
|
|
241
|
+
"update.stage.manifest": "核对包信息",
|
|
242
|
+
"update.stage.replace": "替换文件",
|
|
243
|
+
"update.stage.unknown": "未知阶段",
|
|
244
|
+
"update.retry": "通常是网络或代理的临时问题,稍后重试即可;若反复失败,可到 {url} 手动安装",
|
|
245
|
+
"update.reinstall": "可在 DSH 的 设置 → 插件 里先卸载本插件,再添加 dsh-plugin-notify-sounds 重新安装;仓库:{url}",
|
|
226
246
|
};
|
|
227
247
|
|
|
228
248
|
/** English dictionary, checked complete against the zh key set. */
|
|
@@ -257,7 +277,7 @@ window.__ModuleLoader__.load({
|
|
|
257
277
|
"systemNotify.unsupported": "System notifications are unavailable in this environment",
|
|
258
278
|
"titleFlash.title": "Window title alert",
|
|
259
279
|
"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)",
|
|
280
|
+
"titleFlash.macUnsupported": "Not supported on macOS, so this switch is disabled: 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)",
|
|
261
281
|
"titleFlash.pending": "Waiting for you",
|
|
262
282
|
"titleFlash.done": "Finished",
|
|
263
283
|
"renotify.title": "Repeat unanswered decisions",
|
|
@@ -299,9 +319,17 @@ window.__ModuleLoader__.load({
|
|
|
299
319
|
"update.failed": "Check failed (no network)",
|
|
300
320
|
"update.apply": "Update now",
|
|
301
321
|
"update.updating": "Updating…",
|
|
302
|
-
"update.
|
|
303
|
-
"update.restart": "restart DSH to load it",
|
|
322
|
+
"update.doneDetail": "Updated: {from} → {to} — restart DSH to load it",
|
|
304
323
|
"update.applyFailed": "Update failed ({stage}) — the previous version is intact",
|
|
324
|
+
"update.stage.check": "check",
|
|
325
|
+
"update.stage.download": "download",
|
|
326
|
+
"update.stage.verify": "checksum",
|
|
327
|
+
"update.stage.unpack": "unpack",
|
|
328
|
+
"update.stage.manifest": "package check",
|
|
329
|
+
"update.stage.replace": "file replacement",
|
|
330
|
+
"update.stage.unknown": "unknown step",
|
|
331
|
+
"update.retry": "This is usually a temporary network or proxy problem — try again later. If it keeps failing, install manually from {url}",
|
|
332
|
+
"update.reinstall": "Remove this plugin under Settings → Plugins, then add dsh-plugin-notify-sounds again. Repository: {url}",
|
|
305
333
|
};
|
|
306
334
|
//#endregion
|
|
307
335
|
|
|
@@ -443,19 +471,27 @@ window.__ModuleLoader__.load({
|
|
|
443
471
|
* Peak-normalization gain for one decoded buffer: scales the loudest
|
|
444
472
|
* sample toward full scale, boosting at most 4× so near-silent files
|
|
445
473
|
* do not get blown up.
|
|
474
|
+
*
|
|
475
|
+
* Every sample is visited, deliberately. An earlier version stepped
|
|
476
|
+
* through the buffer taking every 8th frame and claimed the peak would
|
|
477
|
+
* be caught anyway because "neighbouring samples carry the same
|
|
478
|
+
* magnitude" — that only holds for a heavily oversampled signal. At
|
|
479
|
+
* 44.1 kHz a 10 kHz component has about 4.4 samples per period, so a
|
|
480
|
+
* stride of 8 aliases against it and can systematically miss the real
|
|
481
|
+
* peak; a tone near fs/8 is the extreme case. An underestimated peak
|
|
482
|
+
* means a boost that is too large, so the custom sound plays hot and
|
|
483
|
+
* can clip on its loudest transient.
|
|
484
|
+
*
|
|
485
|
+
* The cost is not on a playback path: this runs once per uploaded file,
|
|
486
|
+
* right after `decodeAudioData` (which costs far more), and the result
|
|
487
|
+
* is cached alongside the buffer in `customSoundCache`.
|
|
446
488
|
*/
|
|
447
489
|
function normalizedGain(buffer) {
|
|
448
490
|
try {
|
|
449
491
|
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;
|
|
456
492
|
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
|
457
493
|
const data = buffer.getChannelData(channel);
|
|
458
|
-
for (let i = 0; i < data.length; i
|
|
494
|
+
for (let i = 0; i < data.length; i++) {
|
|
459
495
|
const magnitude = Math.abs(data[i]);
|
|
460
496
|
if (magnitude > peak) peak = magnitude;
|
|
461
497
|
}
|
|
@@ -574,11 +610,25 @@ window.__ModuleLoader__.load({
|
|
|
574
610
|
// Release the master gain once the tail has faded. Without
|
|
575
611
|
// this, every play leaves a gain node permanently wired to
|
|
576
612
|
// the destination — a slow audio-node leak on long sessions.
|
|
577
|
-
|
|
613
|
+
const remaining = () => (t0 + tail + 0.1 - engine.currentTime) * 1000;
|
|
614
|
+
const release = (rechecked) => {
|
|
578
615
|
try {
|
|
616
|
+
// The delay was computed when this was scheduled, and a
|
|
617
|
+
// suspended context freezes `currentTime`: without this
|
|
618
|
+
// second look, a timer that comes due during a freeze
|
|
619
|
+
// disconnects a tail that is still playing. Re-check
|
|
620
|
+
// once — not in a loop, so a clock that never advances
|
|
621
|
+
// (a test's frozen mock, a context suspended for good)
|
|
622
|
+
// still ends with the node released.
|
|
623
|
+
if (!rechecked && remaining() > 5) {
|
|
624
|
+
setTimeout(() => release(true), remaining() + 50);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
579
627
|
master.disconnect();
|
|
580
628
|
} catch {}
|
|
581
|
-
}
|
|
629
|
+
};
|
|
630
|
+
const timer = setTimeout(() => release(false), Math.max(0, remaining() + 50));
|
|
631
|
+
timer.unref?.();
|
|
582
632
|
});
|
|
583
633
|
}
|
|
584
634
|
|
|
@@ -817,6 +867,183 @@ window.__ModuleLoader__.load({
|
|
|
817
867
|
/** Route the Host half applies an update through; must match the Host half's UPDATE_APPLY_PATH. */
|
|
818
868
|
const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
|
|
819
869
|
|
|
870
|
+
/**
|
|
871
|
+
* Where a user goes when the one-click update cannot finish.
|
|
872
|
+
*
|
|
873
|
+
* Shown as plain selectable text rather than a link: the Desktop shell
|
|
874
|
+
* is a `dsh-app://` page and the Settings primitives expose no external
|
|
875
|
+
* link component, so a URL the user can copy is the honest form — a
|
|
876
|
+
* button that silently did nothing here would be worse than no button.
|
|
877
|
+
*/
|
|
878
|
+
const REPOSITORY_URL = "https://github.com/BillCx330/dsh-plugin-notify-sounds";
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Every stage the Host can report, and whether a fresh install is the remedy.
|
|
882
|
+
*
|
|
883
|
+
* Keyed off the stage rather than off the message: telling someone whose
|
|
884
|
+
* download timed out to uninstall the plugin would be actively bad
|
|
885
|
+
* advice, and telling someone whose archive was rejected to "try again
|
|
886
|
+
* later" leaves them with no way forward at all. `true` means the
|
|
887
|
+
* payload itself could not be applied, which retrying will not change.
|
|
888
|
+
*/
|
|
889
|
+
const UPDATE_STAGES = { check: false, download: false, verify: false, unpack: true, manifest: true, replace: true };
|
|
890
|
+
|
|
891
|
+
/** The localized word for a Host-side update stage. */
|
|
892
|
+
const stageWord = (t, stage) => t(`update.stage.${typeof stage === "string" && Object.hasOwn(UPDATE_STAGES, stage) ? stage : "unknown"}`);
|
|
893
|
+
|
|
894
|
+
/** `v1.2.3` for display, `?` when the Host reported no version. */
|
|
895
|
+
const formatVersion = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* The line reporting an installation that landed.
|
|
899
|
+
*
|
|
900
|
+
* Composed here rather than in JSX so the notice above the section and
|
|
901
|
+
* the row inside it say exactly the same thing, and so the whole
|
|
902
|
+
* sentence — punctuation included — lives in the dictionaries instead
|
|
903
|
+
* of being assembled from fragments at the call site (which is how the
|
|
904
|
+
* English UI ended up with full-width stops).
|
|
905
|
+
*/
|
|
906
|
+
const updateAppliedText = (t, applied) => t("update.doneDetail", { from: formatVersion(applied?.from), to: formatVersion(applied?.to) });
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* The two lines a failed update shows: what happened, and what to do.
|
|
910
|
+
*
|
|
911
|
+
* The remedy is chosen from the stage the Host reported, never from the
|
|
912
|
+
* message: a download that timed out must not be answered with "uninstall
|
|
913
|
+
* and reinstall", and an archive that was refused must not be answered
|
|
914
|
+
* with "try again later".
|
|
915
|
+
*/
|
|
916
|
+
const updateFailureText = (t, failed, current) => {
|
|
917
|
+
// Name both versions whenever the target is known: "the update
|
|
918
|
+
// failed" on its own cannot be acted on afterwards, and this is the
|
|
919
|
+
// line a user copies into a bug report.
|
|
920
|
+
const named = typeof failed?.to === "string" ? `${formatVersion(current)} → ${formatVersion(failed.to)} · ` : "";
|
|
921
|
+
return {
|
|
922
|
+
detail: `${named}${t("update.applyFailed", { stage: stageWord(t, failed?.stage) })}`,
|
|
923
|
+
advice: UPDATE_STAGES[failed?.stage] === true
|
|
924
|
+
? t("update.reinstall", { url: REPOSITORY_URL })
|
|
925
|
+
: t("update.retry", { url: REPOSITORY_URL }),
|
|
926
|
+
};
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
/** Channel name and claim key shared by every page of this origin. */
|
|
930
|
+
const ALERT_CHANNEL = "dsh-notify-sounds:alerts";
|
|
931
|
+
const ALERT_LEADER_KEY = "dsh-notify-sounds:leader";
|
|
932
|
+
/** How often the leader re-asserts, and when a quiet claim is considered abandoned. */
|
|
933
|
+
const ALERT_HEARTBEAT_MS = 5000;
|
|
934
|
+
const ALERT_STALE_MS = 15000;
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* `localStorage`, when this page has one that answers.
|
|
938
|
+
*
|
|
939
|
+
* Even reading it can throw where storage is blocked (private mode, a
|
|
940
|
+
* sandboxed frame), and a page without it simply leads itself.
|
|
941
|
+
*
|
|
942
|
+
* @returns the storage, or undefined.
|
|
943
|
+
*/
|
|
944
|
+
const safeAlertStorage = () => {
|
|
945
|
+
try {
|
|
946
|
+
if (typeof localStorage === "undefined") return undefined;
|
|
947
|
+
localStorage.getItem(ALERT_LEADER_KEY);
|
|
948
|
+
return localStorage;
|
|
949
|
+
} catch {
|
|
950
|
+
return undefined;
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Elect one page among the tabs — and windows — of this GUI to be the one
|
|
956
|
+
* that speaks.
|
|
957
|
+
*
|
|
958
|
+
* Two pages are two independent subscribers to the same session facts:
|
|
959
|
+
* left alone, one decision rings the sound twice, raises two system
|
|
960
|
+
* notifications and runs two repeat reminders (the settings even say
|
|
961
|
+
* "at most one quiet check a day" while both of them check). The election
|
|
962
|
+
* is a claim in `localStorage`, which every page of this origin shares,
|
|
963
|
+
* refreshed by a heartbeat; a claim that stops being refreshed is taken
|
|
964
|
+
* over, so a closed, crashed or frozen tab hands the role on within a few
|
|
965
|
+
* seconds. `BroadcastChannel` only makes a claim heard sooner — the
|
|
966
|
+
* correctness rests on the stored claim, because whoever wrote it last
|
|
967
|
+
* owns the role and every page re-reads it rather than trusting a message.
|
|
968
|
+
*
|
|
969
|
+
* The title mark is deliberately NOT gated by this: each window and each
|
|
970
|
+
* tab has its own title, so marking them all is right, while a sound and
|
|
971
|
+
* a toast belong to the machine.
|
|
972
|
+
*
|
|
973
|
+
* Pages that can use neither mechanism — the smoke harness, a hardened
|
|
974
|
+
* browser context — lead themselves, which is the behaviour before this
|
|
975
|
+
* existed.
|
|
976
|
+
*
|
|
977
|
+
* @returns `{ isLeader, refresh, dispose }`, where `refresh` re-runs the
|
|
978
|
+
* election (the tests drive it instead of waiting for a heartbeat).
|
|
979
|
+
*/
|
|
980
|
+
function createAlertLeader() {
|
|
981
|
+
const storage = safeAlertStorage();
|
|
982
|
+
const Channel = typeof BroadcastChannel === "function" ? BroadcastChannel : undefined;
|
|
983
|
+
if (storage === undefined || Channel === undefined) {
|
|
984
|
+
return { isLeader: () => true, refresh: () => {}, dispose: () => {} };
|
|
985
|
+
}
|
|
986
|
+
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
987
|
+
let channel;
|
|
988
|
+
try {
|
|
989
|
+
channel = new Channel(ALERT_CHANNEL);
|
|
990
|
+
} catch {
|
|
991
|
+
return { isLeader: () => true, refresh: () => {}, dispose: () => {} };
|
|
992
|
+
}
|
|
993
|
+
let leader = false;
|
|
994
|
+
const readClaim = () => {
|
|
995
|
+
try {
|
|
996
|
+
const parsed = JSON.parse(storage.getItem(ALERT_LEADER_KEY) ?? "null");
|
|
997
|
+
return parsed !== null && typeof parsed?.id === "string" && Number.isFinite(parsed?.at) ? parsed : undefined;
|
|
998
|
+
} catch {
|
|
999
|
+
return undefined;
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
const claim = () => {
|
|
1003
|
+
leader = true;
|
|
1004
|
+
try {
|
|
1005
|
+
storage.setItem(ALERT_LEADER_KEY, JSON.stringify({ id, at: Date.now() }));
|
|
1006
|
+
} catch {}
|
|
1007
|
+
try {
|
|
1008
|
+
channel.postMessage({ id });
|
|
1009
|
+
} catch {}
|
|
1010
|
+
};
|
|
1011
|
+
const refresh = () => {
|
|
1012
|
+
const current = readClaim();
|
|
1013
|
+
if (current === undefined || current.id === id) {
|
|
1014
|
+
claim();
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
// Somebody else holds a claim. While it is fresh this page stays
|
|
1018
|
+
// silent; once it goes stale its holder is gone and this page
|
|
1019
|
+
// takes over.
|
|
1020
|
+
if (Date.now() - current.at < ALERT_STALE_MS) {
|
|
1021
|
+
leader = false;
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
claim();
|
|
1025
|
+
};
|
|
1026
|
+
channel.onmessage = (event) => {
|
|
1027
|
+
if (event?.data?.id !== id) refresh();
|
|
1028
|
+
};
|
|
1029
|
+
const timer = setInterval(refresh, ALERT_HEARTBEAT_MS);
|
|
1030
|
+
timer.unref?.();
|
|
1031
|
+
refresh();
|
|
1032
|
+
return {
|
|
1033
|
+
isLeader: () => leader,
|
|
1034
|
+
refresh,
|
|
1035
|
+
dispose: () => {
|
|
1036
|
+
clearInterval(timer);
|
|
1037
|
+
try {
|
|
1038
|
+
if (leader && readClaim()?.id === id) storage.removeItem(ALERT_LEADER_KEY);
|
|
1039
|
+
} catch {}
|
|
1040
|
+
try {
|
|
1041
|
+
channel.close();
|
|
1042
|
+
} catch {}
|
|
1043
|
+
},
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
820
1047
|
/**
|
|
821
1048
|
* Show one OS notification; every failure mode is silent.
|
|
822
1049
|
*
|
|
@@ -981,8 +1208,7 @@ window.__ModuleLoader__.load({
|
|
|
981
1208
|
.catch(() => {});
|
|
982
1209
|
}, [autoCheck]);
|
|
983
1210
|
if (applied === undefined || typeof applied.to !== "string") return null;
|
|
984
|
-
|
|
985
|
-
return h("div", { className: css.hint, key: "text" }, `${t("update.done")}:${version(applied.from)} → ${version(applied.to)},${t("update.restart")}`);
|
|
1211
|
+
return h("div", { className: css.hint, key: "text" }, updateAppliedText(t, applied));
|
|
986
1212
|
}
|
|
987
1213
|
|
|
988
1214
|
function NotifySoundsSection({ useConfig, setField, preview, t }) {
|
|
@@ -1099,17 +1325,6 @@ window.__ModuleLoader__.load({
|
|
|
1099
1325
|
},
|
|
1100
1326
|
onPreview: (id) => preview(id, volume),
|
|
1101
1327
|
}),
|
|
1102
|
-
h(SwitchRow, {
|
|
1103
|
-
key: "subagent",
|
|
1104
|
-
t,
|
|
1105
|
-
title: t("subagent.title"),
|
|
1106
|
-
description: t("subagent.description"),
|
|
1107
|
-
label: t("subagent.title"),
|
|
1108
|
-
checked: value?.subagentAlerts === true,
|
|
1109
|
-
disabled,
|
|
1110
|
-
failed: failedField === "subagentAlerts",
|
|
1111
|
-
onChange: (next) => write("subagentAlerts", next),
|
|
1112
|
-
}),
|
|
1113
1328
|
h(CustomSoundRow, {
|
|
1114
1329
|
key: "custom",
|
|
1115
1330
|
t,
|
|
@@ -1152,6 +1367,17 @@ window.__ModuleLoader__.load({
|
|
|
1152
1367
|
},
|
|
1153
1368
|
onPreview: () => preview("custom", volume),
|
|
1154
1369
|
}),
|
|
1370
|
+
h(SwitchRow, {
|
|
1371
|
+
key: "subagent",
|
|
1372
|
+
t,
|
|
1373
|
+
title: t("subagent.title"),
|
|
1374
|
+
description: t("subagent.description"),
|
|
1375
|
+
label: t("subagent.title"),
|
|
1376
|
+
checked: value?.subagentAlerts === true,
|
|
1377
|
+
disabled,
|
|
1378
|
+
failed: failedField === "subagentAlerts",
|
|
1379
|
+
onChange: (next) => write("subagentAlerts", next),
|
|
1380
|
+
}),
|
|
1155
1381
|
h(SystemNotifyRow, {
|
|
1156
1382
|
key: "systemNotify",
|
|
1157
1383
|
t,
|
|
@@ -1170,10 +1396,10 @@ window.__ModuleLoader__.load({
|
|
|
1170
1396
|
// document title like any other, so this is not
|
|
1171
1397
|
// applied there. iOS Safari's UA also contains "like
|
|
1172
1398
|
// Mac OS X" — `isDesktopShell` keeps it out.
|
|
1173
|
-
description:
|
|
1399
|
+
description: titleFlashUnavailable() ? t("titleFlash.macUnsupported") : t("titleFlash.description"),
|
|
1174
1400
|
label: t("titleFlash.title"),
|
|
1175
1401
|
checked: value?.titleFlash === true,
|
|
1176
|
-
disabled,
|
|
1402
|
+
disabled: disabled || titleFlashUnavailable(),
|
|
1177
1403
|
failed: failedField === "titleFlash",
|
|
1178
1404
|
onChange: (next) => write("titleFlash", next),
|
|
1179
1405
|
}),
|
|
@@ -1728,7 +1954,6 @@ window.__ModuleLoader__.load({
|
|
|
1728
1954
|
check(false)();
|
|
1729
1955
|
}, [autoCheck]);
|
|
1730
1956
|
const payload = state.payload;
|
|
1731
|
-
const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
|
|
1732
1957
|
// The Host remembers the last install and repeats it with every
|
|
1733
1958
|
// answer, and this row prefers that: an update swaps the client
|
|
1734
1959
|
// half's own file, so the page may reload and drop this row's
|
|
@@ -1740,15 +1965,21 @@ window.__ModuleLoader__.load({
|
|
|
1740
1965
|
let status = payload === undefined
|
|
1741
1966
|
? t("update.idle")
|
|
1742
1967
|
: payload.updateAvailable === true
|
|
1743
|
-
? `${t("update.available")} ${
|
|
1968
|
+
? `${t("update.available")} ${formatVersion(payload.latest)}`
|
|
1744
1969
|
: t("update.uptodate");
|
|
1745
1970
|
if (state.phase === "checking") status = t("update.checking");
|
|
1746
1971
|
if (state.phase === "updating") status = t("update.updating");
|
|
1747
|
-
let detail = payload === undefined ? status : `${t("update.current")} ${
|
|
1972
|
+
let detail = payload === undefined ? status : `${t("update.current")} ${formatVersion(payload.current)} · ${status}`;
|
|
1973
|
+
// Advice is a second line rather than more words in the first one:
|
|
1974
|
+
// the failure line is the part a user scans, and the remedy only
|
|
1975
|
+
// applies to some stages. `undefined` renders nothing at all.
|
|
1976
|
+
let advice;
|
|
1748
1977
|
if (appliedDone) {
|
|
1749
|
-
detail =
|
|
1978
|
+
detail = updateAppliedText(t, applied);
|
|
1750
1979
|
} else if (state.phase === "applyFailed") {
|
|
1751
|
-
|
|
1980
|
+
const failure = updateFailureText(t, state.result, payload?.current);
|
|
1981
|
+
detail = failure.detail;
|
|
1982
|
+
advice = failure.advice;
|
|
1752
1983
|
}
|
|
1753
1984
|
const busy = state.phase === "checking" || state.phase === "updating";
|
|
1754
1985
|
return h("div", { className: css.row }, [
|
|
@@ -1756,6 +1987,7 @@ window.__ModuleLoader__.load({
|
|
|
1756
1987
|
h("div", { className: css.rowTitle, key: "title" }, t("update.title")),
|
|
1757
1988
|
h("div", { className: css.rowDescription, key: "description" }, t("update.description")),
|
|
1758
1989
|
h("div", { className: css.hint, key: "status" }, detail),
|
|
1990
|
+
advice === undefined ? null : h("div", { className: css.hint, key: "advice" }, advice),
|
|
1759
1991
|
]),
|
|
1760
1992
|
h("div", { className: css.stackControl, key: "control" }, [
|
|
1761
1993
|
payload?.updateAvailable === true && !appliedDone ? h(Button, {
|
|
@@ -1893,6 +2125,12 @@ window.__ModuleLoader__.load({
|
|
|
1893
2125
|
let panelObserver = null;
|
|
1894
2126
|
let observedPanel = null;
|
|
1895
2127
|
const scan = (root) => {
|
|
2128
|
+
// One panel-level probe before the walk. This observer fires on
|
|
2129
|
+
// every change anywhere in the settings tree — a conversation
|
|
2130
|
+
// streaming next to an open panel included — and once this
|
|
2131
|
+
// section's cell carries the glyph there is nothing left to do;
|
|
2132
|
+
// the per-cell checks below would only re-establish that.
|
|
2133
|
+
if (root.querySelector(`[${NAV_ICON_MARKER}]`) !== null) return;
|
|
1896
2134
|
const label = t("nav");
|
|
1897
2135
|
for (const cell of root.querySelectorAll("button")) swapNavIcon(cell, label);
|
|
1898
2136
|
};
|
|
@@ -1961,6 +2199,11 @@ window.__ModuleLoader__.load({
|
|
|
1961
2199
|
*/
|
|
1962
2200
|
function apply(ctx) {
|
|
1963
2201
|
insertStyles();
|
|
2202
|
+
// One page per browser profile speaks (see createAlertLeader): a
|
|
2203
|
+
// sound and a toast belong to the machine, and a second tab of the
|
|
2204
|
+
// same GUI must not raise them a second time.
|
|
2205
|
+
const alertLeader = createAlertLeader();
|
|
2206
|
+
ctx.effect(() => alertLeader.dispose, "notify-sounds: alert leader");
|
|
1964
2207
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "notify-sounds: dictionaries");
|
|
1965
2208
|
const t = ctx.locale.bind(NS);
|
|
1966
2209
|
// The platform's own resolver for package text, which may be a plain
|
|
@@ -1995,7 +2238,8 @@ window.__ModuleLoader__.load({
|
|
|
1995
2238
|
const cooldown = new Map();
|
|
1996
2239
|
|
|
1997
2240
|
/**
|
|
1998
|
-
*
|
|
2241
|
+
* One snapshot of the session list rows, or `undefined` when this
|
|
2242
|
+
* runtime exposes none.
|
|
1999
2243
|
*
|
|
2000
2244
|
* The status facts carry no classification at all — an entry is
|
|
2001
2245
|
* only `{ running, pendingInteraction, completionUnread }` — so
|
|
@@ -2003,24 +2247,20 @@ window.__ModuleLoader__.load({
|
|
|
2003
2247
|
* session list, whose rows carry the same `origin`/`parentId` the
|
|
2004
2248
|
* sidebar uses to keep sub-agents out of the ordinary list.
|
|
2005
2249
|
*
|
|
2250
|
+
* Callers read ONCE per pass and hand the result down: the previous
|
|
2251
|
+
* shape called this per session inside the loops below, so a status
|
|
2252
|
+
* update covering ten sessions took ten snapshots, and the pass
|
|
2253
|
+
* scope was implied by "the same millisecond" rather than stated.
|
|
2254
|
+
*
|
|
2006
2255
|
* @returns the `byId` row map, or undefined when unavailable.
|
|
2007
2256
|
*/
|
|
2008
|
-
|
|
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;
|
|
2013
|
-
const sessionRows = () => {
|
|
2014
|
-
const now = Date.now();
|
|
2015
|
-
if (cachedRows !== undefined && now === cachedRowsAt) return cachedRows;
|
|
2257
|
+
const readSessionRows = () => {
|
|
2016
2258
|
try {
|
|
2017
|
-
|
|
2259
|
+
return ctx.sessions?.list?.getSnapshot?.()?.byId
|
|
2018
2260
|
?? ctx.uiSession?.sessions?.list?.getSnapshot?.()?.byId;
|
|
2019
2261
|
} catch {
|
|
2020
|
-
|
|
2262
|
+
return undefined;
|
|
2021
2263
|
}
|
|
2022
|
-
cachedRowsAt = now;
|
|
2023
|
-
return cachedRows;
|
|
2024
2264
|
};
|
|
2025
2265
|
|
|
2026
2266
|
/**
|
|
@@ -2028,10 +2268,11 @@ window.__ModuleLoader__.load({
|
|
|
2028
2268
|
* sub-agent, a teammate — rather than one of the user's own.
|
|
2029
2269
|
*
|
|
2030
2270
|
* @param sessionId - the session to classify.
|
|
2271
|
+
* @param rows - the pass's session rows, when the runtime has them.
|
|
2031
2272
|
* @returns whether it is a spawned child session.
|
|
2032
2273
|
*/
|
|
2033
|
-
const isSubagentSession = (sessionId) => {
|
|
2034
|
-
const row =
|
|
2274
|
+
const isSubagentSession = (sessionId, rows) => {
|
|
2275
|
+
const row = rows?.[sessionId];
|
|
2035
2276
|
// Deliberately ONLY `origin`, the same test the sidebar and the
|
|
2036
2277
|
// sub-agent UI use. `parentId` alone is not enough: a forked
|
|
2037
2278
|
// conversation carries one too and is still the user's own.
|
|
@@ -2048,12 +2289,13 @@ window.__ModuleLoader__.load({
|
|
|
2048
2289
|
* not something they want a chime for.
|
|
2049
2290
|
*
|
|
2050
2291
|
* @param sessionId - the session to test.
|
|
2292
|
+
* @param rows - the pass's session rows, when the runtime has them.
|
|
2051
2293
|
* @returns whether its events should alert.
|
|
2052
2294
|
*/
|
|
2053
|
-
const isNotifiable = (sessionId) => {
|
|
2295
|
+
const isNotifiable = (sessionId, rows) => {
|
|
2054
2296
|
const value = form.getSnapshot().value;
|
|
2055
2297
|
if (value?.subagentAlerts === true) return true;
|
|
2056
|
-
return !isSubagentSession(sessionId);
|
|
2298
|
+
return !isSubagentSession(sessionId, rows);
|
|
2057
2299
|
};
|
|
2058
2300
|
|
|
2059
2301
|
/**
|
|
@@ -2075,6 +2317,10 @@ window.__ModuleLoader__.load({
|
|
|
2075
2317
|
* reminder is not recorded as sent.
|
|
2076
2318
|
*/
|
|
2077
2319
|
const notify = (kind, interaction, sessionId, options = {}) => {
|
|
2320
|
+
// The elected page is the only one that makes a noise or raises
|
|
2321
|
+
// a notification; every page still keeps its own title mark,
|
|
2322
|
+
// because a title belongs to its own window.
|
|
2323
|
+
if (!alertLeader.isLeader()) return false;
|
|
2078
2324
|
const value = form.getSnapshot().value;
|
|
2079
2325
|
if (value?.enabled === false) return false;
|
|
2080
2326
|
if (value?.quietHours === true && inQuietHours(new Date(), value?.quietStart, value?.quietEnd)) return false;
|
|
@@ -2096,8 +2342,17 @@ window.__ModuleLoader__.load({
|
|
|
2096
2342
|
// Evict the oldest entry rather than clearing the whole
|
|
2097
2343
|
// table: one session hitting the cap must not silence
|
|
2098
2344
|
// every other session's cooldown.
|
|
2099
|
-
|
|
2100
|
-
|
|
2345
|
+
//
|
|
2346
|
+
// LRU, not FIFO: `Map.set` on a key that is already there
|
|
2347
|
+
// updates the value and KEEPS the original position, so
|
|
2348
|
+
// plain eviction would throw away whichever entry is
|
|
2349
|
+
// touched most often — precisely the session whose
|
|
2350
|
+
// two-second dedupe is doing work. Deleting first moves it
|
|
2351
|
+
// to the back on re-insertion.
|
|
2352
|
+
const key = `${kind}:${sessionId}`;
|
|
2353
|
+
if (cooldown.has(key)) cooldown.delete(key);
|
|
2354
|
+
else if (cooldown.size >= 96) cooldown.delete(cooldown.keys().next().value);
|
|
2355
|
+
cooldown.set(key, now);
|
|
2101
2356
|
}
|
|
2102
2357
|
const fallback = kind === "completion" ? "ding" : kind === "question" ? "chime" : "bell";
|
|
2103
2358
|
let id = typeof value?.[kind + "Sound"] === "string" ? value[kind + "Sound"] : fallback;
|
|
@@ -2156,9 +2411,9 @@ window.__ModuleLoader__.load({
|
|
|
2156
2411
|
return value?.quietHours === true && inQuietHours(new Date(), value?.quietStart, value?.quietEnd);
|
|
2157
2412
|
};
|
|
2158
2413
|
/** Whether any session is currently running. */
|
|
2159
|
-
const anyRunning = () => {
|
|
2414
|
+
const anyRunning = (rows) => {
|
|
2160
2415
|
for (const [sessionId, status] of source.getSnapshot()) {
|
|
2161
|
-
if (status.running === true && isNotifiable(sessionId)) return true;
|
|
2416
|
+
if (status.running === true && isNotifiable(sessionId, rows)) return true;
|
|
2162
2417
|
}
|
|
2163
2418
|
return false;
|
|
2164
2419
|
};
|
|
@@ -2180,7 +2435,7 @@ window.__ModuleLoader__.load({
|
|
|
2180
2435
|
// the quiet window ends.
|
|
2181
2436
|
if (record.quiet !== true) return "pending";
|
|
2182
2437
|
}
|
|
2183
|
-
if (anyRunning()) return undefined;
|
|
2438
|
+
if (anyRunning(readSessionRows())) return undefined;
|
|
2184
2439
|
return unreadCompletion ? "done" : undefined;
|
|
2185
2440
|
};
|
|
2186
2441
|
/** Refresh the title alert from the current facts. */
|
|
@@ -2190,12 +2445,12 @@ window.__ModuleLoader__.load({
|
|
|
2190
2445
|
applyTitleFlash(wantedTitleState(), titleAlerts(t));
|
|
2191
2446
|
};
|
|
2192
2447
|
/** Rebuild the tracker from one snapshot, then refresh the flash. */
|
|
2193
|
-
const trackPending = (snapshot) => {
|
|
2448
|
+
const trackPending = (snapshot, rows) => {
|
|
2194
2449
|
const next = new Map();
|
|
2195
2450
|
for (const [sessionId, status] of snapshot) {
|
|
2196
2451
|
// A filtered session is invisible to the whole package:
|
|
2197
2452
|
// it gets no reminder and no title mark either.
|
|
2198
|
-
if (!isNotifiable(sessionId)) continue;
|
|
2453
|
+
if (!isNotifiable(sessionId, rows)) continue;
|
|
2199
2454
|
const interaction = status.pendingInteraction;
|
|
2200
2455
|
if (interaction === undefined) continue;
|
|
2201
2456
|
const record = pending.get(sessionId);
|
|
@@ -2222,11 +2477,14 @@ window.__ModuleLoader__.load({
|
|
|
2222
2477
|
};
|
|
2223
2478
|
const read = () => {
|
|
2224
2479
|
const snapshot = source.getSnapshot();
|
|
2480
|
+
// One row snapshot for this whole pass, handed to every
|
|
2481
|
+
// check below instead of being taken per session.
|
|
2482
|
+
const rows = readSessionRows();
|
|
2225
2483
|
if (previous !== undefined) {
|
|
2226
2484
|
for (const [sessionId, status] of snapshot) {
|
|
2227
2485
|
// Sub-agent sessions stay out of it unless asked for:
|
|
2228
2486
|
// the user wants the main conversation's alerts.
|
|
2229
|
-
if (!isNotifiable(sessionId)) continue;
|
|
2487
|
+
if (!isNotifiable(sessionId, rows)) continue;
|
|
2230
2488
|
const before = previous.get(sessionId);
|
|
2231
2489
|
const beforePending = before === undefined ? undefined : before.pendingInteraction;
|
|
2232
2490
|
if (status.pendingInteraction !== undefined && status.pendingInteraction !== beforePending) {
|
|
@@ -2252,7 +2510,7 @@ window.__ModuleLoader__.load({
|
|
|
2252
2510
|
}
|
|
2253
2511
|
}
|
|
2254
2512
|
previous = snapshot;
|
|
2255
|
-
trackPending(snapshot);
|
|
2513
|
+
trackPending(snapshot, rows);
|
|
2256
2514
|
};
|
|
2257
2515
|
// Returning to the page clears the alert even while the
|
|
2258
2516
|
// decision stays pending — and marks every pending decision
|
|
@@ -2372,9 +2630,17 @@ window.__ModuleLoader__.load({
|
|
|
2372
2630
|
let timer;
|
|
2373
2631
|
const tick = () => {
|
|
2374
2632
|
try {
|
|
2375
|
-
if (form.getSnapshot().value?.updateAutoCheck
|
|
2376
|
-
|
|
2633
|
+
if (form.getSnapshot().value?.updateAutoCheck !== false) {
|
|
2634
|
+
fetch(UPDATE_CHECK_PATH, { method: "GET" }).catch(() => {});
|
|
2635
|
+
}
|
|
2377
2636
|
} catch {}
|
|
2637
|
+
// Rescheduled unconditionally, and that is the fix rather
|
|
2638
|
+
// than a detail: returning early here — which is what the
|
|
2639
|
+
// chain used to do when the preference was off — ended the
|
|
2640
|
+
// self-scheduling for good. Turning the switch off once, or
|
|
2641
|
+
// simply loading the page with it off, meant no check ever
|
|
2642
|
+
// ran again until a full reload, while the row went on
|
|
2643
|
+
// promising "at most one check a day".
|
|
2378
2644
|
timer = setTimeout(tick, 24 * 60 * 60 * 1000);
|
|
2379
2645
|
timer.unref?.();
|
|
2380
2646
|
};
|
|
@@ -2425,6 +2691,21 @@ window.__ModuleLoader__.load({
|
|
|
2425
2691
|
exports.FOCUS_PATH = FOCUS_PATH;
|
|
2426
2692
|
exports.UPDATE_CHECK_PATH = UPDATE_CHECK_PATH;
|
|
2427
2693
|
exports.UPDATE_APPLY_PATH = UPDATE_APPLY_PATH;
|
|
2694
|
+
// Which update stages a retry can fix and which need a fresh install:
|
|
2695
|
+
// the Host names a stage in every failure it reports, so this map is the
|
|
2696
|
+
// one place the advice comes from, and the tests cross-check it against
|
|
2697
|
+
// the stage words the dictionaries carry.
|
|
2698
|
+
exports.UPDATE_STAGES = UPDATE_STAGES;
|
|
2699
|
+
// The two composed messages, so the tests can assert the sentences a
|
|
2700
|
+
// user actually reads rather than only the dictionary keys behind them.
|
|
2701
|
+
exports.updateAppliedText = updateAppliedText;
|
|
2702
|
+
exports.updateFailureText = updateFailureText;
|
|
2703
|
+
// The alert election, so the tests can drive two pages against a stubbed
|
|
2704
|
+
// storage and channel instead of hoping two real tabs cooperate.
|
|
2705
|
+
exports.createAlertLeader = createAlertLeader;
|
|
2706
|
+
// The peak scan, so a test can build the one buffer that tells a full
|
|
2707
|
+
// scan apart from a strided one.
|
|
2708
|
+
exports.normalizedGain = normalizedGain;
|
|
2428
2709
|
exports.timeOfDayToMinutes = timeOfDayToMinutes;
|
|
2429
2710
|
return module.exports;
|
|
2430
2711
|
},
|