@qwqweeo123/dsh-wallpaper 1.0.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 +35 -0
- package/README.zh.md +35 -0
- package/client.js +1902 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +7 -0
- package/index.js +606 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +57 -0
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<rect x="4" y="4" width="56" height="56" rx="14" fill="#3d8f6d"/>
|
|
3
|
+
<rect x="12" y="14" width="40" height="30" rx="4" fill="#dcefe6"/>
|
|
4
|
+
<circle cx="22" cy="23" r="4" fill="#f5c542"/>
|
|
5
|
+
<path d="M12 40l10-11 8 8 8-10 14 13v4H12z" fill="#3d8f6d"/>
|
|
6
|
+
<rect x="12" y="50" width="40" height="4" rx="2" fill="#a9d9c3"/>
|
|
7
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of @local/dsh-wallpaper.
|
|
3
|
+
*
|
|
4
|
+
* Registers same-origin HTTP routes on the DSH web server (`ctx.webServer`)
|
|
5
|
+
* that proxy the wallhaven.cc wallpaper market and own all wallpaper on-disk
|
|
6
|
+
* state, mirroring the security posture of the codeg Tauri backend this
|
|
7
|
+
* feature is ported from:
|
|
8
|
+
*
|
|
9
|
+
* - `purity=100` (SFW) is hard-coded into every upstream search; the app
|
|
10
|
+
* structurally cannot request NSFW results.
|
|
11
|
+
* - The upstream hosts are allowlisted (`wallhaven.cc` + subdomains, https
|
|
12
|
+
* only, no userinfo), redirects are followed manually with the allowlist
|
|
13
|
+
* re-applied per hop.
|
|
14
|
+
* - Downloads and local uploads share one validation path (magic-byte sniff,
|
|
15
|
+
* 16 MiB / 40 Mpx caps) and one atomic write, exactly like codeg's
|
|
16
|
+
* `backgrounds::marketplace`.
|
|
17
|
+
* - Mutating routes require a browser-same-origin fetch (`Sec-Fetch-Site`
|
|
18
|
+
* absent/`none`/`same-origin`/`same-site`), which cross-site pages cannot
|
|
19
|
+
* forge.
|
|
20
|
+
*
|
|
21
|
+
* State lives under `$DSH_HOME/wallpaper/` (wallpaper/, config.json) so it
|
|
22
|
+
* survives profile edits and app upgrades.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createHash } from 'node:crypto'
|
|
26
|
+
import { promises as fs } from 'node:fs'
|
|
27
|
+
import os from 'node:os'
|
|
28
|
+
import path from 'node:path'
|
|
29
|
+
|
|
30
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const ROUTE_PREFIX = '/dsh-wallpaper'
|
|
33
|
+
const USER_AGENT = 'dsh-wallpaper/1.0'
|
|
34
|
+
|
|
35
|
+
const WALLHAVEN_SEARCH_URL = 'https://wallhaven.cc/api/v1/search'
|
|
36
|
+
const WALLHAVEN_PURITY = '100' // SFW only, permanently — never taken from params.
|
|
37
|
+
const MAX_WP_SEARCH_BYTES = 4 * 1024 * 1024
|
|
38
|
+
const MAX_WP_THUMB_BYTES = 4 * 1024 * 1024
|
|
39
|
+
const MAX_WP_BYTES = 16 * 1024 * 1024
|
|
40
|
+
const MAX_WP_PIXELS = 40_000_000
|
|
41
|
+
const MAX_QUERY_CHARS = 128
|
|
42
|
+
const MAX_REDIRECT_HOPS = 5
|
|
43
|
+
|
|
44
|
+
const IMAGE_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
|
45
|
+
|
|
46
|
+
// ─── Small utilities ────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
class HttpError extends Error {
|
|
49
|
+
constructor(status, code, message) {
|
|
50
|
+
super(message)
|
|
51
|
+
this.status = status
|
|
52
|
+
this.code = code
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const invalid = (msg) => new HttpError(400, 'invalid-input', msg)
|
|
57
|
+
const network = (msg) => new HttpError(502, 'network', msg)
|
|
58
|
+
const notFound = (msg) => new HttpError(404, 'not-found', msg)
|
|
59
|
+
|
|
60
|
+
function storageRoot() {
|
|
61
|
+
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
62
|
+
return path.join(home, 'wallpaper')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function wallpaperDir() {
|
|
66
|
+
return path.join(storageRoot(), 'wallpaper')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function configPath() {
|
|
70
|
+
return path.join(storageRoot(), 'config.json')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function pathExists(p) {
|
|
74
|
+
try {
|
|
75
|
+
await fs.access(p)
|
|
76
|
+
return true
|
|
77
|
+
} catch {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Atomic file write: tmp file in the same directory, fsync, rename. */
|
|
83
|
+
async function writeFileAtomic(target, bytes) {
|
|
84
|
+
const tmp = `${target}.tmp-${process.pid}-${Date.now()}`
|
|
85
|
+
const handle = await fs.open(tmp, 'w')
|
|
86
|
+
try {
|
|
87
|
+
await handle.write(bytes)
|
|
88
|
+
await handle.sync()
|
|
89
|
+
} finally {
|
|
90
|
+
await handle.close()
|
|
91
|
+
}
|
|
92
|
+
await fs.rename(tmp, target)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Config ─────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
const CONFIG_DEFAULTS = {
|
|
98
|
+
wallpaperEnabled: false,
|
|
99
|
+
wallpaperOpacity: 0.85, // surface alpha over the wallpaper, 0.5..1
|
|
100
|
+
wallpaperSourceUrl: null,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sanitizeConfig(input) {
|
|
104
|
+
const out = { ...CONFIG_DEFAULTS }
|
|
105
|
+
if (!input || typeof input !== 'object') return out
|
|
106
|
+
if (typeof input.wallpaperEnabled === 'boolean') out.wallpaperEnabled = input.wallpaperEnabled
|
|
107
|
+
if (
|
|
108
|
+
typeof input.wallpaperOpacity === 'number' &&
|
|
109
|
+
input.wallpaperOpacity >= 0.5 &&
|
|
110
|
+
input.wallpaperOpacity <= 1
|
|
111
|
+
) {
|
|
112
|
+
out.wallpaperOpacity = Math.round(input.wallpaperOpacity * 100) / 100
|
|
113
|
+
}
|
|
114
|
+
if (typeof input.wallpaperSourceUrl === 'string' && input.wallpaperSourceUrl.length <= 512) {
|
|
115
|
+
out.wallpaperSourceUrl = input.wallpaperSourceUrl
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function readConfig() {
|
|
121
|
+
try {
|
|
122
|
+
const raw = await fs.readFile(configPath(), 'utf8')
|
|
123
|
+
return sanitizeConfig(JSON.parse(raw))
|
|
124
|
+
} catch {
|
|
125
|
+
return { ...CONFIG_DEFAULTS }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let configWriteChain = Promise.resolve()
|
|
130
|
+
|
|
131
|
+
/** Serialize config writes so concurrent PUTs cannot interleave tmp files. */
|
|
132
|
+
async function writeConfig(next) {
|
|
133
|
+
const clean = sanitizeConfig(next)
|
|
134
|
+
const run = configWriteChain.then(async () => {
|
|
135
|
+
await fs.mkdir(storageRoot(), { recursive: true })
|
|
136
|
+
await writeFileAtomic(configPath(), Buffer.from(JSON.stringify(clean, null, 2) + '\n'))
|
|
137
|
+
})
|
|
138
|
+
configWriteChain = run.catch(() => {})
|
|
139
|
+
await run
|
|
140
|
+
return clean
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── HTTP fetch with allowlist + caps ───────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fetch `url` manually following redirects, re-validating every hop against
|
|
147
|
+
* the wallhaven allowlist, streaming the body with a hard cap.
|
|
148
|
+
*/
|
|
149
|
+
async function fetchCapped(url, { cap, timeoutMs, label, acceptImage }) {
|
|
150
|
+
let current = url
|
|
151
|
+
for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop++) {
|
|
152
|
+
if (!isWallhavenUrl(current)) {
|
|
153
|
+
throw invalid(`${label}: URL host is not allowlisted.`)
|
|
154
|
+
}
|
|
155
|
+
let response
|
|
156
|
+
try {
|
|
157
|
+
response = await fetch(current, {
|
|
158
|
+
redirect: 'manual',
|
|
159
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
160
|
+
headers: { 'user-agent': USER_AGENT, accept: acceptImage ? 'image/*' : '*/*' },
|
|
161
|
+
})
|
|
162
|
+
} catch (err) {
|
|
163
|
+
throw network(`${label} failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
164
|
+
}
|
|
165
|
+
if (response.status >= 300 && response.status < 400) {
|
|
166
|
+
const location = response.headers.get('location')
|
|
167
|
+
response.body?.cancel().catch(() => {})
|
|
168
|
+
if (!location) throw network(`${label}: redirect without Location header.`)
|
|
169
|
+
current = new URL(location, current).toString()
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
response.body?.cancel().catch(() => {})
|
|
174
|
+
throw network(`${label} returned HTTP ${response.status}`)
|
|
175
|
+
}
|
|
176
|
+
const declared = Number(response.headers.get('content-length') || 0)
|
|
177
|
+
if (declared > cap) {
|
|
178
|
+
response.body?.cancel().catch(() => {})
|
|
179
|
+
throw invalid(`${label} is ${declared} bytes, exceeds ${cap} byte cap.`)
|
|
180
|
+
}
|
|
181
|
+
const mime = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
|
182
|
+
const chunks = []
|
|
183
|
+
let total = 0
|
|
184
|
+
const reader = response.body?.getReader()
|
|
185
|
+
if (!reader) throw network(`${label}: empty response body.`)
|
|
186
|
+
try {
|
|
187
|
+
for (;;) {
|
|
188
|
+
const { done, value } = await reader.read()
|
|
189
|
+
if (done) break
|
|
190
|
+
total += value.byteLength
|
|
191
|
+
if (total > cap) {
|
|
192
|
+
throw invalid(`${label} exceeds ${cap} byte cap.`)
|
|
193
|
+
}
|
|
194
|
+
chunks.push(Buffer.from(value))
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
reader.releaseLock()
|
|
198
|
+
}
|
|
199
|
+
return { mime, bytes: Buffer.concat(chunks), url: current }
|
|
200
|
+
}
|
|
201
|
+
throw network(`${label}: more than ${MAX_REDIRECT_HOPS} redirects.`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isWallhavenUrl(raw) {
|
|
205
|
+
let url
|
|
206
|
+
try {
|
|
207
|
+
url = new URL(raw)
|
|
208
|
+
} catch {
|
|
209
|
+
return false
|
|
210
|
+
}
|
|
211
|
+
if (url.protocol !== 'https:') return false
|
|
212
|
+
if (url.username !== '' || url.password !== '') return false
|
|
213
|
+
const host = url.hostname
|
|
214
|
+
return host === 'wallhaven.cc' || host.endsWith('.wallhaven.cc')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function sniffImageMime(bytes) {
|
|
218
|
+
if (bytes.length >= 12) {
|
|
219
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
|
|
220
|
+
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
|
221
|
+
return 'image/png'
|
|
222
|
+
}
|
|
223
|
+
if (bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') {
|
|
224
|
+
return 'image/webp'
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Decode PNG/JPEG/WebP pixel dimensions from the header; null when unreadable. */
|
|
231
|
+
function sniffImagePixels(bytes, mime) {
|
|
232
|
+
try {
|
|
233
|
+
if (mime === 'image/png' && bytes.length >= 24) {
|
|
234
|
+
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }
|
|
235
|
+
}
|
|
236
|
+
if (mime === 'image/webp' && bytes.length >= 30) {
|
|
237
|
+
const fourcc = bytes.toString('ascii', 12, 16)
|
|
238
|
+
if (fourcc === 'VP8X') {
|
|
239
|
+
return {
|
|
240
|
+
width: 1 + bytes.readUIntLE(24, 3),
|
|
241
|
+
height: 1 + bytes.readUIntLE(27, 3),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (fourcc === 'VP8 ' && bytes.length >= 30) {
|
|
245
|
+
return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff }
|
|
246
|
+
}
|
|
247
|
+
if (fourcc === 'VP8L' && bytes.length >= 25) {
|
|
248
|
+
const b0 = bytes[21]
|
|
249
|
+
const b1 = bytes[22]
|
|
250
|
+
const b2 = bytes[23]
|
|
251
|
+
const b3 = bytes[24]
|
|
252
|
+
return {
|
|
253
|
+
width: 1 + (((b1 & 0x3f) << 8) | b0),
|
|
254
|
+
height: 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
if (mime === 'image/jpeg') {
|
|
260
|
+
let offset = 2
|
|
261
|
+
while (offset + 9 < bytes.length) {
|
|
262
|
+
if (bytes[offset] !== 0xff) break
|
|
263
|
+
const marker = bytes[offset + 1]
|
|
264
|
+
const length = bytes.readUInt16BE(offset + 2)
|
|
265
|
+
if (length < 2) break
|
|
266
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
267
|
+
return { height: bytes.readUInt16BE(offset + 5), width: bytes.readUInt16BE(offset + 7) }
|
|
268
|
+
}
|
|
269
|
+
offset += 2 + length
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
/* fall through: unknown dimensions are not a rejection by themselves */
|
|
274
|
+
}
|
|
275
|
+
return null
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function validateWallpaperBytes(bytes) {
|
|
279
|
+
const mime = sniffImageMime(bytes)
|
|
280
|
+
if (!mime) throw invalid('Image is not a JPEG, PNG, or WebP.')
|
|
281
|
+
if (bytes.length > MAX_WP_BYTES) {
|
|
282
|
+
throw invalid(`Image exceeds the ${MAX_WP_BYTES} byte cap.`)
|
|
283
|
+
}
|
|
284
|
+
const pixels = sniffImagePixels(bytes, mime)
|
|
285
|
+
if (pixels && pixels.width * pixels.height > MAX_WP_PIXELS) {
|
|
286
|
+
throw invalid(`Image exceeds the ${MAX_WP_PIXELS} pixel cap.`)
|
|
287
|
+
}
|
|
288
|
+
const ext = mime === 'image/jpeg' ? '.jpg' : mime === 'image/png' ? '.png' : '.webp'
|
|
289
|
+
return { mime, ext }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ─── Marketplace: wallhaven.cc ──────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
function wallhavenCategories(category) {
|
|
295
|
+
switch (category) {
|
|
296
|
+
case undefined:
|
|
297
|
+
case null:
|
|
298
|
+
case '':
|
|
299
|
+
case 'all':
|
|
300
|
+
return '111'
|
|
301
|
+
case 'general':
|
|
302
|
+
return '100'
|
|
303
|
+
case 'anime':
|
|
304
|
+
return '010'
|
|
305
|
+
case 'people':
|
|
306
|
+
return '001'
|
|
307
|
+
default:
|
|
308
|
+
throw invalid(`Unknown wallpaper market category: ${category}`)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function wallpaperMarketSearch(query) {
|
|
313
|
+
const q = (query.get('query') || '').trim()
|
|
314
|
+
if (q.length > MAX_QUERY_CHARS) {
|
|
315
|
+
throw invalid(`Search query exceeds ${MAX_QUERY_CHARS} characters.`)
|
|
316
|
+
}
|
|
317
|
+
const page = Math.max(1, Number(query.get('page')) || 1)
|
|
318
|
+
const categories = wallhavenCategories(query.get('category'))
|
|
319
|
+
const params = new URLSearchParams({ categories, purity: WALLHAVEN_PURITY, page: String(page) })
|
|
320
|
+
if (q) {
|
|
321
|
+
params.set('q', q)
|
|
322
|
+
params.set('sorting', 'relevance')
|
|
323
|
+
} else {
|
|
324
|
+
params.set('sorting', 'toplist')
|
|
325
|
+
params.set('topRange', '1M')
|
|
326
|
+
}
|
|
327
|
+
const { bytes } = await fetchCapped(`${WALLHAVEN_SEARCH_URL}?${params}`, {
|
|
328
|
+
cap: MAX_WP_SEARCH_BYTES,
|
|
329
|
+
timeoutMs: 30_000,
|
|
330
|
+
label: 'wallhaven search',
|
|
331
|
+
})
|
|
332
|
+
let payload
|
|
333
|
+
try {
|
|
334
|
+
payload = JSON.parse(bytes.toString('utf8'))
|
|
335
|
+
} catch {
|
|
336
|
+
throw network('wallhaven returned malformed JSON.')
|
|
337
|
+
}
|
|
338
|
+
const items = []
|
|
339
|
+
for (const item of Array.isArray(payload.data) ? payload.data : []) {
|
|
340
|
+
const fullUrl = item?.path
|
|
341
|
+
const thumbUrl = item?.thumbs?.small
|
|
342
|
+
if (typeof fullUrl !== 'string' || typeof thumbUrl !== 'string') continue
|
|
343
|
+
// A listing entry pointing off wallhaven is dropped, not trusted.
|
|
344
|
+
if (!isWallhavenUrl(fullUrl) || !isWallhavenUrl(thumbUrl)) continue
|
|
345
|
+
const id = String(item.id ?? '')
|
|
346
|
+
items.push({
|
|
347
|
+
id,
|
|
348
|
+
thumbUrl,
|
|
349
|
+
fullUrl,
|
|
350
|
+
sourceUrl: `https://wallhaven.cc/w/${encodeURIComponent(id)}`,
|
|
351
|
+
width: Number(item.dimension_x) || 0,
|
|
352
|
+
height: Number(item.dimension_y) || 0,
|
|
353
|
+
fileSizeBytes: Number(item.file_size) || 0,
|
|
354
|
+
category: String(item.category ?? ''),
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
const meta = payload.meta || {}
|
|
358
|
+
return {
|
|
359
|
+
items,
|
|
360
|
+
page: Math.max(1, Number(meta.current_page) || 1),
|
|
361
|
+
lastPage: Math.max(1, Number(meta.last_page) || 1),
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function wallpaperMarketAsset(query) {
|
|
366
|
+
const url = query.get('url') || ''
|
|
367
|
+
if (!isWallhavenUrl(url)) throw invalid('Asset URL must be a wallhaven.cc URL.')
|
|
368
|
+
const { mime, bytes } = await fetchCapped(url, {
|
|
369
|
+
cap: MAX_WP_THUMB_BYTES,
|
|
370
|
+
timeoutMs: 30_000,
|
|
371
|
+
label: 'wallhaven thumbnail',
|
|
372
|
+
acceptImage: true,
|
|
373
|
+
})
|
|
374
|
+
if (!IMAGE_MIMES.has(mime)) throw network(`Thumbnail returned unsupported content-type '${mime}'.`)
|
|
375
|
+
return { mime, bytes }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function writeWallpaper(bytes) {
|
|
379
|
+
const { mime, ext } = validateWallpaperBytes(bytes)
|
|
380
|
+
const dir = wallpaperDir()
|
|
381
|
+
await fs.mkdir(dir, { recursive: true })
|
|
382
|
+
// Remove previous background files with other extensions, then atomic write.
|
|
383
|
+
for (const old of ['background.jpg', 'background.png', 'background.webp']) {
|
|
384
|
+
if (old !== `background${ext}`) await fs.rm(path.join(dir, old), { force: true })
|
|
385
|
+
}
|
|
386
|
+
await writeFileAtomic(path.join(dir, `background${ext}`), bytes)
|
|
387
|
+
return { mime, ext }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function readWallpaper() {
|
|
391
|
+
const dir = wallpaperDir()
|
|
392
|
+
for (const [file, mime] of [
|
|
393
|
+
['background.jpg', 'image/jpeg'],
|
|
394
|
+
['background.png', 'image/png'],
|
|
395
|
+
['background.webp', 'image/webp'],
|
|
396
|
+
]) {
|
|
397
|
+
const p = path.join(dir, file)
|
|
398
|
+
if (await pathExists(p)) {
|
|
399
|
+
return { mime, bytes: await fs.readFile(p) }
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return null
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function wallpaperMarketDownload(body) {
|
|
406
|
+
const url = typeof body?.url === 'string' ? body.url : ''
|
|
407
|
+
const sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl : ''
|
|
408
|
+
if (!isWallhavenUrl(url)) throw invalid('Download URL must be a wallhaven.cc URL.')
|
|
409
|
+
let source
|
|
410
|
+
try {
|
|
411
|
+
source = new URL(sourceUrl)
|
|
412
|
+
} catch {
|
|
413
|
+
throw invalid('sourceUrl must be a https://wallhaven.cc/w/<id> page URL.')
|
|
414
|
+
}
|
|
415
|
+
if (
|
|
416
|
+
source.protocol !== 'https:' ||
|
|
417
|
+
source.hostname !== 'wallhaven.cc' ||
|
|
418
|
+
!source.pathname.startsWith('/w/')
|
|
419
|
+
) {
|
|
420
|
+
throw invalid('sourceUrl must be a https://wallhaven.cc/w/<id> page URL.')
|
|
421
|
+
}
|
|
422
|
+
const { bytes } = await fetchCapped(url, {
|
|
423
|
+
cap: MAX_WP_BYTES,
|
|
424
|
+
timeoutMs: 300_000,
|
|
425
|
+
label: 'wallpaper download',
|
|
426
|
+
acceptImage: true,
|
|
427
|
+
})
|
|
428
|
+
// Byte sniff is the final authority, not the transport content-type.
|
|
429
|
+
const written = await writeWallpaper(bytes)
|
|
430
|
+
return { mime: written.mime }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** ETag for the current wallpaper so windows can poll cheaply. */
|
|
434
|
+
async function wallpaperVersion() {
|
|
435
|
+
const current = await readWallpaper()
|
|
436
|
+
if (!current) return 'none'
|
|
437
|
+
return createHash('sha1').update(current.bytes).digest('hex').slice(0, 12)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ─── HTTP route plumbing ────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Reject cross-site browser requests to mutating endpoints. `Sec-Fetch-Site`
|
|
444
|
+
* cannot be forged by a cross-origin page; non-browser local callers simply
|
|
445
|
+
* omit it. Read endpoints stay open because they proxy public data only.
|
|
446
|
+
*/
|
|
447
|
+
function mutationAllowed(req) {
|
|
448
|
+
const site = req.headers['sec-fetch-site']
|
|
449
|
+
if (site === undefined) return true
|
|
450
|
+
return site === 'same-origin' || site === 'same-site' || site === 'none'
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function sendJson(res, status, value) {
|
|
454
|
+
const body = Buffer.from(JSON.stringify(value))
|
|
455
|
+
res.writeHead(status, {
|
|
456
|
+
'content-type': 'application/json; charset=utf-8',
|
|
457
|
+
'content-length': body.length,
|
|
458
|
+
'cache-control': 'no-store',
|
|
459
|
+
})
|
|
460
|
+
res.end(body)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function sendBytes(res, mime, bytes) {
|
|
464
|
+
res.writeHead(200, {
|
|
465
|
+
'content-type': mime,
|
|
466
|
+
'content-length': bytes.length,
|
|
467
|
+
// Loopback-only static bytes; `*` lets the Electron file:// shell read them too.
|
|
468
|
+
'access-control-allow-origin': '*',
|
|
469
|
+
'cache-control': 'no-store',
|
|
470
|
+
})
|
|
471
|
+
res.end(bytes)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function sendError(res, err) {
|
|
475
|
+
const status = err instanceof HttpError ? err.status : 500
|
|
476
|
+
const code = err instanceof HttpError ? err.code : 'internal'
|
|
477
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
478
|
+
sendJson(res, status, { ok: false, error: { code, message } })
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function readJsonBody(req, cap = 1024 * 1024) {
|
|
482
|
+
const chunks = []
|
|
483
|
+
let total = 0
|
|
484
|
+
for await (const chunk of req) {
|
|
485
|
+
total += chunk.length
|
|
486
|
+
if (total > cap) throw invalid('Request body is too large.')
|
|
487
|
+
chunks.push(chunk)
|
|
488
|
+
}
|
|
489
|
+
if (chunks.length === 0) return {}
|
|
490
|
+
try {
|
|
491
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
492
|
+
} catch {
|
|
493
|
+
throw invalid('Request body is not valid JSON.')
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function readRawBody(req, cap) {
|
|
498
|
+
const declared = Number(req.headers['content-length'] || 0)
|
|
499
|
+
if (declared > cap) throw invalid(`Upload exceeds the ${cap} byte cap.`)
|
|
500
|
+
const chunks = []
|
|
501
|
+
let total = 0
|
|
502
|
+
for await (const chunk of req) {
|
|
503
|
+
total += chunk.length
|
|
504
|
+
if (total > cap) throw invalid(`Upload exceeds the ${cap} byte cap.`)
|
|
505
|
+
chunks.push(chunk)
|
|
506
|
+
}
|
|
507
|
+
return Buffer.concat(chunks)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function handleApi(req, res, pathname, query) {
|
|
511
|
+
const route = `${req.method} ${pathname}`
|
|
512
|
+
|
|
513
|
+
// ── Config ──
|
|
514
|
+
if (route === `GET ${ROUTE_PREFIX}/api/config`) {
|
|
515
|
+
return sendJson(res, 200, { ok: true, config: await readConfig() })
|
|
516
|
+
}
|
|
517
|
+
if (route === `PUT ${ROUTE_PREFIX}/api/config`) {
|
|
518
|
+
if (!mutationAllowed(req)) throw new HttpError(403, 'forbidden', 'Cross-site request refused.')
|
|
519
|
+
const patch = await readJsonBody(req)
|
|
520
|
+
const current = await readConfig()
|
|
521
|
+
const merged = sanitizeConfig({ ...current, ...patch })
|
|
522
|
+
return sendJson(res, 200, { ok: true, config: await writeConfig(merged) })
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ── Wallpaper marketplace ──
|
|
526
|
+
if (route === `GET ${ROUTE_PREFIX}/api/market/search`) {
|
|
527
|
+
return sendJson(res, 200, { ok: true, ...(await wallpaperMarketSearch(query)) })
|
|
528
|
+
}
|
|
529
|
+
if (route === `GET ${ROUTE_PREFIX}/api/market/asset`) {
|
|
530
|
+
const { mime, bytes } = await wallpaperMarketAsset(query)
|
|
531
|
+
return sendBytes(res, mime, bytes)
|
|
532
|
+
}
|
|
533
|
+
if (route === `POST ${ROUTE_PREFIX}/api/market/download`) {
|
|
534
|
+
if (!mutationAllowed(req)) throw new HttpError(403, 'forbidden', 'Cross-site request refused.')
|
|
535
|
+
return sendJson(res, 200, { ok: true, ...(await wallpaperMarketDownload(await readJsonBody(req))) })
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ── Current wallpaper ──
|
|
539
|
+
if (route === `GET ${ROUTE_PREFIX}/api/current`) {
|
|
540
|
+
const current = await readWallpaper()
|
|
541
|
+
if (!current) throw notFound('No wallpaper is set.')
|
|
542
|
+
return sendBytes(res, current.mime, current.bytes)
|
|
543
|
+
}
|
|
544
|
+
if (route === `GET ${ROUTE_PREFIX}/api/version`) {
|
|
545
|
+
return sendJson(res, 200, { ok: true, version: await wallpaperVersion() })
|
|
546
|
+
}
|
|
547
|
+
if (route === `POST ${ROUTE_PREFIX}/api/upload`) {
|
|
548
|
+
if (!mutationAllowed(req)) throw new HttpError(403, 'forbidden', 'Cross-site request refused.')
|
|
549
|
+
const bytes = await readRawBody(req, MAX_WP_BYTES + 1)
|
|
550
|
+
const written = await writeWallpaper(bytes)
|
|
551
|
+
return sendJson(res, 200, { ok: true, mime: written.mime })
|
|
552
|
+
}
|
|
553
|
+
if (route === `POST ${ROUTE_PREFIX}/api/clear`) {
|
|
554
|
+
if (!mutationAllowed(req)) throw new HttpError(403, 'forbidden', 'Cross-site request refused.')
|
|
555
|
+
for (const old of ['background.jpg', 'background.png', 'background.webp']) {
|
|
556
|
+
await fs.rm(path.join(wallpaperDir(), old), { force: true })
|
|
557
|
+
}
|
|
558
|
+
const config = await readConfig()
|
|
559
|
+
config.wallpaperEnabled = false
|
|
560
|
+
config.wallpaperSourceUrl = null
|
|
561
|
+
await writeConfig(config)
|
|
562
|
+
return sendJson(res, 200, { ok: true })
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
throw notFound(`Unknown route: ${route}`)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function handleRequest(req, res) {
|
|
569
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
570
|
+
const pathname = url.pathname
|
|
571
|
+
if (req.method === 'OPTIONS') {
|
|
572
|
+
// Preflight for the Electron file:// shell; browsers same-origin never send it.
|
|
573
|
+
res.writeHead(204, {
|
|
574
|
+
'access-control-allow-origin': '*',
|
|
575
|
+
'access-control-allow-methods': 'GET, POST, PUT, OPTIONS',
|
|
576
|
+
'access-control-allow-headers': 'content-type',
|
|
577
|
+
'access-control-max-age': '600',
|
|
578
|
+
})
|
|
579
|
+
res.end()
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
await handleApi(req, res, pathname, url.searchParams)
|
|
584
|
+
} catch (err) {
|
|
585
|
+
sendError(res, err)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ─── Plugin entry ───────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
/** @param {import('@deepseek-ai/cordis').Context} ctx */
|
|
592
|
+
export function apply(ctx) {
|
|
593
|
+
// Optional dependency: profiles without a web server (pure Electron shell
|
|
594
|
+
// composition) activate the plugin with no routes instead of failing.
|
|
595
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
596
|
+
webCtx.effect(
|
|
597
|
+
() =>
|
|
598
|
+
webCtx.webServer.register({
|
|
599
|
+
kind: 'prefix',
|
|
600
|
+
path: ROUTE_PREFIX,
|
|
601
|
+
handler: handleRequest,
|
|
602
|
+
}),
|
|
603
|
+
'wallpaper: api routes'
|
|
604
|
+
)
|
|
605
|
+
})
|
|
606
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qwqweeo123/dsh-wallpaper",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Apply a global wallpaper to the whole interface — every surface is re-composited so the image shows through consistently, from the wallhaven.cc marketplace or a local upload.",
|
|
6
|
+
"icon": "./icon.svg",
|
|
7
|
+
"meta": {
|
|
8
|
+
"title": "Wallpaper",
|
|
9
|
+
"description": "Apply a global wallpaper to the whole interface — every surface is re-composited so the image shows through consistently, from the wallhaven.cc marketplace or a local upload."
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./index.js",
|
|
13
|
+
"./client": "./client.js",
|
|
14
|
+
"./package.json": "./package.json",
|
|
15
|
+
"./locale/*.json": "./locale/*.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"index.js",
|
|
19
|
+
"client.js",
|
|
20
|
+
"cordis.patch.yml",
|
|
21
|
+
"icon.svg",
|
|
22
|
+
"locale",
|
|
23
|
+
"desktop",
|
|
24
|
+
"vendor",
|
|
25
|
+
"README.md",
|
|
26
|
+
"README.zh.md",
|
|
27
|
+
"THIRD-PARTY.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"dsh": {
|
|
31
|
+
"bundle": {
|
|
32
|
+
"patch": "./cordis.patch.yml"
|
|
33
|
+
},
|
|
34
|
+
"client": {
|
|
35
|
+
"platform": "web",
|
|
36
|
+
"immediately": true,
|
|
37
|
+
"inject": [
|
|
38
|
+
"@deepseek-ai/dsh-client-locale",
|
|
39
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
40
|
+
"@deepseek-ai/dsh-client-ui-settings-general"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"dsh",
|
|
46
|
+
"dsh-plugin",
|
|
47
|
+
"wallpaper",
|
|
48
|
+
"theme",
|
|
49
|
+
"background",
|
|
50
|
+
"wallhaven"
|
|
51
|
+
],
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"author": "MingXuanZi",
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=20"
|
|
56
|
+
}
|
|
57
|
+
}
|