@cowwo/dsh-icon-custom 0.10.2 → 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
@@ -70,10 +70,17 @@ window.__ModuleLoader__.load({
70
70
  ageYears: "{n}年",
71
71
  workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
72
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 访问),系统角标不可用。",
73
80
  badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
74
81
  clearDelayLabel: "进入会话后多久算已读:",
75
82
  clearDelayUnit: "秒",
76
- clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走、或它又跑完一轮,都会清零重数;刷新页面也重新数。上限 600 秒。"
83
+ clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600 秒。"
77
84
  };
78
85
  const en = {
79
86
  nav: "Favicon",
@@ -136,10 +143,17 @@ window.__ModuleLoader__.load({
136
143
  ageYears: "{n}y",
137
144
  workspaceDotLabel: "Dots on workspace and session rows (experimental)",
138
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.",
139
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.",
140
154
  clearDelayLabel: "Mark read after staying:",
141
155
  clearDelayUnit: "seconds",
142
- 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."
143
157
  };
144
158
  //#endregion
145
159
 
@@ -255,7 +269,9 @@ window.__ModuleLoader__.load({
255
269
  const UNREAD_FALLBACK_REASONS = { completed: true, error: true, blocked: true, "max-tokens": true, interrupted: true, "aborted:user": false, "aborted:other": true };
256
270
  /** Mirror of the host's cap (see lib/unread.js) — 10 minutes. */
257
271
  const UNREAD_CLEAR_DELAY_MAX_SEC = 600;
258
- 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 };
259
275
  const unreadConfigListeners = new Set();
260
276
  /** Mirror of the host's normalizer: any input becomes an integer 0..cap seconds. */
261
277
  function normalizeClearDelay(input) {
@@ -276,12 +292,16 @@ window.__ModuleLoader__.load({
276
292
  reasons,
277
293
  pending: typeof source.pending === "boolean" ? source.pending : true,
278
294
  workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true,
295
+ appBadge: typeof source.appBadge === "boolean" ? source.appBadge : UNREAD_APP_BADGE_FALLBACK,
279
296
  clearDelaySec: normalizeClearDelay(source.clearDelaySec)
280
297
  };
281
298
  }
282
299
  function emitUnreadConfig(input) {
283
300
  unreadConfig = normalizeUnreadConfig(input);
284
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();
285
305
  }
286
306
  function subscribeUnreadConfig(fn) {
287
307
  unreadConfigListeners.add(fn);
@@ -431,25 +451,6 @@ window.__ModuleLoader__.load({
431
451
  const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
432
452
  return value !== null && typeof value === "object" && typeof value.endAt === "number" ? value.endAt : 0;
433
453
  }
434
- /**
435
- * What the "stay here long enough" timer is armed for, or "" when nothing
436
- * should be armed.
437
- *
438
- * Keyed on the session's own `lastTurnEnd.endAt`, deliberately NOT on the
439
- * collected item's `at`: a "waiting for you" item carries `at: Date.now()`,
440
- * which changes on every evaluation — keying on that would tear the timer
441
- * down and re-arm it forever, and a waiting session could never be marked
442
- * read. Including `endAt` is also what makes a NEW ending restart the clock
443
- * instead of inheriting the previous one's progress.
444
- * @param sessionId - the session being viewed.
445
- * @param list - the session list snapshot.
446
- * @returns the key, or "" when there is no session to arm for.
447
- */
448
- function stayKeyFor(sessionId, list) {
449
- if (typeof sessionId !== "string" || sessionId === "") return "";
450
- const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
451
- return `${sessionId}\u0000${lastTurnEndAt(byId[sessionId])}`;
452
- }
453
454
  /**
454
455
  * How many sessions deserve the badge right now: endings whose reason is
455
456
  * enabled and that this browser has not seen since, plus sessions waiting
@@ -557,6 +558,8 @@ window.__ModuleLoader__.load({
557
558
  // The header badge is a DOM node, not a listener, so it is reconciled
558
559
  // here as well as from the mutation watchdog. Idempotent either way.
559
560
  paintHeaderBadge();
561
+ // Same number, second consumer: the OS badge behind the app icon.
562
+ reconcileAppBadge();
560
563
  }
561
564
  /** Manual test input. */
562
565
  function emitBadge(value) {
@@ -588,6 +591,180 @@ window.__ModuleLoader__.load({
588
591
  return count > 99 ? "99+" : String(count);
589
592
  }
590
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
+
591
768
  //#endregion
592
769
  //#region favicon compositing
593
770
  // The browser accepts exactly ONE image per <link rel="icon">, so a tab
@@ -1258,20 +1435,26 @@ window.__ModuleLoader__.load({
1258
1435
  const currentIds = currentSessionIds(list);
1259
1436
  const items = collectUnread(list, pending, config, seenState.seen);
1260
1437
  const count = items.length;
1261
- // Being in a session is what marks it read — but only once you have STAYED
1262
- // `clearDelaySec` seconds (0 = the moment you enter, the original
1263
- // behaviour). A clock is torn down whenever you leave, or whenever that
1264
- // session becomes unread again from a fresh ending, so the clock restarts;
1265
- // that teardown IS the "continuous stay, leaving resets it" rule. A reload
1266
- // restarts it too, since the timers only live in this page.
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.
1267
1453
  //
1268
1454
  // One clock per session the main view shows: with two retained sessions,
1269
1455
  // marking only the first left the session you were reading unread forever.
1270
- const unreadIds = new Set(items.map((item) => item.id));
1271
- const staySignature = currentIds
1272
- .filter((id) => unreadIds.has(id))
1273
- .map((id) => stayKeyFor(id, list))
1274
- .join("|");
1456
+ const stayIds = currentIds.slice().sort();
1457
+ const staySignature = stayIds.join("|");
1275
1458
  const delayMs = config.clearDelaySec * 1000;
1276
1459
  const [, bumpStay] = React.useState(0);
1277
1460
  // The panel can move the marks without going through this component
@@ -1279,35 +1462,40 @@ window.__ModuleLoader__.load({
1279
1462
  // badge all follow immediately.
1280
1463
  const [, bumpPoke] = React.useState(0);
1281
1464
  React.useEffect(() => subscribeUnreadPoke(() => bumpPoke((n) => n + 1)), []);
1282
- // The timer chain below is keyed on the signature, but every attempt reads
1283
- // the CURRENT list and marks; a ref is how the chain sees them.
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.
1284
1467
  const stayLive = React.useRef({ list });
1285
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());
1286
1476
  React.useEffect(() => {
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
+ }
1287
1485
  if (staySignature === "") return undefined;
1288
- const ids = staySignature.split("|").map((key) => key.slice(0, key.indexOf("\u0000")));
1486
+ const armed = stayIds.map((id) => ({ id, endAt: stayArmed.current.get(id) }));
1289
1487
  let cancelled = false;
1290
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.
1291
1491
  const attempt = () => {
1292
1492
  if (cancelled) return;
1293
- const byId = stayLive.current.list !== null && typeof stayLive.current.list === "object" && stayLive.current.list.byId !== null && typeof stayLive.current.list.byId === "object" ? stayLive.current.list.byId : {};
1294
1493
  let changed = false;
1295
- let stillUnread = false;
1296
- for (const id of ids) {
1297
- const endAt = lastTurnEndAt(byId[id]);
1298
- if (endAt <= 0) continue;
1299
- const seenAt = seenState.seen[id];
1300
- if (typeof seenAt === "number" && endAt <= seenAt) continue;
1301
- stillUnread = true;
1302
- if (noteSeen(id, endAt)) changed = true;
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;
1303
1497
  }
1304
1498
  if (changed) { saveSeenState(); bumpStay((n) => n + 1); }
1305
- // Watchdog. With the mark in the Host's own domain it should always
1306
- // land, so this normally stops after the first attempt; it exists for
1307
- // the case the mark DID land and the item is still unread (a fresh
1308
- // ending raced the timer), instead of leaving the dot red for good.
1309
- // A "waiting for you" item has no turn end to acknowledge: stop there.
1310
- timer = stillUnread ? window.setTimeout(attempt, 2000) : 0;
1311
1499
  };
1312
1500
  if (delayMs <= 0) attempt();
1313
1501
  else timer = window.setTimeout(attempt, delayMs);
@@ -1776,6 +1964,9 @@ window.__ModuleLoader__.load({
1776
1964
  const [badgeSourceValue, setBadgeSourceValue] = React.useState(badgeSource);
1777
1965
  const [unread, setUnread] = React.useState(unreadConfig);
1778
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());
1779
1970
  // Draft text for the delay box: typing must not save on every keystroke,
1780
1971
  // and the box has to show the CLAMPED value once the save lands (type
1781
1972
  // 99999 and it settles on the 600 cap instead of lying about it).
@@ -1846,7 +2037,7 @@ window.__ModuleLoader__.load({
1846
2037
  const next = normalizeUnreadConfig({ ...unreadConfig, ...patch });
1847
2038
  emitUnreadConfig(next);
1848
2039
  rpc.call("/api", "iconCustom/setUnreadRule", {
1849
- 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 } }
1850
2041
  }).catch(() => {});
1851
2042
  }, [rpc]);
1852
2043
  // Toggle one unread reason. Applies locally at once; the host keeps the
@@ -1861,6 +2052,11 @@ window.__ModuleLoader__.load({
1861
2052
  const onWorkspaceDotToggle = React.useCallback((checked) => {
1862
2053
  saveUnreadRule({ workspaceDot: checked });
1863
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]);
1864
2060
  // Seconds you must stay in a session before it counts as read; 0 = as soon
1865
2061
  // as you enter. Clamped by the shared normalizer, so a stray keystroke can
1866
2062
  // never persist an absurd delay.
@@ -2129,6 +2325,20 @@ window.__ModuleLoader__.load({
2129
2325
  ),
2130
2326
  React.createElement("div", { style: style.hint }, t("workspaceDotHint"))
2131
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
+ ),
2132
2342
  React.createElement("div", { style: style.hint }, t("badgeTestHint"))
2133
2343
  ),
2134
2344
  notice ? React.createElement("div", { style: style.notice }, notice) : null,
@@ -2271,7 +2481,10 @@ window.__ModuleLoader__.load({
2271
2481
  observer.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ["href", "rel"] });
2272
2482
  window.addEventListener("visibilitychange", schedule);
2273
2483
  window.addEventListener("focus", schedule);
2274
- const interval = window.setInterval(reconcileFavicon, 5000);
2484
+ const interval = window.setInterval(() => {
2485
+ reconcileFavicon();
2486
+ reassertAppBadge();
2487
+ }, 5000);
2275
2488
  return () => {
2276
2489
  observer.disconnect();
2277
2490
  window.removeEventListener("visibilitychange", schedule);
package/lib/index.js CHANGED
@@ -459,7 +459,7 @@ 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, workspaceDot, clearDelaySec, hostNow }`.
462
+ * @returns `{ reasons, pending, workspaceDot, appBadge, clearDelaySec, hostNow }`.
463
463
  */
464
464
  async getUnreadRule() {
465
465
  return this._unreadRuleView()
@@ -469,7 +469,7 @@ export class FaviconCustomService extends TypertRemoteService {
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) {
@@ -496,6 +496,7 @@ export class FaviconCustomService extends TypertRemoteService {
496
496
  reasons: this.unread.reasons,
497
497
  pending: this.unread.pending,
498
498
  workspaceDot: this.unread.workspaceDot,
499
+ appBadge: this.unread.appBadge,
499
500
  clearDelaySec: this.unread.clearDelaySec,
500
501
  hostNow: Date.now()
501
502
  }
@@ -53,6 +53,8 @@ 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
59
  clearDelaySec: z.number().int().nonnegative().max(600),
58
60
  /**
@@ -67,6 +69,7 @@ const setUnreadRuleRequestSchema = z.object({
67
69
  reasons: z.record(z.string(), z.boolean()),
68
70
  pending: z.boolean().optional(),
69
71
  workspaceDot: z.boolean().optional(),
72
+ appBadge: z.boolean().optional(),
70
73
  clearDelaySec: z.number().int().nonnegative().max(600).optional()
71
74
  })
72
75
 
@@ -278,7 +281,7 @@ export const TYPERT = {
278
281
  {
279
282
  kind: 'method',
280
283
  name: 'getUnreadRule',
281
- signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number; hostNow: number }>',
284
+ signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; appBadge: boolean; clearDelaySec: number; hostNow: number }>',
282
285
  description: "Read the unread-badge rule.",
283
286
  summary: "Read the unread-badge rule.",
284
287
  jsDoc: "/** Read the unread-badge rule. */",
@@ -287,7 +290,7 @@ export const TYPERT = {
287
290
  {
288
291
  kind: 'method',
289
292
  name: 'setUnreadRule',
290
- 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; hostNow: 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 }>',
291
294
  description: "Replace the unread-badge rule: which turn-end reasons light the red dot.",
292
295
  summary: "Set the unread-badge rule.",
293
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.2",
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"