@cowwo/dsh-icon-custom 0.8.2 → 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 CHANGED
@@ -482,16 +482,66 @@ window.__ModuleLoader__.load({
482
482
  if (iconState.active !== true) return "image/svg+xml";
483
483
  return iconState.png === true ? "image/png" : (iconState.mime || "image/svg+xml");
484
484
  }
485
- function iconLink() {
486
- let link = document.querySelector('link[rel="icon"]');
487
- if (!link) {
488
- link = document.createElement("link");
489
- link.rel = "icon";
490
- link.type = "image/svg+xml";
491
- link.href = "/favicon.svg";
492
- document.head.appendChild(link);
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");
493
524
  }
494
- return link;
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);
541
+ }
542
+ }
543
+ function iconLink() {
544
+ return iconLinks()[0];
495
545
  }
496
546
  function roundRect(c2d, x, y, w, h) {
497
547
  const r = Math.min(h / 2, w / 2);
@@ -535,15 +585,14 @@ window.__ModuleLoader__.load({
535
585
  });
536
586
  }
537
587
  async function applyBadgeToFavicon(count, size) {
538
- const link = iconLink();
539
588
  const base = desiredIconHref();
540
589
  if (count <= 0) {
541
- // Nothing to show: make sure the page carries the plugin's own icon
542
- // (this also repairs a stale/proxied link that still points elsewhere).
543
- if (link.getAttribute("href") !== base) {
544
- link.setAttribute("href", base);
545
- link.setAttribute("type", desiredIconType());
546
- }
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();
547
596
  faviconBase = null;
548
597
  return;
549
598
  }
@@ -566,8 +615,7 @@ window.__ModuleLoader__.load({
566
615
  const dh = ih * scale;
567
616
  c2d.drawImage(img, (FAVICON_SIZE - dw) / 2, (FAVICON_SIZE - dh) / 2, dw, dh);
568
617
  drawFaviconBadge(c2d, FAVICON_SIZE, count, badgeScale(size));
569
- link.setAttribute("href", canvas.toDataURL("image/png"));
570
- link.setAttribute("type", "image/png");
618
+ setIconEverywhere(canvas.toDataURL("image/png"), "image/png");
571
619
  } catch (error) {
572
620
  // Never silent: a failure here is invisible in the UI (the tab simply
573
621
  // keeps its previous icon), which once cost a whole debugging session.
@@ -588,13 +636,16 @@ window.__ModuleLoader__.load({
588
636
  * composite was replaced and has to be rebuilt from the new base.
589
637
  */
590
638
  function reconcileFavicon() {
591
- const link = document.querySelector('link[rel="icon"]');
592
- const href = link === null ? null : link.getAttribute("href");
593
- const ours = typeof href === "string" && href.slice(0, 5) === "data:";
594
- const wanted = desiredIconHref();
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:");
595
644
  if (effectiveCount() <= 0) {
596
- // No badge to draw, but the page must still carry the plugin's icon.
597
- if (ours || href !== wanted) applyBadgeToFavicon(0);
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);
598
649
  return;
599
650
  }
600
651
  if (ours || faviconComposing) return; // already showing, or a build is running
@@ -1169,30 +1220,18 @@ window.__ModuleLoader__.load({
1169
1220
  // visible in this very tab; other tabs pick it up on their next render.
1170
1221
  const applyLive = React.useCallback((s) => {
1171
1222
  if (!s || s.active !== true) return;
1172
- const link = document.querySelector('link[rel="icon"]');
1173
1223
  const href = s.png === true ? `/icon-custom-192.png?v=${s.rev}` : `/icon-custom.svg?v=${s.rev}`;
1174
1224
  const type = s.png === true ? "image/png" : (s.mime || "image/svg+xml");
1175
- if (link) {
1176
- link.setAttribute("href", href);
1177
- link.setAttribute("type", type);
1178
- } else {
1179
- const el = document.createElement("link");
1180
- el.rel = "icon";
1181
- el.type = type;
1182
- el.href = href;
1183
- document.head.appendChild(el);
1184
- }
1225
+ setIconEverywhere(href, type);
1185
1226
  // The base icon just changed: rebuild the composite from it.
1186
1227
  refreshFaviconBase();
1187
1228
  }, []);
1188
1229
 
1189
- // When cleared, point the icon back at the platform default.
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.
1190
1233
  const applyDefault = React.useCallback(() => {
1191
- const link = document.querySelector('link[rel="icon"]');
1192
- if (link) {
1193
- link.setAttribute("href", "/favicon.svg");
1194
- link.setAttribute("type", "image/svg+xml");
1195
- }
1234
+ restoreShippedIcons();
1196
1235
  refreshFaviconBase();
1197
1236
  }, []);
1198
1237
 
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
- const re = /<link\s+rel="icon"[^>]*\/?>/i
722
- out = re.test(out) ? out.replace(re, tag) : out.replace(/<head>/i, `<head>${tag}`)
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
- const re2 = /<link\s+rel="apple-touch-icon"[^>]*\/?>/i
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.8.2",
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",