@cowwo/dsh-icon-custom 0.6.0 → 0.7.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.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # dsh-icon-custom
2
2
 
3
- Customize the browser tab icon (favicon) for DSH: upload an SVG / PNG / ICO from the settings page, it applies instantly and persists across reloads and restarts.
3
+ Customize the browser tab icon (favicon) for DSH: upload an SVG / PNG / ICO / JPEG from the settings page, it applies instantly and persists across reloads and restarts. From 0.7.0 the PWA / logo path derives the canonical icon sizes automatically so the same upload works on Android, iOS and tablets.
4
4
 
5
- Zero third‑party runtime dependencies.
5
+ Pure-JS dependency (`jimp`) — no native binaries, so it installs cleanly under pnpm's supply-chain policy.
6
6
 
7
7
  ## Install
8
8
 
@@ -21,7 +21,11 @@ Scoped name: `@cowwo/dsh-icon-custom`.
21
21
 
22
22
  ## PWA install icon
23
23
 
24
- Tick **同时替换 PWA 安装图标** (checked state is remembered per icon) to also replace the icon of the installed PWA — the icon used on the desktop / home screen after the site is installed as an app. The plugin rewrites the `/manifest.webmanifest` icons entry to point at your icon (SVG is advertised as `sizes: "any"`, PNG reports its real dimensions, ICO its first entry). For iPhones/iPads a `<link rel="apple-touch-icon">` is pointed at the custom icon as well, which only works with a PNG (iOS ignores SVG touch icons).
24
+ Tick **同时替换 PWA 安装图标** (checked state is remembered per icon) to also replace the icon of the installed PWA — the icon used on the desktop / home screen after the site is installed as an app. The plugin rewrites the `/manifest.webmanifest` icons entry to a canonical derived PNG set: **192×192 `any`**, **512×512 `any`**, and a **512×512 `maskable`** variant (opaque background) so Android launchers crop cleanly. For iPhones/iPads a `<link rel="apple-touch-icon">` points at a derived **180×180 opaque PNG** (iOS ignores SVG touch-icons and transparent images).
25
+
26
+ SVG is rasterized to a 512×512 PNG in the browser before upload, and ICO has its embedded PNG extracted server-side, so any format produces a usable cross-platform icon. A derivation failure falls back to serving the original upload. A well-prepared **512×512 PNG** gives the best results.
27
+
28
+ The option is **off by default**: without it, behavior is identical to before and the platform manifest is served verbatim. Browsers re-check the manifest on their own schedule, so an **already-installed** PWA usually keeps its old icon until you re-install it (or the browser picks up the new `src` on its next manifest fetch).
25
29
 
26
30
  ![PWA install icon option](./docs/pwa-option.png)
27
31
 
@@ -39,20 +43,14 @@ Notes:
39
43
  - It can be toggled at any time after upload — no re-upload needed.
40
44
  - This only affects the sidebar brand mark (the one next to `deepseek HARNESS`); PWA/installed-app icons are a separate option above.
41
45
 
42
- Notes:
43
-
44
- - The option is **off by default**: without it, behavior is identical to before and the platform manifest is served verbatim.
45
- - The option can be toggled at any time after upload — no need to re-upload the file.
46
- - Browsers re-check the manifest on their own schedule. An **already-installed** PWA usually keeps its old icon until you re-install it (or the browser's next manifest update picks up the new `src`).
47
- - PNG is the recommended format for the PWA icon (UI / launcher icons are rasterized); the same uploaded file is used, never re-encoded.
48
-
49
46
  ## Format support
50
47
 
51
- - **SVG** — recognized by content (`<svg`), validated against script / event‑handler / `javascript:` injection.
48
+ - **SVG** — recognized by content (`<svg`), validated against script / event‑handler / `javascript:` injection. Rasterized to PNG in the browser before upload.
52
49
  - **PNG** — recognized by magic bytes (tolerates a stray leading byte some optimizers emit), structurally checked to IHDR → … → IEND.
53
- - **ICO / CUR** — CUR is accepted as ICO.
50
+ - **ICO / CUR** — CUR is accepted as ICO. Its embedded PNG is extracted server-side; a BMP-only ICO is not rasterizable and falls back to the original.
51
+ - **JPEG** — accepted as-is and treated as a raster baseline.
54
52
 
55
- Files are stored verbatim (never re‑encoded), so transparency and quality are preserved.
53
+ The uploaded file is stored verbatim (never re‑encoded) so the favicon keeps its transparency and quality; the derived PWA / logo PNG set is generated separately from it.
56
54
 
57
55
  ## Persistence
58
56
 
package/README.zh.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # dsh-icon-custom
2
2
 
3
- 自定义 DSH 浏览器标签页图标(favicon):在设置页上传 SVG / PNG / ICO,立即生效并持久保存,刷新或重启后都保留。
3
+ 自定义 DSH 浏览器标签页图标(favicon):在设置页上传 SVG / PNG / ICO / JPEG,立即生效并持久保存,刷新或重启后都保留。从 0.7.0 起,PWA / Logo 路径会自动派生各平台的标准图标尺寸,同一张上传图即可适配安卓、iOS 和平板。
4
4
 
5
- 零第三方运行时依赖。
5
+ 纯 JS 依赖(`jimp`),无原生二进制,在 pnpm 供应链政策下也能干净安装。
6
6
 
7
7
  ## 安装
8
8
 
@@ -21,7 +21,11 @@ dsh plugin --profile web add dsh-icon-custom
21
21
 
22
22
  ## PWA 安装图标
23
23
 
24
- 勾选**同时替换 PWA 安装图标**(勾选状态按图标记录保存),网页「安装为应用 / 添加到主屏幕」后,桌面和启动器上的图标也会换成你上传的这张。插件会把 `/manifest.webmanifest` 的 icons 项改写为你的图标(SVG 标注 `sizes: "any"`,PNG 按真实尺寸标注,ICO 取第一条目尺寸);在 iPhone/iPad 上还会把 `<link rel="apple-touch-icon">` 指向你的图标——但这只在你的图标是 **PNG** 时有效(iOS 不支持 SVG 触屏图标)。
24
+ 勾选**同时替换 PWA 安装图标**(勾选状态按图标记录保存),网页「安装为应用 / 添加到主屏幕」后,桌面和启动器上的图标也会换成你上传的这张。插件会把 `/manifest.webmanifest` 的 icons 项改写为自动派生的标准 PNG 集合:**192×192 `any`**、**512×512 `any`**,以及一个 **512×512 `maskable`** 变体(不透明背景),让安卓启动器能干净裁剪;在 iPhone/iPad 上还会把 `<link rel="apple-touch-icon">` 指向派生的 **180×180 不透明 PNG**(iOS 不支持 SVG 触屏图标,也不接受透明图)。
25
+
26
+ SVG 会在浏览器里先栅格化成 512×512 PNG 再上传,ICO 则由后端提取内嵌 PNG,所以任何格式都能生成可用的跨平台图标。若派生失败则回退为原样透传上传图。**推荐上传规范的 512×512 PNG**,效果最佳。
27
+
28
+ 该选项**默认不勾选**:不勾选时行为与之前完全一致,平台 manifest 原样透传。浏览器按自己的节奏重新检查 manifest,**已经安装**的应用通常要等重新安装(或浏览器下一次 manifest 更新读到新的 `src`)才会换图标。
25
29
 
26
30
  ![同时替换 PWA 安装图标选项](./docs/pwa-option.png)
27
31
 
@@ -39,20 +43,14 @@ dsh plugin --profile web add dsh-icon-custom
39
43
  - 上传后随时可以勾选/取消,不用重新上传文件。
40
44
  - 只影响侧边栏的品牌图标(deepseek HARNESS 旁边那只);PWA 安装图标是上面的另一个选项。
41
45
 
42
- 说明:
43
-
44
- - 该选项**默认不勾选**:不勾选时行为与之前完全一致,平台 manifest 原样透传。
45
- - 上传后随时可以勾选/取消,不用重新上传文件。
46
- - 浏览器按自己的节奏重新检查 manifest;**已经安装**的应用通常要等重新安装(或浏览器下一次 manifest 更新读到新的 `src`)才会换图标。
47
- - PWA 图标推荐上传 **PNG**(桌面 / 启动器图标通常会被栅格化);复用同一张上传图,不重新编码。
48
-
49
46
  ## 支持的格式
50
47
 
51
- - **SVG** — 按内容(`<svg`)识别,并拦截 `<script>` / 事件属性 / `javascript:` 注入。
48
+ - **SVG** — 按内容(`<svg`)识别,并拦截 `<script>` / 事件属性 / `javascript:` 注入。上传前会在浏览器里栅格化成 PNG。
52
49
  - **PNG** — 按魔数识别(容忍部分优化器产生的前导字节),并校验 IHDR → … → IEND 结构。
53
- - **ICO / CUR** — CUR 当作 ICO 接受。
50
+ - **ICO / CUR** — CUR 当作 ICO 接受。后端会提取内嵌的 PNG;仅含 BMP 帧的 ICO 无法栅格化,会回退为原样透传。
51
+ - **JPEG** — 直接接受,当作位图基准。
54
52
 
55
- 图标文件**原样保存,不重新编码**,因此透明背景和画质都不会损失。
53
+ 上传的原始文件**原样保存,不重新编码**,favicon 的透明背景和画质都不损失;派生的 PWA / Logo PNG 集合是从它单独生成的。
56
54
 
57
55
  ## 持久化
58
56
 
package/client/client.js CHANGED
@@ -14,7 +14,8 @@ window.__ModuleLoader__.load({
14
14
  current: "当前图标",
15
15
  none: "使用平台默认图标",
16
16
  upload: "上传图标",
17
- uploadHint: "支持 SVG、PNG、ICO,最大 10 MB。上传后立即生效,刷新页面也会保留。",
17
+ uploadHint: "支持 SVG、PNG、ICO,最大 10 MB。SVG 会自动转成 PNG,自动适配安卓/iOS/平板的主屏图标。上传后立即生效,刷新页面也会保留。",
18
+ svgFail: "该 SVG 无法转换,请改用 PNG。",
18
19
  reset: "恢复默认",
19
20
  resetHint: "清除自定义图标并回到平台默认图标。",
20
21
  replace: "替换",
@@ -39,7 +40,8 @@ window.__ModuleLoader__.load({
39
40
  current: "Current icon",
40
41
  none: "Using the platform default icon",
41
42
  upload: "Upload",
42
- uploadHint: "SVG, PNG or ICO, up to 10 MB. Applies instantly and persists across reloads.",
43
+ uploadHint: "SVG, PNG or ICO, up to 10 MB. SVG is auto-rasterized to PNG, and sizes are derived to fit Android/iOS/tablet home-screen icons. Applies instantly and persists across reloads.",
44
+ svgFail: "This SVG could not be rasterized. Use a PNG instead.",
43
45
  reset: "Reset",
44
46
  resetHint: "Clear the custom icon and use the platform default.",
45
47
  replace: "Replace",
@@ -78,6 +80,39 @@ window.__ModuleLoader__.load({
78
80
  return new Date(ms).toLocaleString();
79
81
  } catch { return "—"; }
80
82
  }
83
+ // Rasterize an SVG file to a 512x512 PNG data URL in the browser.
84
+ // We do this on the client because (a) browsers rasterize SVG reliably,
85
+ // and (b) the host's pure-JS Jimp cannot decode SVG. ICO is NOT handled
86
+ // here — browsers do not reliably draw .ico into a canvas, so the host
87
+ // extracts the embedded PNG instead. Returns a `data:image/png;base64,..`
88
+ // string, or throws when the SVG cannot be rasterized.
89
+ function svgToPng(file) {
90
+ return new Promise((resolve, reject) => {
91
+ const url = URL.createObjectURL(file);
92
+ const img = new Image();
93
+ img.onload = () => {
94
+ try {
95
+ const size = 512;
96
+ const canvas = document.createElement("canvas");
97
+ canvas.width = size;
98
+ canvas.height = size;
99
+ const ctx = canvas.getContext("2d");
100
+ ctx.fillStyle = "#fff"; // opaque backdrop for iOS tiles
101
+ ctx.fillRect(0, 0, size, size);
102
+ const scale = Math.min(size / img.width, size / img.height);
103
+ const w = img.width * scale, h = img.height * scale;
104
+ ctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h);
105
+ resolve(canvas.toDataURL("image/png"));
106
+ } catch (e) {
107
+ reject(e);
108
+ } finally {
109
+ URL.revokeObjectURL(url);
110
+ }
111
+ };
112
+ img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("svg-rasterize")); };
113
+ img.src = url;
114
+ });
115
+ }
81
116
  // In-package brand-mark bridge: the settings section pushes the current
82
117
  // logo state here; the sidebar brand-mark component subscribes to it.
83
118
  // Both live in this same module — no RPC loop, no cross-package events.
@@ -92,7 +127,8 @@ window.__ModuleLoader__.load({
92
127
  function syncLogo(status) {
93
128
  emitLogo({
94
129
  enabled: status !== null && typeof status === "object" && status.active === true && status.logo === true,
95
- rev: status && status.rev ? status.rev : null
130
+ rev: status && status.rev ? status.rev : null,
131
+ png: !!(status && typeof status === "object" && status.png === true)
96
132
  });
97
133
  }
98
134
  //#endregion
@@ -119,8 +155,11 @@ window.__ModuleLoader__.load({
119
155
  const size = (props && props.size) || 24;
120
156
  const custom = state && state.enabled === true && state.rev;
121
157
  const style = { width: size, height: size, objectFit: "contain", display: "block" };
158
+ const src = custom
159
+ ? (state.png === true ? `/icon-custom-192.png?v=${state.rev}` : `/icon-custom.svg?v=${state.rev}`)
160
+ : "/favicon.svg";
122
161
  return React.createElement("img", {
123
- src: custom ? `/icon-custom.svg?v=${state.rev}` : "/favicon.svg",
162
+ src,
124
163
  alt: "",
125
164
  style
126
165
  });
@@ -160,14 +199,15 @@ window.__ModuleLoader__.load({
160
199
  const applyLive = React.useCallback((s) => {
161
200
  if (!s || s.active !== true) return;
162
201
  const link = document.querySelector('link[rel="icon"]');
163
- const href = `/icon-custom.svg?v=${s.rev}`;
202
+ const href = s.png === true ? `/icon-custom-192.png?v=${s.rev}` : `/icon-custom.svg?v=${s.rev}`;
203
+ const type = s.png === true ? "image/png" : (s.mime || "image/svg+xml");
164
204
  if (link) {
165
205
  link.setAttribute("href", href);
166
- link.setAttribute("type", s.mime || "image/svg+xml");
206
+ link.setAttribute("type", type);
167
207
  } else {
168
208
  const el = document.createElement("link");
169
209
  el.rel = "icon";
170
- el.type = s.mime || "image/svg+xml";
210
+ el.type = type;
171
211
  el.href = href;
172
212
  document.head.appendChild(el);
173
213
  }
@@ -196,12 +236,27 @@ window.__ModuleLoader__.load({
196
236
  }
197
237
  const reader = new FileReader();
198
238
  reader.onload = async () => {
199
- const data = typeof reader.result === "string" ? reader.result : null;
239
+ let data = typeof reader.result === "string" ? reader.result : null;
200
240
  if (!data) { setError(t("fail")); return; }
241
+ // Rasterize SVG to PNG in the browser so the host always has
242
+ // a rasterizable baseline and iOS/apple-touch-icon works.
243
+ // ICO is left as-is; the host extracts its embedded PNG.
244
+ let mime = file.type || "image/svg+xml";
245
+ if (mime === "image/svg+xml" || /\.svg$/i.test(file.name)) {
246
+ setBusy(true);
247
+ try {
248
+ data = await svgToPng(file);
249
+ mime = "image/png";
250
+ } catch {
251
+ setBusy(false);
252
+ setError(t("svgFail"));
253
+ return;
254
+ }
255
+ }
201
256
  setBusy(true);
202
257
  try {
203
258
  const resp = await rpc.call("/api", "iconCustom/setIcon", {
204
- args: { request: { mime: file.type || "image/svg+xml", name: file.name, pwa: pwaEnabled, logo: logoEnabled, data } }
259
+ args: { request: { mime, name: file.name, pwa: pwaEnabled, logo: logoEnabled, data } }
205
260
  });
206
261
  if (resp && resp.ok === true) {
207
262
  setStatus(resp.value);
package/lib/index.js CHANGED
@@ -10,13 +10,18 @@
10
10
  * index + the `activeId` marker. "reset" clears only `activeId`; icon files are
11
11
  * kept for a future icon-library UI. This round writes exactly one slot.
12
12
  *
13
- * Zero third-party deps: Node's Buffer gives a binary-safe base64 decode (the
14
- * dynamic-prototype bug was a lossy host `atob`; this never goes through it) and
15
- * a structural PNG walk confirms the bytes actually decode as a PNG without
16
- * requiring an exact chunk CRC (optimizers rewrite those and browsers still
17
- * render the file).
13
+ * From 0.7.0 the PWA/logo path derives a PNG of several canonical sizes with
14
+ * Jimp (pure JS, no native binary): 192/512 for Android manifest entries (incl.
15
+ * a maskable 512) and a 180x180 opaque PNG for iOS/apple-touch-icon. SVG and
16
+ * ICO uploads are normalized to a PNG baseline first (SVG via the browser canvas
17
+ * on the client; ICO by extracting the embedded PNG image server-side — browsers
18
+ * do not reliably rasterize .ico). A failure to derive falls back to serving the
19
+ * original bytes so the plugin never hard-fails.
20
+ * `library/<id>.derived/*.png` holds the derived set; `library.json` records
21
+ * `derived: true/false` so we know whether to serve the canonical sizes.
18
22
  */
19
23
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
24
+ import { Jimp } from 'jimp'
20
25
  import { readFileSync } from 'node:fs'
21
26
  import { mkdir, rename, writeFile } from 'node:fs/promises'
22
27
  import { createRequire } from 'node:module'
@@ -28,8 +33,21 @@ const MAX_BYTES = 10 * 1024 * 1024 // 10 MB
28
33
  const MIME_EXT = {
29
34
  'image/svg+xml': 'svg',
30
35
  'image/png': 'png',
36
+ 'image/jpeg': 'jpg',
31
37
  'image/x-icon': 'ico'
32
38
  }
39
+ // Canonical derived sizes for the PWA/logo path. `file` is the stored basename
40
+ // (without ext) under `library/<id>.derived/`; `url` the public route suffix.
41
+ // `maskable` variants get an opaque background so Android launchers crop cleanly;
42
+ // `apple` is opaque-white so iOS shows a solid tile instead of a transparent hole.
43
+ const DERIVED_SIZES = [
44
+ { file: '192', url: '192', size: 192, maskable: false },
45
+ { file: '512', url: '512', size: 512, maskable: false },
46
+ { file: '512-maskable', url: '512-maskable', size: 512, maskable: true },
47
+ { file: 'apple-180', url: 'apple-180', size: 180, apple: true }
48
+ ]
49
+ // How far past the target a source may exceed before we downscale (not a file
50
+ // size cap; Jimp just resizes to the canonical square).
33
51
  // PNG magic is 89 50 4e 47 0d 0a 1a 0a; we match the tail 7 bytes so a stray
34
52
  // leading byte (some encoders emit 0xfd) still identifies a genuine PNG.
35
53
  const PNG_MAGIC = [0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
@@ -77,14 +95,27 @@ function indexOfPattern(buf, pattern, window) {
77
95
  /** Detect the real image type from content bytes, not the declared mime. */
78
96
  function detectMime(buf) {
79
97
  if (indexOfPattern(buf, PNG_MAGIC, 16) !== -1) return 'image/png'
80
- if (indexOfPattern(buf, ICO_MAGIC, 16) !== -1) return 'image/x-icon'
81
- if (indexOfPattern(buf, CUR_MAGIC, 16) !== -1) return 'image/x-icon'
98
+ // ICO/CUR magic must appear at the very start; a loose scan can false-positive
99
+ // on a JPEG header (e.g. JFIF bytes `00 00 01` at offset 13). Match at 0 only.
100
+ if (matchAt(buf, ICO_MAGIC, 0)) return 'image/x-icon'
101
+ if (matchAt(buf, CUR_MAGIC, 0)) return 'image/x-icon'
102
+ // JPEG starts with FF D8 FF (byte 0 is 0xFF, byte 1 is 0xD8).
103
+ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
82
104
  const text = buf.toString('utf8').replace(/^\uFEFF/, '').replace(/^\s+/, '')
83
105
  if (/^<svg[\s>]/i.test(text)) return 'image/svg+xml'
84
106
  if (/^<\?xml/i.test(text) && /<svg[\s>]/i.test(text)) return 'image/svg+xml'
85
107
  return null
86
108
  }
87
109
 
110
+ /** Match a byte pattern exactly at `offset`. */
111
+ function matchAt(buf, pattern, offset) {
112
+ if (offset + pattern.length > buf.length) return false
113
+ for (let j = 0; j < pattern.length; j++) {
114
+ if (buf[offset + j] !== pattern[j]) return false
115
+ }
116
+ return true
117
+ }
118
+
88
119
  /**
89
120
  * Walk the PNG chunk structure from the magic offset and require it to be a
90
121
  * well-formed sequence: starts with IHDR, traverses chunks by their declared
@@ -196,15 +227,72 @@ function iconSizes(mime, bytes) {
196
227
  const label = offset === -1 ? null : pngLabel(bytes, offset)
197
228
  if (label !== null) return label
198
229
  } else if (mime === 'image/x-icon') {
199
- const offset = indexOfPattern(bytes, ICO_MAGIC, 16)
200
- const cur = indexOfPattern(bytes, CUR_MAGIC, 16)
201
- const at = offset !== -1 ? offset : cur
230
+ const at = matchAt(bytes, ICO_MAGIC, 0) ? 0 : (matchAt(bytes, CUR_MAGIC, 0) ? 0 : -1)
202
231
  const size = at === -1 ? null : icoFirstSize(bytes, at)
203
232
  if (size !== null) return `${size.w}x${size.h}`
204
233
  }
205
234
  return 'any'
206
235
  }
207
236
 
237
+ /**
238
+ * Extract the embedded PNG image from an ICO/CUR container. ICO is a header
239
+ * (6 bytes) + a directory of 16-byte entries; each entry points at an image
240
+ * that is either a PNG or a BMP. We return the first PNG-encoded image. If the
241
+ * ICO only packs BMP frames (old-style) we cannot rasterize it without a BMP
242
+ * decoder, so we throw and let the caller fall back.
243
+ * (Browsers do not reliably rasterize .ico into a <canvas>, which is exactly
244
+ * why this extraction happens server-side rather than on the client.)
245
+ */
246
+ function extractIcoPng(buf) {
247
+ const at = matchAt(buf, ICO_MAGIC, 0) ? 0 : (matchAt(buf, CUR_MAGIC, 0) ? 0 : -1)
248
+ if (at === -1) throw new Error('无法识别 ICO 文件')
249
+ if (buf.length < at + 6) throw new Error('ICO 数据不完整')
250
+ const count = buf.readUInt16LE(at + 4)
251
+ if (count === 0) throw new Error('ICO 没有图像')
252
+ const dirStart = at + 6
253
+ for (let i = 0; i < count; i++) {
254
+ const entry = dirStart + i * 16
255
+ if (buf.length < entry + 16) break
256
+ const byteSize = buf.readUInt32LE(entry + 8)
257
+ const imgOffset = buf.readUInt32LE(entry + 12)
258
+ if (imgOffset + byteSize > buf.length || imgOffset < dirStart) continue
259
+ const img = buf.subarray(imgOffset, imgOffset + byteSize)
260
+ // A PNG image begins with the 8-byte signature (89 50 4e 47 ...).
261
+ if (img.length >= 8 && img[0] === 0x89 && img[1] === 0x50 && img[2] === 0x4e && img[3] === 0x47) {
262
+ return img
263
+ }
264
+ }
265
+ throw new Error('该 ICO 仅含 BMP 帧,无法自动转换,请改用 PNG')
266
+ }
267
+
268
+ /** Read an ICO/CUR PNG and re-encode to a canonical square PNG via Jimp. */
269
+ async function pngFromBytes(bytes) {
270
+ return Jimp.fromBuffer(bytes)
271
+ }
272
+
273
+ /**
274
+ * Derive the canonical PNG set for a source image. `source` is the working
275
+ * PNG bytes (already normalized: PNG uploads directly, SVG via the client
276
+ * canvas, ICO by {@link extractIcoPng}). Returns a map of `file` -> Buffer for
277
+ * each DERIVED_SIZES entry, or throws if the source cannot be decoded.
278
+ */
279
+ async function deriveSizes(source) {
280
+ const img = await Jimp.fromBuffer(source)
281
+ const out = {}
282
+ for (const spec of DERIVED_SIZES) {
283
+ const square = img.clone().contain({ w: spec.size, h: spec.size, alignX: 0.5, alignY: 0.5 })
284
+ if (spec.maskable === true || spec.apple === true) {
285
+ // Opaque white backdrop so launchers/iOS render a solid tile.
286
+ const canvas = new Jimp({ width: spec.size, height: spec.size, color: 0xffffffff })
287
+ canvas.composite(square, 0, 0)
288
+ out[spec.file] = await canvas.getBuffer('image/png')
289
+ } else {
290
+ out[spec.file] = await square.getBuffer('image/png')
291
+ }
292
+ }
293
+ return out
294
+ }
295
+
208
296
  /**
209
297
  * 图标替换服务:保存/读取用户上传的 favicon,并通过 webServer 提供
210
298
  * `/icon-custom.svg` 路由与 index 头部改写。通过 typert
@@ -238,6 +326,15 @@ export class FaviconCustomService extends TypertRemoteService {
238
326
  path: '/manifest.webmanifest',
239
327
  handler: (request, response) => this._handleManifest(request, response)
240
328
  }), 'dsh-icon-custom: /manifest.webmanifest route')
329
+ // Derived PNGs: /icon-custom-192.png, /icon-custom-512.png,
330
+ // /icon-custom-512-maskable.png, /icon-custom-apple-180.png.
331
+ for (const spec of DERIVED_SIZES) {
332
+ ctx.effect(() => ctx.webServer.register({
333
+ kind: 'exact',
334
+ path: `/icon-custom-${spec.url}.png`,
335
+ handler: (request, response) => this._handleDerivedIcon(request, response, spec.url)
336
+ }), `dsh-icon-custom: /icon-custom-${spec.url}.png route`)
337
+ }
241
338
  ctx.effect(() => ctx.webServer.tapIndex((html) => this._rewriteIndex(html)),
242
339
  'dsh-icon-custom: index tap')
243
340
  }
@@ -255,10 +352,32 @@ export class FaviconCustomService extends TypertRemoteService {
255
352
  const rec = icons[activeId]
256
353
  if (rec === null || typeof rec !== 'object') { this.library.activeId = null; return }
257
354
  const bytes = readFileSync(join(this.libraryDir, `${activeId}.${rec.ext}`))
258
- this.current = { ...rec, bytes }
355
+ // Re-derive lazily when the record predates the derived set (a
356
+ // pre-0.7.0 icon, or a failed derivation), so /icon-custom-*.png
357
+ // still work after an upgrade. A failure just keeps derived=false.
358
+ const pngBaseline = this._loadBaseline(rec, bytes)
359
+ this.current = { ...rec, bytes, pngBaseline }
259
360
  } catch { /* no persisted icon yet */ }
260
361
  }
261
362
 
363
+ /**
364
+ * Best-effort rasterized baseline for the active icon: prefer the PNG
365
+ * derived set; for a PNG/ICO source, derive from the stored bytes if the
366
+ * derived directory is missing. Always returns a PNG Buffer or null.
367
+ */
368
+ _loadBaseline(rec, bytes) {
369
+ const derivedDir = join(this.libraryDir, `${rec.id}.derived`)
370
+ try {
371
+ const p512 = readFileSync(join(derivedDir, '512.png'))
372
+ return p512
373
+ } catch { /* fall through to derive from original bytes */ }
374
+ try {
375
+ if (rec.mime === 'image/png' || rec.mime === 'image/jpeg') return bytes
376
+ if (rec.mime === 'image/x-icon') return extractIcoPng(bytes)
377
+ } catch { /* no rasterizable baseline */ }
378
+ return null
379
+ }
380
+
262
381
  /** Persist the library index to disk (does not touch icon files). */
263
382
  _saveLibrary() {
264
383
  return writeFile(this.indexPath, JSON.stringify(this.library, null, 2))
@@ -268,8 +387,8 @@ export class FaviconCustomService extends TypertRemoteService {
268
387
  status() {
269
388
  const c = this.current
270
389
  return c === null
271
- ? { active: false, pwa: false, logo: false, rev: null, mime: null, ext: null, name: null, size: null, savedAt: null }
272
- : { active: true, pwa: c.pwa === true, logo: c.logo === true, rev: c.rev, mime: c.mime, ext: c.ext, name: c.name, size: c.size, savedAt: c.savedAt }
390
+ ? { active: false, pwa: false, logo: false, png: false, rev: null, mime: null, ext: null, name: null, size: null, savedAt: null }
391
+ : { active: true, pwa: c.pwa === true, logo: c.logo === true, png: c.pngBaseline !== null, rev: c.rev, mime: c.mime, ext: c.ext, name: c.name, size: c.size, savedAt: c.savedAt }
273
392
  }
274
393
 
275
394
  /** typert endpoint: read current status. */
@@ -298,20 +417,63 @@ export class FaviconCustomService extends TypertRemoteService {
298
417
  const tmp = join(this.libraryDir, `.${id}.${ext}.tmp`)
299
418
  await writeFile(tmp, bytes)
300
419
  await rename(tmp, join(this.libraryDir, `${id}.${ext}`))
420
+ // Derive a PNG baseline for the PWA/logo path. SVG was already turned
421
+ // into a PNG by the client canvas before it reached setIcon (so a client
422
+ // SVG arrives as `image/png`); ICO is decoded here by extracting the
423
+ // embedded PNG. PNG/JPEG pass through as-is. A derivation failure is not
424
+ // fatal: we keep `derived: false` and serve the original bytes, so the
425
+ // favicon still works and only the multi-size PWA/logo set is skipped.
426
+ const derivedSizes = await this._deriveFor(id, mime, bytes)
301
427
  const rec = {
302
428
  id,
303
429
  mime, ext,
304
430
  pwa, logo,
431
+ derived: derivedSizes !== null,
305
432
  name: typeof name === 'string' && name.trim().length > 0 ? name.trim().slice(0, 128) : null,
306
433
  size: bytes.length, savedAt: now, rev
307
434
  }
308
435
  this.library.icons[id] = rec
309
436
  this.library.activeId = id
310
437
  await this._saveLibrary().catch(() => {}) // index is advisory; icon file is authoritative
311
- this.current = { ...rec, bytes }
438
+ this.current = { ...rec, bytes, pngBaseline: derivedSizes ? derivedSizes.baseline : null }
312
439
  return this.status()
313
440
  }
314
441
 
442
+ /**
443
+ * Derive the canonical PNG set from an uploaded icon's bytes. Returns
444
+ * `{ baseline, sizes }` where `baseline` is the working PNG Buffer (used for
445
+ * logo/favicon serving when the source is SVG/ICO) and `sizes` is the
446
+ * file->Buffer map for the DERIVED_SIZES set; or null when the source cannot
447
+ * be rasterized (e.g. BMP-only ICO), in which case we fall back untouched.
448
+ * The derived files are written under `library/<id>.derived/`.
449
+ */
450
+ async _deriveFor(id, mime, bytes) {
451
+ try {
452
+ let baseline = null
453
+ if (mime === 'image/png' || mime === 'image/jpeg') {
454
+ baseline = bytes
455
+ } else if (mime === 'image/x-icon') {
456
+ baseline = extractIcoPng(bytes)
457
+ }
458
+ // SVG handled on the client (canvas -> PNG), so a server-side SVG
459
+ // has no rasterizable baseline; fall back. Same for anything else.
460
+ if (baseline === null) return null
461
+ const sizes = await deriveSizes(baseline)
462
+ const dir = join(this.libraryDir, `${id}.derived`)
463
+ await mkdir(dir, { recursive: true })
464
+ for (const spec of DERIVED_SIZES) {
465
+ const buf = sizes[spec.file]
466
+ if (buf === undefined) continue
467
+ const tmp2 = join(dir, `.${spec.file}.png.tmp`)
468
+ await writeFile(tmp2, buf)
469
+ await rename(tmp2, join(dir, `${spec.file}.png`))
470
+ }
471
+ return { baseline, sizes }
472
+ } catch {
473
+ return null
474
+ }
475
+ }
476
+
315
477
  /** typert endpoint: toggle the PWA-icon flag on the active icon (no re-upload). */
316
478
  async setPwa(request) {
317
479
  if (request === null || typeof request !== 'object' || typeof request.enabled !== 'boolean') {
@@ -378,11 +540,67 @@ export class FaviconCustomService extends TypertRemoteService {
378
540
  response.end(c.bytes)
379
541
  }
380
542
 
543
+ /**
544
+ * Serve a derived PNG (`/icon-custom-<size>.png`, size 192/512/512-maskable/
545
+ * apple-180) for the PWA manifest and the iOS apple-touch-icon. Falls back
546
+ * to the rasterized baseline (or a 404) when no derived set exists yet.
547
+ */
548
+ async _handleDerivedIcon(request, response, sizeUrl) {
549
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
550
+ response.writeHead(405, { allow: 'GET, HEAD' })
551
+ response.end()
552
+ return
553
+ }
554
+ const c = this.current
555
+ if (c === null) {
556
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
557
+ response.end('no custom icon')
558
+ return
559
+ }
560
+ let bytes = null
561
+ let mime = 'image/png'
562
+ const spec = DERIVED_SIZES.find((s) => s.url === sizeUrl)
563
+ if (spec !== undefined) {
564
+ try {
565
+ bytes = readFileSync(join(this.libraryDir, `${c.id}.derived`, `${spec.file}.png`))
566
+ } catch { /* fall back to baseline */ }
567
+ }
568
+ // No derived file yet: serve the rasterized baseline so the manifest
569
+ // entry still resolves to a real PNG rather than a broken href.
570
+ if (bytes === null && c.pngBaseline !== null) bytes = c.pngBaseline
571
+ if (bytes === null) {
572
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
573
+ response.end('icon not rasterizable')
574
+ return
575
+ }
576
+ response.writeHead(200, {
577
+ 'content-type': mime,
578
+ 'content-length': bytes.length,
579
+ 'cache-control': 'public, max-age=31536000, immutable',
580
+ 'x-content-type-options': 'nosniff'
581
+ })
582
+ if (request.method === 'HEAD') { response.end(); return }
583
+ response.end(bytes)
584
+ }
585
+
381
586
  /** PWA manifest body: platform manifest verbatim, or with `icons` pointed
382
- * at the custom icon when the PWA option is on for the active icon. */
587
+ * at the canonical derived PNG set when the PWA option is on for the
588
+ * active icon. */
383
589
  _manifestJson() {
384
590
  const c = this.current
385
591
  if (c === null || c.pwa !== true) return this.baseManifest.raw
592
+ // Preferred: the derived canonical sizes. When no derived set exists,
593
+ // fall back to the original upload so the manifest still declares a
594
+ // usable icon rather than pointing at broken derivative URLs.
595
+ if (c.pngBaseline !== null) {
596
+ const icons = DERIVED_SIZES.filter((s) => s.url !== 'apple-180').map((s) => ({
597
+ src: `/icon-custom-${s.url}.png?v=${c.rev}`,
598
+ sizes: `${s.size}x${s.size}`,
599
+ type: 'image/png',
600
+ purpose: s.maskable === true ? 'maskable' : 'any'
601
+ }))
602
+ return JSON.stringify({ ...this.baseManifest.parsed, icons }, null, 2)
603
+ }
386
604
  const icons = [{
387
605
  src: `/icon-custom.svg?v=${c.rev}`,
388
606
  sizes: iconSizes(c.mime, c.bytes),
@@ -416,18 +634,23 @@ export class FaviconCustomService extends TypertRemoteService {
416
634
 
417
635
  /** Rewrite the head `<link rel="icon">` only when a custom icon is active.
418
636
  * Also points `<link rel="apple-touch-icon">` (iOS home-screen installs)
419
- * at the custom icon when the PWA option is on and the icon is a PNG —
420
- * iOS ignores SVG apple-touch-icons. */
637
+ * at the derived 180x180 opaque PNG when the PWA option is on and we have
638
+ * a rasterized baseline — iOS ignores SVG and transparent apple-touch-icons. */
421
639
  _rewriteIndex(html) {
422
640
  const c = this.current
423
641
  if (c === null) return html
424
642
  let out = html
425
- const href = `/icon-custom.svg?v=${c.rev}`
426
- const tag = `<link rel="icon" type="${c.mime}" href="${href}" />`
643
+ // Prefer the derived PNG set for the favicon so SVG/ICO uploads (and
644
+ // oversize PNGs) render consistently; fall back to the original.
645
+ const favHref = c.pngBaseline !== null
646
+ ? `/icon-custom-192.png?v=${c.rev}`
647
+ : `/icon-custom.svg?v=${c.rev}`
648
+ const favType = c.pngBaseline !== null ? 'image/png' : c.mime
649
+ const tag = `<link rel="icon" type="${favType}" href="${favHref}" />`
427
650
  const re = /<link\s+rel="icon"[^>]*\/?>/i
428
651
  out = re.test(out) ? out.replace(re, tag) : out.replace(/<head>/i, `<head>${tag}`)
429
- if (c.pwa === true && c.mime === 'image/png') {
430
- const tag2 = `<link rel="apple-touch-icon" href="${href}" />`
652
+ if (c.pwa === true && c.pngBaseline !== null) {
653
+ const tag2 = `<link rel="apple-touch-icon" href="/icon-custom-apple-180.png?v=${c.rev}" />`
431
654
  const re2 = /<link\s+rel="apple-touch-icon"[^>]*\/?>/i
432
655
  out = re2.test(out) ? out.replace(re2, tag2) : out.replace(/<head>/i, `<head>${tag2}`)
433
656
  }
@@ -1,10 +1,15 @@
1
- /* Hand-written typert host manifest for dsh-icon-custom (strict face). */
1
+ /* Hand-written typert host manifest for dsh-icon-custom (strict face).
2
+ * The request/response shapes are unchanged from 0.6.x. From 0.7.0 the host
3
+ * additionally derives a canonical PNG set (see lib/index.js); `status.mime` /
4
+ * `status.ext` still reflect the *uploaded* format, while the PWA/logo path
5
+ * always serves the derived PNGs regardless of upload type. */
2
6
  import { z } from 'zod'
3
7
 
4
8
  const statusSchema = z.object({
5
9
  active: z.boolean(),
6
10
  pwa: z.boolean(),
7
11
  logo: z.boolean(),
12
+ png: z.boolean(),
8
13
  rev: z.string().nullable(),
9
14
  mime: z.string().nullable(),
10
15
  ext: z.string().nullable(),
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-icon-custom",
3
- "version": "0.6.0",
4
- "description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists.",
3
+ "version": "0.7.0",
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",
7
7
  "license": "MIT",
8
+ "dependencies": {
9
+ "jimp": "^1.6.1"
10
+ },
8
11
  "dsh": {
9
12
  "bundle": {
10
13
  "patch": "./cordis.patch.yml"
@@ -68,7 +71,8 @@
68
71
  "dsh-plugin",
69
72
  "deepseek-harness",
70
73
  "favicon",
71
- "icon"
74
+ "icon",
75
+ "pwa"
72
76
  ],
73
77
  "repository": {
74
78
  "type": "git",