@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/lib/index.js CHANGED
@@ -2774,9 +2774,14 @@ function resolveLive2dEntry(manifest, dir, options) {
2774
2774
  record("error", "pet " + manifest.id + ": renderer live2d requires a live2d block");
2775
2775
  return;
2776
2776
  }
2777
+ const modelFile = join(dir, block.model);
2777
2778
  let model3;
2778
2779
  try {
2779
- model3 = JSON.parse(readFileSync(join(dir, block.model), "utf8"));
2780
+ if (guardedScannedJsonStat(modelFile, options, "live2d model " + block.model, 33554432) === void 0) {
2781
+ statSync(modelFile);
2782
+ return;
2783
+ }
2784
+ model3 = JSON.parse(readFileSync(modelFile, "utf8"));
2780
2785
  } catch (error) {
2781
2786
  record("error", "pet " + manifest.id + ": live2d model " + block.model + " is not readable: " + (error instanceof Error ? error.message : String(error)));
2782
2787
  return;
@@ -2833,7 +2838,7 @@ function scanPetDir(dir, options) {
2833
2838
  for (const name of names) {
2834
2839
  const manifestFile = join(dir, name, "pet.json");
2835
2840
  if (!existsSync(manifestFile)) continue;
2836
- const parsed = readPetJson(manifestFile, options.warnings);
2841
+ const parsed = readPetJson(manifestFile, options);
2837
2842
  if (parsed === void 0) continue;
2838
2843
  const entryDir = join(dir, name);
2839
2844
  const verdict = parsePetManifest(parsed, entryDir);
@@ -2871,22 +2876,38 @@ function scanPetDir(dir, options) {
2871
2876
  }
2872
2877
  return entries;
2873
2878
  }
2874
- /** Read and parse one manifest file; undefined (warning recorded) on failure. */
2875
- function readPetJson(file, warnings) {
2879
+ /**
2880
+ * Read and parse one pet.json manifest; undefined (warning recorded) on
2881
+ * failure. The descriptor stat guard applies first: a pathological file —
2882
+ * huge, or a FIFO/device — is skipped with a warning instead of stalling
2883
+ * or OOM-ing the host at scan time (same discipline as voice/decoration).
2884
+ */
2885
+ function readPetJson(file, options) {
2886
+ if (guardedScannedJsonStat(file, options, "pet manifest") === void 0) return void 0;
2876
2887
  try {
2877
2888
  return JSON.parse(readFileSync(file, "utf8"));
2878
2889
  } catch (error) {
2879
- warnings?.push("skipping " + file + ": " + (error instanceof Error ? error.message : String(error)));
2890
+ options.warnings?.push("skipping " + file + ": " + (error instanceof Error ? error.message : String(error)));
2880
2891
  return;
2881
2892
  }
2882
2893
  }
2883
2894
  /**
2895
+ * Scan-time read ceiling for user-authored JSON descriptors (voice.json,
2896
+ * .voice.json, decoration.json): the registry reads these synchronously at
2897
+ * plugin startup, and a pathological file — multi-GB, or a FIFO/device
2898
+ * symlink — must not hang or exhaust the host before the warn-and-drop
2899
+ * discipline can apply (review-spd follow-up, pet-center M4/M5).
2900
+ */
2901
+ const PET_SCAN_JSON_CAP = 64 * 1024;
2902
+ /**
2884
2903
  * Stat one scanned JSON descriptor with a regular-file + size guard, so a
2885
2904
  * pathological user file is skipped with a warning instead of stalling or
2886
2905
  * OOM-ing the host at startup. Returns the Stats, or undefined when the
2887
- * caller must skip the file (a warning was recorded).
2906
+ * caller must skip the file (a warning was recorded). 'cap' defaults to
2907
+ * the descriptor ceiling (PET_SCAN_JSON_CAP); model descriptors pass the
2908
+ * larger live2d ceiling.
2888
2909
  */
2889
- function guardedScannedJsonStat(file, options, what) {
2910
+ function guardedScannedJsonStat(file, options, what, cap = PET_SCAN_JSON_CAP) {
2890
2911
  let st;
2891
2912
  try {
2892
2913
  st = statSync(file);
@@ -2905,8 +2926,8 @@ function guardedScannedJsonStat(file, options, what) {
2905
2926
  warn(what + " is not a regular file; ignored");
2906
2927
  return;
2907
2928
  }
2908
- if (st.size > 65536) {
2909
- warn(what + " exceeds the 65536-byte scan ceiling; ignored");
2929
+ if (st.size > cap) {
2930
+ warn(what + " exceeds the " + cap + "-byte scan ceiling; ignored");
2910
2931
  return;
2911
2932
  }
2912
2933
  return st;
@@ -3621,6 +3642,63 @@ function isPairingAccess(value) {
3621
3642
  return value !== void 0 && value !== null && typeof value.isPairedDevice === "function";
3622
3643
  }
3623
3644
  //#endregion
3645
+ //#region src/http.ts
3646
+ /** Default body cap for readJsonBody: 64 KiB. */
3647
+ const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024;
3648
+ /** Family-default JSON response headers; callers may append or override. */
3649
+ const JSON_HEADERS = {
3650
+ "content-type": "application/json; charset=utf-8",
3651
+ "referrer-policy": "no-referrer"
3652
+ };
3653
+ /**
3654
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
3655
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
3656
+ * Overflow destroys the request instead of draining the remainder (no drain
3657
+ * call, matching the current repo-wide behavior); callers must not keep
3658
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
3659
+ * also yield null.
3660
+ */
3661
+ async function readJsonBody(req, opts = {}) {
3662
+ const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES;
3663
+ const chunks = [];
3664
+ let size = 0;
3665
+ for await (const chunk of req) {
3666
+ const buffer = chunk;
3667
+ size += buffer.length;
3668
+ if (size > maxBytes) {
3669
+ req.destroy();
3670
+ return null;
3671
+ }
3672
+ chunks.push(buffer);
3673
+ }
3674
+ const text = Buffer.concat(chunks).toString("utf8");
3675
+ if (text === "") return null;
3676
+ try {
3677
+ const parsed = JSON.parse(text);
3678
+ if (opts.objectOnly && !isJsonObject(parsed)) return null;
3679
+ return parsed;
3680
+ } catch {
3681
+ return null;
3682
+ }
3683
+ }
3684
+ /** Whether a value is a JSON object: typeof object, not null, not an array. */
3685
+ function isJsonObject(value) {
3686
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3687
+ }
3688
+ /**
3689
+ * Write one JSON response. Default headers are the family defaults
3690
+ * (content-type and referrer-policy); caller headers are appended or
3691
+ * override them.
3692
+ */
3693
+ function writeJson(res, status, body, headers = {}) {
3694
+ const payload = JSON.stringify(body);
3695
+ res.writeHead(status, {
3696
+ ...JSON_HEADERS,
3697
+ ...headers
3698
+ });
3699
+ res.end(payload);
3700
+ }
3701
+ //#endregion
3624
3702
  //#region src/routes.ts
3625
3703
  /**
3626
3704
  * Pet HTTP routes — the browser half talks to the host through plain
@@ -3696,52 +3774,19 @@ function mimeFor(file) {
3696
3774
  if (dot < 0) return "application/octet-stream";
3697
3775
  return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? "application/octet-stream";
3698
3776
  }
3699
- /** Write one JSON response. */
3700
- function json(res, status, body) {
3701
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
3702
- res.end(JSON.stringify(body));
3703
- }
3704
3777
  /** Require the method or answer 405. */
3705
3778
  function requireMethod(req, res, method) {
3706
3779
  if (req.method === method) return true;
3707
- json(res, 405, {
3780
+ writeJson(res, 405, {
3708
3781
  ok: false,
3709
3782
  error: "method-not-allowed"
3710
3783
  });
3711
3784
  return false;
3712
3785
  }
3713
- /** Read a JSON request body (bounded). */
3714
- function readJsonBody(req) {
3715
- return new Promise((resolve, reject) => {
3716
- let size = 0;
3717
- const chunks = [];
3718
- req.on("data", (chunk) => {
3719
- size += chunk.length;
3720
- if (size > 64 * 1024) {
3721
- reject(/* @__PURE__ */ new Error("body-too-large"));
3722
- queueMicrotask(() => req.destroy());
3723
- return;
3724
- }
3725
- chunks.push(chunk);
3726
- });
3727
- req.on("end", () => {
3728
- if (chunks.length === 0) {
3729
- resolve({});
3730
- return;
3731
- }
3732
- try {
3733
- resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
3734
- } catch {
3735
- reject(/* @__PURE__ */ new Error("invalid-json"));
3736
- }
3737
- });
3738
- req.on("error", reject);
3739
- });
3740
- }
3741
3786
  /** Shared route fence: loopback always passes; a live paired-device cookie is an extra allow path. */
3742
3787
  function guard(ctx, req, res) {
3743
3788
  if (isPetAllowed(ctx, req)) return true;
3744
- json(res, 403, {
3789
+ writeJson(res, 403, {
3745
3790
  ok: false,
3746
3791
  error: "forbidden: loopback-only"
3747
3792
  });
@@ -3755,8 +3800,8 @@ function getRoute(ctx, path, run) {
3755
3800
  handler: (req, res) => {
3756
3801
  if (!guard(ctx, req, res)) return;
3757
3802
  if (!requireMethod(req, res, "GET")) return;
3758
- run().then((value) => json(res, 200, value), (error) => {
3759
- json(res, 500, {
3803
+ run().then((value) => writeJson(res, 200, value), (error) => {
3804
+ writeJson(res, 500, {
3760
3805
  ok: false,
3761
3806
  error: error instanceof Error ? error.message : String(error)
3762
3807
  });
@@ -3772,15 +3817,16 @@ function postRoute(ctx, path, run) {
3772
3817
  handler: (req, res) => {
3773
3818
  if (!guard(ctx, req, res)) return Promise.resolve();
3774
3819
  if (!requireMethod(req, res, "POST")) return Promise.resolve();
3775
- return readJsonBody(req).then((body) => {
3776
- return run(typeof body === "object" && body !== null ? body : {}).then((value) => json(res, 200, value), (error) => {
3777
- json(res, 400, {
3820
+ return readJsonBody(req, { maxBytes: 64 * 1024 }).then((parsed) => {
3821
+ const payload = parsed ?? {};
3822
+ return run(typeof payload === "object" && payload !== null ? payload : {}).then((value) => writeJson(res, 200, value), (error) => {
3823
+ writeJson(res, 400, {
3778
3824
  ok: false,
3779
3825
  error: error instanceof Error ? error.message : String(error)
3780
3826
  });
3781
3827
  });
3782
3828
  }, (error) => {
3783
- json(res, 400, {
3829
+ writeJson(res, 400, {
3784
3830
  ok: false,
3785
3831
  error: error instanceof Error ? error.message : String(error)
3786
3832
  });
@@ -3976,7 +4022,7 @@ function runtimeHandler(ctx, roots) {
3976
4022
  const base = spec.root === "runtimeDir" ? roots.runtimeDir : roots.vendorDir;
3977
4023
  const file = join(base, name);
3978
4024
  if (!existsSync(file)) {
3979
- json(res, 404, {
4025
+ writeJson(res, 404, {
3980
4026
  ok: false,
3981
4027
  error: "runtime-file-missing",
3982
4028
  file: name
@@ -72,6 +72,9 @@ export declare class PetSettingsCardController {
72
72
  private diagnostics;
73
73
  private loaded;
74
74
  private attempts;
75
+ private disposed;
76
+ /** Pending deferred-load or retry timer; cancelled by dispose(). */
77
+ private pendingTimer;
75
78
  /** @param scope - the bound settings scope for the 'pet' namespace. */
76
79
  constructor(scope: SettingsScope<PetSettings>);
77
80
  /** Fetch registry diagnostics once (soft-fail: an empty list on error). */
@@ -85,8 +88,8 @@ export declare class PetSettingsCardController {
85
88
  */
86
89
  inject(): PetSettingsCardFace;
87
90
  /**
88
- * Release the card's scope subscription and bound stores; the slot
89
- * disposer calls this on teardown.
91
+ * Release the card's scope subscription, bound stores and pending load
92
+ * timers; the slot disposer calls this on teardown.
90
93
  */
91
94
  dispose(): void;
92
95
  }
@@ -1 +1 @@
1
- {"version":3,"file":"PetSettingsCard.d.ts","sourceRoot":"","sources":["../../../src/client/PetSettingsCard.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"PetSettingsCard.d.ts","sourceRoot":"","sources":["../../../src/client/PetSettingsCard.tsx"],"names":[],"mappings":"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"}
@@ -29,6 +29,9 @@ export class PetSettingsCardController {
29
29
  diagnostics = [];
30
30
  loaded = false;
31
31
  attempts = 0;
32
+ disposed = false;
33
+ /** Pending deferred-load or retry timer; cancelled by dispose(). */
34
+ pendingTimer;
32
35
  /** @param scope - the bound settings scope for the 'pet' namespace. */
33
36
  constructor(scope) {
34
37
  this.form = new CardForm(scope, [
@@ -45,7 +48,10 @@ export class PetSettingsCardController {
45
48
  // the first registry request until that pass completes so transport
46
49
  // plugins (notably remote-web-ui on a paired non-loopback origin) can
47
50
  // install their fetch channel before /api/pet/pets is issued.
48
- window.setTimeout(() => {
51
+ this.pendingTimer = window.setTimeout(() => {
52
+ this.pendingTimer = undefined;
53
+ if (this.disposed)
54
+ return;
49
55
  void this.loadPets();
50
56
  void this.loadDiagnostics();
51
57
  }, 0);
@@ -54,6 +60,8 @@ export class PetSettingsCardController {
54
60
  async loadDiagnostics() {
55
61
  try {
56
62
  this.diagnostics = await fetchPetDiagnostics();
63
+ if (this.disposed)
64
+ return;
57
65
  this.store.set(this.projection());
58
66
  }
59
67
  catch {
@@ -62,10 +70,12 @@ export class PetSettingsCardController {
62
70
  }
63
71
  /** Resolve the registry choices once (retried a few times on failure). */
64
72
  async loadPets() {
65
- if (this.loaded)
73
+ if (this.loaded || this.disposed)
66
74
  return;
67
75
  try {
68
76
  const list = await fetchPetChoices();
77
+ if (this.disposed)
78
+ return;
69
79
  this.petChoices.splice(0, this.petChoices.length, ...list.map(choice => choice.id));
70
80
  for (const choice of list)
71
81
  this.petLabels.set(choice.id, choice.displayName);
@@ -73,9 +83,16 @@ export class PetSettingsCardController {
73
83
  this.store.set(this.projection());
74
84
  }
75
85
  catch {
86
+ if (this.disposed)
87
+ return;
76
88
  this.attempts += 1;
77
89
  if (this.attempts < 3) {
78
- window.setTimeout(() => { void this.loadPets(); }, 3000);
90
+ this.pendingTimer = window.setTimeout(() => {
91
+ this.pendingTimer = undefined;
92
+ if (this.disposed)
93
+ return;
94
+ void this.loadPets();
95
+ }, 3000);
79
96
  }
80
97
  }
81
98
  }
@@ -101,10 +118,17 @@ export class PetSettingsCardController {
101
118
  return { hooks: { petSettingsCard: this.store }, ...this.form.actions() };
102
119
  }
103
120
  /**
104
- * Release the card's scope subscription and bound stores; the slot
105
- * disposer calls this on teardown.
121
+ * Release the card's scope subscription, bound stores and pending load
122
+ * timers; the slot disposer calls this on teardown.
106
123
  */
107
124
  dispose() {
125
+ if (this.disposed)
126
+ return;
127
+ this.disposed = true;
128
+ if (this.pendingTimer !== undefined) {
129
+ window.clearTimeout(this.pendingTimer);
130
+ this.pendingTimer = undefined;
131
+ }
108
132
  this.form.dispose();
109
133
  }
110
134
  }
@@ -1 +1 @@
1
- {"version":3,"file":"PetSprite.d.ts","sourceRoot":"","sources":["../../../src/client/PetSprite.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAkE,SAAS,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AAGnH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAIjD,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAGjC,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,gDAAgD;IAChD,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAA;IAC7B,8EAA8E;IAC9E,UAAU,EAAE,aAAa,CAAA;IACzB,qDAAqD;IACrD,OAAO,EAAE,gBAAgB,CAAA;IACzB,sCAAsC;IACtC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAA;IAC5B,8BAA8B;IAC9B,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,+BAA+B;IAC/B,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IAClD,uDAAuD;IACvD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,4DAA4D;IAC5D,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC1C,2DAA2D;IAC3D,cAAc,EAAE,MAAM,IAAI,CAAA;IAC1B;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,+CAA+C;IAC/C,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,CAAC,CAAA;CAC1B;AAgGD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,WAAW,CAygB5D"}
1
+ {"version":3,"file":"PetSprite.d.ts","sourceRoot":"","sources":["../../../src/client/PetSprite.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAkE,SAAS,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AAGnH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAIjD,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAGjC,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,gDAAgD;IAChD,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAA;IAC7B,8EAA8E;IAC9E,UAAU,EAAE,aAAa,CAAA;IACzB,qDAAqD;IACrD,OAAO,EAAE,gBAAgB,CAAA;IACzB,sCAAsC;IACtC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAA;IAC5B,8BAA8B;IAC9B,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,+BAA+B;IAC/B,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IAClD,uDAAuD;IACvD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,4DAA4D;IAC5D,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC1C,2DAA2D;IAC3D,cAAc,EAAE,MAAM,IAAI,CAAA;IAC1B;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,+CAA+C;IAC/C,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,CAAC,CAAA;CAC1B;AAgGD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,WAAW,CA0iB5D"}
@@ -219,8 +219,9 @@ export function PetSprite(props) {
219
219
  // blank while the loop heat-up runs.
220
220
  const leadCol = track.frames[0];
221
221
  const lead = framePosition(cell, row, leadCol, scaleRef.current);
222
+ let lastPosStr = lead.x + 'px ' + lead.y + 'px';
222
223
  if (spriteRef.current !== null) {
223
- spriteRef.current.style.backgroundPosition = lead.x + 'px ' + lead.y + 'px';
224
+ spriteRef.current.style.backgroundPosition = lastPosStr;
224
225
  }
225
226
  if (reduceMotion)
226
227
  return;
@@ -237,8 +238,12 @@ export function PetSprite(props) {
237
238
  const currentTrack = trimTrack(tracks[current.animation], rows[currentRow] ?? tracks[current.animation].frames.length);
238
239
  const col = currentTrack.frames[current.frameIndex];
239
240
  const pos = framePosition(cell, currentRow, col, scaleRef.current);
240
- if (spriteRef.current !== null) {
241
- spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px';
241
+ const posStr = pos.x + 'px ' + pos.y + 'px';
242
+ if (posStr !== lastPosStr) {
243
+ lastPosStr = posStr;
244
+ if (spriteRef.current !== null) {
245
+ spriteRef.current.style.backgroundPosition = posStr;
246
+ }
242
247
  }
243
248
  raf = requestAnimationFrame(tick);
244
249
  return;
@@ -269,8 +274,12 @@ export function PetSprite(props) {
269
274
  }
270
275
  const col = track.frames[st.index];
271
276
  const pos = framePosition(cell, row, col, scaleRef.current);
272
- if (spriteRef.current !== null) {
273
- spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px';
277
+ const posStr = pos.x + 'px ' + pos.y + 'px';
278
+ if (posStr !== lastPosStr) {
279
+ lastPosStr = posStr;
280
+ if (spriteRef.current !== null) {
281
+ spriteRef.current.style.backgroundPosition = posStr;
282
+ }
274
283
  }
275
284
  raf = requestAnimationFrame(tick);
276
285
  };
@@ -407,25 +416,33 @@ export function PetSprite(props) {
407
416
  return;
408
417
  clearHideTimer();
409
418
  hideTimerRef.current = window.setTimeout(() => setHovered(false), 300);
410
- }, children: [_jsx("div", { ref: spriteRef, className: styles.sprite, style: {
411
- width: spriteWidth,
412
- height: spriteHeight,
413
- ...(props.visual === undefined
414
- ? {
415
- backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
416
- backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
417
- backgroundRepeat: 'no-repeat',
418
- backgroundPosition: '0 0',
419
- }
420
- : {}),
421
- cursor: dragRef.current === null ? 'grab' : 'grabbing',
422
- }, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onClick: () => {
423
- // A pointer sequence that moved (dragged) still fires a trailing
424
- // click; skip the pet when that happened.
425
- if (draggedRef.current)
426
- return;
427
- props.onPet();
428
- }, role: "button", "aria-label": definition.displayName, children: props.visual }), feedback !== null && (_jsx("div", { ref: bubbleRef, className: clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet), children: feedback.text }, feedback.at)), feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (_jsxs("div", { ref: bubbleRef, className: styles.bubbleStack, onPointerEnter: () => setStackPeek(true), onPointerLeave: () => setStackPeek(false), children: [visibleSessions.map((session, index) => {
419
+ }, children: [_jsxs("div", { className: styles.spriteWrap, style: { width: spriteWidth, height: spriteHeight }, children: [_jsx("div", { ref: spriteRef, className: styles.sprite, style: {
420
+ width: spriteWidth,
421
+ height: spriteHeight,
422
+ ...(props.visual === undefined
423
+ ? {
424
+ backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
425
+ backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
426
+ backgroundRepeat: 'no-repeat',
427
+ backgroundPosition: '0 0',
428
+ }
429
+ : {}),
430
+ cursor: dragRef.current === null ? 'grab' : 'grabbing',
431
+ }, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onClick: () => {
432
+ // A pointer sequence that moved (dragged) still fires a trailing
433
+ // click; skip the pet when that happened.
434
+ if (draggedRef.current)
435
+ return;
436
+ props.onPet();
437
+ }, role: "button", "aria-label": definition.displayName, children: props.visual }), _jsx("button", { type: "button", className: styles.closeButton, "aria-label": panelLabel('hide', props.t('pet.hide')), title: panelLabel('hide', props.t('pet.hide')), "data-testid": "pet-close", onPointerDown: (e) => {
438
+ // Keep the close control from starting a drag on the sprite.
439
+ e.stopPropagation();
440
+ }, onClick: (e) => {
441
+ // The close control sits beside the pet button; do not pet as a
442
+ // side effect of closing the overlay.
443
+ e.stopPropagation();
444
+ props.onHide();
445
+ }, children: "\u00D7" })] }), feedback !== null && (_jsx("div", { ref: bubbleRef, className: clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet), children: feedback.text }, feedback.at)), feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (_jsxs("div", { ref: bubbleRef, className: styles.bubbleStack, onPointerEnter: () => setStackPeek(true), onPointerLeave: () => setStackPeek(false), children: [visibleSessions.map((session, index) => {
429
446
  // The whisper rides the display session's bubble — the stack's
430
447
  // primary entry (DOM-first, rendered bottom-most by the reversed
431
448
  // column so it stays glued to the sprite when extras open above).
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAwB,aAAa,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAA;AAgEnI,wEAAwE;AACxE,eAAO,MAAM,MAAM,UAA2E,CAAA;AAE9F,qEAAqE;AACrE,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AACxE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACrD,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACtF,YAAY,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AACpE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAqQ9C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAwB,aAAa,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAA;AAgEnI,wEAAwE;AACxE,eAAO,MAAM,MAAM,UAA2E,CAAA;AAE9F,qEAAqE;AACrE,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AACxE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACrD,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACtF,YAAY,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AACpE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAoR9C"}
@@ -56,7 +56,14 @@ export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote
56
56
  * @param ctx - client root context.
57
57
  */
58
58
  export function apply(ctx) {
59
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'pet: dictionaries');
59
+ ctx.effect(() => {
60
+ try {
61
+ return ctx.locale.register(NS, { zh, en });
62
+ }
63
+ catch {
64
+ return () => { };
65
+ }
66
+ }, 'pet: dictionaries');
60
67
  // Built-in renderers dispatch through the plugin-wide registry (pet-center
61
68
  // M3). Registration is idempotent (id wins), so re-applies stay clean.
62
69
  defaultPetRendererRegistry.register(live2dRenderer);
@@ -70,21 +77,31 @@ export function apply(ctx) {
70
77
  };
71
78
  // First-level settings section: one staged form over the 'pet' settings
72
79
  // namespace, registered as a top-level settings page. The controller loads
73
- // the petId choices from the registry endpoint itself.
80
+ // the petId choices from the registry endpoint itself — the registry lists
81
+ // the available pets (built-in assets plus user dirs), so the section only
82
+ // ever shows installed pets. Installing new pets happens in the Workshop
83
+ // store.
74
84
  const petSettings = new PetSettingsCardController(settingsScope);
85
+ // The section entry owns the controller: unregistering it (fiber disposal,
86
+ // hot reload) releases the scope subscription through petSettings.dispose.
75
87
  ctx.slots.inject('settings.section', () => {
76
- const unregister = ctx.slots.register({
77
- name: 'settings.section',
78
- id: 'pet',
79
- order: 130,
80
- label: () => ctx.locale.bind('pet')('settings.title'),
81
- locale: 'pet',
82
- inject: () => petSettings.inject(),
83
- }, PetSettingsSection);
84
- return () => {
85
- petSettings.dispose();
86
- unregister();
87
- };
88
+ try {
89
+ const unregister = ctx.slots.register({
90
+ name: 'settings.section',
91
+ id: 'pet',
92
+ order: 130,
93
+ label: () => ctx.locale.bind('pet')('settings.title'),
94
+ locale: 'pet',
95
+ inject: () => petSettings.inject(),
96
+ }, PetSettingsSection);
97
+ return () => {
98
+ unregister();
99
+ petSettings.dispose();
100
+ };
101
+ }
102
+ catch {
103
+ return () => { };
104
+ }
88
105
  });
89
106
  // The global pet entry, its store, and the poll loop live while the plugin
90
107
  // is enabled; toggling the setting off hides the pet and stops polling.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Shared JSON body/response helpers for the host route families: one strict
3
+ * bounded body reader, one lenient bounded body reader, one JSON object
4
+ * narrow, and one JSON writer. Previously these were copy-pasted across the
5
+ * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
6
+ * family's route module) with drifting contracts: body caps ranging 4 KiB to
7
+ * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
8
+ *
9
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
10
+ * edit this shared source and re-run the sync instead of editing a copy.
11
+ * Consumer code is migrated onto it in follow-up waves; no call site changes
12
+ * belong in the same change as its introduction.
13
+ * @module dsh-web-ui-shared/host/http
14
+ */
15
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http';
16
+ /**
17
+ * Strict bounded body reader: parse a request body of at most maxBytes as
18
+ * JSON.
19
+ * @throws 'body too large' past the cap, or the JSON.parse error for an
20
+ * invalid or empty payload.
21
+ */
22
+ export declare function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown>;
23
+ /**
24
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
25
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
26
+ * Overflow destroys the request instead of draining the remainder (no drain
27
+ * call, matching the current repo-wide behavior); callers must not keep
28
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
29
+ * also yield null.
30
+ */
31
+ export declare function readJsonBody(req: IncomingMessage, opts?: {
32
+ maxBytes?: number;
33
+ objectOnly?: boolean;
34
+ }): Promise<unknown | null>;
35
+ /** Narrow a value to a JSON object, or undefined when it is not one. */
36
+ export declare function asJsonObject(value: unknown): Record<string, unknown> | undefined;
37
+ /**
38
+ * Write one JSON response. Default headers are the family defaults
39
+ * (content-type and referrer-policy); caller headers are appended or
40
+ * override them.
41
+ */
42
+ export declare function writeJson(res: ServerResponse, status: number, body: unknown, headers?: OutgoingHttpHeaders): void;
43
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/http.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAWrF;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,GAAG,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU9F;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,eAAe,EACpB,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAO,GACrD,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBzB;AAOD,wEAAwE;AACxE,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAEhF;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CACvB,GAAG,EAAE,cAAc,EACnB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,OAAO,EACb,OAAO,GAAE,mBAAwB,GAChC,IAAI,CAIN"}