@cowwo/dsh-icon-custom 0.15.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
- - 只有子代理会话不计。**什么算"你在看"**(0.12.1 起):那个会话必须真的显示在主区域的对话里(切到「插件 / 设置」这类页面不算)、浏览器标签页在前台(切到别的标签页或最小化不算)、且屏幕上就是它——残留的"最近打开过"不再算数。**你正开着的那个会话也算**;**要清掉它,切走再切回来即可**(你人已经在里面时又结束的那一轮不会自动消,见下一条)。
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. It only counts while that session is really on screen in the foreground tab: a settings or plugins page, or a tab in the background, is not \"being looked at\". Read state lives on the Host ($DSH_HOME/custom-favicon/unread-seen.json): read on any device means read on every device, and the browser's own copy is only a cache — clearing site data no longer throws it away."
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
 
@@ -613,10 +613,25 @@ window.__ModuleLoader__.load({
613
613
  * @param view - the screen report described above (may be absent).
614
614
  * @returns the ids being viewed, in list order; `[]` when there are none.
615
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
+ * "切一下标签页红点就没了".
625
+ *
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.
629
+ * @param list - the session list snapshot (may be absent).
630
+ * @param view - `{ displayed }`: the session the conversation is rendering.
631
+ * @returns the ids being viewed, in list order; `[]` when there are none.
632
+ */
616
633
  function currentSessionIds(list, view) {
617
634
  const screen = view !== null && typeof view === "object" ? view : {};
618
- if (screen.visible === false) return [];
619
- if (screen.panelActive === true) return [];
620
635
  if (typeof screen.displayed === "string" && screen.displayed !== "") return [screen.displayed];
621
636
  if (list === null || typeof list !== "object") return [];
622
637
  if (typeof list.current === "string" && list.current !== "") return [list.current];
@@ -1120,15 +1135,45 @@ window.__ModuleLoader__.load({
1120
1135
  // Never silent: a failure here is invisible in the UI (the tab simply
1121
1136
  // keeps its previous icon), which once cost a whole debugging session.
1122
1137
  // Warn once per distinct reason so the 5s watchdog cannot spam.
1123
- const why = String((error && error.message) || error);
1124
- if (why !== lastComposeError) {
1125
- lastComposeError = why;
1126
- try { console.warn("dsh-icon-custom: 标签页红点合成失败 / favicon badge compose failed:", why); } catch {}
1127
- }
1138
+ warnFaviconCompose(error);
1128
1139
  } finally {
1129
1140
  if (seq === composeSeq) faviconComposing = false;
1130
1141
  }
1131
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
+ }
1132
1177
  /**
1133
1178
  * Re-assert the tab badge after anything outside this plugin disturbed the
1134
1179
  * icon link — a cached page, a browser extension, a tab move, or a plugin
@@ -1145,17 +1190,17 @@ window.__ModuleLoader__.load({
1145
1190
  // Carries the custom icon, or puts the shipped pair back. Both writers
1146
1191
  // only touch a link that actually differs, so this stays a no-op once
1147
1192
  // the page is correct and cannot re-arm the head observer.
1148
- applyBadgeToFavicon(0);
1193
+ composeFavicon(0);
1149
1194
  return;
1150
1195
  }
1151
1196
  if (ours || faviconComposing) return; // already showing, or a build is running
1152
- applyBadgeToFavicon(effectiveCount(), badgeSize);
1197
+ composeFavicon(effectiveCount(), badgeSize);
1153
1198
  }
1154
1199
  // A freshly applied base icon replaces whatever the browser holds, so the
1155
1200
  // composite must be rebuilt from the NEW base instead of the stale one.
1156
1201
  function refreshFaviconBase() {
1157
1202
  faviconBase = null;
1158
- applyBadgeToFavicon(effectiveCount(), badgeSize);
1203
+ composeFavicon(effectiveCount(), badgeSize);
1159
1204
  }
1160
1205
  //#endregion
1161
1206
 
@@ -1732,6 +1777,9 @@ window.__ModuleLoader__.load({
1732
1777
  // conversation (its own standard prop, the same one the shipped sidebar reads)
1733
1778
  // and the tab going to the background. Both are read here, not inferred, so
1734
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.
1735
1783
  const panelActive = typeof props.usePanelInfo === "function"
1736
1784
  ? props.usePanelInfo((info) => info !== null && typeof info === "object" && info.activePanelId !== null)
1737
1785
  : false;
@@ -1741,7 +1789,9 @@ window.__ModuleLoader__.load({
1741
1789
  window.addEventListener("visibilitychange", onVisibility);
1742
1790
  return () => window.removeEventListener("visibilitychange", onVisibility);
1743
1791
  }, []);
1744
- const currentIds = currentSessionIds(list, { panelActive, visible, displayed: displayedSessionId() });
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() });
1745
1795
  const items = collectUnread(list, pending, config, seenState.seen);
1746
1796
  const count = items.length;
1747
1797
  // Being in a session is what marks it read — and the clock that does it is
@@ -1778,8 +1828,10 @@ window.__ModuleLoader__.load({
1778
1828
  /**
1779
1829
  * Entered-at snapshot: `id → endAt` as it stood when that session joined
1780
1830
  * the view. Deliberately NOT refreshed while the session stays — that is
1781
- * the rule ("an ending during your stay waits for the next entry"). A
1782
- * 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.
1783
1835
  */
1784
1836
  const stayArmed = React.useRef(new Map());
1785
1837
  React.useEffect(() => {
@@ -1791,7 +1843,10 @@ window.__ModuleLoader__.load({
1791
1843
  for (const id of stayIds) {
1792
1844
  if (!stayArmed.current.has(id)) stayArmed.current.set(id, lastTurnEndAt(snapshot[id]));
1793
1845
  }
1794
- 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;
1795
1850
  const armed = stayIds.map((id) => ({ id, endAt: stayArmed.current.get(id) }));
1796
1851
  let cancelled = false;
1797
1852
  let timer = 0;
@@ -1812,7 +1867,7 @@ window.__ModuleLoader__.load({
1812
1867
  cancelled = true;
1813
1868
  if (timer !== 0) window.clearTimeout(timer);
1814
1869
  };
1815
- }, [staySignature, delayMs]);
1870
+ }, [staySignature, delayMs, watching]);
1816
1871
  React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
1817
1872
  // Map session → its Workspace from the official snapshot: the ID anchors the
1818
1873
  // folder dot to the keyed row, the title is what the row displays (not always
@@ -2794,15 +2849,15 @@ window.__ModuleLoader__.load({
2794
2849
  // this effect keeps the tab icon in sync. Teardown restores the
2795
2850
  // pristine platform/custom icon.
2796
2851
  ctx.effect(() => {
2797
- const unsubscribe = subscribeBadge((snapshot) => { applyBadgeToFavicon(snapshot.count, snapshot.size); });
2852
+ const unsubscribe = subscribeBadge((snapshot) => { composeFavicon(snapshot.count, snapshot.size); });
2798
2853
  // Level-triggered on purpose: (re)applying this half must adopt the
2799
2854
  // value that is already on the bridge. The brand mark reads it at
2800
2855
  // mount; the tab icon has to be told, or a plugin hot-reload would
2801
2856
  // leave it restored (the old effect's cleanup) until the next change.
2802
- applyBadgeToFavicon(effectiveCount(), badgeSize);
2857
+ composeFavicon(effectiveCount(), badgeSize);
2803
2858
  return () => {
2804
2859
  unsubscribe();
2805
- applyBadgeToFavicon(0);
2860
+ composeFavicon(0);
2806
2861
  };
2807
2862
  }, "dsh-icon-custom: favicon badge");
2808
2863
  // Watchdog. The tab icon is the one surface other parties can disturb
@@ -2879,7 +2934,7 @@ window.__ModuleLoader__.load({
2879
2934
  }, "dsh-icon-custom: workspace dots");
2880
2935
  // The platform favicon.svg repaints itself for dark mode through
2881
2936
  // prefers-color-scheme; a composited bitmap cannot, so rebuild it.
2882
- ctx.on("theme/change", () => { applyBadgeToFavicon(effectiveCount(), badgeSize); });
2937
+ ctx.on("theme/change", () => { composeFavicon(effectiveCount(), badgeSize); });
2883
2938
  }
2884
2939
  exports.apply = apply;
2885
2940
  exports.inject = inject;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-icon-custom",
3
- "version": "0.15.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",