dsh-client-ui-skin-tianlight 2.0.3 → 2.0.4

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
@@ -6,6 +6,10 @@
6
6
  >
7
7
  > npm 包名 `dsh-client-ui-skin-tianlight`(v2.0.2 起;旧链接安装包名 `@dsh-external/dsh-client-ui-skin-tianlight`)· 布线行 `ui-skin-tianlight` · body 属性 `data-dsh-tianlight`
8
8
 
9
+ ## v2.0.4 · 安全修复版(建议升级)
10
+
11
+ 修复了 v2.0.3 验收中发现的问题,其中最重要的一条:Wallpaper Engine 网页壁纸的页面以前会以 DSH 源的身份运行,现在一律沙箱化。2.0.2 和 2.0.3 都受影响,请升级:先完全退出 DSH,再运行 `npx dsh-client-ui-skin-tianlight@latest`。另外 `npx` 不再顺带下载整套 DSH 依赖,安装快很多。详见 [RELEASE-NOTES-v2.0.4.md](RELEASE-NOTES-v2.0.4.md)。
12
+
9
13
  ## v2.0.3 · 一条命令安装
10
14
 
11
15
  ```bash
package/bin/cli.mjs CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  pruneBackups,
29
29
  readJson,
30
30
  removeInstalledFiles,
31
+ isUncPath,
31
32
  resolveDshHome,
32
33
  scanPatches,
33
34
  writeJsonAtomic,
@@ -59,6 +60,7 @@ const M = {
59
60
  home: (p) => `DSH 数据目录:${p}`,
60
61
  noHome: (p) => `找不到 DSH 数据目录 ${p}\n 请先安装并启动一次 DeepSeek Harness;数据目录不在默认位置时用 --dsh-home <目录> 指定。`,
61
62
  noProfiles: '这个 DSH 数据目录里还没有 profile。请先启动一次 DSH 再运行本命令。',
63
+ unc: '不支持网络路径(\\\\服务器\\共享)作为 DSH 数据目录,请使用本机磁盘上的目录。',
62
64
  profileMissing: (p, list) => `没有名为 "${p}" 的 profile。现有:${list.join('、') || '(无)'}`,
63
65
  profileAmbiguous: (list) => `有多个 profile:${list.join('、')}\n 请用 --profile <名称> 指定要装到哪一个。`,
64
66
  profile: (p) => `目标 profile:${p}`,
@@ -120,6 +122,7 @@ const M = {
120
122
  home: (p) => `DSH home: ${p}`,
121
123
  noHome: (p) => `DSH home not found: ${p}\n Install and start DeepSeek Harness once first, or pass --dsh-home <dir>.`,
122
124
  noProfiles: 'This DSH home has no profile yet. Start DSH once, then run this command again.',
125
+ unc: 'Network paths (\\\\server\\share) are not supported as the DSH home; use a local directory.',
123
126
  profileMissing: (p, list) => `No profile named "${p}". Existing: ${list.join(', ') || '(none)'}`,
124
127
  profileAmbiguous: (list) => `Several profiles exist: ${list.join(', ')}\n Pick one with --profile <name>.`,
125
128
  profile: (p) => `Target profile: ${p}`,
@@ -186,7 +189,10 @@ const die = (line, code = 1) => {
186
189
 
187
190
  // ---- shared steps -----------------------------------------------------------
188
191
  function locate() {
192
+ const requestedHome = value('--dsh-home') ?? process.env.DSH_HOME ?? ''
193
+ if (isUncPath(requestedHome)) die(M.unc)
189
194
  const dshHome = resolveDshHome({ dshHome: value('--dsh-home') })
195
+ if (isUncPath(dshHome)) die(M.unc)
190
196
  say(M.home(dshHome))
191
197
  if (existsSync(dshHome) === false) die(M.noHome(dshHome))
192
198
  const profiles = listProfiles(dshHome)
package/bin/installer.mjs CHANGED
@@ -59,6 +59,11 @@ export function writeJsonAtomic(file, value) {
59
59
 
60
60
  // ---- locating DSH -----------------------------------------------------------
61
61
 
62
+ /** `\\server\share` / `//server/share` (and `\\?\UNC\...`): network locations are refused. */
63
+ export function isUncPath(value) {
64
+ return typeof value === 'string' && /^(?:\\\\|\/\/)/.test(value.trim())
65
+ }
66
+
62
67
  export function resolveDshHome({ dshHome = null, env = process.env, home = homedir() } = {}) {
63
68
  if (typeof dshHome === 'string' && dshHome.trim() !== '') return resolve(dshHome.trim())
64
69
  if (typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim() !== '') return resolve(env.DSH_HOME.trim())
@@ -401,37 +406,57 @@ export function pruneBackups(profileDir, keep = KEEP_BACKUPS) {
401
406
  * backup folder (a link is just unlinked), then swap the staged copy in. If the swap
402
407
  * fails the previous copy is put back.
403
408
  */
409
+ export const STAGING_PREFIX = `.${PKG_NAME}.tl-staging-`
410
+
411
+ /** v2.0.4 (L1): staging copies left behind by an interrupted/failed earlier run. */
412
+ export function cleanStaleStaging(modulesDir) {
413
+ const removed = []
414
+ if (existsSync(modulesDir) === false) return removed
415
+ for (const name of readdirSync(modulesDir)) {
416
+ if (name.startsWith(STAGING_PREFIX) === false) continue
417
+ safeRemoveTree(join(modulesDir, name))
418
+ removed.push(name)
419
+ }
420
+ return removed
421
+ }
422
+
404
423
  export function installFiles({ srcRoot = SELF_ROOT, profileDir, backupDir, entries = packageEntries(srcRoot) }) {
405
424
  const modules = join(profileDir, 'node_modules')
406
425
  mkdirSync(modules, { recursive: true })
426
+ cleanStaleStaging(modules)
407
427
  const target = join(modules, PKG_NAME)
408
- const staging = join(modules, `.${PKG_NAME}.tl-staging-${process.pid}`)
409
- rmSync(staging, { recursive: true, force: true })
410
- mkdirSync(staging, { recursive: true })
411
- for (const entry of entries) cpSync(join(srcRoot, entry), join(staging, entry), { recursive: true })
412
- const stagedManifestPath = join(staging, 'package.json')
413
- const stagedManifest = readJson(stagedManifestPath)
414
- if (stagedManifest.private !== undefined) {
415
- delete stagedManifest.private
416
- writeFileSync(stagedManifestPath, JSON.stringify(stagedManifest, null, 2) + '\n', 'utf8')
417
- }
418
-
419
- const before = inspectInstalled(profileDir)
428
+ const staging = join(modules, `${STAGING_PREFIX}${process.pid}`)
420
429
  let parked = null
421
- if (before.kind === 'link') {
422
- removeLink(target)
423
- } else if (before.kind === 'dir') {
424
- parked = join(backupDir, 'previous-package')
425
- renameSync(target, parked)
426
- }
430
+ let swapped = false
427
431
  try {
428
- renameSync(staging, target)
429
- } catch (error) {
430
- if (parked !== null && existsSync(target) === false) renameSync(parked, target)
431
- rmSync(staging, { recursive: true, force: true })
432
- throw error
432
+ mkdirSync(staging, { recursive: true })
433
+ for (const entry of entries) cpSync(join(srcRoot, entry), join(staging, entry), { recursive: true })
434
+ const stagedManifestPath = join(staging, 'package.json')
435
+ const stagedManifest = readJson(stagedManifestPath)
436
+ if (stagedManifest.private !== undefined) {
437
+ delete stagedManifest.private
438
+ writeFileSync(stagedManifestPath, JSON.stringify(stagedManifest, null, 2) + '\n', 'utf8')
439
+ }
440
+ const before = inspectInstalled(profileDir)
441
+ if (before.kind === 'link') {
442
+ removeLink(target)
443
+ } else if (before.kind === 'dir') {
444
+ parked = join(backupDir, 'previous-package')
445
+ renameSync(target, parked)
446
+ }
447
+ try {
448
+ renameSync(staging, target)
449
+ swapped = true
450
+ } catch (error) {
451
+ if (parked !== null && existsSync(target) === false) renameSync(parked, target)
452
+ throw error
453
+ }
454
+ return { previous: before, parked }
455
+ } finally {
456
+ // v2.0.4 (L1): whatever failed — copy, parking (EPERM on a locked file), swap —
457
+ // never leave the ~37 MB staging copy behind.
458
+ if (swapped === false) safeRemoveTree(staging)
433
459
  }
434
- return { previous: before, parked }
435
460
  }
436
461
 
437
462
  export function removeInstalledFiles({ profileDir, backupDir }) {
package/build-info.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "product": "dsh-client-ui-skin-tianlight",
4
- "version": "2.0.3",
5
- "commit": "e257c5800dbf9dafeba84093f8c57a292ce0eb9c",
6
- "builtAt": "2026-11-12T23:55:42+08:00",
4
+ "version": "2.0.4",
5
+ "commit": "37c4db1b350a47b6794262bbe246a259710177db",
6
+ "builtAt": "2026-11-13T17:10:57+08:00",
7
7
  "source": "pack"
8
8
  }
@@ -0,0 +1,161 @@
1
+ # 第三方组件与许可
2
+
3
+ 天光皮肤本体是 MIT(见根目录 `LICENSE`)。本文件列出随仓库分发(或作为实现依据)的第三方组件、
4
+ 版本、许可与归属;发布包(`node tools/pack.mjs`)会带上 `lib/we-wallpaper/`、`vendor/we-wallpaper/`、
5
+ `vendor/webwallgl/` 及各自的许可文件。
6
+
7
+ ## 1. dsh-we-wallpaper 宿主半(MIT)
8
+
9
+ v0.6.0 的 Wallpaper Engine 库集成把上游 **dsh-we-wallpaper** 的宿主半(MIT)**以源码形式内嵌**,
10
+ 使皮肤能在**不填写任何目录**的前提下浏览本机 Wallpaper Engine 库。
11
+
12
+ | 项 | 值 |
13
+ |---|---|
14
+ | 上游 | <https://github.com/pbadgpmeb22791-sketch/dsh-we-wallpaper> |
15
+ | 版本 / 提交 | `v0.4.0` @ `61ccaa2`(把 v0.4.0 提升到仓库根的 restructure commit) |
16
+ | 归属 | Copyright (c) 2026 **dsh-we-wallpaper contributors** |
17
+ | 内嵌文件 | `lib/we-wallpaper/host.mjs`(单 ESM,仅 Node 内置模块;上游 `lib/index.js`,由上游 TS 源码构建) |
18
+ | 许可全文 | [`lib/we-wallpaper/LICENSE`](../lib/we-wallpaper/LICENSE) |
19
+ | 修改说明 | [`lib/we-wallpaper/README.md`](../lib/we-wallpaper/README.md)(grep `TIANLIGHT` 可定位全部改动) |
20
+
21
+ 保留:无框架扫描器、无依赖 `.pkg`/TEX 解码器、HTTP 路由族。
22
+ 未内嵌:上游 cordis/settings 插件接线与上游客户端半(由本皮肤自己的客户端渲染列表并播放壁纸)。
23
+
24
+ 对本仓库的修改(摘要,详细见上表 README):去掉插件接线改为纯函数导出;同源校验放宽到 DSH Desktop
25
+ webview 与 `127.0.0.1/localhost`(跨站仍 403);运行状态/缓存从 `~/.dsh` 移到插件 gitignored `state/`
26
+ (`TIANLIGHT_WE_HOME` 可覆盖);wcap 路径指向 `vendor/we-wallpaper/wcap/`;本地 project 的 `.pkg` 也支持解出;
27
+ 场景动态缓存 503 语义明确;扫描器结果映射为本皮肤的 `_wallpapers`/`_engines` 形状。
28
+
29
+ ```text
30
+ MIT License
31
+
32
+ Copyright (c) 2026 dsh-we-wallpaper contributors
33
+
34
+ Permission is hereby granted, free of charge, to any person obtaining a copy
35
+ of this software and associated documentation files (the "Software"), to deal
36
+ in the Software without restriction, including without limitation the rights
37
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
38
+ copies of the Software, and to permit persons to whom the Software is
39
+ furnished to do so, subject to the following conditions:
40
+
41
+ The above copyright notice and this permission notice shall be included in all
42
+ copies or substantial portions of the Software.
43
+
44
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
45
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
46
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
47
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
48
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
49
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
50
+ SOFTWARE.
51
+ ```
52
+
53
+ ## 2. wcap(Unlicense / 公有领域)
54
+
55
+ 可选的「场景动态缓存」(Windows-only)使用上游 dsh-we-wallpaper 随附的窗口捕获工具 `wcap`:
56
+
57
+ | 文件 | 来源 | 许可 |
58
+ |---|---|---|
59
+ | `vendor/we-wallpaper/wcap/wcap-x64.exe` | 上游 `tools/wcap/wcap-x64.exe` | Unlicense(public domain) |
60
+ | `vendor/we-wallpaper/wcap/scene-capture-desktop.ps1` | 上游 `tools/wcap/` | Unlicense |
61
+ | `vendor/we-wallpaper/wcap/send-window-capture-hotkey.ps1` | 上游 `tools/wcap/` | Unlicense |
62
+ | 许可全文 | 上游 `tools/wcap/LICENSE`,逐字内嵌 | [`vendor/we-wallpaper/wcap/LICENSE`](../vendor/we-wallpaper/wcap/LICENSE) |
63
+
64
+ ```text
65
+ This is free and unencumbered software released into the public domain.
66
+
67
+ Anyone is free to copy, modify, publish, use, compile, sell, or distribute
68
+ this software, either in source code form or as a compiled binary, for any
69
+ purpose, commercial or non-commercial, and by any means.
70
+
71
+ In jurisdictions that recognize copyright laws, the author or authors of
72
+ this software dedicate any and all copyright interest in the software to the
73
+ public domain. We make this dedication for the benefit of the public at large
74
+ and to the detriment of our heirs and successors. We intend this dedication
75
+ to be an overt act of relinquishment in perpetuity of all present and future
76
+ rights to this software under copyright law.
77
+
78
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
79
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
80
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
81
+ AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
82
+ ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
83
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
84
+
85
+ For more information, please refer to <https://unlicense.org>.
86
+ ```
87
+
88
+ > `wcap` 只在用户显式请求「生成动态」时被 `POST /api/we-wallpaper/scene-video/generate/<id>` 调用;
89
+ > 仓库测试套件用 `TIANLIGHT_WE_DISABLE_SCENE_VIDEO=1` 跳过它(不会在维护者桌面弹窗)。
90
+
91
+ ## 3. RePKG(MIT,DXT 解码的实现依据)
92
+
93
+ `lib/we-wallpaper/host.mjs` 的 `pkg-tex` 解包/解码器在格式实现上参考并移植了 **RePKG** 的 DXT 解码
94
+ (LibSquish 派生的 C# 实现);上游 dsh-we-wallpaper 同样以此为实现依据。
95
+
96
+ | 项 | 值 |
97
+ |---|---|
98
+ | 项目 | <https://github.com/notscuffed/repkg> |
99
+ | 归属 | Copyright (c) **Xalcon @ mmowned.com**(见上游文件头) |
100
+ | 许可 | MIT |
101
+ | 使用方式 | 格式/算法参考与 DXT 解码移植;未整份复制其 C# 实现 |
102
+
103
+ RePKG 的 MIT 许可全文(v2.0.4 起逐字附在这里,随 npm 包分发):
104
+
105
+ ```
106
+ MIT License
107
+
108
+ Copyright (c) Xalcon @ mmowned.com
109
+
110
+ Permission is hereby granted, free of charge, to any person obtaining a copy
111
+ of this software and associated documentation files (the "Software"), to deal
112
+ in the Software without restriction, including without limitation the rights
113
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
114
+ copies of the Software, and to permit persons to whom the Software is
115
+ furnished to do so, subject to the following conditions:
116
+
117
+ The above copyright notice and this permission notice shall be included in all
118
+ copies or substantial portions of the Software.
119
+
120
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
121
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
122
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
123
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
124
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
125
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
126
+ SOFTWARE.
127
+ ```
128
+
129
+ ## 4. WebWallGL(MIT)
130
+
131
+ Wallpaper Engine 场景通道使用仓库内 `vendor/webwallgl/` 的 WebWallGL 3.0(可选依赖,不内联进
132
+ `lib/client.js`):
133
+
134
+ | 项 | 值 |
135
+ |---|---|
136
+ | 位置 | `vendor/webwallgl/webwallgl.global.min.js`(+ `README.upstream.md`) |
137
+ | 归属 | Copyright (c) 2026 **oneincase** `<462534624@qq.com>` |
138
+ | 许可 | MIT;全文见 [`vendor/webwallgl/LICENSE`](../vendor/webwallgl/LICENSE) |
139
+
140
+ ## 4.1 二进制与压缩代码(v2.0.4 起公布校验值)
141
+
142
+ 下面两个文件不是从本仓库源码构建的,包内无法审计其源码,只能按来源与校验值核对。`wcap-x64.exe` 未做代码签名,且只在用户显式点击「生成动态」时才会运行。
143
+
144
+ | 文件 | SHA-256 | 来源 |
145
+ |---|---|---|
146
+ | `vendor/we-wallpaper/wcap/wcap-x64.exe` | `4bd124febf537f0f569bd5cb7486ddbb9e664b509f39e421e6949767829b6be6` | 上游 dsh-we-wallpaper `tools/wcap/wcap-x64.exe`,原样复制 |
147
+ | `vendor/webwallgl/webwallgl.global.min.js` | `2f86ab955f3377e2fa2cfd5ffa72f5813fe8f6e4a272295fed36f4c1e626b1e5` | WebWallGL 3.0 发布的压缩构建,原样复制(见 `vendor/webwallgl/README.upstream.md`) |
148
+
149
+ `tools/test.mjs` 会断言这两个校验值,文件被替换时测试会变红。
150
+
151
+ ## 5. 默认演示素材
152
+
153
+ 仓库完整版附带的 `assets/bg-4k.mp4` / `assets/cat.png` 是演示素材,
154
+ **不在 MIT 代码许可范围内**:桌宠形象与视频版权归各自权利方,替换或再分发前请确认你有使用权;
155
+ 发布包默认**包含**这两个文件(`node tools/pack.mjs` 的随包演示素材白名单,Release 附件同源;
156
+ 需要纯代码包时用 `node tools/pack.mjs --code-only`,此时只带代码、清单、`lib/`、`vendor/`、
157
+ `presets/` 与 `assets/README.md`)。
158
+
159
+ ---
160
+
161
+ *本文件随 v0.6.0 新增;若后续再内嵌第三方代码,请在同一 PR 里补行并附许可全文。*
package/lib/index.js CHANGED
@@ -73,7 +73,7 @@ const HOST_API = '2'
73
73
  * manifest is stamped with it. The handshake no longer compares it, so a
74
74
  * browser-half-only update can run against an unchanged host process.
75
75
  */
76
- const BUILD = '2.0.3'
76
+ const BUILD = '2.0.4'
77
77
 
78
78
  function readRoots() {
79
79
  try {
@@ -5,7 +5,7 @@
5
5
  // list is exactly how "the upload accepted a format the player cannot decode"
6
6
  // bugs happen. One table, imported by every route.
7
7
 
8
- import { statSync } from 'node:fs'
8
+ import { realpathSync, statSync } from 'node:fs'
9
9
  import { dirname, resolve, sep } from 'node:path'
10
10
  import { fileURLToPath } from 'node:url'
11
11
 
@@ -198,7 +198,17 @@ export function isSafeSvgBuffer(buffer) {
198
198
  if (SVG_NAMED_ENTITIES.has(entity[1]) === false) return { ok: false, reason: 'named entity' }
199
199
  entity = entityPattern.exec(text)
200
200
  }
201
- const tagPattern = /<\s*\/?\s*([A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)([^>]*?)>/g
201
+ // v2.0.4 (M1): a raw `>` inside a quoted attribute value (`alt=">"`) used to end
202
+ // the tag early so the attributes after it were never inspected. Two layers now:
203
+ // a document-wide raw scan that does not depend on where a tag ends, and a tag
204
+ // scanner whose attribute part consumes whole quoted strings.
205
+ if (/[\s/"']on[a-z]+\s*=/i.test(text)) return { ok: false, reason: 'inline event handler' }
206
+ for (const denied of SVG_DENY_ELEMENTS) {
207
+ if (new RegExp('<\\s*(?:[A-Za-z_][\\w.-]*:)?' + denied + '(?![\\w.:-])', 'i').test(text)) {
208
+ return { ok: false, reason: 'dangerous element: ' + denied }
209
+ }
210
+ }
211
+ const tagPattern = /<\s*\/?\s*([A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)>/g
202
212
  let firstLocalName = ''
203
213
  let match = tagPattern.exec(text)
204
214
  while (match !== null) {
@@ -250,16 +260,43 @@ export function isSafeSvgBuffer(buffer) {
250
260
  }
251
261
 
252
262
  /**
253
- * Resolves `name` inside `root` and proves the result stays inside it. Symlinks and
254
- * `..` are both defeated by comparing the RESOLVED path against the resolved root
255
- * with a trailing separator, which is the only containment test that cannot be
256
- * fooled by a crafted filename.
263
+ * v2.0.4 (M2): true when the REAL path of `target` (symlinks and Windows junctions
264
+ * resolved) is `root` itself or inside the real path of `root`. Lexical checks
265
+ * (`resolve()` + startsWith) cannot see links; this one can. A path that cannot be
266
+ * resolved (missing, permission) is reported as outside — callers fail closed.
267
+ */
268
+ export function realPathInside(root, target) {
269
+ try {
270
+ const realRoot = realpathSync.native(root)
271
+ const realTarget = realpathSync.native(target)
272
+ const fold = (value) => (process.platform === 'win32' ? value.toLowerCase() : value)
273
+ const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep
274
+ return fold(realTarget) === fold(realRoot) || fold(realTarget).startsWith(fold(prefix))
275
+ } catch {
276
+ return false
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Resolves `name` inside `root` and proves the result stays inside it: `..` and
282
+ * separators are refused by SAFE_NAME plus a lexical prefix test, and an existing
283
+ * entry must also resolve (through any symlink/junction) to a real path inside the
284
+ * real root (v2.0.4; before that the comment claimed this but the code did not).
257
285
  */
258
286
  export function safeJoin(root, name) {
259
287
  if (typeof name !== 'string' || SAFE_NAME.test(name) === false) return null
260
288
  const joined = resolve(root, name)
261
289
  const prefix = root.endsWith(sep) ? root : root + sep
262
- return joined.startsWith(prefix) ? joined : null
290
+ if (joined.startsWith(prefix) === false) return null
291
+ let exists = false
292
+ try {
293
+ statSync(joined)
294
+ exists = true
295
+ } catch {
296
+ exists = false
297
+ }
298
+ if (exists && realPathInside(root, joined) === false) return null
299
+ return joined
263
300
  }
264
301
 
265
302
  /** Card metadata: size is cheap (one stat) and lets the library show real weight. */
package/lib/pet-chat.js CHANGED
@@ -31,6 +31,15 @@ export const PET_CHAT_MAX_CHARS = 4000
31
31
  export const PET_CHAT_MAX_TOKENS = 600
32
32
  /** Whole-call deadline; the browser adapter also races at 20 s. */
33
33
  export const PET_CHAT_TIMEOUT_MS = 20000
34
+ /**
35
+ * v2.0.4 (M3): host-side call budget. The browser keeps its own (stricter, user
36
+ * configurable) quota, but any same-origin script could call this route directly,
37
+ * so the host enforces a ceiling of its own. Set just above the most the UI can ever
38
+ * spend (user daily limit max 120 + 12 autonomous thoughts) so normal use never hits
39
+ * it. Every accepted call counts, including failures and timeouts.
40
+ */
41
+ export const PET_CHAT_HOST_PER_MINUTE = 8
42
+ export const PET_CHAT_HOST_PER_DAY = 140
34
43
 
35
44
  /**
36
45
  * Fixed system prompt. It must not be assembled from Host or page data: the page
@@ -203,7 +212,33 @@ export function createPetChat(options = {}) {
203
212
  ? Math.floor(options.maxBodyBytes)
204
213
  : PET_CHAT_MAX_BODY_BYTES
205
214
  const maxChars = Number.isFinite(options.maxChars) && options.maxChars > 0 ? Math.floor(options.maxChars) : PET_CHAT_MAX_CHARS
215
+ const perMinute =
216
+ Number.isFinite(options.perMinute) && options.perMinute > 0 ? Math.floor(options.perMinute) : PET_CHAT_HOST_PER_MINUTE
217
+ const perDay = Number.isFinite(options.perDay) && options.perDay > 0 ? Math.floor(options.perDay) : PET_CHAT_HOST_PER_DAY
218
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
206
219
  let busy = false
220
+ /** Accepted-call timestamps of the last minute + the per-local-day counter. */
221
+ let recent = []
222
+ let dayKey = ''
223
+ let dayCount = 0
224
+ const localDayKey = (ms) => {
225
+ const d = new Date(ms)
226
+ return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
227
+ }
228
+ /** Reserve one call from the host budget; false when the minute or day is spent. */
229
+ const takeBudget = () => {
230
+ const t = now()
231
+ recent = recent.filter((stamp) => t - stamp < 60000)
232
+ const key = localDayKey(t)
233
+ if (key !== dayKey) {
234
+ dayKey = key
235
+ dayCount = 0
236
+ }
237
+ if (recent.length >= perMinute || dayCount >= perDay) return false
238
+ recent.push(t)
239
+ dayCount += 1
240
+ return true
241
+ }
207
242
 
208
243
  /**
209
244
  * Handle one request. Never throws, never rejects; the response body is always
@@ -287,6 +322,11 @@ export function createPetChat(options = {}) {
287
322
  respond(429, { ok: false, code: PET_CHAT_CODES.BUSY })
288
323
  return
289
324
  }
325
+ if (takeBudget() === false) {
326
+ req?.resume?.()
327
+ respond(429, { ok: false, code: PET_CHAT_CODES.RATE_LIMIT })
328
+ return
329
+ }
290
330
  busy = true
291
331
  acquired = true
292
332
  controller = new AbortController()
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
14
14
  import { join, resolve, sep } from 'node:path'
15
- import { ROUTE, SAFE_NAME, fileSize } from './media-types.js'
15
+ import { ROUTE, SAFE_NAME, fileSize, realPathInside } from './media-types.js'
16
16
 
17
17
  /** Extensions a scene renderer may legitimately request. No HTML/JS/EXE. */
18
18
  export const SCENE_FILE_EXT = new Set([
@@ -81,6 +81,8 @@ export function sceneFilePath(projectDir, segments) {
81
81
  current = resolve(current, segment)
82
82
  if (current.startsWith(prefix) === false) return null
83
83
  }
84
+ // v2.0.4 (M2): a junction/symlink inside the project must not lead outside it.
85
+ if (existsSync(current) && realPathInside(base, current) === false) return null
84
86
  const ext = current.slice(current.lastIndexOf('.')).toLowerCase()
85
87
  if (SCENE_FILE_EXT.has(ext) === false) return null
86
88
  return current
@@ -30,6 +30,7 @@ import { fileURLToPath } from "node:url";
30
30
  import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
31
31
  import { deflateSync } from "node:zlib";
32
32
  import { parseRange } from "../http-media.js";
33
+ import { realPathInside } from "../media-types.js";
33
34
  //#region src/pkg-tex.ts
34
35
  /**
35
36
  * Wallpaper Engine scene.pkg → background image extractor.
@@ -2184,6 +2185,9 @@ function resolveWebTarget(base, segments) {
2184
2185
  const resolvedBase = resolve(base);
2185
2186
  const target = resolve(join(resolvedBase, ...segments.length > 0 ? segments : ["index.html"]));
2186
2187
  if (target !== resolvedBase && !target.startsWith(resolvedBase + sep)) return null;
2188
+ // v2.0.4 (M2): lexical containment alone lets a junction/symlink inside the
2189
+ // project point anywhere on disk; the REAL path must stay inside the real root.
2190
+ if (existsSync(target) && !realPathInside(resolvedBase, target)) return null;
2187
2191
  return target;
2188
2192
  }
2189
2193
  /**
@@ -2235,6 +2239,33 @@ function pipeFileSafe(res, path, options) {
2235
2239
  * @param abs - absolute file path.
2236
2240
  * @param cacheControl - cache directive (default no-cache).
2237
2241
  */
2242
+ /**
2243
+ * v2.0.4 (H1): every HTML document this host serves is a wallpaper page from the
2244
+ * user's Wallpaper Engine library. Served from the DSH origin without a policy it
2245
+ * would run with the DSH origin's storage and APIs whenever it is opened directly or
2246
+ * embedded by any site (the read-only /web route deliberately accepts cross-site and
2247
+ * `Origin: null` requests so the skin's sandboxed frame can load its sub-resources).
2248
+ * `sandbox allow-scripts` makes the document an opaque origin no matter who loads it
2249
+ * — exactly what the skin's own `<iframe sandbox="allow-scripts">` already does, so
2250
+ * the wallpaper behaves the same in the skin — and frame-ancestors keeps other sites
2251
+ * from embedding it at all.
2252
+ */
2253
+ export const WE_HTML_CSP =
2254
+ "default-src 'none'; script-src * blob: data: 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; " +
2255
+ "worker-src * blob: data:; style-src * blob: data: 'unsafe-inline'; img-src * data: blob:; media-src * data: blob:; " +
2256
+ "font-src * data: blob:; connect-src * data: blob:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; " +
2257
+ "frame-ancestors 'self' dsh-app: dsh:; sandbox allow-scripts";
2258
+ /** Response headers that make a served HTML/XHTML/XML document inert to the DSH origin. */
2259
+ export function weDocumentHeaders(type) {
2260
+ const lower = String(type ?? "").toLowerCase();
2261
+ if (lower.startsWith("text/html") || lower.startsWith("application/xhtml") || lower.startsWith("application/xml") || lower.startsWith("text/xml")) {
2262
+ return {
2263
+ "content-security-policy": WE_HTML_CSP,
2264
+ "referrer-policy": "no-referrer"
2265
+ };
2266
+ }
2267
+ return {};
2268
+ }
2238
2269
  function serveFile(req, res, abs, cacheControl = "no-cache") {
2239
2270
  const stat = statSync(abs, { throwIfNoEntry: false });
2240
2271
  if (stat === void 0 || !stat.isFile()) {
@@ -2258,6 +2289,7 @@ function serveFile(req, res, abs, cacheControl = "no-cache") {
2258
2289
  if (type === "image/svg+xml") {
2259
2290
  headers["content-security-policy"] = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:";
2260
2291
  }
2292
+ Object.assign(headers, weDocumentHeaders(type));
2261
2293
  if (req.method === "HEAD") {
2262
2294
  res.writeHead(200, {
2263
2295
  ...headers,
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
19
19
  import { basename, join, resolve, sep } from 'node:path'
20
- import { ROUTE, SAFE_NAME } from './media-types.js'
20
+ import { ROUTE, SAFE_NAME, realPathInside } from './media-types.js'
21
21
  import { servePath, sendText } from './http-media.js'
22
22
 
23
23
  /** Extensions a web wallpaper page may load. No archives, no executables. */
@@ -102,7 +102,9 @@ export function webProjectFilePath(projectDir, segments) {
102
102
  if (list.length === 0) {
103
103
  const entry = projectEntryName(readProjectJson(base))
104
104
  if (SAFE_NAME.test(entry) === false) return null
105
- return join(base, entry)
105
+ const entryPath = join(base, entry)
106
+ if (existsSync(entryPath) && realPathInside(base, entryPath) === false) return null
107
+ return entryPath
106
108
  }
107
109
  if (list.length > MAX_SEGMENTS) return null
108
110
  let current = base
@@ -117,6 +119,8 @@ export function webProjectFilePath(projectDir, segments) {
117
119
  current = resolve(current, segment)
118
120
  if (current.startsWith(prefix) === false) return null
119
121
  }
122
+ // v2.0.4 (M2): a junction/symlink inside the project must not lead outside it.
123
+ if (existsSync(current) && realPathInside(base, current) === false) return null
120
124
  const ext = current.slice(current.lastIndexOf('.')).toLowerCase()
121
125
  if (WEB_FILE_EXT.has(ext) === false) return null
122
126
  return current
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-client-ui-skin-tianlight",
3
3
  "description": "Tianlight skin for the dsh web GUI: Makoto Shinkai daylight skies, a looping video wallpaper, a wallpaper library with upload/rotation, optional WebWallGL scene support, and a draggable Mint chibi-cat desktop companion",
4
- "version": "2.0.3",
4
+ "version": "2.0.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "bin": {
@@ -43,6 +43,14 @@
43
43
  "@deepseek-ai/dsh-api-remotes": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-0 <0.2.2-0 || >=0.2.2-0 <0.3.0-0",
44
44
  "@deepseek-ai/dsh-api-session-controller": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-0 <0.2.2-0 || >=0.2.2-0 <0.3.0-0"
45
45
  },
46
+ "peerDependenciesMeta": {
47
+ "@deepseek-ai/dsh-api-remotes": {
48
+ "optional": true
49
+ },
50
+ "@deepseek-ai/dsh-api-session-controller": {
51
+ "optional": true
52
+ }
53
+ },
46
54
  "scripts": {
47
55
  "build": "node tools/build.mjs",
48
56
  "build:check": "node tools/build.mjs --check",
@@ -68,6 +76,7 @@
68
76
  "assets",
69
77
  "presets",
70
78
  "cordis.patch.yml",
79
+ "docs/THIRD-PARTY.md",
71
80
  "skin.json",
72
81
  "build-info.json",
73
82
  "LICENSE",
package/skin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "id": "tianlight",
3
3
  "name": "天光",
4
4
  "nameEn": "TIANLIGHT",
5
- "version": "2.0.3",
5
+ "version": "2.0.4",
6
6
  "author": "local",
7
7
  "tagline": "新海诚式天光 ・ 动态壁纸与可上传壁纸库 ・ 可选 WebWallGL 场景 ・ 薄荷小猫桌宠 ・ 职责通道(role pack → 桌宠气泡/选择) ・ 生活节奏与主动开口预算 ・ 可替换图片/≤32MiB 视频形象 ・ 液态玻璃三档 ・ AI 桌宠(严格权限) ・ 接入 DSH Agent(受控) ・ 桌宠即入口 ・ 兼容 DSH 0.2.0–0.2.2",
8
8
  "description": "以新海诚天光为基调的沉浸式皮肤:全屏循环视频壁纸置于内容之下,半透明天光玻璃面板;壁纸库支持上传、隐藏、轮播、画面滤镜与标签检索;可选的 WebWallGL 通道实时渲染 Wallpaper Engine 场景;可拖拽的薄荷 Q 版小猫桌宠会靠近回应、陪你互动。v0.5.0 加入了宿主配置同步与 schema 迁移、模块开关、渲染预算、特效与氛围层、时段/专注联动、系统音频与 Now Playing、网页壁纸、动态分辨率与预裁剪缓存、可选转码、导入导出包与配方分享链接、排版增强、可读性护栏、键盘无障碍与 zh/en 双语;v0.6.0 接入 Wallpaper Engine 壁纸库;v0.7.0 新增职责通道:外部角色包可在桌宠旁显示带按钮的职责气泡(审批/完成/喝水等),并把用户选择(等会儿/静音/休息时再说)回传给职责引擎。本版只改浏览器半,无需重启 DSH(Ctrl+F5 即可)。同一份构建适配 DSH 0.2.0 / 0.2.1 / 0.2.2(选择器桥按运行时钩子分层降级)。 v1.0.0 正式版:桌宠升级为有生活节奏的角色智能体——虚拟时钟/每日作息/主动开口预算/跨会话记忆,职责提醒可被静默与预算闸门延迟并在窗口期自动回放;形象可替换为本地图片或 ≤32 MiB 视频;新增可选液态玻璃(真实/柔和/磨砂,低电量自动降级);新增「角色 / 生活」设置页与专注计时;无宿主路由改动,Ctrl+F5 即可。 v1.1.0:角色中枢(侧栏入口 + 全屏面板,一屏管生活/形象/玻璃/记忆)、AI 桌宠(本地大脑 + 可选 LLM 适配器,严格能力白名单与硬上限、审计与暂停开关)、形象支持本地上传图片/视频(≤32MiB)。 v1.2.0:桌宠右键直达角色中枢;新增受控 DSH Agent 接入(agentChat 权限默认关闭、显式授权、无自动触发、每日≤10 次/≤500 字/60s,可连接现有会话或新建桌宠会话)。 v1.3.0:角色入口收进桌宠——去掉设置页「角色 / 生活」与侧栏「角色」按钮,右键桌宠(或桌宠菜单「角色中枢」)打开全部功能;生活节奏设置已并入角色中枢。 v1.4.0:权限模型调整——「驱动 DSH」仍强烈永久禁止;直接联网/读写文件/执行命令/代审批/代发消息/安装插件改为用户可显式授予的高风险权限(默认关闭、可撤销、全程审计;皮肤不内置实现,需你注册能力适配器才会被调用)。 v1.5.0:恢复默认桌宠形象、液态玻璃默认开启并加预览(实时可见)、角色中枢新增天气卡(启用/城市/定位/刷新,显示当前天气)、一键功能自检、应用内反馈面板(复制/预填 GitHub Issue/下载 JSON)+ GitHub issue 模板。 v1.6.0:安全上限整体上调(主动开口 12/50、本地 chat 20/分 1200/天、输入 1000 字、LLM 日 60/上限 120、分钟 6、输出 1200 字、20s 超时、Agent 日 30/上限 100、输入 1000 字/120s);液态玻璃在 depth!=off 时统一应用到全部界面(侧栏/设置/壁纸库/中枢/对话框/菜单/列表框/输入区/气泡/胶囊/提示等),无壁纸场景时自动补一层玻璃兜底,关闭档零变化、低电量自动降级。 v1.6.1:液态玻璃升级 v2(OriginOS 风更厚磨砂、多层 specular 高光、1px 亮暗边、外柔光、细噪点与柔和回弹);修复侧栏「新会话」按钮深色块并玻璃化;DSH 主界面右上新增常驻天气胶囊(点击打开角色中枢天气卡)。 v1.6.2:移除壁纸左上角固定的「晨光/太阳」装饰(旧版每张壁纸重建场景都会绘制一个 top-left dawn burst,看起来像常驻小太阳);场景层只保留 veil 天空光渐变,切换壁纸不再出现任何固定角标(仅视觉变化)。 v1.6.3:显示名精简为「天光」(skin id / 包名 / data 属性 / 设置键均不变,旧用户无迁移成本);完成 DSH 0.2.0-rc.2 / 0.2.1-rc.5 / 0.2.2-rc.1 三线兼容性实证(compat-matrix 三行全 COMPATIBLE、hook-scan 全桥接钩子在位、upgrade-check 升级假设全部成立)。 v1.6.4(公开 v1.0.3 正式版):桌宠新增「AI 对话」菜单(输入条 + 气泡回复,识别当前替换的图片/视频形象,视频上方空间不足自动翻转);每个自定义形象可编辑一套动作名与回复(按 mediaId 生效,未改动跟随默认);角色中枢形象文件名常显列表(文件名/类型/大小,点击切换);完成全代码审查修复(默认文案冻结、WE 宿主 stream error、低电量异步泄漏、bridge 节点清理、转码超时兜底、宿主同源栅栏收紧、聊天视口翻转)。 v1.6.5(公开 v1.0.4 正式版):修复桌宠「AI 对话」关不掉的问题——新增面板「×」关闭按钮、点空白关闭、全局 Esc 关闭、[hidden] 真正隐藏,并支持桌宠靠近屏幕底部时点桌宠直接退出(菜单被裁到视口外也能退出)。仅浏览器半,无宿主路由改动,Ctrl+F5 即可。 v1.6.6(公开 v1.0.5 正式版):修复 AI 对话面板在桌宠默认靠右位置时「×」关闭按钮被视口裁掉的问题(面板水平夹取进视口,拖拽/窗口缩放后重算),并把顶部翻转阈值改为按面板实际高度计算(新增按钮行后不再裁到顶部),略增大关闭按钮点击区域。仅浏览器半;版本常量变更,Ctrl+F5 + 重启 DSH 一次可消除宿主半 stale 提示。 v1.6.7(公开 v1.0.6 正式版):壁纸面板默认视图新增一等「URL 地址」「ShaderToy 代码」快捷来源(点击直达编辑器,并修复 URL/Shader 应用假成功与卡死);桌宠「AI 对话」收到回复即自动收起输入面板、只留气泡回复;新增「☰」对话历史(与 AI 对话相邻):可查看历史、开启新对话、编辑已发消息、删除对话,数据存本机 localStorage。仅浏览器半;版本常量变更,Ctrl+F5 + 重启 DSH 一次可消除宿主半 stale 提示。 v1.6.8(公开 v1.0.7 正式版):桌宠新增「自主思考」智能体——按间隔做内心独白、可自主开口(受既有主动开口预算约束),聊天回复融入最近对话/时段/记忆/当前想法更像人;角色中枢新增「自主思考」卡片(开关/间隔/自主开口/现在想一下/最近想法/清空)。不新增权限与网络通道,不驱动 DSH Agent;仅浏览器半,Ctrl+F5 即可;版本常量变更,重启 DSH 一次可消除宿主半 stale 提示。 v2.0.0:桌宠 AI 对话接入 DSH 自带 LLM(宿主路由 _pet-chat;默认跟随 DSH 默认模型,仍须在角色中枢两步授权 llm;不建会话、不调工具、60/日、6/分、1200 字、20s 超时;失败按稳定错误码提示);版本号统一为 2.0.0(仓库 / DSH 安装 / GitHub 发布同名同号);并入 v1.6.8 之后的体检修复批次(设置同步合并与冲突保护、宿主/桥接安全收口、统一浮层层序、壁纸切换与性能、可读性/低电量、导出导入一致性);默认演示素材精简为 bg-4k.mp4(H.264 4K)与 cat.png,bg-main.mp4 改为可选自备首选槽。本版改了宿主半:升级后必须重启 DSH 一次 + Ctrl+F5,之后纯浏览器半更新仍只需 Ctrl+F5。",