@cowwo/dsh-icon-custom 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 cowwo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # dsh-icon-custom
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.
4
+
5
+ Zero third‑party runtime dependencies.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ dsh plugin --profile web add dsh-icon-custom
11
+ ```
12
+
13
+ Scoped name: `@cowwo/dsh-icon-custom`.
14
+
15
+ ## Usage
16
+
17
+ 1. Open **Settings → icon管理**.
18
+ 2. Click **上传图标** and pick an SVG, PNG or ICO (up to 10 MB).
19
+ 3. The current tab icon changes immediately; it is persisted under `$DSH_HOME/custom-favicon/`.
20
+ 4. Click **恢复默认** to clear the custom icon and fall back to the platform favicon.
21
+
22
+ ## Format support
23
+
24
+ - **SVG** — recognized by content (`<svg`), validated against script / event‑handler / `javascript:` injection.
25
+ - **PNG** — recognized by magic bytes (tolerates a stray leading byte some optimizers emit), structurally checked to IHDR → … → IEND.
26
+ - **ICO / CUR** — CUR is accepted as ICO.
27
+
28
+ Files are stored verbatim (never re‑encoded), so transparency and quality are preserved.
29
+
30
+ ## Persistence
31
+
32
+ Icons are stored per‑user under `$DSH_HOME/custom-favicon/`. "Reset" only clears the active marker; stored files are kept for a future icon‑library UI.
33
+
34
+ ## License
35
+
36
+ MIT
package/README.zh.md ADDED
@@ -0,0 +1,36 @@
1
+ # dsh-icon-custom
2
+
3
+ 自定义 DSH 浏览器标签页图标(favicon):在设置页上传 SVG / PNG / ICO,立即生效并持久保存,刷新或重启后都保留。
4
+
5
+ 零第三方运行时依赖。
6
+
7
+ ## 安装
8
+
9
+ ```sh
10
+ dsh plugin --profile web add dsh-icon-custom
11
+ ```
12
+
13
+ 带 scope 的包名:`@cowwo/dsh-icon-custom`。
14
+
15
+ ## 使用
16
+
17
+ 1. 打开**设置 → icon管理**。
18
+ 2. 点击**上传图标**,选择 SVG、PNG 或 ICO(最大 10 MB)。
19
+ 3. 当前标签页图标立即替换;文件会保存到 `$DSH_HOME/custom-favicon/`。
20
+ 4. 点击**恢复默认**清除自定义图标,回到平台默认图标。
21
+
22
+ ## 支持的格式
23
+
24
+ - **SVG** — 按内容(`<svg`)识别,并拦截 `<script>` / 事件属性 / `javascript:` 注入。
25
+ - **PNG** — 按魔数识别(容忍部分优化器产生的前导字节),并校验 IHDR → … → IEND 结构。
26
+ - **ICO / CUR** — CUR 当作 ICO 接受。
27
+
28
+ 图标文件**原样保存,不重新编码**,因此透明背景和画质都不会损失。
29
+
30
+ ## 持久化
31
+
32
+ 图标按用户保存在 `$DSH_HOME/custom-favicon/`。**恢复默认**只清除当前生效标记,已保存的文件会保留,为将来的图标库 UI 做准备。
33
+
34
+ ## 许可证
35
+
36
+ MIT
@@ -0,0 +1,245 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-icon-custom",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+
8
+ let React = require("react");
9
+
10
+ //#region locales
11
+ const NS = "dsh-icon-custom";
12
+ const zh = {
13
+ nav: "icon管理",
14
+ current: "当前图标",
15
+ none: "使用平台默认图标",
16
+ upload: "上传图标",
17
+ uploadHint: "支持 SVG、PNG、ICO,最大 10 MB。上传后立即生效,刷新页面也会保留。",
18
+ reset: "恢复默认",
19
+ resetHint: "清除自定义图标并回到平台默认图标。",
20
+ replace: "替换",
21
+ replacing: "上传中…",
22
+ resetting: "恢复中…",
23
+ name: "名称",
24
+ size: "大小",
25
+ updated: "更新时间",
26
+ successUpload: "已应用新图标",
27
+ successReset: "已恢复默认图标",
28
+ fail: "操作失败",
29
+ noFile: "请先选择图标文件"
30
+ };
31
+ const en = {
32
+ nav: "Favicon",
33
+ current: "Current icon",
34
+ none: "Using the platform default icon",
35
+ upload: "Upload",
36
+ uploadHint: "SVG, PNG or ICO, up to 10 MB. Applies instantly and persists across reloads.",
37
+ reset: "Reset",
38
+ resetHint: "Clear the custom icon and use the platform default.",
39
+ replace: "Replace",
40
+ replacing: "Uploading…",
41
+ resetting: "Resetting…",
42
+ name: "Name",
43
+ size: "Size",
44
+ updated: "Updated",
45
+ successUpload: "New icon applied",
46
+ successReset: "Default icon restored",
47
+ fail: "Operation failed",
48
+ noFile: "Choose a file first"
49
+ };
50
+ //#endregion
51
+
52
+ //#region helpers
53
+ function bytesLabel(value) {
54
+ if (value == null || value < 0) return "—";
55
+ if (value < 1024) return `${value} B`;
56
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
57
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`;
58
+ }
59
+ function reason(resp, fallback) {
60
+ if (resp && resp.ok === false && resp.error && resp.error.message) return resp.error.message;
61
+ return fallback;
62
+ }
63
+ function fmtTime(ms) {
64
+ if (ms == null) return "—";
65
+ try {
66
+ return new Date(ms).toLocaleString();
67
+ } catch { return "—"; }
68
+ }
69
+ //#endregion
70
+
71
+ //#region component
72
+ function FaviconSection(props) {
73
+ const t = props.t;
74
+ const rpc = props.rpc;
75
+ const [status, setStatus] = React.useState(null);
76
+ const [busy, setBusy] = React.useState(false);
77
+ const [error, setError] = React.useState("");
78
+ const [notice, setNotice] = React.useState("");
79
+ const inputRef = React.useRef(null);
80
+
81
+ const load = React.useCallback(async () => {
82
+ try {
83
+ 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")));
86
+ } catch (e) {
87
+ setError(String((e && e.message) || e || t("fail")));
88
+ }
89
+ }, [rpc, t]);
90
+
91
+ React.useEffect(() => {
92
+ load();
93
+ }, [load]);
94
+
95
+ // Update the live page icon immediately (no reload) so the change is
96
+ // visible in this very tab; other tabs pick it up on their next render.
97
+ const applyLive = React.useCallback((s) => {
98
+ if (!s || s.active !== true) return;
99
+ const link = document.querySelector('link[rel="icon"]');
100
+ const href = `/icon-custom.svg?v=${s.rev}`;
101
+ if (link) {
102
+ link.setAttribute("href", href);
103
+ link.setAttribute("type", s.mime || "image/svg+xml");
104
+ } else {
105
+ const el = document.createElement("link");
106
+ el.rel = "icon";
107
+ el.type = s.mime || "image/svg+xml";
108
+ el.href = href;
109
+ document.head.appendChild(el);
110
+ }
111
+ }, []);
112
+
113
+ // When cleared, point the icon back at the platform default.
114
+ const applyDefault = React.useCallback(() => {
115
+ const link = document.querySelector('link[rel="icon"]');
116
+ if (link) {
117
+ link.setAttribute("href", "/favicon.svg");
118
+ link.setAttribute("type", "image/svg+xml");
119
+ }
120
+ }, []);
121
+
122
+ const pick = React.useCallback(() => inputRef.current && inputRef.current.click(), []);
123
+
124
+ const onFileChange = React.useCallback(async (event) => {
125
+ const file = event.target.files && event.target.files[0];
126
+ event.target.value = "";
127
+ if (!file) return;
128
+ setError("");
129
+ setNotice("");
130
+ if (file.size > 10 * 1024 * 1024) {
131
+ setError(t("uploadHint"));
132
+ return;
133
+ }
134
+ const reader = new FileReader();
135
+ reader.onload = async () => {
136
+ const data = typeof reader.result === "string" ? reader.result : null;
137
+ if (!data) { setError(t("fail")); return; }
138
+ setBusy(true);
139
+ try {
140
+ const resp = await rpc.call("/api", "iconCustom/setIcon", {
141
+ args: { request: { mime: file.type || "image/svg+xml", name: file.name, data } }
142
+ });
143
+ if (resp && resp.ok === true) {
144
+ setStatus(resp.value);
145
+ setNotice(t("successUpload"));
146
+ applyLive(resp.value);
147
+ } else {
148
+ setError(reason(resp, t("fail")));
149
+ }
150
+ } catch (e) {
151
+ setError(String((e && e.message) || e || t("fail")));
152
+ } finally {
153
+ setBusy(false);
154
+ }
155
+ };
156
+ reader.onerror = () => setError(t("fail"));
157
+ reader.readAsDataURL(file);
158
+ }, [rpc, t, applyLive]);
159
+
160
+ const onReset = React.useCallback(async () => {
161
+ setError("");
162
+ setNotice("");
163
+ setBusy(true);
164
+ try {
165
+ const resp = await rpc.call("/api", "iconCustom/resetIcon", { args: {} });
166
+ if (resp && resp.ok === true) {
167
+ setStatus(resp.value);
168
+ setNotice(t("successReset"));
169
+ applyDefault();
170
+ } else {
171
+ setError(reason(resp, t("fail")));
172
+ }
173
+ } catch (e) {
174
+ setError(String((e && e.message) || e || t("fail")));
175
+ } finally {
176
+ setBusy(false);
177
+ }
178
+ }, [rpc, t, applyDefault]);
179
+
180
+ const active = status && status.active === true;
181
+ const previewSrc = active ? `/icon-custom.svg?v=${status.rev}` : "/favicon.svg";
182
+
183
+ const style = {
184
+ row: { display: "flex", alignItems: "center", gap: "16px", padding: "12px 0" },
185
+ meta: { flex: "1 1 auto", minWidth: "0" },
186
+ label: { fontSize: 12, fontWeight: 600, color: "var(--dsw-alias-label-primary)", marginBottom: "4px" },
187
+ desc: { fontSize: 12, color: "var(--dsw-alias-label-secondary)", lineHeight: "18px" },
188
+ preview: { width: 48, height: 48, borderRadius: 10, background: "var(--dsw-alias-bg-layer-3)", border: "1px solid var(--dsw-alias-border-l2)", display: "flex", alignItems: "center", justifyContent: "center", flex: "none", overflow: "hidden" },
189
+ img: { width: 32, height: 32, objectFit: "contain" },
190
+ btn: { border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 8, padding: "6px 12px", fontSize: 12, color: "var(--dsw-alias-label-primary)", background: "var(--dsw-alias-bg-layer-2)", cursor: "pointer" },
191
+ btnPrimary: { border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 8, padding: "6px 12px", fontSize: 12, color: "#fff", background: "var(--dsw-alias-fill-primary, #2a7de1)", cursor: "pointer" },
192
+ btnDisabled: { opacity: 0.5, cursor: "default" },
193
+ error: { fontSize: 12, color: "var(--dsw-alias-danger, #e5484d)", marginTop: "8px" },
194
+ notice: { fontSize: 12, color: "var(--dsw-alias-success, #30a46c)", marginTop: "8px" },
195
+ hint: { fontSize: 11, color: "var(--dsw-alias-label-tertiary)", marginTop: "4px", lineHeight: "16px" }
196
+ };
197
+
198
+ return React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "4px" } },
199
+ React.createElement("div", { style: style.row },
200
+ React.createElement("div", { style: style.preview },
201
+ React.createElement("img", { src: previewSrc, style: style.img, alt: t("current") })
202
+ ),
203
+ React.createElement("div", { style: style.meta },
204
+ React.createElement("div", { style: style.label }, t("current")),
205
+ React.createElement("div", { style: style.desc }, active
206
+ ? (status.name ? `${status.name} · ${bytesLabel(status.size)} · ${fmtTime(status.savedAt)}` : status.mime)
207
+ : t("none"))
208
+ )
209
+ ),
210
+ React.createElement("div", { style: { display: "flex", gap: "8px", alignItems: "center" } },
211
+ React.createElement("button", { type: "button", style: { ...style.btnPrimary, ...(busy ? style.btnDisabled : null) }, disabled: busy, onClick: pick },
212
+ React.createElement("span", null, busy ? t("replacing") : (active ? t("replace") : t("upload")))
213
+ ),
214
+ React.createElement("button", { type: "button", style: { ...style.btn, ...(busy ? style.btnDisabled : null) }, disabled: busy || !active, onClick: onReset },
215
+ React.createElement("span", null, busy ? t("resetting") : t("reset"))
216
+ ),
217
+ 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
+ ),
219
+ React.createElement("div", { style: style.hint }, t("uploadHint")),
220
+ React.createElement("div", { style: style.hint }, t("resetHint")),
221
+ notice ? React.createElement("div", { style: style.notice }, notice) : null,
222
+ error ? React.createElement("div", { style: style.error }, error) : null
223
+ );
224
+ }
225
+ //#endregion
226
+
227
+ const inject = ["slots", "locale", "connection"];
228
+ function apply(ctx) {
229
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-icon-custom: dictionaries");
230
+ const t = ctx.locale.bind(NS);
231
+ const rpc = ctx.connection.rpc;
232
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
233
+ name: "settings.section",
234
+ id: "favicon",
235
+ order: 50,
236
+ label: () => t("nav"),
237
+ locale: NS,
238
+ inject: () => ({ t })
239
+ }, (props) => React.createElement(FaviconSection, { ...props, t, rpc })));
240
+ }
241
+ exports.apply = apply;
242
+ exports.inject = inject;
243
+ return module.exports;
244
+ }
245
+ });
@@ -0,0 +1,4 @@
1
+ # dsh-icon-custom bundle patch: insert the plugin into a profile's layer stack.
2
+ - insert:
3
+ - id: icon-custom
4
+ name: 'dsh-icon-custom'
package/lib/index.js ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * dsh-icon-custom host entry: a TypertRemoteService that stores user-supplied
3
+ * favicons under $DSH_HOME/custom-favicon/ and serves the active one at
4
+ * /icon-custom.svg, rewriting the document head `<link rel="icon">` only when
5
+ * a custom icon is active (zero side effects otherwise — the browser falls back
6
+ * to the platform favicon).
7
+ *
8
+ * Storage is shaped as an icon library from day one (see docs/adr/0001):
9
+ * `library/<id>.<ext>` holds each icon's bytes and `library.json` holds the
10
+ * index + the `activeId` marker. "reset" clears only `activeId`; icon files are
11
+ * kept for a future icon-library UI. This round writes exactly one slot.
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).
18
+ */
19
+ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
20
+ import { readFileSync } from 'node:fs'
21
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
22
+ import { homedir } from 'node:os'
23
+ import { join, resolve } from 'node:path'
24
+
25
+ const MAX_BYTES = 10 * 1024 * 1024 // 10 MB
26
+ // Normalized mime -> safe extension. CUR (mouse cursor) is accepted as ICO.
27
+ const MIME_EXT = {
28
+ 'image/svg+xml': 'svg',
29
+ 'image/png': 'png',
30
+ 'image/x-icon': 'ico'
31
+ }
32
+ // PNG magic is 89 50 4e 47 0d 0a 1a 0a; we match the tail 7 bytes so a stray
33
+ // leading byte (some encoders emit 0xfd) still identifies a genuine PNG.
34
+ const PNG_MAGIC = [0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
35
+ const ICO_MAGIC = [0x00, 0x00, 0x01, 0x00]
36
+ const CUR_MAGIC = [0x00, 0x00, 0x02, 0x00]
37
+
38
+ /** Resolve the Harness home the same way `@deepseek-ai/dsh-home-paths` does. */
39
+ function resolveHome() {
40
+ const fromEnv = process.env.DSH_HOME
41
+ return resolve(fromEnv !== void 0 && fromEnv.trim().length > 0 ? fromEnv : join(homedir(), '.dsh'))
42
+ }
43
+
44
+ /** Find a byte pattern anywhere within the first `window` bytes. */
45
+ function indexOfPattern(buf, pattern, window) {
46
+ const end = Math.min(buf.length, window)
47
+ for (let i = 0; i < end; i++) {
48
+ let ok = true
49
+ for (let j = 0; j < pattern.length; j++) {
50
+ if (buf[i + j] !== pattern[j]) { ok = false; break }
51
+ }
52
+ if (ok) return i
53
+ }
54
+ return -1
55
+ }
56
+
57
+ /** Detect the real image type from content bytes, not the declared mime. */
58
+ function detectMime(buf) {
59
+ if (indexOfPattern(buf, PNG_MAGIC, 16) !== -1) return 'image/png'
60
+ if (indexOfPattern(buf, ICO_MAGIC, 16) !== -1) return 'image/x-icon'
61
+ if (indexOfPattern(buf, CUR_MAGIC, 16) !== -1) return 'image/x-icon'
62
+ const text = buf.toString('utf8').replace(/^\uFEFF/, '').replace(/^\s+/, '')
63
+ if (/^<svg[\s>]/i.test(text)) return 'image/svg+xml'
64
+ if (/^<\?xml/i.test(text) && /<svg[\s>]/i.test(text)) return 'image/svg+xml'
65
+ return null
66
+ }
67
+
68
+ /**
69
+ * Walk the PNG chunk structure from the magic offset and require it to be a
70
+ * well-formed sequence: starts with IHDR, traverses chunks by their declared
71
+ * lengths, and terminates at a real IEND chunk. This confirms the bytes decode
72
+ * as a genuine PNG (better than only checking the magic). We deliberately do not
73
+ * require an exact chunk CRC — optimizers rewrite those and browsers still
74
+ * decode such files.
75
+ */
76
+ function assertPngStructure(buf, offset) {
77
+ // `offset` is the position of the 'P' in the 7-byte tail magic. Both a
78
+ // standard 8-byte PNG (leading 0x89) and an optimizer-rewritten one (leading
79
+ // 0xfd) put the IHDR *type* at offset+11, with the chunk length at offset+7.
80
+ if (buf.length < offset + 15) throw new Error('PNG 数据不完整')
81
+ if (buf.toString('latin1', offset + 11, offset + 15) !== 'IHDR') throw new Error('PNG 缺少 IHDR 数据块')
82
+ let pos = offset + 7 // start of the first chunk's length field
83
+ let chunks = 0
84
+ let guard = 0
85
+ while (pos + 8 <= buf.length) {
86
+ if (++guard > 2048) throw new Error('PNG 结构异常')
87
+ const len = buf.readUInt32BE(pos)
88
+ const type = buf.toString('latin1', pos + 4, pos + 8)
89
+ const dataEnd = pos + 8 + len
90
+ if (dataEnd + 4 > buf.length) throw new Error('PNG 数据不完整')
91
+ if (chunks === 0 && type !== 'IHDR') throw new Error('PNG 缺少 IHDR 数据块')
92
+ if (type === 'IEND') return
93
+ pos = dataEnd + 4
94
+ chunks++
95
+ }
96
+ throw new Error('PNG 缺少 IEND 结尾块')
97
+ }
98
+
99
+ /** Reject SVG content that can act on its own (scripts / event handlers). */
100
+ function assertSafeSvg(buf) {
101
+ const text = buf.toString('utf8')
102
+ if (/(<\s*script\b)/i.test(text)) throw new Error('SVG 不能包含 <script>')
103
+ if (/\bon[a-z]+\s*=/i.test(text)) throw new Error('SVG 不能包含事件属性(如 onload)')
104
+ if (/javascript\s*:/i.test(text)) throw new Error('SVG 不能包含 javascript: 链接')
105
+ }
106
+
107
+ /** Validate raw bytes; returns the detected mime or throws. */
108
+ function sniff(buf) {
109
+ if (buf.length === 0) throw new Error('图标内容为空')
110
+ if (buf.length > MAX_BYTES) throw new Error(`图标不能超过 ${MAX_BYTES / (1024 * 1024)} MB`)
111
+ const mime = detectMime(buf)
112
+ if (mime === null) throw new Error('无法识别图标内容,仅支持 SVG / PNG / ICO')
113
+ if (mime === 'image/png') {
114
+ const offset = indexOfPattern(buf, PNG_MAGIC, 16)
115
+ assertPngStructure(buf, offset)
116
+ }
117
+ if (mime === 'image/svg+xml') assertSafeSvg(buf)
118
+ return mime
119
+ }
120
+
121
+ /** Extract the base64 body from a `data:<mime>;base64,<body>` string. */
122
+ function base64Body(dataUrl) {
123
+ const m = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl || '')
124
+ if (!m) throw new Error('图标数据必须是 data URL(base64)')
125
+ const body = m[2].replace(/\s+/g, '')
126
+ if (body.length === 0) throw new Error('图标数据为空')
127
+ // Binary-safe: Node's Buffer decodes base64 to exact bytes (never lossy).
128
+ return { bytes: Buffer.from(body, 'base64') }
129
+ }
130
+
131
+ /**
132
+ * 图标替换服务:保存/读取用户上传的 favicon,并通过 webServer 提供
133
+ * `/icon-custom.svg` 路由与 index 头部改写。通过 typert
134
+ * `iconCustom/*` 端点暴露给浏览器端。
135
+ */
136
+ export class FaviconCustomService extends TypertRemoteService {
137
+ static inject = ['webServer']
138
+
139
+ constructor(ctx, config) {
140
+ super(ctx, 'iconCustom')
141
+ this.storageDir = join(resolveHome(), 'custom-favicon')
142
+ this.libraryDir = join(this.storageDir, 'library')
143
+ this.indexPath = join(this.storageDir, 'library.json')
144
+ // { activeId, icons: { [id]: { id, mime, ext, name, size, savedAt, rev } } }
145
+ this.library = { activeId: null, icons: {} }
146
+ // The active icon's loaded state: { rev, mime, ext, name, size, savedAt, bytes } | null
147
+ this.current = null
148
+ this._load()
149
+ ctx.effect(() => ctx.webServer.register({
150
+ kind: 'exact',
151
+ path: '/icon-custom.svg',
152
+ handler: (request, response) => this._handleIcon(request, response)
153
+ }), 'dsh-icon-custom: /icon-custom.svg route')
154
+ ctx.effect(() => ctx.webServer.tapIndex((html) => this._rewriteIndex(html)),
155
+ 'dsh-icon-custom: index tap')
156
+ }
157
+
158
+ /** Load persisted library + active icon from disk at startup. */
159
+ _load() {
160
+ try {
161
+ const raw = readFileSync(this.indexPath)
162
+ const lib = JSON.parse(raw.toString('utf8'))
163
+ if (typeof lib !== 'object' || lib === null) return
164
+ const icons = (typeof lib.icons === 'object' && lib.icons !== null) ? lib.icons : {}
165
+ this.library = { activeId: typeof lib.activeId === 'string' ? lib.activeId : null, icons }
166
+ const activeId = this.library.activeId
167
+ if (activeId === null) return
168
+ const rec = icons[activeId]
169
+ if (rec === null || typeof rec !== 'object') { this.library.activeId = null; return }
170
+ const bytes = readFileSync(join(this.libraryDir, `${activeId}.${rec.ext}`))
171
+ this.current = { ...rec, bytes }
172
+ } catch { /* no persisted icon yet */ }
173
+ }
174
+
175
+ /** Persist the library index to disk (does not touch icon files). */
176
+ _saveLibrary() {
177
+ return writeFile(this.indexPath, JSON.stringify(this.library, null, 2))
178
+ }
179
+
180
+ /** Currently active icon as a JSON-safe status object (no raw bytes). */
181
+ status() {
182
+ const c = this.current
183
+ 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 }
186
+ }
187
+
188
+ /** typert endpoint: read current status. */
189
+ async getStatus() {
190
+ return this.status()
191
+ }
192
+
193
+ /** typert endpoint: store a new icon uploaded as a data URL and make it active. */
194
+ async setIcon(request) {
195
+ if (request === null || typeof request !== 'object') throw new Error('缺少图标参数')
196
+ const { name, data } = request
197
+ if (typeof data !== 'string' || data.length === 0) throw new Error('缺少图标数据')
198
+ const { bytes } = base64Body(data)
199
+ const mime = sniff(bytes)
200
+ const ext = MIME_EXT[mime]
201
+ const now = Date.now()
202
+ const id = now.toString(36) + Math.random().toString(36).slice(2, 6)
203
+ const rev = now.toString(36)
204
+ await mkdir(this.libraryDir, { recursive: true })
205
+ // Atomic-ish write: temp file then rename, so a crash never leaves a
206
+ // half-written icon that would 500 the /icon-custom.svg route.
207
+ const tmp = join(this.libraryDir, `.${id}.${ext}.tmp`)
208
+ await writeFile(tmp, bytes)
209
+ await rename(tmp, join(this.libraryDir, `${id}.${ext}`))
210
+ const rec = {
211
+ id,
212
+ mime, ext,
213
+ name: typeof name === 'string' && name.trim().length > 0 ? name.trim().slice(0, 128) : null,
214
+ size: bytes.length, savedAt: now, rev
215
+ }
216
+ this.library.icons[id] = rec
217
+ this.library.activeId = id
218
+ await this._saveLibrary().catch(() => {}) // index is advisory; icon file is authoritative
219
+ this.current = { ...rec, bytes }
220
+ return this.status()
221
+ }
222
+
223
+ /** typert endpoint: clear the active marker so the platform icon is used.
224
+ * Icon files stay on disk for the future icon-library UI. */
225
+ async resetIcon() {
226
+ this.library.activeId = null
227
+ await this._saveLibrary().catch(() => {})
228
+ this.current = null
229
+ return this.status()
230
+ }
231
+
232
+ /** Serve the current custom icon (or 404 when unset). */
233
+ async _handleIcon(request, response) {
234
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
235
+ response.writeHead(405, { allow: 'GET, HEAD' })
236
+ response.end()
237
+ return
238
+ }
239
+ const c = this.current
240
+ if (c === null) {
241
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
242
+ response.end('no custom favicon')
243
+ return
244
+ }
245
+ response.writeHead(200, {
246
+ 'content-type': c.mime,
247
+ 'content-length': c.bytes.length,
248
+ // URL carries ?v=<rev>, so this is effectively content-addressed.
249
+ 'cache-control': 'public, max-age=31536000, immutable',
250
+ 'x-content-type-options': 'nosniff'
251
+ })
252
+ if (request.method === 'HEAD') {
253
+ response.end()
254
+ return
255
+ }
256
+ response.end(c.bytes)
257
+ }
258
+
259
+ /** Rewrite the head `<link rel="icon">` only when a custom icon is active. */
260
+ _rewriteIndex(html) {
261
+ const c = this.current
262
+ if (c === null) return html
263
+ const href = `/icon-custom.svg?v=${c.rev}`
264
+ const tag = `<link rel="icon" type="${c.mime}" href="${href}" />`
265
+ 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}`)
268
+ }
269
+ }
270
+
271
+ export default FaviconCustomService
@@ -0,0 +1,125 @@
1
+ /* Hand-written typert host manifest for dsh-icon-custom (strict face). */
2
+ import { z } from 'zod'
3
+
4
+ const statusSchema = z.object({
5
+ active: z.boolean(),
6
+ rev: z.string().nullable(),
7
+ mime: z.string().nullable(),
8
+ ext: z.string().nullable(),
9
+ name: z.string().nullable(),
10
+ size: z.number().int().nonnegative().nullable(),
11
+ savedAt: z.number().int().nonnegative().nullable()
12
+ })
13
+
14
+ const setIconRequestSchema = z.object({
15
+ mime: z.string().min(1),
16
+ name: z.string().max(128).optional(),
17
+ data: z.string().min(1)
18
+ })
19
+
20
+ const setIconResultSchema = statusSchema
21
+
22
+ export const TYPERT = {
23
+ package: 'dsh-icon-custom',
24
+ face: 'host',
25
+ schemas: [],
26
+ invocations: [
27
+ {
28
+ id: 'dsh-icon-custom#iconCustom/getStatus',
29
+ service: 'iconCustom',
30
+ namespace: 'iconCustom',
31
+ method: 'getStatus',
32
+ invocation: { kind: 'direct' },
33
+ parameters: [],
34
+ result: {
35
+ mode: 'strict',
36
+ typeSymbol: 'dsh-icon-custom#iconCustom/getStatus:result',
37
+ schema: statusSchema
38
+ },
39
+ sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
40
+ },
41
+ {
42
+ id: 'dsh-icon-custom#iconCustom/setIcon',
43
+ service: 'iconCustom',
44
+ namespace: 'iconCustom',
45
+ method: 'setIcon',
46
+ invocation: { kind: 'direct' },
47
+ parameters: [
48
+ {
49
+ name: 'request',
50
+ wire: 'request',
51
+ source: 'json',
52
+ codec: {
53
+ mode: 'strict',
54
+ typeSymbol: 'dsh-icon-custom#iconCustom/setIcon:request',
55
+ schema: setIconRequestSchema
56
+ }
57
+ }
58
+ ],
59
+ result: {
60
+ mode: 'strict',
61
+ typeSymbol: 'dsh-icon-custom#iconCustom/setIcon:result',
62
+ schema: setIconResultSchema
63
+ },
64
+ sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
65
+ },
66
+ {
67
+ id: 'dsh-icon-custom#iconCustom/resetIcon',
68
+ service: 'iconCustom',
69
+ namespace: 'iconCustom',
70
+ method: 'resetIcon',
71
+ invocation: { kind: 'direct' },
72
+ parameters: [],
73
+ result: {
74
+ mode: 'strict',
75
+ typeSymbol: 'dsh-icon-custom#iconCustom/resetIcon:result',
76
+ schema: statusSchema
77
+ },
78
+ sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
79
+ }
80
+ ],
81
+ model: {
82
+ services: [
83
+ {
84
+ description: "Stores a user-supplied favicon and serves it at /icon-custom.svg, rewriting the document-head icon link only when a custom icon is set.",
85
+ summary: "Custom browser tab favicon.",
86
+ jsDoc: "/** Custom browser tab favicon. */",
87
+ tags: [],
88
+ key: 'iconCustom',
89
+ exportName: 'FaviconCustomService',
90
+ members: [
91
+ {
92
+ kind: 'method',
93
+ 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 }>',
95
+ description: "Read the current favicon status.",
96
+ summary: "Read the current favicon status.",
97
+ jsDoc: "/** Read the current favicon status. */",
98
+ tags: []
99
+ },
100
+ {
101
+ kind: 'method',
102
+ 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 }>',
104
+ description: "Store a new favicon uploaded as a data URL.",
105
+ summary: "Store a new favicon.",
106
+ jsDoc: "/** Store a new favicon. */",
107
+ tags: []
108
+ },
109
+ {
110
+ kind: 'method',
111
+ 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 }>',
113
+ description: "Clear the active custom favicon marker so the platform icon is used; stored icon files are kept.",
114
+ summary: "Clear the custom favicon.",
115
+ jsDoc: "/** Clear the custom favicon. */",
116
+ tags: []
117
+ }
118
+ ],
119
+ types: []
120
+ }
121
+ ],
122
+ events: [],
123
+ objects: []
124
+ }
125
+ }
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@cowwo/dsh-icon-custom",
3
+ "version": "0.1.0",
4
+ "description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "license": "MIT",
8
+ "dsh": {
9
+ "bundle": {
10
+ "patch": "./cordis.patch.yml"
11
+ },
12
+ "client": {
13
+ "inject": [
14
+ "@deepseek-ai/dsh-client-connection",
15
+ "@deepseek-ai/dsh-client-runtime",
16
+ "@deepseek-ai/dsh-client-locale"
17
+ ],
18
+ "platform": "web"
19
+ }
20
+ },
21
+ "exports": {
22
+ ".": "./lib/index.js",
23
+ "./client": "./client/client.js",
24
+ "./cordis.patch.yml": "./cordis.patch.yml",
25
+ "./package.json": "./package.json",
26
+ "./typert": "./lib/typert.host.js"
27
+ },
28
+ "peerDependencies": {
29
+ "@deepseek-ai/cordis": "*",
30
+ "@deepseek-ai/dsh-client-connection": "*",
31
+ "@deepseek-ai/dsh-client-locale": "*",
32
+ "@deepseek-ai/dsh-client-runtime": "*",
33
+ "@deepseek-ai/dsh-typert-protocol": "*",
34
+ "zod": "*"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "@deepseek-ai/cordis": {
38
+ "optional": true
39
+ },
40
+ "@deepseek-ai/dsh-client-connection": {
41
+ "optional": true
42
+ },
43
+ "@deepseek-ai/dsh-client-locale": {
44
+ "optional": true
45
+ },
46
+ "@deepseek-ai/dsh-client-runtime": {
47
+ "optional": true
48
+ },
49
+ "@deepseek-ai/dsh-typert-protocol": {
50
+ "optional": true
51
+ },
52
+ "zod": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "files": [
57
+ "lib",
58
+ "client",
59
+ "cordis.patch.yml",
60
+ "README.md",
61
+ "README.zh.md",
62
+ "LICENSE"
63
+ ],
64
+ "keywords": [
65
+ "dsh",
66
+ "dsh-plugin",
67
+ "deepseek-harness",
68
+ "favicon",
69
+ "icon"
70
+ ],
71
+ "repository": {
72
+ "type": "git",
73
+ "url": "git+https://github.com/cowwo/dsh-icon-custom.git"
74
+ },
75
+ "homepage": "https://github.com/cowwo/dsh-icon-custom#readme",
76
+ "bugs": {
77
+ "url": "https://github.com/cowwo/dsh-icon-custom/issues"
78
+ },
79
+ "author": "cowwo <cowwo@users.noreply.github.com>"
80
+ }