@cowwo/dsh-icon-custom 0.10.1 → 0.11.1

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 CHANGED
@@ -43,6 +43,38 @@ Notes:
43
43
  - It can be toggled at any time after upload — no re-upload needed.
44
44
  - This only affects the sidebar brand mark (the one next to `deepseek HARNESS`); PWA/installed-app icons are a separate option above.
45
45
 
46
+ ## App icon badge (the red dot on the system icon)
47
+
48
+ Once the page is **installed as an app**, the same unread number is also shown on the **system icon**. The switch lives in **Settings → icon管理 → unread badge** ("Badge the system app icon") and is **on by default**; the number is **exactly the page number** (one computation, no extra clamping), so the two can never disagree.
49
+
50
+ > ⚠️ **What it looks like on Windows depends on which browser installed the app**:
51
+ > - **Installed with Chrome**: Chrome **draws its own overlay icon** on the taskbar button — a **dark circle with the white number**, at the lower-right of the icon. **Not a red dot, and not part of our artwork.** Above 99 it shows `99+`.
52
+ > - **Installed with Edge**: Edge goes through the **Windows badge channel**, so the badge uses the system style. Microsoft's docs say both empty and numeric badges are supported, but on Windows 11 this machine still drew only a system-blue dot when calling `navigator.setAppBadge(7)` directly from DevTools (2026-10-01). The plugin now has a **5-second low-frequency watchdog** that keeps re-asserting a positive number, but whether Edge/Windows renders it as a number or a dot is ultimately up to the system. For a reliable number, installing with Chrome is the current workaround.
53
+ >
54
+ > Either way it exists **only while the app window is open**: close the window and it is gone (that is not a bug), and nothing can refresh it afterwards (there is no push server, so with no page running there is no code running).
55
+
56
+ Platform support as of 0.11.1:
57
+
58
+ | Platform | Badge | Status |
59
+ |---|---|---|
60
+ | Windows **Chrome** 81+ | Dark circle + **white number** (Chrome's own overlay icon, lower-right) | Confirmed by Chrome's docs screenshot + Chromium source; **not yet measured on this machine** |
61
+ | Windows **Edge** 81+ | Goes through the Windows badge channel; Microsoft's docs support numbers, but this machine measured the number being drawn as a dot | **Measured anomaly** (2026-10-01) |
62
+ | macOS Chrome / Edge 81+ | Dock badge | Unverified (the code is ready; same single call) |
63
+ | macOS Safari 17+ ("Add to Dock") | Dock badge | Unverified |
64
+ | iOS / iPadOS Safari 16.4+ (Add to Home Screen) | Number on the home-screen icon | Unverified; and it appears **only after notifications have been granted** — this plugin does not request that permission yet, so no badge on iOS for now |
65
+ | Android Chrome / Edge | — | **Not supported by the system**: Chromium compiles the badge implementation out on Android, and a launcher dot can only come from a **notification** (this plugin posts none) |
66
+ | HarmonyOS | — | No public evidence of any implementation; **expected unsupported** (native badges there are ArkTS-only) |
67
+ | Firefox / Linux Chromium | — | No API / no OS API — calls resolve and do nothing |
68
+
69
+ The "Current environment" line in the settings page reports **local facts only** (secure context? API present? installed as an app?). It never promises a badge will appear, because **detecting the API is not delivery** (that is exactly the Android and Linux case, and iOS adds a permission on top). Whether it really shows is up to the system.
70
+
71
+ Two behaviours that look like bugs but are not:
72
+
73
+ - **A "manual test" number typed in a normal browser tab produces no taskbar badge** — correct: the badge belongs to the **installed app window**; a plain tab's call is silently ignored.
74
+ - **No badge when the page is reached over LAN `http://192.168.x.x`** — the Badging API requires a secure context; the settings page then says to use localhost or https.
75
+
76
+ The reasoning (why no notifications, why no service worker, why no platform gating) is recorded in [`docs/adr/0005-app-icon-badge.md`](./docs/adr/0005-app-icon-badge.md).
77
+
46
78
  ## Format support
47
79
 
48
80
  - **SVG** — recognized by content (`<svg`), validated against script / event‑handler / `javascript:` injection. Rasterized to PNG in the browser before upload.
package/README.zh.md CHANGED
@@ -58,14 +58,47 @@ SVG 会在浏览器里先栅格化成 512×512 PNG 再上传,ICO 则由后端提
58
58
  规则:
59
59
 
60
60
  - 数字 = **几个会话**在等你(不是几件事);同一个会话只占一格。
61
- - 只有子代理会话不计。**你正开着的那个会话也算**——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分;要清掉它,切走再切回来即可。
61
+ - 只有子代理会话不计。**你正开着的那个会话也算**——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分;**要清掉它,切走再切回来即可**(你人已经在里面时又结束的那一轮不会自动消,见下一条)。
62
62
  - **已读记在每个浏览器自己身上**(localStorage):进过 = 处理过,刷新/重启不会"复活";装插件之前的历史结束也不算。
63
- - **可以要求"待够才算已读"**:设置页填一个秒数(默认 0)。填 0 = 进去就算已读(红点立即消失);填 N = 你要在那个会话里**连续待满 N 秒**它才算看过——中途切走、或它又跑完一轮,都清零重数,刷新页面也重新数。上限 600 秒。
63
+ - **可以要求"待够才算已读"**:设置页填一个秒数(默认 0)。填 0 = 进去就算已读(红点立即消失);填 N = 你要在那个会话里**连续待满 N 秒**它才算看过,中途切走就清零重数,刷新页面也重新数。上限 600 秒。
64
+ **计时只认"你进入会话时已经存在的那次结束"**:你人已经在会话里的时候它又跑完一轮,那一轮**不算数**——红点会一直亮着,等你**切走再进来**才消。这样"我正看着的会话跑完了"这件事不会被几秒钟后自动抹掉(0.11.1 起;此前是"停留满 N 秒就把最新那次结束也标成已读",红点只闪一下)。
64
65
  - 数据全部来自官方:`turn/end` 事件经官方 session projection 折叠,加上官方的"等待交互"注册表——**不依赖任何其他插件**。
65
66
  - 设置页的**数据来源**可以切到"手动测试",手填数字试看效果。
66
67
  - **工作区和会话行上的红点(实验)**:侧栏每个**工作区**那一行的文件夹图标右上角一个小红点,每个**有未读的会话**在标题右边跟一个小红点——一眼看出"事情在哪个工作区的哪个会话"。这一项是**贴着页面结构**做的——DSH 大版本升级后**可能失效**,失效时只会变成"不显示",**不影响**红点数字、清单和跳转;设置页里有一个开关可以随时关掉它。
67
68
  - **想知道具体是哪个会话?** 侧栏「工作区」标题的**右上角**有一个**红底数字**(没有待处理时是灰色 0,位置不变);点开是待处理会话清单,**按工作区分组**(工作区名 · 会话名 · 在等你/刚结束),点其中一条**直接跳到那个会话**。
68
69
 
70
+ ## 应用图标角标(系统图标上的红点)
71
+
72
+ 把网页**安装为应用**后,同一个未读数字也会显示在**系统图标**上。设置在**设置 → icon管理 → 未读红点**里的「在系统应用图标上显示红点」,**默认打开**;数字与页内红点**严格同值**(同一份计算,不额外截断),所以两个地方永远一致。
73
+
74
+ > ⚠️ **Windows 上它长什么样——取决于你用什么浏览器装**:
75
+ > - **Chrome 装的**:Chrome **自己画**一张覆盖图标贴在任务栏按钮上——**深灰圆 + 白色数字**,位置在图标右下角。**不是红色圆点,也不是我们图标的一部分**。数字超过 99 显示 `99+`。
76
+ > - **Edge 装的**:Edge 走 **Windows 系统徽章通道**,外观是系统样式。官方文档说支持数字和空角标,但本机在 Windows 11 上实测(2026-10-01)控制台直接 `navigator.setAppBadge(7)` 也只出现一个系统蓝点。插件已加 **5 秒低频看门狗**,会持续重设正向数字,但最终画成数字还是点由 Edge/Windows 决定;想要稳定数字,可用 Chrome 安装。
77
+ >
78
+ > 两种情况下都:**只在应用窗口开着的时候存在**——关掉窗口角标就消失(这不是插件坏了),窗口全关之后也不会再更新数字(没有推送服务,页面不在就没有代码在跑)。
79
+
80
+ 平台支持情况(截至 0.11.1):
81
+
82
+ | 平台 | 角标 | 状态 |
83
+ |---|---|---|
84
+ | Windows **Chrome** 81+ | 深灰圆 + **白色数字**(Chrome 自己画的覆盖图标,右下角) | 官方文档示例图 + 源码印证;**本机待实测** |
85
+ | Windows **Edge** 81+ | 走 Windows 系统徽章通道;官方文档支持数字,但本机实测数字被画成点(待 Edge/Windows 侧修复) | **本机实测异常**(2026-10-01) |
86
+ | macOS Chrome / Edge 81+ | Dock 角标 | 未验证(代码已就绪,同一行不会额外做事) |
87
+ | macOS Safari 17+(「添加到程序坞」) | Dock 角标 | 未验证 |
88
+ | iOS / iPadOS Safari 16.4+(加到主屏) | 主屏图标上的数字 | 未验证;且**必须先授予通知权限**才会显示——本插件目前不申请通知权限,所以 iOS 上暂时不会出现 |
89
+ | Android Chrome / Edge | —— | **系统不支持**:Chromium 在安卓上把角标实现编译掉了;启动器上的圆点只能由**通知**产生(本插件不发通知) |
90
+ | 鸿蒙 | —— | 无任何公开实现证据,**预计不支持**(鸿蒙的原生角标只能用 ArkTS 设) |
91
+ | Firefox / Linux Chromium | —— | 不支持 / 没有系统 API,调用静默无效 |
92
+
93
+ 设置页里那行「当前环境」只报**本机此刻的事实**(是不是安全上下文、有没有这个 API、是不是已安装的应用),它**不**承诺角标一定会出现——因为**探测到 API ≠ 会显示**(安卓、Linux 上就是如此;iOS 还多一道通知权限)。是否真正显示由系统决定。
94
+
95
+ 顺便说明两个容易被当成 bug 的现象:
96
+
97
+ - **在浏览器标签页里填「手动测试」数字,任务栏没有角标** —— 这是对的:角标只属于**安装后的应用窗口**,普通标签页调用是静默无操作。
98
+ - **局域网 `http://192.168.x.x` 访问时没有角标** —— Badging API 要求安全上下文,这时设置页会提示"请用 localhost 或 https 访问"。
99
+
100
+ 设计取舍(为什么不做通知、不做 service worker、为什么不按平台门禁)记在 [`docs/adr/0005-app-icon-badge.md`](./docs/adr/0005-app-icon-badge.md)。
101
+
69
102
  ## 支持的格式
70
103
 
71
104
  - **SVG** — 按内容(`<svg`)识别,并拦截 `<script>` / 事件属性 / `javascript:` 注入。上传前会在浏览器里栅格化成 PNG。
package/client/client.js CHANGED
@@ -60,12 +60,27 @@ window.__ModuleLoader__.load({
60
60
  unreadWaiting: "在等你",
61
61
  unreadEnded: "刚结束,还没看",
62
62
  unreadPanelOther: "其他",
63
+ unreadMarkAllRead: "全部标记已读",
64
+ /** 与官方侧栏同一形状("13小时"),方便和列表里的时间戳逐字对上。 */
65
+ ageNow: "刚刚",
66
+ ageMinutes: "{n}分钟",
67
+ ageHours: "{n}小时",
68
+ ageDays: "{n}天",
69
+ ageMonths: "{n}个月",
70
+ ageYears: "{n}年",
63
71
  workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
64
72
  workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
73
+ appBadgeLabel: "在系统应用图标上显示红点",
74
+ appBadgeHint: "安装为应用后,把同一个数字也放到系统图标上:Windows 是任务栏图标上的角标,macOS 是 Dock 角标,iOS 是主屏图标上的数字。样子由系统决定——Windows 上 Chrome 画深色圆+白字,Edge 走 Windows 系统徽章通道(是否显示数字由 Edge/Windows 决定);插件会周期重设数字,但只在应用窗口开着时更新。",
75
+ appBadgeFootnote: "角标只在应用窗口开着时由本插件更新;数字超过 99 时由系统显示为 99+。",
76
+ appBadgeProbeInstalled: "当前环境:已安装为应用,本机可以设置角标。是否真正显示由系统决定。",
77
+ appBadgeProbeTab: "当前环境:浏览器标签页(不是安装的应用)——安装为应用后才会显示角标。",
78
+ appBadgeProbeUnsupported: "当前环境:本机没有这个能力(当前浏览器不支持应用角标)。",
79
+ appBadgeProbeInsecure: "当前环境:不是安全上下文(请用 localhost 或 https 访问),系统角标不可用。",
65
80
  badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
66
81
  clearDelayLabel: "进入会话后多久算已读:",
67
82
  clearDelayUnit: "秒",
68
- clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走、或它又跑完一轮,都会清零重数;刷新页面也重新数。上限 600 秒。"
83
+ clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600 秒。"
69
84
  };
70
85
  const en = {
71
86
  nav: "Favicon",
@@ -118,12 +133,27 @@ window.__ModuleLoader__.load({
118
133
  unreadWaiting: "waiting for you",
119
134
  unreadEnded: "ended, not seen yet",
120
135
  unreadPanelOther: "Other",
136
+ unreadMarkAllRead: "Mark all read",
137
+ /** Same shape as the shipped sidebar ("13h"), so the two can be read side by side. */
138
+ ageNow: "now",
139
+ ageMinutes: "{n}min",
140
+ ageHours: "{n}h",
141
+ ageDays: "{n}d",
142
+ ageMonths: "{n}mo",
143
+ ageYears: "{n}y",
121
144
  workspaceDotLabel: "Dots on workspace and session rows (experimental)",
122
145
  workspaceDotHint: "Adds a small red dot to the folder icon of each workspace row. This one reads the page structure, so a DSH upgrade may break it; when it does it simply stops showing, never affecting the counts, the list, or navigation. Turn it off any time.",
146
+ appBadgeLabel: "Badge the system app icon",
147
+ appBadgeHint: "Once installed as an app, the same number also goes to the system icon: a taskbar badge on Windows, a Dock badge on macOS, a number on the iOS home-screen icon. The system decides how it looks — on Windows, Chrome draws a dark circle with white text, while Edge goes through the Windows badge channel (whether it shows the number is up to Edge/Windows). The plugin re-asserts the number periodically, but only while the app window is open.",
148
+ appBadgeFootnote: "The badge is only updated by this plugin while the app window is open; above 99 the system shows 99+.",
149
+ appBadgeProbeInstalled: "This environment: installed as an app, so this device can set a badge. Whether it actually appears is up to the system.",
150
+ appBadgeProbeTab: "This environment: a browser tab (not an installed app) — install as an app to get a badge.",
151
+ appBadgeProbeUnsupported: "This environment: no app-badge capability in this browser.",
152
+ appBadgeProbeInsecure: "This environment: not a secure context (use localhost or https) — the system badge is unavailable.",
123
153
  badgeTestHint: "Real unread = a session ended for one of the checked reasons and you have not looked at it yet (opening a session marks it read). The session you are viewing counts too — the window may be minimised or the page in the background, so this half cannot tell whether you are looking, and does not pretend to. Only sub-agents never count. Manual = type a number to preview. Resets to Real on reload.",
124
154
  clearDelayLabel: "Mark read after staying:",
125
155
  clearDelayUnit: "seconds",
126
- clearDelayHint: "0 = read as soon as you enter (the dot clears at once). Above 0 you must stay in that session for this many seconds before it counts as read: leaving it, or a fresh ending in it, resets the clock, and so does a reload. Capped at 600 seconds."
156
+ clearDelayHint: "0 = read as soon as you enter (the dot clears at once). Above 0 you must stay in that session for this many seconds before it counts as read: leaving it resets the clock, and so does a reload. The clock only ever covers **the ending that was already there when you entered** — a turn that finishes while you are sitting in the session does not count: it stays red until you leave and come back. Capped at 600 seconds."
127
157
  };
128
158
  //#endregion
129
159
 
@@ -239,7 +269,9 @@ window.__ModuleLoader__.load({
239
269
  const UNREAD_FALLBACK_REASONS = { completed: true, error: true, blocked: true, "max-tokens": true, interrupted: true, "aborted:user": false, "aborted:other": true };
240
270
  /** Mirror of the host's cap (see lib/unread.js) — 10 minutes. */
241
271
  const UNREAD_CLEAR_DELAY_MAX_SEC = 600;
242
- let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, clearDelaySec: 0 };
272
+ /** Mirror of the host's default (see lib/unread.js) — badging the app icon is on. */
273
+ const UNREAD_APP_BADGE_FALLBACK = true;
274
+ let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, appBadge: UNREAD_APP_BADGE_FALLBACK, clearDelaySec: 0 };
243
275
  const unreadConfigListeners = new Set();
244
276
  /** Mirror of the host's normalizer: any input becomes an integer 0..cap seconds. */
245
277
  function normalizeClearDelay(input) {
@@ -260,33 +292,83 @@ window.__ModuleLoader__.load({
260
292
  reasons,
261
293
  pending: typeof source.pending === "boolean" ? source.pending : true,
262
294
  workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true,
295
+ appBadge: typeof source.appBadge === "boolean" ? source.appBadge : UNREAD_APP_BADGE_FALLBACK,
263
296
  clearDelaySec: normalizeClearDelay(source.clearDelaySec)
264
297
  };
265
298
  }
266
299
  function emitUnreadConfig(input) {
267
300
  unreadConfig = normalizeUnreadConfig(input);
268
301
  unreadConfigListeners.forEach((fn) => { try { fn(unreadConfig); } catch {} });
302
+ // The rule (or just the app-badge switch) moved: the OS badge follows it
303
+ // in the same breath, so toggling it applies without a reload.
304
+ reconcileAppBadge();
269
305
  }
270
306
  function subscribeUnreadConfig(fn) {
271
307
  unreadConfigListeners.add(fn);
272
308
  return () => { unreadConfigListeners.delete(fn); };
273
309
  }
274
310
 
275
- const SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v1";
311
+ /**
312
+ * The seen watermark lives in the HOST's clock domain.
313
+ *
314
+ * A turn end arrives stamped with the Host's `turn/end.time`; a mark written
315
+ * from the browser's `Date.now()` only lines up while both clocks agree. A
316
+ * browser a few seconds behind the Host therefore wrote marks that never
317
+ * reached `endAt`, so the dot could not be cleared by entering the session at
318
+ * all — the one failure users report as "点进去也不消". Marks now store the
319
+ * `endAt` they acknowledge, and `lastActiveAt` stores a host-domain time, so
320
+ * every comparison in `collectUnread` stays inside one clock.
321
+ */
322
+ const SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v2";
323
+ /** 0.10.1 and earlier wrote browser timestamps here; migrated once. */
324
+ const LEGACY_SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v1";
276
325
  const SEEN_MAX = 400;
326
+ /** Host clock minus browser clock, from the last Host sample; null until known. */
327
+ let hostClockOffset = null;
328
+ /** This browser's clock expressed in the Host's domain (best effort). */
329
+ function hostNow() {
330
+ return Date.now() + (hostClockOffset === null ? 0 : hostClockOffset);
331
+ }
277
332
  function loadSeenState() {
278
333
  try {
279
334
  const raw = JSON.parse(window.localStorage.getItem(SEEN_STORE_KEY) || "null");
280
- if (raw === null || typeof raw !== "object") return { lastActiveAt: 0, seen: {} };
281
- const seen = raw.seen !== null && typeof raw.seen === "object" ? raw.seen : {};
282
- return { lastActiveAt: typeof raw.lastActiveAt === "number" ? raw.lastActiveAt : 0, seen };
283
- } catch { return { lastActiveAt: 0, seen: {} }; }
335
+ if (raw !== null && typeof raw === "object" && raw.domain === "host") {
336
+ const seen = raw.seen !== null && typeof raw.seen === "object" ? raw.seen : {};
337
+ return { domain: "host", lastActiveAt: typeof raw.lastActiveAt === "number" ? raw.lastActiveAt : 0, seen };
338
+ }
339
+ const legacy = JSON.parse(window.localStorage.getItem(LEGACY_SEEN_STORE_KEY) || "null");
340
+ if (legacy !== null && typeof legacy === "object") {
341
+ const seen = legacy.seen !== null && typeof legacy.seen === "object" ? legacy.seen : {};
342
+ return { domain: "browser", lastActiveAt: typeof legacy.lastActiveAt === "number" ? legacy.lastActiveAt : 0, seen };
343
+ }
344
+ } catch { /* fall through to an empty store */ }
345
+ return { domain: "host", lastActiveAt: 0, seen: {} };
284
346
  }
285
347
  const seenState = loadSeenState();
286
- /** When this browser last had the app open before this page load. */
348
+ /** When this browser last had the app open before this page load (host domain). */
287
349
  const seenBootAt = seenState.lastActiveAt > 0 ? seenState.lastActiveAt : 0;
288
350
  function saveSeenState() {
289
- try { window.localStorage.setItem(SEEN_STORE_KEY, JSON.stringify(seenState)); } catch {}
351
+ try {
352
+ window.localStorage.setItem(SEEN_STORE_KEY, JSON.stringify({ domain: "host", lastActiveAt: seenState.lastActiveAt, seen: seenState.seen }));
353
+ } catch {}
354
+ }
355
+ /**
356
+ * Learn the Host↔browser clock offset from a Host timestamp, and fold a
357
+ * legacy browser-domain store into the host domain. Idempotent: the fold
358
+ * happens once, on the first sample that carries a usable `hostNow`.
359
+ * @param hostAt - the Host's `Date.now()`, or anything else (ignored).
360
+ */
361
+ function noteHostClock(hostAt) {
362
+ if (typeof hostAt !== "number" || !Number.isFinite(hostAt) || hostAt <= 0) return;
363
+ const offset = hostAt - Date.now();
364
+ hostClockOffset = offset;
365
+ if (seenState.domain === "host") return;
366
+ seenState.domain = "host";
367
+ if (seenState.lastActiveAt > 0) seenState.lastActiveAt += offset;
368
+ for (const id of Object.keys(seenState.seen)) {
369
+ if (typeof seenState.seen[id] === "number") seenState.seen[id] += offset;
370
+ }
371
+ saveSeenState();
290
372
  }
291
373
  function pruneSeenState() {
292
374
  const ids = Object.keys(seenState.seen);
@@ -294,10 +376,20 @@ window.__ModuleLoader__.load({
294
376
  ids.sort((a, b) => seenState.seen[b] - seenState.seen[a]);
295
377
  for (const id of ids.slice(SEEN_MAX)) delete seenState.seen[id];
296
378
  }
297
- /** Record that this browser is looking at a session right now. */
298
- function noteSeen(sessionId) {
379
+ /**
380
+ * Record that this browser has seen a session up to `seenAt` — the
381
+ * host-domain `endAt` being acknowledged. Marks never move backwards, so a
382
+ * late attempt cannot undo a newer one.
383
+ * @param sessionId - the session that was looked at.
384
+ * @param seenAt - host-domain watermark to store; defaults to the Host's now.
385
+ * @returns whether the mark actually advanced.
386
+ */
387
+ function noteSeen(sessionId, seenAt) {
299
388
  if (typeof sessionId !== "string" || sessionId === "") return false;
300
- seenState.seen[sessionId] = Date.now();
389
+ const mark = typeof seenAt === "number" && Number.isFinite(seenAt) ? seenAt : hostNow();
390
+ const previous = seenState.seen[sessionId];
391
+ if (typeof previous === "number" && previous >= mark) return false;
392
+ seenState.seen[sessionId] = mark;
301
393
  return true;
302
394
  }
303
395
  /**
@@ -307,7 +399,7 @@ window.__ModuleLoader__.load({
307
399
  * @returns whether anything was learned (callers persist only then).
308
400
  */
309
401
  function ensureSeenBaseline(ids) {
310
- const fallback = seenBootAt > 0 ? seenBootAt : Date.now();
402
+ const fallback = seenBootAt > 0 ? seenBootAt : hostNow();
311
403
  let changed = false;
312
404
  for (const id of ids) {
313
405
  if (typeof id !== "string" || id === "") continue;
@@ -318,53 +410,46 @@ window.__ModuleLoader__.load({
318
410
  return changed;
319
411
  }
320
412
  /**
321
- * The session the main view is showing.
413
+ * Every session the main view is showing.
322
414
  *
323
- * DSH 0.2.x carries NO `current` field in the session-list snapshot — it is
324
- * written with exactly `ids` / `byId` / `phase` / `projectionsBySession`. The
325
- * official signal is the row's `retainedBy.mainView` retention count, the same
326
- * one ui-layout's DocumentTitle, ui-cordis, ui-open-in-app and ui-session read.
327
- * Reading `list.current` here made `noteSeen()` a no-op, so the seen watermark
328
- * never advanced and an unread dot could never be cleared by opening the
329
- * session. Runtimes that do expose `current` are still honoured first.
415
+ * The official signal is the row's `retainedBy.mainView` retention count, the
416
+ * same one ui-layout's DocumentTitle, ui-cordis, ui-open-in-app and ui-session
417
+ * read (`list.current` is honoured first for runtimes that expose it).
418
+ *
419
+ * It returns ALL of them, not one. The pane retains the incoming session
420
+ * BEFORE it releases the outgoing one, and a retention that outlives its pane
421
+ * can leave an old session looking "current" for good — and picking a single
422
+ * id then meant the session you were actually reading was never marked read:
423
+ * the dot stayed red however long you stayed in it. Each retained session
424
+ * gets its own stay clock instead.
330
425
  * @param list - the session list snapshot (may be absent).
331
- * @returns the id being viewed, or undefined.
426
+ * @returns the ids being viewed, in list order; `[]` when there are none.
332
427
  */
333
- function currentSessionId(list) {
334
- if (list === null || typeof list !== "object") return undefined;
335
- if (typeof list.current === "string" && list.current !== "") return list.current;
428
+ function currentSessionIds(list) {
429
+ if (list === null || typeof list !== "object") return [];
430
+ if (typeof list.current === "string" && list.current !== "") return [list.current];
336
431
  const byId = list.byId;
337
- if (byId === null || typeof byId !== "object") return undefined;
432
+ if (byId === null || typeof byId !== "object") return [];
433
+ const ids = [];
338
434
  for (const session of Object.values(byId)) {
339
435
  if (session === null || typeof session !== "object") continue;
340
436
  const retainedBy = session.retainedBy;
341
437
  const count = retainedBy !== null && typeof retainedBy === "object" ? retainedBy.mainView : undefined;
342
- if (typeof count === "number" && count > 0) return session.id;
438
+ if (typeof count === "number" && count > 0 && typeof session.id === "string" && session.id !== "") ids.push(session.id);
343
439
  }
344
- return undefined;
440
+ return ids;
345
441
  }
346
442
  /**
347
- * What the "stay here long enough" timer is armed for, or "" when nothing
348
- * should be armed.
349
- *
350
- * Keyed on the session's own `lastTurnEnd.endAt`, deliberately NOT on the
351
- * collected item's `at`: a "waiting for you" item carries `at: Date.now()`,
352
- * which changes on every evaluation — keying on that would tear the timer
353
- * down and re-arm it forever, and a waiting session could never be marked
354
- * read. Including `endAt` is also what makes a NEW ending restart the clock
355
- * instead of inheriting the previous one's progress.
356
- * @param sessionId - the session being viewed.
357
- * @param list - the session list snapshot.
358
- * @returns the key, or "" when there is no session to arm for.
443
+ * One session's last `turn/end` timestamp (host domain), or 0 when it has
444
+ * none. The projection is what the Host folded; an unknown reason still
445
+ * carries the time.
446
+ * @param entry - the session-list row (may be absent).
447
+ * @returns the epoch milliseconds, or 0.
359
448
  */
360
- function stayKeyFor(sessionId, list) {
361
- if (typeof sessionId !== "string" || sessionId === "") return "";
362
- const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
363
- const entry = byId[sessionId];
449
+ function lastTurnEndAt(entry) {
364
450
  const values = entry !== null && typeof entry === "object" ? entry.projectionValues : undefined;
365
451
  const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
366
- const endAt = value !== null && typeof value === "object" && typeof value.endAt === "number" ? value.endAt : 0;
367
- return `${sessionId}\u0000${endAt}`;
452
+ return value !== null && typeof value === "object" && typeof value.endAt === "number" ? value.endAt : 0;
368
453
  }
369
454
  /**
370
455
  * How many sessions deserve the badge right now: endings whose reason is
@@ -473,6 +558,8 @@ window.__ModuleLoader__.load({
473
558
  // The header badge is a DOM node, not a listener, so it is reconciled
474
559
  // here as well as from the mutation watchdog. Idempotent either way.
475
560
  paintHeaderBadge();
561
+ // Same number, second consumer: the OS badge behind the app icon.
562
+ reconcileAppBadge();
476
563
  }
477
564
  /** Manual test input. */
478
565
  function emitBadge(value) {
@@ -504,6 +591,180 @@ window.__ModuleLoader__.load({
504
591
  return count > 99 ? "99+" : String(count);
505
592
  }
506
593
 
594
+ //#endregion
595
+ //#region app badge (OS app icon)
596
+ // The badge the OPERATING SYSTEM draws on the icon of the installed app: the
597
+ // taskbar overlay on Windows, the Dock badge on macOS, the number on an iOS
598
+ // home-screen icon. It is the same number the page badge shows, projected
599
+ // onto the system icon — one number, two consumers.
600
+ //
601
+ // Three facts shape this region (all checked against primary sources; see
602
+ // docs/adr/0005-app-icon-badge.md):
603
+ // * detecting the API is NOT delivery. Chromium on Android exposes
604
+ // `setAppBadge` and silently ignores it, and iOS only draws the badge
605
+ // once notifications have been granted. So the probe reports LOCAL facts
606
+ // and never promises that a badge will appear;
607
+ // * the OS owns the rendering. On Windows the badge is a browser-drawn
608
+ // overlay on the taskbar button of an OPEN window: closing the window
609
+ // drops it, and nothing can refresh it afterwards (no push server);
610
+ // * the API is write-only, so this half keeps its own count — the very
611
+ // `effectiveCount()` the favicon and the header badge already use.
612
+ //
613
+ // Everything that DECIDES is a pure function, so a test drives it with no
614
+ // browser at all; exactly one function touches the API.
615
+ /** Display modes that mean "this window IS the installed app", not a tab. */
616
+ const APP_DISPLAY_MODES = ["standalone", "fullscreen", "minimal-ui", "window-controls-overlay"];
617
+
618
+ /**
619
+ * Normalize what this device is, as three independent local facts.
620
+ *
621
+ * Deliberately NOT "does this platform support it": the method that works
622
+ * on Windows resolves and does nothing on Android, and Apple draws the
623
+ * badge only after notifications are granted. Neither is answerable from
624
+ * inside the page, so the probe stays silent about them and the copy says
625
+ * "whether it actually appears is up to the system" instead.
626
+ * @param env - `{ secure, hasApi, installed }`; anything non-boolean reads false.
627
+ * @returns the same three flags, normalized.
628
+ */
629
+ function badgeCapabilityProbe(env) {
630
+ const source = env !== null && typeof env === "object" ? env : {};
631
+ return {
632
+ secure: source.secure === true,
633
+ hasApi: source.hasApi === true,
634
+ installed: source.installed === true
635
+ };
636
+ }
637
+
638
+ /**
639
+ * Read those facts off the window this code runs in.
640
+ *
641
+ * "Installed" is a display-mode match rather than `standalone` alone: the
642
+ * platform manifest asks for `fullscreen`, so an installed window matches
643
+ * THAT mode, and a standalone-only test would report "browser tab" forever.
644
+ * @returns the probe for this device.
645
+ */
646
+ function appBadgeEnv() {
647
+ const nav = typeof navigator === "object" && navigator !== null ? navigator : {};
648
+ let installed = nav.standalone === true;
649
+ if (!installed && typeof window !== "undefined" && typeof window.matchMedia === "function") {
650
+ for (const mode of APP_DISPLAY_MODES) {
651
+ try {
652
+ if (window.matchMedia(`(display-mode: ${mode})`).matches === true) { installed = true; break; }
653
+ } catch {}
654
+ }
655
+ }
656
+ return badgeCapabilityProbe({
657
+ secure: typeof window !== "undefined" && window.isSecureContext === true,
658
+ hasApi: typeof nav.setAppBadge === "function" && typeof nav.clearAppBadge === "function",
659
+ installed
660
+ });
661
+ }
662
+
663
+ /**
664
+ * What the OS badge should say for a given count.
665
+ *
666
+ * The number is handed over AS IS (no `99+` clamping here): the system
667
+ * saturates it in its own way, and duplicating that rule is how the two
668
+ * projections start disagreeing. Switching the feature off clears the
669
+ * badge rather than abandoning it — a number left behind after "off"
670
+ * reads as a bug.
671
+ * @param count - the unread count, i.e. the number the page badge shows.
672
+ * @param enabled - the `appBadge` setting; only `true` enables it.
673
+ * @returns `{ kind: "set", value }` or `{ kind: "clear" }`.
674
+ */
675
+ function appBadgeEffect(count, enabled) {
676
+ const value = enabled === true ? normalizeCount(count) : 0;
677
+ return value > 0 ? { kind: "set", value } : { kind: "clear" };
678
+ }
679
+
680
+ /**
681
+ * Which self-check line the settings page shows for this device.
682
+ *
683
+ * A missing probe is NOT reported as "insecure": that message tells the
684
+ * user to change how they reach the page, which would be chasing the
685
+ * wrong thing. Only a real reading may claim that.
686
+ * @param probe - a `badgeCapabilityProbe` result.
687
+ * @returns a locale key.
688
+ */
689
+ function appBadgeCapabilityKey(probe) {
690
+ if (probe === null || typeof probe !== "object") return "appBadgeProbeUnsupported";
691
+ const facts = badgeCapabilityProbe(probe);
692
+ if (facts.secure !== true) return "appBadgeProbeInsecure";
693
+ if (facts.hasApi !== true) return "appBadgeProbeUnsupported";
694
+ return facts.installed === true ? "appBadgeProbeInstalled" : "appBadgeProbeTab";
695
+ }
696
+
697
+ /** The last effect actually handed to the API, so a repeat is a no-op. */
698
+ let lastAppBadgeId = null;
699
+
700
+ /**
701
+ * The ONE place that talks to the Badging API.
702
+ *
703
+ * Idempotence is required here, not polish: `notifyBadge` fires on every
704
+ * badge change AND again from the DOM watchdog that re-asserts the tab
705
+ * badge, and each call costs an IPC to the browser process plus a taskbar
706
+ * repaint. Only a real change in the projected effect reaches the API.
707
+ * @param effect - an `appBadgeEffect` result.
708
+ * @param force - re-send even when the effect is unchanged. Used only by
709
+ * the low-frequency OS-badge watchdog, because Windows/Edge can replace a
710
+ * numeric badge with a notification dot after our call succeeded.
711
+ */
712
+ function applyAppBadge(effect, force) {
713
+ const intent = effect !== null && typeof effect === "object" ? effect : {};
714
+ // Anything that is not a positive number is a clear, so a bogus effect
715
+ // can never hand the system a `setAppBadge(0)`.
716
+ const id = intent.kind === "set" && typeof intent.value === "number" && intent.value > 0 ? `set:${intent.value}` : "clear";
717
+ if (id === lastAppBadgeId && force !== true) return;
718
+ const nav = typeof navigator === "object" && navigator !== null ? navigator : null;
719
+ if (nav === null || typeof nav.setAppBadge !== "function" || typeof nav.clearAppBadge !== "function") return;
720
+ lastAppBadgeId = id;
721
+ try {
722
+ // Always a number, never the argument-less "flag" form: on WebKit a
723
+ // no-argument call can REMOVE a badge that is already showing.
724
+ const settled = id === "clear" ? nav.clearAppBadge() : nav.setAppBadge(intent.value);
725
+ if (settled !== null && typeof settled === "object" && typeof settled.catch === "function") {
726
+ settled.catch(() => {
727
+ // The API rejected: the browser did NOT apply this effect, so
728
+ // forget it and let the next reconcile/retry try again. Guard
729
+ // against a newer call having replaced the remembered effect.
730
+ if (lastAppBadgeId === id) lastAppBadgeId = null;
731
+ });
732
+ }
733
+ } catch {
734
+ // A synchronous throw means nothing was applied, so the next
735
+ // reconcile retries instead of remembering a phantom success.
736
+ lastAppBadgeId = null;
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Reconcile the OS badge with the page badge.
742
+ *
743
+ * Both inputs are the page's own: the number the favicon and the header
744
+ * badge already show, and the rule the host persists. Called from
745
+ * `emitUnreadConfig` (rule arrived / switch moved) and `notifyBadge`
746
+ * (number moved) — together those cover every way the badge can change.
747
+ * @param force - re-send even if the projected effect is unchanged.
748
+ */
749
+ function reconcileAppBadge(force) {
750
+ applyAppBadge(appBadgeEffect(effectiveCount(), unreadConfig.appBadge === true), force === true);
751
+ }
752
+
753
+ /**
754
+ * Keep a positive numeric badge asserted while the app window is alive.
755
+ *
756
+ * Windows/Edge can render a notification glyph (a dot) over the numeric
757
+ * badge after we set it; unlike the favicon, the OS badge then has no
758
+ * watcher to put the number back. Re-send only when the plugin's own
759
+ * projection says there SHOULD be a number, and never when it says clear —
760
+ * so a count of 0 is not repeatedly cleared and no phantom badge appears.
761
+ */
762
+ function reassertAppBadge() {
763
+ if (unreadConfig.appBadge !== true) return;
764
+ if (normalizeCount(effectiveCount()) <= 0) return;
765
+ reconcileAppBadge(true);
766
+ }
767
+
507
768
  //#endregion
508
769
  //#region favicon compositing
509
770
  // The browser accepts exactly ONE image per <link rel="icon">, so a tab
@@ -724,6 +985,19 @@ window.__ModuleLoader__.load({
724
985
  unreadPanelListeners.add(fn);
725
986
  return () => { unreadPanelListeners.delete(fn); };
726
987
  }
988
+ /**
989
+ * Tells the badge source that the marks moved somewhere it did not touch —
990
+ * the panel's "mark all read". It must re-render to re-emit the number (which
991
+ * also repaints the row dots and rebuilds the tab badge).
992
+ */
993
+ const unreadPokeListeners = new Set();
994
+ function emitUnreadPoke() {
995
+ unreadPokeListeners.forEach((fn) => { try { fn(); } catch {} });
996
+ }
997
+ function subscribeUnreadPoke(fn) {
998
+ unreadPokeListeners.add(fn);
999
+ return () => { unreadPokeListeners.delete(fn); };
1000
+ }
727
1001
  function toggleUnreadPanel(anchor) {
728
1002
  emitUnreadPanel(!unreadPanelOpen, anchor === undefined ? null : anchor);
729
1003
  }
@@ -773,6 +1047,97 @@ window.__ModuleLoader__.load({
773
1047
  }
774
1048
  return groups;
775
1049
  }
1050
+ /**
1051
+ * Age buckets, in the shipped sidebar's own units.
1052
+ *
1053
+ * Same thresholds as the official `relativeTime`: under a minute "now", then
1054
+ * minutes / hours / days / months / years. Deliberately copied instead of
1055
+ * imported — a display plugin must not depend on a Harness Client package —
1056
+ * but the SHAPE has to match the sidebar's, because the whole point of
1057
+ * showing it here is that a reader can line the panel's "13小时" up with the
1058
+ * timestamp on the row itself.
1059
+ * @param at - host-domain epoch milliseconds.
1060
+ * @param now - host-domain now.
1061
+ * @returns `{ unit, n }`, `unit` in `now|minutes|hours|days|months|years`.
1062
+ */
1063
+ function relativeAgeParts(at, now) {
1064
+ const MINUTE = 60000, HOUR = 3600000, DAY = 86400000;
1065
+ const diff = Math.max(0, now - at);
1066
+ if (diff < MINUTE) return { unit: "now", n: 0 };
1067
+ if (diff < HOUR) return { unit: "minutes", n: Math.floor(diff / MINUTE) };
1068
+ if (diff < DAY) return { unit: "hours", n: Math.floor(diff / HOUR) };
1069
+ if (diff < 30 * DAY) return { unit: "days", n: Math.floor(diff / DAY) };
1070
+ if (diff < 365 * DAY) return { unit: "months", n: Math.floor(diff / (30 * DAY)) };
1071
+ return { unit: "years", n: Math.floor(diff / (365 * DAY)) };
1072
+ }
1073
+ /**
1074
+ * One ending's age as text ("13小时" / "13h"), or "" when there is no time.
1075
+ * @param at - host-domain epoch milliseconds, 0/absent for "unknown".
1076
+ * @param now - host-domain now.
1077
+ * @param t - locale binder.
1078
+ * @returns the label.
1079
+ */
1080
+ function unreadAgeLabel(at, now, t) {
1081
+ if (typeof at !== "number" || !Number.isFinite(at) || at <= 0) return "";
1082
+ const { unit, n } = relativeAgeParts(at, now);
1083
+ // Same shape as the shipped sidebar's own label: the "now" bucket carries
1084
+ // no number.
1085
+ return unit === "now" ? t("ageNow") : t(`age${unit[0].toUpperCase()}${unit.slice(1)}`, { n });
1086
+ }
1087
+ /**
1088
+ * A short, unambiguous handle for a session: the first 8 characters of its id.
1089
+ *
1090
+ * Two sessions can share a title — that is precisely what made one red dot look
1091
+ * unclearable, because the row could not be told apart from its namesake — and
1092
+ * this is what distinguishes them when the titles do not.
1093
+ * @param id - the session id.
1094
+ * @returns the short form, or "".
1095
+ */
1096
+ function shortSessionId(id) {
1097
+ if (typeof id !== "string" || id === "") return "";
1098
+ const bare = id.startsWith("session-") ? id.slice("session-".length) : id;
1099
+ return bare.slice(0, 8);
1100
+ }
1101
+ /**
1102
+ * The dim second line of one panel row: why it is listed, how long ago that
1103
+ * was, and which session it is.
1104
+ * @param item - a row from `collectUnread`.
1105
+ * @param now - host-domain now.
1106
+ * @param t - locale binder.
1107
+ * @returns the line, already joined.
1108
+ */
1109
+ function unreadRowSubtitle(item, now, t) {
1110
+ const parts = [item.waiting === true ? t("unreadWaiting") : t("unreadEnded")];
1111
+ // A "waiting" row's `at` is Date.now() (it has no ending of its own), so only
1112
+ // turn-end rows carry a readable age.
1113
+ if (item.waiting !== true) {
1114
+ const age = unreadAgeLabel(item.at, now, t);
1115
+ if (age !== "") parts.push(age);
1116
+ }
1117
+ const short = shortSessionId(item.id);
1118
+ if (short !== "") parts.push(short);
1119
+ return parts.join(" · ");
1120
+ }
1121
+ /**
1122
+ * What "mark all read" may actually acknowledge: the turn-end rows.
1123
+ *
1124
+ * A "waiting for you" row is not watermark-based at all — it is listed while
1125
+ * that interaction is live, and no mark can clear it — so the caller must
1126
+ * leave it alone.
1127
+ * @param items - rows from `collectUnread`.
1128
+ * @returns `[{ id, seenAt }]`, each carrying the `endAt` it acknowledges.
1129
+ */
1130
+ function unreadAckTargets(items) {
1131
+ const targets = [];
1132
+ for (const item of Array.isArray(items) ? items : []) {
1133
+ if (item === null || typeof item !== "object") continue;
1134
+ if (item.waiting === true) continue;
1135
+ if (typeof item.id !== "string" || item.id === "") continue;
1136
+ if (typeof item.at !== "number" || !Number.isFinite(item.at) || item.at <= 0) continue;
1137
+ targets.push({ id: item.id, seenAt: item.at });
1138
+ }
1139
+ return targets;
1140
+ }
776
1141
  //#endregion
777
1142
 
778
1143
  //#region workspace dots
@@ -834,6 +1199,11 @@ window.__ModuleLoader__.load({
834
1199
  if (workspaceDotFailures < WS_MAX_FAILURES || workspaceDotGivenUp) return;
835
1200
  workspaceDotGivenUp = true;
836
1201
  try { console.warn("dsh-icon-custom: 工作区红点连续失败,本次会话内已自动关闭,可在设置里关掉这一项。"); } catch {}
1202
+ // Take the markers OUT rather than leaving them frozen: a stale red dot
1203
+ // that no longer reflects the rule (or that can never be cleared) is worse
1204
+ // than no marker at all, and "breaks down to not showing" is this feature's
1205
+ // stated posture.
1206
+ clearWorkspaceDots();
837
1207
  }
838
1208
  /** The marker already sitting in this host for that key, or null. */
839
1209
  function existingMarker(host, attribute, key) {
@@ -898,6 +1268,38 @@ window.__ModuleLoader__.load({
898
1268
  if (rect.height < WS_ROW_MIN_H || rect.height > WS_ROW_MAX_H || rect.width < WS_ROW_MIN_W) return null;
899
1269
  return { title: label, row };
900
1270
  }
1271
+ /**
1272
+ * The DOM row of one session, by the official row key.
1273
+ *
1274
+ * ui-workspace stamps every row with `data-row-key` (`session:<id>`,
1275
+ * `workspace:<id>`, `overflow:<id>`), which is the only stable, unambiguous
1276
+ * handle on a row: titles are neither unique (two sessions can share one) nor
1277
+ * stable (a generated title lands later). Matching by title text used to find
1278
+ * two rows and refuse, so a session whose title collided with another's could
1279
+ * never be marked at all.
1280
+ * @param container - the browsing region root.
1281
+ * @param id - the session id.
1282
+ * @returns the row element, or null.
1283
+ */
1284
+ function sessionRowElement(container, id) {
1285
+ if (typeof id !== "string" || id === "") return null;
1286
+ try { return container.querySelector(`[data-row-key="session:${id}"]`); } catch { return null; }
1287
+ }
1288
+ /**
1289
+ * Where one session's marker goes: the tight title holder INSIDE that
1290
+ * session's own row. The row is found by `data-row-key`; the title inside it
1291
+ * is still matched by text, because that is the element the marker hangs off.
1292
+ * Falls back to the old whole-region title match on a build without row keys.
1293
+ * @returns `{ title, row }`, or null when this row cannot be trusted.
1294
+ */
1295
+ function sessionRowTarget(container, id, title) {
1296
+ const row = sessionRowElement(container, id);
1297
+ if (row !== null) {
1298
+ const inside = labelElementFor(row, title);
1299
+ return inside === null ? null : titleTargetFor(inside, title);
1300
+ }
1301
+ return titleTargetFor(labelElementFor(container, title), title);
1302
+ }
901
1303
  function paintWorkspaceDots() {
902
1304
  if (workspaceDotGivenUp) return;
903
1305
  const enabled = unreadConfig.workspaceDot !== false;
@@ -950,8 +1352,7 @@ window.__ModuleLoader__.load({
950
1352
  // Session rows: the marker rides inline right after the title, so it stays
951
1353
  // beside the name whatever the timestamp happens to say.
952
1354
  for (const entry of wantedRows) {
953
- const label = labelElementFor(container, entry.title);
954
- const target = titleTargetFor(label, entry.title);
1355
+ const target = sessionRowTarget(container, entry.id, entry.title);
955
1356
  if (target === null) { missed++; continue; }
956
1357
  if (existingMarker(target.row, ROW_DOT_ATTR, entry.id) !== null) { placed++; continue; }
957
1358
  const dot = document.createElement("span");
@@ -1031,26 +1432,78 @@ window.__ModuleLoader__.load({
1031
1432
  pruneSeenState();
1032
1433
  saveSeenState();
1033
1434
  }, [list]);
1034
- const currentId = currentSessionId(list);
1435
+ const currentIds = currentSessionIds(list);
1035
1436
  const items = collectUnread(list, pending, config, seenState.seen);
1036
1437
  const count = items.length;
1037
- // Being in a session is what marks it read — but only once you have STAYED
1038
- // `clearDelaySec` seconds (0 = the moment you enter, the original
1039
- // behaviour). The timer is torn down whenever you leave, or whenever that
1040
- // session becomes unread again from a fresh ending, so the clock restarts;
1041
- // that teardown IS the "continuous stay, leaving resets it" rule. A reload
1042
- // restarts it too, since the timer only lives in this page.
1043
- const currentUnread = items.some((item) => item.id === currentId);
1044
- const stayKey = currentUnread ? stayKeyFor(currentId, list) : "";
1438
+ // Being in a session is what marks it read — and the clock that does it is
1439
+ // armed by ENTERING the session, never by a new ending.
1440
+ //
1441
+ // `clearDelaySec` still means "stay this long before the ending counts as
1442
+ // seen" (0 = the moment you enter). What is new in 0.11.1 is WHEN the clock
1443
+ // takes its snapshot: the moment the session joins the view. An ending that
1444
+ // lands while you are already sitting there is therefore none of this
1445
+ // clock's business — it stays unread until you leave and come back. The
1446
+ // previous version re-armed on every new ending and chased the newest one,
1447
+ // so a turn that finished while you watched was auto-read `clearDelaySec`
1448
+ // seconds later: the dot for the session in front of you was a flash
1449
+ // nobody could catch. See docs/adr/0006-entry-bounded-stay-clock.md.
1450
+ //
1451
+ // Leaving tears the clock down, which is what makes the stay "continuous".
1452
+ // A reload restarts it too, since the timers only live in this page.
1453
+ //
1454
+ // One clock per session the main view shows: with two retained sessions,
1455
+ // marking only the first left the session you were reading unread forever.
1456
+ const stayIds = currentIds.slice().sort();
1457
+ const staySignature = stayIds.join("|");
1045
1458
  const delayMs = config.clearDelaySec * 1000;
1046
1459
  const [, bumpStay] = React.useState(0);
1460
+ // The panel can move the marks without going through this component
1461
+ // ("mark all read"); re-render so the number, the row dots and the tab
1462
+ // badge all follow immediately.
1463
+ const [, bumpPoke] = React.useState(0);
1464
+ React.useEffect(() => subscribeUnreadPoke(() => bumpPoke((n) => n + 1)), []);
1465
+ // The effect below is keyed on the signature, but the snapshot it marks is
1466
+ // taken from the CURRENT list; a ref is how it sees that list.
1467
+ const stayLive = React.useRef({ list });
1468
+ stayLive.current = { list };
1469
+ /**
1470
+ * Entered-at snapshot: `id → endAt` as it stood when that session joined
1471
+ * the view. Deliberately NOT refreshed while the session stays — that is
1472
+ * the rule ("an ending during your stay waits for the next entry"). A
1473
+ * session that leaves is forgotten, so coming back takes a fresh snapshot.
1474
+ */
1475
+ const stayArmed = React.useRef(new Map());
1047
1476
  React.useEffect(() => {
1048
- if (stayKey === "") return undefined;
1049
- const markRead = () => { if (noteSeen(currentId)) { saveSeenState(); bumpStay((n) => n + 1); } };
1050
- if (delayMs <= 0) { markRead(); return undefined; }
1051
- const timer = window.setTimeout(markRead, delayMs);
1052
- return () => window.clearTimeout(timer);
1053
- }, [stayKey, currentId, delayMs]);
1477
+ const snapshot = stayLive.current.list !== null && typeof stayLive.current.list === "object" && stayLive.current.list.byId !== null && typeof stayLive.current.list.byId === "object" ? stayLive.current.list.byId : {};
1478
+ const live = new Set(stayIds);
1479
+ for (const id of Array.from(stayArmed.current.keys())) {
1480
+ if (!live.has(id)) stayArmed.current.delete(id);
1481
+ }
1482
+ for (const id of stayIds) {
1483
+ if (!stayArmed.current.has(id)) stayArmed.current.set(id, lastTurnEndAt(snapshot[id]));
1484
+ }
1485
+ if (staySignature === "") return undefined;
1486
+ const armed = stayIds.map((id) => ({ id, endAt: stayArmed.current.get(id) }));
1487
+ let cancelled = false;
1488
+ let timer = 0;
1489
+ // Single-shot on purpose: the snapshot is fixed, so there is nothing to
1490
+ // chase. A newer ending found here belongs to the NEXT entry.
1491
+ const attempt = () => {
1492
+ if (cancelled) return;
1493
+ let changed = false;
1494
+ for (const target of armed) {
1495
+ if (typeof target.endAt !== "number" || target.endAt <= 0) continue;
1496
+ if (noteSeen(target.id, target.endAt)) changed = true;
1497
+ }
1498
+ if (changed) { saveSeenState(); bumpStay((n) => n + 1); }
1499
+ };
1500
+ if (delayMs <= 0) attempt();
1501
+ else timer = window.setTimeout(attempt, delayMs);
1502
+ return () => {
1503
+ cancelled = true;
1504
+ if (timer !== 0) window.clearTimeout(timer);
1505
+ };
1506
+ }, [staySignature, delayMs]);
1054
1507
  React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
1055
1508
  // The workspace rows are labelled with the workspace TITLE, which is not
1056
1509
  // always the cwd basename, so map session → title from the official snapshot.
@@ -1187,6 +1640,9 @@ window.__ModuleLoader__.load({
1187
1640
  if (headerBadgeFailures < HEAD_BADGE_MAX_FAILURES || headerBadgeGivenUp) return;
1188
1641
  headerBadgeGivenUp = true;
1189
1642
  try { console.warn("dsh-icon-custom: 工作区标题红点连续失败,本次会话内已自动关闭。"); } catch {}
1643
+ // Same posture as the row dots: give up by disappearing, never by freezing a
1644
+ // number that has stopped following the rule.
1645
+ clearHeaderBadge();
1190
1646
  }
1191
1647
 
1192
1648
  /**
@@ -1339,8 +1795,16 @@ window.__ModuleLoader__.load({
1339
1795
  function UnreadPopup(props) {
1340
1796
  const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
1341
1797
  const [open, setOpen] = React.useState(unreadPanelOpen);
1798
+ // Ages are computed against the HOST's clock (the endings are host
1799
+ // timestamps); re-tick while open so "刚刚" does not stay stale.
1800
+ const [, tick] = React.useState(0);
1342
1801
  React.useEffect(() => subscribeBadge(setSnapshot), []);
1343
1802
  React.useEffect(() => subscribeUnreadPanel(() => setOpen(unreadPanelOpen)), []);
1803
+ React.useEffect(() => {
1804
+ if (!open) return undefined;
1805
+ const interval = window.setInterval(() => tick((n) => n + 1), 30000);
1806
+ return () => window.clearInterval(interval);
1807
+ }, [open]);
1344
1808
  React.useEffect(() => {
1345
1809
  if (!open) return undefined;
1346
1810
  const onKey = (event) => { if (event.key === "Escape") emitUnreadPanel(false); };
@@ -1374,6 +1838,22 @@ window.__ModuleLoader__.load({
1374
1838
  boxShadow: "0 10px 30px rgba(0,0,0,.18)", color: "var(--dsw-alias-label-primary)", fontSize: "12px"
1375
1839
  };
1376
1840
  const items = Array.isArray(snapshot.items) ? snapshot.items : [];
1841
+ const ackTargets = unreadAckTargets(items);
1842
+ /**
1843
+ * Acknowledge every turn-end row at once.
1844
+ *
1845
+ * "Waiting for you" rows cannot be acknowledged (they are not watermark
1846
+ * based), so the panel stays open while any of them remain — closing it
1847
+ * would hide the one thing that still needs the reader.
1848
+ */
1849
+ const markAllRead = () => {
1850
+ let changed = false;
1851
+ for (const target of ackTargets) if (noteSeen(target.id, target.seenAt)) changed = true;
1852
+ if (!changed) return;
1853
+ saveSeenState();
1854
+ emitUnreadPoke();
1855
+ if (ackTargets.length === items.length) emitUnreadPanel(false);
1856
+ };
1377
1857
  return React.createElement("div", { "data-icon-custom-unread": "1", style },
1378
1858
  React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t("unreadPanelTitle")),
1379
1859
  items.length === 0
@@ -1389,10 +1869,17 @@ window.__ModuleLoader__.load({
1389
1869
  style: { display: "block", width: "100%", textAlign: "left", padding: "6px 8px", border: "0", borderRadius: "8px", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer" }
1390
1870
  },
1391
1871
  React.createElement("span", { style: { display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, item.title),
1392
- React.createElement("span", { style: { display: "block", opacity: 0.6, fontSize: "11px" } }, item.waiting === true ? t("unreadWaiting") : t("unreadEnded"))
1872
+ React.createElement("span", { style: { display: "block", opacity: 0.6, fontSize: "11px" } }, unreadRowSubtitle(item, hostNow(), t))
1393
1873
  ))
1394
1874
  ))
1395
- )
1875
+ ),
1876
+ ackTargets.length > 0 && React.createElement("div", { style: { marginTop: "6px", paddingTop: "6px", borderTop: "1px solid var(--dsw-alias-border-l2)" } },
1877
+ React.createElement("button", {
1878
+ type: "button",
1879
+ onClick: markAllRead,
1880
+ style: { display: "block", width: "100%", padding: "5px 8px", border: "0", borderRadius: "8px", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer", opacity: 0.85 }
1881
+ }, t("unreadMarkAllRead"))
1882
+ )
1396
1883
  );
1397
1884
  }
1398
1885
 
@@ -1477,6 +1964,9 @@ window.__ModuleLoader__.load({
1477
1964
  const [badgeSourceValue, setBadgeSourceValue] = React.useState(badgeSource);
1478
1965
  const [unread, setUnread] = React.useState(unreadConfig);
1479
1966
  React.useEffect(() => subscribeUnreadConfig(setUnread), []);
1967
+ // Sampled when the section mounts, which is the moment the user is
1968
+ // looking at it. It reports local facts only (see badgeCapabilityProbe).
1969
+ const [appBadgeProbe] = React.useState(() => appBadgeEnv());
1480
1970
  // Draft text for the delay box: typing must not save on every keystroke,
1481
1971
  // and the box has to show the CLAMPED value once the save lands (type
1482
1972
  // 99999 and it settles on the 600 cap instead of lying about it).
@@ -1547,7 +2037,7 @@ window.__ModuleLoader__.load({
1547
2037
  const next = normalizeUnreadConfig({ ...unreadConfig, ...patch });
1548
2038
  emitUnreadConfig(next);
1549
2039
  rpc.call("/api", "iconCustom/setUnreadRule", {
1550
- args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, clearDelaySec: next.clearDelaySec } }
2040
+ args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, appBadge: next.appBadge, clearDelaySec: next.clearDelaySec } }
1551
2041
  }).catch(() => {});
1552
2042
  }, [rpc]);
1553
2043
  // Toggle one unread reason. Applies locally at once; the host keeps the
@@ -1562,6 +2052,11 @@ window.__ModuleLoader__.load({
1562
2052
  const onWorkspaceDotToggle = React.useCallback((checked) => {
1563
2053
  saveUnreadRule({ workspaceDot: checked });
1564
2054
  }, [saveUnreadRule]);
2055
+ // The system-icon projection of the same number. Saved through the same
2056
+ // one save path, so this field can never be dropped by a caller.
2057
+ const onAppBadgeToggle = React.useCallback((checked) => {
2058
+ saveUnreadRule({ appBadge: checked });
2059
+ }, [saveUnreadRule]);
1565
2060
  // Seconds you must stay in a session before it counts as read; 0 = as soon
1566
2061
  // as you enter. Clamped by the shared normalizer, so a stray keystroke can
1567
2062
  // never persist an absurd delay.
@@ -1830,6 +2325,20 @@ window.__ModuleLoader__.load({
1830
2325
  ),
1831
2326
  React.createElement("div", { style: style.hint }, t("workspaceDotHint"))
1832
2327
  ),
2328
+ React.createElement("div", { style: { marginTop: "10px" } },
2329
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
2330
+ React.createElement("input", {
2331
+ type: "checkbox",
2332
+ checked: unread.appBadge !== false,
2333
+ onChange: (event) => onAppBadgeToggle(event.target.checked === true)
2334
+ }),
2335
+ React.createElement("span", null, t("appBadgeLabel"))
2336
+ ),
2337
+ React.createElement("div", { style: style.hint }, t("appBadgeHint")),
2338
+ // What THIS device is — never a claim about the platform.
2339
+ React.createElement("div", { style: style.hint }, t(appBadgeCapabilityKey(appBadgeProbe))),
2340
+ React.createElement("div", { style: style.hint }, t("appBadgeFootnote"))
2341
+ ),
1833
2342
  React.createElement("div", { style: style.hint }, t("badgeTestHint"))
1834
2343
  ),
1835
2344
  notice ? React.createElement("div", { style: style.notice }, notice) : null,
@@ -1853,12 +2362,21 @@ window.__ModuleLoader__.load({
1853
2362
  .then((resp) => { if (resp && resp.ok === true) syncStatus(resp.value); })
1854
2363
  .catch(() => {});
1855
2364
  rpc.call("/api", "iconCustom/getUnreadRule", { args: {} })
1856
- .then((resp) => { if (resp && resp.ok === true) emitUnreadConfig(resp.value); })
2365
+ .then((resp) => {
2366
+ if (resp === null || typeof resp !== "object" || resp.ok !== true) return;
2367
+ // The rule response carries the Host's clock; it is what puts the seen
2368
+ // watermark (and a legacy browser-domain store) into the Host's domain.
2369
+ if (resp.value !== null && typeof resp.value === "object") noteHostClock(resp.value.hostNow);
2370
+ emitUnreadConfig(resp.value);
2371
+ })
1857
2372
  .catch(() => {});
1858
2373
  // Keep "the last moment this browser was here" fresh, so an ending that
1859
2374
  // happens while the app is closed still counts as unread when you return.
1860
2375
  ctx.effect(() => {
1861
- const touch = () => { seenState.lastActiveAt = Date.now(); saveSeenState(); };
2376
+ // Host domain (see the seen store): "the last moment this browser was
2377
+ // here" must be comparable with `turn/end.time`, not with the browser's
2378
+ // own clock.
2379
+ const touch = () => { seenState.lastActiveAt = hostNow(); saveSeenState(); };
1862
2380
  const interval = window.setInterval(touch, 20000);
1863
2381
  window.addEventListener("visibilitychange", touch);
1864
2382
  window.addEventListener("beforeunload", touch);
@@ -1869,6 +2387,22 @@ window.__ModuleLoader__.load({
1869
2387
  touch();
1870
2388
  };
1871
2389
  }, "dsh-icon-custom: unread watermark");
2390
+ // Re-sample the Host↔browser clock offset after a sleep/resume, which is the
2391
+ // one moment the two can drift apart mid-page. Throttled: it is one small
2392
+ // RPC per visible-again, and never on beforeunload (nothing would come back).
2393
+ ctx.effect(() => {
2394
+ let sampledAt = 0;
2395
+ const sample = () => {
2396
+ if (document.visibilityState === "hidden") return;
2397
+ if (Date.now() - sampledAt < 60000) return;
2398
+ sampledAt = Date.now();
2399
+ rpc.call("/api", "iconCustom/getUnreadRule", { args: {} })
2400
+ .then((resp) => { if (resp && resp.ok === true && resp.value !== null && typeof resp.value === "object") noteHostClock(resp.value.hostNow); })
2401
+ .catch(() => {});
2402
+ };
2403
+ window.addEventListener("visibilitychange", sample);
2404
+ return () => window.removeEventListener("visibilitychange", sample);
2405
+ }, "dsh-icon-custom: host clock sample");
1872
2406
  // Brand-mark seat: replaces only the whale mark (verified: a
1873
2407
  // third-party registration wins over the official occupant and the
1874
2408
  // official package's mark steps aside). The brand NAME text is a
@@ -1947,7 +2481,10 @@ window.__ModuleLoader__.load({
1947
2481
  observer.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ["href", "rel"] });
1948
2482
  window.addEventListener("visibilitychange", schedule);
1949
2483
  window.addEventListener("focus", schedule);
1950
- const interval = window.setInterval(reconcileFavicon, 5000);
2484
+ const interval = window.setInterval(() => {
2485
+ reconcileFavicon();
2486
+ reassertAppBadge();
2487
+ }, 5000);
1951
2488
  return () => {
1952
2489
  observer.disconnect();
1953
2490
  window.removeEventListener("visibilitychange", schedule);
package/lib/index.js CHANGED
@@ -459,17 +459,17 @@ export class FaviconCustomService extends TypertRemoteService {
459
459
  * icon RPCs (`getStatus` / `setIcon` / `setPwa` / `setLogo` / `resetIcon`)
460
460
  * keep their EXACT pre-badge response shape, so a problem in this feature
461
461
  * can never break the icon itself.
462
- * @returns `{ reasons, pending }`.
462
+ * @returns `{ reasons, pending, workspaceDot, appBadge, clearDelaySec, hostNow }`.
463
463
  */
464
464
  async getUnreadRule() {
465
- return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot, clearDelaySec: this.unread.clearDelaySec }
465
+ return this._unreadRuleView()
466
466
  }
467
467
 
468
468
  /**
469
469
  * typert endpoint: replace the unread-badge rule — which turn-end reasons
470
470
  * light the badge. The rule is applied on the client, so this takes effect
471
471
  * without re-folding any session log.
472
- * @param request - `{ reasons, pending?, workspaceDot?, clearDelaySec? }`; unknown or missing keys fall back to defaults.
472
+ * @param request - `{ reasons, pending?, workspaceDot?, appBadge?, clearDelaySec? }`; unknown or missing keys fall back to defaults.
473
473
  * @returns the stored rule.
474
474
  */
475
475
  async setUnreadRule(request) {
@@ -478,7 +478,28 @@ export class FaviconCustomService extends TypertRemoteService {
478
478
  }
479
479
  this.unread = normalizeUnreadConfig(request)
480
480
  await this._saveUnread().catch(() => {}) // rule file is advisory
481
- return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot, clearDelaySec: this.unread.clearDelaySec }
481
+ return this._unreadRuleView()
482
+ }
483
+
484
+ /**
485
+ * The rule as the client reads it, plus the Host's clock.
486
+ *
487
+ * `hostNow` is here because the badge compares the Host's `turn/end.time`
488
+ * against a watermark this browser keeps: without a Host time sample the
489
+ * browser has to guess that domain from its own clock, and a browser a few
490
+ * seconds behind the Host then clears nothing. It rides this existing
491
+ * response instead of a new endpoint, so an older client simply ignores it.
492
+ * @returns the stored rule with `hostNow`.
493
+ */
494
+ _unreadRuleView() {
495
+ return {
496
+ reasons: this.unread.reasons,
497
+ pending: this.unread.pending,
498
+ workspaceDot: this.unread.workspaceDot,
499
+ appBadge: this.unread.appBadge,
500
+ clearDelaySec: this.unread.clearDelaySec,
501
+ hostNow: Date.now()
502
+ }
482
503
  }
483
504
 
484
505
  /** typert endpoint: store a new icon uploaded as a data URL and make it active. */
@@ -53,14 +53,23 @@ const unreadRuleSchema = z.object({
53
53
  pending: z.boolean(),
54
54
  /** 实验项:工作区图标上的红点(贴产品 DOM,可一键关闭)。 */
55
55
  workspaceDot: z.boolean(),
56
+ /** 在系统应用图标上显示角标(安装为应用后生效);只决定"设几/清空",样子由系统决定。 */
57
+ appBadge: z.boolean(),
56
58
  /** 进入会话后要连续待满多少秒才算"看过";0 = 进入即已读。上限 10 分钟。 */
57
- clearDelaySec: z.number().int().nonnegative().max(600)
59
+ clearDelaySec: z.number().int().nonnegative().max(600),
60
+ /**
61
+ * 宿主此刻的 `Date.now()`。
62
+ * 客户端用它把"看过"水位换算进宿主时钟域(`turn/end.time` 也是宿主时钟):
63
+ * 浏览器与宿主差几秒,红点就会永远清不掉。
64
+ */
65
+ hostNow: z.number()
58
66
  })
59
67
 
60
68
  const setUnreadRuleRequestSchema = z.object({
61
69
  reasons: z.record(z.string(), z.boolean()),
62
70
  pending: z.boolean().optional(),
63
71
  workspaceDot: z.boolean().optional(),
72
+ appBadge: z.boolean().optional(),
64
73
  clearDelaySec: z.number().int().nonnegative().max(600).optional()
65
74
  })
66
75
 
@@ -272,7 +281,7 @@ export const TYPERT = {
272
281
  {
273
282
  kind: 'method',
274
283
  name: 'getUnreadRule',
275
- signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number }>',
284
+ signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; appBadge: boolean; clearDelaySec: number; hostNow: number }>',
276
285
  description: "Read the unread-badge rule.",
277
286
  summary: "Read the unread-badge rule.",
278
287
  jsDoc: "/** Read the unread-badge rule. */",
@@ -281,7 +290,7 @@ export const TYPERT = {
281
290
  {
282
291
  kind: 'method',
283
292
  name: 'setUnreadRule',
284
- signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean; clearDelaySec?: number }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number }>',
293
+ signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean; appBadge?: boolean; clearDelaySec?: number }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; appBadge: boolean; clearDelaySec: number; hostNow: number }>',
285
294
  description: "Replace the unread-badge rule: which turn-end reasons light the red dot.",
286
295
  summary: "Set the unread-badge rule.",
287
296
  jsDoc: "/** Set the unread-badge rule. */",
package/lib/unread.js CHANGED
@@ -52,6 +52,16 @@ export const UNREAD_PENDING_DEFAULT = true
52
52
  */
53
53
  export const UNREAD_WORKSPACE_DOT_DEFAULT = true
54
54
 
55
+ /**
56
+ * 默认:在系统应用图标上显示角标(安装为应用后生效)。
57
+ *
58
+ * 它只是"同一个数字在系统图标上的投影",所以默认跟随红点一起打开;
59
+ * 关掉它等于"不要这个投影",而不等于"这个功能不存在"。
60
+ * 注意:**探测到 API ≠ 角标会出现**(安卓与 Linux 上方法存在但静默无效,
61
+ * iOS 上还额外要求通知权限),详见 docs/adr/0005-app-icon-badge.md。
62
+ */
63
+ export const UNREAD_APP_BADGE_DEFAULT = true
64
+
55
65
  /**
56
66
  * 默认:进入会话即视为已读(0 秒)。
57
67
  * 大于 0 时,你必须在那个会话里**连续**待满这么多秒它才算"看过";中途切走会
@@ -87,8 +97,8 @@ export function normalizeReasons(input) {
87
97
 
88
98
  /**
89
99
  * 把任意输入夹成完整设置。
90
- * @param input - `{ reasons?, pending?, workspaceDot?, clearDelaySec? }`,可以是脏的、缺项的、null
91
- * @returns `{ reasons, pending, workspaceDot, clearDelaySec }` —— 每一项都完整
100
+ * @param input - `{ reasons?, pending?, workspaceDot?, appBadge?, clearDelaySec? }`,可以是脏的、缺项的、null
101
+ * @returns `{ reasons, pending, workspaceDot, appBadge, clearDelaySec }` —— 每一项都完整
92
102
  */
93
103
  export function normalizeUnreadConfig(input) {
94
104
  const source = input !== null && typeof input === 'object' ? input : {}
@@ -96,6 +106,7 @@ export function normalizeUnreadConfig(input) {
96
106
  reasons: normalizeReasons(source.reasons),
97
107
  pending: typeof source.pending === 'boolean' ? source.pending : UNREAD_PENDING_DEFAULT,
98
108
  workspaceDot: typeof source.workspaceDot === 'boolean' ? source.workspaceDot : UNREAD_WORKSPACE_DOT_DEFAULT,
109
+ appBadge: typeof source.appBadge === 'boolean' ? source.appBadge : UNREAD_APP_BADGE_DEFAULT,
99
110
  clearDelaySec: normalizeClearDelay(source.clearDelaySec)
100
111
  }
101
112
  }
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-icon-custom",
3
- "version": "0.10.1",
4
- "description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists, auto-sizes PWA icons for Android/iOS/tablets.",
3
+ "version": "0.11.1",
4
+ "description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标,并可把未读数字显示为系统应用图标角标(Windows 任务栏)。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists, auto-sizes PWA icons for Android/iOS/tablets, and projects the unread count onto the system app-icon badge (Windows taskbar).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "license": "MIT",
8
+ "scripts": {
9
+ "test": "node test/app-badge.mjs && node test/unread-panel.mjs && node test/unread-clear.mjs"
10
+ },
8
11
  "dependencies": {
9
12
  "jimp": "^1.6.1",
10
13
  "zod": "^4.4.3"