@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 +21 -0
- package/README.md +36 -0
- package/README.zh.md +36 -0
- package/client/client.js +245 -0
- package/cordis.patch.yml +4 -0
- package/lib/index.js +271 -0
- package/lib/typert.host.js +125 -0
- package/package.json +80 -0
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
|
package/client/client.js
ADDED
|
@@ -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
|
+
});
|
package/cordis.patch.yml
ADDED
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
|
+
}
|