@cowwo/dsh-icon-custom 0.11.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,6 +88,8 @@ The uploaded file is stored verbatim (never re‑encoded) so the favicon keeps i
88
88
 
89
89
  Icons are stored per‑user under `$DSH_HOME/custom-favicon/`. "Reset" only clears the active marker; stored files are kept for a future icon‑library UI.
90
90
 
91
+ That directory also holds two JSON files: `unread.json` (which turn-end reasons count as unread) and `unread-seen.json` (the seen watermark, `{ version, seen: { sessionId → endAt } }`, capped at 400 entries and written temp-then-rename). Both are **disposable**: deleting the first restores the default rule, deleting the second makes each browser re-baseline at "the last time it was open" (which does not resurrect old dots). The watermark is host-owned on purpose — looked at on any device means read on every device — while each browser keeps its own copy as a cache and offline queue.
92
+
91
93
  ## License
92
94
 
93
95
  MIT
package/README.zh.md CHANGED
@@ -58,13 +58,13 @@ SVG 会在浏览器里先栅格化成 512×512 PNG 再上传,ICO 则由后端提
58
58
  规则:
59
59
 
60
60
  - 数字 = **几个会话**在等你(不是几件事);同一个会话只占一格。
61
- - 只有子代理会话不计。**你正开着的那个会话也算**——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分;**要清掉它,切走再切回来即可**(你人已经在里面时又结束的那一轮不会自动消,见下一条)。
62
- - **已读记在每个浏览器自己身上**(localStorage):进过 = 处理过,刷新/重启不会"复活";装插件之前的历史结束也不算。
61
+ - 只有子代理会话不计。**什么算"你在看"**(0.12.1 起):那个会话必须真的显示在主区域的对话里(切到「插件 / 设置」这类页面不算)、浏览器标签页在前台(切到别的标签页或最小化不算)、且屏幕上就是它——残留的"最近打开过"不再算数。**你正开着的那个会话也算**;**要清掉它,切走再切回来即可**(你人已经在里面时又结束的那一轮不会自动消,见下一条)。
62
+ - **已读记在宿主**(`$DSH_HOME/custom-favicon/unread-seen.json`):进过 = 处理过,刷新/重启不会"复活",**任何一台设备上看过,所有设备都不再提醒**(开着的页面最多约一分钟收敛,刷新则立刻)。浏览器自己也留一份,但只当缓存兼离线队列——所以清站点数据、换浏览器、无痕窗口都不会把"已读"丢回去。装插件之前的历史结束也不算。
63
63
  - **可以要求"待够才算已读"**:设置页填一个秒数(默认 0)。填 0 = 进去就算已读(红点立即消失);填 N = 你要在那个会话里**连续待满 N 秒**它才算看过,中途切走就清零重数,刷新页面也重新数。上限 600 秒。
64
64
  **计时只认"你进入会话时已经存在的那次结束"**:你人已经在会话里的时候它又跑完一轮,那一轮**不算数**——红点会一直亮着,等你**切走再进来**才消。这样"我正看着的会话跑完了"这件事不会被几秒钟后自动抹掉(0.11.1 起;此前是"停留满 N 秒就把最新那次结束也标成已读",红点只闪一下)。
65
65
  - 数据全部来自官方:`turn/end` 事件经官方 session projection 折叠,加上官方的"等待交互"注册表——**不依赖任何其他插件**。
66
66
  - 设置页的**数据来源**可以切到"手动测试",手填数字试看效果。
67
- - **工作区和会话行上的红点(实验)**:侧栏每个**工作区**那一行的文件夹图标右上角一个小红点,每个**有未读的会话**在标题右边跟一个小红点——一眼看出"事情在哪个工作区的哪个会话"。这一项是**贴着页面结构**做的——DSH 大版本升级后**可能失效**,失效时只会变成"不显示",**不影响**红点数字、清单和跳转;设置页里有一个开关可以随时关掉它。
67
+ - **工作区和会话行上的红点(实验)**:侧栏每个**工作区**那一行的文件夹图标右上角一个小红点(收起的工作区里有多少未读,就看它),每个**有未读的会话**在标题右边跟一个小红点——一眼看出"事情在哪个工作区的哪个会话"。定位走 DSH 自己的 `data-row-key`(工作区、会话都按 **id**,不靠标题文字):标题重复不会串行,**折叠起来的会话不单独画点**——它的行没有渲染,由工作区那一行的点和「待处理」清单代表它。这一项是**贴着页面结构**做的——DSH 大版本升级后**可能失效**,失效时只会变成"不显示",**不影响**红点数字、清单和跳转;设置页里有一个开关可以随时关掉它。
68
68
  - **想知道具体是哪个会话?** 侧栏「工作区」标题的**右上角**有一个**红底数字**(没有待处理时是灰色 0,位置不变);点开是待处理会话清单,**按工作区分组**(工作区名 · 会话名 · 在等你/刚结束),点其中一条**直接跳到那个会话**。
69
69
 
70
70
  ## 应用图标角标(系统图标上的红点)
@@ -112,6 +112,8 @@ SVG 会在浏览器里先栅格化成 512×512 PNG 再上传,ICO 则由后端提
112
112
 
113
113
  图标按用户保存在 `$DSH_HOME/custom-favicon/`。**恢复默认**只清除当前生效标记,已保存的文件会保留,为将来的图标库 UI 做准备。
114
114
 
115
+ 同一个目录里还有两份 JSON:`unread.json`(哪些结束原因算未读)与 `unread-seen.json`(已读水位线,`{ version, seen: { 会话id → endAt } }`,上限 400 条、写入是 temp+rename)。两份都是**可丢的**:删掉 `unread.json` 回到默认规则,删掉 `unread-seen.json` 则每台浏览器按"上次在线时间"重新起基线(不会把历史红点全部点亮)。
116
+
115
117
  ## 许可证
116
118
 
117
119
  MIT
package/client/client.js CHANGED
@@ -69,7 +69,7 @@ window.__ModuleLoader__.load({
69
69
  ageMonths: "{n}个月",
70
70
  ageYears: "{n}年",
71
71
  workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
72
- workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
72
+ workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点(收起的工作区里有多少未读,就看它),有未读的会话在其标题右边也跟一个小红点。会话行上的点只在那一行真的显示着时才画:折叠起来的会话由工作区那一行的点和「待处理」清单代表。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
73
73
  appBadgeLabel: "在系统应用图标上显示红点",
74
74
  appBadgeHint: "安装为应用后,把同一个数字也放到系统图标上:Windows 是任务栏图标上的角标,macOS 是 Dock 角标,iOS 是主屏图标上的数字。样子由系统决定——Windows 上 Chrome 画深色圆+白字,Edge 走 Windows 系统徽章通道(是否显示数字由 Edge/Windows 决定);插件会周期重设数字,但只在应用窗口开着时更新。",
75
75
  appBadgeFootnote: "角标只在应用窗口开着时由本插件更新;数字超过 99 时由系统显示为 99+。",
@@ -80,7 +80,7 @@ window.__ModuleLoader__.load({
80
80
  badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
81
81
  clearDelayLabel: "进入会话后多久算已读:",
82
82
  clearDelayUnit: "秒",
83
- clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600 秒。"
83
+ clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600 秒。**只有它真的显示在屏幕上、且标签页在前台时才算看过**:切到「插件/设置」页或把标签页切到后台都不算。已读记在宿主($DSH_HOME/custom-favicon/unread-seen.json):任何一台设备上看过,所有设备都不再提醒;浏览器那份只是缓存,清站点数据不会把已读丢回去。"
84
84
  };
85
85
  const en = {
86
86
  nav: "Favicon",
@@ -142,7 +142,7 @@ window.__ModuleLoader__.load({
142
142
  ageMonths: "{n}mo",
143
143
  ageYears: "{n}y",
144
144
  workspaceDotLabel: "Dots on workspace and session rows (experimental)",
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.",
145
+ workspaceDotHint: "Adds a small red dot to the folder icon of each workspace row (a collapsed workspace shows how many of its sessions are unread there), and one beside the title of every unread session row. A session dot is only drawn while that row is really rendered: folded sessions are represented by their workspace row's dot and the pending panel. 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
146
  appBadgeLabel: "Badge the system app icon",
147
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
148
  appBadgeFootnote: "The badge is only updated by this plugin while the app window is open; above 99 the system shows 99+.",
@@ -153,7 +153,7 @@ window.__ModuleLoader__.load({
153
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.",
154
154
  clearDelayLabel: "Mark read after staying:",
155
155
  clearDelayUnit: "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."
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. It only counts while that session is really on screen in the foreground tab: a settings or plugins page, or a tab in the background, is not \"being looked at\". Read state lives on the Host ($DSH_HOME/custom-favicon/unread-seen.json): read on any device means read on every device, and the browser's own copy is only a cache — clearing site data no longer throws it away."
157
157
  };
158
158
  //#endregion
159
159
 
@@ -309,7 +309,7 @@ window.__ModuleLoader__.load({
309
309
  }
310
310
 
311
311
  /**
312
- * The seen watermark lives in the HOST's clock domain.
312
+ * The seen watermark: the HOST owns it, this browser keeps the working copy.
313
313
  *
314
314
  * A turn end arrives stamped with the Host's `turn/end.time`; a mark written
315
315
  * from the browser's `Date.now()` only lines up while both clocks agree. A
@@ -318,11 +318,26 @@ window.__ModuleLoader__.load({
318
318
  * all — the one failure users report as "点进去也不消". Marks now store the
319
319
  * `endAt` they acknowledge, and `lastActiveAt` stores a host-domain time, so
320
320
  * every comparison in `collectUnread` stays inside one clock.
321
+ *
322
+ * Since 0.11.2 the authoritative table lives in the Host
323
+ * (`~/.dsh/custom-favicon/unread-seen.json`, merged max-per-session). This
324
+ * copy is what the badge reads — so the page never waits for a round trip and
325
+ * keeps working offline — and doubles as the pending queue: every local mark
326
+ * is pushed back on the next sync, and a merge can only move marks forward.
327
+ * That is what makes "looked at it" survive a cleared browser profile and mean
328
+ * the same thing on every device.
321
329
  */
322
330
  const SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v2";
323
331
  /** 0.10.1 and earlier wrote browser timestamps here; migrated once. */
324
332
  const LEGACY_SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v1";
333
+ /** Mirror of the Host's cap (see lib/unread.js). */
325
334
  const SEEN_MAX = 400;
335
+ /** Coalescing window for watermark pushes: one small RPC per burst, not per session. */
336
+ const HOST_SYNC_DEBOUNCE_MS = 2000;
337
+ /** Retry gap after a failed push (offline, or a Host without the endpoint). */
338
+ const HOST_SYNC_RETRY_MS = 30000;
339
+ /** Ceiling for that retry gap: an old Host must not be probed forever every 20 s. */
340
+ const HOST_SYNC_MAX_RETRY_MS = 300000;
326
341
  /** Host clock minus browser clock, from the last Host sample; null until known. */
327
342
  let hostClockOffset = null;
328
343
  /** This browser's clock expressed in the Host's domain (best effort). */
@@ -347,10 +362,140 @@ window.__ModuleLoader__.load({
347
362
  const seenState = loadSeenState();
348
363
  /** When this browser last had the app open before this page load (host domain). */
349
364
  const seenBootAt = seenState.lastActiveAt > 0 ? seenState.lastActiveAt : 0;
365
+ /** The connection RPC, once `apply` has one; null keeps every push a no-op. */
366
+ let seenRpc = null;
367
+ /** Whether this page load has already converged with the Host once. */
368
+ let hostSyncedOnce = false;
369
+ /** Whether local marks changed since the last successful push. */
370
+ let hostDirty = false;
371
+ let hostSyncTimer = 0;
372
+ let hostRetryTimer = 0;
373
+ let hostSyncInFlight = false;
374
+ /** Escalating gap between failed pushes; any success resets it. */
375
+ let hostRetryDelay = HOST_SYNC_RETRY_MS;
376
+ /** No push is attempted before this moment (the backoff window). */
377
+ let hostBackoffUntil = 0;
378
+ /** Whether the current failure streak has been reported (warn once per streak). */
379
+ let hostSyncWarned = false;
380
+ /**
381
+ * Push the local table soon, unless there is nothing to say.
382
+ *
383
+ * Called from `saveSeenState`, which is also the 20-second "this browser is
384
+ * still here" touch: that touch must NOT count as a change, or the table would
385
+ * travel every 20 seconds. `hostSyncedOnce` covers the first push of a page
386
+ * load, which is also the migration (local-only marks going up) and the
387
+ * self-heal (a lost Host file coming back from this browser).
388
+ * @param delayMs - override the coalescing window; non-positive uses the default.
389
+ */
390
+ function scheduleHostSync(delayMs) {
391
+ if (seenRpc === null || hostSyncInFlight) return;
392
+ if (!hostDirty && hostSyncedOnce) return;
393
+ if (hostSyncTimer !== 0) return;
394
+ // A failed push escalates its own retry; the 20-second touch must not keep
395
+ // poking a Host that has no such endpoint (an older DSH, or offline).
396
+ if (Date.now() < hostBackoffUntil) return;
397
+ const wait = typeof delayMs === "number" && delayMs > 0 ? delayMs : HOST_SYNC_DEBOUNCE_MS;
398
+ hostSyncTimer = window.setTimeout(() => { hostSyncTimer = 0; flushHostSeen(); }, wait);
399
+ }
400
+ /**
401
+ * Adopt the Host's table: per session the NEWER of the two wins.
402
+ *
403
+ * Max-merge (never overwrite) is what makes both directions safe: the Host file
404
+ * lost or truncated → this browser's copy wins; this browser's localStorage
405
+ * cleared → the Host's copy wins. It is also the whole migration path.
406
+ * @param seen - the Host's table (`id → endAt`), or anything.
407
+ * @returns whether the local table gained anything.
408
+ */
409
+ function adoptHostSeen(seen) {
410
+ if (seen === null || typeof seen !== "object") return false;
411
+ let gained = false;
412
+ for (const id of Object.keys(seen)) {
413
+ if (typeof id !== "string" || id === "") continue;
414
+ const at = seen[id];
415
+ if (typeof at !== "number" || !Number.isFinite(at) || at <= 0) continue;
416
+ const previous = seenState.seen[id];
417
+ if (typeof previous === "number" && previous >= at) continue;
418
+ seenState.seen[id] = at;
419
+ gained = true;
420
+ }
421
+ return gained;
422
+ }
423
+ /**
424
+ * Send the whole local table and adopt the merged answer.
425
+ *
426
+ * Whole-table, not a delta: the payload is a few KB, the Host merges by max (so
427
+ * a duplicate or partial round trip is harmless), and any lost message heals on
428
+ * the next one. A failure keeps the marks here — they are already in
429
+ * localStorage and the badge reads them — and retries later.
430
+ */
431
+ function flushHostSeen() {
432
+ if (seenRpc === null || hostSyncInFlight) return;
433
+ if (!hostDirty && hostSyncedOnce) return;
434
+ hostSyncInFlight = true;
435
+ const marks = { ...seenState.seen };
436
+ let pending;
437
+ try {
438
+ // Args are nested under the descriptor's parameter name ("request"), which
439
+ // is what the gateway checks: a flat `{ marks }` is rejected with
440
+ // `gateway/arguments-invalid` before the method ever runs.
441
+ pending = seenRpc.call("/api", "iconCustom/setUnreadSeen", { args: { request: { marks } } });
442
+ } catch (error) {
443
+ // A carrier that throws synchronously must not wedge the in-flight guard:
444
+ // that would stop every later push for the life of the page, silently.
445
+ hostSyncInFlight = false;
446
+ noteHostSyncFailure(error);
447
+ return;
448
+ }
449
+ Promise.resolve(pending).then(
450
+ (resp) => {
451
+ hostSyncInFlight = false;
452
+ if (resp === null || typeof resp !== "object" || resp.ok !== true) {
453
+ const reason = resp !== null && typeof resp === "object" && resp.error !== undefined ? JSON.stringify(resp.error) : "resp.ok !== true";
454
+ noteHostSyncFailure(new Error("setUnreadSeen 被拒绝: " + reason));
455
+ return;
456
+ }
457
+ const value = resp.value !== null && typeof resp.value === "object" ? resp.value : {};
458
+ const gained = adoptHostSeen(value.seen);
459
+ hostSyncedOnce = true;
460
+ hostRetryDelay = HOST_SYNC_RETRY_MS;
461
+ hostBackoffUntil = 0;
462
+ hostSyncWarned = false;
463
+ // The Host knew marks this browser did not: keep them and push the union
464
+ // once more. The next answer cannot gain anything, so this terminates.
465
+ // Repaint too: the badge, the row dots and the panel all read this
466
+ // browser's copy, and nothing else would re-render for a mark that
467
+ // arrived over the wire.
468
+ hostDirty = gained;
469
+ if (gained) { saveSeenState(); emitUnreadPoke(); scheduleHostSync(); }
470
+ },
471
+ (error) => { hostSyncInFlight = false; noteHostSyncFailure(error); }
472
+ );
473
+ }
474
+ /**
475
+ * One failed push: report it once per streak, then back off and try again later.
476
+ *
477
+ * Reported (not swallowed) because a watermark that cannot reach the Host is a
478
+ * silent, confusing failure — "为什么另一台设备还亮着" — and there is nothing on
479
+ * screen to explain it. The badge itself keeps working from the local copy, so
480
+ * this must never look fatal, and it warns once rather than on every retry.
481
+ * @param error - whatever the carrier rejected with.
482
+ */
483
+ function noteHostSyncFailure(error) {
484
+ if (!hostSyncWarned) {
485
+ hostSyncWarned = true;
486
+ try { console.warn("dsh-icon-custom: 已读水位线同步到宿主失败(红点仍照常工作,会自动重试):", (error && error.message) || error); } catch {}
487
+ }
488
+ const wait = hostRetryDelay;
489
+ hostRetryDelay = Math.min(hostRetryDelay * 2, HOST_SYNC_MAX_RETRY_MS);
490
+ hostBackoffUntil = Date.now() + wait;
491
+ if (hostRetryTimer !== 0) window.clearTimeout(hostRetryTimer);
492
+ hostRetryTimer = window.setTimeout(() => { hostRetryTimer = 0; flushHostSeen(); }, wait);
493
+ }
350
494
  function saveSeenState() {
351
495
  try {
352
496
  window.localStorage.setItem(SEEN_STORE_KEY, JSON.stringify({ domain: "host", lastActiveAt: seenState.lastActiveAt, seen: seenState.seen }));
353
497
  } catch {}
498
+ scheduleHostSync();
354
499
  }
355
500
  /**
356
501
  * Learn the Host↔browser clock offset from a Host timestamp, and fold a
@@ -390,12 +535,20 @@ window.__ModuleLoader__.load({
390
535
  const previous = seenState.seen[sessionId];
391
536
  if (typeof previous === "number" && previous >= mark) return false;
392
537
  seenState.seen[sessionId] = mark;
538
+ // Only a REAL mark counts as a change: the 20-second "still here" touch also
539
+ // lands in `saveSeenState`, and it must not drag the whole table to the Host.
540
+ hostDirty = true;
393
541
  return true;
394
542
  }
395
543
  /**
396
544
  * First sight of a session is watermarked at the last time this browser was
397
545
  * here — so an ending that happened while the app was closed still counts as
398
546
  * unread, while endings from before the plugin was installed do not.
547
+ *
548
+ * Unchanged by the move to a Host-owned table: a session nobody has a mark for
549
+ * still needs a starting value, and "the last time this browser was here" is
550
+ * the honest one. A mark dropped by the Host's pruning simply re-baselines here
551
+ * on the next load, which is why pruning cannot resurrect old dots.
399
552
  * @returns whether anything was learned (callers persist only then).
400
553
  */
401
554
  function ensureSeenBaseline(ids) {
@@ -409,23 +562,62 @@ window.__ModuleLoader__.load({
409
562
  }
410
563
  return changed;
411
564
  }
565
+ /**
566
+ * Whether this page is on screen at all.
567
+ *
568
+ * A hidden tab is not "being looked at": the clock must not run while the page
569
+ * is in the background, or a session left open in another tab would be marked
570
+ * read for every device. Unknown (an exotic embedder) counts as visible, which
571
+ * is the pre-0.12.1 behaviour.
572
+ * @returns whether the page is visible.
573
+ */
574
+ function documentVisible() {
575
+ try { return document.visibilityState !== "hidden"; } catch { return true; }
576
+ }
577
+ /**
578
+ * The session whose conversation the page is rendering right now, from the DOM.
579
+ *
580
+ * This is the only signal that names the session on screen, and it is the one
581
+ * that keeps a stale retention from clearing a dot: `retainedBy.mainView` means
582
+ * "recently opened, not yet released", not "displayed". `undefined` means this
583
+ * build carries no such marker (or the probe failed) and callers must fall back
584
+ * to the retention answer rather than treat it as "nothing is displayed".
585
+ * @returns the session id, or undefined when the page cannot say.
586
+ */
587
+ function displayedSessionId() {
588
+ try {
589
+ const node = document.querySelector("[data-conversation-session]");
590
+ if (node === null) return undefined;
591
+ const id = node.getAttribute("data-conversation-session");
592
+ return typeof id === "string" && id !== "" ? id : undefined;
593
+ } catch { return undefined; }
594
+ }
412
595
  /**
413
596
  * Every session the main view is showing.
414
597
  *
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).
598
+ * `view` carries what the page itself says about the screen:
599
+ * * `panelActive` — a global panel (settings, plugins, …) replaced the
600
+ * conversation. DSH's own sidebar treats that as "no current session"
601
+ * (`usePanelInfo((info) => info.activePanelId !== null)`), and following it
602
+ * is what fixes the "cleared a dot I never opened" report: the conversation
603
+ * is simply not on screen.
604
+ * * `visible` — whether this page is on screen at all.
605
+ * * `displayed` — the session the conversation is rendering, when the DOM can
606
+ * name it. That is the precise answer and it wins over retention.
418
607
  *
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.
608
+ * Retention (`retainedBy.mainView`) stays as the fallback: it is what shipped
609
+ * before, it keeps working on a build without the newer signals, and its known
610
+ * weakness (a release that lags behind the pane) only costs a false "read" when
611
+ * nothing better is available.
425
612
  * @param list - the session list snapshot (may be absent).
613
+ * @param view - the screen report described above (may be absent).
426
614
  * @returns the ids being viewed, in list order; `[]` when there are none.
427
615
  */
428
- function currentSessionIds(list) {
616
+ function currentSessionIds(list, view) {
617
+ const screen = view !== null && typeof view === "object" ? view : {};
618
+ if (screen.visible === false) return [];
619
+ if (screen.panelActive === true) return [];
620
+ if (typeof screen.displayed === "string" && screen.displayed !== "") return [screen.displayed];
429
621
  if (list === null || typeof list !== "object") return [];
430
622
  if (typeof list.current === "string" && list.current !== "") return [list.current];
431
623
  const byId = list.byId;
@@ -1004,22 +1196,32 @@ window.__ModuleLoader__.load({
1004
1196
  /**
1005
1197
  * Roll the pending sessions up to one dot per workspace.
1006
1198
  *
1007
- * Pure: `titles` maps sessionId → the workspace title the sidebar shows
1008
- * (assembled from the official workspace snapshot); a session missing from it
1009
- * falls back to the cwd basename `collectUnread` already carries.
1010
- * @returns `[{ name, count }]`, busiest workspace first.
1199
+ * Pure: `owners` maps sessionId → `{ id, title }` from the official workspace
1200
+ * snapshot. Both halves matter downstream: the ID anchors the marker to the
1201
+ * row the browser itself keys (`data-row-key="workspace:<id>"`), which is what
1202
+ * makes the folder dot survive a Workspace whose displayed name differs from
1203
+ * the stored title, two Workspaces sharing one title, and a title that is
1204
+ * simply empty. The title stays for the tooltip; a session with neither falls
1205
+ * back to the cwd basename `collectUnread` already carries.
1206
+ * @returns `[{ id, name, count }]`, busiest workspace first.
1011
1207
  */
1012
- function dotsFromItems(items, titles) {
1208
+ function dotsFromItems(items, owners) {
1013
1209
  const counts = new Map();
1014
1210
  for (const item of Array.isArray(items) ? items : []) {
1015
1211
  if (item === null || typeof item !== "object") continue;
1016
- const mapped = titles !== null && typeof titles === "object" ? titles[item.id] : undefined;
1017
- const name = typeof mapped === "string" && mapped !== "" ? mapped : (typeof item.where === "string" ? item.where : "");
1212
+ const owner = owners !== null && typeof owners === "object" ? owners[item.id] : undefined;
1213
+ const title = owner !== null && typeof owner === "object" && typeof owner.title === "string" ? owner.title : "";
1214
+ const name = title !== "" ? title : (typeof item.where === "string" ? item.where : "");
1018
1215
  if (name === "") continue;
1019
- counts.set(name, (counts.get(name) || 0) + 1);
1216
+ const id = owner !== null && typeof owner === "object" && typeof owner.id === "string" ? owner.id : "";
1217
+ // One dot per Workspace: two Sessions of the same Workspace must not
1218
+ // produce two entries that later paint on top of each other.
1219
+ const key = id !== "" ? id : name;
1220
+ const existing = counts.get(key);
1221
+ if (existing === undefined) counts.set(key, { id, name, count: 1 });
1222
+ else existing.count++;
1020
1223
  }
1021
- return [...counts.entries()]
1022
- .map(([name, count]) => ({ name, count }))
1224
+ return [...counts.values()]
1023
1225
  .sort((a, b) => b.count - a.count || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1024
1226
  }
1025
1227
 
@@ -1289,17 +1491,101 @@ window.__ModuleLoader__.load({
1289
1491
  * Where one session's marker goes: the tight title holder INSIDE that
1290
1492
  * session's own row. The row is found by `data-row-key`; the title inside it
1291
1493
  * 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.
1494
+ *
1495
+ * A Session whose row is NOT in the DOM — folded behind "Show more", inside a
1496
+ * collapsed Workspace, or filtered out — gets NO marker. It used to fall back
1497
+ * to a whole-region title match, and that guess is exactly what puts a dot on
1498
+ * the wrong row: a DIFFERENT Session with the same title owns the only
1499
+ * rendered text node of that name, so the folded one's marker landed on the
1500
+ * visible one's row (two markers on one row, none on the real one). The
1501
+ * Workspace folder dot and the pending panel already speak for Sessions whose
1502
+ * rows are not rendered, so skipping loses no information.
1503
+ *
1504
+ * `legacyTitleMatch` is reserved for a build that stamps no row keys at all,
1505
+ * where the title is the only handle that exists.
1506
+ * @param legacyTitleMatch - whether this build predates `data-row-key`.
1293
1507
  * @returns `{ title, row }`, or null when this row cannot be trusted.
1294
1508
  */
1295
- function sessionRowTarget(container, id, title) {
1509
+ function sessionRowTarget(container, id, title, legacyTitleMatch) {
1296
1510
  const row = sessionRowElement(container, id);
1297
1511
  if (row !== null) {
1298
1512
  const inside = labelElementFor(row, title);
1299
1513
  return inside === null ? null : titleTargetFor(inside, title);
1300
1514
  }
1515
+ if (legacyTitleMatch !== true) return null;
1301
1516
  return titleTargetFor(labelElementFor(container, title), title);
1302
1517
  }
1518
+ /**
1519
+ * Whether the shipped browser stamps Session rows with `data-row-key`.
1520
+ *
1521
+ * Asked once per paint, not once per row: the strict path is only relaxed for
1522
+ * a build that has no row keys anywhere, never for one unlucky lookup.
1523
+ * @param container - the browsing region root (non-null).
1524
+ * @returns true when row keys are in use (and on any probe failure — the
1525
+ * strict path is the safe default).
1526
+ */
1527
+ function sessionRowKeysInUse(container) {
1528
+ try { return container.querySelector('[data-row-key^="session:"]') !== null; } catch { return true; }
1529
+ }
1530
+ /**
1531
+ * The DOM row of one Workspace, by the official row key, or null.
1532
+ *
1533
+ * Same handle as `sessionRowElement`, and the reason the folder dot no longer
1534
+ * depends on the displayed name: DSH renders a Workspace still carrying its
1535
+ * automatic title under a localized default name, and two Workspaces can share
1536
+ * one title, so a text match can miss or collide.
1537
+ * @param container - the browsing region root.
1538
+ * @param id - the Workspace id.
1539
+ * @returns the row element, or null.
1540
+ */
1541
+ function workspaceRowElement(container, id) {
1542
+ if (typeof id !== "string" || id === "") return null;
1543
+ try { return container.querySelector(`[data-row-key="workspace:${id}"]`); } catch { return null; }
1544
+ }
1545
+ /**
1546
+ * The row box + folder icon a Workspace marker hangs off, from the keyed row.
1547
+ *
1548
+ * The key normally sits on the row itself; a build that stamps it on a group
1549
+ * wrapper fails the row-size check, so a bounded set of that wrapper's own rows
1550
+ * gets the same test before giving up. Either way the marker must land on a
1551
+ * row-sized box whose LEADING glyph is the folder — requiring a left-hand icon
1552
+ * is what keeps the dot off the end of the text.
1553
+ * @returns `{ row, icon }`, or null when this row cannot be trusted.
1554
+ */
1555
+ function folderTargetFor(row) {
1556
+ if (row === null || row === undefined) return null;
1557
+ const candidates = [row];
1558
+ try {
1559
+ const inner = row.querySelectorAll("div");
1560
+ for (let i = 0; i < inner.length && candidates.length < 12; i++) candidates.push(inner[i]);
1561
+ } catch { /* the keyed element alone is still worth testing */ }
1562
+ for (const candidate of candidates) {
1563
+ const rect = candidate.getBoundingClientRect();
1564
+ if (rect.height < WS_ROW_MIN_H || rect.height > WS_ROW_MAX_H || rect.width < WS_ROW_MIN_W) continue;
1565
+ const icons = candidate.querySelectorAll("svg");
1566
+ for (const icon of icons) {
1567
+ const iconRect = icon.getBoundingClientRect();
1568
+ if (iconRect.width <= 0 || iconRect.height <= 0) continue;
1569
+ if (iconRect.left - rect.left > rect.width / 3) continue;
1570
+ return { row: candidate, icon };
1571
+ }
1572
+ }
1573
+ return null;
1574
+ }
1575
+ /**
1576
+ * The stable key a Workspace marker is stored under: its ID when known.
1577
+ *
1578
+ * The attribute value used to be the displayed title, which two Workspaces can
1579
+ * share — the reconcile pass would then treat one of them as a stranger and
1580
+ * remove a marker that is still wanted.
1581
+ * @param entry - one `{ id, name }` entry from `dotsFromItems`.
1582
+ * @returns the key, or undefined when the entry carries neither.
1583
+ */
1584
+ function workspaceDotKey(entry) {
1585
+ if (entry === null || typeof entry !== "object") return undefined;
1586
+ if (typeof entry.id === "string" && entry.id !== "") return entry.id;
1587
+ return entry.name;
1588
+ }
1303
1589
  function paintWorkspaceDots() {
1304
1590
  if (workspaceDotGivenUp) return;
1305
1591
  const enabled = unreadConfig.workspaceDot !== false;
@@ -1315,7 +1601,7 @@ window.__ModuleLoader__.load({
1315
1601
  // Reconcile, never wipe-and-redraw: a marker that is already correct is
1316
1602
  // left untouched, so one unlucky lookup (a re-render in flight, a name
1317
1603
  // matched twice for a moment) can no longer blink an existing dot away.
1318
- const wantedNames = new Set(wanted.map((entry) => entry.name));
1604
+ const wantedNames = new Set(wanted.map((entry) => workspaceDotKey(entry)));
1319
1605
  const wantedIds = new Set(wantedRows.map((entry) => entry.id));
1320
1606
  document.querySelectorAll("[" + WS_DOT_ATTR + "]").forEach((node) => {
1321
1607
  if (!node.isConnected || !wantedNames.has(node.getAttribute(WS_DOT_ATTR))) node.remove();
@@ -1324,12 +1610,21 @@ window.__ModuleLoader__.load({
1324
1610
  if (!node.isConnected || !wantedIds.has(node.getAttribute(ROW_DOT_ATTR))) node.remove();
1325
1611
  });
1326
1612
  if (container === null) { workspaceDotPainting = false; return; } // start-up race
1613
+ const legacyTitleMatch = !sessionRowKeysInUse(container);
1327
1614
  for (const entry of wanted) {
1328
- const label = labelElementFor(container, entry.name);
1329
- const target = label === null ? null : markerTargetFor(label);
1615
+ const key = workspaceDotKey(entry);
1616
+ if (key === undefined) { missed++; continue; }
1617
+ // The browser's own row key first; the title walk-up is only for a build
1618
+ // that stamps no Workspace keys (or an entry the snapshot could not name).
1619
+ const keyedRow = workspaceRowElement(container, entry.id);
1620
+ let target = keyedRow === null ? null : folderTargetFor(keyedRow);
1621
+ if (target === null && keyedRow === null) {
1622
+ const label = labelElementFor(container, entry.name);
1623
+ target = label === null ? null : markerTargetFor(label);
1624
+ }
1330
1625
  if (target === null) { missed++; continue; }
1331
1626
  const row = target.row;
1332
- if (existingMarker(row, WS_DOT_ATTR, entry.name) !== null) { placed++; continue; }
1627
+ if (existingMarker(row, WS_DOT_ATTR, key) !== null) { placed++; continue; }
1333
1628
  const icon = target.icon;
1334
1629
  const rowRect = row.getBoundingClientRect();
1335
1630
  const anchorRect = icon.getBoundingClientRect();
@@ -1338,9 +1633,9 @@ window.__ModuleLoader__.load({
1338
1633
  row.style.position = "relative";
1339
1634
  }
1340
1635
  const dot = document.createElement("span");
1341
- dot.setAttribute(WS_DOT_ATTR, entry.name);
1636
+ dot.setAttribute(WS_DOT_ATTR, key);
1342
1637
  dot.setAttribute("aria-hidden", "true");
1343
- dot.title = entry.name + " · " + entry.count;
1638
+ dot.title = (typeof entry.name === "string" && entry.name !== "" ? entry.name : key) + " · " + entry.count;
1344
1639
  dot.style.cssText = "position:absolute;pointer-events:none;width:8px;height:8px;border-radius:50%;"
1345
1640
  + "background:#e5484d;box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff);"
1346
1641
  + "left:" + Math.round(anchorRect.right - rowRect.left - 4) + "px;"
@@ -1352,7 +1647,7 @@ window.__ModuleLoader__.load({
1352
1647
  // Session rows: the marker rides inline right after the title, so it stays
1353
1648
  // beside the name whatever the timestamp happens to say.
1354
1649
  for (const entry of wantedRows) {
1355
- const target = sessionRowTarget(container, entry.id, entry.title);
1650
+ const target = sessionRowTarget(container, entry.id, entry.title, legacyTitleMatch);
1356
1651
  if (target === null) { missed++; continue; }
1357
1652
  if (existingMarker(target.row, ROW_DOT_ATTR, entry.id) !== null) { placed++; continue; }
1358
1653
  const dot = document.createElement("span");
@@ -1432,7 +1727,21 @@ window.__ModuleLoader__.load({
1432
1727
  pruneSeenState();
1433
1728
  saveSeenState();
1434
1729
  }, [list]);
1435
- const currentIds = currentSessionIds(list);
1730
+ // What the page says about the screen, refreshed on the two moments that can
1731
+ // change it without the session list changing: a global panel replacing the
1732
+ // conversation (its own standard prop, the same one the shipped sidebar reads)
1733
+ // and the tab going to the background. Both are read here, not inferred, so
1734
+ // "you are looking at it" stops being a guess about stale retention.
1735
+ const panelActive = typeof props.usePanelInfo === "function"
1736
+ ? props.usePanelInfo((info) => info !== null && typeof info === "object" && info.activePanelId !== null)
1737
+ : false;
1738
+ const [visible, setVisible] = React.useState(documentVisible);
1739
+ React.useEffect(() => {
1740
+ const onVisibility = () => setVisible(documentVisible());
1741
+ window.addEventListener("visibilitychange", onVisibility);
1742
+ return () => window.removeEventListener("visibilitychange", onVisibility);
1743
+ }, []);
1744
+ const currentIds = currentSessionIds(list, { panelActive, visible, displayed: displayedSessionId() });
1436
1745
  const items = collectUnread(list, pending, config, seenState.seen);
1437
1746
  const count = items.length;
1438
1747
  // Being in a session is what marks it read — and the clock that does it is
@@ -1505,19 +1814,21 @@ window.__ModuleLoader__.load({
1505
1814
  };
1506
1815
  }, [staySignature, delayMs]);
1507
1816
  React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
1508
- // The workspace rows are labelled with the workspace TITLE, which is not
1509
- // always the cwd basename, so map session → title from the official snapshot.
1817
+ // Map session → its Workspace from the official snapshot: the ID anchors the
1818
+ // folder dot to the keyed row, the title is what the row displays (not always
1819
+ // the cwd basename, and not always equal to the stored title either).
1510
1820
  React.useEffect(() => {
1511
- const titles = {};
1821
+ const owners = {};
1512
1822
  const list = workspaces !== null && typeof workspaces === "object" && Array.isArray(workspaces.items) ? workspaces.items : [];
1513
1823
  for (const workspace of list) {
1514
1824
  if (workspace === null || typeof workspace !== "object") continue;
1825
+ if (!Array.isArray(workspace.sessionIds)) continue;
1826
+ const id = typeof workspace.workspaceId === "string" ? workspace.workspaceId : "";
1515
1827
  const title = typeof workspace.title === "string" ? workspace.title : "";
1516
- if (title === "" || !Array.isArray(workspace.sessionIds)) continue;
1517
- for (const id of workspace.sessionIds) titles[id] = title;
1828
+ for (const sessionId of workspace.sessionIds) owners[sessionId] = { id, title };
1518
1829
  }
1519
1830
  emitSidebarMarks({
1520
- workspaces: dotsFromItems(items, titles),
1831
+ workspaces: dotsFromItems(items, owners),
1521
1832
  sessions: items.map((item) => ({ id: item.id, title: item.title }))
1522
1833
  });
1523
1834
  }, [items, workspaces]);
@@ -2355,6 +2666,10 @@ window.__ModuleLoader__.load({
2355
2666
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-icon-custom: dictionaries");
2356
2667
  const t = ctx.locale.bind(NS);
2357
2668
  const rpc = ctx.connection.rpc;
2669
+ // The watermark's Host half rides the same connection as every other RPC.
2670
+ // Setting it here (not at module scope) is what keeps the store usable in a
2671
+ // test harness with no connection: pushes stay no-ops, the badge still works.
2672
+ seenRpc = rpc;
2358
2673
  // Pull the host state once, so the brand mark and the badge are correct
2359
2674
  // before the settings section is ever opened. The unread rule rides its
2360
2675
  // own endpoint: the icon RPCs keep their pre-badge response shape.
@@ -2366,8 +2681,16 @@ window.__ModuleLoader__.load({
2366
2681
  if (resp === null || typeof resp !== "object" || resp.ok !== true) return;
2367
2682
  // The rule response carries the Host's clock; it is what puts the seen
2368
2683
  // 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);
2684
+ // It also carries the Host's watermark table, which merges in here —
2685
+ // then the first push sends the union back, which is both the migration
2686
+ // (marks this browser made before 0.11.2) and the self-heal (a Host file
2687
+ // that was lost or pruned gets this browser's copy back).
2688
+ if (resp.value !== null && typeof resp.value === "object") {
2689
+ noteHostClock(resp.value.hostNow);
2690
+ if (adoptHostSeen(resp.value.seen)) saveSeenState();
2691
+ }
2370
2692
  emitUnreadConfig(resp.value);
2693
+ scheduleHostSync();
2371
2694
  })
2372
2695
  .catch(() => {});
2373
2696
  // Keep "the last moment this browser was here" fresh, so an ending that
@@ -2390,18 +2713,33 @@ window.__ModuleLoader__.load({
2390
2713
  // Re-sample the Host↔browser clock offset after a sleep/resume, which is the
2391
2714
  // one moment the two can drift apart mid-page. Throttled: it is one small
2392
2715
  // RPC per visible-again, and never on beforeunload (nothing would come back).
2716
+ // The same response carries the Host's watermark, so this is also how a
2717
+ // SECOND device learns that something was read elsewhere — a page that is
2718
+ // simply left open converges within a minute without a reload. Coming back
2719
+ // into view is also the natural moment to retry a failed push.
2393
2720
  ctx.effect(() => {
2394
2721
  let sampledAt = 0;
2395
2722
  const sample = () => {
2396
2723
  if (document.visibilityState === "hidden") return;
2724
+ scheduleHostSync();
2397
2725
  if (Date.now() - sampledAt < 60000) return;
2398
2726
  sampledAt = Date.now();
2399
2727
  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); })
2728
+ .then((resp) => {
2729
+ if (resp === null || typeof resp !== "object" || resp.ok !== true) return;
2730
+ const value = resp.value !== null && typeof resp.value === "object" ? resp.value : null;
2731
+ if (value === null) return;
2732
+ noteHostClock(value.hostNow);
2733
+ if (adoptHostSeen(value.seen)) { saveSeenState(); emitUnreadPoke(); }
2734
+ })
2401
2735
  .catch(() => {});
2402
2736
  };
2403
2737
  window.addEventListener("visibilitychange", sample);
2404
- return () => window.removeEventListener("visibilitychange", sample);
2738
+ const interval = window.setInterval(sample, 60000);
2739
+ return () => {
2740
+ window.removeEventListener("visibilitychange", sample);
2741
+ window.clearInterval(interval);
2742
+ };
2405
2743
  }, "dsh-icon-custom: host clock sample");
2406
2744
  // Brand-mark seat: replaces only the whale mark (verified: a
2407
2745
  // third-party registration wins over the official occupant and the
package/lib/index.js CHANGED
@@ -29,12 +29,16 @@ import { homedir } from 'node:os'
29
29
  import { join, resolve } from 'node:path'
30
30
  import {
31
31
  LAST_TURN_END_KEY,
32
+ UNREAD_SEEN_VERSION,
32
33
  applyLastTurnEnd,
33
34
  initLastTurnEnd,
34
35
  lastTurnEndStateSchema,
35
36
  lastTurnEndView,
36
37
  lastTurnEndViewSchema,
37
- normalizeUnreadConfig
38
+ mergeSeen,
39
+ normalizeSeen,
40
+ normalizeUnreadConfig,
41
+ pruneSeen
38
42
  } from './unread.js'
39
43
 
40
44
  const MAX_BYTES = 10 * 1024 * 1024 // 10 MB
@@ -330,6 +334,10 @@ export class FaviconCustomService extends TypertRemoteService {
330
334
  this.libraryDir = join(this.storageDir, 'library')
331
335
  this.indexPath = join(this.storageDir, 'library.json')
332
336
  this.unreadPath = join(this.storageDir, 'unread.json')
337
+ // The "I have looked at this session" watermark. Host-owned on purpose: it
338
+ // survives a cleared browser profile and means the same thing on every
339
+ // device, while each browser keeps its own copy as cache + offline queue.
340
+ this.seenPath = join(this.storageDir, 'unread-seen.json')
333
341
  // { activeId, icons: { [id]: { id, mime, ext, name, size, savedAt, rev, pwa, logo } } }
334
342
  this.library = { activeId: null, icons: {} }
335
343
  // The active icon's loaded state: { rev, mime, ext, name, size, savedAt, pwa, logo, bytes } | null
@@ -338,10 +346,13 @@ export class FaviconCustomService extends TypertRemoteService {
338
346
  // Kept in its own file so a settings change can never corrupt the icon
339
347
  // library index.
340
348
  this.unread = normalizeUnreadConfig(null)
349
+ // Host-side watermark: { [sessionId]: endAt } — see lib/unread.js.
350
+ this.seen = {}
341
351
  // Platform PWA manifest (raw + parsed), read once at startup.
342
352
  this.baseManifest = loadBaseManifest()
343
353
  this._load()
344
354
  this._loadUnread()
355
+ this._loadUnreadSeen()
345
356
  // "How did this session's last turn end" as an official session
346
357
  // projection. Optional registration on purpose: this plugin is a display
347
358
  // plugin first, so a deployment without the projection service loses the
@@ -439,6 +450,30 @@ export class FaviconCustomService extends TypertRemoteService {
439
450
  return writeFile(this.unreadPath, JSON.stringify(this.unread, null, 2))
440
451
  }
441
452
 
453
+ /**
454
+ * Load the host-side watermark.
455
+ *
456
+ * A missing file, an unknown `version` (a future format), or a corrupt document
457
+ * all open as an EMPTY table — never as a guess. An empty table cannot light
458
+ * anything up: the client re-baselines a session it has no mark for at "the last
459
+ * time this browser was here", so old endings stay read.
460
+ */
461
+ _loadUnreadSeen() {
462
+ try {
463
+ const raw = JSON.parse(readFileSync(this.seenPath).toString('utf8'))
464
+ if (raw === null || typeof raw !== 'object' || raw.version !== UNREAD_SEEN_VERSION) return
465
+ this.seen = pruneSeen(normalizeSeen(raw.seen))
466
+ } catch { /* no watermark yet */ }
467
+ }
468
+
469
+ /** Persist the watermark (best effort, advisory file). Temp then rename, so a crash never truncates it. */
470
+ async _saveUnreadSeen() {
471
+ await mkdir(this.storageDir, { recursive: true })
472
+ const tmp = `${this.seenPath}.tmp`
473
+ await writeFile(tmp, JSON.stringify({ version: UNREAD_SEEN_VERSION, seen: this.seen }, null, 2))
474
+ await rename(tmp, this.seenPath)
475
+ }
476
+
442
477
  /** Currently active icon as a JSON-safe status object (no raw bytes). */
443
478
  status() {
444
479
  const c = this.current
@@ -482,14 +517,42 @@ export class FaviconCustomService extends TypertRemoteService {
482
517
  }
483
518
 
484
519
  /**
485
- * The rule as the client reads it, plus the Host's clock.
520
+ * typert endpoint: merge this browser's "seen" watermarks into the Host's copy.
521
+ *
522
+ * The watermark lives here so that "I have looked at this session" survives a
523
+ * cleared browser profile and holds across devices; each browser keeps its own
524
+ * copy as a cache and offline queue. Merging is max-per-session and therefore
525
+ * idempotent: re-sending the whole table is harmless (that is also how a fresh
526
+ * browser migrates its localStorage marks up), and a stale client can never move
527
+ * a mark backwards.
528
+ * @param request - `{ marks: { [sessionId]: endAt } }`; dirty keys/values are dropped.
529
+ * @returns `{ seen }` — the merged table, authoritative for the caller.
530
+ */
531
+ async setUnreadSeen(request) {
532
+ if (request === null || typeof request !== 'object') {
533
+ throw new Error('缺少 marks 参数')
534
+ }
535
+ const merged = mergeSeen(this.seen, request.marks)
536
+ this.seen = pruneSeen(merged.seen)
537
+ // The write is part of the contract, NOT swallowed: this feature's whole value is
538
+ // that the watermark survives, so answering "ok" while nothing reached the disk
539
+ // is the one failure nobody can see (the client would stop retrying and another
540
+ // device would keep its old answer forever). Throwing lets the client warn once
541
+ // and retry with backoff.
542
+ await this._saveUnreadSeen()
543
+ return { seen: { ...this.seen } }
544
+ }
545
+
546
+ /**
547
+ * The rule as the client reads it, plus the Host's clock and watermark.
486
548
  *
487
549
  * `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`.
550
+ * against a watermark: without a Host time sample the browser has to guess that
551
+ * domain from its own clock, and a browser a few seconds behind the Host then
552
+ * clears nothing. `seen` is the watermark itself — the authority the client
553
+ * merges into its local cache. Both ride this existing response instead of a new
554
+ * read endpoint, so an older client simply ignores the field it does not know.
555
+ * @returns the stored rule with `hostNow` and `seen`.
493
556
  */
494
557
  _unreadRuleView() {
495
558
  return {
@@ -498,7 +561,8 @@ export class FaviconCustomService extends TypertRemoteService {
498
561
  workspaceDot: this.unread.workspaceDot,
499
562
  appBadge: this.unread.appBadge,
500
563
  clearDelaySec: this.unread.clearDelaySec,
501
- hostNow: Date.now()
564
+ hostNow: Date.now(),
565
+ seen: { ...this.seen }
502
566
  }
503
567
  }
504
568
 
@@ -62,7 +62,14 @@ const unreadRuleSchema = z.object({
62
62
  * 客户端用它把"看过"水位换算进宿主时钟域(`turn/end.time` 也是宿主时钟):
63
63
  * 浏览器与宿主差几秒,红点就会永远清不掉。
64
64
  */
65
- hostNow: z.number()
65
+ hostNow: z.number(),
66
+ /**
67
+ * 宿主持有的"已读"水位线:会话 id → 已确认的最后一次 `turn/end`(宿主时钟域)。
68
+ *
69
+ * 它是真相,浏览器那份是缓存 + 离线队列;随本响应一起下发是为了不新增读端点
70
+ * (老客户端忽略这个字段即可)。客户端拿它按会话取 max 合并。
71
+ */
72
+ seen: z.record(z.string(), z.number())
66
73
  })
67
74
 
68
75
  const setUnreadRuleRequestSchema = z.object({
@@ -73,6 +80,15 @@ const setUnreadRuleRequestSchema = z.object({
73
80
  clearDelaySec: z.number().int().nonnegative().max(600).optional()
74
81
  })
75
82
 
83
+ /** 一次水位线同步:浏览器把自己那份交上来,宿主逐会话取 max 后回权威表。 */
84
+ const setUnreadSeenRequestSchema = z.object({
85
+ marks: z.record(z.string(), z.number())
86
+ })
87
+
88
+ const unreadSeenSchema = z.object({
89
+ seen: z.record(z.string(), z.number())
90
+ })
91
+
76
92
  const setIconResultSchema = statusSchema
77
93
 
78
94
  export const TYPERT = {
@@ -221,6 +237,31 @@ export const TYPERT = {
221
237
  create: () => unreadRuleSchema
222
238
  },
223
239
  sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
240
+ },
241
+ {
242
+ id: 'dsh-icon-custom#iconCustom/setUnreadSeen',
243
+ service: 'iconCustom',
244
+ namespace: 'iconCustom',
245
+ method: 'setUnreadSeen',
246
+ invocation: { kind: 'direct' },
247
+ parameters: [
248
+ {
249
+ name: 'request',
250
+ wire: 'request',
251
+ source: 'json',
252
+ codec: {
253
+ mode: 'strict',
254
+ typeSymbol: 'dsh-icon-custom#iconCustom/setUnreadSeen:request',
255
+ create: () => setUnreadSeenRequestSchema
256
+ }
257
+ }
258
+ ],
259
+ result: {
260
+ mode: 'strict',
261
+ typeSymbol: 'dsh-icon-custom#iconCustom/setUnreadSeen:result',
262
+ create: () => unreadSeenSchema
263
+ },
264
+ sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
224
265
  }
225
266
  ],
226
267
  model: {
@@ -281,8 +322,8 @@ export const TYPERT = {
281
322
  {
282
323
  kind: 'method',
283
324
  name: 'getUnreadRule',
284
- signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; appBadge: boolean; clearDelaySec: number; hostNow: number }>',
285
- description: "Read the unread-badge rule.",
325
+ signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; appBadge: boolean; clearDelaySec: number; hostNow: number; seen: Record<string, number> }>',
326
+ description: "Read the unread-badge rule, the Host clock and the seen watermark.",
286
327
  summary: "Read the unread-badge rule.",
287
328
  jsDoc: "/** Read the unread-badge rule. */",
288
329
  tags: []
@@ -290,11 +331,20 @@ export const TYPERT = {
290
331
  {
291
332
  kind: 'method',
292
333
  name: 'setUnreadRule',
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 }>',
334
+ 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; seen: Record<string, number> }>',
294
335
  description: "Replace the unread-badge rule: which turn-end reasons light the red dot.",
295
336
  summary: "Set the unread-badge rule.",
296
337
  jsDoc: "/** Set the unread-badge rule. */",
297
338
  tags: []
339
+ },
340
+ {
341
+ kind: 'method',
342
+ name: 'setUnreadSeen',
343
+ signature: 'async setUnreadSeen(request: { marks: Record<string, number> }): Promise<{ seen: Record<string, number> }>',
344
+ description: "Merge this browser's seen watermarks into the Host's copy (max per session).",
345
+ summary: "Merge the seen watermark.",
346
+ jsDoc: "/** Merge the seen watermark. */",
347
+ tags: []
298
348
  }
299
349
  ],
300
350
  types: []
package/lib/unread.js CHANGED
@@ -164,3 +164,82 @@ export const lastTurnEndViewSchema = z.object({
164
164
  endAt: z.number(),
165
165
  reason: z.string().nullable()
166
166
  })
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // 水位线(宿主持有的"已读"标记)
170
+ //
171
+ // 这是这套东西唯一的真相:浏览器那份降级为缓存兼离线队列。放宿主有两个理由——
172
+ // 清掉浏览器数据不再等于"全部未读",以及"我看过了"跨设备一致。
173
+ // 判定精度不变:仍是"进入会话并连续待够 N 秒"才算看过,规则在 client/client.js。
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /** 水位线文件的版本;不认识的版本当空库(猜错会把红点全点亮)。 */
177
+ export const UNREAD_SEEN_VERSION = 1
178
+
179
+ /** 一个库最多留多少条水位线(client/client.js 的 SEEN_MAX 是它的镜像)。 */
180
+ export const UNREAD_SEEN_MAX = 400
181
+
182
+ /** 单个会话 id 的长度上限:挡脏输入,不是安全边界。 */
183
+ const SESSION_ID_MAX = 128
184
+
185
+ /** 水位线文件:`{ version, seen }`;`seen` 是 会话 id → 已确认的最后一次 `turn/end`(宿主时钟域)。 */
186
+ export const unreadSeenFileSchema = z.object({
187
+ version: z.number(),
188
+ seen: z.record(z.string(), z.number())
189
+ })
190
+
191
+ /**
192
+ * 把任意输入夹成一张干净的水位线表。
193
+ * 脏 key(空、超长、非字符串)与脏值(非有限数、≤0)直接丢掉,绝不写回文件。
194
+ * @param input - 文件内容、RPC 载荷,或任何东西
195
+ * @returns 只含合法条目的新对象
196
+ */
197
+ export function normalizeSeen(input) {
198
+ const out = {}
199
+ if (input === null || typeof input !== 'object') return out
200
+ for (const [id, at] of Object.entries(input)) {
201
+ if (typeof id !== 'string' || id === '' || id.length > SESSION_ID_MAX) continue
202
+ if (typeof at !== 'number' || !Number.isFinite(at) || at <= 0) continue
203
+ out[id] = at
204
+ }
205
+ return out
206
+ }
207
+
208
+ /**
209
+ * 合并一批水位线:逐会话取 max。
210
+ *
211
+ * "标记永不倒退"是这套东西唯一的并发保护:两个浏览器(或两个 dsh 进程)同时写,
212
+ * 后写者也不会把别人更新的标记盖回旧的——那会让已清掉的红点"诈尸"。
213
+ * @param seen - 现有水位线表
214
+ * @param marks - 要并入的标记
215
+ * @returns `{ seen, changed }`,`changed` 表示是否有条目前进
216
+ */
217
+ export function mergeSeen(seen, marks) {
218
+ const base = normalizeSeen(seen)
219
+ const incoming = normalizeSeen(marks)
220
+ let changed = false
221
+ for (const [id, at] of Object.entries(incoming)) {
222
+ const previous = base[id]
223
+ if (typeof previous === 'number' && previous >= at) continue
224
+ base[id] = at
225
+ changed = true
226
+ }
227
+ return { seen: base, changed }
228
+ }
229
+
230
+ /**
231
+ * 只留最新的 N 条水位线。
232
+ *
233
+ * 丢掉的都是"很久以前就看过"的记录,不会把红点变回来:客户端对没有标记的会话
234
+ * 按"这台浏览器上次在线"重新起基线,老结束仍算已读(见 client/client.js 的
235
+ * `ensureSeenBaseline`)。所以按时间保留最新即可。
236
+ * @param seen - 水位线表
237
+ * @param max - 上限
238
+ * @returns 新对象
239
+ */
240
+ export function pruneSeen(seen, max = UNREAD_SEEN_MAX) {
241
+ const entries = Object.entries(normalizeSeen(seen))
242
+ if (entries.length <= max) return Object.fromEntries(entries)
243
+ entries.sort((a, b) => b[1] - a[1])
244
+ return Object.fromEntries(entries.slice(0, max))
245
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-icon-custom",
3
- "version": "0.11.1",
3
+ "version": "0.15.0",
4
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
8
  "scripts": {
9
- "test": "node test/app-badge.mjs && node test/unread-panel.mjs && node test/unread-clear.mjs"
9
+ "test": "node test/app-badge.mjs && node test/unread-panel.mjs && node test/unread-clear.mjs && node test/unread-rows.mjs && node test/unread-seen.mjs && node test/unread-host-sync.mjs"
10
10
  },
11
11
  "dependencies": {
12
12
  "jimp": "^1.6.1",