@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 +32 -0
- package/README.zh.md +35 -2
- package/client/client.js +607 -70
- package/lib/index.js +25 -4
- package/lib/typert.host.js +12 -3
- package/lib/unread.js +13 -2
- package/package.json +5 -2
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
298
|
-
|
|
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
|
-
|
|
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 :
|
|
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
|
-
*
|
|
413
|
+
* Every session the main view is showing.
|
|
322
414
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
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
|
|
426
|
+
* @returns the ids being viewed, in list order; `[]` when there are none.
|
|
332
427
|
*/
|
|
333
|
-
function
|
|
334
|
-
if (list === null || typeof list !== "object") return
|
|
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
|
|
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
|
|
438
|
+
if (typeof count === "number" && count > 0 && typeof session.id === "string" && session.id !== "") ids.push(session.id);
|
|
343
439
|
}
|
|
344
|
-
return
|
|
440
|
+
return ids;
|
|
345
441
|
}
|
|
346
442
|
/**
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 —
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
1041
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
1049
|
-
const
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
|
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) => {
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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. */
|
package/lib/typert.host.js
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA
|
|
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"
|