@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 +2 -0
- package/README.zh.md +4 -2
- package/client/client.js +318 -33
- package/lib/index.js +72 -8
- package/lib/typert.host.js +54 -4
- package/lib/unread.js +79 -0
- package/package.json +2 -2
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
|
-
-
|
|
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
|
-
- **工作区和会话行上的红点(实验)
|
|
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: "
|
|
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
|
|
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: `
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
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,
|
|
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
|
|
1017
|
-
const
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
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
|
|
1329
|
-
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
1509
|
-
//
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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) => {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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
|
|
package/lib/typert.host.js
CHANGED
|
@@ -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.
|
|
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",
|