@cowwo/dsh-icon-custom 0.12.0 → 0.16.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.zh.md CHANGED
@@ -58,7 +58,7 @@ SVG 会在浏览器里先栅格化成 512×512 PNG 再上传,ICO 则由后端提
58
58
  规则:
59
59
 
60
60
  - 数字 = **几个会话**在等你(不是几件事);同一个会话只占一格。
61
- - 只有子代理会话不计。**你正开着的那个会话也算**——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分;**要清掉它,切走再切回来即可**(你人已经在里面时又结束的那一轮不会自动消,见下一条)。
61
+ - 只有子代理会话不计。**什么算"你在看"**(0.15.2 起):那个会话必须真的显示在主区域的对话里、浏览器标签页在前台(切到别的标签页或最小化不算)、且屏幕上就是它——残留的"最近打开过"不再算数。**这两种"不算"都只是暂停计时,不会因此清掉红点**;真正算"离开"的只有一件事:**去别的会话**(那时它的快照被丢弃,你下次进来待够 N 秒才算看过)。**你正开着的那个会话也算**;**要清掉它,切走再切回来即可**(你人已经在里面时又结束的那一轮不会自动消,见下一条)。
62
62
  - **已读记在宿主**(`$DSH_HOME/custom-favicon/unread-seen.json`):进过 = 处理过,刷新/重启不会"复活",**任何一台设备上看过,所有设备都不再提醒**(开着的页面最多约一分钟收敛,刷新则立刻)。浏览器自己也留一份,但只当缓存兼离线队列——所以清站点数据、换浏览器、无痕窗口都不会把"已读"丢回去。装插件之前的历史结束也不算。
63
63
  - **可以要求"待够才算已读"**:设置页填一个秒数(默认 0)。填 0 = 进去就算已读(红点立即消失);填 N = 你要在那个会话里**连续待满 N 秒**它才算看过,中途切走就清零重数,刷新页面也重新数。上限 600 秒。
64
64
  **计时只认"你进入会话时已经存在的那次结束"**:你人已经在会话里的时候它又跑完一轮,那一轮**不算数**——红点会一直亮着,等你**切走再进来**才消。这样"我正看着的会话跑完了"这件事不会被几秒钟后自动抹掉(0.11.1 起;此前是"停留满 N 秒就把最新那次结束也标成已读",红点只闪一下)。
package/client/client.js CHANGED
@@ -80,7 +80,7 @@ window.__ModuleLoader__.load({
80
80
  badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
81
81
  clearDelayLabel: "进入会话后多久算已读:",
82
82
  clearDelayUnit: "秒",
83
- clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600 秒。已读记在宿主($DSH_HOME/custom-favicon/unread-seen.json):任何一台设备上看过,所有设备都不再提醒;浏览器那份只是缓存,清站点数据不会把已读丢回去。"
83
+ clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600 秒。**切到「插件/设置」页、或把标签页切到后台,都只是暂停计时,不会因此清掉红点**(它们不算离开);真正算离开的只有一件事:**去别的会话**。已读记在宿主($DSH_HOME/custom-favicon/unread-seen.json):任何一台设备上看过,所有设备都不再提醒;浏览器那份只是缓存,清站点数据不会把已读丢回去。"
84
84
  };
85
85
  const en = {
86
86
  nav: "Favicon",
@@ -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. 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."
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. A settings or plugins page, or a tab in the background, only **pauses** the clock — neither clears a dot (neither counts as leaving); the one thing that does count as leaving is **opening another session**. 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
 
@@ -562,23 +562,77 @@ window.__ModuleLoader__.load({
562
562
  }
563
563
  return changed;
564
564
  }
565
+ /**
566
+ * Whether this page is on screen at all.
567
+ *
568
+ * A hidden tab is not "being looked at": the clock must not run while the page
569
+ * is in the background, or a session left open in another tab would be marked
570
+ * read for every device. Unknown (an exotic embedder) counts as visible, which
571
+ * is the pre-0.12.1 behaviour.
572
+ * @returns whether the page is visible.
573
+ */
574
+ function documentVisible() {
575
+ try { return document.visibilityState !== "hidden"; } catch { return true; }
576
+ }
577
+ /**
578
+ * The session whose conversation the page is rendering right now, from the DOM.
579
+ *
580
+ * This is the only signal that names the session on screen, and it is the one
581
+ * that keeps a stale retention from clearing a dot: `retainedBy.mainView` means
582
+ * "recently opened, not yet released", not "displayed". `undefined` means this
583
+ * build carries no such marker (or the probe failed) and callers must fall back
584
+ * to the retention answer rather than treat it as "nothing is displayed".
585
+ * @returns the session id, or undefined when the page cannot say.
586
+ */
587
+ function displayedSessionId() {
588
+ try {
589
+ const node = document.querySelector("[data-conversation-session]");
590
+ if (node === null) return undefined;
591
+ const id = node.getAttribute("data-conversation-session");
592
+ return typeof id === "string" && id !== "" ? id : undefined;
593
+ } catch { return undefined; }
594
+ }
565
595
  /**
566
596
  * Every session the main view is showing.
567
597
  *
568
- * The official signal is the row's `retainedBy.mainView` retention count, the
569
- * same one ui-layout's DocumentTitle, ui-cordis, ui-open-in-app and ui-session
570
- * read (`list.current` is honoured first for runtimes that expose it).
598
+ * `view` carries what the page itself says about the screen:
599
+ * * `panelActive` — a global panel (settings, plugins, …) replaced the
600
+ * conversation. DSH's own sidebar treats that as "no current session"
601
+ * (`usePanelInfo((info) => info.activePanelId !== null)`), and following it
602
+ * is what fixes the "cleared a dot I never opened" report: the conversation
603
+ * is simply not on screen.
604
+ * * `visible` — whether this page is on screen at all.
605
+ * * `displayed` — the session the conversation is rendering, when the DOM can
606
+ * name it. That is the precise answer and it wins over retention.
607
+ *
608
+ * Retention (`retainedBy.mainView`) stays as the fallback: it is what shipped
609
+ * before, it keeps working on a build without the newer signals, and its known
610
+ * weakness (a release that lags behind the pane) only costs a false "read" when
611
+ * nothing better is available.
612
+ * @param list - the session list snapshot (may be absent).
613
+ * @param view - the screen report described above (may be absent).
614
+ * @returns the ids being viewed, in list order; `[]` when there are none.
615
+ */
616
+ /**
617
+ * Which session this page is ON — the set that defines "entered" and "left".
618
+ *
619
+ * Only a change here counts as leaving a session, so this must NOT be emptied
620
+ * by pausing: a background tab, or a global panel (settings, plugins, …) that
621
+ * covers the conversation, keeps you on the same session. Emptying it would
622
+ * drop the entered-at snapshot, and taking a fresh one on return is exactly
623
+ * what acknowledges an ending you never left the session for — the bug behind
624
+ * "切一下标签页红点就没了".
571
625
  *
572
- * It returns ALL of them, not one. The pane retains the incoming session
573
- * BEFORE it releases the outgoing one, and a retention that outlives its pane
574
- * can leave an old session looking "current" for good — and picking a single
575
- * id then meant the session you were actually reading was never marked read:
576
- * the dot stayed red however long you stayed in it. Each retained session
577
- * gets its own stay clock instead.
626
+ * `view.displayed` (read from the DOM) is the precise answer when available;
627
+ * `list.current` and then retention are the fallbacks, which is also what keeps
628
+ * the answer meaningful while a panel covers the conversation.
578
629
  * @param list - the session list snapshot (may be absent).
630
+ * @param view - `{ displayed }`: the session the conversation is rendering.
579
631
  * @returns the ids being viewed, in list order; `[]` when there are none.
580
632
  */
581
- function currentSessionIds(list) {
633
+ function currentSessionIds(list, view) {
634
+ const screen = view !== null && typeof view === "object" ? view : {};
635
+ if (typeof screen.displayed === "string" && screen.displayed !== "") return [screen.displayed];
582
636
  if (list === null || typeof list !== "object") return [];
583
637
  if (typeof list.current === "string" && list.current !== "") return [list.current];
584
638
  const byId = list.byId;
@@ -1081,15 +1135,45 @@ window.__ModuleLoader__.load({
1081
1135
  // Never silent: a failure here is invisible in the UI (the tab simply
1082
1136
  // keeps its previous icon), which once cost a whole debugging session.
1083
1137
  // Warn once per distinct reason so the 5s watchdog cannot spam.
1084
- const why = String((error && error.message) || error);
1085
- if (why !== lastComposeError) {
1086
- lastComposeError = why;
1087
- try { console.warn("dsh-icon-custom: 标签页红点合成失败 / favicon badge compose failed:", why); } catch {}
1088
- }
1138
+ warnFaviconCompose(error);
1089
1139
  } finally {
1090
1140
  if (seq === composeSeq) faviconComposing = false;
1091
1141
  }
1092
1142
  }
1143
+ /**
1144
+ * Report a failed tab-icon composition, once per distinct reason.
1145
+ *
1146
+ * The 5-second watchdog retries, so repeating the same line would bury the
1147
+ * console in noise; a NEW reason is exactly the signal worth seeing.
1148
+ * @param error - whatever the compose threw.
1149
+ */
1150
+ function warnFaviconCompose(error) {
1151
+ const why = String((error && error.message) || error);
1152
+ if (why === lastComposeError) return;
1153
+ lastComposeError = why;
1154
+ try { console.warn("dsh-icon-custom: 标签页红点合成失败 / favicon badge compose failed:", why); } catch {}
1155
+ }
1156
+ /**
1157
+ * Fire-and-forget composition whose failures are still reported.
1158
+ *
1159
+ * `applyBadgeToFavicon` is async, so it never throws synchronously: a caller's
1160
+ * `try { ... } catch {}` (the badge listeners all have one) cannot see its
1161
+ * rejection, and the tab icon would simply never change with nothing in the
1162
+ * console — the exact "no warning, no badge" state. Every call site goes through
1163
+ * here instead.
1164
+ * @param count - sessions needing you; 0 restores the plain icon.
1165
+ * @param size - badge size key.
1166
+ */
1167
+ function composeFavicon(count, size) {
1168
+ try {
1169
+ Promise.resolve(applyBadgeToFavicon(count, size)).catch(warnFaviconCompose);
1170
+ } catch (error) {
1171
+ // An `async` callee never throws synchronously, so this arm is dead TODAY —
1172
+ // it is here so a future refactor (an early-returning sync path) cannot
1173
+ // quietly reintroduce the silent "no warning, no badge" state.
1174
+ warnFaviconCompose(error);
1175
+ }
1176
+ }
1093
1177
  /**
1094
1178
  * Re-assert the tab badge after anything outside this plugin disturbed the
1095
1179
  * icon link — a cached page, a browser extension, a tab move, or a plugin
@@ -1106,17 +1190,17 @@ window.__ModuleLoader__.load({
1106
1190
  // Carries the custom icon, or puts the shipped pair back. Both writers
1107
1191
  // only touch a link that actually differs, so this stays a no-op once
1108
1192
  // the page is correct and cannot re-arm the head observer.
1109
- applyBadgeToFavicon(0);
1193
+ composeFavicon(0);
1110
1194
  return;
1111
1195
  }
1112
1196
  if (ours || faviconComposing) return; // already showing, or a build is running
1113
- applyBadgeToFavicon(effectiveCount(), badgeSize);
1197
+ composeFavicon(effectiveCount(), badgeSize);
1114
1198
  }
1115
1199
  // A freshly applied base icon replaces whatever the browser holds, so the
1116
1200
  // composite must be rebuilt from the NEW base instead of the stale one.
1117
1201
  function refreshFaviconBase() {
1118
1202
  faviconBase = null;
1119
- applyBadgeToFavicon(effectiveCount(), badgeSize);
1203
+ composeFavicon(effectiveCount(), badgeSize);
1120
1204
  }
1121
1205
  //#endregion
1122
1206
 
@@ -1688,7 +1772,26 @@ window.__ModuleLoader__.load({
1688
1772
  pruneSeenState();
1689
1773
  saveSeenState();
1690
1774
  }, [list]);
1691
- const currentIds = currentSessionIds(list);
1775
+ // What the page says about the screen, refreshed on the two moments that can
1776
+ // change it without the session list changing: a global panel replacing the
1777
+ // conversation (its own standard prop, the same one the shipped sidebar reads)
1778
+ // and the tab going to the background. Both are read here, not inferred, so
1779
+ // "you are looking at it" stops being a guess about stale retention.
1780
+ // Pausing signals, NOT leaving signals: they stop the stay clock without
1781
+ // touching which session this page is on (so the entered-at snapshot
1782
+ // survives). Only switching to another session counts as leaving.
1783
+ const panelActive = typeof props.usePanelInfo === "function"
1784
+ ? props.usePanelInfo((info) => info !== null && typeof info === "object" && info.activePanelId !== null)
1785
+ : false;
1786
+ const [visible, setVisible] = React.useState(documentVisible);
1787
+ React.useEffect(() => {
1788
+ const onVisibility = () => setVisible(documentVisible());
1789
+ window.addEventListener("visibilitychange", onVisibility);
1790
+ return () => window.removeEventListener("visibilitychange", onVisibility);
1791
+ }, []);
1792
+ /** Looking at the conversation right now: on screen, and this tab in front. */
1793
+ const watching = visible && panelActive !== true;
1794
+ const currentIds = currentSessionIds(list, { displayed: displayedSessionId() });
1692
1795
  const items = collectUnread(list, pending, config, seenState.seen);
1693
1796
  const count = items.length;
1694
1797
  // Being in a session is what marks it read — and the clock that does it is
@@ -1725,8 +1828,10 @@ window.__ModuleLoader__.load({
1725
1828
  /**
1726
1829
  * Entered-at snapshot: `id → endAt` as it stood when that session joined
1727
1830
  * the view. Deliberately NOT refreshed while the session stays — that is
1728
- * the rule ("an ending during your stay waits for the next entry"). A
1729
- * session that leaves is forgotten, so coming back takes a fresh snapshot.
1831
+ * the rule ("an ending during your stay waits for the next entry"). Only a
1832
+ * session that LEAVES the view is forgotten, so coming back to it takes a
1833
+ * fresh snapshot; pausing (background tab, a global panel) keeps both the
1834
+ * session and its snapshot.
1730
1835
  */
1731
1836
  const stayArmed = React.useRef(new Map());
1732
1837
  React.useEffect(() => {
@@ -1738,7 +1843,10 @@ window.__ModuleLoader__.load({
1738
1843
  for (const id of stayIds) {
1739
1844
  if (!stayArmed.current.has(id)) stayArmed.current.set(id, lastTurnEndAt(snapshot[id]));
1740
1845
  }
1741
- if (staySignature === "") return undefined;
1846
+ // Paused: nothing may be acknowledged while nobody is watching, but the
1847
+ // snapshots above stay put — that is what makes resuming continue the
1848
+ // same entry instead of silently re-entering it.
1849
+ if (staySignature === "" || watching !== true) return undefined;
1742
1850
  const armed = stayIds.map((id) => ({ id, endAt: stayArmed.current.get(id) }));
1743
1851
  let cancelled = false;
1744
1852
  let timer = 0;
@@ -1759,7 +1867,7 @@ window.__ModuleLoader__.load({
1759
1867
  cancelled = true;
1760
1868
  if (timer !== 0) window.clearTimeout(timer);
1761
1869
  };
1762
- }, [staySignature, delayMs]);
1870
+ }, [staySignature, delayMs, watching]);
1763
1871
  React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
1764
1872
  // Map session → its Workspace from the official snapshot: the ID anchors the
1765
1873
  // folder dot to the keyed row, the title is what the row displays (not always
@@ -2741,15 +2849,15 @@ window.__ModuleLoader__.load({
2741
2849
  // this effect keeps the tab icon in sync. Teardown restores the
2742
2850
  // pristine platform/custom icon.
2743
2851
  ctx.effect(() => {
2744
- const unsubscribe = subscribeBadge((snapshot) => { applyBadgeToFavicon(snapshot.count, snapshot.size); });
2852
+ const unsubscribe = subscribeBadge((snapshot) => { composeFavicon(snapshot.count, snapshot.size); });
2745
2853
  // Level-triggered on purpose: (re)applying this half must adopt the
2746
2854
  // value that is already on the bridge. The brand mark reads it at
2747
2855
  // mount; the tab icon has to be told, or a plugin hot-reload would
2748
2856
  // leave it restored (the old effect's cleanup) until the next change.
2749
- applyBadgeToFavicon(effectiveCount(), badgeSize);
2857
+ composeFavicon(effectiveCount(), badgeSize);
2750
2858
  return () => {
2751
2859
  unsubscribe();
2752
- applyBadgeToFavicon(0);
2860
+ composeFavicon(0);
2753
2861
  };
2754
2862
  }, "dsh-icon-custom: favicon badge");
2755
2863
  // Watchdog. The tab icon is the one surface other parties can disturb
@@ -2826,7 +2934,7 @@ window.__ModuleLoader__.load({
2826
2934
  }, "dsh-icon-custom: workspace dots");
2827
2935
  // The platform favicon.svg repaints itself for dark mode through
2828
2936
  // prefers-color-scheme; a composited bitmap cannot, so rebuild it.
2829
- ctx.on("theme/change", () => { applyBadgeToFavicon(effectiveCount(), badgeSize); });
2937
+ ctx.on("theme/change", () => { composeFavicon(effectiveCount(), badgeSize); });
2830
2938
  }
2831
2939
  exports.apply = apply;
2832
2940
  exports.inject = inject;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-icon-custom",
3
- "version": "0.12.0",
3
+ "version": "0.16.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",