dsh-mpkg-wallpaper 3.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.en.md +233 -0
- package/README.md +232 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +3477 -0
- package/lib/index.js +509 -0
- package/package.json +58 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
// dsh-mpkg-wallpaper —— 宿主端(hybrid 模式:大文件流式上传 + Range 播放 + Steam 自动发现)
|
|
2
|
+
// 纯客户端逻辑仍在 ./client.js。本文件提供:
|
|
3
|
+
// GET /api/mpkg-wallpaper/ping → { ok:true }(客户端探测 host 是否可用)
|
|
4
|
+
// POST /api/mpkg-wallpaper/upload → 流式接收 mpkg → 存磁盘 → 返回条目索引
|
|
5
|
+
// GET /api/mpkg-wallpaper/media?token=&index= → Range 流式返回 mpkg 内某个条目
|
|
6
|
+
// GET /api/mpkg-wallpaper/steam-inventory → (Windows) 自动发现壁纸引擎安装与壁纸列表
|
|
7
|
+
import { createWriteStream, createReadStream, mkdirSync, existsSync, statSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
// tmpdir 不再使用(持久目录用 DATA_DIR)
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const BASE = '/api/mpkg-wallpaper';
|
|
14
|
+
const HEAD_BYTES = 2 * 1024 * 1024; // 与客户端一致的容器头读取量
|
|
15
|
+
const WE_APPID = '431960';
|
|
16
|
+
const STEAM_PROBE_DIRS = [
|
|
17
|
+
'C:\\Program Files (x86)\\Steam',
|
|
18
|
+
'C:\\Program Files\\Steam',
|
|
19
|
+
'D:\\Steam',
|
|
20
|
+
'D:\\SteamLibrary',
|
|
21
|
+
'E:\\SteamLibrary',
|
|
22
|
+
];
|
|
23
|
+
/** ①(修正) 持久数据目录:放 DSH_HOME/HOME 下而非 tmpdir —— Termux/proot 环境
|
|
24
|
+
* /tmp 每次重启清空 → 重启后 mpkg 与 customDir 全部丢失(用户实测)。
|
|
25
|
+
* 用持久目录:上传的 mpkg、custom-dir.json、token 映射重启后都能恢复。 */
|
|
26
|
+
const DATA_DIR = join(process.env.DSH_HOME || process.env.HOME || '.', '.dsh-mpkg-wallpaper');
|
|
27
|
+
|
|
28
|
+
/** token → { path, size, dataStart, entries } */
|
|
29
|
+
const files = new Map();
|
|
30
|
+
/** ④(新) 本地壁纸库:ltoken → { dir, type, title, media, preview }(Steam 自动发现) */
|
|
31
|
+
const library = new Map();
|
|
32
|
+
/** ①(新) 自定义本地壁纸目录:用户指定的文件夹路径(只读媒体文件,安全校验) */
|
|
33
|
+
let customDir = null;
|
|
34
|
+
/** ①(修正) customDir 持久化:dsh 重启后恢复(否则 /custom-media 404 → 自定义目录壁纸消失)。
|
|
35
|
+
* 存入 tmpdir 下的 JSON,随 tmpdir 清理策略(上传的 mpkg 同目录)。 */
|
|
36
|
+
function persistCustomDir() {
|
|
37
|
+
try {
|
|
38
|
+
const dir = DATA_DIR;
|
|
39
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
40
|
+
writeFileSync(join(dir, 'custom-dir.json'), JSON.stringify({ dir: customDir }));
|
|
41
|
+
} catch { /* 忽略 */ }
|
|
42
|
+
}
|
|
43
|
+
function restoreCustomDir() {
|
|
44
|
+
try {
|
|
45
|
+
const f = join(DATA_DIR, 'custom-dir.json');
|
|
46
|
+
if (!existsSync(f)) return;
|
|
47
|
+
const d = JSON.parse(readFileSync(f, 'utf8'));
|
|
48
|
+
if (d && d.dir && existsSync(d.dir) && statSync(d.dir).isDirectory()) customDir = d.dir;
|
|
49
|
+
} catch { /* 忽略 */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 解析 mpkg 容器头(与 client.js 的 parseMpkg 相同逻辑,返回 dataStart + 条目表)。 */
|
|
53
|
+
function parseMpkgHead(buf) {
|
|
54
|
+
let pos = 0;
|
|
55
|
+
const versionLength = buf.readUInt32LE(pos); pos += 4;
|
|
56
|
+
pos += versionLength; // 跳过 version 字符串
|
|
57
|
+
const fileTotal = buf.readUInt32LE(pos); pos += 4;
|
|
58
|
+
const entries = [];
|
|
59
|
+
for (let i = 0; i < fileTotal; i++) {
|
|
60
|
+
const nameLength = buf.readUInt32LE(pos); pos += 4;
|
|
61
|
+
const name = buf.toString('utf8', pos, pos + nameLength); pos += nameLength;
|
|
62
|
+
const index = buf.readUInt32LE(pos); pos += 4;
|
|
63
|
+
const size = buf.readUInt32LE(pos); pos += 4;
|
|
64
|
+
entries.push({ name, index, size });
|
|
65
|
+
}
|
|
66
|
+
return { dataStart: pos, entries };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function json(res, code, obj) {
|
|
70
|
+
res.writeHead(code, { 'content-type': 'application/json' });
|
|
71
|
+
res.end(JSON.stringify(obj));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** ①(新) 内容哈希:只哈希实际代码文件(client.js + index.js),README 变更不影响 */
|
|
75
|
+
function codeHash() {
|
|
76
|
+
try {
|
|
77
|
+
const client = readFileSync(new URL('./client.js', import.meta.url), 'utf8');
|
|
78
|
+
const index = readFileSync(new URL('./index.js', import.meta.url), 'utf8');
|
|
79
|
+
return crypto.createHash('sha1').update(client + index).digest('hex').slice(0, 12);
|
|
80
|
+
} catch { return 'local'; }
|
|
81
|
+
}
|
|
82
|
+
/** ①(新) 从 GitHub 拉取某个文件的内容(自己的仓库,可信源) */
|
|
83
|
+
async function fetchRaw(path) {
|
|
84
|
+
const r = await fetch('https://raw.githubusercontent.com/XHR666/dsh-mpkg-wallpaper/main/' + path);
|
|
85
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
86
|
+
return await r.text();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Steam 注册表里的安装路径(Windows)。 */
|
|
90
|
+
function steamPathFromRegistry() {
|
|
91
|
+
if (process.platform !== 'win32') return null;
|
|
92
|
+
try {
|
|
93
|
+
const reg = join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'reg.exe');
|
|
94
|
+
const out = execFileSync(reg, ['query', 'HKCU\\Software\\Valve\\Steam', '/v', 'SteamPath'], {
|
|
95
|
+
encoding: 'utf8', windowsHide: true, timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
96
|
+
});
|
|
97
|
+
const m = /SteamPath\s+REG_SZ\s+(.+)/i.exec(out);
|
|
98
|
+
return m ? m[1].trim().replace(/\\\\/g, '\\') : null;
|
|
99
|
+
} catch { return null; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** ③ 自动发现:定位壁纸引擎安装目录(Steam libraryfolders.vdf + 常见路径探测)。 */
|
|
103
|
+
function locateWallpaperEngine() {
|
|
104
|
+
const probes = [];
|
|
105
|
+
const reg = steamPathFromRegistry();
|
|
106
|
+
if (reg) probes.push(reg);
|
|
107
|
+
probes.push(...STEAM_PROBE_DIRS);
|
|
108
|
+
const libraries = [];
|
|
109
|
+
for (const probe of probes) {
|
|
110
|
+
const vdf = join(probe, 'steamapps', 'libraryfolders.vdf');
|
|
111
|
+
if (existsSync(vdf)) {
|
|
112
|
+
try {
|
|
113
|
+
const text = readFileSync(vdf, 'utf8');
|
|
114
|
+
let current = null;
|
|
115
|
+
for (const line of text.split(/\r?\n/)) {
|
|
116
|
+
const m = /^\s*"path"\s+"([^"]+)"\s*$/.exec(line);
|
|
117
|
+
if (m) { current = m[1].replace(/\\\\/g, '\\'); continue; }
|
|
118
|
+
if (current && line.includes(WE_APPID) && !libraries.includes(current)) libraries.push(current);
|
|
119
|
+
}
|
|
120
|
+
} catch { /* skip */ }
|
|
121
|
+
}
|
|
122
|
+
if (existsSync(join(probe, 'steamapps', 'common', 'wallpaper_engine'))) libraries.push(probe);
|
|
123
|
+
}
|
|
124
|
+
const roots = [...new Set([...probes, ...libraries])];
|
|
125
|
+
for (const root of roots) {
|
|
126
|
+
const dir = join(root, 'steamapps', 'common', 'wallpaper_engine');
|
|
127
|
+
if (existsSync(join(dir, 'wallpaper32.exe'))) return dir;
|
|
128
|
+
}
|
|
129
|
+
const alt = 'C:\\Program Files (x86)\\Wallpaper Engine';
|
|
130
|
+
return existsSync(join(alt, 'wallpaper32.exe')) ? alt : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ①(修正) 硬依赖 webServer:cordis 等待 HTTP 服务挂载后再 apply(dsh-wallpaper-engine 同款)。
|
|
134
|
+
// 之前用 ctx.inject(['webServer'], (ws) => …) 时回调参数是 webCtx 而非服务对象,
|
|
135
|
+
// ws.register 实际不存在 → 路由从未注册 → 客户端 ping 404 → hybrid 回退纯浏览器模式。
|
|
136
|
+
export const inject = ['webServer'];
|
|
137
|
+
|
|
138
|
+
/** ①(修正) 重启后恢复上传的 mpkg 映射:扫描临时目录下的 *.mpkg(文件名即 token),
|
|
139
|
+
* 重新解析容器头 → files Map 重建 → 浏览器里的 "host:" 背景在 dsh 重启后仍能加载。
|
|
140
|
+
* 之前 files Map 只在内存,dsh 重启即清空 → media 404 → 壁纸有时不显示。 */
|
|
141
|
+
function restoreFiles() {
|
|
142
|
+
try {
|
|
143
|
+
const dir = DATA_DIR;
|
|
144
|
+
if (!existsSync(dir)) return;
|
|
145
|
+
for (const f of readdirSync(dir)) {
|
|
146
|
+
if (!f.endsWith('.mpkg')) continue;
|
|
147
|
+
const token = f.slice(0, -5);
|
|
148
|
+
if (files.has(token)) continue;
|
|
149
|
+
try {
|
|
150
|
+
const path = join(dir, f);
|
|
151
|
+
const head = readFileSync(path).subarray(0, HEAD_BYTES);
|
|
152
|
+
const { dataStart, entries } = parseMpkgHead(head);
|
|
153
|
+
files.set(token, { path, size: statSync(path).size, dataStart, entries });
|
|
154
|
+
} catch { /* 跳过损坏文件 */ }
|
|
155
|
+
}
|
|
156
|
+
} catch { /* 忽略 */ }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function apply(ctx) {
|
|
160
|
+
restoreFiles();
|
|
161
|
+
restoreCustomDir();
|
|
162
|
+
const webServer = ctx.webServer;
|
|
163
|
+
if (!webServer || typeof webServer.register !== 'function') return;
|
|
164
|
+
{
|
|
165
|
+
// 探测 host 可用性
|
|
166
|
+
webServer.register({
|
|
167
|
+
kind: 'exact', path: BASE + '/ping',
|
|
168
|
+
handler: (req, res) => json(res, 200, { ok: true }),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// 流式接收 mpkg → 磁盘 → 返回条目索引(hybrid 大文件模式)
|
|
172
|
+
webServer.register({
|
|
173
|
+
kind: 'exact', path: BASE + '/upload',
|
|
174
|
+
handler: async (req, res) => {
|
|
175
|
+
try {
|
|
176
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
177
|
+
const dir = DATA_DIR;
|
|
178
|
+
mkdirSync(dir, { recursive: true });
|
|
179
|
+
const filePath = join(dir, token + '.mpkg');
|
|
180
|
+
const out = createWriteStream(filePath);
|
|
181
|
+
let head = Buffer.alloc(0);
|
|
182
|
+
let size = 0;
|
|
183
|
+
for await (const chunk of req) {
|
|
184
|
+
size += chunk.length;
|
|
185
|
+
if (head.length < HEAD_BYTES) head = Buffer.concat([head, chunk]);
|
|
186
|
+
if (!out.write(chunk)) await new Promise((r) => out.once('drain', r));
|
|
187
|
+
}
|
|
188
|
+
await new Promise((r) => { out.end(r); });
|
|
189
|
+
const { dataStart, entries } = parseMpkgHead(head);
|
|
190
|
+
files.set(token, { path: filePath, size, dataStart, entries });
|
|
191
|
+
json(res, 200, { ok: true, token, size, entries });
|
|
192
|
+
} catch (err) {
|
|
193
|
+
json(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Range 流式返回 mpkg 内某个条目(视频/图片直接播放)
|
|
199
|
+
webServer.register({
|
|
200
|
+
kind: 'exact', path: BASE + '/media',
|
|
201
|
+
handler: (req, res) => {
|
|
202
|
+
try {
|
|
203
|
+
const url = new URL(req.url || '', 'http://localhost');
|
|
204
|
+
const token = url.searchParams.get('token') || '';
|
|
205
|
+
const idx = Number(url.searchParams.get('index'));
|
|
206
|
+
let rec = files.get(token);
|
|
207
|
+
// ①(修正) 重启竞态兜底:dsh 重启后浏览器可能先请求 /media 而 restoreFiles()
|
|
208
|
+
// 尚未重建 Map(或该 token 恰好不在初始扫描结果里)→ 懒重扫一次再判 404,
|
|
209
|
+
// 避免"重启后壁纸偶尔消失且不再恢复"。
|
|
210
|
+
if (!rec) { restoreFiles(); rec = files.get(token); }
|
|
211
|
+
if (!rec || !rec.entries[idx]) { json(res, 404, { ok: false, error: 'not found' }); return; }
|
|
212
|
+
const entry = rec.entries[idx];
|
|
213
|
+
const st = statSync(rec.path);
|
|
214
|
+
const off = Math.max(0, Number(url.searchParams.get('offset')) || 0);
|
|
215
|
+
const start = rec.dataStart + entry.index + off;
|
|
216
|
+
const end = start + entry.size - 1 - off;
|
|
217
|
+
const name = entry.name.toLowerCase();
|
|
218
|
+
const mime = name.endsWith('.gif') ? 'image/gif'
|
|
219
|
+
: name.endsWith('.png') ? 'image/png'
|
|
220
|
+
: name.endsWith('.jpg') || name.endsWith('.jpeg') ? 'image/jpeg'
|
|
221
|
+
: name.endsWith('.webp') ? 'image/webp'
|
|
222
|
+
: name.endsWith('.mp4') ? 'video/mp4'
|
|
223
|
+
: name.endsWith('.webm') ? 'video/webm'
|
|
224
|
+
: 'application/octet-stream';
|
|
225
|
+
const range = req.headers.range;
|
|
226
|
+
if (range) {
|
|
227
|
+
const m = /bytes=(\d*)-(\d*)/.exec(range);
|
|
228
|
+
const rs = m && m[1] ? parseInt(m[1], 10) : 0;
|
|
229
|
+
const re = m && m[2] ? parseInt(m[2], 10) : entry.size - 1;
|
|
230
|
+
const s = start + Math.max(0, rs);
|
|
231
|
+
const e = start + Math.min(entry.size - 1, re);
|
|
232
|
+
if (s > e) { res.writeHead(416, { 'content-range': `bytes */${entry.size}` }); res.end(); return; }
|
|
233
|
+
res.writeHead(206, {
|
|
234
|
+
'content-type': mime, 'accept-ranges': 'bytes',
|
|
235
|
+
'content-range': `bytes ${s - start}-${e - start}/${entry.size}`,
|
|
236
|
+
'content-length': e - s + 1,
|
|
237
|
+
});
|
|
238
|
+
createReadStream(rec.path, { start: s, end: e }).pipe(res);
|
|
239
|
+
} else {
|
|
240
|
+
res.writeHead(200, { 'content-type': mime, 'accept-ranges': 'bytes', 'content-length': entry.size });
|
|
241
|
+
createReadStream(rec.path, { start, end }).pipe(res);
|
|
242
|
+
}
|
|
243
|
+
} catch (err) {
|
|
244
|
+
json(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ①(新) 检测更新:对比 GitHub 上 client.js + index.js 的内容哈希(README 变更不触发)
|
|
250
|
+
webServer.register({
|
|
251
|
+
kind: 'exact', path: BASE + '/update-check',
|
|
252
|
+
handler: async (req, res) => {
|
|
253
|
+
try {
|
|
254
|
+
const local = codeHash();
|
|
255
|
+
let remote = null, remoteVersion = null, releaseAt = null;
|
|
256
|
+
try {
|
|
257
|
+
const remoteClient = await fetchRaw('lib/client.js');
|
|
258
|
+
const remoteIndex = await fetchRaw('lib/index.js');
|
|
259
|
+
remote = crypto.createHash('sha1').update(remoteClient + remoteIndex).digest('hex').slice(0, 12);
|
|
260
|
+
const pkg = JSON.parse(await fetchRaw('package.json'));
|
|
261
|
+
remoteVersion = pkg.version;
|
|
262
|
+
// ⑩(新) 最新 release 发布时间(用于"新版本刚发布可能有问题"提示)
|
|
263
|
+
try {
|
|
264
|
+
const rel = await fetch('https://api.github.com/repos/XHR666/dsh-mpkg-wallpaper/releases/latest');
|
|
265
|
+
if (rel.ok) { const rj = await rel.json(); releaseAt = rj.published_at || null; }
|
|
266
|
+
} catch { /* 忽略 */ }
|
|
267
|
+
} catch { /* 网络失败 → remote 保持 null */ }
|
|
268
|
+
const pkgLocal = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
269
|
+
json(res, 200, { ok: true, local, localVersion: pkgLocal.version, remote, remoteVersion, releaseAt, hasUpdate: !!remote && remote !== local });
|
|
270
|
+
} catch (err) { json(res, 500, { ok: false, error: String(err && err.message || err) }); }
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ①(新) 热更新:从 GitHub 下载最新 lib/client.js + lib/index.js 写回本插件目录
|
|
275
|
+
webServer.register({
|
|
276
|
+
kind: 'exact', path: BASE + '/update-apply',
|
|
277
|
+
handler: async (req, res) => {
|
|
278
|
+
try {
|
|
279
|
+
const dir = new URL('./', import.meta.url);
|
|
280
|
+
const client = await fetchRaw('lib/client.js');
|
|
281
|
+
const index = await fetchRaw('lib/index.js');
|
|
282
|
+
if (!client.includes('dsh-mpkg-wallpaper') || !index.includes('dsh-mpkg-wallpaper')) { json(res, 400, { ok: false, error: 'invalid payload' }); return; }
|
|
283
|
+
writeFileSync(new URL('./client.js', dir), client, 'utf8');
|
|
284
|
+
writeFileSync(new URL('./index.js', dir), index, 'utf8');
|
|
285
|
+
// 同步更新 package.json 版本号(让"更新到 vX"名副其实)
|
|
286
|
+
try {
|
|
287
|
+
const pkg = await fetchRaw('package.json');
|
|
288
|
+
const pj = JSON.parse(pkg);
|
|
289
|
+
if (pj && pj.version) {
|
|
290
|
+
const localPkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
291
|
+
localPkg.version = pj.version;
|
|
292
|
+
writeFileSync(new URL('../package.json', import.meta.url), JSON.stringify(localPkg, null, 2), 'utf8');
|
|
293
|
+
}
|
|
294
|
+
} catch { /* 版本号同步失败不阻塞更新 */ }
|
|
295
|
+
json(res, 200, { ok: true });
|
|
296
|
+
} catch (err) { json(res, 500, { ok: false, error: String(err && err.message || err) }); }
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ①(新) 目录浏览:返回指定路径下的子目录列表(跨平台,供文件夹选择器逐级浏览)
|
|
301
|
+
webServer.register({
|
|
302
|
+
kind: 'exact', path: BASE + '/list-dirs',
|
|
303
|
+
handler: (req, res) => {
|
|
304
|
+
try {
|
|
305
|
+
const url = new URL(req.url || '', 'http://localhost');
|
|
306
|
+
const p = (url.searchParams.get('path') || '').trim();
|
|
307
|
+
// ③(修正) 默认起始路径:Windows → C:\;其他平台 → 当前用户 HOME(proot 的 /root 等),
|
|
308
|
+
// 而不是直接开根目录(用户反馈打开的是安卓根目录、看不到自己环境)
|
|
309
|
+
const base = p || (process.platform === 'win32' ? 'C:\\' : (process.env.HOME || '/'));
|
|
310
|
+
if (!existsSync(base) || !statSync(base).isDirectory()) { json(res, 400, { ok: false, error: 'invalid dir' }); return; }
|
|
311
|
+
const subdirs = readdirSync(base, { withFileTypes: true })
|
|
312
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
|
313
|
+
.map((d) => d.name);
|
|
314
|
+
json(res, 200, { ok: true, dir: base, subdirs, home: process.env.HOME || '/', platform: process.platform });
|
|
315
|
+
} catch (err) { json(res, 500, { ok: false, error: String(err && err.message || err) }); }
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ①(新) 自定义本地壁纸目录:POST 设置并扫描目录内的媒体文件
|
|
320
|
+
webServer.register({
|
|
321
|
+
kind: 'exact', path: BASE + '/custom-dir',
|
|
322
|
+
handler: async (req, res) => {
|
|
323
|
+
try {
|
|
324
|
+
let body = "";
|
|
325
|
+
for await (const c of req) body += String(c);
|
|
326
|
+
const dir = (JSON.parse(body || '{}').dir || '').trim();
|
|
327
|
+
if (!dir || !existsSync(dir) || !statSync(dir).isDirectory()) { json(res, 400, { ok: false, error: 'invalid dir' }); return; }
|
|
328
|
+
customDir = dir;
|
|
329
|
+
persistCustomDir();
|
|
330
|
+
const exts = ['.gif', '.png', '.jpg', '.jpeg', '.webp', '.mp4', '.webm', '.mov', '.mpkg'];
|
|
331
|
+
// ①(安全) 只收图片/视频/mpkg;排除 exe/bat/sh/scr 等可执行与脚本(防病毒注入)
|
|
332
|
+
const files = readdirSync(dir)
|
|
333
|
+
.filter((f) => exts.some((e) => f.toLowerCase().endsWith(e)))
|
|
334
|
+
.map((f) => ({ name: f, type: /\.(mp4|webm|mov)$/i.test(f) ? 'video' : /\.mpkg$/i.test(f) ? 'mpkg' : 'image' }));
|
|
335
|
+
json(res, 200, { ok: true, dir, files });
|
|
336
|
+
} catch (err) { json(res, 500, { ok: false, error: String(err && err.message || err) }); }
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ⑥(新) 自定义目录里的 .mpkg:解析容器,分配 token,选素材(preview / 独立 mp4)
|
|
341
|
+
webServer.register({
|
|
342
|
+
kind: 'exact', path: BASE + '/custom-mpkg',
|
|
343
|
+
handler: (req, res) => {
|
|
344
|
+
try {
|
|
345
|
+
// ①(修正) 重启竞态兜底:懒恢复 customDir(防 404 壁纸消失)
|
|
346
|
+
if (!customDir) restoreCustomDir();
|
|
347
|
+
if (!customDir) { json(res, 404, { ok: false, error: 'no dir' }); return; }
|
|
348
|
+
const url = new URL(req.url || '', 'http://localhost');
|
|
349
|
+
const file = url.searchParams.get('file') || '';
|
|
350
|
+
if (!file || file.includes('..') || file.includes('/') || file.includes('\\')) { json(res, 403, { ok: false, error: 'forbidden' }); return; }
|
|
351
|
+
const filePath = join(customDir, file);
|
|
352
|
+
if (!existsSync(filePath) || !file.toLowerCase().endsWith('.mpkg')) { json(res, 404, { ok: false, error: 'not found' }); return; }
|
|
353
|
+
const head = readFileSync(filePath).subarray(0, HEAD_BYTES);
|
|
354
|
+
const { dataStart, entries } = parseMpkgHead(head);
|
|
355
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
356
|
+
files.set(token, { path: filePath, size: statSync(filePath).size, dataStart, entries });
|
|
357
|
+
// ④(修正) 选素材:preview 图片 > 独立 mp4 > 视频纹理 tex(含 ftyp)
|
|
358
|
+
const vid = entries.findIndex((e) => /\.(mp4|webm|mov)$/i.test(e.name));
|
|
359
|
+
const img = entries.findIndex((e) => /\.(gif|png|jpe?g|webp)$/i.test(e.name));
|
|
360
|
+
let sel = vid >= 0 ? { index: vid, name: entries[vid].name, isMp4: true }
|
|
361
|
+
: img >= 0 ? { index: img, name: entries[img].name, isMp4: false }
|
|
362
|
+
: null;
|
|
363
|
+
if (sel === null) {
|
|
364
|
+
// ④(新) 场景壁纸(无 preview/mp4)→ 尝试视频纹理 tex(内嵌 mp4)
|
|
365
|
+
const whole = readFileSync(filePath);
|
|
366
|
+
for (let i = 0; i < entries.length; i++) {
|
|
367
|
+
const e = entries[i];
|
|
368
|
+
if (!e.name.endsWith('.tex') || e.size < 1024 * 1024) continue;
|
|
369
|
+
if (/蓝幕|绿幕|bluescreen|greenscreen|chroma|keying|抠像|入场|intro|entry/i.test(e.name)) continue;
|
|
370
|
+
const seg = whole.subarray(dataStart + e.index, dataStart + e.index + 65536);
|
|
371
|
+
const p = seg.indexOf(Buffer.from('ftyp'));
|
|
372
|
+
if (p >= 4) { sel = { index: i, name: e.name, isMp4: true, offset: p - 4 }; break; }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (sel === null) { json(res, 200, { ok: true, token, entries, selected: null }); return; }
|
|
376
|
+
json(res, 200, { ok: true, token, entries, selected: sel });
|
|
377
|
+
} catch (err) { json(res, 500, { ok: false, error: String(err && err.message || err) }); }
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// ①(新) 自定义目录媒体:按文件名读取(Range),校验文件在自定义目录内且无路径穿越
|
|
382
|
+
webServer.register({
|
|
383
|
+
kind: 'exact', path: BASE + '/custom-media',
|
|
384
|
+
handler: (req, res) => {
|
|
385
|
+
try {
|
|
386
|
+
// ①(修正) 重启竞态兜底:懒恢复 customDir(防 404 壁纸消失)
|
|
387
|
+
if (!customDir) restoreCustomDir();
|
|
388
|
+
if (!customDir) { json(res, 404, { ok: false, error: 'no dir' }); return; }
|
|
389
|
+
const url = new URL(req.url || '', 'http://localhost');
|
|
390
|
+
const file = url.searchParams.get('file') || '';
|
|
391
|
+
if (!file || file.includes('..') || file.includes('/') || file.includes('\\')) { json(res, 403, { ok: false, error: 'forbidden' }); return; }
|
|
392
|
+
const filePath = join(customDir, file);
|
|
393
|
+
if (!existsSync(filePath) || !statSync(filePath).isFile()) { json(res, 404, { ok: false, error: 'not found' }); return; }
|
|
394
|
+
const st = statSync(filePath);
|
|
395
|
+
const name = file.toLowerCase();
|
|
396
|
+
const mime = name.endsWith('.mp4') ? 'video/mp4' : name.endsWith('.webm') ? 'video/webm'
|
|
397
|
+
: name.endsWith('.mov') ? 'video/quicktime' : name.endsWith('.gif') ? 'image/gif'
|
|
398
|
+
: name.endsWith('.png') ? 'image/png' : name.endsWith('.webp') ? 'image/webp'
|
|
399
|
+
: name.endsWith('.jpg') || name.endsWith('.jpeg') ? 'image/jpeg' : 'application/octet-stream';
|
|
400
|
+
const range = req.headers.range;
|
|
401
|
+
if (range) {
|
|
402
|
+
const m = /bytes=(\d*)-(\d*)/.exec(range);
|
|
403
|
+
const rs = m && m[1] ? parseInt(m[1], 10) : 0;
|
|
404
|
+
const re = m && m[2] ? parseInt(m[2], 10) : st.size - 1;
|
|
405
|
+
const s = Math.max(0, rs); const e = Math.min(st.size - 1, re);
|
|
406
|
+
if (s > e) { res.writeHead(416, { 'content-range': `bytes */${st.size}` }); res.end(); return; }
|
|
407
|
+
res.writeHead(206, { 'content-type': mime, 'accept-ranges': 'bytes', 'content-range': `bytes ${s}-${e}/${st.size}`, 'content-length': e - s + 1 });
|
|
408
|
+
createReadStream(filePath, { start: s, end: e }).pipe(res);
|
|
409
|
+
} else {
|
|
410
|
+
res.writeHead(200, { 'content-type': mime, 'accept-ranges': 'bytes', 'content-length': st.size });
|
|
411
|
+
createReadStream(filePath).pipe(res);
|
|
412
|
+
}
|
|
413
|
+
} catch (err) { json(res, 500, { ok: false, error: String(err && err.message || err) }); }
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// ④(新) 本地壁纸库媒体:按 ltoken + 文件名读取(Range),校验防路径穿越
|
|
418
|
+
webServer.register({
|
|
419
|
+
kind: 'exact', path: BASE + '/library-media',
|
|
420
|
+
handler: (req, res) => {
|
|
421
|
+
try {
|
|
422
|
+
const url = new URL(req.url || '', 'http://localhost');
|
|
423
|
+
const lt = url.searchParams.get('ltoken') || '';
|
|
424
|
+
const file = url.searchParams.get('file') || '';
|
|
425
|
+
const rec = library.get(lt);
|
|
426
|
+
if (!rec || !rec.media) { json(res, 404, { ok: false, error: 'not found' }); return; }
|
|
427
|
+
// 安全:file 必须是 media 基础名(禁止 ../ 路径穿越)
|
|
428
|
+
if (file !== rec.media.split(/[\\/]/).pop()) { json(res, 403, { ok: false, error: 'forbidden' }); return; }
|
|
429
|
+
const filePath = rec.media;
|
|
430
|
+
if (!existsSync(filePath)) { json(res, 404, { ok: false, error: 'not found' }); return; }
|
|
431
|
+
const st = statSync(filePath);
|
|
432
|
+
const mime = filePath.toLowerCase().endsWith('.mp4') ? 'video/mp4'
|
|
433
|
+
: filePath.toLowerCase().endsWith('.webm') ? 'video/webm'
|
|
434
|
+
: filePath.toLowerCase().endsWith('.mov') ? 'video/quicktime'
|
|
435
|
+
: filePath.toLowerCase().endsWith('.html') || filePath.toLowerCase().endsWith('.htm') ? 'text/html'
|
|
436
|
+
: 'application/octet-stream';
|
|
437
|
+
const range = req.headers.range;
|
|
438
|
+
if (range) {
|
|
439
|
+
const m = /bytes=(\d*)-(\d*)/.exec(range);
|
|
440
|
+
const rs = m && m[1] ? parseInt(m[1], 10) : 0;
|
|
441
|
+
const re = m && m[2] ? parseInt(m[2], 10) : st.size - 1;
|
|
442
|
+
const s = Math.max(0, rs); const e = Math.min(st.size - 1, re);
|
|
443
|
+
if (s > e) { res.writeHead(416, { 'content-range': `bytes */${st.size}` }); res.end(); return; }
|
|
444
|
+
res.writeHead(206, {
|
|
445
|
+
'content-type': mime, 'accept-ranges': 'bytes',
|
|
446
|
+
'content-range': `bytes ${s}-${e}/${st.size}`, 'content-length': e - s + 1,
|
|
447
|
+
});
|
|
448
|
+
createReadStream(filePath, { start: s, end: e }).pipe(res);
|
|
449
|
+
} else {
|
|
450
|
+
res.writeHead(200, { 'content-type': mime, 'accept-ranges': 'bytes', 'content-length': st.size });
|
|
451
|
+
createReadStream(filePath).pipe(res);
|
|
452
|
+
}
|
|
453
|
+
} catch (err) {
|
|
454
|
+
json(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// ③(新) Steam 自动发现:返回壁纸引擎安装目录 + 可移植壁纸列表(video/web)
|
|
460
|
+
webServer.register({
|
|
461
|
+
kind: 'exact', path: BASE + '/steam-inventory',
|
|
462
|
+
handler: (req, res) => {
|
|
463
|
+
try {
|
|
464
|
+
const installDir = locateWallpaperEngine();
|
|
465
|
+
if (!installDir) { json(res, 200, { ok: true, installDir: null, wallpapers: [] }); return; }
|
|
466
|
+
const projectsRoots = [
|
|
467
|
+
join(installDir, 'projects', 'myprojects'),
|
|
468
|
+
join(installDir, 'projects', 'defaultprojects'),
|
|
469
|
+
join(installDir, 'steamapps', 'workshop', 'content', WE_APPID),
|
|
470
|
+
];
|
|
471
|
+
const wallpapers = [];
|
|
472
|
+
const scan = (root) => {
|
|
473
|
+
if (!existsSync(root)) return;
|
|
474
|
+
for (const dir of readdirSync(root)) {
|
|
475
|
+
const p = join(root, dir);
|
|
476
|
+
const proj = join(p, 'project.json');
|
|
477
|
+
if (!existsSync(proj)) continue;
|
|
478
|
+
try {
|
|
479
|
+
const meta = JSON.parse(readFileSync(proj, 'utf8'));
|
|
480
|
+
const type = (meta.type || 'scene').toLowerCase();
|
|
481
|
+
// ④(安全) 排除 application / exe 壁纸:这类已弃用的 .exe 格式可能被注入病毒,
|
|
482
|
+
// 本插件绝不读取/执行任何 .exe。scene 只给预览图(无法渲染)。
|
|
483
|
+
if (type === 'application' || type === 'exe' || type === 'app') continue;
|
|
484
|
+
// 可移植类型(video/web)附带 media 路径(mp4/html);scene 只给 preview
|
|
485
|
+
let media = null;
|
|
486
|
+
if (type === 'video') {
|
|
487
|
+
const v = readdirSync(p).find((f) => /\.(mp4|webm|mov)$/i.test(f));
|
|
488
|
+
if (v) media = join(p, v);
|
|
489
|
+
} else if (type === 'web') {
|
|
490
|
+
const h = readdirSync(p).find((f) => /\.(html?|htm)$/i.test(f));
|
|
491
|
+
if (h) media = join(p, h);
|
|
492
|
+
}
|
|
493
|
+
const ltoken = crypto.randomBytes(8).toString('hex');
|
|
494
|
+
library.set(ltoken, { dir: p, type, title: meta.title || dir, media, preview: join(p, 'preview.jpg') });
|
|
495
|
+
wallpapers.push({ title: meta.title || dir, type, ltoken, preview: join(p, 'preview.jpg'), media });
|
|
496
|
+
} catch { /* skip */ }
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
for (const root of projectsRoots) scan(root);
|
|
500
|
+
json(res, 200, { ok: true, installDir, wallpapers });
|
|
501
|
+
} catch (err) {
|
|
502
|
+
json(res, 500, { ok: false, error: String(err && err.message || err) });
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export { apply };
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-mpkg-wallpaper",
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"description": "DSH Web 壁纸引擎 Wallpaper Engine mpkg 背景插件:浏览器内直接解析 .mpkg(preview.gif 动态背景/内嵌 mp4 视频/多时段切换),整屏统一虚化/对话框/弹层/遮罩独立虚化、镜头缩放平移、时钟、冲突检测。",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"cordis.patch.yml",
|
|
10
|
+
"README.md",
|
|
11
|
+
"README.en.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./lib/index.js",
|
|
16
|
+
"./client": "./lib/client.js",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"dsh": {
|
|
20
|
+
"bundle": {
|
|
21
|
+
"patch": "./cordis.patch.yml"
|
|
22
|
+
},
|
|
23
|
+
"client": {
|
|
24
|
+
"inject": [
|
|
25
|
+
"@deepseek-ai/dsh-client-connection",
|
|
26
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
27
|
+
"@deepseek-ai/dsh-client-locale",
|
|
28
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
29
|
+
"@deepseek-ai/dsh-api-remotes"
|
|
30
|
+
],
|
|
31
|
+
"platform": "web",
|
|
32
|
+
"immediately": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"react": "^18.2.0",
|
|
37
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
38
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
39
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"keywords": [
|
|
43
|
+
"dsh",
|
|
44
|
+
"dsh-plugin",
|
|
45
|
+
"wallpaper-engine",
|
|
46
|
+
"mpkg",
|
|
47
|
+
"background",
|
|
48
|
+
"web-ui"
|
|
49
|
+
],
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "https://github.com/XHR666/dsh-mpkg-wallpaper.git"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://github.com/XHR666/dsh-mpkg-wallpaper",
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/XHR666/dsh-mpkg-wallpaper/issues"
|
|
57
|
+
}
|
|
58
|
+
}
|