@cowwo/dsh-icon-custom 0.19.0 → 0.19.1
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/client/client.js +284 -91
- package/package.json +1 -1
package/client/client.js
CHANGED
|
@@ -646,69 +646,102 @@ window.__ModuleLoader__.load({
|
|
|
646
646
|
function documentVisible() {
|
|
647
647
|
try { return document.visibilityState !== "hidden"; } catch { return true; }
|
|
648
648
|
}
|
|
649
|
+
/**
|
|
650
|
+
* The official "Session on screen" answer, when this build exposes it.
|
|
651
|
+
*
|
|
652
|
+
* `ctx.uiSession.adapter.current` is the very binding the renderer derives
|
|
653
|
+
* `[data-conversation-session]` from, so it answers with the same value without
|
|
654
|
+
* the DOM's first-match ambiguity (see `conversationOnScreen`) — and it keeps
|
|
655
|
+
* answering while a global panel has unmounted the conversation, where the DOM
|
|
656
|
+
* has nothing to say. Filled in by `apply`, where a client context exists;
|
|
657
|
+
* until then it answers nothing, which is exactly what the other fallbacks are
|
|
658
|
+
* for.
|
|
659
|
+
* @returns the session id, or undefined when this build does not say.
|
|
660
|
+
*/
|
|
661
|
+
let officialSessionId = () => undefined;
|
|
662
|
+
/**
|
|
663
|
+
* What the conversation on screen is rendering, in two parts.
|
|
664
|
+
*
|
|
665
|
+
* `rendered` says whether the main conversation is on screen at all, and `id` is
|
|
666
|
+
* the session it renders (`undefined` while none is selected — the landing view).
|
|
667
|
+
* The distinction matters: "the conversation is showing no session" is an answer
|
|
668
|
+
* ("you are not in a session"), while "there is no conversation element" is not
|
|
669
|
+
* an answer at all (a global panel replaced it, the page is still booting).
|
|
670
|
+
*
|
|
671
|
+
* TWO occurrences can carry this attribute: the main conversation, and the
|
|
672
|
+
* embedded one the right sidebar keeps MOUNTED while collapsed (hidden by CSS and
|
|
673
|
+
* `inert`). A plain `querySelector` returns the first in document order, so the
|
|
674
|
+
* moment the main occurrence carries no attribute — no session selected, the
|
|
675
|
+
* archive/close path — the hidden one becomes the answer: "you are looking at
|
|
676
|
+
* that subagent conversation". The stay clock reads that as "you left this
|
|
677
|
+
* session", drops the entered-at snapshot, and the ending that arrived meanwhile
|
|
678
|
+
* is acknowledged the moment you come back: a dot that goes out without you ever
|
|
679
|
+
* leaving. Only an occurrence the user can actually see is an answer.
|
|
680
|
+
* @returns `{ rendered, id }`.
|
|
681
|
+
*/
|
|
682
|
+
function conversationOnScreen() {
|
|
683
|
+
try {
|
|
684
|
+
const nodes = document.querySelectorAll("[data-conversation-session]");
|
|
685
|
+
let rendered = false;
|
|
686
|
+
for (const node of nodes) {
|
|
687
|
+
if (typeof node.closest === "function" && (node.closest("[data-sidebar-right-panel]") !== null || node.closest("[inert]") !== null)) continue;
|
|
688
|
+
rendered = true;
|
|
689
|
+
const id = node.getAttribute("data-conversation-session");
|
|
690
|
+
if (typeof id === "string" && id !== "") return { rendered: true, id };
|
|
691
|
+
}
|
|
692
|
+
return { rendered, id: undefined };
|
|
693
|
+
} catch { return { rendered: false, id: undefined }; }
|
|
694
|
+
}
|
|
649
695
|
/**
|
|
650
696
|
* The session whose conversation the page is rendering right now, from the DOM.
|
|
651
697
|
*
|
|
652
698
|
* This is the only signal that names the session on screen, and it is the one
|
|
653
699
|
* that keeps a stale retention from clearing a dot: `retainedBy.mainView` means
|
|
654
|
-
* "recently opened, not yet released", not "displayed". `undefined` means
|
|
655
|
-
*
|
|
656
|
-
*
|
|
700
|
+
* "recently opened, not yet released", not "displayed". `undefined` means the page
|
|
701
|
+
* does not name one (see `conversationOnScreen`) and callers must fall back to the
|
|
702
|
+
* other answers rather than treat it as "nothing is displayed".
|
|
657
703
|
* @returns the session id, or undefined when the page cannot say.
|
|
658
704
|
*/
|
|
659
705
|
function displayedSessionId() {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
if (node === null) return undefined;
|
|
663
|
-
const id = node.getAttribute("data-conversation-session");
|
|
664
|
-
return typeof id === "string" && id !== "" ? id : undefined;
|
|
665
|
-
} catch { return undefined; }
|
|
666
|
-
}
|
|
667
|
-
/**
|
|
668
|
-
* Every session the main view is showing.
|
|
669
|
-
*
|
|
670
|
-
* `view` carries what the page itself says about the screen:
|
|
671
|
-
* * `panelActive` — a global panel (settings, plugins, …) replaced the
|
|
672
|
-
* conversation. DSH's own sidebar treats that as "no current session"
|
|
673
|
-
* (`usePanelInfo((info) => info.activePanelId !== null)`), and following it
|
|
674
|
-
* is what fixes the "cleared a dot I never opened" report: the conversation
|
|
675
|
-
* is simply not on screen.
|
|
676
|
-
* * `visible` — whether this page is on screen at all.
|
|
677
|
-
* * `displayed` — the session the conversation is rendering, when the DOM can
|
|
678
|
-
* name it. That is the precise answer and it wins over retention.
|
|
679
|
-
*
|
|
680
|
-
* Retention (`retainedBy.mainView`) stays as the fallback: it is what shipped
|
|
681
|
-
* before, it keeps working on a build without the newer signals, and its known
|
|
682
|
-
* weakness (a release that lags behind the pane) only costs a false "read" when
|
|
683
|
-
* nothing better is available.
|
|
684
|
-
* @param list - the session list snapshot (may be absent).
|
|
685
|
-
* @param view - the screen report described above (may be absent).
|
|
686
|
-
* @returns the ids being viewed, in list order; `[]` when there are none.
|
|
687
|
-
*/
|
|
706
|
+
return conversationOnScreen().id;
|
|
707
|
+
}
|
|
688
708
|
/**
|
|
689
|
-
*
|
|
709
|
+
* The session this page is ON — the set that defines "entered" and "left" — plus
|
|
710
|
+
* whether the answer is actually known.
|
|
711
|
+
*
|
|
712
|
+
* Only a change here counts as leaving a session, so this must NOT be emptied by
|
|
713
|
+
* pausing: a background tab, or a global panel (settings, plugins, …) that covers
|
|
714
|
+
* the conversation, keeps you on the same session. Emptying it would drop the
|
|
715
|
+
* entered-at snapshot, and taking a fresh one on return is exactly what
|
|
716
|
+
* acknowledges an ending you never left the session for.
|
|
690
717
|
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
718
|
+
* The answers, in order:
|
|
719
|
+
* * the visible conversation's own session (the precise one, and the only one
|
|
720
|
+
* that may overrule retention);
|
|
721
|
+
* * the official "Session on screen" binding (`view.official`, from
|
|
722
|
+
* `ctx.uiSession`) — the very value the attribute above is rendered from, so
|
|
723
|
+
* it still answers while a global panel has unmounted the conversation;
|
|
724
|
+
* * `list.current`, when a build carries it;
|
|
725
|
+
* * retention (`retainedBy.mainView > 0`), the pre-0.12 answer.
|
|
697
726
|
*
|
|
698
|
-
* `
|
|
699
|
-
*
|
|
700
|
-
* the
|
|
727
|
+
* `known: false` is the important one: it means the page could not name ANY
|
|
728
|
+
* session, which is NOT the same as "no session is being viewed". Callers must
|
|
729
|
+
* keep the snapshots they already have instead of reading it as a leave — a
|
|
730
|
+
* single such frame used to be enough to acknowledge an ending you were sitting
|
|
731
|
+
* in front of.
|
|
701
732
|
* @param list - the session list snapshot (may be absent).
|
|
702
|
-
* @param view - `{ displayed }`: the
|
|
703
|
-
* @returns
|
|
733
|
+
* @param view - `{ displayed, official }`: what the page says about the screen.
|
|
734
|
+
* @returns `{ ids, known }`; `ids` in list order, `[]` when there are none.
|
|
704
735
|
*/
|
|
705
|
-
function
|
|
736
|
+
function viewedSessions(list, view) {
|
|
706
737
|
const screen = view !== null && typeof view === "object" ? view : {};
|
|
707
|
-
if (typeof screen.displayed === "string" && screen.displayed !== "") return [screen.displayed];
|
|
708
|
-
if (
|
|
709
|
-
if (
|
|
738
|
+
if (typeof screen.displayed === "string" && screen.displayed !== "") return { ids: [screen.displayed], known: true };
|
|
739
|
+
if (typeof screen.official === "string" && screen.official !== "") return { ids: [screen.official], known: true };
|
|
740
|
+
if (screen.idle === true) return { ids: [], known: true }; // the conversation is up, showing no session
|
|
741
|
+
if (list === null || typeof list !== "object") return { ids: [], known: false };
|
|
742
|
+
if (typeof list.current === "string" && list.current !== "") return { ids: [list.current], known: true };
|
|
710
743
|
const byId = list.byId;
|
|
711
|
-
if (byId === null || typeof byId !== "object") return [];
|
|
744
|
+
if (byId === null || typeof byId !== "object") return { ids: [], known: false };
|
|
712
745
|
const ids = [];
|
|
713
746
|
for (const session of Object.values(byId)) {
|
|
714
747
|
if (session === null || typeof session !== "object") continue;
|
|
@@ -716,7 +749,11 @@ window.__ModuleLoader__.load({
|
|
|
716
749
|
const count = retainedBy !== null && typeof retainedBy === "object" ? retainedBy.mainView : undefined;
|
|
717
750
|
if (typeof count === "number" && count > 0 && typeof session.id === "string" && session.id !== "") ids.push(session.id);
|
|
718
751
|
}
|
|
719
|
-
|
|
752
|
+
// Retention with nothing in it, and the conversation not on screen either, is
|
|
753
|
+
// exactly the frame this must not read as a leave: it is what a global panel
|
|
754
|
+
// over a released session, a boot, or a DOM gap looks like. The landing view
|
|
755
|
+
// (`idle`) is the positive "you are on no session" answer, not this.
|
|
756
|
+
return { ids, known: ids.length > 0 };
|
|
720
757
|
}
|
|
721
758
|
/**
|
|
722
759
|
* One session's last `turn/end` timestamp (host domain), or 0 when it has
|
|
@@ -762,13 +799,16 @@ window.__ModuleLoader__.load({
|
|
|
762
799
|
* @param list - the session list snapshot (may be absent).
|
|
763
800
|
* @param pending - the pending-interaction map (may be absent).
|
|
764
801
|
* @param config - the unread rule to apply.
|
|
802
|
+
* @param lit - the ids currently showing a dot, so a change of ENDING KIND cannot
|
|
803
|
+
* put an already lit dot out (see below). Optional.
|
|
765
804
|
* @returns the badge number.
|
|
766
805
|
*/
|
|
767
|
-
function collectUnread(list, pending, config, seen) {
|
|
806
|
+
function collectUnread(list, pending, config, seen, lit) {
|
|
768
807
|
const reasons = config !== null && typeof config === "object" && config.reasons !== null && typeof config.reasons === "object" ? config.reasons : {};
|
|
769
808
|
// The watermark is a parameter, not module state: this stays a pure
|
|
770
809
|
// decision function that a test can drive with any marks it likes.
|
|
771
810
|
const marks = seen !== null && typeof seen === "object" ? seen : {};
|
|
811
|
+
const litIds = lit !== null && lit !== undefined && typeof lit.has === "function" ? lit : null;
|
|
772
812
|
/** { id, title, waiting, kind, at } — newest first, "in wait" ahead of "ended". */
|
|
773
813
|
const hits = [];
|
|
774
814
|
const ids = new Set();
|
|
@@ -788,7 +828,12 @@ window.__ModuleLoader__.load({
|
|
|
788
828
|
if (endAt <= 0 || typeof seenAt !== "number" || endAt <= seenAt) continue;
|
|
789
829
|
// Unknown reasons (a newer DSH adds one) stay hidden until the user
|
|
790
830
|
// turns them on, so a vocabulary growth can never surprise the badge.
|
|
791
|
-
|
|
831
|
+
// A reason the user turned OFF behaves the same way — with ONE
|
|
832
|
+
// exception: it must never put out a dot that is already lit. That dot
|
|
833
|
+
// stands for an ending nobody has read yet, and "the newest turn ended
|
|
834
|
+
// in a way I do not want to be told about" is not "I read it". Only the
|
|
835
|
+
// watermark clears a lit dot.
|
|
836
|
+
if (reasons[value.reason] !== true && !(litIds !== null && litIds.has(id))) continue;
|
|
792
837
|
ids.add(id);
|
|
793
838
|
hits.push({ id, title: titleOf(id, entry), where: sessionWhereOf(entry), waiting: false, kind: value.reason, at: endAt });
|
|
794
839
|
}
|
|
@@ -903,6 +948,16 @@ window.__ModuleLoader__.load({
|
|
|
903
948
|
let realCount = 0;
|
|
904
949
|
/** The sessions behind `realCount`, for the sidebar list (newest first). */
|
|
905
950
|
let realItems = [];
|
|
951
|
+
/**
|
|
952
|
+
* Which sessions are showing an unread dot right now.
|
|
953
|
+
*
|
|
954
|
+
* Read back by `collectUnread` so a change of ending KIND cannot put a lit dot
|
|
955
|
+
* out (see the reason filter there). Kept here rather than derived on the fly
|
|
956
|
+
* because the projection only carries the LATEST ending: once a newer one lands
|
|
957
|
+
* with a reason the user turned off, the one that lit the dot is no longer
|
|
958
|
+
* visible anywhere.
|
|
959
|
+
*/
|
|
960
|
+
let litUnreadIds = new Set();
|
|
906
961
|
/**
|
|
907
962
|
* How many sessions are running right now — the official `running` fact,
|
|
908
963
|
* already folded by `runningSessionIds`. It rides the SAME bridge as the
|
|
@@ -963,6 +1018,14 @@ window.__ModuleLoader__.load({
|
|
|
963
1018
|
function emitRealBadge(value, items) {
|
|
964
1019
|
realCount = normalizeCount(value);
|
|
965
1020
|
realItems = Array.isArray(items) ? items : [];
|
|
1021
|
+
// Remember what is lit BEFORE the next round of folding can hide it: the
|
|
1022
|
+
// projection only carries the latest ending, so an ending whose reason is
|
|
1023
|
+
// turned off would otherwise erase the memory of the one that lit the dot.
|
|
1024
|
+
const lit = new Set();
|
|
1025
|
+
for (const item of realItems) {
|
|
1026
|
+
if (item !== null && typeof item === "object" && typeof item.id === "string" && item.id !== "") lit.add(item.id);
|
|
1027
|
+
}
|
|
1028
|
+
litUnreadIds = lit;
|
|
966
1029
|
notifyBadge();
|
|
967
1030
|
}
|
|
968
1031
|
/**
|
|
@@ -1381,11 +1444,34 @@ window.__ModuleLoader__.load({
|
|
|
1381
1444
|
// canvas and the merged bitmap is handed back to the browser. Count 0 (and
|
|
1382
1445
|
// teardown) restores the pristine platform/custom href.
|
|
1383
1446
|
const FAVICON_SIZE = 64;
|
|
1447
|
+
/**
|
|
1448
|
+
* How long one base-image fetch may take before the compose gives up on it.
|
|
1449
|
+
*
|
|
1450
|
+
* A fetch that neither loads nor errors (a stalled request, a proxy that never
|
|
1451
|
+
* answers) used to leave the compose pending forever — and silently, because no
|
|
1452
|
+
* error is ever raised — so the tab icon simply stopped following the numbers
|
|
1453
|
+
* with nothing in the console. This timeout turns that into a reported failure
|
|
1454
|
+
* the watchdog can retry.
|
|
1455
|
+
*/
|
|
1456
|
+
const FAVICON_LOAD_TIMEOUT_MS = 8000;
|
|
1384
1457
|
let faviconBase = null;
|
|
1385
1458
|
let composeSeq = 0;
|
|
1386
1459
|
let faviconComposing = false;
|
|
1387
1460
|
/** Last compose failure, so a repeated failure warns once instead of spamming. */
|
|
1388
1461
|
let lastComposeError = null;
|
|
1462
|
+
/**
|
|
1463
|
+
* The picture the tab is carrying right now: `未读|进行中|基图`.
|
|
1464
|
+
*
|
|
1465
|
+
* A `data:` href alone says nothing — an OLD bitmap and a bitmap whose compose
|
|
1466
|
+
* FAILED look exactly the same from the DOM. Remembering which picture was
|
|
1467
|
+
* actually written is what lets the watchdog tell "already right" from "stale
|
|
1468
|
+
* but still ours", which is the hole that used to freeze the tab icon.
|
|
1469
|
+
*/
|
|
1470
|
+
let faviconDrawnSignature = "";
|
|
1471
|
+
/** The picture a compose is fetching right now, so a repeat cannot start a second fetch. */
|
|
1472
|
+
let faviconPendingSignature = "";
|
|
1473
|
+
/** Whether the last compose failed: the watchdog must retry instead of trusting the DOM. */
|
|
1474
|
+
let faviconFailed = false;
|
|
1389
1475
|
/** Which icon the plugin considers current (filled from the status RPC). */
|
|
1390
1476
|
let iconState = { active: false, rev: null, png: false, mime: null };
|
|
1391
1477
|
/** The icon URL this page SHOULD be showing, per the plugin's own status. */
|
|
@@ -1399,6 +1485,24 @@ window.__ModuleLoader__.load({
|
|
|
1399
1485
|
if (iconState.active !== true) return "image/svg+xml";
|
|
1400
1486
|
return iconState.png === true ? "image/png" : (iconState.mime || "image/svg+xml");
|
|
1401
1487
|
}
|
|
1488
|
+
/**
|
|
1489
|
+
* One string that names one picture: which numbers, on which base.
|
|
1490
|
+
*
|
|
1491
|
+
* It is the identity used both to remember what was drawn and to decide whether
|
|
1492
|
+
* a compose still has anything to do. The numbers are part of it on purpose —
|
|
1493
|
+
* "1 unread" and "2 unread" are different pictures even though both are `data:`.
|
|
1494
|
+
* @param count - the unread number this picture carries.
|
|
1495
|
+
* @param runningCount - the running number this picture carries.
|
|
1496
|
+
* @param base - the base icon href the numbers ride on.
|
|
1497
|
+
* @returns the signature string.
|
|
1498
|
+
*/
|
|
1499
|
+
function faviconSignature(count, runningCount, base) {
|
|
1500
|
+
return normalizeCount(count) + "|" + normalizeCount(runningCount) + "|" + base;
|
|
1501
|
+
}
|
|
1502
|
+
/** The picture this page SHOULD be carrying right now, from live state. */
|
|
1503
|
+
function wantedFaviconSignature() {
|
|
1504
|
+
return faviconSignature(effectiveCount(), runningTotal, desiredIconHref());
|
|
1505
|
+
}
|
|
1402
1506
|
/**
|
|
1403
1507
|
* Every `<link rel="icon">` the document carries, creating one if the page
|
|
1404
1508
|
* has none.
|
|
@@ -1545,36 +1649,64 @@ window.__ModuleLoader__.load({
|
|
|
1545
1649
|
c2d.fillText(text, cx, cy + size * 0.01);
|
|
1546
1650
|
c2d.restore();
|
|
1547
1651
|
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Load one image for the composite, with a deadline.
|
|
1654
|
+
*
|
|
1655
|
+
* Settles exactly once, whatever happens: a late `onload` after the timeout must
|
|
1656
|
+
* not draw on top of the picture that replaced it.
|
|
1657
|
+
* @param href - absolute URL of the base icon.
|
|
1658
|
+
* @returns the loaded image, or a rejection whose message says why.
|
|
1659
|
+
*/
|
|
1548
1660
|
function loadIconImage(href) {
|
|
1549
1661
|
return new Promise((resolve, reject) => {
|
|
1550
1662
|
const img = new Image();
|
|
1551
|
-
|
|
1552
|
-
|
|
1663
|
+
let done = false;
|
|
1664
|
+
const settle = (fn, value) => {
|
|
1665
|
+
if (done) return;
|
|
1666
|
+
done = true;
|
|
1667
|
+
window.clearTimeout(timer);
|
|
1668
|
+
fn(value);
|
|
1669
|
+
};
|
|
1670
|
+
const timer = window.setTimeout(() => { settle(reject, new Error("icon-load-timeout")); }, FAVICON_LOAD_TIMEOUT_MS);
|
|
1671
|
+
img.onload = () => { settle(resolve, img); };
|
|
1672
|
+
img.onerror = () => { settle(reject, new Error("icon-load")); };
|
|
1553
1673
|
img.src = href;
|
|
1554
1674
|
});
|
|
1555
1675
|
}
|
|
1556
1676
|
async function applyBadgeToFavicon(count, size) {
|
|
1557
|
-
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
faviconBase = null;
|
|
1569
|
-
return;
|
|
1570
|
-
}
|
|
1571
|
-
// The base is resolved from the plugin's status, never from whatever the
|
|
1572
|
-
// page happens to carry, and never from our own previous bitmap.
|
|
1573
|
-
faviconBase = { href: base, type: desiredIconType() };
|
|
1677
|
+
// ONE snapshot decides this whole compose: which marks to draw AND with which
|
|
1678
|
+
// numbers. `runningTotal` used to be read again after the await, so a run that
|
|
1679
|
+
// stopped mid-fetch was painted as a yellow "0", and the two numbers could
|
|
1680
|
+
// come from two different moments.
|
|
1681
|
+
const wantedCount = normalizeCount(count);
|
|
1682
|
+
const runningCount = normalizeCount(runningTotal);
|
|
1683
|
+
const signature = faviconSignature(wantedCount, runningCount, desiredIconHref());
|
|
1684
|
+
// The sequence is taken BEFORE the "nothing to draw" branch: restoring the
|
|
1685
|
+
// plain icon has to invalidate any compose still in flight, or that older
|
|
1686
|
+
// bitmap lands afterwards and paints the number we just cleared back onto
|
|
1687
|
+
// the tab. (That is exactly the "stale dot that will not go away" report.)
|
|
1574
1688
|
const seq = ++composeSeq;
|
|
1575
1689
|
faviconComposing = true;
|
|
1690
|
+
faviconPendingSignature = signature;
|
|
1691
|
+
faviconFailed = false;
|
|
1576
1692
|
try {
|
|
1577
|
-
|
|
1693
|
+
if (wantedCount <= 0 && runningCount <= 0) {
|
|
1694
|
+
// No badge to draw. With a custom icon active the page must carry it on
|
|
1695
|
+
// EVERY link (a stale scheme-specific sibling outranks the one we set);
|
|
1696
|
+
// with none, the shipped dark/light pair goes back untouched — the
|
|
1697
|
+
// plugin promises zero side effects while it has no icon of its own.
|
|
1698
|
+
if (seq !== composeSeq) return;
|
|
1699
|
+
if (iconState.active === true) setIconEverywhere(desiredIconHref(), desiredIconType());
|
|
1700
|
+
else restoreShippedIcons();
|
|
1701
|
+
faviconBase = null;
|
|
1702
|
+
faviconDrawnSignature = signature;
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
// The base is resolved from the plugin's status, never from whatever the
|
|
1706
|
+
// page happens to carry, and never from our own previous bitmap.
|
|
1707
|
+
const base = desiredIconHref();
|
|
1708
|
+
faviconBase = { href: base, type: desiredIconType() };
|
|
1709
|
+
const img = await loadIconImage(new URL(base, document.baseURI).href);
|
|
1578
1710
|
if (seq !== composeSeq) return;
|
|
1579
1711
|
const canvas = document.createElement("canvas");
|
|
1580
1712
|
canvas.width = FAVICON_SIZE;
|
|
@@ -1590,16 +1722,22 @@ window.__ModuleLoader__.load({
|
|
|
1590
1722
|
// together to the largest pair that fits, so neither reads as a bug and
|
|
1591
1723
|
// the two can never overlap.
|
|
1592
1724
|
const badgeUnit = badgeScale(size);
|
|
1593
|
-
const badgeK =
|
|
1594
|
-
if (
|
|
1595
|
-
if (
|
|
1725
|
+
const badgeK = wantedCount > 0 && runningCount > 0 ? badgeUnit * faviconBadgeFitScale(badgeUnit) : badgeUnit;
|
|
1726
|
+
if (wantedCount > 0) drawFaviconBadge(c2d, FAVICON_SIZE, wantedCount, badgeK);
|
|
1727
|
+
if (runningCount > 0) drawFaviconRunningBadge(c2d, FAVICON_SIZE, runningCount, badgeK);
|
|
1728
|
+
setIconEverywhere(canvas.toDataURL("image/png"), "image/png");
|
|
1729
|
+
faviconDrawnSignature = signature;
|
|
1596
1730
|
} catch (error) {
|
|
1597
1731
|
// Never silent: a failure here is invisible in the UI (the tab simply
|
|
1598
1732
|
// keeps its previous icon), which once cost a whole debugging session.
|
|
1599
1733
|
// Warn once per distinct reason so the 5s watchdog cannot spam.
|
|
1734
|
+
faviconFailed = true;
|
|
1600
1735
|
warnFaviconCompose(error);
|
|
1601
1736
|
} finally {
|
|
1602
|
-
if (seq === composeSeq)
|
|
1737
|
+
if (seq === composeSeq) {
|
|
1738
|
+
faviconComposing = false;
|
|
1739
|
+
faviconPendingSignature = "";
|
|
1740
|
+
}
|
|
1603
1741
|
}
|
|
1604
1742
|
}
|
|
1605
1743
|
/**
|
|
@@ -1623,10 +1761,22 @@ window.__ModuleLoader__.load({
|
|
|
1623
1761
|
* rejection, and the tab icon would simply never change with nothing in the
|
|
1624
1762
|
* console — the exact "no warning, no badge" state. Every call site goes through
|
|
1625
1763
|
* here instead.
|
|
1764
|
+
*
|
|
1765
|
+
* It is also the ONE place that decides whether there is anything to do: this is
|
|
1766
|
+
* called on every badge notification, and a badge notification happens on every
|
|
1767
|
+
* render. Without the signature guard each of those started its own base-image
|
|
1768
|
+
* fetch, and on a page whose base image is slow (a reverse proxy, a cold cache,
|
|
1769
|
+
* a laptop waking up) every fetch was superseded before it landed — the tab icon
|
|
1770
|
+
* fell further behind the numbers with every repaint instead of catching up.
|
|
1626
1771
|
* @param count - sessions needing you; 0 restores the plain icon.
|
|
1627
1772
|
* @param size - badge size key.
|
|
1773
|
+
* @param force - recompose even when this exact picture was already drawn (the
|
|
1774
|
+
* page was disturbed, the base icon changed, the last compose failed).
|
|
1628
1775
|
*/
|
|
1629
|
-
function composeFavicon(count, size) {
|
|
1776
|
+
function composeFavicon(count, size, force) {
|
|
1777
|
+
const signature = faviconSignature(count, runningTotal, desiredIconHref());
|
|
1778
|
+
if (signature === faviconPendingSignature) return; // this very picture is being fetched
|
|
1779
|
+
if (force !== true && signature === faviconDrawnSignature && faviconFailed !== true) return; // already on the tab
|
|
1630
1780
|
try {
|
|
1631
1781
|
Promise.resolve(applyBadgeToFavicon(count, size)).catch(warnFaviconCompose);
|
|
1632
1782
|
} catch (error) {
|
|
@@ -1641,6 +1791,12 @@ window.__ModuleLoader__.load({
|
|
|
1641
1791
|
* icon link — a cached page, a browser extension, a tab move, or a plugin
|
|
1642
1792
|
* reload. Our own output is a `data:` URL, so any other href means the
|
|
1643
1793
|
* composite was replaced and has to be rebuilt from the new base.
|
|
1794
|
+
*
|
|
1795
|
+
* "Still ours" is not the same as "still right": a `data:` href can carry an
|
|
1796
|
+
* older number, or one whose compose died halfway. Comparing the picture that
|
|
1797
|
+
* was actually written against the one that is wanted now is what lets this
|
|
1798
|
+
* watchdog repair those — and what makes a failed compose a retry instead of a
|
|
1799
|
+
* tab icon that is frozen for the rest of the page's life.
|
|
1644
1800
|
*/
|
|
1645
1801
|
function reconcileFavicon() {
|
|
1646
1802
|
// Inspect EVERY icon link: 0.2.x ships two (dark/light), and a sibling
|
|
@@ -1648,21 +1804,24 @@ window.__ModuleLoader__.load({
|
|
|
1648
1804
|
// stale sibling is exactly what used to outrank the icon we set.
|
|
1649
1805
|
const hrefs = iconLinks().map((candidate) => candidate.getAttribute("href"));
|
|
1650
1806
|
const ours = hrefs.length > 0 && hrefs.every((value) => typeof value === "string" && value.slice(0, 5) === "data:");
|
|
1651
|
-
|
|
1807
|
+
const wanted = wantedFaviconSignature();
|
|
1808
|
+
if (ours && faviconDrawnSignature === wanted && faviconFailed !== true) return; // exactly right, right now
|
|
1809
|
+
const count = effectiveCount();
|
|
1810
|
+
if (count <= 0 && normalizeCount(runningTotal) <= 0) {
|
|
1652
1811
|
// Carries the custom icon, or puts the shipped pair back. Both writers
|
|
1653
1812
|
// only touch a link that actually differs, so this stays a no-op once
|
|
1654
1813
|
// the page is correct and cannot re-arm the head observer.
|
|
1655
|
-
composeFavicon(0);
|
|
1814
|
+
composeFavicon(0, badgeSize, ours !== true);
|
|
1656
1815
|
return;
|
|
1657
1816
|
}
|
|
1658
|
-
if (
|
|
1659
|
-
composeFavicon(
|
|
1817
|
+
if (faviconComposing) return; // a build is already running
|
|
1818
|
+
composeFavicon(count, badgeSize, ours !== true);
|
|
1660
1819
|
}
|
|
1661
1820
|
// A freshly applied base icon replaces whatever the browser holds, so the
|
|
1662
1821
|
// composite must be rebuilt from the NEW base instead of the stale one.
|
|
1663
1822
|
function refreshFaviconBase() {
|
|
1664
1823
|
faviconBase = null;
|
|
1665
|
-
composeFavicon(effectiveCount(), badgeSize);
|
|
1824
|
+
composeFavicon(effectiveCount(), badgeSize, true);
|
|
1666
1825
|
}
|
|
1667
1826
|
//#endregion
|
|
1668
1827
|
|
|
@@ -2575,8 +2734,19 @@ window.__ModuleLoader__.load({
|
|
|
2575
2734
|
}, []);
|
|
2576
2735
|
/** Looking at the conversation right now: on screen, and this tab in front. */
|
|
2577
2736
|
const watching = visible && panelActive !== true;
|
|
2578
|
-
|
|
2579
|
-
|
|
2737
|
+
// Read the screen once per render: which session the visible conversation
|
|
2738
|
+
// renders (`displayed`), whether it is up but showing none (`idle` — the
|
|
2739
|
+
// landing view, a real "you are not in a session"), and whether it is not up
|
|
2740
|
+
// at all (neither ⇒ `known: false`, and the entered-at snapshots stay).
|
|
2741
|
+
const onScreen = conversationOnScreen();
|
|
2742
|
+
const viewed = viewedSessions(list, {
|
|
2743
|
+
displayed: onScreen.id,
|
|
2744
|
+
official: officialSessionId(),
|
|
2745
|
+
idle: onScreen.rendered === true && onScreen.id === undefined
|
|
2746
|
+
});
|
|
2747
|
+
const currentIds = viewed.ids;
|
|
2748
|
+
const stayKnown = viewed.known;
|
|
2749
|
+
const items = collectUnread(list, pending, config, seenState.seen, litUnreadIds);
|
|
2580
2750
|
/**
|
|
2581
2751
|
* Map session → its Workspace from the official snapshot: the ID anchors the
|
|
2582
2752
|
* folder dot to the keyed row, the title is what the row displays (not always
|
|
@@ -2650,9 +2820,17 @@ window.__ModuleLoader__.load({
|
|
|
2650
2820
|
const stayArmed = React.useRef(new Map());
|
|
2651
2821
|
React.useEffect(() => {
|
|
2652
2822
|
const snapshot = stayLive.current.list !== null && typeof stayLive.current.list === "object" && stayLive.current.list.byId !== null && typeof stayLive.current.list.byId === "object" ? stayLive.current.list.byId : {};
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2823
|
+
// ONLY A KNOWN ANSWER MAY DROP A SNAPSHOT. A frame in which the page could
|
|
2824
|
+
// not name any session (a panel unmounting the conversation while the
|
|
2825
|
+
// official binding is unavailable, a DOM gap, a build without either
|
|
2826
|
+
// signal) is not a leave: forgetting the snapshot there is what made the
|
|
2827
|
+
// clock re-enter the session behind the reader's back and acknowledge an
|
|
2828
|
+
// ending that arrived while they were sitting in front of it.
|
|
2829
|
+
if (stayKnown !== false) {
|
|
2830
|
+
const live = new Set(stayIds);
|
|
2831
|
+
for (const id of Array.from(stayArmed.current.keys())) {
|
|
2832
|
+
if (!live.has(id)) stayArmed.current.delete(id);
|
|
2833
|
+
}
|
|
2656
2834
|
}
|
|
2657
2835
|
for (const id of stayIds) {
|
|
2658
2836
|
if (!stayArmed.current.has(id)) stayArmed.current.set(id, lastTurnEndAt(snapshot[id]));
|
|
@@ -2681,7 +2859,7 @@ window.__ModuleLoader__.load({
|
|
|
2681
2859
|
cancelled = true;
|
|
2682
2860
|
if (timer !== 0) window.clearTimeout(timer);
|
|
2683
2861
|
};
|
|
2684
|
-
}, [staySignature, delayMs, watching]);
|
|
2862
|
+
}, [staySignature, delayMs, watching, stayKnown]);
|
|
2685
2863
|
React.useEffect(() => { emitRealBadge(count, itemsWithOwner); }, [count, itemsWithOwner]);
|
|
2686
2864
|
/**
|
|
2687
2865
|
* One session's displayed title, the way the sidebar shows it.
|
|
@@ -3783,6 +3961,18 @@ window.__ModuleLoader__.load({
|
|
|
3783
3961
|
// Setting it here (not at module scope) is what keeps the store usable in a
|
|
3784
3962
|
// test harness with no connection: pushes stay no-ops, the badge still works.
|
|
3785
3963
|
seenRpc = rpc;
|
|
3964
|
+
// The official "Session on screen" binding, looked up lazily so a build
|
|
3965
|
+
// without it (or a plugin loaded before ui-session) simply answers nothing
|
|
3966
|
+
// and the DOM/retention fallbacks keep the behaviour this file shipped with.
|
|
3967
|
+
officialSessionId = () => {
|
|
3968
|
+
try {
|
|
3969
|
+
const service = ctx.get("uiSession");
|
|
3970
|
+
const current = service !== null && service !== undefined ? service.adapter?.current : undefined;
|
|
3971
|
+
const value = current !== null && current !== undefined && typeof current.getSnapshot === "function" ? current.getSnapshot() : undefined;
|
|
3972
|
+
const key = value !== null && typeof value === "object" ? value.key : undefined;
|
|
3973
|
+
return typeof key === "string" && key !== "" ? key : undefined;
|
|
3974
|
+
} catch { return undefined; }
|
|
3975
|
+
};
|
|
3786
3976
|
// Pull the host state once, so the brand mark and the badge are correct
|
|
3787
3977
|
// before the settings section is ever opened. The unread rule rides its
|
|
3788
3978
|
// own endpoint: the icon RPCs keep their pre-badge response shape.
|
|
@@ -3928,10 +4118,12 @@ window.__ModuleLoader__.load({
|
|
|
3928
4118
|
// value that is already on the bridge. The brand mark reads it at
|
|
3929
4119
|
// mount; the tab icon has to be told, or a plugin hot-reload would
|
|
3930
4120
|
// leave it restored (the old effect's cleanup) until the next change.
|
|
3931
|
-
composeFavicon(effectiveCount(), badgeSize);
|
|
4121
|
+
composeFavicon(effectiveCount(), badgeSize, true);
|
|
3932
4122
|
return () => {
|
|
3933
4123
|
unsubscribe();
|
|
3934
|
-
|
|
4124
|
+
// Forced: the promise is that unloading this half leaves the page's
|
|
4125
|
+
// own icon behind, whatever this page load had drawn before.
|
|
4126
|
+
composeFavicon(0, badgeSize, true);
|
|
3935
4127
|
};
|
|
3936
4128
|
}, "dsh-icon-custom: favicon badge");
|
|
3937
4129
|
// Watchdog. The tab icon is the one surface other parties can disturb
|
|
@@ -4015,8 +4207,9 @@ window.__ModuleLoader__.load({
|
|
|
4015
4207
|
};
|
|
4016
4208
|
}, "dsh-icon-custom: workspace dots");
|
|
4017
4209
|
// The platform favicon.svg repaints itself for dark mode through
|
|
4018
|
-
// prefers-color-scheme; a composited bitmap cannot, so rebuild it
|
|
4019
|
-
|
|
4210
|
+
// prefers-color-scheme; a composited bitmap cannot, so rebuild it —
|
|
4211
|
+
// forced, because the base it was built from is exactly what changed.
|
|
4212
|
+
ctx.on("theme/change", () => { composeFavicon(effectiveCount(), badgeSize, true); });
|
|
4020
4213
|
}
|
|
4021
4214
|
exports.apply = apply;
|
|
4022
4215
|
exports.inject = inject;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cowwo/dsh-icon-custom",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标,显示未读红点与进行中黄点(工作区数字/会话行/左上角/标签页,数字可点开清单、点条目定位到侧栏),并可把未读数显示为系统应用图标角标(Windows 任务栏 / macOS Dock,含通知权限申请)。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists, auto-sizes PWA icons for Android/iOS/tablets, shows unread red dots and running yellow marks (workspace number / session row / top-left / tab icon, both counts open a list whose rows locate the session in the sidebar), and projects the unread count onto the system app-icon badge (Windows taskbar / macOS Dock, notifications permission included).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|