dsh-pet-live2d 1.23.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 (76) hide show
  1. package/LICENSE +56 -0
  2. package/README.md +425 -0
  3. package/cordis.patch.yml +8 -0
  4. package/docs/README.md +14 -0
  5. package/docs/downscale-fix.png +0 -0
  6. package/docs/preview.png +0 -0
  7. package/lib/client.js +3525 -0
  8. package/lib/index.js +1027 -0
  9. package/lib/live2d-vendor.js +997 -0
  10. package/package.json +61 -0
  11. package/pets/ds-whale-girl/LICENSE +37 -0
  12. package/pets/ds-whale-girl/README.md +67 -0
  13. package/pets/ds-whale-girl/c_0120.model3.json +249 -0
  14. package/pets/ds-whale-girl/catalog.json +310 -0
  15. package/pets/ds-whale-girl/expressions/angry.exp3.json +55 -0
  16. package/pets/ds-whale-girl/expressions/blank-eyes.exp3.json +10 -0
  17. package/pets/ds-whale-girl/expressions/cat-paws.exp3.json +20 -0
  18. package/pets/ds-whale-girl/expressions/claw-recolor.exp3.json +10 -0
  19. package/pets/ds-whale-girl/expressions/claw.exp3.json +10 -0
  20. package/pets/ds-whale-girl/expressions/cry.exp3.json +60 -0
  21. package/pets/ds-whale-girl/expressions/dark-tablecloth.exp3.json +11 -0
  22. package/pets/ds-whale-girl/expressions/dizzy.exp3.json +40 -0
  23. package/pets/ds-whale-girl/expressions/draw-brush.exp3.json +10 -0
  24. package/pets/ds-whale-girl/expressions/draw-eraser.exp3.json +10 -0
  25. package/pets/ds-whale-girl/expressions/draw-undo.exp3.json +10 -0
  26. package/pets/ds-whale-girl/expressions/drool.exp3.json +10 -0
  27. package/pets/ds-whale-girl/expressions/excited.exp3.json +65 -0
  28. package/pets/ds-whale-girl/expressions/exclaim.exp3.json +10 -0
  29. package/pets/ds-whale-girl/expressions/facial-red.exp3.json +10 -0
  30. package/pets/ds-whale-girl/expressions/glasses-oval.exp3.json +10 -0
  31. package/pets/ds-whale-girl/expressions/glasses-round.exp3.json +10 -0
  32. package/pets/ds-whale-girl/expressions/glasses-square.exp3.json +10 -0
  33. package/pets/ds-whale-girl/expressions/gloomy.exp3.json +10 -0
  34. package/pets/ds-whale-girl/expressions/hands-peace.exp3.json +10 -0
  35. package/pets/ds-whale-girl/expressions/headband.exp3.json +11 -0
  36. package/pets/ds-whale-girl/expressions/heart-pop.exp3.json +10 -0
  37. package/pets/ds-whale-girl/expressions/heartbeat.exp3.json +10 -0
  38. package/pets/ds-whale-girl/expressions/ketchup-squeeze.exp3.json +10 -0
  39. package/pets/ds-whale-girl/expressions/love-eyes.exp3.json +60 -0
  40. package/pets/ds-whale-girl/expressions/menu-press.exp3.json +10 -0
  41. package/pets/ds-whale-girl/expressions/naughty.exp3.json +20 -0
  42. package/pets/ds-whale-girl/expressions/omurice.exp3.json +16 -0
  43. package/pets/ds-whale-girl/expressions/parfait.exp3.json +10 -0
  44. package/pets/ds-whale-girl/expressions/phone-recolor.exp3.json +10 -0
  45. package/pets/ds-whale-girl/expressions/ponytail.exp3.json +11 -0
  46. package/pets/ds-whale-girl/expressions/question.exp3.json +10 -0
  47. package/pets/ds-whale-girl/expressions/sad.exp3.json +35 -0
  48. package/pets/ds-whale-girl/expressions/soul-out.exp3.json +10 -0
  49. package/pets/ds-whale-girl/expressions/sparkle.exp3.json +10 -0
  50. package/pets/ds-whale-girl/expressions/star-eyes.exp3.json +10 -0
  51. package/pets/ds-whale-girl/expressions/sticker-bow.exp3.json +10 -0
  52. package/pets/ds-whale-girl/expressions/sticker-cat.exp3.json +10 -0
  53. package/pets/ds-whale-girl/expressions/sticker-rabbit.exp3.json +10 -0
  54. package/pets/ds-whale-girl/expressions/sunglasses.exp3.json +20 -0
  55. package/pets/ds-whale-girl/expressions/sweat.exp3.json +10 -0
  56. package/pets/ds-whale-girl/expressions/tongue-out.exp3.json +26 -0
  57. package/pets/ds-whale-girl/expressions/whale-on-desk.exp3.json +11 -0
  58. package/pets/ds-whale-girl/expressions/whale.exp3.json +11 -0
  59. package/pets/ds-whale-girl/model/c_0120.cdi3.json +1802 -0
  60. package/pets/ds-whale-girl/model/c_0120.moc3 +0 -0
  61. package/pets/ds-whale-girl/model/c_0120.physics3.json +2396 -0
  62. package/pets/ds-whale-girl/motions/bubble-gum.motion3.json +369 -0
  63. package/pets/ds-whale-girl/motions/hammer.motion3.json +1120 -0
  64. package/pets/ds-whale-girl/motions/idle.motion3.json +4746 -0
  65. package/pets/ds-whale-girl/motions/ketchup.motion3.json +768 -0
  66. package/pets/ds-whale-girl/motions/open-case.motion3.json +116 -0
  67. package/pets/ds-whale-girl/motions/selfie-quick.motion3.json +47 -0
  68. package/pets/ds-whale-girl/motions/selfie.motion3.json +671 -0
  69. package/pets/ds-whale-girl/motions/spray-water.motion3.json +31 -0
  70. package/pets/ds-whale-girl/pet.json +599 -0
  71. package/pets/ds-whale-girl/previews/idle.png +0 -0
  72. package/pets/ds-whale-girl/textures/texture_00.png +0 -0
  73. package/pets/ds-whale-girl/textures/texture_01.png +0 -0
  74. package/pets/ds-whale-girl/voice.json +46 -0
  75. package/pets/ds-whale-girl//345/256/211/350/243/205/350/257/264/346/230/216.md +30 -0
  76. package/src/vendor-entry.ts +28 -0
package/lib/index.js ADDED
@@ -0,0 +1,1027 @@
1
+ /**
2
+ * dsh-live2d-pet — host half.
3
+ *
4
+ * A self-contained Live2D desk-pet plugin for the DSH Web GUI. It is
5
+ * deliberately independent of any other pet plugin: it discovers Live2D pet
6
+ * directories itself, serves their model reference closure, serves the Live2D
7
+ * runtime files, and answers one catalog endpoint the browser half renders.
8
+ *
9
+ * Discovery follows the DSH pet convention: every directory under
10
+ * '$DSH_HOME/pets/<id>/' holding a pet.json whose 'renderer' is 'live2d'.
11
+ * The model's declared reference closure (moc3, textures, motions, physics,
12
+ * expressions) is the allow-list the asset route serves — a crafted '..'
13
+ * segment can never match, and realpath containment is the second layer.
14
+ *
15
+ * The proprietary Cubism Core runtime is NEVER bundled or downloaded by this
16
+ * plugin: it is read from the user-supplied
17
+ * '$DSH_HOME/pets/.runtime/live2dcubismcore.min.js'.
18
+ *
19
+ * buildRoutes() is exported so the same route table can be mounted by the
20
+ * DSH web server (apply) and by tests.
21
+ */
22
+
23
+ import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, readdirSync, statSync, writeFileSync } from 'node:fs'
24
+ import { readFile } from 'node:fs/promises'
25
+ import { homedir } from 'node:os'
26
+ import { dirname, extname, join, sep } from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+
29
+ export const name = 'live2d-pet'
30
+
31
+ export const inject = ['webServer']
32
+
33
+ /** Browser-facing API base. */
34
+ export const API = '/api/live2d-pet'
35
+
36
+ /** Size ceilings per served file class, in bytes. */
37
+ const CAP_JSON = 64 * 1024
38
+ const CAP_IMAGE = 24 * 1024 * 1024
39
+ const CAP_MODEL = 48 * 1024 * 1024
40
+ const CAP_RUNTIME = 24 * 1024 * 1024
41
+
42
+ const MIME = {
43
+ '.png': 'image/png',
44
+ '.webp': 'image/webp',
45
+ '.jpg': 'image/jpeg',
46
+ '.jpeg': 'image/jpeg',
47
+ '.gif': 'image/gif',
48
+ '.json': 'application/json; charset=utf-8',
49
+ '.moc3': 'application/octet-stream',
50
+ '.js': 'application/javascript; charset=utf-8',
51
+ }
52
+
53
+ /** $DSH_HOME (defaults to ~/.dsh). */
54
+ export function dshHome() {
55
+ const raw = process.env.DSH_HOME
56
+ return raw !== undefined && raw.trim() !== '' ? raw.trim() : join(homedir(), '.dsh')
57
+ }
58
+
59
+ /** Directory holding every installed pet. */
60
+ export function petsRoot() {
61
+ return join(dshHome(), 'pets')
62
+ }
63
+
64
+ /** Directory holding the user-supplied Live2D runtime files. */
65
+ export function runtimeDir() {
66
+ return join(petsRoot(), '.runtime')
67
+ }
68
+
69
+ /** Package root of this plugin (lib/ -> package root), resolved once. */
70
+ let packageRoot
71
+ export function pluginRoot() {
72
+ packageRoot ??= fileURLToPath(new URL('..', import.meta.url))
73
+ return packageRoot
74
+ }
75
+
76
+ /** Plain JSON read that never throws. */
77
+ function readJson(file) {
78
+ try {
79
+ return JSON.parse(readFileSync(file, 'utf8'))
80
+ } catch {
81
+ return undefined
82
+ }
83
+ }
84
+
85
+ /** Safe relative path: no absolute paths, no backslashes, no traversal, plain segments. */
86
+ export function safeRel(raw) {
87
+ if (typeof raw !== 'string' || raw.trim() === '') return undefined
88
+ const value = raw.trim()
89
+ if (value.includes('\\') || /^[a-z][a-z0-9+.-]*:/i.test(value) || /^[\\/]/.test(value)) return undefined
90
+ const segments = value.split('/').filter((segment) => segment !== '')
91
+ if (segments.length === 0) return undefined
92
+ if (segments.some((segment) => segment === '.' || segment === '..' || !/^[A-Za-z0-9._-]+$/.test(segment))) return undefined
93
+ return segments.join('/')
94
+ }
95
+
96
+ /**
97
+ * The reference closure of one Cubism model3.json — exactly the files the
98
+ * asset route may serve for the pet that declares it.
99
+ */
100
+ export function modelClosure(model3) {
101
+ const out = new Set()
102
+ if (typeof model3 !== 'object' || model3 === null) return out
103
+ const refs = model3.FileReferences
104
+ if (typeof refs !== 'object' || refs === null) return out
105
+ const push = (raw) => {
106
+ const safe = safeRel(raw)
107
+ if (safe !== undefined) out.add(safe)
108
+ }
109
+ if (refs.Moc !== undefined) push(refs.Moc)
110
+ if (Array.isArray(refs.Textures)) refs.Textures.forEach(push)
111
+ for (const key of ['Physics', 'Pose', 'DisplayInfo', 'UserData']) if (refs[key] !== undefined) push(refs[key])
112
+ if (Array.isArray(refs.Expressions)) {
113
+ for (const expression of refs.Expressions) {
114
+ if (typeof expression === 'object' && expression !== null) push(expression.File)
115
+ }
116
+ }
117
+ if (typeof refs.Motions === 'object' && refs.Motions !== null) {
118
+ for (const motions of Object.values(refs.Motions)) {
119
+ if (!Array.isArray(motions)) continue
120
+ for (const motion of motions) {
121
+ if (typeof motion === 'object' && motion !== null) push(motion.File)
122
+ }
123
+ }
124
+ }
125
+ return out
126
+ }
127
+
128
+ /**
129
+ * Validate a pet's declared dress-up slots.
130
+ *
131
+ * Anything malformed is dropped rather than passed on: the browser half renders
132
+ * these straight into buttons, so a slot without an id, or an option without an
133
+ * expression, would produce a dead control. An empty result simply means the pet
134
+ * has no dress-up panel, which is the right outcome for a model that needs none.
135
+ */
136
+ /**
137
+ * Validate a procedural sweep.
138
+ *
139
+ * A sweep drives model parameters from a generated curve instead of from a
140
+ * baked motion. Only the channels the pet actually names are kept, so a typo in
141
+ * one axis cannot silently produce a stationary hand.
142
+ */
143
+ function normaliseSweep(raw) {
144
+ if (typeof raw !== 'object' || raw === null) return null
145
+ const axes = {}
146
+ for (const key of ['x', 'y', 'z', 'rz']) {
147
+ if (typeof raw[key] === 'string' && raw[key].trim() !== '') axes[key] = raw[key].trim()
148
+ }
149
+ if (Object.keys(axes).length === 0) return null
150
+ const num = (value, fallback) => (typeof value === 'number' && Number.isFinite(value) ? value : fallback)
151
+ return {
152
+ ...axes,
153
+ ampX: num(raw.ampX, 16),
154
+ ampY: num(raw.ampY, 8),
155
+ ampZ: num(raw.ampZ, 6),
156
+ // One full trace of the loop, in milliseconds. The pen never lifts: the
157
+ // path closes on itself, which is what makes it read as flowing rather
158
+ // than as a series of strokes being snapped back to the margin.
159
+ loopMs: num(raw.loopMs, 2600),
160
+ // Optional slow drift, so a long-running loop does not look pinned to one
161
+ // spot. 0 keeps it a pure closed curve.
162
+ driftMs: num(raw.driftMs, 0),
163
+ driftY: num(raw.driftY, 0),
164
+ }
165
+ }
166
+
167
+ function normaliseSlots(raw) {
168
+ if (!Array.isArray(raw)) return []
169
+ const out = []
170
+ for (const slot of raw) {
171
+ if (typeof slot !== 'object' || slot === null) continue
172
+ const id = typeof slot.id === 'string' ? slot.id.trim() : ''
173
+ if (id === '') continue
174
+ const options = []
175
+ for (const option of Array.isArray(slot.options) ? slot.options : []) {
176
+ if (typeof option !== 'object' || option === null) continue
177
+ // An option may need SEVERAL expressions: this model's 白魔爪 is
178
+ // 魔爪换色 layered on 桌面粉魔爪, and the recolour alone renders nothing
179
+ // because there is no claw to recolour. Accept either shape so a pet can
180
+ // write the common single case without an array.
181
+ const raw = Array.isArray(option.expressions)
182
+ ? option.expressions
183
+ : (typeof option.expression === 'string' ? [option.expression] : [])
184
+ const expressions = []
185
+ for (const name of raw) {
186
+ if (typeof name !== 'string') continue
187
+ const trimmed = name.trim()
188
+ if (trimmed !== '' && !expressions.includes(trimmed)) expressions.push(trimmed)
189
+ }
190
+ // An option may also drive a MOTION, because some of this model's actions
191
+ // sit at the same level as an expression: 吹泡泡糖 belongs with the mouth,
192
+ // 掏出手机 and 挤番茄酱 with the hand. `requires` lists expressions that must
193
+ // be on for the action to make sense (挤番茄酱 needs 蛋包饭 first).
194
+ const motion = typeof option.motion === 'string' && option.motion.trim() !== ''
195
+ ? option.motion.trim()
196
+ : null
197
+ const requires = []
198
+ for (const name of Array.isArray(option.requires) ? option.requires : []) {
199
+ if (typeof name === 'string' && name.trim() !== '' && !requires.includes(name.trim())) {
200
+ requires.push(name.trim())
201
+ }
202
+ }
203
+ // Slots this option must RESET. 写本本 is a right-hand action that needs the
204
+ // left hand empty first, and "empty" is another slot's choice, so the
205
+ // option has to be able to clear it.
206
+ const clears = []
207
+ for (const id of Array.isArray(option.clears) ? option.clears : []) {
208
+ if (typeof id === 'string' && id.trim() !== '' && !clears.includes(id.trim())) clears.push(id.trim())
209
+ }
210
+ // A SWEEP is an animation this plugin generates, for parameters the model
211
+ // left unanimated. This model ships 点菜手X/Y/Z (pointX/pointY/pointY2) with
212
+ // a ±30 range and NOTHING driving them — the author meant the hand to
213
+ // follow the mouse and never wired it up. Writing a curve per frame turns
214
+ // them into a hand that actually writes on the tablet.
215
+ // Options that cannot be worn together, named by their LABEL. 吐魂 and
216
+ // 吹泡泡糖 write different parameters, so nothing in the engine stops them
217
+ // overlapping — but the soul leaving the mouth on top of a bubble-gum
218
+ // pucker is one mouth doing two things.
219
+ const conflicts = []
220
+ for (const label of Array.isArray(option.conflicts) ? option.conflicts : []) {
221
+ if (typeof label === 'string' && label.trim() !== '' && !conflicts.includes(label.trim())) {
222
+ conflicts.push(label.trim())
223
+ }
224
+ }
225
+ // Pairs and breaks, for options that travel with (or rule out) another
226
+ // slot's choice: 喵喵手 only makes sense with the cat sticker on, and any
227
+ // other hand pose means the sticker should come off.
228
+ const pairs = {}
229
+ if (typeof option.pairs === 'object' && option.pairs !== null) {
230
+ for (const [slotId, label] of Object.entries(option.pairs)) {
231
+ if (typeof slotId === 'string' && typeof label === 'string' && label !== '') pairs[slotId] = label
232
+ }
233
+ }
234
+ const breaks = []
235
+ for (const label of Array.isArray(option.breaks) ? option.breaks : []) {
236
+ if (typeof label === 'string' && label.trim() !== '' && !breaks.includes(label.trim())) breaks.push(label.trim())
237
+ }
238
+ const sweep = normaliseSweep(option.sweep)
239
+ // Fidget weighting. Most of the time the mouth should simply be normal,
240
+ // and a couple of options should never come up on their own at all, so the
241
+ // idle fidget needs more than a uniform draw.
242
+ const fidgetOff = option.fidget === false
243
+ const fidgetWeight = typeof option.fidgetWeight === 'number' && option.fidgetWeight > 0
244
+ ? option.fidgetWeight
245
+ : 1
246
+ if (expressions.length === 0 && motion === null && sweep === null) continue
247
+ options.push({
248
+ label: typeof option.label === 'string' && option.label !== ''
249
+ ? option.label
250
+ : (expressions[0] ?? motion),
251
+ expressions,
252
+ ...(motion === null ? {} : { motion }),
253
+ ...(requires.length === 0 ? {} : { requires }),
254
+ ...(clears.length === 0 ? {} : { clears }),
255
+ ...(sweep === null ? {} : { sweep }),
256
+ ...(conflicts.length === 0 ? {} : { conflicts }),
257
+ ...(Object.keys(pairs).length === 0 ? {} : { pairs }),
258
+ ...(breaks.length === 0 ? {} : { breaks }),
259
+ ...(fidgetOff ? { fidget: false } : {}),
260
+ ...(fidgetWeight === 1 ? {} : { fidgetWeight }),
261
+ })
262
+ }
263
+ if (options.length === 0) continue
264
+ out.push({
265
+ id,
266
+ label: typeof slot.label === 'string' && slot.label !== '' ? slot.label : id,
267
+ none: typeof slot.none === 'string' && slot.none !== '' ? slot.none : '无',
268
+ // How strongly the idle fidget should prefer leaving this slot alone.
269
+ // The mouth sets it high so the pet mostly looks normal.
270
+ ...(typeof slot.fidgetNone === 'number' && slot.fidgetNone > 0 ? { fidgetNone: slot.fidgetNone } : {}),
271
+ options,
272
+ })
273
+ }
274
+ return out
275
+ }
276
+
277
+ /** Scan one pet directory into a catalog entry, or undefined when unusable. */
278
+ export function scanPet(dir, id) {
279
+ const manifestFile = join(dir, 'pet.json')
280
+ if (!existsSync(manifestFile)) return undefined
281
+ const manifest = readJson(manifestFile)
282
+ if (manifest === undefined || typeof manifest !== 'object' || manifest === null) return undefined
283
+ if (manifest.renderer !== 'live2d') return undefined
284
+ const block = manifest.live2d
285
+ if (typeof block !== 'object' || block === null) return undefined
286
+ const modelPath = safeRel(block.model)
287
+ if (modelPath === undefined || !modelPath.endsWith('.model3.json')) return undefined
288
+ const modelFile = join(dir, modelPath)
289
+ if (!existsSync(modelFile)) return undefined
290
+ const model3 = readJson(modelFile)
291
+ if (model3 === undefined) return undefined
292
+ const closure = modelClosure(model3)
293
+ if (closure.size === 0) return undefined
294
+ // The model descriptor itself rides the same route: the browser fetches it
295
+ // first, then every file it names, so it belongs in the servable set.
296
+ closure.add(modelPath)
297
+
298
+ // Labels/categories are optional host-only metadata shipped beside the
299
+ // manifest ('catalog.json'); the authoritative motion/expression lists
300
+ // always come from the model itself, so a pet without one still lists
301
+ // everything, just with the model's own names.
302
+ const labels = readJson(join(dir, 'catalog.json')) ?? {}
303
+ const labelFor = (kind, key) => {
304
+ const list = labels[kind]
305
+ if (!Array.isArray(list)) return undefined
306
+ const hit = list.find((entry) => entry !== null && typeof entry === 'object' && entry.key === key)
307
+ return hit === undefined ? undefined : hit
308
+ }
309
+
310
+ // Every motion entry carries its own duration and loop flag, read from the
311
+ // motion3.json the model references. The browser half needs both: the
312
+ // engine refuses to restart a still-active group+index, and a motion
313
+ // flagged Loop never emits motionFinish — so playback has to be driven by
314
+ // the model's own timing instead of by that event alone.
315
+ const motions = []
316
+ const motionGroups = model3.FileReferences?.Motions
317
+ if (typeof motionGroups === 'object' && motionGroups !== null) {
318
+ for (const [group, list] of Object.entries(motionGroups)) {
319
+ if (!Array.isArray(list) || list.length === 0) continue
320
+ const meta = labelFor('motions', group)
321
+ const items = list.map((entry, index) => {
322
+ const file = typeof entry === 'object' && entry !== null ? safeRel(entry.File) : undefined
323
+ const motionMeta = file === undefined ? undefined : readJson(join(dir, file))
324
+ const rawDuration = motionMeta?.Meta?.Duration
325
+ // Every parameter the motion writes. The browser half needs this to
326
+ // clean up after a one-shot action: a motion such as 吹泡泡糖 drives
327
+ // its own mouth/pose parameters that the idle loop does NOT drive, so
328
+ // once the motion stops its last written value stays on the model
329
+ // forever unless something puts it back ("泡泡吹完嘴没还原").
330
+ const params = []
331
+ const curves = motionMeta?.Curves
332
+ if (Array.isArray(curves)) {
333
+ for (const curve of curves) {
334
+ if (curve?.Target === 'Parameter' && typeof curve.Id === 'string' && curve.Id !== '') {
335
+ params.push(curve.Id)
336
+ }
337
+ }
338
+ }
339
+ return {
340
+ index,
341
+ duration: typeof rawDuration === 'number' && rawDuration > 0 ? Math.round(rawDuration * 1000) : 0,
342
+ loop: motionMeta?.Meta?.Loop === true,
343
+ params,
344
+ }
345
+ })
346
+ motions.push({
347
+ group,
348
+ count: items.length,
349
+ label: meta?.label ?? group,
350
+ category: meta?.category ?? 'action',
351
+ items,
352
+ })
353
+ }
354
+ }
355
+
356
+ const expressions = []
357
+ const expressionRefs = model3.FileReferences?.Expressions
358
+ if (Array.isArray(expressionRefs)) {
359
+ for (const reference of expressionRefs) {
360
+ if (typeof reference !== 'object' || reference === null) continue
361
+ const expressionName = typeof reference.Name === 'string' && reference.Name !== '' ? reference.Name : reference.File
362
+ if (typeof expressionName !== 'string') continue
363
+ const meta = labelFor('expressions', expressionName)
364
+ // Ship each expression's own parameter writes.
365
+ //
366
+ // The engine's expression pipeline loads the file correctly (44
367
+ // definitions, the right parameter count, weight 1) but never actually
368
+ // moves a parameter, so nothing it plays is visible. The browser half
369
+ // therefore applies these values itself through the same parameter
370
+ // machinery it already uses for motions — which also gives the UI the
371
+ // per-expression data it needs to work out which effects conflict.
372
+ const file = safeRel(reference.File)
373
+ const params = []
374
+ if (file !== undefined) {
375
+ let parsed
376
+ try {
377
+ parsed = readJson(join(dir, file))
378
+ } catch {
379
+ parsed = undefined
380
+ }
381
+ if (Array.isArray(parsed?.Parameters)) {
382
+ for (const parameter of parsed.Parameters) {
383
+ if (typeof parameter?.Id !== 'string' || parameter.Id === '') continue
384
+ const value = Number(parameter.Value)
385
+ if (!Number.isFinite(value)) continue
386
+ const blend = parameter.Blend === 'Multiply' ? 'Multiply'
387
+ : parameter.Blend === 'Overwrite' ? 'Overwrite' : 'Add'
388
+ params.push({ id: parameter.Id, value, blend })
389
+ }
390
+ }
391
+ }
392
+ expressions.push({
393
+ name: expressionName,
394
+ label: meta?.label ?? expressionName,
395
+ category: meta?.category ?? 'other',
396
+ file: file ?? '',
397
+ params,
398
+ })
399
+ }
400
+ }
401
+
402
+ return {
403
+ id,
404
+ displayName: typeof manifest.displayName === 'string' && manifest.displayName !== '' ? manifest.displayName : id,
405
+ description: typeof manifest.description === 'string' ? manifest.description : '',
406
+ scale: typeof block.scale === 'number' && block.scale > 0 && block.scale <= 10 ? block.scale : 1,
407
+ // The manifest's phase -> motion-group / expression maps (same keys the
408
+ // catalog uses), so the browser half can retarget session phases per pet.
409
+ motionsByPhase: typeof block.motions === 'object' && block.motions !== null ? block.motions : {},
410
+ // Per-motion playback policy (hold / reset / prepend) declared by the pet.
411
+ // See the browser half's motion controller: a model whose motion3.json all
412
+ // say "Loop": true cannot express "play once and hold the pose" or "clean
413
+ // up the mouth afterwards" on its own, so the pet says it here.
414
+ motionOptions: typeof block.motionOptions === 'object' && block.motionOptions !== null ? block.motionOptions : {},
415
+ expressionsByPhase: typeof block.expressions === 'object' && block.expressions !== null ? block.expressions : {},
416
+ // Mutually exclusive dress-up slots. Several of the model's expressions are
417
+ // switches for the same thing (three pairs of glasses, three stickers), so
418
+ // the pet declares which may not be worn together; the browser half turns
419
+ // that into a one-choice-per-slot panel.
420
+ expressionSlots: normaliseSlots(block.expressionSlots),
421
+ // Session phase -> a whole LOOK expressed in slot vocabulary
422
+ // ({ whale: "头顶鲸", lhand: "画笔" }), so a phase drives several slots at
423
+ // once instead of a single expression.
424
+ looksByPhase: typeof block.looksByPhase === 'object' && block.looksByPhase !== null ? block.looksByPhase : {},
425
+ // motion group -> { slotId: [acceptable option labels] }. A motion whose
426
+ // premise is missing (a selfie with no phone out, a spray with no whale)
427
+ // must not play at all, from ANY path: the panel, a fidget or a phase.
428
+ motionGuards: typeof block.motionGuards === 'object' && block.motionGuards !== null ? block.motionGuards : {},
429
+ // Motion groups that belong to the SLOT menu rather than the 动作 tab.
430
+ // 掏出手机 and 吹泡泡糖 are slot choices now; listing them twice made the
431
+ // same thing reachable two ways, and the tab copy could not hold a pose.
432
+ hiddenMotions: Array.isArray(block.hiddenMotions) ? block.hiddenMotions.filter((m) => typeof m === 'string') : [],
433
+ translate: {
434
+ x: typeof block.translate?.x === 'number' ? block.translate.x : 0,
435
+ y: typeof block.translate?.y === 'number' ? block.translate.y : 0,
436
+ },
437
+ dir,
438
+ modelPath,
439
+ modelUrl: API + '/asset/' + encodeURIComponent(id) + '/' + modelPath.split('/').map(encodeURIComponent).join('/'),
440
+ closure,
441
+ motions,
442
+ expressions,
443
+ }
444
+ }
445
+
446
+ /** Build the live catalog from disk (fresh per request, so installs are picked up). */
447
+ /**
448
+ * 把**随包分发**的宠物装进用户的宠物目录。
449
+ *
450
+ * 以前宠物是仓库里单独一份,装插件的人还得自己再拷一次 —— 从插件市场装完
451
+ * 「看不见宠物」就是这么来的(插件包里有代码,没有模型)。
452
+ *
453
+ * 为什么模型能随包、Cubism Core 不能:模型是 CC BY-NC-SA 4.0,且 Live2D 作者
454
+ * 已明确授权本项目转载与开源(见 pets/ds-whale-girl/LICENSE);Core 是 Live2D Inc.
455
+ * 的专有软件,只允许从官方渠道取,所以它走 CDN 兜底那条路。
456
+ *
457
+ * 只在目标**不存在**时复制:用户自己改过的宠物永远优先,绝不覆盖。
458
+ */
459
+ export function installBundledPets() {
460
+ const bundled = join(pluginRoot(), 'pets')
461
+ if (!existsSync(bundled)) return
462
+ let names = []
463
+ try {
464
+ names = readdirSync(bundled).filter((entry) => !entry.startsWith('.'))
465
+ } catch {
466
+ return
467
+ }
468
+ for (const name of names) {
469
+ const source = join(bundled, name)
470
+ const target = join(petsRoot(), name)
471
+ try {
472
+ if (!statSync(source).isDirectory()) continue
473
+ if (existsSync(target)) continue
474
+ mkdirSync(dirname(target), { recursive: true })
475
+ cpSync(source, target, { recursive: true })
476
+ } catch {
477
+ /* 只读文件系统之类:让用户自己拷,别让整个目录扫描失败 */
478
+ }
479
+ }
480
+ }
481
+
482
+ export function buildCatalog() {
483
+ installBundledPets()
484
+ const root = petsRoot()
485
+ if (!existsSync(root)) return []
486
+ let names = []
487
+ try {
488
+ names = readdirSync(root).filter((entry) => !entry.startsWith('.'))
489
+ } catch {
490
+ return []
491
+ }
492
+ names.sort()
493
+ const pets = []
494
+ for (const entry of names) {
495
+ const dir = join(root, entry)
496
+ try {
497
+ if (!statSync(dir).isDirectory()) continue
498
+ } catch {
499
+ continue
500
+ }
501
+ const pet = scanPet(dir, entry)
502
+ if (pet !== undefined) pets.push(pet)
503
+ }
504
+ return pets
505
+ }
506
+
507
+ /** realpath containment; a symlink escaping its root is refused. */
508
+ function contained(base, candidate) {
509
+ try {
510
+ const realBase = realpathSync(base)
511
+ const realCandidate = realpathSync(candidate)
512
+ return realCandidate === realBase || realCandidate.startsWith(realBase + sep) ? realCandidate : undefined
513
+ } catch {
514
+ return undefined
515
+ }
516
+ }
517
+
518
+ function sendJson(response, status, payload) {
519
+ const body = Buffer.from(JSON.stringify(payload), 'utf8')
520
+ response.writeHead(status, {
521
+ 'cache-control': 'no-store',
522
+ 'content-type': 'application/json; charset=utf-8',
523
+ 'content-length': String(body.byteLength),
524
+ })
525
+ response.end(body)
526
+ }
527
+
528
+ /** Weak validator from size + mtime. */
529
+ function weakEtag(stat) {
530
+ return '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"'
531
+ }
532
+
533
+ /** Serve one file with containment, size ceiling and revalidation. */
534
+ function serveFile(request, response, base, file, cap) {
535
+ const resolved = contained(base, file)
536
+ if (resolved === undefined) {
537
+ response.writeHead(403)
538
+ response.end()
539
+ return
540
+ }
541
+ let stat
542
+ try {
543
+ stat = statSync(resolved)
544
+ if (!stat.isFile()) throw new Error('not a file')
545
+ if (stat.size > cap) {
546
+ response.writeHead(413)
547
+ response.end()
548
+ return
549
+ }
550
+ } catch {
551
+ response.writeHead(404)
552
+ response.end()
553
+ return
554
+ }
555
+ const etag = weakEtag(stat)
556
+ if (request.headers['if-none-match'] === etag) {
557
+ response.writeHead(304, { etag, 'cache-control': 'no-cache' })
558
+ response.end()
559
+ return
560
+ }
561
+ readFile(resolved).then((body) => {
562
+ response.writeHead(200, {
563
+ 'content-type': MIME[extname(resolved).toLowerCase()] ?? 'application/octet-stream',
564
+ 'content-length': String(body.byteLength),
565
+ 'cache-control': 'no-cache',
566
+ etag,
567
+ })
568
+ if (request.method === 'HEAD') {
569
+ response.end()
570
+ return
571
+ }
572
+ response.end(body)
573
+ }, () => {
574
+ response.writeHead(404)
575
+ response.end()
576
+ })
577
+ }
578
+
579
+ /** Loopback-only fence: the pet API never answers a non-local peer. */
580
+ function loopbackOnly(request) {
581
+ const address = request.socket?.remoteAddress ?? ''
582
+ return address === '' || address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
583
+ }
584
+
585
+ /** Split a request path into the segments after one known prefix. */
586
+ function segmentsAfter(pathname, prefix) {
587
+ if (!pathname.startsWith(prefix + '/')) return undefined
588
+ return pathname.slice(prefix.length + 1).split('/')
589
+ }
590
+
591
+ /** The catalog route (GET /api/live2d-pet/catalog). */
592
+ function catalogRoute() {
593
+ return {
594
+ kind: 'exact',
595
+ path: API + '/catalog',
596
+ handler: (request, response) => {
597
+ if (!loopbackOnly(request)) {
598
+ response.writeHead(403)
599
+ response.end()
600
+ return
601
+ }
602
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
603
+ response.writeHead(405, { allow: 'GET, HEAD' })
604
+ response.end()
605
+ return
606
+ }
607
+ sendJson(response, 200, {
608
+ ok: true,
609
+ coreUrl: API + '/runtime/live2dcubismcore.min.js',
610
+ vendorUrl: API + '/runtime/live2d-vendor.js',
611
+ pets: buildCatalog().map((pet) => ({
612
+ id: pet.id,
613
+ displayName: pet.displayName,
614
+ description: pet.description,
615
+ modelUrl: pet.modelUrl,
616
+ scale: pet.scale,
617
+ translate: pet.translate,
618
+ motionsByPhase: pet.motionsByPhase,
619
+ expressionsByPhase: pet.expressionsByPhase,
620
+ expressionSlots: pet.expressionSlots,
621
+ looksByPhase: pet.looksByPhase,
622
+ motionGuards: pet.motionGuards,
623
+ hiddenMotions: pet.hiddenMotions,
624
+ motionOptions: pet.motionOptions,
625
+ motions: pet.motions,
626
+ expressions: pet.expressions,
627
+ })),
628
+ })
629
+ },
630
+ }
631
+ }
632
+
633
+ /** The model reference-closure route (GET /api/live2d-pet/asset/<id>/<path>). */
634
+ function assetRoute() {
635
+ return {
636
+ kind: 'prefix',
637
+ path: API + '/asset',
638
+ handler: (request, response) => {
639
+ if (!loopbackOnly(request)) {
640
+ response.writeHead(403)
641
+ response.end()
642
+ return
643
+ }
644
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
645
+ response.writeHead(405)
646
+ response.end()
647
+ return
648
+ }
649
+ let pathname
650
+ try {
651
+ pathname = new URL(request.url ?? '/', 'http://pet.local').pathname
652
+ } catch {
653
+ response.writeHead(400)
654
+ response.end()
655
+ return
656
+ }
657
+ const segments = segmentsAfter(pathname, API + '/asset')
658
+ if (segments === undefined || segments.length < 2) {
659
+ response.writeHead(404)
660
+ response.end()
661
+ return
662
+ }
663
+ let id
664
+ let rel
665
+ try {
666
+ id = decodeURIComponent(segments[0])
667
+ rel = segments.slice(1).map(decodeURIComponent).join('/')
668
+ } catch {
669
+ response.writeHead(400)
670
+ response.end()
671
+ return
672
+ }
673
+ const pet = buildCatalog().find((candidate) => candidate.id === id)
674
+ // The closure probe is a Set lookup on scan-time normalized paths, so a
675
+ // crafted '..' / '.' segment can never match a real file.
676
+ if (pet === undefined || !pet.closure.has(rel)) {
677
+ response.writeHead(404)
678
+ response.end()
679
+ return
680
+ }
681
+ const ext = extname(rel).toLowerCase()
682
+ const cap = ext === '.json' ? CAP_JSON : (ext === '.moc3' ? CAP_MODEL : CAP_IMAGE)
683
+ serveFile(request, response, pet.dir, join(pet.dir, rel), cap)
684
+ },
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Live2D 官方托管的 Cubism Core。
690
+ *
691
+ * 这份运行时是 Live2D 的专有软件,插件**不内置也不代下**(授权要求),但
692
+ * **用户的机器可以从 Live2D 自己的 CDN 取** —— 官方 SDK 文档就是让使用者在页面里
693
+ * 引这一行。取到之后缓存进 runtimeDir(),之后离线也能用。
694
+ *
695
+ * 有了这条兜底,从插件市场装完就能直接用,不必先去找 Cubism Core 放哪。
696
+ */
697
+ const CORE_CDN = 'https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js'
698
+
699
+ /** 本地没有 Core 时:去 Live2D 官方 CDN 拿一份,顺手缓存到本地。 */
700
+ async function fetchCoreFromCdn(response, cachePath) {
701
+ try {
702
+ const upstream = await fetch(CORE_CDN, { redirect: 'follow' })
703
+ if (!upstream.ok) throw new Error('HTTP ' + upstream.status)
704
+ const bytes = Buffer.from(await upstream.arrayBuffer())
705
+ // 拿回来的必须真是一份 JS:否则多半是被网关/登录页替换了。
706
+ if (bytes.length < 10000 || !bytes.includes('Live2DCubismCore')) {
707
+ throw new Error('unexpected payload: ' + bytes.length + ' bytes')
708
+ }
709
+ try {
710
+ mkdirSync(dirname(cachePath), { recursive: true })
711
+ writeFileSync(cachePath, bytes)
712
+ } catch {
713
+ /* 缓存写不进去不影响这一次:直接把它发出去 */
714
+ }
715
+ response.writeHead(200, {
716
+ 'content-type': 'text/javascript; charset=utf-8',
717
+ 'content-length': String(bytes.length),
718
+ 'cache-control': 'public, max-age=86400',
719
+ 'x-live2d-core-source': 'cdn',
720
+ })
721
+ response.end(bytes)
722
+ } catch (error) {
723
+ sendJson(response, 502, {
724
+ ok: false,
725
+ error: 'cubism-core-unavailable',
726
+ detail: String(error?.message ?? error),
727
+ hint: 'Cubism Core 是 Live2D 的专有运行时,本插件不内置。可以让这台机器能访问 '
728
+ + CORE_CDN + ',或自行下载后放到 ' + join(runtimeDir(), 'live2dcubismcore.min.js'),
729
+ })
730
+ }
731
+ }
732
+
733
+ /** The runtime route (user-supplied Cubism Core + plugin vendor bundle). */
734
+ function runtimeRoute() {
735
+ const vendorBase = join(pluginRoot(), 'lib')
736
+ const coreBase = runtimeDir()
737
+ return {
738
+ kind: 'prefix',
739
+ path: API + '/runtime',
740
+ handler: (request, response) => {
741
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
742
+ response.writeHead(405)
743
+ response.end()
744
+ return
745
+ }
746
+ let pathname
747
+ try {
748
+ pathname = new URL(request.url ?? '/', 'http://pet.local').pathname
749
+ } catch {
750
+ response.writeHead(400)
751
+ response.end()
752
+ return
753
+ }
754
+ const segments = segmentsAfter(pathname, API + '/runtime')
755
+ if (segments === undefined || segments.length !== 1) {
756
+ response.writeHead(404)
757
+ response.end()
758
+ return
759
+ }
760
+ // An exact-name allow-list: a segment carrying a separator never matches.
761
+ const runtimeName = segments[0]
762
+ let base
763
+ if (runtimeName === 'live2dcubismcore.min.js') base = coreBase
764
+ else if (runtimeName === 'live2d-vendor.js') base = vendorBase
765
+ else {
766
+ response.writeHead(404)
767
+ response.end()
768
+ return
769
+ }
770
+ const file = join(base, runtimeName)
771
+ if (!existsSync(file)) {
772
+ // Cubism Core 走官方 CDN 兜底:用户不用再自己找文件。
773
+ if (runtimeName === 'live2dcubismcore.min.js') {
774
+ void fetchCoreFromCdn(response, file)
775
+ return
776
+ }
777
+ sendJson(response, 404, { ok: false, error: 'runtime-file-missing', file: runtimeName })
778
+ return
779
+ }
780
+ serveFile(request, response, base, file, CAP_RUNTIME)
781
+ },
782
+ }
783
+ }
784
+
785
+
786
+ // ---------------------------------------------------------------- activity
787
+ //
788
+ // Session-activity mirror (requirement #4): the pet follows the DSH agent's
789
+ // real lifecycle instead of only reacting to clicks.
790
+ //
791
+ // The event vocabulary is the official one (verified against dsh-agent's
792
+ // runtime-types): 'agent/status' carries idle <-> running, 'agent/turn-stopping'
793
+ // fires when a turn finishes, 'agent/error' on failure, and 'approval/request'
794
+ // (a waterfall event, so it must be resumed) while the user is being asked.
795
+ //
796
+ // Delivery is a same-origin SSE stream rather than polling: transitions are
797
+ // pushed the moment they happen, and an idle page costs only a keep-alive
798
+ // comment frame.
799
+
800
+ /** The activity phases the browser half understands. */
801
+ export const ACTIVITY_PHASES = ['idle', 'thinking', 'waiting', 'tool', 'done', 'failed']
802
+
803
+ /** How long the 'done' celebration is held before falling back to idle. */
804
+ const DONE_HOLD_MS = 3500
805
+
806
+ /** SSE keep-alive interval. */
807
+ const SSE_PING_MS = 30000
808
+
809
+ /**
810
+ * The activity hub: folds DSH session events into one coarse phase, and fans
811
+ * that phase out to every subscribed SSE response.
812
+ */
813
+ export class ActivityHub {
814
+ constructor() {
815
+ this.phase = 'idle'
816
+ this.detail = ''
817
+ this.listeners = new Set()
818
+ this.doneTimer = undefined
819
+ }
820
+
821
+ /** Current snapshot (sent as the first frame of every stream). */
822
+ snapshot() {
823
+ return { phase: this.phase, detail: this.detail, at: Date.now() }
824
+ }
825
+
826
+ /** Subscribe one SSE response; returns the unsubscribe function. */
827
+ subscribe(listener) {
828
+ this.listeners.add(listener)
829
+ return () => { this.listeners.delete(listener) }
830
+ }
831
+
832
+ set(phase, detail = '') {
833
+ if (this.phase === phase && this.detail === detail) return
834
+ this.phase = phase
835
+ this.detail = detail
836
+ this.emit()
837
+ }
838
+
839
+ /**
840
+ * Enter the 'done' celebration and fall back to idle after a hold, so the
841
+ * pet visibly finishes a turn instead of snapping straight back.
842
+ */
843
+ celebrate() {
844
+ if (this.doneTimer !== undefined) clearTimeout(this.doneTimer)
845
+ this.set('done', '')
846
+ this.doneTimer = setTimeout(() => {
847
+ this.doneTimer = undefined
848
+ this.set('idle', '')
849
+ }, DONE_HOLD_MS)
850
+ // Keep the process free to exit; the timer is purely cosmetic.
851
+ this.doneTimer?.unref?.()
852
+ }
853
+
854
+ emit() {
855
+ const payload = this.snapshot()
856
+ for (const listener of this.listeners) {
857
+ try {
858
+ listener(payload)
859
+ } catch {
860
+ /* a dead stream must not break the others */
861
+ }
862
+ }
863
+ }
864
+
865
+ dispose() {
866
+ if (this.doneTimer !== undefined) {
867
+ clearTimeout(this.doneTimer)
868
+ this.doneTimer = undefined
869
+ }
870
+ this.listeners.clear()
871
+ }
872
+ }
873
+
874
+ /**
875
+ * How long the pet stays in the 'tool' phase after the last tool returns.
876
+ *
877
+ * Long enough to bridge the gap between consecutive tool calls in one turn
878
+ * (otherwise the phase flaps and the animation restarts constantly), short
879
+ * enough that the pet visibly settles once the work really stops.
880
+ */
881
+ const TOOL_IDLE_MS = 1200
882
+
883
+ /**
884
+ * Fold the official DSH events into hub phases. Every subscription is optional
885
+ * at runtime: an older host that lacks one simply keeps mirroring the others.
886
+ */
887
+ export function attachActivityEvents(ctx, hub) {
888
+ const on = (event, handler) => {
889
+ try {
890
+ ctx.on(event, handler)
891
+ } catch {
892
+ /* host without this event */
893
+ }
894
+ }
895
+ // Waterfall events only continue if the handler resumes the chain, so a
896
+ // throw here would silently break tool execution for the whole session.
897
+ const onWaterfall = (event, handler) => {
898
+ try {
899
+ ctx.on(event, (...args) => {
900
+ try {
901
+ return handler(...args)
902
+ } catch {
903
+ // Never swallow the chain: hand control straight on.
904
+ const next = args[args.length - 1]
905
+ return typeof next === 'function' ? next() : undefined
906
+ }
907
+ })
908
+ } catch {
909
+ /* host without this event */
910
+ }
911
+ }
912
+ on('agent/status', (payload) => {
913
+ const status = payload?.status
914
+ if (status === 'running') hub.set('thinking', '')
915
+ else if (status === 'idle' && hub.phase !== 'done') hub.set('idle', '')
916
+ })
917
+ on('agent/turn-stopping', () => hub.celebrate())
918
+ on('agent/error', () => hub.set('failed', ''))
919
+ // approval/request is a waterfall event: it MUST resume the chain.
920
+ on('approval/request', (_request, next) => {
921
+ hub.set('waiting', '')
922
+ return typeof next === 'function' ? next() : undefined
923
+ })
924
+
925
+ // Tool activity refines the generic 'thinking' phase while a turn runs.
926
+ //
927
+ // The obvious-looking event name for this is 'tool/call', but that is a
928
+ // SESSION LOG event (a record appended to the transcript), not a cordis
929
+ // lifecycle event — subscribing to it never fires, which is why the pet
930
+ // appeared to ignore tool activity entirely. The live hooks are the
931
+ // 'tools/*' waterfall events, which carry the ToolExecution itself.
932
+ //
933
+ // A turn usually runs MANY tools back to back, so reverting the instant one
934
+ // returns would flap tool -> thinking -> tool several times a second, and each
935
+ // transition restarts the pet's animation. The revert is therefore debounced:
936
+ // it only happens once the tools actually stop arriving.
937
+ let toolIdleTimer
938
+ onWaterfall('tools/pre-execute', (exec, next) => {
939
+ if (toolIdleTimer !== undefined) {
940
+ clearTimeout(toolIdleTimer)
941
+ toolIdleTimer = undefined
942
+ }
943
+ const name = exec?.name
944
+ hub.set('tool', typeof name === 'string' ? name : '')
945
+ return next()
946
+ })
947
+ onWaterfall('tools/post-execute', (_exec, _result, next) => {
948
+ if (toolIdleTimer !== undefined) clearTimeout(toolIdleTimer)
949
+ toolIdleTimer = setTimeout(() => {
950
+ toolIdleTimer = undefined
951
+ // Only step down if nothing else has taken over in the meantime.
952
+ if (hub.phase === 'tool') hub.set('thinking', '')
953
+ }, TOOL_IDLE_MS)
954
+ return next()
955
+ })
956
+ }
957
+
958
+ /** The activity stream + snapshot route. */
959
+ function eventsRoute(hub) {
960
+ return {
961
+ kind: 'exact',
962
+ path: API + '/events',
963
+ handler: (request, response) => {
964
+ if (!loopbackOnly(request)) {
965
+ response.writeHead(403)
966
+ response.end()
967
+ return
968
+ }
969
+ if (request.method !== 'GET' || hub === undefined) {
970
+ response.writeHead(405, { allow: 'GET' })
971
+ response.end()
972
+ return
973
+ }
974
+ response.writeHead(200, {
975
+ 'content-type': 'text/event-stream; charset=utf-8',
976
+ 'cache-control': 'no-store',
977
+ connection: 'keep-alive',
978
+ 'x-accel-buffering': 'no',
979
+ })
980
+ const frame = (payload) => {
981
+ // Guard every write: the peer may have gone away mid-push.
982
+ try {
983
+ response.write('data: ' + JSON.stringify(payload) + '\n\n')
984
+ } catch {
985
+ /* stream already closed */
986
+ }
987
+ }
988
+ frame(hub.snapshot())
989
+ const unsubscribe = hub.subscribe(frame)
990
+ const ping = setInterval(() => {
991
+ try {
992
+ response.write(': ping\n\n')
993
+ } catch {
994
+ /* stream already closed */
995
+ }
996
+ }, SSE_PING_MS)
997
+ ping.unref?.()
998
+ const close = () => {
999
+ clearInterval(ping)
1000
+ unsubscribe()
1001
+ }
1002
+ request.on('close', close)
1003
+ response.on('close', close)
1004
+ response.on('error', close)
1005
+ },
1006
+ }
1007
+ }
1008
+
1009
+ /** The complete route table this plugin owns. */
1010
+ export function buildRoutes(hub) {
1011
+ return [catalogRoute(), assetRoute(), runtimeRoute(), eventsRoute(hub)]
1012
+ }
1013
+
1014
+ export function apply(ctx) {
1015
+ const hub = new ActivityHub()
1016
+ attachActivityEvents(ctx, hub)
1017
+ ctx.effect(() => () => hub.dispose(), 'live2d-pet: activity hub')
1018
+ ctx.inject(['webServer'], (host) => {
1019
+ for (const route of buildRoutes(hub)) {
1020
+ try {
1021
+ host.effect(() => host.webServer.register(route), 'live2d-pet: route ' + route.path)
1022
+ } catch (error) {
1023
+ host.logger?.warn('live2d-pet: route ' + route.path + ' failed: ' + String(error))
1024
+ }
1025
+ }
1026
+ })
1027
+ }