@cowwo/dsh-icon-custom 0.11.1 → 0.12.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
@@ -59,12 +59,12 @@ SVG 会在浏览器里先栅格化成 512×512 PNG 再上传,ICO 则由后端提
59
59
 
60
60
  - 数字 = **几个会话**在等你(不是几件事);同一个会话只占一格。
61
61
  - 只有子代理会话不计。**你正开着的那个会话也算**——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分;**要清掉它,切走再切回来即可**(你人已经在里面时又结束的那一轮不会自动消,见下一条)。
62
- - **已读记在每个浏览器自己身上**(localStorage):进过 = 处理过,刷新/重启不会"复活";装插件之前的历史结束也不算。
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. 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) {
@@ -1004,22 +1157,32 @@ window.__ModuleLoader__.load({
1004
1157
  /**
1005
1158
  * Roll the pending sessions up to one dot per workspace.
1006
1159
  *
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.
1160
+ * Pure: `owners` maps sessionId → `{ id, title }` from the official workspace
1161
+ * snapshot. Both halves matter downstream: the ID anchors the marker to the
1162
+ * row the browser itself keys (`data-row-key="workspace:<id>"`), which is what
1163
+ * makes the folder dot survive a Workspace whose displayed name differs from
1164
+ * the stored title, two Workspaces sharing one title, and a title that is
1165
+ * simply empty. The title stays for the tooltip; a session with neither falls
1166
+ * back to the cwd basename `collectUnread` already carries.
1167
+ * @returns `[{ id, name, count }]`, busiest workspace first.
1011
1168
  */
1012
- function dotsFromItems(items, titles) {
1169
+ function dotsFromItems(items, owners) {
1013
1170
  const counts = new Map();
1014
1171
  for (const item of Array.isArray(items) ? items : []) {
1015
1172
  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 : "");
1173
+ const owner = owners !== null && typeof owners === "object" ? owners[item.id] : undefined;
1174
+ const title = owner !== null && typeof owner === "object" && typeof owner.title === "string" ? owner.title : "";
1175
+ const name = title !== "" ? title : (typeof item.where === "string" ? item.where : "");
1018
1176
  if (name === "") continue;
1019
- counts.set(name, (counts.get(name) || 0) + 1);
1177
+ const id = owner !== null && typeof owner === "object" && typeof owner.id === "string" ? owner.id : "";
1178
+ // One dot per Workspace: two Sessions of the same Workspace must not
1179
+ // produce two entries that later paint on top of each other.
1180
+ const key = id !== "" ? id : name;
1181
+ const existing = counts.get(key);
1182
+ if (existing === undefined) counts.set(key, { id, name, count: 1 });
1183
+ else existing.count++;
1020
1184
  }
1021
- return [...counts.entries()]
1022
- .map(([name, count]) => ({ name, count }))
1185
+ return [...counts.values()]
1023
1186
  .sort((a, b) => b.count - a.count || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1024
1187
  }
1025
1188
 
@@ -1289,17 +1452,101 @@ window.__ModuleLoader__.load({
1289
1452
  * Where one session's marker goes: the tight title holder INSIDE that
1290
1453
  * session's own row. The row is found by `data-row-key`; the title inside it
1291
1454
  * 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.
1455
+ *
1456
+ * A Session whose row is NOT in the DOM — folded behind "Show more", inside a
1457
+ * collapsed Workspace, or filtered out — gets NO marker. It used to fall back
1458
+ * to a whole-region title match, and that guess is exactly what puts a dot on
1459
+ * the wrong row: a DIFFERENT Session with the same title owns the only
1460
+ * rendered text node of that name, so the folded one's marker landed on the
1461
+ * visible one's row (two markers on one row, none on the real one). The
1462
+ * Workspace folder dot and the pending panel already speak for Sessions whose
1463
+ * rows are not rendered, so skipping loses no information.
1464
+ *
1465
+ * `legacyTitleMatch` is reserved for a build that stamps no row keys at all,
1466
+ * where the title is the only handle that exists.
1467
+ * @param legacyTitleMatch - whether this build predates `data-row-key`.
1293
1468
  * @returns `{ title, row }`, or null when this row cannot be trusted.
1294
1469
  */
1295
- function sessionRowTarget(container, id, title) {
1470
+ function sessionRowTarget(container, id, title, legacyTitleMatch) {
1296
1471
  const row = sessionRowElement(container, id);
1297
1472
  if (row !== null) {
1298
1473
  const inside = labelElementFor(row, title);
1299
1474
  return inside === null ? null : titleTargetFor(inside, title);
1300
1475
  }
1476
+ if (legacyTitleMatch !== true) return null;
1301
1477
  return titleTargetFor(labelElementFor(container, title), title);
1302
1478
  }
1479
+ /**
1480
+ * Whether the shipped browser stamps Session rows with `data-row-key`.
1481
+ *
1482
+ * Asked once per paint, not once per row: the strict path is only relaxed for
1483
+ * a build that has no row keys anywhere, never for one unlucky lookup.
1484
+ * @param container - the browsing region root (non-null).
1485
+ * @returns true when row keys are in use (and on any probe failure — the
1486
+ * strict path is the safe default).
1487
+ */
1488
+ function sessionRowKeysInUse(container) {
1489
+ try { return container.querySelector('[data-row-key^="session:"]') !== null; } catch { return true; }
1490
+ }
1491
+ /**
1492
+ * The DOM row of one Workspace, by the official row key, or null.
1493
+ *
1494
+ * Same handle as `sessionRowElement`, and the reason the folder dot no longer
1495
+ * depends on the displayed name: DSH renders a Workspace still carrying its
1496
+ * automatic title under a localized default name, and two Workspaces can share
1497
+ * one title, so a text match can miss or collide.
1498
+ * @param container - the browsing region root.
1499
+ * @param id - the Workspace id.
1500
+ * @returns the row element, or null.
1501
+ */
1502
+ function workspaceRowElement(container, id) {
1503
+ if (typeof id !== "string" || id === "") return null;
1504
+ try { return container.querySelector(`[data-row-key="workspace:${id}"]`); } catch { return null; }
1505
+ }
1506
+ /**
1507
+ * The row box + folder icon a Workspace marker hangs off, from the keyed row.
1508
+ *
1509
+ * The key normally sits on the row itself; a build that stamps it on a group
1510
+ * wrapper fails the row-size check, so a bounded set of that wrapper's own rows
1511
+ * gets the same test before giving up. Either way the marker must land on a
1512
+ * row-sized box whose LEADING glyph is the folder — requiring a left-hand icon
1513
+ * is what keeps the dot off the end of the text.
1514
+ * @returns `{ row, icon }`, or null when this row cannot be trusted.
1515
+ */
1516
+ function folderTargetFor(row) {
1517
+ if (row === null || row === undefined) return null;
1518
+ const candidates = [row];
1519
+ try {
1520
+ const inner = row.querySelectorAll("div");
1521
+ for (let i = 0; i < inner.length && candidates.length < 12; i++) candidates.push(inner[i]);
1522
+ } catch { /* the keyed element alone is still worth testing */ }
1523
+ for (const candidate of candidates) {
1524
+ const rect = candidate.getBoundingClientRect();
1525
+ if (rect.height < WS_ROW_MIN_H || rect.height > WS_ROW_MAX_H || rect.width < WS_ROW_MIN_W) continue;
1526
+ const icons = candidate.querySelectorAll("svg");
1527
+ for (const icon of icons) {
1528
+ const iconRect = icon.getBoundingClientRect();
1529
+ if (iconRect.width <= 0 || iconRect.height <= 0) continue;
1530
+ if (iconRect.left - rect.left > rect.width / 3) continue;
1531
+ return { row: candidate, icon };
1532
+ }
1533
+ }
1534
+ return null;
1535
+ }
1536
+ /**
1537
+ * The stable key a Workspace marker is stored under: its ID when known.
1538
+ *
1539
+ * The attribute value used to be the displayed title, which two Workspaces can
1540
+ * share — the reconcile pass would then treat one of them as a stranger and
1541
+ * remove a marker that is still wanted.
1542
+ * @param entry - one `{ id, name }` entry from `dotsFromItems`.
1543
+ * @returns the key, or undefined when the entry carries neither.
1544
+ */
1545
+ function workspaceDotKey(entry) {
1546
+ if (entry === null || typeof entry !== "object") return undefined;
1547
+ if (typeof entry.id === "string" && entry.id !== "") return entry.id;
1548
+ return entry.name;
1549
+ }
1303
1550
  function paintWorkspaceDots() {
1304
1551
  if (workspaceDotGivenUp) return;
1305
1552
  const enabled = unreadConfig.workspaceDot !== false;
@@ -1315,7 +1562,7 @@ window.__ModuleLoader__.load({
1315
1562
  // Reconcile, never wipe-and-redraw: a marker that is already correct is
1316
1563
  // left untouched, so one unlucky lookup (a re-render in flight, a name
1317
1564
  // matched twice for a moment) can no longer blink an existing dot away.
1318
- const wantedNames = new Set(wanted.map((entry) => entry.name));
1565
+ const wantedNames = new Set(wanted.map((entry) => workspaceDotKey(entry)));
1319
1566
  const wantedIds = new Set(wantedRows.map((entry) => entry.id));
1320
1567
  document.querySelectorAll("[" + WS_DOT_ATTR + "]").forEach((node) => {
1321
1568
  if (!node.isConnected || !wantedNames.has(node.getAttribute(WS_DOT_ATTR))) node.remove();
@@ -1324,12 +1571,21 @@ window.__ModuleLoader__.load({
1324
1571
  if (!node.isConnected || !wantedIds.has(node.getAttribute(ROW_DOT_ATTR))) node.remove();
1325
1572
  });
1326
1573
  if (container === null) { workspaceDotPainting = false; return; } // start-up race
1574
+ const legacyTitleMatch = !sessionRowKeysInUse(container);
1327
1575
  for (const entry of wanted) {
1328
- const label = labelElementFor(container, entry.name);
1329
- const target = label === null ? null : markerTargetFor(label);
1576
+ const key = workspaceDotKey(entry);
1577
+ if (key === undefined) { missed++; continue; }
1578
+ // The browser's own row key first; the title walk-up is only for a build
1579
+ // that stamps no Workspace keys (or an entry the snapshot could not name).
1580
+ const keyedRow = workspaceRowElement(container, entry.id);
1581
+ let target = keyedRow === null ? null : folderTargetFor(keyedRow);
1582
+ if (target === null && keyedRow === null) {
1583
+ const label = labelElementFor(container, entry.name);
1584
+ target = label === null ? null : markerTargetFor(label);
1585
+ }
1330
1586
  if (target === null) { missed++; continue; }
1331
1587
  const row = target.row;
1332
- if (existingMarker(row, WS_DOT_ATTR, entry.name) !== null) { placed++; continue; }
1588
+ if (existingMarker(row, WS_DOT_ATTR, key) !== null) { placed++; continue; }
1333
1589
  const icon = target.icon;
1334
1590
  const rowRect = row.getBoundingClientRect();
1335
1591
  const anchorRect = icon.getBoundingClientRect();
@@ -1338,9 +1594,9 @@ window.__ModuleLoader__.load({
1338
1594
  row.style.position = "relative";
1339
1595
  }
1340
1596
  const dot = document.createElement("span");
1341
- dot.setAttribute(WS_DOT_ATTR, entry.name);
1597
+ dot.setAttribute(WS_DOT_ATTR, key);
1342
1598
  dot.setAttribute("aria-hidden", "true");
1343
- dot.title = entry.name + " · " + entry.count;
1599
+ dot.title = (typeof entry.name === "string" && entry.name !== "" ? entry.name : key) + " · " + entry.count;
1344
1600
  dot.style.cssText = "position:absolute;pointer-events:none;width:8px;height:8px;border-radius:50%;"
1345
1601
  + "background:#e5484d;box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff);"
1346
1602
  + "left:" + Math.round(anchorRect.right - rowRect.left - 4) + "px;"
@@ -1352,7 +1608,7 @@ window.__ModuleLoader__.load({
1352
1608
  // Session rows: the marker rides inline right after the title, so it stays
1353
1609
  // beside the name whatever the timestamp happens to say.
1354
1610
  for (const entry of wantedRows) {
1355
- const target = sessionRowTarget(container, entry.id, entry.title);
1611
+ const target = sessionRowTarget(container, entry.id, entry.title, legacyTitleMatch);
1356
1612
  if (target === null) { missed++; continue; }
1357
1613
  if (existingMarker(target.row, ROW_DOT_ATTR, entry.id) !== null) { placed++; continue; }
1358
1614
  const dot = document.createElement("span");
@@ -1505,19 +1761,21 @@ window.__ModuleLoader__.load({
1505
1761
  };
1506
1762
  }, [staySignature, delayMs]);
1507
1763
  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.
1764
+ // Map session → its Workspace from the official snapshot: the ID anchors the
1765
+ // folder dot to the keyed row, the title is what the row displays (not always
1766
+ // the cwd basename, and not always equal to the stored title either).
1510
1767
  React.useEffect(() => {
1511
- const titles = {};
1768
+ const owners = {};
1512
1769
  const list = workspaces !== null && typeof workspaces === "object" && Array.isArray(workspaces.items) ? workspaces.items : [];
1513
1770
  for (const workspace of list) {
1514
1771
  if (workspace === null || typeof workspace !== "object") continue;
1772
+ if (!Array.isArray(workspace.sessionIds)) continue;
1773
+ const id = typeof workspace.workspaceId === "string" ? workspace.workspaceId : "";
1515
1774
  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;
1775
+ for (const sessionId of workspace.sessionIds) owners[sessionId] = { id, title };
1518
1776
  }
1519
1777
  emitSidebarMarks({
1520
- workspaces: dotsFromItems(items, titles),
1778
+ workspaces: dotsFromItems(items, owners),
1521
1779
  sessions: items.map((item) => ({ id: item.id, title: item.title }))
1522
1780
  });
1523
1781
  }, [items, workspaces]);
@@ -2355,6 +2613,10 @@ window.__ModuleLoader__.load({
2355
2613
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-icon-custom: dictionaries");
2356
2614
  const t = ctx.locale.bind(NS);
2357
2615
  const rpc = ctx.connection.rpc;
2616
+ // The watermark's Host half rides the same connection as every other RPC.
2617
+ // Setting it here (not at module scope) is what keeps the store usable in a
2618
+ // test harness with no connection: pushes stay no-ops, the badge still works.
2619
+ seenRpc = rpc;
2358
2620
  // Pull the host state once, so the brand mark and the badge are correct
2359
2621
  // before the settings section is ever opened. The unread rule rides its
2360
2622
  // own endpoint: the icon RPCs keep their pre-badge response shape.
@@ -2366,8 +2628,16 @@ window.__ModuleLoader__.load({
2366
2628
  if (resp === null || typeof resp !== "object" || resp.ok !== true) return;
2367
2629
  // The rule response carries the Host's clock; it is what puts the seen
2368
2630
  // 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);
2631
+ // It also carries the Host's watermark table, which merges in here —
2632
+ // then the first push sends the union back, which is both the migration
2633
+ // (marks this browser made before 0.11.2) and the self-heal (a Host file
2634
+ // that was lost or pruned gets this browser's copy back).
2635
+ if (resp.value !== null && typeof resp.value === "object") {
2636
+ noteHostClock(resp.value.hostNow);
2637
+ if (adoptHostSeen(resp.value.seen)) saveSeenState();
2638
+ }
2370
2639
  emitUnreadConfig(resp.value);
2640
+ scheduleHostSync();
2371
2641
  })
2372
2642
  .catch(() => {});
2373
2643
  // Keep "the last moment this browser was here" fresh, so an ending that
@@ -2390,18 +2660,33 @@ window.__ModuleLoader__.load({
2390
2660
  // Re-sample the Host↔browser clock offset after a sleep/resume, which is the
2391
2661
  // one moment the two can drift apart mid-page. Throttled: it is one small
2392
2662
  // RPC per visible-again, and never on beforeunload (nothing would come back).
2663
+ // The same response carries the Host's watermark, so this is also how a
2664
+ // SECOND device learns that something was read elsewhere — a page that is
2665
+ // simply left open converges within a minute without a reload. Coming back
2666
+ // into view is also the natural moment to retry a failed push.
2393
2667
  ctx.effect(() => {
2394
2668
  let sampledAt = 0;
2395
2669
  const sample = () => {
2396
2670
  if (document.visibilityState === "hidden") return;
2671
+ scheduleHostSync();
2397
2672
  if (Date.now() - sampledAt < 60000) return;
2398
2673
  sampledAt = Date.now();
2399
2674
  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); })
2675
+ .then((resp) => {
2676
+ if (resp === null || typeof resp !== "object" || resp.ok !== true) return;
2677
+ const value = resp.value !== null && typeof resp.value === "object" ? resp.value : null;
2678
+ if (value === null) return;
2679
+ noteHostClock(value.hostNow);
2680
+ if (adoptHostSeen(value.seen)) { saveSeenState(); emitUnreadPoke(); }
2681
+ })
2401
2682
  .catch(() => {});
2402
2683
  };
2403
2684
  window.addEventListener("visibilitychange", sample);
2404
- return () => window.removeEventListener("visibilitychange", sample);
2685
+ const interval = window.setInterval(sample, 60000);
2686
+ return () => {
2687
+ window.removeEventListener("visibilitychange", sample);
2688
+ window.clearInterval(interval);
2689
+ };
2405
2690
  }, "dsh-icon-custom: host clock sample");
2406
2691
  // Brand-mark seat: replaces only the whale mark (verified: a
2407
2692
  // 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.12.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",