dsh-plugin-notify-sounds 1.8.4 → 1.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -185,6 +185,7 @@ window.__ModuleLoader__.load({
185
185
  "custom.remove": "移除",
186
186
  "custom.tooLarge": "文件超过 512KB,请压缩后重试",
187
187
  "custom.undecodable": "无法解码该音频:文件可能损坏,或当前环境不支持解码",
188
+ "custom.unreadable": "无法读取该文件:它可能已被移动,或读取权限被撤销,请重新选择",
188
189
  "systemNotify.title": "系统通知",
189
190
  "systemNotify.description": "页面处于后台时弹出系统通知,点击可回到窗口",
190
191
  "systemNotify.granted": "已授权系统通知",
@@ -201,7 +202,7 @@ window.__ModuleLoader__.load({
201
202
  "renotify.interval": "重复间隔",
202
203
  "renotify.minutes": "{n} 分钟",
203
204
  "quiet.title": "静音时段",
204
- "quiet.description": "每天此时间段内不发出任何提醒(可跨午夜,如 22:00 至次日 08:00)",
205
+ "quiet.description": "每天此时间段内不发出任何提醒(可跨午夜,如 22:00 至次日 08:00;起止时间相同视为未设置静音)",
205
206
  "quiet.start": "开始时间",
206
207
  "quiet.end": "结束时间",
207
208
  "preview": "试听",
@@ -222,32 +223,23 @@ window.__ModuleLoader__.load({
222
223
  "notify.completion.title": "任务完成",
223
224
  "notify.completion.body": "智能体已完成当前任务",
224
225
  "update.title": "更新",
225
- "update.description": "从 npm 检查本插件的新版本,发现新版可一键安装",
226
+ "update.description": "从 npm 检查本插件的新版本;安装请用插件管理器(本插件不再自己安装更新)",
226
227
  "update.auto": "自动检查更新",
227
228
  "update.auto.description": "每天最多静默检查一次,发现新版只在下方标记,不弹通知",
228
229
  "update.check": "检查更新",
229
230
  "update.current": "当前版本",
231
+ "update.currentUnknown": "无法读取当前版本",
230
232
  "update.idle": "尚未检查",
231
233
  "update.checking": "检查中…",
232
234
  "update.uptodate": "已是最新",
233
235
  "update.available": "有新版",
234
- "update.failed": "检查失败(网络不可用)",
235
- "update.apply": "立即更新",
236
- "update.desktopGuidance": "升级请走包管理器:设置 → 插件 → 卸载本插件,再重新添加 dsh-plugin-notify-sounds。安装由包管理器完成,升级才能留在新版(本插件不再自己替换文件)",
236
+ "update.failed": "检查失败",
237
+ "update.failedDetail": "两个源都没有给出可用的答复:可能是网络、代理或镜像源临时不可用,稍后重试即可",
238
+ "update.desktopGuidance": "升级走包管理器:左侧栏「插件」→ 在「已安装插件」里卸载本插件 → 点「添加插件」→ 粘贴 dsh-plugin-notify-sounds@{version} → 安装 → 重启 DSH",
239
+ "update.versionPinned": "务必带版本号:发布不满 24 小时的版本会被 pnpm 的 minimumReleaseAge 策略挡掉,只写包名会装回上一个版本",
237
240
  "update.webGuidance": "在终端执行 dsh plugin --profile {profile} add dsh-plugin-notify-sounds@{version}",
238
- "update.unregistered": "这个安装没有被包管理器登记(记录仍为 {recorded}),下次安装其他插件时可能被退回。请按上面的方式重新安装一次。",
239
- "update.updating": "更新中…",
240
- "update.doneDetail": "更新完成:{from} → {to},重启 DSH 后生效",
241
- "update.applyFailed": "更新失败({stage}),已恢复原状",
242
- "update.stage.check": "检查",
243
- "update.stage.download": "下载",
244
- "update.stage.verify": "校验",
245
- "update.stage.unpack": "解包",
246
- "update.stage.manifest": "核对包信息",
247
- "update.stage.replace": "替换文件",
248
- "update.stage.unknown": "未知阶段",
249
- "update.retry": "通常是网络或代理的临时问题,稍后重试即可;若反复失败,可到 {url} 手动安装",
250
- "update.reinstall": "可在 DSH 的 设置 → 插件 里先卸载本插件,再添加 dsh-plugin-notify-sounds 重新安装;仓库:{url}",
241
+ "update.webGuidancePlain": "在终端执行 dsh plugin add dsh-plugin-notify-sounds@{version}",
242
+ "update.unregistered": "这个安装没有被包管理器登记(记录仍为 {recorded}),下次安装其他插件时可能被退回。请按下面的步骤重新安装一次。",
251
243
  };
252
244
 
253
245
  /** English dictionary, checked complete against the zh key set. */
@@ -275,6 +267,7 @@ window.__ModuleLoader__.load({
275
267
  "custom.remove": "Remove",
276
268
  "custom.tooLarge": "File exceeds 512KB; please compress it and retry",
277
269
  "custom.undecodable": "Could not decode this audio: the file may be damaged, or this environment cannot decode audio",
270
+ "custom.unreadable": "Could not read this file: it may have been moved, or read permission was revoked — choose it again",
278
271
  "systemNotify.title": "System notifications",
279
272
  "systemNotify.description": "Show an OS notification while the page is hidden; click it to focus the window",
280
273
  "systemNotify.granted": "Notification permission granted",
@@ -293,7 +286,7 @@ window.__ModuleLoader__.load({
293
286
  "quiet.title": "Quiet hours",
294
287
  "goalFinalOnly.title": "Only alert on the final goal round",
295
288
  "goalFinalOnly.description": "A goal runs several automatic rounds; with this on, intermediate rounds stay silent and one chime marks the end of the goal (or a pause or block). Ordinary sessions are unaffected",
296
- "quiet.description": "No alerts during this daily time range (may wrap past midnight, e.g. 22:00 to 08:00)",
289
+ "quiet.description": "No alerts during this daily time range (may wrap past midnight, e.g. 22:00 to 08:00; identical start and end means no quiet range)",
297
290
  "quiet.start": "Start time",
298
291
  "quiet.end": "End time",
299
292
  "preview": "Preview",
@@ -314,32 +307,23 @@ window.__ModuleLoader__.load({
314
307
  "notify.completion.title": "Task finished",
315
308
  "notify.completion.body": "The agent has finished the current task",
316
309
  "update.title": "Updates",
317
- "update.description": "Check npm for a newer version of this plugin and install it in one click",
310
+ "update.description": "Check npm for a newer version; install it through the plugin manager (this plugin no longer installs updates itself)",
318
311
  "update.auto": "Check for updates automatically",
319
312
  "update.auto.description": "At most one quiet check a day; a newer version is only marked below, never announced",
320
313
  "update.check": "Check now",
321
314
  "update.current": "This version",
315
+ "update.currentUnknown": "This version could not be read",
322
316
  "update.idle": "Not checked yet",
323
317
  "update.checking": "Checking…",
324
318
  "update.uptodate": "Up to date",
325
319
  "update.available": "New version available",
326
- "update.failed": "Check failed (no network)",
327
- "update.apply": "Update now",
328
- "update.desktopGuidance": "Update through the package manager: Settings → Plugins → remove this plugin, then add dsh-plugin-notify-sounds again. The installer keeps the upgrade (this plugin no longer replaces its own files)",
320
+ "update.failed": "Check failed",
321
+ "update.failedDetail": "Neither source returned a usable answer — the network, a proxy or a mirror may be temporarily unavailable. Try again later.",
322
+ "update.desktopGuidance": "Update through the package manager: sidebar Plugins → uninstall this plugin under Installed plugins → Add plugin → paste dsh-plugin-notify-sounds@{version} → Install → restart DSH",
323
+ "update.versionPinned": "Keep the version: pnpm's minimumReleaseAge policy blocks a release for its first 24 hours, so the bare name resolves to the previous version",
329
324
  "update.webGuidance": "Run dsh plugin --profile {profile} add dsh-plugin-notify-sounds@{version} in a terminal",
330
- "update.unregistered": "This install is not registered with the package manager (it still records {recorded}), so a later plugin install may roll it back. Reinstall it with the step above.",
331
- "update.updating": "Updating…",
332
- "update.doneDetail": "Updated: {from} → {to} — restart DSH to load it",
333
- "update.applyFailed": "Update failed ({stage}) — the previous version is intact",
334
- "update.stage.check": "check",
335
- "update.stage.download": "download",
336
- "update.stage.verify": "checksum",
337
- "update.stage.unpack": "unpack",
338
- "update.stage.manifest": "package check",
339
- "update.stage.replace": "file replacement",
340
- "update.stage.unknown": "unknown step",
341
- "update.retry": "This is usually a temporary network or proxy problem — try again later. If it keeps failing, install manually from {url}",
342
- "update.reinstall": "Remove this plugin under Settings → Plugins, then add dsh-plugin-notify-sounds again. Repository: {url}",
325
+ "update.webGuidancePlain": "Run dsh plugin add dsh-plugin-notify-sounds@{version} in a terminal",
326
+ "update.unregistered": "This install is not registered with the package manager (it still records {recorded}), so a later plugin install may roll it back. Reinstall it once with the steps below.",
343
327
  };
344
328
  //#endregion
345
329
 
@@ -801,15 +785,23 @@ window.__ModuleLoader__.load({
801
785
  /** Active title observer, while a title alert is wanted. */
802
786
  let titleObserver;
803
787
 
804
- /** Parse "HH:MM" into minutes past midnight; `NaN` when malformed. */
788
+ /**
789
+ * Parse "HH:MM" into minutes past midnight; `NaN` when malformed.
790
+ *
791
+ * Deliberately as strict as the Host's `TIME_OF_DAY` pattern: two-digit
792
+ * hours, two-digit minutes. A hand-edited `9:05` is refused by the
793
+ * Config schema, so accepting it here would mean the row claims a value
794
+ * is fine while the plugin itself declines to load — one field, two
795
+ * verdicts.
796
+ *
797
+ * @param text - the stored value.
798
+ * @returns minutes past midnight, or NaN.
799
+ */
805
800
  function timeOfDayToMinutes(text) {
806
801
  if (typeof text !== "string") return NaN;
807
- const match = /^(\d{1,2}):(\d{2})$/.exec(text);
802
+ const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(text);
808
803
  if (match === null) return NaN;
809
- const hours = Number(match[1]);
810
- const minutes = Number(match[2]);
811
- if (hours > 23 || minutes > 59) return NaN;
812
- return hours * 60 + minutes;
804
+ return Number(match[1]) * 60 + Number(match[2]);
813
805
  }
814
806
 
815
807
  /**
@@ -874,64 +866,47 @@ window.__ModuleLoader__.load({
874
866
  /** Route the Host half answers the update check on; must match the Host half's UPDATE_CHECK_PATH. */
875
867
  const UPDATE_CHECK_PATH = "/notify-sounds/update/check";
876
868
 
877
-
878
- /**
879
- * Where a user goes when the one-click update cannot finish.
880
- *
881
- * Shown as plain selectable text rather than a link: the Desktop shell
882
- * is a `dsh-app://` page and the Settings primitives expose no external
883
- * link component, so a URL the user can copy is the honest form — a
884
- * button that silently did nothing here would be worse than no button.
885
- */
886
- const REPOSITORY_URL = "https://github.com/BillCx330/dsh-plugin-notify-sounds";
887
-
888
- /**
889
- * Every stage the Host can report, and whether a fresh install is the remedy.
890
- *
891
- * Keyed off the stage rather than off the message: telling someone whose
892
- * download timed out to uninstall the plugin would be actively bad
893
- * advice, and telling someone whose archive was rejected to "try again
894
- * later" leaves them with no way forward at all. `true` means the
895
- * payload itself could not be applied, which retrying will not change.
896
- */
897
- const UPDATE_STAGES = { check: false, download: false, verify: false, unpack: true, manifest: true, replace: true };
898
-
899
- /** The localized word for a Host-side update stage. */
900
- const stageWord = (t, stage) => t(`update.stage.${typeof stage === "string" && Object.hasOwn(UPDATE_STAGES, stage) ? stage : "unknown"}`);
869
+ /** Route the Host half answers a session's goal phase on; must match the Host half's GOAL_PHASE_PATH. */
870
+ const GOAL_PHASE_PATH = "/notify-sounds/goal";
901
871
 
902
872
  /** `v1.2.3` for display, `?` when the Host reported no version. */
903
873
  const formatVersion = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
904
874
 
905
875
  /**
906
- * The line reporting an installation that landed.
876
+ * The version as a package specifier segment: `1.2.3`, never `v1.2.3`.
907
877
  *
908
- * Composed here rather than in JSX so the notice above the section and
909
- * the row inside it say exactly the same thing, and so the whole
910
- * sentence — punctuation included — lives in the dictionaries instead
911
- * of being assembled from fragments at the call site (which is how the
912
- * English UI ended up with full-width stops).
878
+ * The display form and the installable form are deliberately different.
879
+ * Every install instruction this package prints is pasted into a package
880
+ * manager, and `pkg@v1.2.3` only works because semver tolerates the
881
+ * prefix — not because anything promises it. A command that fails to
882
+ * parse is worse than a slightly plainer one.
913
883
  */
914
- const updateAppliedText = (t, applied) => t("update.doneDetail", { from: formatVersion(applied?.from), to: formatVersion(applied?.to) });
884
+ const packageVersion = (text) => (typeof text === "string" && text !== "" ? text : "");
915
885
 
916
886
  /**
917
- * The two lines a failed update shows: what happened, and what to do.
887
+ * The version an install instruction should name, or undefined when
888
+ * there is nothing to install.
889
+ *
890
+ * Only these two states need an instruction, and both have to name a
891
+ * version or the command runs into `minimumReleaseAge` and quietly
892
+ * installs the previous release:
918
893
  *
919
- * The remedy is chosen from the stage the Host reported, never from the
920
- * message: a download that timed out must not be answered with "uninstall
921
- * and reinstall", and an archive that was refused must not be answered
922
- * with "try again later".
894
+ * - a newer version exists → install that one;
895
+ * - the install is not recorded by the profile's lockfile → install
896
+ * the version already on disk, so the record catches up.
897
+ *
898
+ * When both hold, the newer version is the target: reinstalling it
899
+ * fixes the record at the same time. A plain "up to date" install
900
+ * yields undefined, so the row stays quiet.
901
+ *
902
+ * @param payload - the Host's answer, or undefined before one arrives.
903
+ * @param currentKnown - whether `payload.current` is a usable version.
904
+ * @returns the version string, as a package specifier segment.
923
905
  */
924
- const updateFailureText = (t, failed, current) => {
925
- // Name both versions whenever the target is known: "the update
926
- // failed" on its own cannot be acted on afterwards, and this is the
927
- // line a user copies into a bug report.
928
- const named = typeof failed?.to === "string" ? `${formatVersion(current)} → ${formatVersion(failed.to)} · ` : "";
929
- return {
930
- detail: `${named}${t("update.applyFailed", { stage: stageWord(t, failed?.stage) })}`,
931
- advice: UPDATE_STAGES[failed?.stage] === true
932
- ? t("update.reinstall", { url: REPOSITORY_URL })
933
- : t("update.retry", { url: REPOSITORY_URL }),
934
- };
906
+ const installTargetVersion = (payload, currentKnown) => {
907
+ if (payload?.updateAvailable === true && typeof payload.latest === "string" && payload.latest !== "") return payload.latest;
908
+ if (payload?.unregistered === true && currentKnown && typeof payload.current === "string" && payload.current !== "") return payload.current;
909
+ return undefined;
935
910
  };
936
911
 
937
912
  /** Channel name and claim key shared by every page of this origin. */
@@ -1073,7 +1048,9 @@ window.__ModuleLoader__.load({
1073
1048
  // to collapse repeats means the platform closes the toast that
1074
1049
  // is being superseded — and a click that arrives for that older
1075
1050
  // toast can be dropped with it, which is one whole class of
1076
- // "clicked the notification and nothing happened".
1051
+ // "clicked the notification and nothing happened". The tag is
1052
+ // only our own key for closing the previous generation, which
1053
+ // is why it is not passed to the constructor below either.
1077
1054
  const previous = liveNotifications.get(tag);
1078
1055
  if (previous !== undefined) {
1079
1056
  liveNotifications.delete(tag);
@@ -1081,13 +1058,12 @@ window.__ModuleLoader__.load({
1081
1058
  previous.close();
1082
1059
  } catch {}
1083
1060
  }
1084
- // `tag` is deliberately NOT passed to the platform. Collapsing
1085
- // repeats by tag means the platform supersedes the previous
1086
- // toast — and a click aimed at the superseded toast can be
1087
- // dropped with it, which is the whole "clicked the
1088
- // notification and nothing happened" class of failure. The tag
1089
- // is only our own key for closing the previous generation.
1090
1061
  const notification = new Notification(title, { body, silent: true });
1062
+ // The constructor worked, so this environment can build a toast
1063
+ // after all. Clearing the flag matters: it is what disables the
1064
+ // row (see SystemNotifyRow), and one transient refusal must not
1065
+ // leave the switch dead for the life of the page.
1066
+ notificationConstructionFailed = false;
1091
1067
  liveNotifications.set(tag, notification);
1092
1068
  // Held here — and deliberately NOT released on click or close.
1093
1069
  // Releasing it was what left a replaced (or twice-clicked) toast
@@ -1175,50 +1151,13 @@ window.__ModuleLoader__.load({
1175
1151
 
1176
1152
  //#region settings rows
1177
1153
  /**
1178
- * The transient write-failure marker shared by the rows: `null` when the
1179
- * last write settled, otherwise the field whose write was refused.
1154
+ * The Settings section: the row list, the write helper every row shares,
1155
+ * and the "this field was refused" marker they render.
1180
1156
  *
1181
1157
  * @param props - the form snapshot hook, the field writer, the preview
1182
1158
  * player, and the locale seat.
1183
1159
  * @returns the Settings section element tree.
1184
1160
  */
1185
- /**
1186
- * The one-line notice directly under the section intro: what the last
1187
- * update did, shown where the page lands.
1188
- *
1189
- * An update swaps the plugin's own client file, so DSH reloads this
1190
- * half and the section is rebuilt from the top — anything anchored at
1191
- * the bottom of the row list is somewhere the user then has to scroll
1192
- * to find, and the scroll itself makes the page look like it jumped.
1193
- * The outcome therefore lives on the Host (it survives the reload) and
1194
- * this notice repeats it at the top, without moving anything, until
1195
- * DSH restarts — which is exactly as long as "restart to load it" is
1196
- * true. Nothing is shown otherwise, so the section is unchanged for
1197
- * everyone who did not just update.
1198
- *
1199
- * @param props - copy.
1200
- * @returns the notice element, or null when there is nothing to say.
1201
- */
1202
- function UpdateNotice({ t, autoCheck }) {
1203
- const [applied, setApplied] = useState(undefined);
1204
- // Explicit `=== true`, not `!== false`: during the first frame
1205
- // `useConfig` may still be loading, and `undefined` must NOT be
1206
- // treated as "allowed" — that is the one case where a request
1207
- // could slip past a user who has the switch off. The dep array is
1208
- // `[autoCheck]` rather than `[]` so the effect picks up the real
1209
- // value when it arrives, instead of only ever reading the first
1210
- // render's prop.
1211
- useEffect(() => {
1212
- if (autoCheck !== true) return;
1213
- fetch(UPDATE_CHECK_PATH, { method: "GET" })
1214
- .then((response) => response.json())
1215
- .then((payload) => setApplied(payload?.applied))
1216
- .catch(() => {});
1217
- }, [autoCheck]);
1218
- if (applied === undefined || typeof applied.to !== "string") return null;
1219
- return h("div", { className: css.hint, key: "text" }, updateAppliedText(t, applied));
1220
- }
1221
-
1222
1161
  function NotifySoundsSection({ useConfig, setField, preview, t }) {
1223
1162
  const state = useConfig((snapshot) => snapshot);
1224
1163
  const value = state.status === "ready" ? state.value : undefined;
@@ -1252,22 +1191,16 @@ window.__ModuleLoader__.load({
1252
1191
  const pickerIds = hasCustom ? SOUND_IDS : SOUND_IDS.filter((id) => id !== "custom");
1253
1192
  /** Accept a stored preset the menu actually offers, else fall back. */
1254
1193
  const soundValue = (raw, fallback) => (typeof raw === "string" && pickerIds.includes(raw) ? raw : fallback);
1255
- // Snap to an interval the control actually offers: a hand-edited
1256
- // value such as 7 would otherwise leave the segmented control with
1257
- // no selection at all (its index would be -1).
1258
- const renotifyMinutes = RENOTIFY_INTERVALS.includes(value?.renotifyMinutes)
1259
- ? value.renotifyMinutes
1260
- : RENOTIFY_INTERVALS.reduce((best, option) => (
1261
- Math.abs(option - (Number.isFinite(value?.renotifyMinutes) ? value.renotifyMinutes : 5))
1262
- < Math.abs(best - (Number.isFinite(value?.renotifyMinutes) ? value.renotifyMinutes : 5)) ? option : best
1263
- ), RENOTIFY_INTERVALS[0]);
1194
+ // The interval in force, snapped to a value the control offers — the
1195
+ // same call the reminder timer makes, so the row cannot promise a
1196
+ // cadence the timer does not keep.
1197
+ const renotifyMinutes = renotifyInterval(value?.renotifyMinutes);
1264
1198
  const quietStart = Number.isNaN(timeOfDayToMinutes(value?.quietStart)) ? "22:00" : value.quietStart;
1265
1199
  const quietEnd = Number.isNaN(timeOfDayToMinutes(value?.quietEnd)) ? "08:00" : value.quietEnd;
1266
1200
 
1267
1201
  return h("div", { className: css.section }, [
1268
1202
  h("h2", { className: css.title, key: "title" }, t("title")),
1269
1203
  h("p", { className: css.intro, key: "intro" }, t("intro")),
1270
- h(UpdateNotice, { key: "update-notice", t, autoCheck: value === undefined ? undefined : value.updateAutoCheck !== false }),
1271
1204
  h("div", { className: css.rows, key: "rows" }, [
1272
1205
  h(SwitchRow, {
1273
1206
  key: "enabled",
@@ -1698,18 +1631,29 @@ window.__ModuleLoader__.load({
1698
1631
  setError(null);
1699
1632
  setBusy(true);
1700
1633
  try {
1701
- const bytes = await file.arrayBuffer();
1634
+ // The read and the decode are separate failures with separate
1635
+ // causes, so they get separate sentences: a file that was
1636
+ // moved or whose permission was revoked never reached the
1637
+ // decoder at all, and telling the user it "could not be
1638
+ // decoded" sends them to compress a file that is not there.
1639
+ let bytes;
1640
+ try {
1641
+ bytes = await file.arrayBuffer();
1642
+ } catch {
1643
+ setError("custom.unreadable");
1644
+ return;
1645
+ }
1702
1646
  const decoded = await validateCustomSoundBytes(bytes);
1703
1647
  if (decoded === undefined) {
1704
1648
  setError("custom.undecodable");
1705
1649
  return;
1706
1650
  }
1707
- await onUpload(bytesToBase64(bytes), file.name);
1708
- } catch {
1709
- // A read can fail (the file was moved or its permission
1710
- // revoked between the picker and the read). Say so rather
1711
- // than leaving an unhandled rejection and no feedback.
1712
- setError("custom.undecodable");
1651
+ // A refused write is reported by the row's own `failed`
1652
+ // marker; swallowing a rejection here only keeps it from
1653
+ // surfacing as an unhandled promise.
1654
+ try {
1655
+ await onUpload(bytesToBase64(bytes), file.name);
1656
+ } catch {}
1713
1657
  } finally {
1714
1658
  setBusy(false);
1715
1659
  }
@@ -1825,6 +1769,27 @@ window.__ModuleLoader__.load({
1825
1769
  /** Re-ping interval choices, in minutes. */
1826
1770
  const RENOTIFY_INTERVALS = [1, 5, 15, 30];
1827
1771
 
1772
+ /**
1773
+ * The interval actually in force, snapped to a value the control offers.
1774
+ *
1775
+ * The schema accepts any whole minute from 1 to 60, while the control
1776
+ * offers four; a hand-edited `7` therefore has to be reconciled
1777
+ * somewhere. It is reconciled HERE, once, and both the row and the
1778
+ * reminder use this function — snapping only for display made the
1779
+ * control say "5 min" while reminders fired every seven, which is the
1780
+ * one outcome a settings row must never produce.
1781
+ *
1782
+ * @param value - the stored preference, possibly absent or out of range.
1783
+ * @returns one of `RENOTIFY_INTERVALS`.
1784
+ */
1785
+ const renotifyInterval = (value) => {
1786
+ const raw = Number.isFinite(value) ? value : 5;
1787
+ if (RENOTIFY_INTERVALS.includes(raw)) return raw;
1788
+ return RENOTIFY_INTERVALS.reduce((best, option) => (
1789
+ Math.abs(option - raw) < Math.abs(best - raw) ? option : best
1790
+ ), RENOTIFY_INTERVALS[0]);
1791
+ };
1792
+
1828
1793
  /**
1829
1794
  * The repeat-reminder row: its toggle and, beside it, the interval
1830
1795
  * SegmentedControl (disabled until the toggle is on).
@@ -1929,15 +1894,14 @@ window.__ModuleLoader__.load({
1929
1894
  }
1930
1895
 
1931
1896
  /**
1932
- * The update row: what this installation runs, what npm has, and the
1933
- * buttons to ask and to install.
1897
+ * The update row: what this installation runs, what npm has, and one
1898
+ * button to ask.
1934
1899
  *
1935
- * The check is a read-only question; the update is one POST to the
1936
- * Host, which does the downloading, verifying and swapping itself —
1937
- * the page never touches the package. Both routes are fixed to this
1938
- * plugin's own package, so there is no input here to aim anywhere
1939
- * else, and the Host answers recent questions from its own cache, so
1940
- * opening the section costs no network at all.
1900
+ * The check is a read-only question to this plugin's own Host route.
1901
+ * Installing is the package manager's job (since 1.8.0), so the row
1902
+ * reports and points rather than acting; the route takes no input from
1903
+ * here, and the Host answers from a cache, so opening the section
1904
+ * costs no network at all.
1941
1905
  *
1942
1906
  * @param props - copy.
1943
1907
  * @returns the row element tree.
@@ -1955,37 +1919,80 @@ window.__ModuleLoader__.load({
1955
1919
  }
1956
1920
  };
1957
1921
  // A cached answer makes this free; only a cold Host reaches out.
1958
- // Same guard as the notice: `=== true` (not `!== false`), because
1959
- // during the loading frame `undefined` must not be treated as
1960
- // permission to reach out.
1922
+ // `=== true` (not `!== false`), because during the loading frame
1923
+ // `undefined` must not be treated as permission to reach out.
1961
1924
  useEffect(() => {
1962
1925
  if (autoCheck !== true) return;
1963
1926
  check(false)();
1964
1927
  }, [autoCheck]);
1965
1928
  const payload = state.payload;
1966
1929
  const latest = payload?.latest === undefined ? undefined : formatVersion(payload.latest);
1967
- let status = payload === undefined
1968
- ? t("update.idle")
1969
- : payload.updateAvailable === true
1970
- ? `${t("update.available")} ${latest}`
1971
- : t("update.uptodate");
1930
+ // A payload without a readable current version is NOT "up to date":
1931
+ // that would turn "I could not read it" into a claim, and hide the
1932
+ // one state where reinstalling is the actual fix.
1933
+ const currentKnown = payload !== undefined && typeof payload.current === "string" && payload.current !== "";
1934
+ // Only an answer that actually came back can report a failure. A
1935
+ // forced re-check must not keep showing the previous failure while
1936
+ // the new request is in the air.
1937
+ const failed = state.phase === "failed" || (state.phase === "done" && payload?.ok === false);
1938
+ let status;
1972
1939
  if (state.phase === "checking") status = t("update.checking");
1973
- if (state.phase === "failed") status = t("update.failed");
1974
- const detail = payload === undefined ? status : `${t("update.current")} ${formatVersion(payload.current)} · ${status}`;
1975
- // The install belongs to the package manager now: this plugin only
1976
- // checks, and every route below installs THROUGH pnpm so the
1977
- // profile's lockfile moves with the files. The two shells reach it
1978
- // differently, so the row spells out the one that applies here.
1940
+ else if (failed) status = t("update.failed");
1941
+ else if (payload === undefined) status = t("update.idle");
1942
+ else if (!currentKnown) status = t("update.currentUnknown");
1943
+ else if (payload.updateAvailable === true) status = `${t("update.available")} ${latest}`;
1944
+ else status = t("update.uptodate");
1945
+ const detail = currentKnown && !failed
1946
+ ? `${t("update.current")} ${formatVersion(payload.current)} · ${status}`
1947
+ : status;
1948
+ // The install belongs to the package manager now: each line below
1949
+ // spells out the one route that applies here, and every one of them
1950
+ // pins the version. A release younger than pnpm's
1951
+ // `minimumReleaseAge` window is otherwise resolved back to the
1952
+ // previous one — which is exactly what "I upgraded but the version
1953
+ // did not move" looks like from the outside.
1954
+ // Two different reasons to send someone to the package manager, and
1955
+ // both need the steps spelled out:
1956
+ //
1957
+ // - a newer version exists (`updateAvailable`);
1958
+ // - the install is not registered (`unregistered`) — the state a
1959
+ // self-updated tree is in: the files say 1.8.6, the lockfile
1960
+ // still records 1.7.0, and the next plugin operation would roll
1961
+ // it back. This is the one prompt that makes an in-app update
1962
+ // permanent, so it must not point at steps that are not shown.
1963
+ const installTarget = installTargetVersion(payload, currentKnown);
1979
1964
  const lines = [];
1965
+ if (failed) {
1966
+ // The Host's own `error` is one fixed English sentence; this
1967
+ // line says what it means, without claiming a cause it cannot
1968
+ // know (an offline machine and a mirror returning HTML look
1969
+ // identical from here).
1970
+ lines.push(h("div", { className: css.hint, key: "why" }, t("update.failedDetail")));
1971
+ }
1980
1972
  if (payload?.unregistered === true) {
1981
1973
  lines.push(h("div", { className: css.hint, key: "unregistered" }, t("update.unregistered", {
1982
1974
  recorded: formatVersion(payload.recorded),
1983
1975
  })));
1984
1976
  }
1985
- if (payload?.updateAvailable === true && latest !== undefined) {
1986
- lines.push(h("div", { className: css.hint, key: "howto" }, isDesktopShell()
1987
- ? t("update.desktopGuidance")
1988
- : t("update.webGuidance", { profile: payload.profile ?? "web", version: latest })));
1977
+ if (installTarget !== undefined && installTarget !== "") {
1978
+ const version = packageVersion(installTarget);
1979
+ let guidance;
1980
+ if (isDesktopShell()) {
1981
+ guidance = t("update.desktopGuidance", { version });
1982
+ } else if (typeof payload.profile === "string" && payload.profile !== "") {
1983
+ guidance = t("update.webGuidance", { profile: payload.profile, version });
1984
+ } else {
1985
+ // No profile name is known, so the command must not invent
1986
+ // one: `--profile web` pointed at a profile that does not
1987
+ // exist and failed for a reason that had nothing to do with
1988
+ // the install.
1989
+ guidance = t("update.webGuidancePlain", { version });
1990
+ }
1991
+ lines.push(h("div", { className: css.hint, key: "howto" }, guidance));
1992
+ // Its own line: the steps above are what to do, this is why the
1993
+ // version cannot be dropped — and a parcel of text this long
1994
+ // reads as one dense block when it is glued onto the steps.
1995
+ lines.push(h("div", { className: css.hint, key: "pinned" }, t("update.versionPinned")));
1989
1996
  }
1990
1997
  return h("div", { className: css.row }, [
1991
1998
  h("div", { key: "text" }, [
@@ -2415,23 +2422,27 @@ window.__ModuleLoader__.load({
2415
2422
  }
2416
2423
  return false;
2417
2424
  };
2418
- /** Route the Host answers a session's goal phase on. */
2419
- const GOAL_PHASE_PATH = "/notify-sounds/goal";
2420
2425
  /** How often an active goal's phase is re-read while waiting for its end. */
2421
2426
  const GOAL_POLL_MS = 2000;
2422
2427
  /** Longest a goal may stay `active` before its watcher gives up. */
2423
2428
  const GOAL_SETTLE_TIMEOUT_MS = 30 * 60 * 1000;
2429
+ /** A single phase read gives up rather than parking the watcher for good. */
2430
+ const GOAL_FETCH_TIMEOUT_MS = 5000;
2424
2431
  /**
2425
2432
  * One goal phase from the Host, or undefined when it cannot be read.
2426
2433
  *
2427
2434
  * The Host owns the durable projection; this page's own store only
2428
2435
  * carries sessions it has opened, and a completion edge can belong to a
2429
2436
  * session this page never opened. Undefined keeps the old behaviour
2430
- * (alert) for any runtime that does not answer the route.
2437
+ * (alert) for any runtime that does not answer the route — and for a
2438
+ * read that timed out, which is the same "cannot know, so speak" case.
2431
2439
  */
2432
2440
  const readGoalPhase = async (sessionId) => {
2433
2441
  try {
2434
- const response = await fetch(GOAL_PHASE_PATH + "?session=" + encodeURIComponent(sessionId), { method: "GET" });
2442
+ const signal = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function"
2443
+ ? AbortSignal.timeout(GOAL_FETCH_TIMEOUT_MS)
2444
+ : undefined;
2445
+ const response = await fetch(GOAL_PHASE_PATH + "?session=" + encodeURIComponent(sessionId), { method: "GET", signal });
2435
2446
  if (!response.ok) return undefined;
2436
2447
  const payload = await response.json();
2437
2448
  return typeof payload?.phase === "string" ? payload.phase : undefined;
@@ -2471,7 +2482,13 @@ window.__ModuleLoader__.load({
2471
2482
  goalWatchers.delete(sessionId);
2472
2483
  clearTimeout(pollTimer);
2473
2484
  clearTimeout(deadline);
2474
- if (chime) alertCompletion(sessionId);
2485
+ // The decision to wait was taken at the edge, but the wait
2486
+ // can last half an hour — long enough for the user to turn
2487
+ // the switch OFF precisely because they did not want this
2488
+ // chime. Re-read it here: a preference that was honoured
2489
+ // when it arrived must not be ignored when it leaves.
2490
+ const stillWanted = form.getSnapshot().value?.goalFinalOnly === true;
2491
+ if (chime && stillWanted) alertCompletion(sessionId);
2475
2492
  };
2476
2493
  goalWatchers.set(sessionId, () => finish(false));
2477
2494
  const poll = async () => {
@@ -2622,7 +2639,7 @@ window.__ModuleLoader__.load({
2622
2639
  refreshFlash();
2623
2640
  const value = form.getSnapshot().value;
2624
2641
  if (value?.renotify !== true || pending.size === 0) return;
2625
- const minutes = Number.isFinite(value?.renotifyMinutes) ? Math.max(1, value.renotifyMinutes) : 5;
2642
+ const minutes = renotifyInterval(value?.renotifyMinutes);
2626
2643
  const intervalMs = minutes * 60000;
2627
2644
  const now = Date.now();
2628
2645
  const watching = pageIsWatched();
@@ -2753,21 +2770,12 @@ window.__ModuleLoader__.load({
2753
2770
  exports.inject = inject;
2754
2771
  // Shared contracts with the Host half, exported so the tests can
2755
2772
  // cross-check the two spellings against each other (test/host.mjs):
2756
- // the preset id list behind the Config union, the focus route the
2757
- // notification click posts to, and the time parser every quiet-hours
2758
- // value must survive.
2773
+ // the preset id list behind the Config union, the three routes this
2774
+ // half calls, and the time parser every quiet-hours value must survive.
2759
2775
  exports.SOUND_IDS = SOUND_IDS;
2760
2776
  exports.FOCUS_PATH = FOCUS_PATH;
2761
2777
  exports.UPDATE_CHECK_PATH = UPDATE_CHECK_PATH;
2762
- // Which update stages a retry can fix and which need a fresh install:
2763
- // the Host names a stage in every failure it reports, so this map is the
2764
- // one place the advice comes from, and the tests cross-check it against
2765
- // the stage words the dictionaries carry.
2766
- exports.UPDATE_STAGES = UPDATE_STAGES;
2767
- // The two composed messages, so the tests can assert the sentences a
2768
- // user actually reads rather than only the dictionary keys behind them.
2769
- exports.updateAppliedText = updateAppliedText;
2770
- exports.updateFailureText = updateFailureText;
2778
+ exports.GOAL_PHASE_PATH = GOAL_PHASE_PATH;
2771
2779
  // The alert election, so the tests can drive two pages against a stubbed
2772
2780
  // storage and channel instead of hoping two real tabs cooperate.
2773
2781
  exports.createAlertLeader = createAlertLeader;
@@ -2775,6 +2783,12 @@ window.__ModuleLoader__.load({
2775
2783
  // scan apart from a strided one.
2776
2784
  exports.normalizedGain = normalizedGain;
2777
2785
  exports.timeOfDayToMinutes = timeOfDayToMinutes;
2786
+ // The interval the row shows and the timer keeps, so a test can prove
2787
+ // the two agree instead of reading the same number twice.
2788
+ exports.renotifyInterval = renotifyInterval;
2789
+ // Which version an install instruction names, so the "make this update
2790
+ // permanent" case cannot silently become a dead end again.
2791
+ exports.installTargetVersion = installTargetVersion;
2778
2792
  return module.exports;
2779
2793
  },
2780
2794
  });