@linxin666/dsh-pet 0.2.9 → 0.3.1
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 +1 -1
- package/README.zh.md +1 -1
- package/assets/whale-refined/previews/idle.gif +0 -0
- package/lib/client.js +105 -47
- package/lib/client.js.map +1 -1
- package/lib/index.js +97 -51
- package/lib/types/client/PetSettingsCard.d.ts +5 -2
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +29 -5
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +41 -24
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +31 -14
- package/lib/types/http.d.ts +43 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/http.js +92 -0
- package/lib/types/registry.d.ts +8 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +37 -9
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +16 -44
- package/package.json +1 -1
- package/src/client/PetSettingsCard.tsx +23 -5
- package/src/client/PetSprite.test.tsx +47 -0
- package/src/client/PetSprite.tsx +65 -32
- package/src/client/index.ts +28 -13
- package/src/client/pet-css.test.ts +9 -0
- package/src/client/pet.module.css +37 -2
- package/src/client/settings-section.module.css +3 -3
- package/src/http.ts +105 -0
- package/src/registry.test.ts +83 -1
- package/src/registry.ts +40 -8
- package/src/routes.ts +16 -45
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/http.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Shared JSON body/response helpers for the host route families: one strict
|
|
4
|
+
* bounded body reader, one lenient bounded body reader, one JSON object
|
|
5
|
+
* narrow, and one JSON writer. Previously these were copy-pasted across the
|
|
6
|
+
* package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
|
|
7
|
+
* family's route module) with drifting contracts: body caps ranging 4 KiB to
|
|
8
|
+
* 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
|
|
9
|
+
*
|
|
10
|
+
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
11
|
+
* edit this shared source and re-run the sync instead of editing a copy.
|
|
12
|
+
* Consumer code is migrated onto it in follow-up waves; no call site changes
|
|
13
|
+
* belong in the same change as its introduction.
|
|
14
|
+
* @module dsh-web-ui-shared/host/http
|
|
15
|
+
*/
|
|
16
|
+
/** Default body cap for readJsonBody: 64 KiB. */
|
|
17
|
+
const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024;
|
|
18
|
+
/** Family-default JSON response headers; callers may append or override. */
|
|
19
|
+
const JSON_HEADERS = {
|
|
20
|
+
'content-type': 'application/json; charset=utf-8',
|
|
21
|
+
'referrer-policy': 'no-referrer',
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Strict bounded body reader: parse a request body of at most maxBytes as
|
|
25
|
+
* JSON.
|
|
26
|
+
* @throws 'body too large' past the cap, or the JSON.parse error for an
|
|
27
|
+
* invalid or empty payload.
|
|
28
|
+
*/
|
|
29
|
+
export async function readBoundedJson(req, maxBytes) {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
let size = 0;
|
|
32
|
+
for await (const chunk of req) {
|
|
33
|
+
const buffer = chunk;
|
|
34
|
+
size += buffer.length;
|
|
35
|
+
if (size > maxBytes)
|
|
36
|
+
throw new Error('body too large');
|
|
37
|
+
chunks.push(buffer);
|
|
38
|
+
}
|
|
39
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Lenient bounded body reader: parse a request body as JSON, or null on an
|
|
43
|
+
* empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
|
|
44
|
+
* Overflow destroys the request instead of draining the remainder (no drain
|
|
45
|
+
* call, matching the current repo-wide behavior); callers must not keep
|
|
46
|
+
* reading the request afterwards. With objectOnly, non-JSON-object payloads
|
|
47
|
+
* also yield null.
|
|
48
|
+
*/
|
|
49
|
+
export async function readJsonBody(req, opts = {}) {
|
|
50
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES;
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let size = 0;
|
|
53
|
+
for await (const chunk of req) {
|
|
54
|
+
const buffer = chunk;
|
|
55
|
+
size += buffer.length;
|
|
56
|
+
if (size > maxBytes) {
|
|
57
|
+
req.destroy();
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
chunks.push(buffer);
|
|
61
|
+
}
|
|
62
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
63
|
+
if (text === '')
|
|
64
|
+
return null;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(text);
|
|
67
|
+
if (opts.objectOnly && !isJsonObject(parsed))
|
|
68
|
+
return null;
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Whether a value is a JSON object: typeof object, not null, not an array. */
|
|
76
|
+
function isJsonObject(value) {
|
|
77
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
/** Narrow a value to a JSON object, or undefined when it is not one. */
|
|
80
|
+
export function asJsonObject(value) {
|
|
81
|
+
return isJsonObject(value) ? value : undefined;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Write one JSON response. Default headers are the family defaults
|
|
85
|
+
* (content-type and referrer-policy); caller headers are appended or
|
|
86
|
+
* override them.
|
|
87
|
+
*/
|
|
88
|
+
export function writeJson(res, status, body, headers = {}) {
|
|
89
|
+
const payload = JSON.stringify(body);
|
|
90
|
+
res.writeHead(status, { ...JSON_HEADERS, ...headers });
|
|
91
|
+
res.end(payload);
|
|
92
|
+
}
|
package/lib/types/registry.d.ts
CHANGED
|
@@ -258,6 +258,14 @@ export declare function resolvePetManifest(raw: unknown, dir: string, options?:
|
|
|
258
258
|
* discipline can apply (review-spd follow-up, pet-center M4/M5).
|
|
259
259
|
*/
|
|
260
260
|
export declare const PET_SCAN_JSON_CAP: number;
|
|
261
|
+
/**
|
|
262
|
+
* Scan-time read ceiling for a live2d model3.json, matching the asset
|
|
263
|
+
* route's model cap (PET_ASSET_CAPS.model). Model descriptors are far
|
|
264
|
+
* larger than the other scanned JSON, but a pathological file — huge, or a
|
|
265
|
+
* FIFO/device — must still be skipped with a warning instead of stalling
|
|
266
|
+
* or OOM-ing the host at plugin startup (same review-spd follow-up).
|
|
267
|
+
*/
|
|
268
|
+
export declare const PET_SCAN_LIVE2D_MODEL_CAP: number;
|
|
261
269
|
/** Decoration asset URL prefix (served by the decoration route, M5). */
|
|
262
270
|
export declare const DECORATION_ASSET_PREFIX = "/api/pet/decoration";
|
|
263
271
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/registry.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/registry.ts"],"names":[],"mappings":"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"}
|
package/lib/types/registry.js
CHANGED
|
@@ -302,9 +302,20 @@ function resolveLive2dEntry(manifest, dir, options) {
|
|
|
302
302
|
record('error', 'pet ' + manifest.id + ': renderer live2d requires a live2d block');
|
|
303
303
|
return undefined;
|
|
304
304
|
}
|
|
305
|
+
const modelFile = join(dir, block.model);
|
|
305
306
|
let model3;
|
|
306
307
|
try {
|
|
307
|
-
|
|
308
|
+
// Stat guard before the read: a pathological model file — huge, or a
|
|
309
|
+
// FIFO/device — is skipped with a warning instead of stalling the host
|
|
310
|
+
// at scan time, mirroring the voice/decoration descriptor discipline.
|
|
311
|
+
// The guard stays silent on stat errors, so a missing or unreadable
|
|
312
|
+
// path is re-stat'ed here to fall through to the original fail-closed
|
|
313
|
+
// 'not readable' diagnostic below.
|
|
314
|
+
if (guardedScannedJsonStat(modelFile, options, 'live2d model ' + block.model, PET_SCAN_LIVE2D_MODEL_CAP) === undefined) {
|
|
315
|
+
statSync(modelFile);
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
model3 = JSON.parse(readFileSync(modelFile, 'utf8'));
|
|
308
319
|
}
|
|
309
320
|
catch (error) {
|
|
310
321
|
record('error', 'pet ' + manifest.id + ': live2d model ' + block.model + ' is not readable: '
|
|
@@ -373,7 +384,7 @@ function scanPetDir(dir, options) {
|
|
|
373
384
|
const manifestFile = join(dir, name, 'pet.json');
|
|
374
385
|
if (!existsSync(manifestFile))
|
|
375
386
|
continue;
|
|
376
|
-
const parsed = readPetJson(manifestFile, options
|
|
387
|
+
const parsed = readPetJson(manifestFile, options);
|
|
377
388
|
if (parsed === undefined)
|
|
378
389
|
continue;
|
|
379
390
|
const entryDir = join(dir, name);
|
|
@@ -406,13 +417,20 @@ function scanPetDir(dir, options) {
|
|
|
406
417
|
}
|
|
407
418
|
return entries;
|
|
408
419
|
}
|
|
409
|
-
/**
|
|
410
|
-
|
|
420
|
+
/**
|
|
421
|
+
* Read and parse one pet.json manifest; undefined (warning recorded) on
|
|
422
|
+
* failure. The descriptor stat guard applies first: a pathological file —
|
|
423
|
+
* huge, or a FIFO/device — is skipped with a warning instead of stalling
|
|
424
|
+
* or OOM-ing the host at scan time (same discipline as voice/decoration).
|
|
425
|
+
*/
|
|
426
|
+
function readPetJson(file, options) {
|
|
427
|
+
if (guardedScannedJsonStat(file, options, 'pet manifest') === undefined)
|
|
428
|
+
return undefined;
|
|
411
429
|
try {
|
|
412
430
|
return JSON.parse(readFileSync(file, 'utf8'));
|
|
413
431
|
}
|
|
414
432
|
catch (error) {
|
|
415
|
-
warnings?.push('skipping ' + file + ': ' + (error instanceof Error ? error.message : String(error)));
|
|
433
|
+
options.warnings?.push('skipping ' + file + ': ' + (error instanceof Error ? error.message : String(error)));
|
|
416
434
|
return undefined;
|
|
417
435
|
}
|
|
418
436
|
}
|
|
@@ -424,13 +442,23 @@ function readPetJson(file, warnings) {
|
|
|
424
442
|
* discipline can apply (review-spd follow-up, pet-center M4/M5).
|
|
425
443
|
*/
|
|
426
444
|
export const PET_SCAN_JSON_CAP = 64 * 1024;
|
|
445
|
+
/**
|
|
446
|
+
* Scan-time read ceiling for a live2d model3.json, matching the asset
|
|
447
|
+
* route's model cap (PET_ASSET_CAPS.model). Model descriptors are far
|
|
448
|
+
* larger than the other scanned JSON, but a pathological file — huge, or a
|
|
449
|
+
* FIFO/device — must still be skipped with a warning instead of stalling
|
|
450
|
+
* or OOM-ing the host at plugin startup (same review-spd follow-up).
|
|
451
|
+
*/
|
|
452
|
+
export const PET_SCAN_LIVE2D_MODEL_CAP = 32 * 1024 * 1024;
|
|
427
453
|
/**
|
|
428
454
|
* Stat one scanned JSON descriptor with a regular-file + size guard, so a
|
|
429
455
|
* pathological user file is skipped with a warning instead of stalling or
|
|
430
456
|
* OOM-ing the host at startup. Returns the Stats, or undefined when the
|
|
431
|
-
* caller must skip the file (a warning was recorded).
|
|
457
|
+
* caller must skip the file (a warning was recorded). 'cap' defaults to
|
|
458
|
+
* the descriptor ceiling (PET_SCAN_JSON_CAP); model descriptors pass the
|
|
459
|
+
* larger live2d ceiling.
|
|
432
460
|
*/
|
|
433
|
-
function guardedScannedJsonStat(file, options, what) {
|
|
461
|
+
function guardedScannedJsonStat(file, options, what, cap = PET_SCAN_JSON_CAP) {
|
|
434
462
|
let st;
|
|
435
463
|
try {
|
|
436
464
|
st = statSync(file);
|
|
@@ -446,8 +474,8 @@ function guardedScannedJsonStat(file, options, what) {
|
|
|
446
474
|
warn(what + ' is not a regular file; ignored');
|
|
447
475
|
return undefined;
|
|
448
476
|
}
|
|
449
|
-
if (st.size >
|
|
450
|
-
warn(what + ' exceeds the ' +
|
|
477
|
+
if (st.size > cap) {
|
|
478
|
+
warn(what + ' exceeds the ' + cap + '-byte scan ceiling; ignored');
|
|
451
479
|
return undefined;
|
|
452
480
|
}
|
|
453
481
|
return st;
|
|
@@ -1 +1 @@
|
|
|
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;
|
|
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;AAO9C,+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;;IAEzB,iDAAiD;;IAEjD,8EAA8E;;CAEtE,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;AAwND,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
|
@@ -17,6 +17,7 @@ import { join, sep } from 'node:path';
|
|
|
17
17
|
import { DECORATION_ASSET_PREFIX, petEntryView, petPackageRoot } from "./registry.js";
|
|
18
18
|
import { isPetAllowed } from "./access.js";
|
|
19
19
|
import { dshHome } from "./dsh-home.js";
|
|
20
|
+
import { readJsonBody, writeJson } from "./http.js";
|
|
20
21
|
/** Browser-facing base path of the pet API. */
|
|
21
22
|
export const PET_API_PREFIX = '/api/pet';
|
|
22
23
|
/** Browser-facing base path of the pet asset routes ('/pet/<id>/...'). */
|
|
@@ -76,52 +77,18 @@ function mimeFor(file) {
|
|
|
76
77
|
return 'application/octet-stream';
|
|
77
78
|
return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? 'application/octet-stream';
|
|
78
79
|
}
|
|
79
|
-
/** Write one JSON response. */
|
|
80
|
-
function json(res, status, body) {
|
|
81
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
82
|
-
res.end(JSON.stringify(body));
|
|
83
|
-
}
|
|
84
80
|
/** Require the method or answer 405. */
|
|
85
81
|
function requireMethod(req, res, method) {
|
|
86
82
|
if (req.method === method)
|
|
87
83
|
return true;
|
|
88
|
-
|
|
84
|
+
writeJson(res, 405, { ok: false, error: 'method-not-allowed' });
|
|
89
85
|
return false;
|
|
90
86
|
}
|
|
91
|
-
/** Read a JSON request body (bounded). */
|
|
92
|
-
function readJsonBody(req) {
|
|
93
|
-
return new Promise((resolve, reject) => {
|
|
94
|
-
let size = 0;
|
|
95
|
-
const chunks = [];
|
|
96
|
-
req.on('data', (chunk) => {
|
|
97
|
-
size += chunk.length;
|
|
98
|
-
if (size > 64 * 1024) {
|
|
99
|
-
reject(new Error('body-too-large'));
|
|
100
|
-
queueMicrotask(() => req.destroy());
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
chunks.push(chunk);
|
|
104
|
-
});
|
|
105
|
-
req.on('end', () => {
|
|
106
|
-
if (chunks.length === 0) {
|
|
107
|
-
resolve({});
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
try {
|
|
111
|
-
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
112
|
-
}
|
|
113
|
-
catch {
|
|
114
|
-
reject(new Error('invalid-json'));
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
req.on('error', reject);
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
87
|
/** Shared route fence: loopback always passes; a live paired-device cookie is an extra allow path. */
|
|
121
88
|
function guard(ctx, req, res) {
|
|
122
89
|
if (isPetAllowed(ctx, req))
|
|
123
90
|
return true;
|
|
124
|
-
|
|
91
|
+
writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' });
|
|
125
92
|
return false;
|
|
126
93
|
}
|
|
127
94
|
/** Wrap one async service call as a GET JSON route. */
|
|
@@ -134,8 +101,8 @@ function getRoute(ctx, path, run) {
|
|
|
134
101
|
return;
|
|
135
102
|
if (!requireMethod(req, res, 'GET'))
|
|
136
103
|
return;
|
|
137
|
-
run().then((value) =>
|
|
138
|
-
|
|
104
|
+
run().then((value) => writeJson(res, 200, value), (error) => {
|
|
105
|
+
writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
139
106
|
});
|
|
140
107
|
},
|
|
141
108
|
};
|
|
@@ -150,13 +117,18 @@ function postRoute(ctx, path, run) {
|
|
|
150
117
|
return Promise.resolve();
|
|
151
118
|
if (!requireMethod(req, res, 'POST'))
|
|
152
119
|
return Promise.resolve();
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
120
|
+
// Shared lenient reader (64 KiB cap): an empty body yields null and is
|
|
121
|
+
// restored to {} at the call site (legacy empty-body semantics); invalid
|
|
122
|
+
// JSON and over-limit bodies also yield null, so the endpoint validators
|
|
123
|
+
// below keep answering 400 with the same { ok: false, error } envelope.
|
|
124
|
+
return readJsonBody(req, { maxBytes: 64 * 1024 }).then((parsed) => {
|
|
125
|
+
const payload = parsed ?? {};
|
|
126
|
+
const record = (typeof payload === 'object' && payload !== null) ? payload : {};
|
|
127
|
+
return run(record).then((value) => writeJson(res, 200, value), (error) => {
|
|
128
|
+
writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
157
129
|
});
|
|
158
130
|
}, (error) => {
|
|
159
|
-
|
|
131
|
+
writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
160
132
|
});
|
|
161
133
|
},
|
|
162
134
|
};
|
|
@@ -369,7 +341,7 @@ function runtimeHandler(ctx, roots) {
|
|
|
369
341
|
const base = spec.root === 'runtimeDir' ? roots.runtimeDir : roots.vendorDir;
|
|
370
342
|
const file = join(base, name);
|
|
371
343
|
if (!existsSync(file)) {
|
|
372
|
-
|
|
344
|
+
writeJson(res, 404, { ok: false, error: 'runtime-file-missing', file: name });
|
|
373
345
|
return;
|
|
374
346
|
}
|
|
375
347
|
const resolved = containedRealpath(base, file);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-pet",
|
|
3
3
|
"description": "Multi-pet companion plugin for the dsh web GUI: a registry-driven floating pet that reacts to model activity, with per-pet naming, petting/feeding interactions and an affinity score",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -103,6 +103,9 @@ export class PetSettingsCardController {
|
|
|
103
103
|
private diagnostics: PetDiagnosticView[] = []
|
|
104
104
|
private loaded = false
|
|
105
105
|
private attempts = 0
|
|
106
|
+
private disposed = false
|
|
107
|
+
/** Pending deferred-load or retry timer; cancelled by dispose(). */
|
|
108
|
+
private pendingTimer: number | undefined
|
|
106
109
|
|
|
107
110
|
/** @param scope - the bound settings scope for the 'pet' namespace. */
|
|
108
111
|
constructor(scope: SettingsScope<PetSettings>) {
|
|
@@ -120,7 +123,9 @@ export class PetSettingsCardController {
|
|
|
120
123
|
// the first registry request until that pass completes so transport
|
|
121
124
|
// plugins (notably remote-web-ui on a paired non-loopback origin) can
|
|
122
125
|
// install their fetch channel before /api/pet/pets is issued.
|
|
123
|
-
window.setTimeout(() => {
|
|
126
|
+
this.pendingTimer = window.setTimeout(() => {
|
|
127
|
+
this.pendingTimer = undefined
|
|
128
|
+
if (this.disposed) return
|
|
124
129
|
void this.loadPets()
|
|
125
130
|
void this.loadDiagnostics()
|
|
126
131
|
}, 0)
|
|
@@ -130,6 +135,7 @@ export class PetSettingsCardController {
|
|
|
130
135
|
private async loadDiagnostics(): Promise<void> {
|
|
131
136
|
try {
|
|
132
137
|
this.diagnostics = await fetchPetDiagnostics()
|
|
138
|
+
if (this.disposed) return
|
|
133
139
|
this.store.set(this.projection())
|
|
134
140
|
} catch {
|
|
135
141
|
this.diagnostics = []
|
|
@@ -138,17 +144,23 @@ export class PetSettingsCardController {
|
|
|
138
144
|
|
|
139
145
|
/** Resolve the registry choices once (retried a few times on failure). */
|
|
140
146
|
private async loadPets(): Promise<void> {
|
|
141
|
-
if (this.loaded) return
|
|
147
|
+
if (this.loaded || this.disposed) return
|
|
142
148
|
try {
|
|
143
149
|
const list = await fetchPetChoices()
|
|
150
|
+
if (this.disposed) return
|
|
144
151
|
this.petChoices.splice(0, this.petChoices.length, ...list.map(choice => choice.id))
|
|
145
152
|
for (const choice of list) this.petLabels.set(choice.id, choice.displayName)
|
|
146
153
|
this.loaded = true
|
|
147
154
|
this.store.set(this.projection())
|
|
148
155
|
} catch {
|
|
156
|
+
if (this.disposed) return
|
|
149
157
|
this.attempts += 1
|
|
150
158
|
if (this.attempts < 3) {
|
|
151
|
-
window.setTimeout(() => {
|
|
159
|
+
this.pendingTimer = window.setTimeout(() => {
|
|
160
|
+
this.pendingTimer = undefined
|
|
161
|
+
if (this.disposed) return
|
|
162
|
+
void this.loadPets()
|
|
163
|
+
}, 3000)
|
|
152
164
|
}
|
|
153
165
|
}
|
|
154
166
|
}
|
|
@@ -177,10 +189,16 @@ export class PetSettingsCardController {
|
|
|
177
189
|
}
|
|
178
190
|
|
|
179
191
|
/**
|
|
180
|
-
* Release the card's scope subscription
|
|
181
|
-
* disposer calls this on teardown.
|
|
192
|
+
* Release the card's scope subscription, bound stores and pending load
|
|
193
|
+
* timers; the slot disposer calls this on teardown.
|
|
182
194
|
*/
|
|
183
195
|
dispose(): void {
|
|
196
|
+
if (this.disposed) return
|
|
197
|
+
this.disposed = true
|
|
198
|
+
if (this.pendingTimer !== undefined) {
|
|
199
|
+
window.clearTimeout(this.pendingTimer)
|
|
200
|
+
this.pendingTimer = undefined
|
|
201
|
+
}
|
|
184
202
|
this.form.dispose()
|
|
185
203
|
}
|
|
186
204
|
}
|
|
@@ -159,6 +159,22 @@ describe('PetSprite custom visual (pet-center M3)', () => {
|
|
|
159
159
|
})
|
|
160
160
|
})
|
|
161
161
|
|
|
162
|
+
describe('PetSprite always-visible close control', () => {
|
|
163
|
+
it('renders a corner close button and hides without petting', () => {
|
|
164
|
+
const onHide = vi.fn()
|
|
165
|
+
const onPet = vi.fn()
|
|
166
|
+
renderPet({ onHide, onPet })
|
|
167
|
+
|
|
168
|
+
const close = screen.getByTestId('pet-close')
|
|
169
|
+
expect(close.getAttribute('aria-label')).toBe('隐藏')
|
|
170
|
+
expect(close.getAttribute('title')).toBe('隐藏')
|
|
171
|
+
fireEvent.click(close)
|
|
172
|
+
|
|
173
|
+
expect(onHide).toHaveBeenCalledTimes(1)
|
|
174
|
+
expect(onPet).not.toHaveBeenCalled()
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
162
178
|
describe('PetSprite rename input', () => {
|
|
163
179
|
it('submits the draft on Enter outside composition', () => {
|
|
164
180
|
const { onRename } = renderPet()
|
|
@@ -560,6 +576,37 @@ describe('PetSprite definition-driven render', () => {
|
|
|
560
576
|
act(() => { nextFrame?.(1_500) })
|
|
561
577
|
expect(sprite.style.backgroundPosition).toBe('0px -960px')
|
|
562
578
|
})
|
|
579
|
+
|
|
580
|
+
it('skips redundant backgroundPosition style assignments when frame coordinates do not change (issue #1013)', () => {
|
|
581
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
582
|
+
matches: false,
|
|
583
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
584
|
+
onchange: null,
|
|
585
|
+
addEventListener: () => {},
|
|
586
|
+
removeEventListener: () => {},
|
|
587
|
+
addListener: () => {},
|
|
588
|
+
removeListener: () => {},
|
|
589
|
+
dispatchEvent: () => false,
|
|
590
|
+
})
|
|
591
|
+
vi.spyOn(performance, 'now').mockReturnValue(0)
|
|
592
|
+
let nextFrame: FrameRequestCallback | undefined
|
|
593
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
594
|
+
nextFrame = callback
|
|
595
|
+
return 1
|
|
596
|
+
})
|
|
597
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
598
|
+
renderPet({
|
|
599
|
+
snapshot: { ...snapshot, animation: 'idle', phase: 'idle' },
|
|
600
|
+
})
|
|
601
|
+
const sprite = screen.getByRole('button', { name: '鲸鱼娘' })
|
|
602
|
+
const styleSetterSpy = vi.spyOn(sprite.style, 'backgroundPosition', 'set')
|
|
603
|
+
// Idle frame 0 duration is 400ms. An intermediate tick at 16ms does not advance the frame.
|
|
604
|
+
act(() => { nextFrame?.(16) })
|
|
605
|
+
expect(styleSetterSpy).not.toHaveBeenCalled()
|
|
606
|
+
// A tick past 400ms advances to frame 1 and should set backgroundPosition once.
|
|
607
|
+
act(() => { nextFrame?.(410) })
|
|
608
|
+
expect(styleSetterSpy).toHaveBeenCalledTimes(1)
|
|
609
|
+
})
|
|
563
610
|
})
|
|
564
611
|
|
|
565
612
|
describe('PetSprite panel chrome from the voice pack (pet-center M4)', () => {
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -268,8 +268,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
268
268
|
// blank while the loop heat-up runs.
|
|
269
269
|
const leadCol = track.frames[0]!
|
|
270
270
|
const lead = framePosition(cell, row, leadCol, scaleRef.current)
|
|
271
|
+
let lastPosStr = lead.x + 'px ' + lead.y + 'px'
|
|
271
272
|
if (spriteRef.current !== null) {
|
|
272
|
-
spriteRef.current.style.backgroundPosition =
|
|
273
|
+
spriteRef.current.style.backgroundPosition = lastPosStr
|
|
273
274
|
}
|
|
274
275
|
if (reduceMotion) return
|
|
275
276
|
let raf = 0
|
|
@@ -288,8 +289,12 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
288
289
|
)
|
|
289
290
|
const col = currentTrack.frames[current.frameIndex]!
|
|
290
291
|
const pos = framePosition(cell, currentRow, col, scaleRef.current)
|
|
291
|
-
|
|
292
|
-
|
|
292
|
+
const posStr = pos.x + 'px ' + pos.y + 'px'
|
|
293
|
+
if (posStr !== lastPosStr) {
|
|
294
|
+
lastPosStr = posStr
|
|
295
|
+
if (spriteRef.current !== null) {
|
|
296
|
+
spriteRef.current.style.backgroundPosition = posStr
|
|
297
|
+
}
|
|
293
298
|
}
|
|
294
299
|
raf = requestAnimationFrame(tick)
|
|
295
300
|
return
|
|
@@ -319,8 +324,12 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
319
324
|
}
|
|
320
325
|
const col = track.frames[st.index]!
|
|
321
326
|
const pos = framePosition(cell, row, col, scaleRef.current)
|
|
322
|
-
|
|
323
|
-
|
|
327
|
+
const posStr = pos.x + 'px ' + pos.y + 'px'
|
|
328
|
+
if (posStr !== lastPosStr) {
|
|
329
|
+
lastPosStr = posStr
|
|
330
|
+
if (spriteRef.current !== null) {
|
|
331
|
+
spriteRef.current.style.backgroundPosition = posStr
|
|
332
|
+
}
|
|
324
333
|
}
|
|
325
334
|
raf = requestAnimationFrame(tick)
|
|
326
335
|
}
|
|
@@ -466,34 +475,58 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
466
475
|
}}
|
|
467
476
|
>
|
|
468
477
|
<div
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
style={{
|
|
472
|
-
width: spriteWidth,
|
|
473
|
-
height: spriteHeight,
|
|
474
|
-
...(props.visual === undefined
|
|
475
|
-
? {
|
|
476
|
-
backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
|
|
477
|
-
backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
|
|
478
|
-
backgroundRepeat: 'no-repeat',
|
|
479
|
-
backgroundPosition: '0 0',
|
|
480
|
-
}
|
|
481
|
-
: {}),
|
|
482
|
-
cursor: dragRef.current === null ? 'grab' : 'grabbing',
|
|
483
|
-
}}
|
|
484
|
-
onPointerDown={onPointerDown}
|
|
485
|
-
onPointerMove={onPointerMove}
|
|
486
|
-
onPointerUp={onPointerUp}
|
|
487
|
-
onClick={() => {
|
|
488
|
-
// A pointer sequence that moved (dragged) still fires a trailing
|
|
489
|
-
// click; skip the pet when that happened.
|
|
490
|
-
if (draggedRef.current) return
|
|
491
|
-
props.onPet()
|
|
492
|
-
}}
|
|
493
|
-
role="button"
|
|
494
|
-
aria-label={definition.displayName}
|
|
478
|
+
className={styles.spriteWrap}
|
|
479
|
+
style={{ width: spriteWidth, height: spriteHeight }}
|
|
495
480
|
>
|
|
496
|
-
|
|
481
|
+
<div
|
|
482
|
+
ref={spriteRef}
|
|
483
|
+
className={styles.sprite}
|
|
484
|
+
style={{
|
|
485
|
+
width: spriteWidth,
|
|
486
|
+
height: spriteHeight,
|
|
487
|
+
...(props.visual === undefined
|
|
488
|
+
? {
|
|
489
|
+
backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
|
|
490
|
+
backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
|
|
491
|
+
backgroundRepeat: 'no-repeat',
|
|
492
|
+
backgroundPosition: '0 0',
|
|
493
|
+
}
|
|
494
|
+
: {}),
|
|
495
|
+
cursor: dragRef.current === null ? 'grab' : 'grabbing',
|
|
496
|
+
}}
|
|
497
|
+
onPointerDown={onPointerDown}
|
|
498
|
+
onPointerMove={onPointerMove}
|
|
499
|
+
onPointerUp={onPointerUp}
|
|
500
|
+
onClick={() => {
|
|
501
|
+
// A pointer sequence that moved (dragged) still fires a trailing
|
|
502
|
+
// click; skip the pet when that happened.
|
|
503
|
+
if (draggedRef.current) return
|
|
504
|
+
props.onPet()
|
|
505
|
+
}}
|
|
506
|
+
role="button"
|
|
507
|
+
aria-label={definition.displayName}
|
|
508
|
+
>
|
|
509
|
+
{props.visual}
|
|
510
|
+
</div>
|
|
511
|
+
<button
|
|
512
|
+
type="button"
|
|
513
|
+
className={styles.closeButton}
|
|
514
|
+
aria-label={panelLabel('hide', props.t('pet.hide'))}
|
|
515
|
+
title={panelLabel('hide', props.t('pet.hide'))}
|
|
516
|
+
data-testid="pet-close"
|
|
517
|
+
onPointerDown={(e) => {
|
|
518
|
+
// Keep the close control from starting a drag on the sprite.
|
|
519
|
+
e.stopPropagation()
|
|
520
|
+
}}
|
|
521
|
+
onClick={(e) => {
|
|
522
|
+
// The close control sits beside the pet button; do not pet as a
|
|
523
|
+
// side effect of closing the overlay.
|
|
524
|
+
e.stopPropagation()
|
|
525
|
+
props.onHide()
|
|
526
|
+
}}
|
|
527
|
+
>
|
|
528
|
+
×
|
|
529
|
+
</button>
|
|
497
530
|
</div>
|
|
498
531
|
{feedback !== null && (
|
|
499
532
|
<div key={feedback.at} ref={bubbleRef} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
|