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.
- package/LICENSE +43 -0
- package/README.md +249 -2
- package/assets/models/BlueFatFish.vrm.br +0 -0
- package/assets/models/README.md +99 -0
- package/assets/models/SampleModel.vrm.br +0 -0
- package/assets/models/vrmpet3d-models.json +116 -0
- package/assets/motions/README.md +177 -0
- package/assets/motions/attention/wave hand 1.vrma.br +0 -0
- package/assets/motions/attention/wave hand 2.vrma.br +0 -0
- package/assets/motions/attention/wave hand 3.vrma.br +0 -0
- package/assets/motions/dance/dance1.vrma.br +0 -0
- package/assets/motions/dance/dance2.vrma.br +0 -0
- package/assets/motions/fail/fall down1.vrma.br +0 -0
- package/assets/motions/fail/fall down2.vrma.br +0 -0
- package/assets/motions/idle/hungry1.vrma.br +0 -0
- package/assets/motions/idle/hungry2.vrma.br +0 -0
- package/assets/motions/idle/idle lookaround1.vrma.br +0 -0
- package/assets/motions/idle/idle lookaround2.vrma.br +0 -0
- package/assets/motions/idle/idle stretch1.vrma.br +0 -0
- package/assets/motions/idle/idle stretch2.vrma.br +0 -0
- package/assets/motions/idle/idle stretch3.vrma.br +0 -0
- package/assets/motions/idle/idle stretch4.vrma.br +0 -0
- package/assets/motions/idle/idle.vrma.br +0 -0
- package/assets/motions/idle/idle2.vrma.br +0 -0
- package/assets/motions/idle/idle3.vrma.br +0 -0
- package/assets/motions/idle/idle4.vrma.br +0 -0
- package/assets/motions/idle/touch hair1.vrma.br +0 -0
- package/assets/motions/idle/touch hair2.vrma.br +0 -0
- package/assets/motions/offline/sleeping.vrma.br +0 -0
- package/assets/motions/success/jump 1.vrma.br +0 -0
- package/assets/motions/success/jump 2.vrma.br +0 -0
- package/assets/motions/success/jump 3.vrma.br +0 -0
- package/assets/motions/vrmpet3d-motions.json +283 -0
- package/assets/motions/working/dance1.vrma.br +0 -0
- package/cordis.patch.yml +7 -0
- package/desktop/main.js +1782 -0
- package/desktop/package.json +7 -0
- package/desktop/preload.js +66 -0
- package/desktop/probe-motion-picker.js +122 -0
- package/desktop/probe-parts-editor.js +247 -0
- package/lib/brotli.js +74 -0
- package/lib/client.core.js +7563 -0
- package/lib/client.js +5031 -0
- package/lib/client.menu.js +501 -0
- package/lib/client.scene.js +5128 -0
- package/lib/client.vendor.js +55286 -0
- package/lib/config-page.html +194 -0
- package/lib/config-page.js +4754 -0
- package/lib/core.js +7541 -0
- package/lib/desktop-page.html +131 -0
- package/lib/desktop-page.js +3927 -0
- package/lib/desktop.js +536 -0
- package/lib/env.js +20 -0
- package/lib/global-page.html +338 -0
- package/lib/index.js +4170 -0
- package/lib/menu.js +479 -0
- package/lib/model-picker.js +794 -0
- package/lib/pages.css +350 -0
- package/lib/picker-page.html +64 -0
- package/lib/picker-page.js +145 -0
- package/lib/placeholder.js +566 -0
- package/lib/scene.js +5106 -0
- package/lib/settings.js +259 -0
- package/lib/setup-page.html +240 -0
- package/lib/setup-page.js +706 -0
- package/lib/vrm-info.js +926 -0
- package/package.json +55 -4
package/desktop/main.js
ADDED
|
@@ -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
|
+
});
|