@cowwo/dsh-icon-custom 0.8.1 → 0.9.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/client/client.js +141 -49
- package/lib/index.js +22 -4
- package/package.json +1 -1
package/client/client.js
CHANGED
|
@@ -300,6 +300,32 @@ window.__ModuleLoader__.load({
|
|
|
300
300
|
}
|
|
301
301
|
return changed;
|
|
302
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* The session the main view is showing.
|
|
305
|
+
*
|
|
306
|
+
* DSH 0.2.x carries NO `current` field in the session-list snapshot — it is
|
|
307
|
+
* written with exactly `ids` / `byId` / `phase` / `projectionsBySession`. The
|
|
308
|
+
* official signal is the row's `retainedBy.mainView` retention count, the same
|
|
309
|
+
* one ui-layout's DocumentTitle, ui-cordis, ui-open-in-app and ui-session read.
|
|
310
|
+
* Reading `list.current` here made `noteSeen()` a no-op, so the seen watermark
|
|
311
|
+
* never advanced and an unread dot could never be cleared by opening the
|
|
312
|
+
* session. Runtimes that do expose `current` are still honoured first.
|
|
313
|
+
* @param list - the session list snapshot (may be absent).
|
|
314
|
+
* @returns the id being viewed, or undefined.
|
|
315
|
+
*/
|
|
316
|
+
function currentSessionId(list) {
|
|
317
|
+
if (list === null || typeof list !== "object") return undefined;
|
|
318
|
+
if (typeof list.current === "string" && list.current !== "") return list.current;
|
|
319
|
+
const byId = list.byId;
|
|
320
|
+
if (byId === null || typeof byId !== "object") return undefined;
|
|
321
|
+
for (const session of Object.values(byId)) {
|
|
322
|
+
if (session === null || typeof session !== "object") continue;
|
|
323
|
+
const retainedBy = session.retainedBy;
|
|
324
|
+
const count = retainedBy !== null && typeof retainedBy === "object" ? retainedBy.mainView : undefined;
|
|
325
|
+
if (typeof count === "number" && count > 0) return session.id;
|
|
326
|
+
}
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
303
329
|
/**
|
|
304
330
|
* How many sessions deserve the badge right now: endings whose reason is
|
|
305
331
|
* enabled and that this browser has not seen since, plus sessions waiting
|
|
@@ -318,7 +344,7 @@ window.__ModuleLoader__.load({
|
|
|
318
344
|
/** { id, title, waiting, kind, at } — newest first, "in wait" ahead of "ended". */
|
|
319
345
|
const hits = [];
|
|
320
346
|
const ids = new Set();
|
|
321
|
-
const current = list
|
|
347
|
+
const current = currentSessionId(list);
|
|
322
348
|
const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
|
|
323
349
|
const titleOf = (id, entry) => (entry !== null && typeof entry === "object" && typeof entry.displayTitle === "string" && entry.displayTitle !== "" ? entry.displayTitle : id);
|
|
324
350
|
// Which workspace the session lives in. The sidebar's own workspace rows are
|
|
@@ -456,16 +482,66 @@ window.__ModuleLoader__.load({
|
|
|
456
482
|
if (iconState.active !== true) return "image/svg+xml";
|
|
457
483
|
return iconState.png === true ? "image/png" : (iconState.mime || "image/svg+xml");
|
|
458
484
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
485
|
+
/**
|
|
486
|
+
* Every `<link rel="icon">` the document carries, creating one if the page
|
|
487
|
+
* has none.
|
|
488
|
+
*
|
|
489
|
+
* DSH 0.2.x ships TWO of them — a dark and a light one, each behind a
|
|
490
|
+
* `media` query. Touching only `querySelector`'s first match left the light
|
|
491
|
+
* one pointing at the shipped favicon, and the browser honours the LAST
|
|
492
|
+
* suitable candidate, so a custom icon silently failed to apply in light
|
|
493
|
+
* mode (the tab kept the platform whale and the feature looked dead).
|
|
494
|
+
* Every write below therefore goes to all of them.
|
|
495
|
+
*/
|
|
496
|
+
function iconLinks() {
|
|
497
|
+
const links = Array.from(document.querySelectorAll('link[rel="icon"]'));
|
|
498
|
+
if (links.length > 0) return links;
|
|
499
|
+
const link = document.createElement("link");
|
|
500
|
+
link.rel = "icon";
|
|
501
|
+
link.type = "image/svg+xml";
|
|
502
|
+
link.href = "/favicon.svg";
|
|
503
|
+
document.head.appendChild(link);
|
|
504
|
+
return [link];
|
|
505
|
+
}
|
|
506
|
+
/** Authored href/type, remembered before the first write so a reset can put the shipped pair back. */
|
|
507
|
+
const ICON_ORIGINALS = new WeakMap();
|
|
508
|
+
function rememberIcon(link) {
|
|
509
|
+
if (ICON_ORIGINALS.has(link)) return;
|
|
510
|
+
ICON_ORIGINALS.set(link, { href: link.getAttribute("href"), type: link.getAttribute("type") });
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Point every icon link at one image, so no scheme-specific sibling is left stale.
|
|
514
|
+
* Writes only on change: the head MutationObserver feeds `reconcileFavicon`,
|
|
515
|
+
* so a redundant write would re-arm the watchdog on every tick.
|
|
516
|
+
*/
|
|
517
|
+
function setIconEverywhere(href, type) {
|
|
518
|
+
for (const link of iconLinks()) {
|
|
519
|
+
rememberIcon(link);
|
|
520
|
+
if (link.getAttribute("href") !== href) link.setAttribute("href", href);
|
|
521
|
+
if (typeof type === "string" && type !== "") {
|
|
522
|
+
if (link.getAttribute("type") !== type) link.setAttribute("type", type);
|
|
523
|
+
} else if (link.getAttribute("type") !== null) link.removeAttribute("type");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Put the authored hrefs back; a link this plugin created falls back to the
|
|
528
|
+
* platform default. Idempotent for the same watchdog reason as above, and a
|
|
529
|
+
* no-op for any link the plugin never touched — which is what keeps the
|
|
530
|
+
* documented "zero side effects while no custom icon is set" true.
|
|
531
|
+
*/
|
|
532
|
+
function restoreShippedIcons() {
|
|
533
|
+
for (const link of iconLinks()) {
|
|
534
|
+
const original = ICON_ORIGINALS.get(link);
|
|
535
|
+
if (original === undefined) continue; // never touched → leave the shipped tag alone
|
|
536
|
+
const href = original.href === null ? "/favicon.svg" : original.href;
|
|
537
|
+
if (link.getAttribute("href") !== href) link.setAttribute("href", href);
|
|
538
|
+
if (original.type === null) {
|
|
539
|
+
if (link.getAttribute("type") !== null) link.removeAttribute("type");
|
|
540
|
+
} else if (link.getAttribute("type") !== original.type) link.setAttribute("type", original.type);
|
|
467
541
|
}
|
|
468
|
-
|
|
542
|
+
}
|
|
543
|
+
function iconLink() {
|
|
544
|
+
return iconLinks()[0];
|
|
469
545
|
}
|
|
470
546
|
function roundRect(c2d, x, y, w, h) {
|
|
471
547
|
const r = Math.min(h / 2, w / 2);
|
|
@@ -509,15 +585,14 @@ window.__ModuleLoader__.load({
|
|
|
509
585
|
});
|
|
510
586
|
}
|
|
511
587
|
async function applyBadgeToFavicon(count, size) {
|
|
512
|
-
const link = iconLink();
|
|
513
588
|
const base = desiredIconHref();
|
|
514
589
|
if (count <= 0) {
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
590
|
+
// No badge to draw. With a custom icon active the page must carry it on
|
|
591
|
+
// EVERY link (a stale scheme-specific sibling outranks the one we set);
|
|
592
|
+
// with none, the shipped dark/light pair goes back untouched — the
|
|
593
|
+
// plugin promises zero side effects while it has no icon of its own.
|
|
594
|
+
if (iconState.active === true) setIconEverywhere(base, desiredIconType());
|
|
595
|
+
else restoreShippedIcons();
|
|
521
596
|
faviconBase = null;
|
|
522
597
|
return;
|
|
523
598
|
}
|
|
@@ -540,8 +615,7 @@ window.__ModuleLoader__.load({
|
|
|
540
615
|
const dh = ih * scale;
|
|
541
616
|
c2d.drawImage(img, (FAVICON_SIZE - dw) / 2, (FAVICON_SIZE - dh) / 2, dw, dh);
|
|
542
617
|
drawFaviconBadge(c2d, FAVICON_SIZE, count, badgeScale(size));
|
|
543
|
-
|
|
544
|
-
link.setAttribute("type", "image/png");
|
|
618
|
+
setIconEverywhere(canvas.toDataURL("image/png"), "image/png");
|
|
545
619
|
} catch (error) {
|
|
546
620
|
// Never silent: a failure here is invisible in the UI (the tab simply
|
|
547
621
|
// keeps its previous icon), which once cost a whole debugging session.
|
|
@@ -562,13 +636,16 @@ window.__ModuleLoader__.load({
|
|
|
562
636
|
* composite was replaced and has to be rebuilt from the new base.
|
|
563
637
|
*/
|
|
564
638
|
function reconcileFavicon() {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const
|
|
639
|
+
// Inspect EVERY icon link: 0.2.x ships two (dark/light), and a sibling
|
|
640
|
+
// left stale by anything outside the plugin must be repaired too — that
|
|
641
|
+
// stale sibling is exactly what used to outrank the icon we set.
|
|
642
|
+
const hrefs = iconLinks().map((candidate) => candidate.getAttribute("href"));
|
|
643
|
+
const ours = hrefs.length > 0 && hrefs.every((value) => typeof value === "string" && value.slice(0, 5) === "data:");
|
|
569
644
|
if (effectiveCount() <= 0) {
|
|
570
|
-
//
|
|
571
|
-
|
|
645
|
+
// Carries the custom icon, or puts the shipped pair back. Both writers
|
|
646
|
+
// only touch a link that actually differs, so this stays a no-op once
|
|
647
|
+
// the page is correct and cannot re-arm the head observer.
|
|
648
|
+
applyBadgeToFavicon(0);
|
|
572
649
|
return;
|
|
573
650
|
}
|
|
574
651
|
if (ours || faviconComposing) return; // already showing, or a build is running
|
|
@@ -875,11 +952,28 @@ window.__ModuleLoader__.load({
|
|
|
875
952
|
* count simply drops back to 0.
|
|
876
953
|
*/
|
|
877
954
|
function BadgeSource(props) {
|
|
878
|
-
//
|
|
879
|
-
// (which carries every session's `lastTurnEnd` projection value)
|
|
880
|
-
//
|
|
955
|
+
// The hooks below are official root-scope standard props: the session list
|
|
956
|
+
// (which carries every session's `lastTurnEnd` projection value), the
|
|
957
|
+
// session-status map the sidebar row marker already uses, and the workspace
|
|
958
|
+
// snapshot that names each row.
|
|
881
959
|
const list = typeof props.useSessions === "function" ? props.useSessions((state) => state) : undefined;
|
|
882
|
-
|
|
960
|
+
// 0.2.x has no `useSessionPendingInteraction` — the string occurs nowhere in
|
|
961
|
+
// the runtime, so this half used to contribute nothing at all. The official
|
|
962
|
+
// replacement is the session-status map, whose rows carry `pendingInteraction`
|
|
963
|
+
// (alongside `completionUnread`, which the shipped sidebar reads too). Reshape
|
|
964
|
+
// it into the `id → { kind }` map `collectUnread` expects.
|
|
965
|
+
const status = typeof props.useSessionStatus === "function" ? props.useSessionStatus((map) => map) : undefined;
|
|
966
|
+
const pending = React.useMemo(() => {
|
|
967
|
+
const out = new Map();
|
|
968
|
+
if (status === null || status === undefined || typeof status.forEach !== "function") return out;
|
|
969
|
+
status.forEach((value, id) => {
|
|
970
|
+
const interaction = value !== null && typeof value === "object" ? value.pendingInteraction : undefined;
|
|
971
|
+
if (interaction === null || interaction === undefined) return;
|
|
972
|
+
const kind = typeof interaction === "object" && typeof interaction.kind === "string" ? interaction.kind : "";
|
|
973
|
+
out.set(id, { kind });
|
|
974
|
+
});
|
|
975
|
+
return out;
|
|
976
|
+
}, [status]);
|
|
883
977
|
const workspaces = typeof props.useWorkspaces === "function" ? props.useWorkspaces((state) => state) : undefined;
|
|
884
978
|
const [config, setConfig] = React.useState(unreadConfig);
|
|
885
979
|
React.useEffect(() => subscribeUnreadConfig(setConfig), []);
|
|
@@ -891,7 +985,7 @@ window.__ModuleLoader__.load({
|
|
|
891
985
|
saveSeenState();
|
|
892
986
|
}, [list]);
|
|
893
987
|
// Looking at a session marks it seen; the count also excludes it outright.
|
|
894
|
-
const currentId = list
|
|
988
|
+
const currentId = currentSessionId(list);
|
|
895
989
|
React.useEffect(() => {
|
|
896
990
|
if (noteSeen(currentId)) saveSeenState();
|
|
897
991
|
}, [currentId]);
|
|
@@ -1126,30 +1220,18 @@ window.__ModuleLoader__.load({
|
|
|
1126
1220
|
// visible in this very tab; other tabs pick it up on their next render.
|
|
1127
1221
|
const applyLive = React.useCallback((s) => {
|
|
1128
1222
|
if (!s || s.active !== true) return;
|
|
1129
|
-
const link = document.querySelector('link[rel="icon"]');
|
|
1130
1223
|
const href = s.png === true ? `/icon-custom-192.png?v=${s.rev}` : `/icon-custom.svg?v=${s.rev}`;
|
|
1131
1224
|
const type = s.png === true ? "image/png" : (s.mime || "image/svg+xml");
|
|
1132
|
-
|
|
1133
|
-
link.setAttribute("href", href);
|
|
1134
|
-
link.setAttribute("type", type);
|
|
1135
|
-
} else {
|
|
1136
|
-
const el = document.createElement("link");
|
|
1137
|
-
el.rel = "icon";
|
|
1138
|
-
el.type = type;
|
|
1139
|
-
el.href = href;
|
|
1140
|
-
document.head.appendChild(el);
|
|
1141
|
-
}
|
|
1225
|
+
setIconEverywhere(href, type);
|
|
1142
1226
|
// The base icon just changed: rebuild the composite from it.
|
|
1143
1227
|
refreshFaviconBase();
|
|
1144
1228
|
}, []);
|
|
1145
1229
|
|
|
1146
|
-
// When cleared,
|
|
1230
|
+
// When cleared, put the shipped dark/light pair back. Restoring the
|
|
1231
|
+
// authored hrefs (rather than forcing one href on every link) keeps the
|
|
1232
|
+
// platform's scheme-aware icons working after a reset.
|
|
1147
1233
|
const applyDefault = React.useCallback(() => {
|
|
1148
|
-
|
|
1149
|
-
if (link) {
|
|
1150
|
-
link.setAttribute("href", "/favicon.svg");
|
|
1151
|
-
link.setAttribute("type", "image/svg+xml");
|
|
1152
|
-
}
|
|
1234
|
+
restoreShippedIcons();
|
|
1153
1235
|
refreshFaviconBase();
|
|
1154
1236
|
}, []);
|
|
1155
1237
|
|
|
@@ -1447,7 +1529,10 @@ window.__ModuleLoader__.load({
|
|
|
1447
1529
|
}
|
|
1448
1530
|
//#endregion
|
|
1449
1531
|
|
|
1450
|
-
|
|
1532
|
+
// `sessions` is gone from this list: its only use was the removed
|
|
1533
|
+
// `ctx.sessions.open(id)`. Navigation now goes through an optional
|
|
1534
|
+
// `ctx.get("uiWorkspace")` lookup instead of a hard dependency.
|
|
1535
|
+
const inject = ["slots", "locale", "connection"];
|
|
1451
1536
|
function apply(ctx) {
|
|
1452
1537
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-icon-custom: dictionaries");
|
|
1453
1538
|
const t = ctx.locale.bind(NS);
|
|
@@ -1495,8 +1580,15 @@ window.__ModuleLoader__.load({
|
|
|
1495
1580
|
// Additive seats for "where exactly?" — a foot entry beside the shipped
|
|
1496
1581
|
// Settings/Cordis buttons, and the frame-wide overlay for its list. A fresh
|
|
1497
1582
|
// id is added beside the others; nothing shipped is shadowed.
|
|
1583
|
+
//
|
|
1584
|
+
// 0.2.x has no `ctx.sessions.open(id)` (the ClientSessions service exposes
|
|
1585
|
+
// retain/using/retainInfo/refreshProjections/search/fork/scope/binding — no
|
|
1586
|
+
// navigation). Official code jumps to a session with
|
|
1587
|
+
// `ctx.uiWorkspace.openSession(id)` (ui-chat does exactly this), so the entry
|
|
1588
|
+
// is looked up optionally: navigation is best effort and must never make the
|
|
1589
|
+
// favicon half depend on the workspace UI being mounted.
|
|
1498
1590
|
const openSession = (id) => {
|
|
1499
|
-
try { ctx.
|
|
1591
|
+
try { ctx.get("uiWorkspace")?.openSession(id); } catch { /* navigation is best effort */ }
|
|
1500
1592
|
};
|
|
1501
1593
|
ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register(
|
|
1502
1594
|
{ name: "sidebar.footer.action", id: "icon-custom-unread", order: 20, label: () => t("unreadPanelShort") },
|
package/lib/index.js
CHANGED
|
@@ -302,6 +302,20 @@ async function deriveSizes(source) {
|
|
|
302
302
|
return out
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Insert markup immediately after the document's opening `<head …>` tag.
|
|
307
|
+
*
|
|
308
|
+
* Tolerates attributes on `<head>` (a bare `/<head>/` test silently missed those,
|
|
309
|
+
* which would drop the icon tag entirely). Returns the input unchanged when the
|
|
310
|
+
* document has no head to anchor to.
|
|
311
|
+
* @param html - full document source.
|
|
312
|
+
* @param markup - tag(s) to inject.
|
|
313
|
+
* @returns the rewritten document.
|
|
314
|
+
*/
|
|
315
|
+
function insertAfterHead(html, markup) {
|
|
316
|
+
return html.replace(/<head[^>]*>/i, (open) => `${open}${markup}`)
|
|
317
|
+
}
|
|
318
|
+
|
|
305
319
|
/**
|
|
306
320
|
* 图标替换服务:保存/读取用户上传的 favicon,并通过 webServer 提供
|
|
307
321
|
* `/icon-custom.svg` 路由与 index 头部改写。通过 typert
|
|
@@ -718,12 +732,16 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
718
732
|
: `/icon-custom.svg?v=${c.rev}`
|
|
719
733
|
const favType = c.pngBaseline !== null ? 'image/png' : c.mime
|
|
720
734
|
const tag = `<link rel="icon" type="${favType}" href="${favHref}" />`
|
|
721
|
-
|
|
722
|
-
|
|
735
|
+
// DSH 0.2.x ships TWO `<link rel="icon">` tags — a dark and a light one, each
|
|
736
|
+
// behind a `media` query. Replacing only the first left the light one still
|
|
737
|
+
// serving the platform favicon, and the browser honours the LAST suitable
|
|
738
|
+
// candidate, so a custom icon silently failed to apply in light mode (the tab
|
|
739
|
+
// kept the whale) while looking fine in dark mode. Drop every icon link and
|
|
740
|
+
// insert one scheme-agnostic tag, which wins under either `prefers-color-scheme`.
|
|
741
|
+
out = insertAfterHead(out.replace(/<link\s+rel="icon"[^>]*\/?>/gi, ''), tag)
|
|
723
742
|
if (c.pwa === true && c.pngBaseline !== null) {
|
|
724
743
|
const tag2 = `<link rel="apple-touch-icon" href="/icon-custom-apple-180.png?v=${c.rev}" />`
|
|
725
|
-
|
|
726
|
-
out = re2.test(out) ? out.replace(re2, tag2) : out.replace(/<head>/i, `<head>${tag2}`)
|
|
744
|
+
out = insertAfterHead(out.replace(/<link\s+rel="apple-touch-icon"[^>]*\/?>/gi, ''), tag2)
|
|
727
745
|
}
|
|
728
746
|
return out
|
|
729
747
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cowwo/dsh-icon-custom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists, auto-sizes PWA icons for Android/iOS/tablets.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|