@linxin666/dsh-pet 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +65 -2
- package/README.zh.md +65 -2
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- package/contracts/pet-manifest-v2.schema.json +281 -0
- package/contracts/status-decoration-v1.schema.json +144 -0
- package/contracts/voice-pack-v1.schema.json +234 -0
- package/lib/client.js +251 -43
- package/lib/client.js.map +1 -1
- package/lib/index.js +1139 -100
- package/lib/types/access.d.ts +16 -0
- package/lib/types/access.d.ts.map +1 -0
- package/lib/types/access.js +20 -0
- package/lib/types/chatter.d.ts +61 -3
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +71 -12
- package/lib/types/client/PetSettingsCard.d.ts +4 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +11 -3
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +111 -4
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.js +133 -25
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/contracts/status-decoration.d.ts +85 -0
- package/lib/types/contracts/status-decoration.d.ts.map +1 -0
- package/lib/types/contracts/status-decoration.js +21 -0
- package/lib/types/decoration.d.ts +39 -0
- package/lib/types/decoration.d.ts.map +1 -0
- package/lib/types/decoration.js +210 -0
- package/lib/types/event-projection.d.ts +8 -3
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +9 -4
- package/lib/types/image-dimensions.d.ts +26 -0
- package/lib/types/image-dimensions.d.ts.map +1 -0
- package/lib/types/image-dimensions.js +77 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +3 -1
- package/lib/types/registry.d.ts +55 -2
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +240 -16
- package/lib/types/routes.d.ts +6 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +163 -32
- package/lib/types/service.d.ts +33 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +42 -3
- package/lib/types/voice-pack.d.ts +98 -0
- package/lib/types/voice-pack.d.ts.map +1 -0
- package/lib/types/voice-pack.js +384 -0
- package/package.json +11 -10
- package/src/access.ts +40 -0
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetSettingsCard.tsx +26 -2
- package/src/client/PetSprite.test.tsx +325 -12
- package/src/client/PetSprite.tsx +150 -25
- package/src/client/locales.ts +4 -0
- package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
- package/src/client/renderers/live2d/runtime.ts +8 -2
- package/src/client/renderers/live2d.test.ts +233 -9
- package/src/client/renderers/live2d.ts +143 -30
- package/src/client/settings-form.ts +8 -6
- package/src/contracts/status-decoration.ts +78 -0
- package/src/decoration.test.ts +178 -0
- package/src/decoration.ts +220 -0
- package/src/event-projection.ts +10 -5
- package/src/image-dimensions.test.ts +85 -0
- package/src/image-dimensions.ts +76 -0
- package/src/index.ts +3 -1
- package/src/registry.test.ts +276 -0
- package/src/registry.ts +274 -15
- package/src/routes.ts +162 -33
- package/src/service.ts +59 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
package/lib/types/registry.js
CHANGED
|
@@ -26,11 +26,15 @@
|
|
|
26
26
|
* whale-girl manifest overrides its own durations.
|
|
27
27
|
* @module @linxin666/dsh-pet/registry
|
|
28
28
|
*/
|
|
29
|
-
import { existsSync,
|
|
29
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
30
30
|
import { homedir } from 'node:os';
|
|
31
31
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
32
32
|
import { fileURLToPath } from 'node:url';
|
|
33
33
|
import { normalizePetRemarks } from './remarks.js';
|
|
34
|
+
import { mergeVoicePacks, normalizeVoicePack } from './voice-pack.js';
|
|
35
|
+
import { parseDecorationManifest } from './decoration.js';
|
|
36
|
+
import { imageDimensions } from './image-dimensions.js';
|
|
37
|
+
import { PET_DECORATION_API_VERSION } from './contracts/status-decoration.js';
|
|
34
38
|
import { dshHome } from './dsh-home.js';
|
|
35
39
|
import { parsePetManifest } from './manifest-v2.js';
|
|
36
40
|
import { collectModel3References } from './model3.js';
|
|
@@ -376,22 +380,25 @@ function scanPetDir(dir, options) {
|
|
|
376
380
|
}
|
|
377
381
|
if (!verdict.ok)
|
|
378
382
|
continue;
|
|
383
|
+
let entry;
|
|
379
384
|
if (verdict.manifest.renderer === 'live2d') {
|
|
380
|
-
|
|
381
|
-
if (entry !== undefined)
|
|
382
|
-
entries.push(entry);
|
|
383
|
-
continue;
|
|
385
|
+
entry = resolveLive2dEntry(verdict.manifest, entryDir, options);
|
|
384
386
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
387
|
+
else {
|
|
388
|
+
const legacy = flattenV2Sprite2d(verdict.manifest);
|
|
389
|
+
if (legacy === undefined) {
|
|
390
|
+
const note = 'pet ' + verdict.manifest.id + ': sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver';
|
|
391
|
+
options.diagnostics?.push({ level: 'error', source: entryDir, message: note });
|
|
392
|
+
options.warnings?.push(note);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
entry = resolvePetManifest(legacy, entryDir, options);
|
|
391
396
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
397
|
+
if (entry === undefined)
|
|
398
|
+
continue;
|
|
399
|
+
// Optional voice pack (voice.json) — pure content, warn-and-drop (M4).
|
|
400
|
+
const voice = loadVoicePackFile(join(entryDir, 'voice.json'), options);
|
|
401
|
+
entries.push({ ...entry, ...(voice === undefined ? {} : { voice }) });
|
|
395
402
|
}
|
|
396
403
|
return entries;
|
|
397
404
|
}
|
|
@@ -405,6 +412,177 @@ function readPetJson(file, warnings) {
|
|
|
405
412
|
return undefined;
|
|
406
413
|
}
|
|
407
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* Scan-time read ceiling for user-authored JSON descriptors (voice.json,
|
|
417
|
+
* .voice.json, decoration.json): the registry reads these synchronously at
|
|
418
|
+
* plugin startup, and a pathological file — multi-GB, or a FIFO/device
|
|
419
|
+
* symlink — must not hang or exhaust the host before the warn-and-drop
|
|
420
|
+
* discipline can apply (review-spd follow-up, pet-center M4/M5).
|
|
421
|
+
*/
|
|
422
|
+
export const PET_SCAN_JSON_CAP = 64 * 1024;
|
|
423
|
+
/**
|
|
424
|
+
* Stat one scanned JSON descriptor with a regular-file + size guard, so a
|
|
425
|
+
* pathological user file is skipped with a warning instead of stalling or
|
|
426
|
+
* OOM-ing the host at startup. Returns the Stats, or undefined when the
|
|
427
|
+
* caller must skip the file (a warning was recorded).
|
|
428
|
+
*/
|
|
429
|
+
function guardedScannedJsonStat(file, options, what) {
|
|
430
|
+
let st;
|
|
431
|
+
try {
|
|
432
|
+
st = statSync(file);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
const warn = (message) => {
|
|
438
|
+
options.warnings?.push(file + ': ' + message);
|
|
439
|
+
options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message });
|
|
440
|
+
};
|
|
441
|
+
if (!st.isFile()) {
|
|
442
|
+
warn(what + ' is not a regular file; ignored');
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
if (st.size > PET_SCAN_JSON_CAP) {
|
|
446
|
+
warn(what + ' exceeds the ' + PET_SCAN_JSON_CAP + '-byte scan ceiling; ignored');
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
return st;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Load and normalize one optional voice.json (pet-center M4). A missing
|
|
453
|
+
* file is silent; a broken file warns and drops. The pack is pure content,
|
|
454
|
+
* so every issue stays a warning — a bad voice.json never rejects a pet.
|
|
455
|
+
*/
|
|
456
|
+
function loadVoicePackFile(file, options) {
|
|
457
|
+
if (!existsSync(file))
|
|
458
|
+
return undefined;
|
|
459
|
+
if (guardedScannedJsonStat(file, options, 'voice pack') === undefined)
|
|
460
|
+
return undefined;
|
|
461
|
+
const warn = (message) => {
|
|
462
|
+
options.warnings?.push(file + ': ' + message);
|
|
463
|
+
options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message });
|
|
464
|
+
};
|
|
465
|
+
let raw;
|
|
466
|
+
try {
|
|
467
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
warn('voice pack is not valid JSON; ignored: ' + (error instanceof Error ? error.message : String(error)));
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
return normalizeVoicePack(raw, warn);
|
|
474
|
+
}
|
|
475
|
+
/** Decoration asset URL prefix (served by the decoration route, M5). */
|
|
476
|
+
export const DECORATION_ASSET_PREFIX = '/api/pet/decoration';
|
|
477
|
+
/** Read the pixel dimensions of a decoration strip (PNG/WebP), if decodable. */
|
|
478
|
+
function readImageDimensions(file) {
|
|
479
|
+
let header;
|
|
480
|
+
try {
|
|
481
|
+
// Only the header is needed for dimensions; cap the read so a huge or
|
|
482
|
+
// corrupt strip cannot balloon memory during the registry scan.
|
|
483
|
+
const fd = openSync(file, 'r');
|
|
484
|
+
try {
|
|
485
|
+
header = Buffer.alloc(64);
|
|
486
|
+
const read = readSync(fd, header, 0, header.length, 0);
|
|
487
|
+
if (read < 0)
|
|
488
|
+
return undefined;
|
|
489
|
+
header = header.subarray(0, read);
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
closeSync(fd);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
return imageDimensions(header);
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Scan one directory of decoration folders ('decoration.json' + strip).
|
|
502
|
+
* Later scans override earlier ones on id collision; a bad descriptor warns
|
|
503
|
+
* and skips — the never-throw philosophy holds for decorations too (M5).
|
|
504
|
+
*/
|
|
505
|
+
function scanDecorationDir(dir, options) {
|
|
506
|
+
if (!existsSync(dir))
|
|
507
|
+
return [];
|
|
508
|
+
let names = [];
|
|
509
|
+
try {
|
|
510
|
+
names = readdirSync(dir).filter(name => !name.startsWith('.'));
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return [];
|
|
514
|
+
}
|
|
515
|
+
names.sort();
|
|
516
|
+
const entries = [];
|
|
517
|
+
for (const name of names) {
|
|
518
|
+
const entryDir = join(dir, name);
|
|
519
|
+
const manifestFile = join(entryDir, 'decoration.json');
|
|
520
|
+
if (!existsSync(manifestFile))
|
|
521
|
+
continue;
|
|
522
|
+
if (guardedScannedJsonStat(manifestFile, options, 'decoration descriptor') === undefined)
|
|
523
|
+
continue;
|
|
524
|
+
let raw;
|
|
525
|
+
try {
|
|
526
|
+
raw = JSON.parse(readFileSync(manifestFile, 'utf8'));
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
const message = 'skipping ' + manifestFile + ': ' + (error instanceof Error ? error.message : String(error));
|
|
530
|
+
options.warnings?.push(message);
|
|
531
|
+
options.diagnostics?.push({ level: 'error', source: entryDir, message });
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
const verdict = parseDecorationManifest(raw, manifestFile);
|
|
535
|
+
for (const diagnostic of verdict.diagnostics) {
|
|
536
|
+
options.diagnostics?.push({ level: diagnostic.level, source: entryDir, message: diagnostic.message });
|
|
537
|
+
options.warnings?.push(diagnostic.message);
|
|
538
|
+
}
|
|
539
|
+
if (!verdict.ok)
|
|
540
|
+
continue;
|
|
541
|
+
const manifest = verdict.manifest;
|
|
542
|
+
// A missing strip keeps the entry listed (mirroring the live2d closure
|
|
543
|
+
// discipline) but earns a diagnostic: the ornament will silently render
|
|
544
|
+
// nothing, and the warning names the file to fix.
|
|
545
|
+
if (!existsSync(join(entryDir, manifest.entry))) {
|
|
546
|
+
const message = 'decoration ' + manifest.id + ': strip file missing: ' + manifest.entry;
|
|
547
|
+
options.warnings?.push(message);
|
|
548
|
+
options.diagnostics?.push({ level: 'warning', source: entryDir, message });
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
// Geometry check: the client renders the strip as a single row of
|
|
552
|
+
// 'columns' frames (background-position advances by frame width only),
|
|
553
|
+
// so the strip must be exactly cell.width * columns wide and cell.height
|
|
554
|
+
// tall. A mismatched strip silently shows the wrong/partial frames —
|
|
555
|
+
// warn-and-keep, mirroring the missing-strip discipline (never throw).
|
|
556
|
+
const actual = readImageDimensions(join(entryDir, manifest.entry));
|
|
557
|
+
if (actual !== undefined) {
|
|
558
|
+
const expectedWidth = manifest.cell.width * manifest.columns;
|
|
559
|
+
if (actual.width !== expectedWidth || actual.height !== manifest.cell.height) {
|
|
560
|
+
const message = 'decoration ' + manifest.id + ': strip ' + actual.width + 'x' + actual.height
|
|
561
|
+
+ ' does not match cell ' + manifest.cell.width + 'x' + manifest.cell.height + ' x ' + manifest.columns
|
|
562
|
+
+ ' columns (expected ' + expectedWidth + 'x' + manifest.cell.height + '); frames will render wrong';
|
|
563
|
+
options.warnings?.push(message);
|
|
564
|
+
options.diagnostics?.push({ level: 'warning', source: entryDir, message });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
entries.push({
|
|
569
|
+
apiVersion: PET_DECORATION_API_VERSION,
|
|
570
|
+
id: manifest.id,
|
|
571
|
+
dir: entryDir,
|
|
572
|
+
entryPath: manifest.entry,
|
|
573
|
+
servable: ['decoration.json', manifest.entry],
|
|
574
|
+
license: manifest.license,
|
|
575
|
+
assetBase: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id),
|
|
576
|
+
entryUrl: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id) + '/' + manifest.entry,
|
|
577
|
+
cell: manifest.cell,
|
|
578
|
+
columns: manifest.columns,
|
|
579
|
+
durations: manifest.durations,
|
|
580
|
+
loop: manifest.loop,
|
|
581
|
+
phases: manifest.phases,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
return entries;
|
|
585
|
+
}
|
|
408
586
|
/**
|
|
409
587
|
* Load the pet registry: built-in 'assets/*' first, then the hatch-pet
|
|
410
588
|
* custom pets directory, then composed 'extra' manifests (each later source
|
|
@@ -435,12 +613,15 @@ export function loadPetRegistry(options) {
|
|
|
435
613
|
}
|
|
436
614
|
// The pet-center user directory ranks above the legacy hatch-pet source.
|
|
437
615
|
const dshPetsDir = options.dshPetsDir ?? join(dshHome(), 'pets');
|
|
616
|
+
let globalVoice;
|
|
438
617
|
if (dshPetsDir !== '') {
|
|
439
618
|
for (const entry of scanPetDir(dshPetsDir, { assetPrefix, warnings, diagnostics })) {
|
|
440
619
|
if (byId.has(entry.id))
|
|
441
620
|
warnings.push('user pet ' + entry.id + ' overrides an earlier registration');
|
|
442
621
|
byId.set(entry.id, entry);
|
|
443
622
|
}
|
|
623
|
+
// The global voice override layers under every per-pet pack (M4, #677).
|
|
624
|
+
globalVoice = loadVoicePackFile(join(dshPetsDir, '.voice.json'), { warnings, diagnostics });
|
|
444
625
|
}
|
|
445
626
|
for (const manifest of options.extra ?? []) {
|
|
446
627
|
const raw = manifest.spritesheetPath;
|
|
@@ -461,17 +642,59 @@ export function loadPetRegistry(options) {
|
|
|
461
642
|
warnings.push('composed pet ' + entry.id + ' overrides an earlier registration');
|
|
462
643
|
byId.set(entry.id, entry);
|
|
463
644
|
}
|
|
645
|
+
// Status decorations (pet-center M5, #567): built-in entries first,
|
|
646
|
+
// then same-id user entries under '$DSH_HOME/pets/decorations' override.
|
|
647
|
+
const decorationById = new Map();
|
|
648
|
+
for (const entry of scanDecorationDir(join(packageRoot, 'assets', 'decorations'), { warnings, diagnostics })) {
|
|
649
|
+
decorationById.set(entry.id, entry);
|
|
650
|
+
}
|
|
651
|
+
if (dshPetsDir !== '') {
|
|
652
|
+
for (const entry of scanDecorationDir(join(dshPetsDir, 'decorations'), { warnings, diagnostics })) {
|
|
653
|
+
if (decorationById.has(entry.id)) {
|
|
654
|
+
warnings.push('user decoration ' + entry.id + ' overrides the built-in one');
|
|
655
|
+
}
|
|
656
|
+
decorationById.set(entry.id, entry);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
464
659
|
const entries = [...byId.values()];
|
|
660
|
+
const decorations = [...decorationById.values()];
|
|
465
661
|
return {
|
|
466
662
|
entries,
|
|
467
663
|
warnings,
|
|
468
664
|
diagnostics,
|
|
469
665
|
byId: (id) => byId.get(id),
|
|
470
666
|
defaultEntry: () => entries.find(entry => builtinIds.has(entry.id)) ?? entries[0],
|
|
667
|
+
...(globalVoice === undefined ? {} : { globalVoice }),
|
|
668
|
+
decorations,
|
|
669
|
+
decorationById: (id) => decorationById.get(id),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
/** The built-in default decoration id (M5): the first reference ornament. */
|
|
673
|
+
export const DEFAULT_DECORATION_ID = 'whale';
|
|
674
|
+
/** Strip host-only fields, leaving the browser-visible decoration view. */
|
|
675
|
+
export function decorationView(entry) {
|
|
676
|
+
return {
|
|
677
|
+
apiVersion: PET_DECORATION_API_VERSION,
|
|
678
|
+
id: entry.id,
|
|
679
|
+
assetBase: entry.assetBase,
|
|
680
|
+
entryUrl: entry.entryUrl,
|
|
681
|
+
cell: entry.cell,
|
|
682
|
+
columns: entry.columns,
|
|
683
|
+
durations: entry.durations,
|
|
684
|
+
loop: entry.loop,
|
|
685
|
+
phases: entry.phases,
|
|
471
686
|
};
|
|
472
687
|
}
|
|
473
|
-
/**
|
|
474
|
-
|
|
688
|
+
/**
|
|
689
|
+
* Strip host-only fields, leaving the client-visible definition. When the
|
|
690
|
+
* registry carries a global voice pack, its panel chrome layers under the
|
|
691
|
+
* entry's own pack (per-slot merge, pet > global), mirroring the voice-pool
|
|
692
|
+
* layering (pet-center M4, issue #677).
|
|
693
|
+
*/
|
|
694
|
+
export function petEntryView(entry, globalVoice) {
|
|
695
|
+
const panel = globalVoice === undefined
|
|
696
|
+
? entry.voice?.panel
|
|
697
|
+
: mergeVoicePacks(globalVoice, entry.voice)?.panel;
|
|
475
698
|
return {
|
|
476
699
|
id: entry.id,
|
|
477
700
|
displayName: entry.displayName,
|
|
@@ -486,6 +709,7 @@ export function petEntryView(entry) {
|
|
|
486
709
|
...(entry.sequences === undefined ? {} : { sequences: entry.sequences }),
|
|
487
710
|
atlasUrl: entry.atlasUrl,
|
|
488
711
|
manifestUrl: entry.manifestUrl,
|
|
712
|
+
...(panel === undefined ? {} : { panel }),
|
|
489
713
|
};
|
|
490
714
|
}
|
|
491
715
|
/** The absolute file a pet's atlas resolves to (host asset route). */
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
* domains are platform-registered, so the pet serves its own API and media —
|
|
6
6
|
* the same pattern as dsh-remote-web-ui's '/api/pair' family. The asset route
|
|
7
7
|
* is one prefix registration serving every registry entry (manifest, atlas,
|
|
8
|
-
* optional previews), so adding a pet never touches route wiring.
|
|
8
|
+
* optional previews), so adding a pet never touches route wiring. Both the
|
|
9
|
+
* JSON API, the asset prefix, and the Live2D runtime prefix are loopback-only
|
|
10
|
+
* by default; a live paired-device cookie is an extra allow path when
|
|
11
|
+
* remote-web-ui is loaded.
|
|
9
12
|
* @module @linxin666/dsh-pet/routes
|
|
10
13
|
*/
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
11
15
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
|
|
12
16
|
import type { PetService } from './service.ts';
|
|
13
17
|
/** Browser-facing base path of the pet API. */
|
|
@@ -53,6 +57,7 @@ export interface PetRuntimeRoots {
|
|
|
53
57
|
/** Build the full route family (API + assets + runtime) for one service. */
|
|
54
58
|
export declare function makePetRoutes(deps: {
|
|
55
59
|
service: PetService;
|
|
60
|
+
ctx: Context;
|
|
56
61
|
assetCaps?: PetAssetCaps;
|
|
57
62
|
} & PetRuntimeRoots): WebRoute[];
|
|
58
63
|
export { petPackageRoot } from './registry.ts';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAM9C,+CAA+C;AAC/C,eAAO,MAAM,cAAc,aAAa,CAAA;AAExC,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,SAAS,CAAA;AAMtC;;;;GAIG;AACH,eAAO,MAAM,cAAc;IACzB,yBAAyB;aACzB,QAAQ;IACR,iDAAiD;aACjD,KAAK;IACL,8EAA8E;aAC9E,KAAK;CACG,CAAA;AAEV,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAWD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUrF;AAsPD,4EAA4E;AAC5E,eAAO,MAAM,kBAAkB,QAA8B,CAAA;AAe7D,4EAA4E;AAC5E,eAAO,MAAM,eAAe,QAAmB,CAAA;AAE/C,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAwMD,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,UAAU,CAAC;IAAC,GAAG,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,YAAY,CAAA;CAAE,GAAG,eAAe,GAAG,QAAQ,EAAE,CAwDjI;AAGD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA"}
|
package/lib/types/routes.js
CHANGED
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
* domains are platform-registered, so the pet serves its own API and media —
|
|
6
6
|
* the same pattern as dsh-remote-web-ui's '/api/pair' family. The asset route
|
|
7
7
|
* is one prefix registration serving every registry entry (manifest, atlas,
|
|
8
|
-
* optional previews), so adding a pet never touches route wiring.
|
|
8
|
+
* optional previews), so adding a pet never touches route wiring. Both the
|
|
9
|
+
* JSON API, the asset prefix, and the Live2D runtime prefix are loopback-only
|
|
10
|
+
* by default; a live paired-device cookie is an extra allow path when
|
|
11
|
+
* remote-web-ui is loaded.
|
|
9
12
|
* @module @linxin666/dsh-pet/routes
|
|
10
13
|
*/
|
|
11
14
|
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
12
15
|
import { readFile } from 'node:fs/promises';
|
|
13
16
|
import { join, sep } from 'node:path';
|
|
14
|
-
import { petEntryView, petPackageRoot } from './registry.js';
|
|
15
|
-
import {
|
|
17
|
+
import { DECORATION_ASSET_PREFIX, petEntryView, petPackageRoot } from './registry.js';
|
|
18
|
+
import { isPetAllowed } from './access.js';
|
|
16
19
|
import { dshHome } from './dsh-home.js';
|
|
17
20
|
/** Browser-facing base path of the pet API. */
|
|
18
21
|
export const PET_API_PREFIX = '/api/pet';
|
|
@@ -114,20 +117,20 @@ function readJsonBody(req) {
|
|
|
114
117
|
req.on('error', reject);
|
|
115
118
|
});
|
|
116
119
|
}
|
|
117
|
-
/** Shared route fence:
|
|
118
|
-
function guard(req, res) {
|
|
119
|
-
if (
|
|
120
|
+
/** Shared route fence: loopback always passes; a live paired-device cookie is an extra allow path. */
|
|
121
|
+
function guard(ctx, req, res) {
|
|
122
|
+
if (isPetAllowed(ctx, req))
|
|
120
123
|
return true;
|
|
121
124
|
json(res, 403, { ok: false, error: 'forbidden: loopback-only' });
|
|
122
125
|
return false;
|
|
123
126
|
}
|
|
124
127
|
/** Wrap one async service call as a GET JSON route. */
|
|
125
|
-
function getRoute(path, run) {
|
|
128
|
+
function getRoute(ctx, path, run) {
|
|
126
129
|
return {
|
|
127
130
|
kind: 'exact',
|
|
128
131
|
path,
|
|
129
132
|
handler: (req, res) => {
|
|
130
|
-
if (!guard(req, res))
|
|
133
|
+
if (!guard(ctx, req, res))
|
|
131
134
|
return;
|
|
132
135
|
if (!requireMethod(req, res, 'GET'))
|
|
133
136
|
return;
|
|
@@ -138,12 +141,12 @@ function getRoute(path, run) {
|
|
|
138
141
|
};
|
|
139
142
|
}
|
|
140
143
|
/** Wrap one async service call as a POST JSON route (body passed through). */
|
|
141
|
-
function postRoute(path, run) {
|
|
144
|
+
function postRoute(ctx, path, run) {
|
|
142
145
|
return {
|
|
143
146
|
kind: 'exact',
|
|
144
147
|
path,
|
|
145
148
|
handler: (req, res) => {
|
|
146
|
-
if (!guard(req, res))
|
|
149
|
+
if (!guard(ctx, req, res))
|
|
147
150
|
return Promise.resolve();
|
|
148
151
|
if (!requireMethod(req, res, 'POST'))
|
|
149
152
|
return Promise.resolve();
|
|
@@ -178,10 +181,10 @@ function dirAliases(registry) {
|
|
|
178
181
|
* match; containedRealpath stays as the second layer. Composed pets without
|
|
179
182
|
* a manifest file get a synthesized pet.json.
|
|
180
183
|
*/
|
|
181
|
-
function assetHandler(registry, caps) {
|
|
184
|
+
function assetHandler(ctx, registry, caps) {
|
|
182
185
|
const aliases = dirAliases(registry);
|
|
183
|
-
return (req, res) => {
|
|
184
|
-
if (!guard(req, res))
|
|
186
|
+
return ((req, res) => {
|
|
187
|
+
if (!guard(ctx, req, res))
|
|
185
188
|
return;
|
|
186
189
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
187
190
|
res.writeHead(405);
|
|
@@ -248,7 +251,7 @@ function assetHandler(registry, caps) {
|
|
|
248
251
|
file = existsSync(preview) ? preview : undefined;
|
|
249
252
|
}
|
|
250
253
|
if (synthesized) {
|
|
251
|
-
const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), 'utf8');
|
|
254
|
+
const body = Buffer.from(JSON.stringify(petEntryView(entry, registry.globalVoice), null, 2), 'utf8');
|
|
252
255
|
res.writeHead(200, {
|
|
253
256
|
'content-type': 'application/json; charset=utf-8',
|
|
254
257
|
'content-length': String(body.byteLength),
|
|
@@ -289,7 +292,7 @@ function assetHandler(registry, caps) {
|
|
|
289
292
|
res.end();
|
|
290
293
|
return;
|
|
291
294
|
}
|
|
292
|
-
readFile(resolved).then((body) => {
|
|
295
|
+
return readFile(resolved).then((body) => {
|
|
293
296
|
res.writeHead(200, {
|
|
294
297
|
'content-type': mimeFor(resolved),
|
|
295
298
|
'content-length': String(body.byteLength),
|
|
@@ -304,7 +307,7 @@ function assetHandler(registry, caps) {
|
|
|
304
307
|
res.writeHead(404);
|
|
305
308
|
res.end();
|
|
306
309
|
});
|
|
307
|
-
};
|
|
310
|
+
});
|
|
308
311
|
}
|
|
309
312
|
/** Browser-facing base path of the plugin runtime files (pet-center M3). */
|
|
310
313
|
export const PET_RUNTIME_PREFIX = PET_API_PREFIX + '/runtime';
|
|
@@ -328,9 +331,9 @@ export const PET_RUNTIME_CAP = 16 * 1024 * 1024;
|
|
|
328
331
|
* guidance (the Cubism Core is user-supplied, so its absence is a normal
|
|
329
332
|
* state, not an error).
|
|
330
333
|
*/
|
|
331
|
-
function runtimeHandler(roots) {
|
|
332
|
-
return (req, res) => {
|
|
333
|
-
if (!guard(req, res))
|
|
334
|
+
function runtimeHandler(ctx, roots) {
|
|
335
|
+
return ((req, res) => {
|
|
336
|
+
if (!guard(ctx, req, res))
|
|
334
337
|
return;
|
|
335
338
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
336
339
|
res.writeHead(405);
|
|
@@ -387,7 +390,7 @@ function runtimeHandler(roots) {
|
|
|
387
390
|
res.end();
|
|
388
391
|
return;
|
|
389
392
|
}
|
|
390
|
-
readFile(resolved).then((body) => {
|
|
393
|
+
return readFile(resolved).then((body) => {
|
|
391
394
|
res.writeHead(200, {
|
|
392
395
|
'content-type': name.endsWith('.map') ? 'application/json' : 'application/javascript; charset=utf-8',
|
|
393
396
|
'content-length': String(body.byteLength),
|
|
@@ -402,40 +405,163 @@ function runtimeHandler(roots) {
|
|
|
402
405
|
res.writeHead(404);
|
|
403
406
|
res.end();
|
|
404
407
|
});
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The decoration asset handler behind '/api/pet/decoration/<id>/<file>'
|
|
412
|
+
* (pet-center M5, #567). Serves exactly the files a decoration descriptor
|
|
413
|
+
* declares — decoration.json and the PNG/WebP strip — by exact allow-list
|
|
414
|
+
* match, with realpath containment and the same size ceilings as pet
|
|
415
|
+
* assets. Crafted '..' or '.' segments never match the normalized closure.
|
|
416
|
+
*/
|
|
417
|
+
function decorationHandler(ctx, registry, caps) {
|
|
418
|
+
return (req, res) => {
|
|
419
|
+
if (!guard(ctx, req, res))
|
|
420
|
+
return;
|
|
421
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
422
|
+
res.writeHead(405);
|
|
423
|
+
res.end();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
let pathname;
|
|
427
|
+
try {
|
|
428
|
+
pathname = new URL(req.url ?? '/', 'http://pet.local').pathname;
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
res.writeHead(400);
|
|
432
|
+
res.end();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const segments = pathname.split('/').filter(segment => segment !== '');
|
|
436
|
+
const prefixSegments = DECORATION_ASSET_PREFIX.split('/').filter(segment => segment !== '');
|
|
437
|
+
if (segments.length < prefixSegments.length + 2) {
|
|
438
|
+
res.writeHead(404);
|
|
439
|
+
res.end();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
for (let i = 0; i < prefixSegments.length; i += 1) {
|
|
443
|
+
if (segments[i] !== prefixSegments[i]) {
|
|
444
|
+
res.writeHead(404);
|
|
445
|
+
res.end();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
let id;
|
|
450
|
+
try {
|
|
451
|
+
id = decodeURIComponent(segments[prefixSegments.length]);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
res.writeHead(400);
|
|
455
|
+
res.end();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const entry = registry.decorationById?.(id);
|
|
459
|
+
if (entry === undefined) {
|
|
460
|
+
res.writeHead(404);
|
|
461
|
+
res.end();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const rest = [];
|
|
465
|
+
for (const segment of segments.slice(prefixSegments.length + 1)) {
|
|
466
|
+
let decoded;
|
|
467
|
+
try {
|
|
468
|
+
decoded = decodeURIComponent(segment);
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
res.writeHead(400);
|
|
472
|
+
res.end();
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
rest.push(decoded);
|
|
476
|
+
}
|
|
477
|
+
const rel = rest.join('/');
|
|
478
|
+
if (!entry.servable.includes(rel)) {
|
|
479
|
+
res.writeHead(404);
|
|
480
|
+
res.end();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const file = join(entry.dir, rel);
|
|
484
|
+
const resolved = containedRealpath(entry.dir, file);
|
|
485
|
+
if (resolved === undefined) {
|
|
486
|
+
res.writeHead(403);
|
|
487
|
+
res.end();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const cap = rel === 'decoration.json' ? caps.manifest : caps.image;
|
|
491
|
+
let stat;
|
|
492
|
+
try {
|
|
493
|
+
stat = statSync(resolved);
|
|
494
|
+
if (stat.size > cap) {
|
|
495
|
+
res.writeHead(413);
|
|
496
|
+
res.end();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
res.writeHead(404);
|
|
502
|
+
res.end();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
// Weak ETag from size + mtime: 'no-cache' forces revalidation, and the
|
|
506
|
+
// validator lets repeat requests settle as 304 — the ornament remounts
|
|
507
|
+
// on whisper and display-session flips, and without a validator each
|
|
508
|
+
// remount would re-download the full strip body.
|
|
509
|
+
const etag = '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"';
|
|
510
|
+
if (req.headers['if-none-match'] === etag) {
|
|
511
|
+
res.writeHead(304, { etag, 'cache-control': 'no-cache' });
|
|
512
|
+
res.end();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
readFile(resolved).then((body) => {
|
|
516
|
+
res.writeHead(200, {
|
|
517
|
+
'content-type': mimeFor(resolved),
|
|
518
|
+
'content-length': String(body.byteLength),
|
|
519
|
+
'cache-control': 'no-cache',
|
|
520
|
+
etag,
|
|
521
|
+
});
|
|
522
|
+
if (req.method === 'HEAD') {
|
|
523
|
+
res.end();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
res.end(body);
|
|
527
|
+
}, () => {
|
|
528
|
+
res.writeHead(404);
|
|
529
|
+
res.end();
|
|
530
|
+
});
|
|
405
531
|
};
|
|
406
532
|
}
|
|
407
533
|
/** Build the full route family (API + assets + runtime) for one service. */
|
|
408
534
|
export function makePetRoutes(deps) {
|
|
409
|
-
const { service } = deps;
|
|
535
|
+
const { service, ctx } = deps;
|
|
410
536
|
const apiRoutes = [
|
|
411
|
-
getRoute(PET_API_PREFIX + '/state', () => service.state()),
|
|
412
|
-
getRoute(PET_API_PREFIX + '/pets', () => service.pets()),
|
|
413
|
-
getRoute(PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
|
|
414
|
-
postRoute(PET_API_PREFIX + '/interact', (body) => {
|
|
537
|
+
getRoute(ctx, PET_API_PREFIX + '/state', () => service.state()),
|
|
538
|
+
getRoute(ctx, PET_API_PREFIX + '/pets', () => service.pets()),
|
|
539
|
+
getRoute(ctx, PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
|
|
540
|
+
postRoute(ctx, PET_API_PREFIX + '/interact', (body) => {
|
|
415
541
|
const kind = body.kind;
|
|
416
542
|
if (kind !== 'pet' && kind !== 'feed')
|
|
417
543
|
return Promise.reject(new Error('invalid-kind'));
|
|
418
544
|
return service.interact(kind);
|
|
419
545
|
}),
|
|
420
|
-
postRoute(PET_API_PREFIX + '/set-visible', (body) => {
|
|
546
|
+
postRoute(ctx, PET_API_PREFIX + '/set-visible', (body) => {
|
|
421
547
|
const visible = body.visible;
|
|
422
548
|
if (typeof visible !== 'boolean')
|
|
423
549
|
return Promise.reject(new Error('invalid-visible'));
|
|
424
550
|
return service.setVisible(visible);
|
|
425
551
|
}),
|
|
426
|
-
postRoute(PET_API_PREFIX + '/set-config', (body) => service.setConfig({
|
|
552
|
+
postRoute(ctx, PET_API_PREFIX + '/set-config', (body) => service.setConfig({
|
|
427
553
|
...(typeof body.size === 'number' ? { size: body.size } : {}),
|
|
428
554
|
...(typeof body.right === 'number' ? { right: body.right } : {}),
|
|
429
555
|
...(typeof body.bottom === 'number' ? { bottom: body.bottom } : {}),
|
|
430
556
|
...(typeof body.visible === 'boolean' ? { visible: body.visible } : {}),
|
|
431
557
|
})),
|
|
432
|
-
postRoute(PET_API_PREFIX + '/set-name', (body) => {
|
|
558
|
+
postRoute(ctx, PET_API_PREFIX + '/set-name', (body) => {
|
|
433
559
|
const name = body.name;
|
|
434
560
|
if (typeof name !== 'string')
|
|
435
561
|
return Promise.reject(new Error('invalid-name'));
|
|
436
562
|
return service.setName(name);
|
|
437
563
|
}),
|
|
438
|
-
postRoute(PET_API_PREFIX + '/set-pet', (body) => {
|
|
564
|
+
postRoute(ctx, PET_API_PREFIX + '/set-pet', (body) => {
|
|
439
565
|
const petId = body.petId;
|
|
440
566
|
if (typeof petId !== 'string')
|
|
441
567
|
return Promise.reject(new Error('invalid-pet'));
|
|
@@ -445,17 +571,22 @@ export function makePetRoutes(deps) {
|
|
|
445
571
|
const assetRoute = {
|
|
446
572
|
kind: 'prefix',
|
|
447
573
|
path: PET_ASSET_PREFIX,
|
|
448
|
-
handler: assetHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
|
|
574
|
+
handler: assetHandler(ctx, service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
|
|
449
575
|
};
|
|
450
576
|
const runtimeRoute = {
|
|
451
577
|
kind: 'prefix',
|
|
452
578
|
path: PET_RUNTIME_PREFIX,
|
|
453
|
-
handler: runtimeHandler({
|
|
579
|
+
handler: runtimeHandler(ctx, {
|
|
454
580
|
runtimeDir: deps.runtimeDir ?? join(dshHome(), 'pets', '.runtime'),
|
|
455
581
|
vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), 'lib'),
|
|
456
582
|
}),
|
|
457
583
|
};
|
|
458
|
-
|
|
584
|
+
const decorationRoute = {
|
|
585
|
+
kind: 'prefix',
|
|
586
|
+
path: DECORATION_ASSET_PREFIX,
|
|
587
|
+
handler: decorationHandler(ctx, service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
|
|
588
|
+
};
|
|
589
|
+
return [...apiRoutes, assetRoute, runtimeRoute, decorationRoute];
|
|
459
590
|
}
|
|
460
591
|
// Re-exported for the package surface (the registry owns the definition now).
|
|
461
592
|
export { petPackageRoot } from './registry.js';
|