dsh-arknights-theme 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.
Files changed (58) hide show
  1. package/ICON-ATTRIBUTION.md +45 -0
  2. package/LICENSE +28 -0
  3. package/README.md +283 -0
  4. package/assets/0d555849af53296a423101fb2319e4d3-narrow.webp +0 -0
  5. package/assets/0d555849af53296a423101fb2319e4d3-preview.webp +0 -0
  6. package/assets/0d555849af53296a423101fb2319e4d3-wide.webp +0 -0
  7. package/assets/1aa5e564d740a65d2d7a12164e9a826f-narrow.webp +0 -0
  8. package/assets/1aa5e564d740a65d2d7a12164e9a826f-preview.webp +0 -0
  9. package/assets/1aa5e564d740a65d2d7a12164e9a826f-wide.webp +0 -0
  10. package/assets/411e9b905a14497996bca303cf8429e6-narrow.webp +0 -0
  11. package/assets/411e9b905a14497996bca303cf8429e6-preview.webp +0 -0
  12. package/assets/411e9b905a14497996bca303cf8429e6-wide.webp +0 -0
  13. package/assets/7edeaee2037cd33e39f4bb1f1e358aab-narrow.webp +0 -0
  14. package/assets/7edeaee2037cd33e39f4bb1f1e358aab-preview.webp +0 -0
  15. package/assets/7edeaee2037cd33e39f4bb1f1e358aab-wide.webp +0 -0
  16. package/assets/832d435484f5905093721d315bb60134-narrow.webp +0 -0
  17. package/assets/832d435484f5905093721d315bb60134-preview.webp +0 -0
  18. package/assets/832d435484f5905093721d315bb60134-wide.webp +0 -0
  19. package/assets/83d33525d0e9dbdf83dbf7be5844b51c-narrow.webp +0 -0
  20. package/assets/83d33525d0e9dbdf83dbf7be5844b51c-preview.webp +0 -0
  21. package/assets/83d33525d0e9dbdf83dbf7be5844b51c-wide.webp +0 -0
  22. package/assets/87a14beae58e6309e52915cc62b9cd0b-narrow.webp +0 -0
  23. package/assets/87a14beae58e6309e52915cc62b9cd0b-preview.webp +0 -0
  24. package/assets/87a14beae58e6309e52915cc62b9cd0b-wide.webp +0 -0
  25. package/assets/8af3a3cc2360a8c74e9809fb5281a644-narrow.webp +0 -0
  26. package/assets/8af3a3cc2360a8c74e9809fb5281a644-preview.webp +0 -0
  27. package/assets/8af3a3cc2360a8c74e9809fb5281a644-wide.webp +0 -0
  28. package/assets/90164de0952ef8a66f46b0c1d9e19cb0-narrow.webp +0 -0
  29. package/assets/90164de0952ef8a66f46b0c1d9e19cb0-preview.webp +0 -0
  30. package/assets/90164de0952ef8a66f46b0c1d9e19cb0-wide.webp +0 -0
  31. package/assets/a897ddc19e122d7cb0ba70505db073fe-narrow.webp +0 -0
  32. package/assets/a897ddc19e122d7cb0ba70505db073fe-preview.webp +0 -0
  33. package/assets/a897ddc19e122d7cb0ba70505db073fe-wide.webp +0 -0
  34. package/assets/c90ef1248afbcba5f2034afedd601a05-narrow.webp +0 -0
  35. package/assets/c90ef1248afbcba5f2034afedd601a05-preview.webp +0 -0
  36. package/assets/c90ef1248afbcba5f2034afedd601a05-wide.webp +0 -0
  37. package/assets/d03aad3af10c655b80e37c3fe5dec298-narrow.webp +0 -0
  38. package/assets/d03aad3af10c655b80e37c3fe5dec298-preview.webp +0 -0
  39. package/assets/d03aad3af10c655b80e37c3fe5dec298-wide.webp +0 -0
  40. package/assets/preview-data.json +4874 -0
  41. package/assets/signatures/doctor.png +0 -0
  42. package/assets/signatures/kaltsit.png +0 -0
  43. package/assets/signatures/theresa.png +0 -0
  44. package/assets/skin.css +1749 -0
  45. package/assets/wallpapers.json +775 -0
  46. package/cordis.patch.yml +6 -0
  47. package/icons/folder-dossier-active.svg +6 -0
  48. package/icons/folder-dossier.svg +5 -0
  49. package/icons/hazard-rule.svg +4 -0
  50. package/icons/panel-collapse.svg +6 -0
  51. package/icons/panel-expand.svg +6 -0
  52. package/icons/perm-full.svg +5 -0
  53. package/icons/perm-readonly.svg +5 -0
  54. package/icons/perm-workspace.svg +4 -0
  55. package/icons/wallpaper-step.svg +5 -0
  56. package/lib/client.js +6219 -0
  57. package/lib/index.js +330 -0
  58. package/package.json +66 -0
package/lib/index.js ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * dsh-amiya-wallpaper — host half.
3
+ *
4
+ * The wallpaper skin itself lives in the browser half (`exports["./client"]`).
5
+ * This half exists for one reason: the generated wallpaper assets are several
6
+ * megabytes of WebP, far too much to inline into a client bundle, so the host
7
+ * must serve them. It registers a single read-only prefix route on the browser
8
+ * HTTP carrier and owns nothing else.
9
+ *
10
+ * Route surface (`/amiya-wallpaper`):
11
+ * GET /amiya-wallpaper/ -> assets/wallpapers.json
12
+ * GET /amiya-wallpaper/<file>.webp -> assets/<file>.webp
13
+ * GET /amiya-wallpaper/icons/<n>.svg -> icons/<n>.svg
14
+ *
15
+ * Responses are content-addressed by generation time, so they are served
16
+ * `immutable` with a long max-age: the asset set only changes when the import
17
+ * module is re-run and the bundle is reinstalled.
18
+ *
19
+ * Security: the route serves exactly two directories and rejects any request
20
+ * whose decoded path escapes them, so a traversal segment cannot read outside
21
+ * the package. Only GET and HEAD are answered.
22
+ */
23
+ import os from 'node:os'
24
+ import { readFile, stat, writeFile } from 'node:fs/promises'
25
+ import { tmpdir } from 'node:os'
26
+ import path from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+
29
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
30
+ const PACKAGE_ROOT = path.resolve(HERE, '..')
31
+
32
+ /** Directory of generated wallpaper assets (`wallpapers.json` + WebP files). */
33
+ const ASSET_ROOT = path.join(PACKAGE_ROOT, 'assets')
34
+ /** Directory of authored SVG glyphs. */
35
+ const ICON_ROOT = path.join(PACKAGE_ROOT, 'icons')
36
+
37
+ /** Public route prefix; the client half builds its URLs from the same value. */
38
+ export const ROUTE = '/amiya-wallpaper'
39
+
40
+ const MIME = new Map([
41
+ ['.webp', 'image/webp'],
42
+ ['.svg', 'image/svg+xml'],
43
+ ['.json', 'application/json; charset=utf-8'],
44
+ ['.jpg', 'image/jpeg'],
45
+ ['.jpeg', 'image/jpeg'],
46
+ ['.png', 'image/png'],
47
+ ['.avif', 'image/avif'],
48
+ ])
49
+
50
+ /** One year; the asset set is regenerated only by a reinstall. */
51
+ const MAX_AGE_SECONDS = 31_536_000
52
+
53
+ /** Cordis hard dependency: with no HTTP carrier there is nothing to serve. */
54
+ export const inject = ['webServer']
55
+
56
+ /**
57
+ * Where this plugin reports its own startup outcome. Outside the package on
58
+ * purpose: it is a diagnostic, not an asset, and writing it must never dirty an
59
+ * installation.
60
+ */
61
+ const STATUS_FILE = path.join(tmpdir(), 'dsh-amiya-wallpaper-host.json')
62
+
63
+ /**
64
+ * Host plugin body: register the asset route for exactly this fiber's lifetime,
65
+ * then record whether that worked.
66
+ * @param {import('@deepseek-ai/cordis').Context} ctx
67
+ */
68
+ export function apply(ctx) {
69
+ let registered = false
70
+ let error
71
+
72
+ /*
73
+ * Reach the browser through index.html rather than through the client module
74
+ * graph. Two reasons, both learned the hard way (the mount contract follows the
75
+ * beautiCode DSH bridge):
76
+ *
77
+ * * the stylesheet arrives as a plain <link>, so it applies before the first
78
+ * paint and does not depend on a client bundle being snapshotted, served,
79
+ * and executed in the right order;
80
+ * * the attribute is set by a blocking inline script, so every rule in the
81
+ * stylesheet — all of which are scoped to it — is live from the first frame,
82
+ * and the stylesheet stays inert in a DSH that is not running this skin.
83
+ */
84
+ ctx.effect(() => {
85
+ const disposeTap = ctx.webServer.tapIndex((html) => {
86
+ if (html.includes('data-amiya-skin=')) return html
87
+ const head =
88
+ '<link rel="stylesheet" href="' +
89
+ ROUTE +
90
+ '/assets/skin.css" data-amiya-skin="1">' +
91
+ '<script data-amiya-skin="1">document.documentElement.setAttribute("data-amiya-skin","1")</script>'
92
+ return html.includes('</head>')
93
+ ? html.replace('</head>', head + '</head>')
94
+ : head + html
95
+ })
96
+ return disposeTap
97
+ }, 'amiya-wallpaper: index injection')
98
+
99
+ ctx.effect(() => {
100
+ try {
101
+ const dispose = ctx.webServer.register({
102
+ kind: 'prefix',
103
+ path: ROUTE,
104
+ handler: (req, res) => {
105
+ void respond(req, res)
106
+ },
107
+ })
108
+ registered = true
109
+ return dispose
110
+ } catch (cause) {
111
+ /*
112
+ * A route pattern is a composition-level contract, so a second owner of
113
+ * this path throws. The realistic case is a live preview of this same
114
+ * skin mounted beside the installed plugin: both serve identical bytes
115
+ * from the same directory, so the right move is to log it and let the
116
+ * existing owner answer rather than fail this fiber.
117
+ */
118
+ error = cause instanceof Error ? cause.message : String(cause)
119
+ console.warn(
120
+ `[dsh-amiya-wallpaper] ${ROUTE} is already served by another owner; ` +
121
+ `leaving the route alone (${error})`,
122
+ )
123
+ return () => {}
124
+ }
125
+ }, 'amiya-wallpaper: asset route')
126
+
127
+ /*
128
+ * A host half that silently fails to register looks exactly like a host half
129
+ * with missing assets: the client half still styles the interface, but every
130
+ * wallpaper URL 404s — a skin with no wallpaper. The harness keeps no log this
131
+ * toolchain can read, so the outcome is written where the toolchain can look.
132
+ */
133
+ void writeStatus({ registered, error: error ?? null, packageRoot: PACKAGE_ROOT, assetRoot: ASSET_ROOT })
134
+ }
135
+
136
+ /**
137
+ * Record the startup outcome, best effort.
138
+ * @param {object} detail
139
+ */
140
+ async function writeStatus(detail) {
141
+ try {
142
+ await writeFile(
143
+ STATUS_FILE,
144
+ `${JSON.stringify({ at: new Date().toISOString(), route: ROUTE, ...detail }, null, 2)}\n`,
145
+ 'utf8',
146
+ )
147
+ } catch {
148
+ /* A read-only temp directory is not a reason to fail a fiber. */
149
+ }
150
+ }
151
+
152
+ /** Where the browser half's layout report lands. */
153
+ const DIAG_FILE = path.join(os.tmpdir(), 'dsh-amiya-wallpaper-diag.json')
154
+
155
+ /**
156
+ * Read a request body up to `limit` bytes.
157
+ * @param {import('node:http').IncomingMessage} req
158
+ * @param {number} limit
159
+ */
160
+ async function readBody(req, limit) {
161
+ const chunks = []
162
+ let size = 0
163
+ for await (const chunk of req) {
164
+ size += chunk.length
165
+ if (size > limit) throw new Error('body too large')
166
+ chunks.push(chunk)
167
+ }
168
+ return Buffer.concat(chunks).toString('utf8')
169
+ }
170
+
171
+ /**
172
+ * Answer one request from the asset or icon directory.
173
+ * @param {import('node:http').IncomingMessage} req
174
+ * @param {import('node:http').ServerResponse} res
175
+ */
176
+ async function respond(req, res) {
177
+ const method = req.method ?? 'GET'
178
+
179
+ /* Diagnostics channel, written by the browser half: it POSTs a measurement of the
180
+ settings dialog and GET returns the last one. Kept on its own path so the read-only
181
+ asset route stays exactly as strict as it was for everything else. */
182
+ try {
183
+ const diagPath = decodePath(req.url ?? '')
184
+ if (diagPath === 'diag') {
185
+ if (method === 'POST') {
186
+ const body = await readBody(req, 512 * 1024)
187
+ /* Append rather than overwrite: one capture per screen the reader visits. */
188
+ let captures = []
189
+ try {
190
+ const previous = JSON.parse(await readFile(DIAG_FILE, 'utf8'))
191
+ if (Array.isArray(previous.captures)) captures = previous.captures
192
+ } catch {
193
+ /* first capture */
194
+ }
195
+ let capture
196
+ try {
197
+ capture = JSON.parse(body)
198
+ } catch {
199
+ capture = { unparsed: body.slice(0, 4000) }
200
+ }
201
+ captures.push(capture)
202
+ if (captures.length > 20) captures = captures.slice(-20)
203
+ await writeFile(
204
+ DIAG_FILE,
205
+ JSON.stringify({ updatedAt: new Date().toISOString(), captures }, null, 1),
206
+ 'utf8',
207
+ )
208
+ send(res, 200, 'text/plain; charset=utf-8', 'ok ' + captures.length)
209
+ return
210
+ }
211
+ try {
212
+ const stored = await readFile(DIAG_FILE, 'utf8')
213
+ send(res, 200, 'application/json; charset=utf-8', stored)
214
+ } catch {
215
+ send(res, 404, 'text/plain; charset=utf-8', 'no report yet')
216
+ }
217
+ return
218
+ }
219
+ } catch {
220
+ /* fall through to the asset route */
221
+ }
222
+
223
+ if (method !== 'GET' && method !== 'HEAD') {
224
+ res.writeHead(405, { allow: 'GET, HEAD' })
225
+ res.end()
226
+ return
227
+ }
228
+
229
+ let relative
230
+ try {
231
+ relative = decodePath(req.url ?? '')
232
+ } catch {
233
+ send(res, 400, 'text/plain; charset=utf-8', 'bad request path')
234
+ return
235
+ }
236
+
237
+ const target = resolveTarget(relative)
238
+ if (target === undefined) {
239
+ send(res, 404, 'text/plain; charset=utf-8', 'not found')
240
+ return
241
+ }
242
+
243
+ let body
244
+ try {
245
+ const info = await stat(target)
246
+ if (!info.isFile()) throw new Error('not a file')
247
+ body = await readFile(target)
248
+ } catch {
249
+ send(res, 404, 'text/plain; charset=utf-8', 'not found')
250
+ return
251
+ }
252
+
253
+ const type = MIME.get(path.extname(target).toLowerCase()) ?? 'application/octet-stream'
254
+ /* Stylesheet and manifest are re-read per request and must not be cached: they
255
+ are how a fix lands on a page reload without restarting DSH. The images are
256
+ content-stable and are cached hard. */
257
+ const cacheable = type !== 'text/css; charset=utf-8' && type !== 'application/json; charset=utf-8'
258
+ const headers = {
259
+ 'content-type': type,
260
+ 'cache-control': cacheable ? `public, max-age=${MAX_AGE_SECONDS}, immutable` : 'no-store',
261
+ 'content-length': String(body.length),
262
+ }
263
+ res.writeHead(200, headers)
264
+ if (method === 'HEAD') res.end()
265
+ else res.end(body)
266
+ }
267
+
268
+ /**
269
+ * Decode a request URL into a package-relative path below the route prefix.
270
+ * @param {string} url
271
+ * @returns {string} e.g. `''`, `foo-wide.webp`, or `icons/amiya-mark.svg`
272
+ */
273
+ function decodePath(url) {
274
+ const withoutQuery = url.split('?')[0].split('#')[0]
275
+ const decoded = decodeURIComponent(withoutQuery)
276
+ const rest = decoded.startsWith(ROUTE) ? decoded.slice(ROUTE.length) : decoded
277
+ return rest.replace(/^\/+/, '')
278
+ }
279
+
280
+ /**
281
+ * Map a relative path onto a file inside the two served directories.
282
+ *
283
+ * Three shapes are accepted, and nothing else: the manifest, a bare wallpaper
284
+ * file name, or a name under `assets/` or `icons/`. Anything containing a path
285
+ * separator beyond that known prefix, a parent link, or a NUL is refused — which
286
+ * is what blocks traversal and absolute paths.
287
+ *
288
+ * @param {string} relative
289
+ * @returns {string | undefined}
290
+ */
291
+ function resolveTarget(relative) {
292
+ if (relative === '') return path.join(ASSET_ROOT, 'wallpapers.json')
293
+
294
+ const directories = [
295
+ { prefix: 'assets/', root: ASSET_ROOT },
296
+ { prefix: 'icons/', root: ICON_ROOT },
297
+ ]
298
+ for (const { prefix, root } of directories) {
299
+ if (!relative.startsWith(prefix)) continue
300
+ return safeJoin(root, relative.slice(prefix.length))
301
+ }
302
+ return safeJoin(ASSET_ROOT, relative)
303
+ }
304
+
305
+ /**
306
+ * Join one flat file name under `root`, refusing anything that could escape it.
307
+ * @param {string} root
308
+ * @param {string} name
309
+ * @returns {string | undefined}
310
+ */
311
+ function safeJoin(root, name) {
312
+ if (name === '' || name.includes('\0')) return undefined
313
+ if (name.includes('/') || name.includes('\\')) return undefined
314
+ if (name === '.' || name === '..') return undefined
315
+ const resolved = path.resolve(root, name)
316
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep
317
+ return resolved.startsWith(prefix) ? resolved : undefined
318
+ }
319
+
320
+ /**
321
+ * Write a small plain-text response.
322
+ * @param {import('node:http').ServerResponse} res
323
+ * @param {number} status
324
+ * @param {string} type
325
+ * @param {string} body
326
+ */
327
+ function send(res, status, type, body) {
328
+ res.writeHead(status, { 'content-type': type, 'content-length': String(Buffer.byteLength(body)) })
329
+ res.end(body)
330
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "dsh-arknights-theme",
3
+ "version": "1.0.0",
4
+ "description": "为《明日方舟》玩家在 DSH Web 上构建沉浸感:12 张整屏壁纸(按画面自适应的边缘虚化)、按每张图解算的玻璃与文字色、罗德岛图标体系,以及签字式的授权与选择交互。 / Building immersion for Arknights players on the DSH Web GUI: twelve full-screen wallpapers with per-image edge blur, glass and ink solved from each picture, Rhodes Island iconography, and sign-off style approval and choice interactions.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "icon": "./icons/amiya-mark.svg",
8
+ "exports": {
9
+ ".": {
10
+ "default": "./lib/index.js"
11
+ },
12
+ "./client": "./lib/client.js",
13
+ "./cordis.patch.yml": "./cordis.patch.yml",
14
+ "./wallpapers.json": "./assets/wallpapers.json",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "lib",
19
+ "assets/*.webp",
20
+ "assets/*.json",
21
+ "assets/*.css",
22
+ "assets/signatures",
23
+ "icons",
24
+ "cordis.patch.yml",
25
+ "README.md",
26
+ "ICON-ATTRIBUTION.md",
27
+ "LICENSE"
28
+ ],
29
+ "dsh": {
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ },
33
+ "client": {
34
+ "inject": [],
35
+ "platform": "web"
36
+ },
37
+ "manifestVersion": 1
38
+ },
39
+ "keywords": [
40
+ "deepseek-harness",
41
+ "dsh",
42
+ "dsh-plugin",
43
+ "arknights",
44
+ "rhodes-island",
45
+ "wallpaper",
46
+ "theme",
47
+ "immersion",
48
+ "acrylic"
49
+ ],
50
+ "engines": {
51
+ "node": ">=20"
52
+ },
53
+ "peerDependencies": {
54
+ "@deepseek-ai/cordis": "^4.0.4"
55
+ },
56
+ "license": "MIT",
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/Theflowyears/dsh-arknights-theme.git"
60
+ },
61
+ "homepage": "https://github.com/Theflowyears/dsh-arknights-theme#readme",
62
+ "bugs": {
63
+ "url": "https://github.com/Theflowyears/dsh-arknights-theme/issues"
64
+ },
65
+ "author": "Theflowyears"
66
+ }