dsh-plugin-vrmpet3d 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +43 -0
  2. package/README.md +249 -2
  3. package/assets/models/BlueFatFish.vrm.br +0 -0
  4. package/assets/models/README.md +99 -0
  5. package/assets/models/SampleModel.vrm.br +0 -0
  6. package/assets/models/vrmpet3d-models.json +116 -0
  7. package/assets/motions/README.md +177 -0
  8. package/assets/motions/attention/wave hand 1.vrma.br +0 -0
  9. package/assets/motions/attention/wave hand 2.vrma.br +0 -0
  10. package/assets/motions/attention/wave hand 3.vrma.br +0 -0
  11. package/assets/motions/dance/dance1.vrma.br +0 -0
  12. package/assets/motions/dance/dance2.vrma.br +0 -0
  13. package/assets/motions/fail/fall down1.vrma.br +0 -0
  14. package/assets/motions/fail/fall down2.vrma.br +0 -0
  15. package/assets/motions/idle/hungry1.vrma.br +0 -0
  16. package/assets/motions/idle/hungry2.vrma.br +0 -0
  17. package/assets/motions/idle/idle lookaround1.vrma.br +0 -0
  18. package/assets/motions/idle/idle lookaround2.vrma.br +0 -0
  19. package/assets/motions/idle/idle stretch1.vrma.br +0 -0
  20. package/assets/motions/idle/idle stretch2.vrma.br +0 -0
  21. package/assets/motions/idle/idle stretch3.vrma.br +0 -0
  22. package/assets/motions/idle/idle stretch4.vrma.br +0 -0
  23. package/assets/motions/idle/idle.vrma.br +0 -0
  24. package/assets/motions/idle/idle2.vrma.br +0 -0
  25. package/assets/motions/idle/idle3.vrma.br +0 -0
  26. package/assets/motions/idle/idle4.vrma.br +0 -0
  27. package/assets/motions/idle/touch hair1.vrma.br +0 -0
  28. package/assets/motions/idle/touch hair2.vrma.br +0 -0
  29. package/assets/motions/offline/sleeping.vrma.br +0 -0
  30. package/assets/motions/success/jump 1.vrma.br +0 -0
  31. package/assets/motions/success/jump 2.vrma.br +0 -0
  32. package/assets/motions/success/jump 3.vrma.br +0 -0
  33. package/assets/motions/vrmpet3d-motions.json +283 -0
  34. package/assets/motions/working/dance1.vrma.br +0 -0
  35. package/cordis.patch.yml +7 -0
  36. package/desktop/main.js +1782 -0
  37. package/desktop/package.json +7 -0
  38. package/desktop/preload.js +66 -0
  39. package/desktop/probe-motion-picker.js +122 -0
  40. package/desktop/probe-parts-editor.js +247 -0
  41. package/lib/brotli.js +74 -0
  42. package/lib/client.core.js +7563 -0
  43. package/lib/client.js +5031 -0
  44. package/lib/client.menu.js +501 -0
  45. package/lib/client.scene.js +5128 -0
  46. package/lib/client.vendor.js +55286 -0
  47. package/lib/config-page.html +194 -0
  48. package/lib/config-page.js +4754 -0
  49. package/lib/core.js +7541 -0
  50. package/lib/desktop-page.html +131 -0
  51. package/lib/desktop-page.js +3927 -0
  52. package/lib/desktop.js +536 -0
  53. package/lib/env.js +20 -0
  54. package/lib/global-page.html +338 -0
  55. package/lib/index.js +4170 -0
  56. package/lib/menu.js +479 -0
  57. package/lib/model-picker.js +794 -0
  58. package/lib/pages.css +350 -0
  59. package/lib/picker-page.html +64 -0
  60. package/lib/picker-page.js +145 -0
  61. package/lib/placeholder.js +566 -0
  62. package/lib/scene.js +5106 -0
  63. package/lib/settings.js +259 -0
  64. package/lib/setup-page.html +240 -0
  65. package/lib/setup-page.js +706 -0
  66. package/lib/vrm-info.js +926 -0
  67. package/package.json +55 -4
@@ -0,0 +1,1782 @@
1
+ // The pet's own window: always on top of every other window, transparent, and click-through
2
+ // everywhere except over the pet itself.
3
+ //
4
+ // Started by the plugin's host half (`lib/desktop.js`) with a bare-Electron runtime, so this
5
+ // file must not assume anything about DSH: it gets its page URL, its model id and its
6
+ // parent's pid on the command line and owns nothing else.
7
+ //
8
+ // Everything here was measured before it was written (see HANDOFF §12): a transparent
9
+ // always-on-top window renders WebGL with a real alpha channel, can be resized to the whole
10
+ // screen, and `capturePage()` returns pixels that are mostly fully transparent.
11
+ const { app, BrowserWindow, dialog, ipcMain, screen } = require('electron');
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ /** Cursor polling rate. The gaze is smoothed by the idle, so this is about drag latency. */
16
+ const CURSOR_HZ = 60;
17
+
18
+ /** How often to check that the process that started us is still alive. */
19
+ const WATCHDOG_MS = 2000;
20
+
21
+ /**
22
+ * The size the pet's window starts at, before the page has told us where the pet actually is.
23
+ *
24
+ * A placeholder, not a policy: every `vrmpet3d:rect` report re-sizes and re-places the window onto the
25
+ * pet's OWN rectangle, so this only has to be a sane shape for the first frame. The window used to be
26
+ * the whole work area and nothing about the pet's size was involved — see the note on the window
27
+ * options below for the measurement that ended that.
28
+ */
29
+ const DEFAULT_PET_WINDOW = { width: 240, height: 320 };
30
+
31
+ /**
32
+ * The marker both halves agree on for "here is what the page is doing".
33
+ *
34
+ * Duplicated in `lib/desktop.js` because the launcher is a separate process with no imports
35
+ * from the plugin; the `desktop` suite asserts the two copies are identical, so a rename on
36
+ * one side fails a test instead of silently ending the reporting.
37
+ */
38
+ const VRMPET3D_STATUS_PREFIX = 'vrmpet3d-desktop: ';
39
+
40
+ /**
41
+ * The payload the page expects for a cursor sample.
42
+ *
43
+ * `frame` is the window's origin, and it is load-bearing: the page positions its canvas in the VIRTUAL
44
+ * work-area frame it has always used (the pet's placement, its zoom ceiling and its gaze are all
45
+ * measured against that), so it needs the window origin to convert to window coordinates. A sample
46
+ * without it would leave the page laying its canvas out against a stale origin — which is why the
47
+ * self-test's synthetic cursors go through this too, and why it lives at module scope: those probes
48
+ * run outside the `whenReady` scope.
49
+ */
50
+ function cursorMessage(target, point, isInteracting) {
51
+ const frame = target && !target.isDestroyed() ? target.getBounds() : { x: 0, y: 0 };
52
+ return {
53
+ x: point.x,
54
+ y: point.y,
55
+ interacting: isInteracting === true,
56
+ frame: { x: frame.x, y: frame.y },
57
+ };
58
+ }
59
+
60
+ function parseArgs(argv) {
61
+ const out = { url: '', modelId: '', parentPid: 0, selfTest: '', config: false };
62
+ for (const arg of argv) {
63
+ const match = /^--([a-z-]+)=(.*)$/.exec(arg);
64
+ if (!match) continue;
65
+ if (match[1] === 'url') out.url = match[2];
66
+ else if (match[1] === 'model-id') out.modelId = match[2];
67
+ else if (match[1] === 'parent-pid') out.parentPid = Number(match[2]) || 0;
68
+ else if (match[1] === 'self-test') out.selfTest = match[2];
69
+ else if (match[1] === 'config') out.config = match[2] !== '0';
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * A parent that stops reading must not put a modal dialog in front of the user.
76
+ *
77
+ * `announce()` writes one status line to stdout per report, and a broken pipe surfaces as an
78
+ * ASYNCHRONOUS `error` event on the stream, not as a throw - so the try/catch around the write
79
+ * cannot see it, and Node turns it into an uncaught exception. Electron then shows
80
+ * "A JavaScript error occurred in the main process" (measured 2026-10-08: killing a probe
81
+ * instance raised EPIPE at Socket._write through announce -> IpcMainImpl, and the dialog stayed on
82
+ * the user's screen). The pet itself is fine in that situation; it simply has nobody to talk to.
83
+ */
84
+ process.stdout.on('error', () => {});
85
+ process.stderr.on('error', () => {});
86
+
87
+ /**
88
+ * How long the self-test waits for the page's own verdict before it gives up.
89
+ *
90
+ * Without it a page that never sends `vrmpet3d:ready` leaves the window and this process alive for
91
+ * ever (measured twice on 2026-10-08), which is a stray always-on-top window on the user's screen
92
+ * and a self-test that never writes its report. The `desktop` suite waits 60 s for the file, so
93
+ * this has to fire before that.
94
+ */
95
+ const SELF_TEST_TIMEOUT_MS = 45_000;
96
+
97
+ let selfTestTimer = null;
98
+
99
+ /**
100
+ * Keep drawing even when Windows thinks nobody is looking.
101
+ *
102
+ * A screen-sized, transparent, always-on-top window gets classified as occluded the moment something covers
103
+ * it - or, on Windows, when it is activated by a click - and Chromium then STOPS PAINTING it. What is left on
104
+ * screen is the window's last frame, and for a transparent window that frame carries a snapshot of whatever
105
+ * was behind it, so the desktop appears frozen like a screenshot (reported 2026-10-08: it happened the first
106
+ * time the user clicked the model).
107
+ *
108
+ * Both switches are about that one behaviour: the occlusion calculation is what marks the window occluded, and
109
+ * the backgrounding flag is what lets an occluded window stop rendering. A desk pet is always on top, so it is
110
+ * never 'behind' anything in a way that would justify throttling it.
111
+ */
112
+ app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion');
113
+ //
114
+ // And the compositing path itself.
115
+ //
116
+ // A screen-sized transparent window is normally presented through DirectComposition / an independent flip
117
+ // plane - the shortest path to the screen, and the one that can leave whatever is behind the window showing
118
+ // a stale frame when the window stops or changes how it presents (reported again after the occlusion
119
+ // switches, so they were necessary but not sufficient). `disable-direct-composition` takes the ordinary
120
+ // compositing path, which keeps the desktop behind the pet live. `enable-transparent-visuals` asks for the
121
+ // transparency support explicitly rather than relying on the default.
122
+ //
123
+ app.commandLine.appendSwitch('disable-direct-composition');
124
+ app.commandLine.appendSwitch('enable-transparent-visuals');
125
+ app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
126
+ app.commandLine.appendSwitch('disable-renderer-backgrounding');
127
+
128
+ const options = parseArgs(process.argv.slice(2));
129
+ const report = {
130
+ startedAt: new Date().toISOString(),
131
+ argv: process.argv.slice(1, 4),
132
+ versions: { electron: process.versions.electron, chrome: process.versions.chrome, node: process.versions.node },
133
+ url: options.url,
134
+ modelId: options.modelId,
135
+ checks: {},
136
+ errors: [],
137
+ };
138
+ let selfTestWritten = false;
139
+
140
+ /**
141
+ * EVERY window gets its own Chromium profile — not only a self-test.
142
+ *
143
+ * Chromium's process singleton is per `user-data-dir`, so two windows of THIS app that share one either exit at once or — measured,
144
+ * twice, on 2026-10-08 — hang before their page ever mounts, because the shared profile is owned by the live instance. That was
145
+ * handled for self-tests only, which left the case that matters in production: the PET window and the SETTINGS window are the same
146
+ * launcher (`--config=1`) and shared a profile, so whichever started second was the one whose page never mounted.
147
+ *
148
+ * The operator's window on 2026-10-15 was exactly that, and it read as 「弹簧骨状态丢了…头发现在动的感觉不对了」: no mount means no scene,
149
+ * so no spring bones, no motion, and `ready: false` with an empty error list — while the same launcher started by hand, with no second
150
+ * window in the way, mounted normally (`ready: true`, `pageTitle: … > ready:m1`).
151
+ *
152
+ * The directory is kept between runs so Chromium's one-time work happens once, and the mode is in the name so the pet, the settings
153
+ * window and a probe never share one. Set before `app.whenReady()`, which is the only time it takes effect.
154
+ */
155
+ const profileSuffix = options.config === true ? '-config' : '-pet';
156
+ const ownProfile = `${app.getPath('userData')}${profileSuffix}${options.selfTest ? '-selftest' : ''}`;
157
+ try {
158
+ fs.mkdirSync(ownProfile, { recursive: true });
159
+ app.setPath('userData', ownProfile);
160
+ } catch (_error) {
161
+ // A window that cannot get its own profile is still worth opening in the shared one.
162
+ }
163
+
164
+ function writeSelfTest(code) {
165
+ if (!options.selfTest || selfTestWritten) return;
166
+ selfTestWritten = true;
167
+ if (selfTestTimer) clearTimeout(selfTestTimer);
168
+ selfTestTimer = null;
169
+ report.finishedAt = new Date().toISOString();
170
+ // Two windows, two definitions of working. The settings window is judged on the things that make it
171
+ // a usable form: it loaded, it is an ordinary window (not always-on-top, not transparent), and it is
172
+ // not trying to be the pet.
173
+ if (options.config === true) {
174
+ report.ok = report.errors.length === 0
175
+ && report.checks.loaded === true
176
+ && report.checks.configWindow === true
177
+ && report.checks.alwaysOnTop === false
178
+ && report.checks.transparent === false;
179
+ try {
180
+ fs.writeFileSync(options.selfTest, JSON.stringify(report, null, 2));
181
+ } catch (error) {
182
+ report.errors.push('self-test write failed: ' + error.message);
183
+ }
184
+ app.exit(report.ok ? 0 : 1);
185
+ return;
186
+ }
187
+ report.ok = report.errors.length === 0
188
+ && report.checks.alwaysOnTop === true
189
+ && report.checks.clickThroughAccepted === true
190
+ && report.checks.transparent === true
191
+ && report.checks.petSized === true
192
+ && report.checks.rendersPixels === true
193
+ && report.checks.hasTransparency === true
194
+ && report.checks.pageReady === true
195
+ // The window taking clicks only where the pet was painted is part of what "it works" means:
196
+ // a window that passes everything through is unusable, and one that swallows the whole screen
197
+ // is worse.
198
+ && report.checks.perPixelHitTest === true
199
+ // And the right-click MENU has to take clicks as well, or a right-click would open a control surface
200
+ // that cannot be touched.
201
+ && report.checks.menuHitTest === true
202
+ // And the wheel has to zoom about the cursor: a resize that walks the pet out from under the
203
+ // pointer is what ends a zoom gesture after one step in a click-through window.
204
+ && report.checks.wheelZoomOk === true;
205
+ try {
206
+ fs.writeFileSync(options.selfTest, JSON.stringify(report, null, 2));
207
+ } catch (error) {
208
+ report.errors.push('self-test write failed: ' + error.message);
209
+ }
210
+ app.exit(code);
211
+ }
212
+
213
+ /** The pet's rectangle on screen, as last reported by the page; null until it mounts. */
214
+ let petRect = null;
215
+ /** The right-click menu's rectangle in screen coordinates, while it is open. DOM, so the window holds it. */
216
+ let menuRect = null;
217
+ /** The status prompt's rectangle in screen coordinates, while the pet has something to say. Same reason. */
218
+ let statusRect = null;
219
+ /**
220
+ * The 碎碎念 bubble's rectangle, while the pet is saying something of its own.
221
+ *
222
+ * The same mechanism as the prompt's — it is DOM above the pet's head, so the window has to hold it — and
223
+ * deliberately NOT part of the interactive test: the launcher grows the window for the bubble, and the page's
224
+ * per-pixel hit test keeps the mouse passing through it.
225
+ */
226
+ let speechRect = null;
227
+ /** The last cursor this process saw, so a bounds change can re-send it with the new frame origin. */
228
+ let lastCursor = null;
229
+ /** Whether the cursor is on a pixel the page actually painted — the page's own verdict. */
230
+ let overPet = false;
231
+ let interacting = false;
232
+ /** True while the self-test drives the page with synthetic cursors. */
233
+ let probing = false;
234
+ /**
235
+ * How long the window stays interactive after a wheel notch, and when that expires.
236
+ *
237
+ * Deliberately longer than the page's own 450 ms hold: the window must still be receiving the wheel
238
+ * when the page is still willing to act on it, or the burst is cut at whichever boundary is shorter.
239
+ */
240
+ const ZOOM_HOLD_MS = 700;
241
+ let zoomHoldUntil = 0;
242
+ let cursorTimer = null;
243
+ let watchdogTimer = null;
244
+ /** Page console output, kept so a silent page can be explained after the fact. */
245
+ const pageLog = [];
246
+ /** The page's frame counter as this process last saw it, for the "is it still drawing" watchdog. */
247
+ let lastSeenFrames = -1;
248
+ let stalledChecks = 0;
249
+
250
+ app.whenReady().then(async () => {
251
+ try {
252
+ const display = screen.getPrimaryDisplay();
253
+ // The WORK area: the frame the pet is placed in, and the bounds its window is kept inside. The page
254
+ // is handed this rectangle, because its placement is expressed as fractions of it.
255
+ const bounds = display.workArea;
256
+ report.display = { bounds: display.bounds, workArea: display.workArea, scaleFactor: display.scaleFactor };
257
+
258
+ // Which of the two windows this process is: the screen-sized click-through pet, or an ordinary
259
+ // settings form. Read from the argument rather than from a separate entry point, because the rest of
260
+ // the launcher (the borrowed runtime, the watchdog, the parent-pid handshake) is identical for both.
261
+ const isConfig = options.config === true;
262
+
263
+ /**
264
+ * Arm the self-test's own deadline before anything can hang.
265
+ *
266
+ * The pet self-test writes its report when the PAGE says it is ready; a page that never gets
267
+ * there (a renderer that cannot get a context, a model that never resolves) leaves the window
268
+ * and this process alive for ever. Measured twice on 2026-10-08, each time as an always-on-top
269
+ * window the user had to get rid of by hand. A report with an error is the honest outcome.
270
+ */
271
+ if (options.selfTest) {
272
+ selfTestTimer = setTimeout(() => {
273
+ report.errors.push(`the page never reported ready within ${SELF_TEST_TIMEOUT_MS} ms`);
274
+ writeSelfTest(1);
275
+ }, SELF_TEST_TIMEOUT_MS);
276
+ }
277
+
278
+ const win = new BrowserWindow(isConfig ? {
279
+ /**
280
+ * The settings window: an ordinary window on purpose.
281
+ *
282
+ * Not transparent, not always-on-top, not click-through, resizable and focusable — it is a
283
+ * form, not a pet. Everything the pet window needs (screen-sized, click-through except where it
284
+ * painted, never focused) is exactly what a settings form must not be, which is why the two
285
+ * share the launcher and nothing else.
286
+ */
287
+ width: 780,
288
+ height: 860,
289
+ minWidth: 560,
290
+ minHeight: 420,
291
+ title: 'vrmpet3d - model settings',
292
+ backgroundColor: '#16171c',
293
+ show: true,
294
+ webPreferences: {
295
+ /**
296
+ * The settings page gets the SAME bridge the pet page has, and it needs it for one thing: the
297
+ * model-import section asks for a native FILE picker (`vrmpet3d:pick-path`), which no web page can open
298
+ * for itself. Everything else the page reads over the host's HTTP routes, exactly as before.
299
+ */
300
+ preload: path.join(__dirname, 'preload.js'),
301
+ contextIsolation: true,
302
+ nodeIntegration: false,
303
+ backgroundThrottling: false,
304
+ },
305
+ } : {
306
+ // THE PET'S OWN BOX, not the screen — this is the fix for the freeze.
307
+ //
308
+ // Measured on 2026-10-08 with a local reference window and screen captures: a work-area-sized
309
+ // transparent topmost window that TAKES THE MOUSE freezes everything composed behind it for a
310
+ // second or two (5 of 8 samples after a click on the pet; 0 of 8 with the same click on a
311
+ // pet-sized window). The mechanism fits all three plausible explanations at once — Chromium's
312
+ // occlusion tracker in the window underneath, DWM promoting a fullscreen-shaped layered window
313
+ // to an overlay plane, and a screen-sized layered surface being re-uploaded every frame.
314
+ // All three need a window that covers the screen, so the window no longer does: it is created
315
+ // at a modest default (bottom-right of the work area) and then FOLLOWS the pet's own rectangle,
316
+ // which the page reports on every `vrmpet3d:rect`.
317
+ // THE PET'S OWN BOX, not the screen — the fix for the freeze.
318
+ //
319
+ // Measured with a local reference window and screen captures: with the ORIGINAL screen-sized window a
320
+ // click on the pet froze everything composed behind it for a second or two (5 of 8 samples), and with
321
+ // a pet-sized window the same click did not (0 of 8). The freeze has not been reproduced since the
322
+ // page and the follow logic changed, so size is best treated as "what our window looked like when it
323
+ // happened" rather than a proven cause — see HANDOFF §16, which is explicit about what is measured
324
+ // and what is not. What IS certain: this shape is the one that never froze for the user, and it is
325
+ // what ships.
326
+ x: bounds.x + Math.max(0, bounds.width - DEFAULT_PET_WINDOW.width - 40),
327
+ y: bounds.y + Math.max(0, bounds.height - DEFAULT_PET_WINDOW.height - 40),
328
+ width: DEFAULT_PET_WINDOW.width,
329
+ height: DEFAULT_PET_WINDOW.height,
330
+ frame: false,
331
+ transparent: true,
332
+ // 'screen-saver' rather than a plain always-on-top: the plain level is still below
333
+ // some full-screen-ish windows, which is exactly what a desk pet must not be.
334
+ alwaysOnTop: true,
335
+ skipTaskbar: true,
336
+ hasShadow: false,
337
+ resizable: false,
338
+ focusable: false,
339
+ fullscreenable: false,
340
+ show: true,
341
+ webPreferences: {
342
+ preload: path.join(__dirname, 'preload.js'),
343
+ contextIsolation: true,
344
+ nodeIntegration: false,
345
+ backgroundThrottling: false,
346
+ },
347
+ });
348
+ /**
349
+ * Whether the window currently receives the mouse.
350
+ *
351
+ * `null` means "never set", which is not the same as `false`: the point of tracking it is to call
352
+ * `setIgnoreMouseEvents` only when the answer CHANGES. The cursor poll runs at 60 Hz, and repeated
353
+ * SetWindowLong-style updates on a layered window are a known source of stale hit regions — the
354
+ * second of the three fixes the user's freeze report asks for.
355
+ */
356
+ let mouseThrough = null;
357
+
358
+ /**
359
+ * Re-assert what a screen-sized topmost window must be, whenever its interactivity changes.
360
+ *
361
+ * ACTIVATION is what starts the freeze: Windows re-evaluates the window's presentation path when
362
+ * its style/layered state changes, and a window that keeps focus is the one that gets the
363
+ * "fullscreen" treatment. `focusable: false` should already mean `WS_EX_NOACTIVATE`; `blur()` is
364
+ * the cheap way to be certain, and re-asserting `screen-saver` after the click-through call puts
365
+ * the window back on top, which is the one thing that API can drop.
366
+ */
367
+ /**
368
+ * 视角旋转期间,这个窗口暂时允许聚焦。
369
+ *
370
+ * Chromium 只给**聚焦的文档**发指针锁 —— 实测(自检里的 `pointerLock` 探测)报的是
371
+ * 「The root document of this element is not valid for pointer lock.」而这个窗口是刻意
372
+ * `focusable: false` 的。操作者要求把窗口内桌宠的"旋转时锁住鼠标"搬到这里(2026-10-12):
373
+ * 「独立桌宠的旋转功能一直都异常,从来没能把鼠标真正锁住过」,而这个窗口只有宠物那么大,
374
+ * 右拖几个像素鼠标就出窗、事件就断 —— 所以锁在这里比在主窗更重要。
375
+ *
376
+ * 取舍写在明处:只在这**确实开始旋转**(按住右键并移动超过阈值)时聚焦,松手立刻恢复不可聚焦并交回
377
+ * 光标。普通右键点击永远不会碰焦点。
378
+ */
379
+ let orbitFocus = false;
380
+
381
+ const keepOnTop = () => {
382
+ if (win.isDestroyed()) return;
383
+ win.setAlwaysOnTop(true, 'screen-saver');
384
+ // 旋转期间不 blur:聚焦正是这次旋转要拿指针锁的条件。
385
+ if (!orbitFocus && win.isFocused()) win.blur();
386
+ };
387
+
388
+ const setClickThrough = (through) => {
389
+ if (win.isDestroyed() || mouseThrough === through) return;
390
+ mouseThrough = through;
391
+ if (through) win.setIgnoreMouseEvents(true, { forward: true });
392
+ else win.setIgnoreMouseEvents(false);
393
+ keepOnTop();
394
+ };
395
+
396
+ if (!isConfig) {
397
+ win.setAlwaysOnTop(true, 'screen-saver');
398
+ win.setIgnoreMouseEvents(true, { forward: true });
399
+ mouseThrough = true;
400
+ // A screen-sized transparent topmost window must never hold focus. If Windows hands it one
401
+ // anyway, drop it immediately — this is half of the first fix.
402
+ /**
403
+ * 绝不持有焦点 —— **旋转期间除外**。
404
+ *
405
+ * 这是上一轮指针锁失败的第二个原因,而且是我漏掉的:`keepOnTop()` 里的 blur 我加了 `orbitFocus` 守卫,
406
+ * 这个 focus→blur 的处理器却没有。于是 `win.focus()` 刚成功,这个回调立刻把焦点 blur 掉,页面里的
407
+ * `document.hasFocus()` 一直是 false(操作者机器上的实测:`{"locked":false,"hasFocus":false}`)。
408
+ */
409
+ win.on('focus', () => {
410
+ if (!win.isDestroyed() && !orbitFocus) win.blur();
411
+ });
412
+ report.checks.alwaysOnTop = win.isAlwaysOnTop() === true;
413
+ } else {
414
+ // Recorded so a self-test of the settings window can say what it is, without pretending it
415
+ // has the pet window's properties.
416
+ report.checks.configWindow = true;
417
+ report.checks.alwaysOnTop = false;
418
+ report.checks.transparent = false;
419
+ }
420
+ if (!isConfig) {
421
+ report.checks.transparent = true;
422
+ report.checks.clickThroughAccepted = true;
423
+ // The pet window is the PET'S BOX, not the screen — and it is placed by the page's own report
424
+ // (`vrmpet3d:rect`), so at this moment it still has its default size. What is asserted here is the
425
+ // property that matters and that no longer depends on timing: it does NOT cover the work area.
426
+ const actual = win.getBounds();
427
+ report.checks.petSized = actual.width < bounds.width && actual.height < bounds.height;
428
+ report.checks.startBounds = actual;
429
+ report.checks.workArea = bounds;
430
+ } else {
431
+ // The settings window is none of those things, and it is not asked to be: a form that is
432
+ // transparent, screen-sized or click-through would be unusable.
433
+ report.checks.clickThroughAccepted = false;
434
+ report.checks.petSized = false;
435
+ }
436
+
437
+ // A page that throws AFTER it reported ready would otherwise be invisible: the pet would
438
+ // simply never draw. Both channels land in the report the self-test writes.
439
+ win.webContents.on('console-message', (...args) => {
440
+ // Electron 44 hands over ONE event object and warns about the old positional form; older
441
+ // versions pass (event, level, message, line, source). Accepting both keeps the launcher
442
+ // quiet on either.
443
+ const first = args[0];
444
+ const entry = first && typeof first === 'object' && 'message' in first
445
+ ? { level: first.level, message: first.message, line: first.lineNumber, source: first.sourceId }
446
+ : { level: args[1], message: args[2], line: args[3], source: args[4] };
447
+ pageLog.push({
448
+ level: entry.level,
449
+ message: String(entry.message === undefined ? '' : entry.message).slice(0, 400),
450
+ line: entry.line,
451
+ source: entry.source,
452
+ });
453
+ });
454
+ win.webContents.on('render-process-gone', (_event, details) => {
455
+ report.errors.push(`the page's renderer went away: ${details && details.reason}`);
456
+ });
457
+
458
+ /**
459
+ * Decide, for this cursor position, whether the window should receive the mouse.
460
+ *
461
+ * The verdict is the PAGE's, not the launcher's: only the page knows which pixels it actually
462
+ * painted, so `over` is a per-pixel hit test — a gap between hair strands is click-through
463
+ * while the hair itself takes the click. The rectangle is kept for the self-test and for
464
+ * diagnostics, and is deliberately NOT what decides this: a rectangle swallows every
465
+ * transparent pixel inside the box, which is what the first version did wrong in both
466
+ * directions (the whole box was retired from the user's report, and before that the whole
467
+ * screen was).
468
+ */
469
+ const applyInteractivity = (cursor) => {
470
+ if (interacting) {
471
+ // While the user holds the pet, the window must receive moves anywhere: the pet follows
472
+ // the cursor, so the cursor leaves the painted pixels immediately.
473
+ setClickThrough(false);
474
+ return;
475
+ }
476
+ if (Date.now() < zoomHoldUntil) {
477
+ // And for a moment after every wheel notch: a scroll is a burst, the model turns while it
478
+ // happens, and one pixel-less frame under the cursor would otherwise hand the rest of the
479
+ // burst to whatever is behind the window.
480
+ setClickThrough(false);
481
+ return;
482
+ }
483
+ setClickThrough(!overPet);
484
+ report.checks.hoverTogglesClickThrough = true;
485
+ };
486
+
487
+ /**
488
+ * Tell the page where the cursor is, and where the window is.
489
+ *
490
+ * `frame` is load-bearing: the page positions its canvas in the VIRTUAL work-area frame it has
491
+ * always used, so it needs the window's origin to convert that into window coordinates. The
492
+ * window's bounds change whenever the pet is dragged, zoomed, or the menu opens — which is why
493
+ * this rides the 60 Hz cursor stream rather than an event of its own.
494
+ */
495
+ const sendCursor = (cursor) => {
496
+ if (win.isDestroyed() || !cursor) return;
497
+ lastCursor = { x: cursor.x, y: cursor.y };
498
+ win.webContents.send('vrmpet3d:cursor', cursorMessage(win, cursor, interacting));
499
+ };
500
+
501
+ /**
502
+ * Put the window on the pet's own rectangle and tell the page where its canvas goes.
503
+ *
504
+ * This is the whole of the anti-freeze fix, and it is a *follow* rather than a resize: the page
505
+ * keeps working in the virtual work-area frame it has always used (placement fractions, drag,
506
+ * zoom anchoring, gaze), and only the window that shows the result moves and sizes itself to the
507
+ * box the page reports — plus the right-click menu when it is open, which is DOM and therefore has
508
+ * to be inside the window to be visible and clickable.
509
+ *
510
+ * `frame` is the window's origin, which the page needs because its canvas is positioned by CSS in
511
+ * *virtual* coordinates: the canvas goes at `box.left - frame.x` inside the window.
512
+ *
513
+ * Called on every rect report, so it is guarded: `setBounds` only when the rectangle actually
514
+ * changes, and by at least a pixel. A 60 Hz `setBounds` on a layered window is exactly the kind of
515
+ * churn the click-through guard exists to avoid.
516
+ */
517
+ const windowRect = { left: null, top: null, width: null, height: null };
518
+ const followPet = () => {
519
+ if (win.isDestroyed() || !petRect || petRect.width < 1 || petRect.height < 1) return;
520
+ const menu = menuRect && menuRect.width > 1 && menuRect.height > 1 ? menuRect : null;
521
+ /**
522
+ * The window holds the pet, the right-click MENU and the status prompt.
523
+ *
524
+ * All three are unioned rather than assumed to be inside the pet's own box, because two of them are DOM
525
+ * that paints nothing into the framebuffer: a window sized to the pet alone clips a menu beside it and a
526
+ * prompt above its head. The page reports both rectangles in screen coordinates precisely so this process
527
+ * never has to guess where they are.
528
+ */
529
+ const status = statusRect && statusRect.width > 1 && statusRect.height > 1 ? statusRect : null;
530
+ // ...and the 碎碎念 bubble, for exactly the same reason. It is not in the page's hit test, so the window
531
+ // grows to SHOW it without becoming interactive over it.
532
+ const speech = speechRect && speechRect.width > 1 && speechRect.height > 1 ? speechRect : null;
533
+ const extras = [menu, status, speech].filter(Boolean);
534
+ let left = petRect.left;
535
+ let top = petRect.top;
536
+ let right = petRect.left + petRect.width;
537
+ let bottom = petRect.top + petRect.height;
538
+ for (const rect of extras) {
539
+ left = Math.min(left, rect.left);
540
+ top = Math.min(top, rect.top);
541
+ right = Math.max(right, rect.left + rect.width);
542
+ bottom = Math.max(bottom, rect.top + rect.height);
543
+ }
544
+ const next = {
545
+ left: Math.round(left),
546
+ top: Math.round(top),
547
+ width: Math.max(1, Math.round(right - left)),
548
+ height: Math.max(1, Math.round(bottom - top)),
549
+ };
550
+ if (windowRect.left === next.left && windowRect.top === next.top
551
+ && windowRect.width === next.width && windowRect.height === next.height) return;
552
+ Object.assign(windowRect, next);
553
+ // `x`/`y`, NOT `left`/`top`: Electron ignores unknown keys, so a bounds object spelled with the
554
+ // names this process uses internally resizes the window and leaves it where it was — measured
555
+ // 2026-10-08, and it cost a clipped canvas (`pixels.left - windowOrigin.x` came out negative).
556
+ win.setBounds({ x: next.left, y: next.top, width: next.width, height: next.height });
557
+ // The cursor stream carries this to the page, so a report that arrived before the bounds settled
558
+ // does not leave the canvas a few pixels out until the next tick.
559
+ //
560
+ // NOT while a self-test is probing: the probe drives the page with its own synthetic cursor, and
561
+ // re-sending the REAL one here overwrote it mid-probe — the wheel check then anchored its resize
562
+ // at the physical mouse position and reported a drifted anchor, with every other number correct.
563
+ if (!probing) sendCursor(lastCursor);
564
+ };
565
+
566
+ ipcMain.handle('vrmpet3d:config', () => ({
567
+ modelId: options.modelId,
568
+ assetBase: '/vrmpet3d/asset',
569
+ // The VIRTUAL frame the page places the pet in: still the whole work area, because that is what
570
+ // the placement fractions and the zoom ceiling are measured against. The window is only the part
571
+ // of it the pet occupies, and `vrmpet3d:cursor` carries that window's origin to the page.
572
+ screen: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
573
+ selfTest: Boolean(options.selfTest),
574
+ }));
575
+ /**
576
+ * The import picker: a native folder dialog, answered with a path or null.
577
+ *
578
+ * This is the one thing a settings page cannot do for itself — a web page cannot open a native dialog, and
579
+ * this process is the only one with an Electron to open it in. The path goes back to the PAGE, which hands it
580
+ * to the HOST (`/vrmpet3d/models` or `/vrmpet3d/motions` depending on which section asked); the host stays the only
581
+ * writer of every import store, and the `VRMPET3D_*_FILE` overrides the suites rely on are not bypassed.
582
+ *
583
+ * `kind` decides a FILE dialog from a folder one, and the title each shows. A MODEL is a FILE now (the
584
+ * operator: 「选择VRM模型所在的文件…不再是选中一个文件夹了,万一文件夹移位了,也会出bug」), filtered to the three
585
+ * formats the asset routes can serve; a MOTION is a FOLDER, because a pack is a folder of clips filed by
586
+ * state and the host copies the folder.
587
+ *
588
+ * `defaultPath` is the user's home directory rather than a model directory: the launcher does not know where
589
+ * models or motions live, and guessing would open the dialog somewhere surprising.
590
+ */
591
+ ipcMain.handle('vrmpet3d:pick-path', async (_event, options) => {
592
+ const kind = options && typeof options === 'object' && options.kind === 'motion' ? 'motion' : 'model';
593
+ try {
594
+ const result = await dialog.showOpenDialog(win, kind === 'motion' ? {
595
+ title: '导入动作目录',
596
+ properties: ['openDirectory'],
597
+ defaultPath: app.getPath('home'),
598
+ } : {
599
+ title: '导入模型文件',
600
+ properties: ['openFile'],
601
+ filters: [{ name: 'VRM / glTF 模型', extensions: ['vrm', 'glb', 'gltf'] }],
602
+ defaultPath: app.getPath('home'),
603
+ });
604
+ if (result.canceled || !Array.isArray(result.filePaths) || !result.filePaths.length) return null;
605
+ return result.filePaths[0];
606
+ } catch (_error) {
607
+ // A dialog that cannot open is not a crash: the page falls back to a typed path.
608
+ return null;
609
+ }
610
+ });
611
+ ipcMain.on('vrmpet3d:rect', (_event, rect) => {
612
+ if (!rect || typeof rect !== 'object') return;
613
+ petRect = {
614
+ left: Number(rect.left) || 0,
615
+ top: Number(rect.top) || 0,
616
+ width: Number(rect.width) || 0,
617
+ height: Number(rect.height) || 0,
618
+ };
619
+ overPet = rect.over === true;
620
+ /**
621
+ * 旋转也算"正在交互"。
622
+ *
623
+ * 这个窗口只有宠物那么大,而 `applyInteractivity` 是按"光标是否在宠物像素上"决定穿透的 —— 旋转时鼠标
624
+ * 必然离开宠物,于是窗口穿透、指针事件断掉、旋转停住(操作者:「旋转功能一直都异常」)。整段旋转期间保持
625
+ * 不可穿透,指针才留得住。
626
+ */
627
+ interacting = rect.dragging === true || rect.orbiting === true;
628
+ // The menu's own rectangle, in the same screen coordinates: it is DOM, so it cannot be painted into
629
+ // the window's transparent area — the window has to grow to hold it.
630
+ menuRect = rect.menu && typeof rect.menu === 'object'
631
+ ? {
632
+ left: Number(rect.menu.left) || 0,
633
+ top: Number(rect.menu.top) || 0,
634
+ width: Number(rect.menu.width) || 0,
635
+ height: Number(rect.menu.height) || 0,
636
+ }
637
+ : null;
638
+ // And the status prompt's, for exactly the same reason: it is DOM above the model's head, so a window
639
+ // that were only the pet's box would clip the one thing the feature exists to show.
640
+ statusRect = rect.status && typeof rect.status === 'object'
641
+ ? {
642
+ left: Number(rect.status.left) || 0,
643
+ top: Number(rect.status.top) || 0,
644
+ width: Number(rect.status.width) || 0,
645
+ height: Number(rect.status.height) || 0,
646
+ }
647
+ : null;
648
+ // And the 碎碎念 bubble's, on exactly the same terms — DOM above the head that the window must hold.
649
+ speechRect = rect.speech && typeof rect.speech === 'object'
650
+ ? {
651
+ left: Number(rect.speech.left) || 0,
652
+ top: Number(rect.speech.top) || 0,
653
+ width: Number(rect.speech.width) || 0,
654
+ height: Number(rect.speech.height) || 0,
655
+ }
656
+ : null;
657
+ // Every notch produces a rect report (the page bumps `zoomSeq`), which is what refreshes the
658
+ // hold below — the page's own wheel answer expires after 450 ms, this one after 700 ms, so the
659
+ // window is still interactive while the page is still willing to take the notch.
660
+ if (rect.zooming === true) zoomHoldUntil = Date.now() + ZOOM_HOLD_MS;
661
+ followPet();
662
+ announce();
663
+ });
664
+ ipcMain.on('vrmpet3d:ready', (_event, info) => {
665
+ report.page = info || null;
666
+ report.checks.pageReady = Boolean(info && info.webgl2);
667
+ // One machine-readable line on stdout, which the host half relays and parses into its
668
+ // status. Without it the host can only say "the process is running" — and a window whose
669
+ // page never mounted looks exactly like a working one from there, which is how "I turned
670
+ // it on and nothing happened" stayed silent.
671
+ announce(info);
672
+ if (options.selfTest) finishSelfTest(win);
673
+ });
674
+ /**
675
+ * 开始/结束一次视角旋转 —— 页面唯一的请求,启动器负责让它成立。
676
+ *
677
+ * `true`:允许聚焦并把焦点给窗口,然后回一条 `vrmpet3d:orbit-focus`,页面收到后才请求指针锁
678
+ * (请求必须发生在聚焦之后,否则 Chromium 直接拒绝 —— 上面 `pointerLock` 的实测就是这么来的)。
679
+ * `false`:恢复 `focusable: false`、交回焦点、重新置顶,也就是这个窗口平时的样子。
680
+ */
681
+ /**
682
+ * 页面请求取锁/放锁的脚本 —— 由**主进程**执行,带 `userGesture` 标记。
683
+ *
684
+ * 为什么不能由页面自己请求:Chromium 要求发起请求的那一刻文档既已聚焦、又处于用户激活状态,而这里的请求必须
685
+ * 先过一次 IPC(页面 → 启动器聚焦 → 页面)—— 跨过这一步之后,页面自己发出的请求就不再被认可。操作者
686
+ * 2026-10-12 的反馈「并没有成功,光标没有被锁住」正是这个原因,而自检里唯一稳定拿到锁的那条路径,就是主进程用
687
+ * `userGesture: true` 替页面请求(`locked: true`,见下面的实测)。
688
+ *
689
+ * 取锁脚本还把 `requestPointerLock()` 的 promise **交出来**,而不是自己吞掉:它是"浏览器答复了"的时刻
690
+ * (授予或拒绝),而不是"脚本执行了"的时刻 —— 启动器要等的正是它,才能在授权迟到(请求发出之后这次旋转已经
691
+ * 结束)时把锁收回(见下面那个 `.then`)。返回 `undefined` 的老版 Chromium 等于"立刻答复",判断退化成
692
+ * "脚本已执行",也是安全的。
693
+ */
694
+ const ORBIT_LOCK_JS = '(function () {'
695
+ + ' var c = document.querySelector("canvas");'
696
+ + ' if (!c || typeof c.requestPointerLock !== "function") return "no-canvas";'
697
+ + ' try { var r = c.requestPointerLock(); if (r && typeof r.catch === "function") r.catch(function () {}); return r; }'
698
+ + ' catch (error) { return String(error && error.message); }'
699
+ + ' })()';
700
+ /**
701
+ * 交回光标的脚本 —— **无条件**调用 `exitPointerLock()`。
702
+ *
703
+ * 上一版是 `if (document.pointerLockElement) document.exitPointerLock()`,而"交回"恰恰发生在锁还在路上的
704
+ * 时候:页面松手 → 这条 IPC → 启动器发这个脚本,而浏览器可能尚未把锁授予渲染进程(`pointerLockElement`
705
+ * 还是 null)。那一刻这一次释放被自己的判断吞掉,锁随后落地、页面以为没有锁、启动器以为交回过了 —— 操作者
706
+ * 的两句话就是它的两个面:「右键点击后菜单点击没反应」(点击的投递目标是那个被锁的元素,到不了菜单项)和
707
+ * 「没有在拖动,却把右键锁住了」。规范上没锁时 `exitPointerLock()` 什么都不做,所以无条件调用是免费的。
708
+ */
709
+ const ORBIT_UNLOCK_JS = '(function () {'
710
+ + ' if (typeof document.exitPointerLock === "function") document.exitPointerLock();'
711
+ + ' return document.pointerLockElement === null;'
712
+ + ' })()';
713
+
714
+ /** 交回光标。旋转结束、以及授权迟到时都走这里,缺一次就是一把没人释放的锁。 */
715
+ const releaseOrbitLock = () => {
716
+ if (win.isDestroyed()) return;
717
+ try {
718
+ win.webContents.executeJavaScript(ORBIT_UNLOCK_JS, true).catch(() => {});
719
+ } catch (_error) { /* the window is going away */ }
720
+ };
721
+
722
+ ipcMain.on('vrmpet3d:orbit', (event, active) => {
723
+ if (win.isDestroyed()) return;
724
+ if (active) {
725
+ orbitFocus = true;
726
+ win.setFocusable(true);
727
+ win.focus();
728
+ /**
729
+ * 聚焦之后再请求,并且晚一拍:`focus()` 到渲染进程真正成为聚焦文档之间有一个窗口消息的往返,
730
+ * 早于此发出的请求就是那条 "not valid for pointer lock"。
731
+ */
732
+ setTimeout(() => {
733
+ if (win.isDestroyed() || !orbitFocus) return;
734
+ win.webContents.executeJavaScript(ORBIT_LOCK_JS, true).then(() => {
735
+ /**
736
+ * 浏览器答复时才检查"这次旋转还成立吗"。
737
+ *
738
+ * 页面对同一种迟到也有一道网(它的 `pointerlockchange`:锁落地时 `orbitAsked` 已是 false 就当场交回),
739
+ * 但启动器这一侧的问题是"我请求了一把没人要的锁",所以它自己也得能收回。两层都留着:卡住的是**全局
740
+ * 输入状态**,一次误判就是鼠标被扣住,不值得只靠一层。
741
+ */
742
+ if (!orbitFocus) releaseOrbitLock();
743
+ }).catch(() => { /* 被拒或页面正在退出:没有锁要交回 */ });
744
+ }, 30);
745
+ return;
746
+ }
747
+ orbitFocus = false;
748
+ win.setFocusable(false);
749
+ if (win.isFocused()) win.blur();
750
+ win.setAlwaysOnTop(true, 'screen-saver');
751
+ releaseOrbitLock();
752
+ });
753
+ ipcMain.on('vrmpet3d:quit', () => app.quit());
754
+ win.on('closed', () => app.quit());
755
+
756
+ // The cursor is read from the OS rather than from page events: a click-through window
757
+ // receives no pointer events at all outside the pet, so events cannot drive the gaze,
758
+ // and polling also tells us when to stop being click-through.
759
+ //
760
+ // None of that applies to the settings window: it is an ordinary window that receives its own
761
+ // events, so polling the cursor would only add a timer whose answer nothing consumes — and
762
+ // `applyInteractivity` would make a settings form click-through, which is the opposite of usable.
763
+ if (!isConfig) cursorTimer = setInterval(() => {
764
+ if (win.isDestroyed()) return;
765
+ // The self-test drives the page with synthetic cursors; a real one arriving between them
766
+ // would overwrite the answer being measured.
767
+ if (probing) return;
768
+ const cursor = screen.getCursorScreenPoint();
769
+ applyInteractivity(cursor);
770
+ sendCursor(cursor);
771
+ }, Math.max(4, Math.round(1000 / CURSOR_HZ)));
772
+
773
+ if (!options.url) throw new Error('no --url given');
774
+ await win.loadURL(options.url);
775
+ report.checks.loaded = true;
776
+
777
+ /**
778
+ * Watch whether the PAGE is still drawing, and say so when it stops.
779
+ *
780
+ * A single throw inside the page's frame loop ends the loop: the window stays open, the last
781
+ * rectangle stays reported, and from the outside it is indistinguishable from a pet that is simply
782
+ * not painted — which is exactly how "I turned it on and nothing happened" stayed silent the first
783
+ * time. The page publishes its frame count and its last error; this asks for them every two
784
+ * seconds and reports a stalled loop as an error the host and the page's own note can show.
785
+ */
786
+ if (!isConfig) setInterval(async () => {
787
+ if (win.isDestroyed() || probing) return;
788
+ const state = await readPageState(win);
789
+ lastPageState = state || lastPageState;
790
+ if (!state || typeof state.frames !== 'number') {
791
+ // No state object at all: the page's mount never got as far as publishing, which is a
792
+ // different fault from a loop that stopped, and worth saying out loud.
793
+ if (!report.errors.some((entry) => entry.startsWith('the page never published'))) {
794
+ report.errors.push('the page never published its state: the frame loop did not start');
795
+ }
796
+ announce();
797
+ return;
798
+ }
799
+ if (state.frames > lastSeenFrames) {
800
+ lastSeenFrames = state.frames;
801
+ stalledChecks = 0;
802
+ announce();
803
+ return;
804
+ }
805
+ stalledChecks += 1;
806
+ // Three checks with no new frame: the loop is gone, not slow.
807
+ if (stalledChecks === 3) {
808
+ const reason = state.lastError ? `: ${state.lastError}` : '';
809
+ if (!report.errors.some((entry) => entry.startsWith('the page stopped drawing'))) {
810
+ report.errors.push(`the page stopped drawing at frame ${state.frames}${reason}`);
811
+ }
812
+ }
813
+ announce();
814
+ }, 2000);
815
+
816
+ /**
817
+ * The settings window writes its self-test HERE, not on a message from the page.
818
+ *
819
+ * The pet page drives its own readiness through the preload bridge, which is what the pet self-test
820
+ * waits for. The settings page reads everything it shows over the host's HTTP routes and announces
821
+ * nothing of its own — the bridge it now has exists for the path picker (`vrmpet3d:pick-path`) — so
822
+ * waiting for a message it will never send is how the first version of this mode produced no report at
823
+ * all. The delay is for the page's own fetches, and any error it throws in the meantime is already
824
+ * captured through the console relay.
825
+ */
826
+ if (isConfig && options.selfTest) {
827
+ setTimeout(() => finishSelfTest(win), 1500);
828
+ }
829
+ } catch (error) {
830
+ report.errors.push('launcher failed: ' + (error && error.message));
831
+ writeSelfTest(1);
832
+ app.quit();
833
+ }
834
+ });
835
+
836
+ /**
837
+ * Prove the window really is what it claims, then exit.
838
+ *
839
+ * Used by `scripts/verify-desktop.mjs`, which is the only automated check of the real
840
+ * launcher: the page is asked for pixels (`capturePage`) and the alpha channel is counted,
841
+ * because "transparent: true was accepted" is not evidence of anything.
842
+ */
843
+ async function finishSelfTest(win) {
844
+ try {
845
+ await new Promise((resolve) => setTimeout(resolve, 900));
846
+ const image = await win.webContents.capturePage();
847
+ const size = image.getSize();
848
+ const bitmap = image.toBitmap(); // BGRA
849
+ let opaque = 0;
850
+ let translucent = 0;
851
+ let clear = 0;
852
+ for (let i = 3; i < bitmap.length; i += 4) {
853
+ const alpha = bitmap[i];
854
+ if (alpha === 0) clear += 1;
855
+ else if (alpha === 255) opaque += 1;
856
+ else translucent += 1;
857
+ }
858
+ report.checks.capture = {
859
+ size: size,
860
+ pixels: bitmap.length / 4,
861
+ opaquePixels: opaque,
862
+ translucentPixels: translucent,
863
+ transparentPixels: clear,
864
+ };
865
+ report.checks.rendersPixels = opaque + translucent > 500;
866
+ report.checks.hasTransparency = clear > 500;
867
+ /**
868
+ * The screenshot, beside the report.
869
+ *
870
+ * Pixel COUNTS prove that something was drawn; "the transparent parts of the clothes are black" is a
871
+ * claim about a picture, and the only thing that can settle it is the picture. Written next to the
872
+ * report so a probe leaves evidence a person can open, and taken BEFORE the synthetic cursors below so
873
+ * what is on disk is the pet at rest rather than mid-throw.
874
+ */
875
+ try {
876
+ const pngPath = options.selfTest.replace(/\.json$/i, '') + '.png';
877
+ fs.writeFileSync(pngPath, image.toPNG());
878
+ report.checks.captureFile = pngPath;
879
+ } catch (error) {
880
+ report.errors.push('the capture could not be written: ' + error.message);
881
+ }
882
+ // What the CANVAS holds, independently of what the window composited. If these two disagree,
883
+ // the fault is compositing; if the canvas is empty as well, nothing was drawn at all.
884
+ try {
885
+ const raw = await win.webContents.executeJavaScript('JSON.stringify(window.__VRMPET3D_DESKTOP_SCAN__ ? window.__VRMPET3D_DESKTOP_SCAN__() : null)');
886
+ report.checks.canvasPixels = raw ? JSON.parse(raw) : null;
887
+ } catch (error) {
888
+ report.errors.push('the canvas could not be scanned: ' + error.message);
889
+ }
890
+ /**
891
+ * The RENDERER's pixels, as a file.
892
+ *
893
+ * `capturePage()` above goes through window compositing, and a transparent always-on-top window can come
894
+ * back empty (measured: 353 430 pixels all fully transparent while the canvas held 58 938 painted ones).
895
+ * This asks the page for its own framebuffer instead — the only image that can settle a report about how
896
+ * something LOOKS — and the page composites it over grey, so "transparent" and "black" are different
897
+ * pixels in the file.
898
+ */
899
+ try {
900
+ const raw = await win.webContents.executeJavaScript(
901
+ 'JSON.stringify(window.__VRMPET3D_DESKTOP_SNAPSHOT__ ? (function () {'
902
+ + ' var shot = window.__VRMPET3D_DESKTOP_SNAPSHOT__(); var png = shot && shot.png;'
903
+ + ' if (shot) { shot.png = null; } return { fact: shot, png: png }; }()) : null)',
904
+ );
905
+ const shot = raw ? JSON.parse(raw) : null;
906
+ if (shot && shot.png) {
907
+ const pngPath = options.selfTest.replace(/\.json$/i, '') + '-canvas.png';
908
+ fs.writeFileSync(pngPath, Buffer.from(String(shot.png).split(',')[1], 'base64'));
909
+ report.checks.canvasFile = pngPath;
910
+ }
911
+ report.checks.canvasImage = shot ? shot.fact : null;
912
+ } catch (error) {
913
+ report.errors.push('the canvas image could not be written: ' + error.message);
914
+ }
915
+ /**
916
+ * And the same picture under a few deliberate changes, because ONE image cannot say WHICH material painted
917
+ * something: `no-outline` (three-vrm draws each MToon surface twice — the second is an inverted-hull
918
+ * outline, and a cut-out the outline pass still draws is how a see-through garment turns black),
919
+ * `alpha-test-0` (does the cut-out depend on the mask at all), `only-cloth` (is the black even the dress),
920
+ * and `as-loaded` to put visibility back.
921
+ */
922
+ for (const label of ['as-loaded', 'only-cloth', 'alpha-test-2', 'force-define']) {
923
+ try {
924
+ const raw = await win.webContents.executeJavaScript(
925
+ 'globalThis.__VRMPET3D_DESKTOP_VARIANT__ ? globalThis.__VRMPET3D_DESKTOP_VARIANT__(' + JSON.stringify(label) + ') : null',
926
+ );
927
+ const shot = raw ? JSON.parse(raw) : null;
928
+ if (!shot) continue;
929
+ const pngPath = options.selfTest.replace(/\.json$/i, '') + '-' + label + '.png';
930
+ if (shot.png) fs.writeFileSync(pngPath, Buffer.from(String(shot.png).split(',')[1], 'base64'));
931
+ report.checks['variant_' + label.replace(/-/g, '_')] = {
932
+ file: pngPath,
933
+ painted: shot.painted,
934
+ black: shot.black,
935
+ average: shot.average,
936
+ };
937
+ } catch (error) {
938
+ report.errors.push('variant ' + label + ' failed: ' + error.message);
939
+ }
940
+ }
941
+ report.checks.bounds = win.getBounds();
942
+ report.checks.petRect = petRect;
943
+ // What the PAGE thinks is happening. Without it, "the window opened but nothing is drawn"
944
+ // is indistinguishable from "the page never ran a second frame".
945
+ try {
946
+ // Two pages, two state objects. The pet page publishes its frames and hit test; the settings page
947
+ // publishes what it rendered (how many expressions and spring chains it found in the model), which
948
+ // is the only way a test can tell "the window opened" from "the window has something in it".
949
+ const globalName = options.config === true ? '__VRMPET3D_CONFIG_STATE__' : '__VRMPET3D_DESKTOP_STATE__';
950
+ const readState = async () => {
951
+ const raw = await win.webContents.executeJavaScript(
952
+ 'JSON.stringify(globalThis.' + globalName + ' || null)',
953
+ );
954
+ return raw ? JSON.parse(raw) : null;
955
+ };
956
+ const first = await readState();
957
+ report.pageState = first;
958
+ } catch (error) {
959
+ report.errors.push('the page state could not be read: ' + error.message);
960
+ }
961
+ // The pet probes only make sense for the pet: a settings window has no canvas to hit-test and no
962
+ // wheel-to-zoom to measure, and asking would fill the report with errors about a page that is
963
+ // behaving exactly as designed.
964
+ if (options.config !== true) {
965
+ await probeHitTest(win);
966
+ await probeWheel(win);
967
+ } else {
968
+ /**
969
+ * The 部位 probe runs on the page this window was opened with (the model's), because 部位 is one of the
970
+ * sections that lives there — the navigation below would leave it looking at a page without a part table.
971
+ * The 状态与动作 clip picker lives on the OTHER page, so its probe is injected by `probeOtherPage` after
972
+ * the route swap (see `extraScripts` there).
973
+ */
974
+ await probePartsEditor(win);
975
+ await probeOtherPage(win, report);
976
+ }
977
+ } catch (error) {
978
+ report.errors.push('self-test capture failed: ' + (error && error.message));
979
+ }
980
+ if (pageLog.length) report.pageLog = pageLog.slice(-20);
981
+ writeSelfTest(report.ok === false ? 1 : 0);
982
+ }
983
+
984
+ /**
985
+ * Read the OTHER settings page, and merge the two readouts into one.
986
+ *
987
+ * The settings window is TWO PAGES since 2026-10-11 (`/vrmpet3d/config` for 模型参数 and `/vrmpet3d/global` for 通用设置,
988
+ * operator: "模型参数和通用设置(原总设置)是两个单独的页面"), and a self-test that visited only the page it was
989
+ * launched on would leave half the settings unproven — including every control that lives on the other one. So
990
+ * the window is NAVIGATED to its sibling PAGE (the route, not a query parameter) and read again.
991
+ *
992
+ * The merge is KEYED, not naive: each page runs the same script and publishes the same shape, so a page reports
993
+ * 0/null for the sections it does not carry, and a shallow spread would overwrite the other page's real
994
+ * `speech.stateRows === 6` with this one's 0. The sibling page's own keys win where a section lives there;
995
+ * everything else comes from the page the window opened with.
996
+ *
997
+ * `sizes` and `wind` are in the second list for a reason worth naming: 大小/悬浮窗大小 and the four 鼠标互动
998
+ * controls are sections of the 通用设置 page, so the model page reports `sizes.controls === false` and
999
+ * `wind.rows === 0` — the same "0 for a section I do not carry" shape as above. The model page's own reading of
1000
+ * them is kept in `modelPage`, so nothing is thrown away by preferring the page that draws them.
1001
+ */
1002
+ /**
1003
+ * Navigate the settings window to its SIBLING page and hand back the state it publishes.
1004
+ *
1005
+ * Split out of `probeOtherPage` because two things need the other page: reading its published state (the merge
1006
+ * below) and driving the 状态与动作 clip picker, whose box sets live only there. Reading it requires the page to
1007
+ * have published, so this also waits the moment the page needs.
1008
+ */
1009
+ async function readOtherPageState(win, report) {
1010
+ const first = report.pageState;
1011
+ if (!first || first.page !== 'model') return { state: null, error: null };
1012
+ const current = win.webContents.getURL();
1013
+ /**
1014
+ * SWAP THE ROUTE rather than rewriting a string that may not be there.
1015
+ *
1016
+ * The launcher's own URLs name the page they open (`/vrmpet3d/config`, `/vrmpet3d/global`), but a self-test
1017
+ * (and the desktop suite) may open the page directly — so this reads the URL's own path and changes only that.
1018
+ * The first version of this replaced a `?panel=` query parameter that a URL without it never had, which
1019
+ * silently skipped the second reading and proved nothing; a route swap cannot go missing while the first page
1020
+ * is the one it was read from.
1021
+ */
1022
+ const target = (function () {
1023
+ try {
1024
+ const url = new URL(current);
1025
+ url.pathname = first.page === 'model' ? '/vrmpet3d/global' : '/vrmpet3d/config';
1026
+ // The model is a 模型参数 parameter: it must not ride along to the page that has no model picker.
1027
+ if (url.pathname === '/vrmpet3d/global') url.searchParams.delete('model');
1028
+ return url.toString();
1029
+ } catch (_error) {
1030
+ return current;
1031
+ }
1032
+ })();
1033
+ if (target === current) return { state: null, error: null };
1034
+ await win.loadURL(target);
1035
+ // The page publishes on its first settings snapshot; a moment here beats reading a half-built DOM.
1036
+ await new Promise((resolve) => setTimeout(resolve, 800));
1037
+ const raw = await win.webContents.executeJavaScript(
1038
+ 'JSON.stringify(globalThis.__VRMPET3D_CONFIG_STATE__ || null)',
1039
+ );
1040
+ const state = raw ? JSON.parse(raw) : null;
1041
+ if (!state) {
1042
+ report.errors.push('the 通用设置 page could not be read after navigating to it');
1043
+ return { state: null, error: 'not readable' };
1044
+ }
1045
+ return { state, error: null };
1046
+ }
1047
+
1048
+ async function probeOtherPage(win, report) {
1049
+ const GLOBAL_KEYS = ['speech', 'import', 'motionImport', 'gaze', 'motions', 'sizes', 'wind'];
1050
+ try {
1051
+ const first = report.pageState;
1052
+ if (!first || first.page !== 'model') return;
1053
+ const globalState = (await readOtherPageState(win, report)).state;
1054
+ if (!globalState) return;
1055
+ /**
1056
+ * AND THE CLIP PICKER IS DRIVEN HERE, on this page, which is the whole reason it exists.
1057
+ *
1058
+ * Operator report, 2026-10-12: "最后一项 idle/touch hair2.vrma 无法选中". `onMotionToggle` collected every
1059
+ * checked checkbox in the state's row, including each ticked clip's 凝视 switch — and a checkbox written
1060
+ * without a `value` attribute has the DOM default `'on'`, so a tick stored `on` as a clip id. Those junk
1061
+ * entries took slots in the per-state list and the clip the operator had just ticked was the one dropped.
1062
+ * The ids reach the host as a perfectly valid POST, so only the page itself can be asked.
1063
+ */
1064
+ await probeMotionPicker(win);
1065
+ const merged = Object.assign({}, first);
1066
+ for (const key of GLOBAL_KEYS) {
1067
+ if (globalState[key] !== undefined) merged[key] = globalState[key];
1068
+ }
1069
+ merged.page = globalState.page;
1070
+ merged.pages = [first.page, globalState.page];
1071
+ merged.modelPage = first;
1072
+ merged.globalPage = globalState;
1073
+ report.pageState = merged;
1074
+ } catch (error) {
1075
+ report.errors.push('the 通用设置 page could not be read: ' + (error && error.message));
1076
+ }
1077
+ }
1078
+
1079
+ /**
1080
+ * Drive the 部位 editor the way a user does, and report what the PAGE did with it.
1081
+ *
1082
+ * The settings window's self-test reads what the page rendered, which answers "the section is there" and nothing
1083
+ * about "the section works": a button that does nothing, a structure box that drops what was typed and a chip
1084
+ * whose mode toggle does not toggle all publish exactly the same counts. So this performs the four gestures in
1085
+ * order — 新增部位, type a structure and commit it, flip its match mode, remove the chip — and re-READS the page
1086
+ * state between them, because the page repaints the whole table on every change (a reference held across one is
1087
+ * stale, which is what the first version of this probe tripped over).
1088
+ *
1089
+ * Nothing here is asserted: the numbers travel to `scripts/verify-desktop.mjs`, where the suite decides.
1090
+ */
1091
+ async function probePartsEditor(win) {
1092
+ try {
1093
+ /**
1094
+ * The script is a FILE, not a template literal in this one.
1095
+ *
1096
+ * It used to be inline, and twice a stray backtick in a comment ended the literal early — which does not fail
1097
+ * a test, it breaks the LAUNCHER: `main.js` stops parsing, so the floating window cannot open and the only
1098
+ * symptom is an error dialog. Read from disk, the hazard is gone by construction, and the suite can
1099
+ * `node --check` the file before it spawns anything.
1100
+ */
1101
+ const script = fs.readFileSync(path.join(__dirname, 'probe-parts-editor.js'), 'utf8');
1102
+ const raw = await win.webContents.executeJavaScript(script);
1103
+ report.partsEdit = raw ? JSON.parse(raw) : null;
1104
+ } catch (error) {
1105
+ report.errors.push('the 部位 editor could not be driven: ' + (error && error.message));
1106
+ }
1107
+ }
1108
+
1109
+ /**
1110
+ * Drive the 状态与动作 clip picker, and report what it collected.
1111
+ *
1112
+ * Same file-not-literal rule as `probePartsEditor` above, and the same reason: a broken template literal in
1113
+ * `main.js` breaks the launcher rather than a test. The probe ticks the last clip in 空闲 and reports what the
1114
+ * page thinks is selected against what the core will keep, which is the pair that exposes a junk clip id.
1115
+ */
1116
+ async function probeMotionPicker(win) {
1117
+ try {
1118
+ const script = fs.readFileSync(path.join(__dirname, 'probe-motion-picker.js'), 'utf8');
1119
+ const raw = await win.webContents.executeJavaScript(script);
1120
+ report.motionPick = raw ? JSON.parse(raw) : null;
1121
+ } catch (error) {
1122
+ report.errors.push('the clip picker could not be driven: ' + (error && error.message));
1123
+ }
1124
+ }
1125
+
1126
+ /**
1127
+ * Prove the per-pixel hit test without a mouse.
1128
+ *
1129
+ * The policy under test — "clicks land only on pixels we painted" — cannot be checked by moving a
1130
+ * real cursor from a script, so the launcher drives the page with synthetic cursor positions
1131
+ * instead: several points inside the pet's box, and one far away. The page answers with its own
1132
+ * verdict for each, and the two assertions that matter follow: at least one point inside the box IS
1133
+ * painted (the pet takes clicks somewhere) and the far point is not (the transparent desktop passes
1134
+ * them through). The real cursor stream is paused for the duration, or it would overwrite the
1135
+ * answers being measured.
1136
+ */
1137
+ async function probeHitTest(win) {
1138
+ if (!petRect || petRect.width <= 0 || petRect.height <= 0) {
1139
+ report.checks.perPixelHitTest = false;
1140
+ report.errors.push('the pet never reported a rectangle, so its hit area could not be probed');
1141
+ return;
1142
+ }
1143
+ probing = true;
1144
+ try {
1145
+ const fractions = [[0.5, 0.5], [0.5, 0.62], [0.42, 0.55], [0.58, 0.55], [0.5, 0.8]];
1146
+ const answers = [];
1147
+ for (const [fx, fy] of fractions) {
1148
+ const point = {
1149
+ x: petRect.left + Math.round(petRect.width * fx),
1150
+ y: petRect.top + Math.round(petRect.height * fy),
1151
+ };
1152
+ win.webContents.send('vrmpet3d:cursor', cursorMessage(win, point, false));
1153
+ await new Promise((resolve) => setTimeout(resolve, 140));
1154
+ // The page's own numbers for this probe, not only its verdict: "no clicks land on the pet"
1155
+ // is indistinguishable from "the readback found a transparent pixel" without them, and the
1156
+ // two have different causes.
1157
+ let detail = null;
1158
+ try {
1159
+ const raw = await win.webContents.executeJavaScript('JSON.stringify(window.__VRMPET3D_DESKTOP_STATE__ || null)');
1160
+ const snapshot = raw ? JSON.parse(raw) : null;
1161
+ detail = snapshot ? { lastHit: snapshot.lastHit, hitReads: snapshot.hitReads, over: snapshot.over } : null;
1162
+ } catch (_error) {
1163
+ detail = null;
1164
+ }
1165
+ answers.push({ at: [fx, fy], over: overPet, detail: detail });
1166
+ }
1167
+ // Far outside the pet: the top-left of the screen, where nothing is drawn.
1168
+ win.webContents.send('vrmpet3d:cursor', cursorMessage(win, { x: 4, y: 4 }, false));
1169
+ await new Promise((resolve) => setTimeout(resolve, 140));
1170
+ const overFar = overPet;
1171
+ const insidePainted = answers.filter((answer) => answer.over).length;
1172
+ report.checks.hitTest = { inside: answers, insidePainted: insidePainted, overFar: overFar };
1173
+ report.checks.perPixelHitTest = insidePainted > 0 && overFar === false;
1174
+
1175
+ /*
1176
+ * The right-click MENU is DOM, not canvas, so it has its own path into the hit test: none of its pixels
1177
+ * are in the framebuffer, and if the page did not answer for its rectangle too the menu would be
1178
+ * click-through and unusable. Opened through the page's own hook — which places it at the pet's centre,
1179
+ * because a script cannot move a real mouse — and probed with a synthetic cursor on it.
1180
+ */
1181
+ let menuBoxRect = null;
1182
+ let overMenu = null;
1183
+ try {
1184
+ await win.webContents.executeJavaScript('window.__VRMPET3D_DESKTOP_MENU__ && window.__VRMPET3D_DESKTOP_MENU__(true)');
1185
+ await new Promise((resolve) => setTimeout(resolve, 200));
1186
+ const raw = await win.webContents.executeJavaScript('JSON.stringify(window.__VRMPET3D_DESKTOP_STATE__ || null)');
1187
+ const snapshot = raw ? JSON.parse(raw) : null;
1188
+ const origin = (snapshot && snapshot.screen) || { x: 0, y: 0 };
1189
+ const box = snapshot && snapshot.menu ? snapshot.menu.rect : null;
1190
+ // The page reports the menu in VIRTUAL frame coordinates; this process needs it in screen
1191
+ // coordinates, the same system every `vrmpet3d:rect` arrives in, because `followPet` sizes the
1192
+ // window to hold it.
1193
+ menuBoxRect = box
1194
+ ? {
1195
+ left: origin.x + Number(box.left || 0),
1196
+ top: origin.y + Number(box.top || 0),
1197
+ width: Number(box.width || 0),
1198
+ height: Number(box.height || 0),
1199
+ }
1200
+ : null;
1201
+ if (menuBoxRect) {
1202
+ win.webContents.send('vrmpet3d:cursor', cursorMessage(win, {
1203
+ x: menuBoxRect.left + Math.round(menuBoxRect.width / 2),
1204
+ y: menuBoxRect.top + Math.round(menuBoxRect.height / 2),
1205
+ }, false));
1206
+ await new Promise((resolve) => setTimeout(resolve, 160));
1207
+ overMenu = overPet;
1208
+ }
1209
+ report.checks.menu = {
1210
+ open: Boolean(snapshot && snapshot.menu && snapshot.menu.open),
1211
+ rect: menuBoxRect,
1212
+ overMenu: overMenu,
1213
+ /**
1214
+ * Whether the page built that menu from the SHARED module (`lib/menu.js`) rather than from markup of
1215
+ * its own. Recorded because the failure is silent in the other direction: a page whose menu module never
1216
+ * loaded still opens *something* — or, as the panel did once, throws deep inside a click — and only this
1217
+ * flag says the right code is behind the rectangle.
1218
+ */
1219
+ module: Boolean(snapshot && snapshot.menu && snapshot.menu.module),
1220
+ };
1221
+ report.checks.menuHitTest = Boolean(menuBoxRect) && overMenu === true;
1222
+ } catch (error) {
1223
+ report.checks.menuHitTest = false;
1224
+ report.errors.push('the right-click menu could not be probed: ' + error.message);
1225
+ } finally {
1226
+ try {
1227
+ await win.webContents.executeJavaScript('window.__VRMPET3D_DESKTOP_MENU__ && window.__VRMPET3D_DESKTOP_MENU__(false)');
1228
+ } catch (_error) {
1229
+ // Closing it is tidiness; the window is about to exit either way.
1230
+ }
1231
+ }
1232
+ } catch (error) {
1233
+ report.errors.push('the hit test could not be probed: ' + error.message);
1234
+ } finally {
1235
+ probing = false;
1236
+ }
1237
+
1238
+ /**
1239
+ * 独立桌宠的旋转到底能不能锁住光标 —— 走**生产路径**实测,因为上一版测错了地方。
1240
+ *
1241
+ * 上一版由主进程直接 `executeJavaScript(requestPointerLock, true)`,于是它成功了,而生产里页面自己发的请求
1242
+ * 失败 —— 操作者 2026-10-12:「并没有成功,光标没有被锁住」。差别就在那个 `userGesture` 标记,以及请求发出时
1243
+ * 文档是否已经聚焦。所以这次从页面自己的桥接口出发:`vrmpet3dDesktop.orbit(true)` → preload → 本进程的
1244
+ * `vrmpet3d:orbit` 处理(设为可聚焦、聚焦、用 `userGesture` 请求锁)→ 回读 `document.pointerLockElement`,
1245
+ * 再 `orbit(false)` 并确认锁被放开。这就是页面在生产里走的每一个环节。
1246
+ */
1247
+ try {
1248
+ const measured = await win.webContents.executeJavaScript(`(function () {
1249
+ if (!window.vrmpet3dDesktop || typeof window.vrmpet3dDesktop.orbit !== 'function') {
1250
+ return Promise.resolve({ noBridge: true });
1251
+ }
1252
+ window.vrmpet3dDesktop.orbit(true);
1253
+ return new Promise(function (resolve) {
1254
+ setTimeout(function () {
1255
+ var canvas = document.querySelector('canvas');
1256
+ var locked = document.pointerLockElement === canvas;
1257
+ var hasFocus = document.hasFocus();
1258
+ window.vrmpet3dDesktop.orbit(false);
1259
+ setTimeout(function () {
1260
+ resolve({
1261
+ supported: true,
1262
+ locked: locked,
1263
+ hasFocus: hasFocus,
1264
+ releasedAfter: document.pointerLockElement === null,
1265
+ });
1266
+ }, 250);
1267
+ }, 400);
1268
+ });
1269
+ })()`, true);
1270
+ report.checks.pointerLock = measured;
1271
+ report.checks.pointerLockOrbit = Boolean(measured) && measured.locked === true;
1272
+ } catch (error) {
1273
+ report.checks.pointerLock = { error: String(error && error.message) };
1274
+ report.checks.pointerLockOrbit = false;
1275
+ }
1276
+
1277
+ /**
1278
+ * 真正要断言的那件事:**右键拖动时,视角跟着走。**
1279
+ *
1280
+ * 上一版把断言押在"窗口能不能拿到指针锁"上,而操作者机器上的实测是
1281
+ * `{"locked":false,"hasFocus":false}` —— Windows 的前台窗口规则不允许这个刻意不聚焦的窗口抢焦点,所以锁根本
1282
+ * 拿不到。机制不可靠,**能看见的行为**才是要钉住的:按下右键、把指针移到远离宠物的位置,视角必须继续转;松开
1283
+ * 之后旋转必须结束。
1284
+ *
1285
+ * 这里用 `sendInputEvent` 发真实的鼠标事件(和滚轮探测同一条路),坐标逐步远离宠物中心 —— 覆盖"光标离开宠物
1286
+ * 像素"这一段,也就是窗口此前会立刻穿透、指针事件断掉的那一段。
1287
+ */
1288
+ try {
1289
+ const view = async () => JSON.parse(
1290
+ (await win.webContents.executeJavaScript('JSON.stringify((window.__VRMPET3D_DESKTOP_STATE__ || {}).view || null)')) || 'null',
1291
+ );
1292
+ /**
1293
+ * `sendInputEvent` 的坐标是**窗口客户区**坐标,不是屏幕坐标 —— 第一版把页面上报的屏幕矩形直接喂了进去,
1294
+ * 事件落在窗口之外、没有目标,于是"视角没转"看起来像功能坏了。中心点因此从页面自己的 DOM 里问。
1295
+ */
1296
+ const box = JSON.parse((await win.webContents.executeJavaScript(
1297
+ 'JSON.stringify((function () { var c = document.querySelector("canvas"); if (!c) return null;'
1298
+ + ' var r = c.getBoundingClientRect();'
1299
+ + ' return { x: r.left + r.width / 2, y: r.top + r.height / 2, w: r.width, h: r.height }; })())',
1300
+ )) || 'null');
1301
+ if (!box) throw new Error('the page has no canvas to right-drag');
1302
+ const centre = { x: Math.round(box.x), y: Math.round(box.y) };
1303
+ const radius = Math.max(6, Math.round(Math.min(box.w, box.h) / 6));
1304
+ const before = await view();
1305
+ win.webContents.sendInputEvent({ type: 'mouseDown', x: centre.x, y: centre.y, button: 'right', clickCount: 1 });
1306
+ // 三段移动,一段比一段远:出宠物中心、出宠物像素、贴近窗口边缘。
1307
+ const path = [
1308
+ { x: centre.x + radius, y: centre.y + Math.round(radius / 2) },
1309
+ { x: centre.x + radius * 2, y: centre.y + radius },
1310
+ { x: centre.x + radius * 3, y: centre.y + Math.round(radius * 1.5) },
1311
+ ];
1312
+ for (const at of path) {
1313
+ win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(at.x), y: Math.round(at.y), button: 'right' });
1314
+ await new Promise((resolve) => setTimeout(resolve, 40));
1315
+ }
1316
+ const turned = await view();
1317
+ win.webContents.sendInputEvent({ type: 'mouseUp', x: Math.round(path[path.length - 1].x), y: Math.round(path[path.length - 1].y), button: 'right', clickCount: 1 });
1318
+ await new Promise((resolve) => setTimeout(resolve, 80));
1319
+ const after = JSON.parse(
1320
+ (await win.webContents.executeJavaScript('JSON.stringify(window.__VRMPET3D_DESKTOP_STATE__ || null)')) || 'null',
1321
+ );
1322
+ const yawMoved = Boolean(before && turned) && Math.abs(turned.yaw - before.yaw) > 1e-6;
1323
+ const stoppedAfterRelease = Boolean(after) && after.rect && after.rect.orbiting === false;
1324
+ report.checks.orbit = { before: before && before.yaw, during: turned && turned.yaw, yawMoved: yawMoved, stoppedAfterRelease: stoppedAfterRelease };
1325
+ report.checks.orbitFollowsThePointer = yawMoved && stoppedAfterRelease;
1326
+ } catch (error) {
1327
+ report.checks.orbit = { error: String(error && error.message) };
1328
+ report.checks.orbitFollowsThePointer = false;
1329
+ } finally {
1330
+ // 立刻恢复这个窗口平时的样子:不可聚焦、不持有焦点、置顶。
1331
+ try {
1332
+ win.setFocusable(false);
1333
+ if (win.isFocused()) win.blur();
1334
+ win.setAlwaysOnTop(true, 'screen-saver');
1335
+ } catch (_error) { /* the window is about to exit */ }
1336
+ }
1337
+ }
1338
+
1339
+ /**
1340
+ * Prove the wheel in the real window, with a real wheel event.
1341
+ *
1342
+ * `sendInputEvent` puts a wheel event into the renderer the way a mouse would, so this exercises the
1343
+ * whole path — the page's wheel listener, the anchored resize, and the rect report that refreshes the
1344
+ * launcher's interactivity hold. It also measures the two things the user asked for: the size grows,
1345
+ * and the point under the cursor stays put (the pet is not walked out from under the pointer).
1346
+ */
1347
+ async function probeWheel(win) {
1348
+ if (!petRect || petRect.width <= 0 || petRect.height <= 0) {
1349
+ report.checks.wheelZoom = false;
1350
+ report.errors.push('the pet never reported a rectangle, so the wheel could not be probed');
1351
+ return;
1352
+ }
1353
+ probing = true;
1354
+ try {
1355
+ // Park the pet where a resize has room to grow. The probe measures whether the point under the
1356
+ // cursor STAYS PUT during the resize, and a pet sitting against the frame's edge has that answer
1357
+ // bent by the frame's own clamp — which made this check depend on where the operator's last drag
1358
+ // left the pet (it went red on 2026-10-08 for exactly that reason, with every number correct).
1359
+ try {
1360
+ await win.webContents.executeJavaScript(
1361
+ 'globalThis.__VRMPET3D_DESKTOP_PLACE__ && globalThis.__VRMPET3D_DESKTOP_PLACE__(0.35, 0.35)',
1362
+ );
1363
+ await new Promise((resolve) => setTimeout(resolve, 220));
1364
+ } catch (_error) {
1365
+ // The probe still runs; only the anchor half of it is affected.
1366
+ }
1367
+ const before = await readPageState(win);
1368
+ if (!before) throw new Error('the page state could not be read');
1369
+ const sizeBefore = before.placement ? before.placement.width : null;
1370
+ /**
1371
+ * A point on the pet — found by ASKING the page, not by dividing the box.
1372
+ *
1373
+ * This used to be a fixed 30 % / 70 % of the box, which was painted when the built-in procedural avatar
1374
+ * filled its box and is NOT painted on a humanoid VRM, whose silhouette is narrower than the box. The
1375
+ * operator removed that avatar on 2026-10-10, and the very next run reported `wheelZoomOk=false` with every
1376
+ * number correct — because the cursor was over empty space and the window, by design, ignores a wheel that
1377
+ * is not over painted pixels. So the candidates are tried in order and the page's own hit test picks one.
1378
+ */
1379
+ const candidates = [[0.5, 0.5], [0.5, 0.62], [0.5, 0.38], [0.35, 0.5], [0.65, 0.5], [0.3, 0.7]];
1380
+ let at = null;
1381
+ for (const [across, down] of candidates) {
1382
+ const probe = {
1383
+ x: petRect.left + Math.round(petRect.width * across),
1384
+ y: petRect.top + Math.round(petRect.height * down),
1385
+ };
1386
+ win.webContents.send('vrmpet3d:cursor', cursorMessage(win, probe, false));
1387
+ await new Promise((resolve) => setTimeout(resolve, 120));
1388
+ const state = await readPageState(win);
1389
+ // overModel is the page's own hit test against PAINTED PIXELS, which is exactly the question being
1390
+ // asked; it lives under hit in the published state.
1391
+ if (state && state.hit && state.hit.overModel === true) { at = probe; break; }
1392
+ }
1393
+ if (!at) {
1394
+ // Nothing painted anywhere the candidates look: say so rather than reporting a wheel failure.
1395
+ throw new Error('no painted point could be found on the pet to aim the wheel at');
1396
+ }
1397
+ win.webContents.send('vrmpet3d:cursor', cursorMessage(win, at, false));
1398
+ await new Promise((resolve) => setTimeout(resolve, 140));
1399
+ const acrossBefore = (at.x - petRect.left) / petRect.width;
1400
+ const downBefore = (at.y - petRect.top) / petRect.height;
1401
+
1402
+ for (let notch = 0; notch < 3; notch += 1) {
1403
+ // `sendInputEvent` takes WINDOW coordinates, and this process knows the window's origin exactly,
1404
+ // so ask IT rather than the page's cached `windowOrigin`: while a probe runs the cursor stream is
1405
+ // paused, so the page's copy is stale and the injected point landed outside the window — the
1406
+ // notch was dropped and the check reported "the wheel does nothing".
1407
+ const origin = win.isDestroyed() ? { x: 0, y: 0 } : win.getBounds();
1408
+ win.webContents.sendInputEvent({
1409
+ type: 'mouseWheel',
1410
+ x: at.x - origin.x,
1411
+ y: at.y - origin.y,
1412
+ deltaY: 120,
1413
+ canScroll: false,
1414
+ });
1415
+ await new Promise((resolve) => setTimeout(resolve, 180));
1416
+ }
1417
+ const after = await readPageState(win);
1418
+ if (!after) throw new Error('the page state could not be read after the notches');
1419
+ const rectAfter = after.rect || petRect;
1420
+ const acrossAfter = rectAfter.width ? (at.x - rectAfter.left) / rectAfter.width : null;
1421
+ const downAfter = rectAfter.height ? (at.y - rectAfter.top) / rectAfter.height : null;
1422
+ report.checks.wheelZoom = {
1423
+ sizeBefore: sizeBefore,
1424
+ sizeAfter: after.placement ? after.placement.width : null,
1425
+ anchor: at,
1426
+ relativeBefore: [Number(acrossBefore.toFixed(4)), Number(downBefore.toFixed(4))],
1427
+ relativeAfter: [Number((acrossAfter || 0).toFixed(4)), Number((downAfter || 0).toFixed(4))],
1428
+ zoomingReported: Boolean(after.zoomHolding),
1429
+ stillOver: overPet,
1430
+ };
1431
+ const grew = report.checks.wheelZoom.sizeAfter > report.checks.wheelZoom.sizeBefore;
1432
+ /**
1433
+ * What has to hold in the real window.
1434
+ *
1435
+ * The pet is parked on the bottom of the work area, so the vertical anchor CANNOT be honoured
1436
+ * exactly: keeping the point under the cursor still would push the box's bottom below the screen,
1437
+ * and the clamp wins. That is the documented edge case, and the property that actually matters is
1438
+ * the one measured here — the cursor is still on the pet after the notches, so the next notch
1439
+ * still reaches it. The exact anchoring is asserted on a placement with room around it, in the
1440
+ * core suite, where the clamp cannot interfere.
1441
+ */
1442
+ const drift = (beforeValue, afterValue) => (afterValue === null ? 1 : Math.abs(afterValue - beforeValue));
1443
+ const anchored = acrossAfter !== null && downAfter !== null && drift(acrossBefore, acrossAfter) < 0.05
1444
+ && drift(downBefore, downAfter) < 0.15;
1445
+ report.checks.wheelZoomOk = grew && anchored && overPet === true;
1446
+ } catch (error) {
1447
+ report.checks.wheelZoomOk = false;
1448
+ report.errors.push('the wheel could not be probed: ' + error.message);
1449
+ } finally {
1450
+ probing = false;
1451
+ }
1452
+ }
1453
+
1454
+ /** Where the PAGE got to, readable even when it never published a state at all. */
1455
+ let lastPageDiagnostics = null;
1456
+
1457
+ /** The page's own account of itself, as an object, or null. */
1458
+ async function readPageState(win) {
1459
+ try {
1460
+ /**
1461
+ * …and WHY it might have none, read from the window itself rather than from the page's script.
1462
+ *
1463
+ * The page's inline glue returns silently when the preload BRIDGE is missing (`if (!bridge) return;`) and never calls
1464
+ * `bridge.ready` when `config()` does not resolve — and both leave `__VRMPET3D_DESKTOP_STATE__` ABSENT: no pet, no error, and a
1465
+ * status line repeating the last good frames. That is what the operator's window did on 2026-10-15 while a window this session
1466
+ * launched by hand worked.
1467
+ *
1468
+ * The four reads below are deliberately INDEPENDENT of the page's own script: the bridge, the sources' global, and the page's
1469
+ * `document.title` (which the glue advances one step at a time) all answer even when the glue returned early. They are kept in
1470
+ * `lastPageDiagnostics` instead of being returned, because the caller wants the page STATE — and dropping them whenever there is
1471
+ * no state is exactly how the first version of this measurement managed to tell us nothing at all.
1472
+ */
1473
+ const raw = await win.webContents.executeJavaScript(
1474
+ 'JSON.stringify({'
1475
+ + 'state: window.__VRMPET3D_DESKTOP_STATE__ || null,'
1476
+ + 'bridge: typeof window.vrmpet3dDesktop === "object" && window.vrmpet3dDesktop !== null,'
1477
+ + 'sources: Boolean(globalThis.__VRMPET3D_DESKTOP__),'
1478
+ + 'title: String(document.title || ""),'
1479
+ + '})',
1480
+ );
1481
+ const parsed = raw ? JSON.parse(raw) : null;
1482
+ if (!parsed) return null;
1483
+ lastPageDiagnostics = {
1484
+ bridge: parsed.bridge === true,
1485
+ sources: parsed.sources === true,
1486
+ title: parsed.title || null,
1487
+ };
1488
+ return parsed.state;
1489
+ } catch (error) {
1490
+ lastPageDiagnostics = {
1491
+ bridge: null,
1492
+ sources: null,
1493
+ title: null,
1494
+ readError: String(error && error.message ? error.message : error),
1495
+ };
1496
+ return null;
1497
+ }
1498
+ }
1499
+
1500
+ /**
1501
+ * Quit when the process that started us is gone.
1502
+ *
1503
+ * The host half kills this window on dispose, but a host that is killed rather than disposed
1504
+ * would otherwise leave a pet floating over the desktop with nothing left to control it.
1505
+ */
1506
+ function startWatchdog() {
1507
+ if (!options.parentPid) return;
1508
+ watchdogTimer = setInterval(() => {
1509
+ try {
1510
+ process.kill(options.parentPid, 0);
1511
+ } catch (_error) {
1512
+ app.quit();
1513
+ }
1514
+ }, WATCHDOG_MS);
1515
+ }
1516
+
1517
+ /**
1518
+ * The page's last published state, kept so `announce` can report the motion without being async.
1519
+ *
1520
+ * The watchdog reads it every two seconds anyway; the status line is written far more often than that, and
1521
+ * making the announcement await a page read would turn a status line into a round trip.
1522
+ */
1523
+ let lastPageState = null;
1524
+
1525
+ /**
1526
+ * The page's last READY report, kept because the watchdog announces without one.
1527
+ *
1528
+ * The page reports once, through `bridge.ready(info)`, and that is the only payload that can say `ready: true`. Every two seconds the
1529
+ * watchdog announces again for its own reasons and has no `info` — and `announce()` used to fall back to `report.page`, which is the
1530
+ * SELF-TEST's page and is null in a normal window. So a mounted, rendering pet was reported as `ready: false` two seconds after it
1531
+ * said otherwise, and the host's readout flip-flopped between "mounted" and "never mounted" — which is exactly how this session lost
1532
+ * an hour to a window that was working (measured 2026-10-15: `pageTitle` showed `… > ready:m1` in one read and `ready: false` in the
1533
+ * next, with the same window).
1534
+ */
1535
+ let lastReadyInfo = null;
1536
+
1537
+ /**
1538
+ * Tell the host half what the page is doing, on stdout, in one line it can parse.
1539
+ *
1540
+ * `VRMPET3D_STATUS_PREFIX` is duplicated in `lib/desktop.js`; the pair is asserted by the `desktop`
1541
+ * suite so the two cannot drift into silence. The payload carries the hit state as well, because
1542
+ * "the window is open but every click goes through it" is otherwise indistinguishable from a
1543
+ * working pet when read from the host.
1544
+ *
1545
+ * @param {object} [info] the page's own ready report, when this call is the first one
1546
+ */
1547
+ function announce(info) {
1548
+ /**
1549
+ * A bare announcement keeps the last REAL one instead of erasing it.
1550
+ *
1551
+ * See `lastReadyInfo`: the page reports once, the watchdog announces every two seconds without any report, and falling back to the
1552
+ * self-test's `report.page` (null in a normal window) made a working pet report `ready: false` on the next tick.
1553
+ */
1554
+ if (info) lastReadyInfo = info;
1555
+ const effective = info || lastReadyInfo;
1556
+ // The page's own notes AND this process's errors. Only the page's used to travel, which is why a
1557
+ // launcher-side failure showed up in the host as "running, never ready" with nothing to read.
1558
+ const pageErrors = effective && Array.isArray(effective.errors) ? effective.errors.filter(Boolean) : [];
1559
+ const ownErrors = Array.isArray(report.errors) ? report.errors.filter(Boolean) : [];
1560
+ const payload = {
1561
+ ready: effective ? Boolean(effective.ok) : Boolean(report.page && report.page.ok),
1562
+ webgl2: effective ? Boolean(effective.webgl2) : Boolean(report.page && report.page.webgl2),
1563
+ renderer: (effective && effective.renderer) || (report.page && report.page.renderer) || null,
1564
+ errors: [...pageErrors, ...ownErrors].slice(0, 3),
1565
+ /**
1566
+ * WHERE the page got to, which the fields above cannot say.
1567
+ *
1568
+ * `ready`/`webgl2` stay false both when the page never mounted and when it mounted without WebGL, and `errors` is empty in both.
1569
+ * These split that apart, and they answer even when the page's own glue returned early (the page state being ABSENT is itself one
1570
+ * of the answers). `title` is the page's own progress line, walked one step at a time by the glue in `desktop-page.html`.
1571
+ */
1572
+ bridge: lastPageDiagnostics ? lastPageDiagnostics.bridge : null,
1573
+ sources: lastPageDiagnostics ? lastPageDiagnostics.sources : null,
1574
+ pageTitle: lastPageDiagnostics ? lastPageDiagnostics.title : null,
1575
+ pageReadError: lastPageDiagnostics && lastPageDiagnostics.readError ? lastPageDiagnostics.readError : null,
1576
+ /**
1577
+ * The page's frame count — so "the loop is alive" is a READOUT rather than an inference.
1578
+ *
1579
+ * Twice this session a frozen spring bone and an un-stoppable dance were described from outside while the page reported nothing,
1580
+ * and the frame count is the one number that separates "the loop stopped" from "the loop runs and the scene is idle". Relayed
1581
+ * from the page state, so a page that never published reports `null` rather than a stale number.
1582
+ */
1583
+ frames: lastPageState && typeof lastPageState.frames === 'number' ? lastPageState.frames : null,
1584
+ over: overPet,
1585
+ interacting: interacting,
1586
+ rect: petRect,
1587
+ // What the page's motion path is doing, and this is not decoration: "the pet is playing a clip" and "the
1588
+ // pet is standing in its procedural pose" look identical from outside, and the page's own state is the
1589
+ // only place that knows. Published here so a single `GET /vrmpet3d/desktop` answers it.
1590
+ motion: lastPageState && lastPageState.motion
1591
+ ? {
1592
+ kind: lastPageState.motion.kind,
1593
+ format: lastPageState.motion.format,
1594
+ /**
1595
+ * How many clips the PAGE's own catalog holds, and whether the route that fills it answered at
1596
+ * all. The page publishes both (`lib/desktop-page.js`) and this copy list dropped both, which
1597
+ * made the two failures behind "no motion ever plays" indistinguishable from outside: a host
1598
+ * that never served `/vrmpet3d/motions` and a window with no clip mapped to a state both reported
1599
+ * `mapped: {}` and `available: []`, and nothing else. Measured on the live window on
1600
+ * 2026-10-10: 6 clips in the host catalog, `mapped: {}`, `available: []`.
1601
+ *
1602
+ * An older page without the fields reports `null` rather than a lie, the same rule
1603
+ * `status.scale` follows below.
1604
+ */
1605
+ offered: lastPageState.motion.offered === undefined ? null : lastPageState.motion.offered,
1606
+ routed: lastPageState.motion.routed === undefined ? null : lastPageState.motion.routed,
1607
+ playing: lastPageState.motion.playing,
1608
+ /**
1609
+ * The face the window resolved, and whether three is HOLDING the clip's last frame.
1610
+ *
1611
+ * Relayed for the same reason `status` is: both answer a question the host cannot otherwise read. `face` says whether the
1612
+ * per-clip answer (the profile's, else the pack's template) reached this surface at all — 「默认无表情的覆盖没有生效」 is
1613
+ * unanswerable without it — and `clamping` says whether the one-shot on screen is being held, which is 「跳支舞之后卡在
1614
+ * 最后一帧」 in one boolean. Measured 2026-10-15.
1615
+ */
1616
+ face: lastPageState.motion.face === undefined ? null : lastPageState.motion.face,
1617
+ clamping: lastPageState.motion.clamping === undefined ? null : lastPageState.motion.clamping,
1618
+ mapped: lastPageState.motion.mapped,
1619
+ available: Array.isArray(lastPageState.motion.available)
1620
+ ? lastPageState.motion.available.map((entry) => ({
1621
+ id: entry.id,
1622
+ tracks: entry.tracks,
1623
+ duration: entry.duration,
1624
+ // The loop/once flag travels with the entry: without it a diagnostic cannot tell a clip that
1625
+ // is meant to play once from one that is meant to repeat, and "the pet waves for ever" and
1626
+ // "the pet waved once" look identical from the host.
1627
+ loop: entry.loop !== false,
1628
+ }))
1629
+ : [],
1630
+ reason: lastPageState.motion.reason,
1631
+ /**
1632
+ * Which clip the state DREW, and what the one-shot interval is doing — including WHICH STATE it is
1633
+ * armed for. That last field is the whole reason this exists: the interval belongs to a state, so a
1634
+ * window configured for `resting` fires nothing while the pet sits in `working` or `offline`, and from
1635
+ * outside those two situations look identical ("I set 20–80 seconds and nothing happens").
1636
+ */
1637
+ picked: lastPageState.motion.picked || null,
1638
+ scheduled: lastPageState.motion.scheduled || null,
1639
+ }
1640
+ : null,
1641
+ /**
1642
+ * What the 视线 settings currently resolve to IN THE RENDERER, and the aim the last frame fed the head.
1643
+ *
1644
+ * Published here because the host is where a probe can read it: "the slider reached the renderer" and
1645
+ * "the slider did nothing" are indistinguishable from outside otherwise, and this is the one field that
1646
+ * says which curve the window is actually using.
1647
+ */
1648
+ gaze: lastPageState && lastPageState.gaze
1649
+ ? {
1650
+ curve: lastPageState.gaze.curve,
1651
+ spanX: lastPageState.gaze.spanX,
1652
+ spanY: lastPageState.gaze.spanY,
1653
+ aim: lastPageState.gaze.aim,
1654
+ }
1655
+ : null,
1656
+ /**
1657
+ * Whether the spring-bone solver is running, and why it stopped if it did.
1658
+ *
1659
+ * A thrown physics step disables the chains for the rest of the avatar's life, and a disabled solver
1660
+ * looks exactly like "the hair hangs naturally but never moves" — the chains keep the pose they were
1661
+ * bound in. That failure has to be visible from outside, so the flag and the reason travel here.
1662
+ */
1663
+ physics: lastPageState && lastPageState.physics ? lastPageState.physics : null,
1664
+ /**
1665
+ * The page's own box arithmetic: the slot it thinks it has, the pixels it places it at, the canvas it
1666
+ * sized, the virtual frame it was told about, and its device pixel ratio.
1667
+ *
1668
+ * Published because the window rectangle below (`rect`) is the page's report, and when that report is
1669
+ * wrong there is no way to tell WHICH number is wrong from the host: a slot that grew, a canvas that
1670
+ * grew, or a frame that was mis-measured all produce the same symptom — a window of the wrong size —
1671
+ * and the fix belongs in a different place for each. This is the readout that tells them apart.
1672
+ */
1673
+ metrics: lastPageState
1674
+ ? {
1675
+ placement: lastPageState.placement || null,
1676
+ pixels: lastPageState.pixels || null,
1677
+ canvas: lastPageState.canvas || null,
1678
+ screen: lastPageState.screen || null,
1679
+ viewport: lastPageState.viewport || null,
1680
+ devicePixelRatio: lastPageState.devicePixelRatio || null,
1681
+ }
1682
+ : null,
1683
+ /**
1684
+ * The prompt above the model's head, as the PAGE draws it: its rectangle (null while nothing is up),
1685
+ * the line, and which animation it uses.
1686
+ *
1687
+ * Published for the same reason `motion`, `gaze` and `physics` are, and it was the field whose absence
1688
+ * cost the most: "the pet never says 深度求索中" has four causes that look identical from the host — the
1689
+ * state never reached this page (see `state`), the state has no line, the user's own settings cleared
1690
+ * that line, or the line is up but the layout put it where the window cannot show it (the window is
1691
+ * sized from this very rectangle). The rectangle is what separates "nothing to say" from "said, but not
1692
+ * on screen", and only the renderer knows it.
1693
+ */
1694
+ status: lastPageState && lastPageState.status
1695
+ ? {
1696
+ rect: lastPageState.status.rect || null,
1697
+ text: lastPageState.status.text || null,
1698
+ animation: lastPageState.status.animation || null,
1699
+ /** The whale, and the size the label is drawn at — the two halves of "does it look like DSH's". */
1700
+ icon: lastPageState.status.icon === true,
1701
+ fontPx: lastPageState.status.fontPx || null,
1702
+ /**
1703
+ * The model's own text-size correction, which the wheel ON THE TEXT drives.
1704
+ *
1705
+ * Relayed because the page publishes it but this report COPIES a fixed list of fields: without it the
1706
+ * correction existed in the page and was invisible from outside, so "the text gesture did nothing" and
1707
+ * "it worked and the size is right" could not be told apart without a browser console. Reading it from
1708
+ * `/vrmpet3d/settings` (`preferences.statusScale`) is the fallback on a launcher older than this field.
1709
+ */
1710
+ scale: lastPageState.status.scale === undefined ? null : lastPageState.status.scale,
1711
+ /**
1712
+ * The look of the line: `gold` + bold for 任务达成, `danger` for 任务失败/出错, null otherwise.
1713
+ *
1714
+ * Relayed because it is a DECISION the page only renders and this report copies a fixed field list:
1715
+ * without it, "the prompt is not gold" cannot be told apart from "the state is not 任务达成" from
1716
+ * outside the window — the same reason `scale` above is here.
1717
+ */
1718
+ tone: lastPageState.status.tone || null,
1719
+ bold: lastPageState.status.bold === true,
1720
+ }
1721
+ : null,
1722
+ /**
1723
+ * 碎碎念: the pet's own remark, on the same terms as the prompt above.
1724
+ *
1725
+ * Relayed because the launcher copies a fixed list of fields and the bubble is DOM this process sizes the
1726
+ * window around: without it, "the pet said nothing" and "it said something the window did not grow to
1727
+ * hold" look identical from outside.
1728
+ */
1729
+ speech: lastPageState && lastPageState.speech
1730
+ ? {
1731
+ rect: lastPageState.speech.rect || null,
1732
+ text: lastPageState.speech.text || null,
1733
+ fontPx: lastPageState.speech.fontPx || null,
1734
+ }
1735
+ : null,
1736
+ /**
1737
+ * What state the page is DRAWING, and what it was TOLD — plus why there is none, when the channel said so.
1738
+ *
1739
+ * Relayed for the same reason `status` is, and learned the hard way on 2026-10-11: the operator reported
1740
+ * "the window still says 深度求索中 after the turn ended", and the answer needed BOTH sides — the host's
1741
+ * `/vrmpet3d/state` (which said `state: null` with a TypeError from the app's pet) and this page's own view
1742
+ * (which was faithfully drawing the last state it had ever received). Without `published` and `error` in
1743
+ * this report, those two look identical from outside and the fixed field list made the page's view
1744
+ * invisible.
1745
+ */
1746
+ state: lastPageState && lastPageState.state
1747
+ ? {
1748
+ drawn: lastPageState.state.drawn || null,
1749
+ published: lastPageState.state.published === undefined ? null : lastPageState.state.published,
1750
+ at: lastPageState.state.at || null,
1751
+ error: lastPageState.state.error || null,
1752
+ }
1753
+ : null,
1754
+ /**
1755
+ * The last press the window reacted to, and WHICH PART of the model it landed on.
1756
+ *
1757
+ * Relayed because the answer lives only in the page: "I clicked its eye" and "I clicked its body" are the
1758
+ * same event from the launcher's side, and which one happened decides whether a reaction was configured
1759
+ * for it at all.
1760
+ */
1761
+ touch: lastPageState && lastPageState.touch
1762
+ ? {
1763
+ reason: lastPageState.touch.reason || null,
1764
+ region: lastPageState.touch.region || null,
1765
+ bone: lastPageState.touch.bone || null,
1766
+ mesh: lastPageState.touch.mesh || null,
1767
+ }
1768
+ : null,
1769
+ };
1770
+ try {
1771
+ process.stdout.write(`${VRMPET3D_STATUS_PREFIX}${JSON.stringify(payload)}\n`);
1772
+ } catch (_error) {
1773
+ // A closed stdout must not take the window down with it.
1774
+ }
1775
+ }
1776
+
1777
+ app.on('window-all-closed', () => app.quit());
1778
+ app.on('will-quit', () => {
1779
+ if (cursorTimer) clearInterval(cursorTimer);
1780
+ if (watchdogTimer) clearInterval(watchdogTimer);
1781
+ if (selfTestTimer) clearTimeout(selfTestTimer);
1782
+ });