@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 +11 -13
- package/README.zh.md +11 -13
- package/client/client.js +64 -9
- package/lib/index.js +244 -21
- package/lib/typert.host.js +6 -1
- package/package.json +7 -3
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
|
-
|
|
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
|
|
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
|

|
|
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
|
-
|
|
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
|
|
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
|

|
|
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
|
|
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",
|
|
206
|
+
link.setAttribute("type", type);
|
|
167
207
|
} else {
|
|
168
208
|
const el = document.createElement("link");
|
|
169
209
|
el.rel = "icon";
|
|
170
|
-
el.type =
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* a
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
426
|
-
|
|
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.
|
|
430
|
-
const tag2 = `<link rel="apple-touch-icon" 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
|
}
|
package/lib/typert.host.js
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO
|
|
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",
|