@cowwo/dsh-icon-custom 0.1.0 → 0.2.1

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
@@ -19,6 +19,23 @@ 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
+ ![PWA install icon option](./docs/pwa-option.png)
27
+
28
+ When the site is installed as an app, the browser's install dialog picks the custom icon up:
29
+
30
+ ![PWA install dialog](./docs/install-dialog.png)
31
+
32
+ Notes:
33
+
34
+ - The option is **off by default**: without it, behavior is identical to before and the platform manifest is served verbatim.
35
+ - The option can be toggled at any time after upload — no need to re-upload the file.
36
+ - 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`).
37
+ - PNG is the recommended format for the PWA icon (UI / launcher icons are rasterized); the same uploaded file is used, never re-encoded.
38
+
22
39
  ## Format support
23
40
 
24
41
  - **SVG** — recognized by content (`<svg`), validated against script / event‑handler / `javascript:` injection.
package/README.zh.md CHANGED
@@ -19,6 +19,23 @@ 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
+ ![同时替换 PWA 安装图标选项](./docs/pwa-option.png)
27
+
28
+ 把网页「安装为应用」时,浏览器弹出的安装对话框也会显示你的图标:
29
+
30
+ ![PWA 安装对话框](./docs/install-dialog.png)
31
+
32
+ 说明:
33
+
34
+ - 该选项**默认不勾选**:不勾选时行为与之前完全一致,平台 manifest 原样透传。
35
+ - 上传后随时可以勾选/取消,不用重新上传文件。
36
+ - 浏览器按自己的节奏重新检查 manifest;**已经安装**的应用通常要等重新安装(或浏览器下一次 manifest 更新读到新的 `src`)才会换图标。
37
+ - PWA 图标推荐上传 **PNG**(桌面 / 启动器图标通常会被栅格化);复用同一张上传图,不重新编码。
38
+
22
39
  ## 支持的格式
23
40
 
24
41
  - **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) setStatus(resp.value);
85
- else setError(reason(resp, t("fail")));
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
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
- /** Rewrite the head `<link rel="icon">` only when a custom icon is active. */
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
- if (re.test(html)) return html.replace(re, tag)
267
- return html.replace(/<head>/i, `<head>${tag}`)
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
 
@@ -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.1.0",
3
+ "version": "0.2.1",
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,8 @@
57
57
  "lib",
58
58
  "client",
59
59
  "cordis.patch.yml",
60
+ "docs/pwa-option.png",
61
+ "docs/install-dialog.png",
60
62
  "README.md",
61
63
  "README.zh.md",
62
64
  "LICENSE"