@cowwo/dsh-icon-custom 0.1.0 → 0.2.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 +13 -0
- package/README.zh.md +13 -0
- package/client/client.js +46 -4
- package/docs/pwa-option.png +0 -0
- package/lib/index.js +157 -7
- package/lib/typert.host.js +43 -3
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -19,6 +19,19 @@ Scoped name: `@cowwo/dsh-icon-custom`.
|
|
|
19
19
|
3. The current tab icon changes immediately; it is persisted under `$DSH_HOME/custom-favicon/`.
|
|
20
20
|
4. Click **恢复默认** to clear the custom icon and fall back to the platform favicon.
|
|
21
21
|
|
|
22
|
+
## PWA install icon
|
|
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).
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
Notes:
|
|
29
|
+
|
|
30
|
+
- The option is **off by default**: without it, behavior is identical to before and the platform manifest is served verbatim.
|
|
31
|
+
- The option can be toggled at any time after upload — no need to re-upload the file.
|
|
32
|
+
- 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`).
|
|
33
|
+
- PNG is the recommended format for the PWA icon (UI / launcher icons are rasterized); the same uploaded file is used, never re-encoded.
|
|
34
|
+
|
|
22
35
|
## Format support
|
|
23
36
|
|
|
24
37
|
- **SVG** — recognized by content (`<svg`), validated against script / event‑handler / `javascript:` injection.
|
package/README.zh.md
CHANGED
|
@@ -19,6 +19,19 @@ dsh plugin --profile web add dsh-icon-custom
|
|
|
19
19
|
3. 当前标签页图标立即替换;文件会保存到 `$DSH_HOME/custom-favicon/`。
|
|
20
20
|
4. 点击**恢复默认**清除自定义图标,回到平台默认图标。
|
|
21
21
|
|
|
22
|
+
## PWA 安装图标
|
|
23
|
+
|
|
24
|
+
勾选**同时替换 PWA 安装图标**(勾选状态按图标记录保存),网页「安装为应用 / 添加到主屏幕」后,桌面和启动器上的图标也会换成你上传的这张。插件会把 `/manifest.webmanifest` 的 icons 项改写为你的图标(SVG 标注 `sizes: "any"`,PNG 按真实尺寸标注,ICO 取第一条目尺寸);在 iPhone/iPad 上还会把 `<link rel="apple-touch-icon">` 指向你的图标——但这只在你的图标是 **PNG** 时有效(iOS 不支持 SVG 触屏图标)。
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
说明:
|
|
29
|
+
|
|
30
|
+
- 该选项**默认不勾选**:不勾选时行为与之前完全一致,平台 manifest 原样透传。
|
|
31
|
+
- 上传后随时可以勾选/取消,不用重新上传文件。
|
|
32
|
+
- 浏览器按自己的节奏重新检查 manifest;**已经安装**的应用通常要等重新安装(或浏览器下一次 manifest 更新读到新的 `src`)才会换图标。
|
|
33
|
+
- PWA 图标推荐上传 **PNG**(桌面 / 启动器图标通常会被栅格化);复用同一张上传图,不重新编码。
|
|
34
|
+
|
|
22
35
|
## 支持的格式
|
|
23
36
|
|
|
24
37
|
- **SVG** — 按内容(`<svg`)识别,并拦截 `<script>` / 事件属性 / `javascript:` 注入。
|
package/client/client.js
CHANGED
|
@@ -25,6 +25,9 @@ window.__ModuleLoader__.load({
|
|
|
25
25
|
updated: "更新时间",
|
|
26
26
|
successUpload: "已应用新图标",
|
|
27
27
|
successReset: "已恢复默认图标",
|
|
28
|
+
pwa: "同时替换 PWA 安装图标",
|
|
29
|
+
pwaHint: "安装为应用(添加到主屏幕)后,桌面上的图标也换成这张;已安装的应用可能需要重新安装才会更新。",
|
|
30
|
+
successPwa: "PWA 图标设置已更新",
|
|
28
31
|
fail: "操作失败",
|
|
29
32
|
noFile: "请先选择图标文件"
|
|
30
33
|
};
|
|
@@ -44,6 +47,9 @@ window.__ModuleLoader__.load({
|
|
|
44
47
|
updated: "Updated",
|
|
45
48
|
successUpload: "New icon applied",
|
|
46
49
|
successReset: "Default icon restored",
|
|
50
|
+
pwa: "Also replace the PWA app icon",
|
|
51
|
+
pwaHint: "The icon used after installing the site as an app (add to home screen). Already-installed apps may need a reinstall to pick it up.",
|
|
52
|
+
successPwa: "PWA icon setting updated",
|
|
47
53
|
fail: "Operation failed",
|
|
48
54
|
noFile: "Choose a file first"
|
|
49
55
|
};
|
|
@@ -73,6 +79,7 @@ window.__ModuleLoader__.load({
|
|
|
73
79
|
const t = props.t;
|
|
74
80
|
const rpc = props.rpc;
|
|
75
81
|
const [status, setStatus] = React.useState(null);
|
|
82
|
+
const [pwaEnabled, setPwaEnabled] = React.useState(false);
|
|
76
83
|
const [busy, setBusy] = React.useState(false);
|
|
77
84
|
const [error, setError] = React.useState("");
|
|
78
85
|
const [notice, setNotice] = React.useState("");
|
|
@@ -81,8 +88,10 @@ window.__ModuleLoader__.load({
|
|
|
81
88
|
const load = React.useCallback(async () => {
|
|
82
89
|
try {
|
|
83
90
|
const resp = await rpc.call("/api", "iconCustom/getStatus", { args: {} });
|
|
84
|
-
if (resp && resp.ok === true)
|
|
85
|
-
|
|
91
|
+
if (resp && resp.ok === true) {
|
|
92
|
+
setStatus(resp.value);
|
|
93
|
+
setPwaEnabled(resp.value.pwa === true);
|
|
94
|
+
} else setError(reason(resp, t("fail")));
|
|
86
95
|
} catch (e) {
|
|
87
96
|
setError(String((e && e.message) || e || t("fail")));
|
|
88
97
|
}
|
|
@@ -138,10 +147,11 @@ window.__ModuleLoader__.load({
|
|
|
138
147
|
setBusy(true);
|
|
139
148
|
try {
|
|
140
149
|
const resp = await rpc.call("/api", "iconCustom/setIcon", {
|
|
141
|
-
args: { request: { mime: file.type || "image/svg+xml", name: file.name, data } }
|
|
150
|
+
args: { request: { mime: file.type || "image/svg+xml", name: file.name, pwa: pwaEnabled, data } }
|
|
142
151
|
});
|
|
143
152
|
if (resp && resp.ok === true) {
|
|
144
153
|
setStatus(resp.value);
|
|
154
|
+
setPwaEnabled(resp.value.pwa === true);
|
|
145
155
|
setNotice(t("successUpload"));
|
|
146
156
|
applyLive(resp.value);
|
|
147
157
|
} else {
|
|
@@ -155,7 +165,7 @@ window.__ModuleLoader__.load({
|
|
|
155
165
|
};
|
|
156
166
|
reader.onerror = () => setError(t("fail"));
|
|
157
167
|
reader.readAsDataURL(file);
|
|
158
|
-
}, [rpc, t, applyLive]);
|
|
168
|
+
}, [rpc, t, pwaEnabled, applyLive]);
|
|
159
169
|
|
|
160
170
|
const onReset = React.useCallback(async () => {
|
|
161
171
|
setError("");
|
|
@@ -165,6 +175,7 @@ window.__ModuleLoader__.load({
|
|
|
165
175
|
const resp = await rpc.call("/api", "iconCustom/resetIcon", { args: {} });
|
|
166
176
|
if (resp && resp.ok === true) {
|
|
167
177
|
setStatus(resp.value);
|
|
178
|
+
setPwaEnabled(false);
|
|
168
179
|
setNotice(t("successReset"));
|
|
169
180
|
applyDefault();
|
|
170
181
|
} else {
|
|
@@ -177,6 +188,32 @@ window.__ModuleLoader__.load({
|
|
|
177
188
|
}
|
|
178
189
|
}, [rpc, t, applyDefault]);
|
|
179
190
|
|
|
191
|
+
const onTogglePwa = React.useCallback(async (event) => {
|
|
192
|
+
const enabled = event.target.checked === true;
|
|
193
|
+
setError("");
|
|
194
|
+
setNotice("");
|
|
195
|
+
setBusy(true);
|
|
196
|
+
try {
|
|
197
|
+
const resp = await rpc.call("/api", "iconCustom/setPwa", {
|
|
198
|
+
args: { request: { enabled } }
|
|
199
|
+
});
|
|
200
|
+
if (resp && resp.ok === true) {
|
|
201
|
+
setStatus(resp.value);
|
|
202
|
+
setPwaEnabled(resp.value.pwa === true);
|
|
203
|
+
setNotice(t("successPwa"));
|
|
204
|
+
applyLive(resp.value);
|
|
205
|
+
} else {
|
|
206
|
+
setPwaEnabled(!enabled);
|
|
207
|
+
setError(reason(resp, t("fail")));
|
|
208
|
+
}
|
|
209
|
+
} catch (e) {
|
|
210
|
+
setPwaEnabled(!enabled);
|
|
211
|
+
setError(String((e && e.message) || e || t("fail")));
|
|
212
|
+
} finally {
|
|
213
|
+
setBusy(false);
|
|
214
|
+
}
|
|
215
|
+
}, [rpc, t, applyLive]);
|
|
216
|
+
|
|
180
217
|
const active = status && status.active === true;
|
|
181
218
|
const previewSrc = active ? `/icon-custom.svg?v=${status.rev}` : "/favicon.svg";
|
|
182
219
|
|
|
@@ -216,6 +253,11 @@ window.__ModuleLoader__.load({
|
|
|
216
253
|
),
|
|
217
254
|
React.createElement("input", { ref: inputRef, type: "file", accept: ".svg,.png,.ico,.cur,image/svg+xml,image/png,image/x-icon", style: { display: "none" }, onChange: onFileChange })
|
|
218
255
|
),
|
|
256
|
+
React.createElement("label", { style: { display: "flex", alignItems: "center", gap: "8px", padding: "10px 0 0", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: busy ? "default" : "pointer" } },
|
|
257
|
+
React.createElement("input", { type: "checkbox", checked: pwaEnabled, disabled: busy || !active, onChange: onTogglePwa, style: { cursor: busy ? "default" : "pointer" } }),
|
|
258
|
+
React.createElement("span", null, t("pwa"))
|
|
259
|
+
),
|
|
260
|
+
React.createElement("div", { style: style.hint }, t("pwaHint")),
|
|
219
261
|
React.createElement("div", { style: style.hint }, t("uploadHint")),
|
|
220
262
|
React.createElement("div", { style: style.hint }, t("resetHint")),
|
|
221
263
|
notice ? React.createElement("div", { style: style.notice }, notice) : null,
|
|
Binary file
|
package/lib/index.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
20
20
|
import { readFileSync } from 'node:fs'
|
|
21
21
|
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
22
|
+
import { createRequire } from 'node:module'
|
|
22
23
|
import { homedir } from 'node:os'
|
|
23
24
|
import { join, resolve } from 'node:path'
|
|
24
25
|
|
|
@@ -34,6 +35,25 @@ const MIME_EXT = {
|
|
|
34
35
|
const PNG_MAGIC = [0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
35
36
|
const ICO_MAGIC = [0x00, 0x00, 0x01, 0x00]
|
|
36
37
|
const CUR_MAGIC = [0x00, 0x00, 0x02, 0x00]
|
|
38
|
+
// Fallback platform manifest, mirroring @deepseek-ai/dsh-web-frontend/dist/
|
|
39
|
+
// manifest.webmanifest. Used only when the real file cannot be resolved (e.g.
|
|
40
|
+
// the plugin is developed outside the harness node_modules); the primary path
|
|
41
|
+
// reads the actual dist file at startup so platform-side field changes are
|
|
42
|
+
// followed automatically.
|
|
43
|
+
const FALLBACK_MANIFEST = {
|
|
44
|
+
id: '/',
|
|
45
|
+
name: 'DeepSeek Harness',
|
|
46
|
+
short_name: 'DSH',
|
|
47
|
+
start_url: '/',
|
|
48
|
+
scope: '/',
|
|
49
|
+
display: 'fullscreen',
|
|
50
|
+
icons: [{
|
|
51
|
+
src: '/favicon.svg',
|
|
52
|
+
sizes: 'any',
|
|
53
|
+
type: 'image/svg+xml',
|
|
54
|
+
purpose: 'any'
|
|
55
|
+
}]
|
|
56
|
+
}
|
|
37
57
|
|
|
38
58
|
/** Resolve the Harness home the same way `@deepseek-ai/dsh-home-paths` does. */
|
|
39
59
|
function resolveHome() {
|
|
@@ -128,6 +148,63 @@ function base64Body(dataUrl) {
|
|
|
128
148
|
return { bytes: Buffer.from(body, 'base64') }
|
|
129
149
|
}
|
|
130
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Read the platform PWA manifest once at startup. Primary path resolves the
|
|
153
|
+
* actual dist file the same way @deepseek-ai/dsh-web-app does; the fallback
|
|
154
|
+
* constants keep the plugin working when developed outside the harness
|
|
155
|
+
* node_modules. Returns both the raw bytes (served verbatim while the PWA
|
|
156
|
+
* option is off) and the parsed object (base for the rewritten manifest).
|
|
157
|
+
*/
|
|
158
|
+
function loadBaseManifest() {
|
|
159
|
+
try {
|
|
160
|
+
const req = createRequire(import.meta.url)
|
|
161
|
+
const path = req.resolve('@deepseek-ai/dsh-web-frontend/dist/manifest.webmanifest')
|
|
162
|
+
const raw = readFileSync(path, 'utf8')
|
|
163
|
+
const parsed = JSON.parse(raw)
|
|
164
|
+
if (typeof parsed === 'object' && parsed !== null) return { raw, parsed }
|
|
165
|
+
} catch { /* fall through to constants */ }
|
|
166
|
+
const parsed = FALLBACK_MANIFEST
|
|
167
|
+
return { raw: JSON.stringify(parsed, null, 2), parsed }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** PNG pixel size from IHDR: `<W>x<H>`, or null when unreadable. */
|
|
171
|
+
function pngLabel(buf, offset) {
|
|
172
|
+
if (buf.length < offset + 23) return null
|
|
173
|
+
const w = buf.readUInt32BE(offset + 15)
|
|
174
|
+
const h = buf.readUInt32BE(offset + 19)
|
|
175
|
+
if (w <= 0 || h <= 0) return null
|
|
176
|
+
return `${w}x${h}`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** First ICO/CUR directory entry size; a 0 byte means 256. */
|
|
180
|
+
function icoFirstSize(buf, offset) {
|
|
181
|
+
if (buf.length < offset + 8) return null
|
|
182
|
+
const w = buf[offset + 6]
|
|
183
|
+
const h = buf[offset + 7]
|
|
184
|
+
return { w: w === 0 ? 256 : w, h: h === 0 ? 256 : h }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Manifest `sizes` attribute for the active icon: SVG is scalable (`any`),
|
|
189
|
+
* PNG reports its true IHDR dimensions, ICO reports its first entry size.
|
|
190
|
+
* Anything unreadable falls back to `any` — the platform manifest itself
|
|
191
|
+
* ships an SVG with `sizes: "any"` and remains installable.
|
|
192
|
+
*/
|
|
193
|
+
function iconSizes(mime, bytes) {
|
|
194
|
+
if (mime === 'image/png') {
|
|
195
|
+
const offset = indexOfPattern(bytes, PNG_MAGIC, 16)
|
|
196
|
+
const label = offset === -1 ? null : pngLabel(bytes, offset)
|
|
197
|
+
if (label !== null) return label
|
|
198
|
+
} 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
|
|
202
|
+
const size = at === -1 ? null : icoFirstSize(bytes, at)
|
|
203
|
+
if (size !== null) return `${size.w}x${size.h}`
|
|
204
|
+
}
|
|
205
|
+
return 'any'
|
|
206
|
+
}
|
|
207
|
+
|
|
131
208
|
/**
|
|
132
209
|
* 图标替换服务:保存/读取用户上传的 favicon,并通过 webServer 提供
|
|
133
210
|
* `/icon-custom.svg` 路由与 index 头部改写。通过 typert
|
|
@@ -141,16 +218,26 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
141
218
|
this.storageDir = join(resolveHome(), 'custom-favicon')
|
|
142
219
|
this.libraryDir = join(this.storageDir, 'library')
|
|
143
220
|
this.indexPath = join(this.storageDir, 'library.json')
|
|
144
|
-
// { activeId, icons: { [id]: { id, mime, ext, name, size, savedAt, rev } } }
|
|
221
|
+
// { activeId, icons: { [id]: { id, mime, ext, name, size, savedAt, rev, pwa } } }
|
|
145
222
|
this.library = { activeId: null, icons: {} }
|
|
146
|
-
// The active icon's loaded state: { rev, mime, ext, name, size, savedAt, bytes } | null
|
|
223
|
+
// The active icon's loaded state: { rev, mime, ext, name, size, savedAt, pwa, bytes } | null
|
|
147
224
|
this.current = null
|
|
225
|
+
// Platform PWA manifest (raw + parsed), read once at startup.
|
|
226
|
+
this.baseManifest = loadBaseManifest()
|
|
148
227
|
this._load()
|
|
149
228
|
ctx.effect(() => ctx.webServer.register({
|
|
150
229
|
kind: 'exact',
|
|
151
230
|
path: '/icon-custom.svg',
|
|
152
231
|
handler: (request, response) => this._handleIcon(request, response)
|
|
153
232
|
}), 'dsh-icon-custom: /icon-custom.svg route')
|
|
233
|
+
// Exact route beats the frontend-static fallback (webserver match order:
|
|
234
|
+
// exact table → prefix table → fallback), so this transparently overrides
|
|
235
|
+
// the dist manifest without touching any platform file.
|
|
236
|
+
ctx.effect(() => ctx.webServer.register({
|
|
237
|
+
kind: 'exact',
|
|
238
|
+
path: '/manifest.webmanifest',
|
|
239
|
+
handler: (request, response) => this._handleManifest(request, response)
|
|
240
|
+
}), 'dsh-icon-custom: /manifest.webmanifest route')
|
|
154
241
|
ctx.effect(() => ctx.webServer.tapIndex((html) => this._rewriteIndex(html)),
|
|
155
242
|
'dsh-icon-custom: index tap')
|
|
156
243
|
}
|
|
@@ -181,8 +268,8 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
181
268
|
status() {
|
|
182
269
|
const c = this.current
|
|
183
270
|
return c === null
|
|
184
|
-
? { active: false, rev: null, mime: null, ext: null, name: null, size: null, savedAt: null }
|
|
185
|
-
: { active: true, rev: c.rev, mime: c.mime, ext: c.ext, name: c.name, size: c.size, savedAt: c.savedAt }
|
|
271
|
+
? { active: false, pwa: false, rev: null, mime: null, ext: null, name: null, size: null, savedAt: null }
|
|
272
|
+
: { active: true, pwa: c.pwa === true, rev: c.rev, mime: c.mime, ext: c.ext, name: c.name, size: c.size, savedAt: c.savedAt }
|
|
186
273
|
}
|
|
187
274
|
|
|
188
275
|
/** typert endpoint: read current status. */
|
|
@@ -193,6 +280,8 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
193
280
|
/** typert endpoint: store a new icon uploaded as a data URL and make it active. */
|
|
194
281
|
async setIcon(request) {
|
|
195
282
|
if (request === null || typeof request !== 'object') throw new Error('缺少图标参数')
|
|
283
|
+
// `pwa`: whether this icon also replaces the PWA install icon (opt-in).
|
|
284
|
+
const pwa = request.pwa === true
|
|
196
285
|
const { name, data } = request
|
|
197
286
|
if (typeof data !== 'string' || data.length === 0) throw new Error('缺少图标数据')
|
|
198
287
|
const { bytes } = base64Body(data)
|
|
@@ -210,6 +299,7 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
210
299
|
const rec = {
|
|
211
300
|
id,
|
|
212
301
|
mime, ext,
|
|
302
|
+
pwa,
|
|
213
303
|
name: typeof name === 'string' && name.trim().length > 0 ? name.trim().slice(0, 128) : null,
|
|
214
304
|
size: bytes.length, savedAt: now, rev
|
|
215
305
|
}
|
|
@@ -220,6 +310,21 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
220
310
|
return this.status()
|
|
221
311
|
}
|
|
222
312
|
|
|
313
|
+
/** typert endpoint: toggle the PWA-icon flag on the active icon (no re-upload). */
|
|
314
|
+
async setPwa(request) {
|
|
315
|
+
if (request === null || typeof request !== 'object' || typeof request.enabled !== 'boolean') {
|
|
316
|
+
throw new Error('缺少 enabled 参数')
|
|
317
|
+
}
|
|
318
|
+
const id = this.library.activeId
|
|
319
|
+
if (id === null || this.current === null) throw new Error('当前没有自定义图标')
|
|
320
|
+
const rec = this.library.icons[id]
|
|
321
|
+
if (rec === null || typeof rec !== 'object') throw new Error('图标记录不存在')
|
|
322
|
+
rec.pwa = request.enabled === true
|
|
323
|
+
this.current.pwa = rec.pwa
|
|
324
|
+
await this._saveLibrary().catch(() => {}) // index is advisory
|
|
325
|
+
return this.status()
|
|
326
|
+
}
|
|
327
|
+
|
|
223
328
|
/** typert endpoint: clear the active marker so the platform icon is used.
|
|
224
329
|
* Icon files stay on disk for the future icon-library UI. */
|
|
225
330
|
async resetIcon() {
|
|
@@ -256,15 +361,60 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
256
361
|
response.end(c.bytes)
|
|
257
362
|
}
|
|
258
363
|
|
|
259
|
-
/**
|
|
364
|
+
/** PWA manifest body: platform manifest verbatim, or with `icons` pointed
|
|
365
|
+
* at the custom icon when the PWA option is on for the active icon. */
|
|
366
|
+
_manifestJson() {
|
|
367
|
+
const c = this.current
|
|
368
|
+
if (c === null || c.pwa !== true) return this.baseManifest.raw
|
|
369
|
+
const icons = [{
|
|
370
|
+
src: `/icon-custom.svg?v=${c.rev}`,
|
|
371
|
+
sizes: iconSizes(c.mime, c.bytes),
|
|
372
|
+
type: c.mime,
|
|
373
|
+
purpose: 'any'
|
|
374
|
+
}]
|
|
375
|
+
return JSON.stringify({ ...this.baseManifest.parsed, icons }, null, 2)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Serve the PWA manifest (platform default unless the PWA option is on). */
|
|
379
|
+
async _handleManifest(request, response) {
|
|
380
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
381
|
+
response.writeHead(405, { allow: 'GET, HEAD' })
|
|
382
|
+
response.end()
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
const body = this._manifestJson()
|
|
386
|
+
response.writeHead(200, {
|
|
387
|
+
'content-type': 'application/manifest+json',
|
|
388
|
+
// The static dist server sends no cache headers; require revalidation
|
|
389
|
+
// so a toggled PWA icon is picked up without a hard cache purge.
|
|
390
|
+
'cache-control': 'no-cache',
|
|
391
|
+
'x-content-type-options': 'nosniff'
|
|
392
|
+
})
|
|
393
|
+
if (request.method === 'HEAD') {
|
|
394
|
+
response.end()
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
response.end(body)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Rewrite the head `<link rel="icon">` only when a custom icon is active.
|
|
401
|
+
* Also points `<link rel="apple-touch-icon">` (iOS home-screen installs)
|
|
402
|
+
* at the custom icon when the PWA option is on and the icon is a PNG —
|
|
403
|
+
* iOS ignores SVG apple-touch-icons. */
|
|
260
404
|
_rewriteIndex(html) {
|
|
261
405
|
const c = this.current
|
|
262
406
|
if (c === null) return html
|
|
407
|
+
let out = html
|
|
263
408
|
const href = `/icon-custom.svg?v=${c.rev}`
|
|
264
409
|
const tag = `<link rel="icon" type="${c.mime}" href="${href}" />`
|
|
265
410
|
const re = /<link\s+rel="icon"[^>]*\/?>/i
|
|
266
|
-
|
|
267
|
-
|
|
411
|
+
out = re.test(out) ? out.replace(re, tag) : out.replace(/<head>/i, `<head>${tag}`)
|
|
412
|
+
if (c.pwa === true && c.mime === 'image/png') {
|
|
413
|
+
const tag2 = `<link rel="apple-touch-icon" href="${href}" />`
|
|
414
|
+
const re2 = /<link\s+rel="apple-touch-icon"[^>]*\/?>/i
|
|
415
|
+
out = re2.test(out) ? out.replace(re2, tag2) : out.replace(/<head>/i, `<head>${tag2}`)
|
|
416
|
+
}
|
|
417
|
+
return out
|
|
268
418
|
}
|
|
269
419
|
}
|
|
270
420
|
|
package/lib/typert.host.js
CHANGED
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
|
|
4
4
|
const statusSchema = z.object({
|
|
5
5
|
active: z.boolean(),
|
|
6
|
+
pwa: z.boolean(),
|
|
6
7
|
rev: z.string().nullable(),
|
|
7
8
|
mime: z.string().nullable(),
|
|
8
9
|
ext: z.string().nullable(),
|
|
@@ -14,9 +15,14 @@ const statusSchema = z.object({
|
|
|
14
15
|
const setIconRequestSchema = z.object({
|
|
15
16
|
mime: z.string().min(1),
|
|
16
17
|
name: z.string().max(128).optional(),
|
|
18
|
+
pwa: z.boolean().optional(),
|
|
17
19
|
data: z.string().min(1)
|
|
18
20
|
})
|
|
19
21
|
|
|
22
|
+
const setPwaRequestSchema = z.object({
|
|
23
|
+
enabled: z.boolean()
|
|
24
|
+
})
|
|
25
|
+
|
|
20
26
|
const setIconResultSchema = statusSchema
|
|
21
27
|
|
|
22
28
|
export const TYPERT = {
|
|
@@ -63,6 +69,31 @@ export const TYPERT = {
|
|
|
63
69
|
},
|
|
64
70
|
sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
|
|
65
71
|
},
|
|
72
|
+
{
|
|
73
|
+
id: 'dsh-icon-custom#iconCustom/setPwa',
|
|
74
|
+
service: 'iconCustom',
|
|
75
|
+
namespace: 'iconCustom',
|
|
76
|
+
method: 'setPwa',
|
|
77
|
+
invocation: { kind: 'direct' },
|
|
78
|
+
parameters: [
|
|
79
|
+
{
|
|
80
|
+
name: 'request',
|
|
81
|
+
wire: 'request',
|
|
82
|
+
source: 'json',
|
|
83
|
+
codec: {
|
|
84
|
+
mode: 'strict',
|
|
85
|
+
typeSymbol: 'dsh-icon-custom#iconCustom/setPwa:request',
|
|
86
|
+
schema: setPwaRequestSchema
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
result: {
|
|
91
|
+
mode: 'strict',
|
|
92
|
+
typeSymbol: 'dsh-icon-custom#iconCustom/setPwa:result',
|
|
93
|
+
schema: statusSchema
|
|
94
|
+
},
|
|
95
|
+
sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
|
|
96
|
+
},
|
|
66
97
|
{
|
|
67
98
|
id: 'dsh-icon-custom#iconCustom/resetIcon',
|
|
68
99
|
service: 'iconCustom',
|
|
@@ -91,7 +122,7 @@ export const TYPERT = {
|
|
|
91
122
|
{
|
|
92
123
|
kind: 'method',
|
|
93
124
|
name: 'getStatus',
|
|
94
|
-
signature: 'async getStatus(): Promise<{ active: boolean; rev: string | null; mime: string | null; ext: string | null; name: string | null; size: number | null; savedAt: number | null }>',
|
|
125
|
+
signature: 'async getStatus(): Promise<{ active: boolean; pwa: boolean; rev: string | null; mime: string | null; ext: string | null; name: string | null; size: number | null; savedAt: number | null }>',
|
|
95
126
|
description: "Read the current favicon status.",
|
|
96
127
|
summary: "Read the current favicon status.",
|
|
97
128
|
jsDoc: "/** Read the current favicon status. */",
|
|
@@ -100,16 +131,25 @@ export const TYPERT = {
|
|
|
100
131
|
{
|
|
101
132
|
kind: 'method',
|
|
102
133
|
name: 'setIcon',
|
|
103
|
-
signature: 'async setIcon(request: { mime: string; name?: string; data: string }): Promise<{ active: boolean; rev: string | null; mime: string | null; ext: string | null; name: string | null; size: number | null; savedAt: number | null }>',
|
|
134
|
+
signature: 'async setIcon(request: { mime: string; name?: string; pwa?: boolean; data: string }): Promise<{ active: boolean; pwa: boolean; rev: string | null; mime: string | null; ext: string | null; name: string | null; size: number | null; savedAt: number | null }>',
|
|
104
135
|
description: "Store a new favicon uploaded as a data URL.",
|
|
105
136
|
summary: "Store a new favicon.",
|
|
106
137
|
jsDoc: "/** Store a new favicon. */",
|
|
107
138
|
tags: []
|
|
108
139
|
},
|
|
140
|
+
{
|
|
141
|
+
kind: 'method',
|
|
142
|
+
name: 'setPwa',
|
|
143
|
+
signature: 'async setPwa(request: { enabled: boolean }): Promise<{ active: boolean; pwa: boolean; rev: string | null; mime: string | null; ext: string | null; name: string | null; size: number | null; savedAt: number | null }>',
|
|
144
|
+
description: "Toggle whether the active custom icon also replaces the PWA install icon.",
|
|
145
|
+
summary: "Toggle the PWA-icon option.",
|
|
146
|
+
jsDoc: "/** Toggle the PWA-icon option. */",
|
|
147
|
+
tags: []
|
|
148
|
+
},
|
|
109
149
|
{
|
|
110
150
|
kind: 'method',
|
|
111
151
|
name: 'resetIcon',
|
|
112
|
-
signature: 'async resetIcon(): Promise<{ active: boolean; rev: string | null; mime: string | null; ext: string | null; name: string | null; size: number | null; savedAt: number | null }>',
|
|
152
|
+
signature: 'async resetIcon(): Promise<{ active: boolean; pwa: boolean; rev: string | null; mime: string | null; ext: string | null; name: string | null; size: number | null; savedAt: number | null }>',
|
|
113
153
|
description: "Clear the active custom favicon marker so the platform icon is used; stored icon files are kept.",
|
|
114
154
|
summary: "Clear the custom favicon.",
|
|
115
155
|
jsDoc: "/** Clear the custom favicon. */",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cowwo/dsh-icon-custom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"lib",
|
|
58
58
|
"client",
|
|
59
59
|
"cordis.patch.yml",
|
|
60
|
+
"docs/pwa-option.png",
|
|
60
61
|
"README.md",
|
|
61
62
|
"README.zh.md",
|
|
62
63
|
"LICENSE"
|