dsh-agentic-pet 0.0.0-stage → 0.3.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.
@@ -0,0 +1,1038 @@
1
+ /**
2
+ * dsh-pet Electron main process. Owns the pet window (transparent,
3
+ * always-on-top, click-through outside the pet body), the pet-profile child
4
+ * process (spawned through Electron's own binary in Node mode, mirroring
5
+ * apps/desktop-host), and the pet-asset protocol that streams the pet's
6
+ * animation media from a local directory without vendoring it into the
7
+ * repository.
8
+ *
9
+ * Window drag is manual: the renderer captures the pointer over the pet body
10
+ * and reports screen coordinates; the main process follows with setBounds
11
+ * whose size is pinned to the canonical WINDOW_SIZE and whose position
12
+ * recomputes from the gesture anchor, so neither the DWM re-measure ratchet
13
+ * nor a per-call nudge can accumulate (DSH-PET.md §5 error chain #11-12).
14
+ */
15
+ import { app, BrowserWindow, ipcMain, Menu, protocol, screen, session } from 'electron';
16
+ import { spawn } from 'node:child_process';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
19
+ import { readFile, readdir, rm, writeFile } from 'node:fs/promises';
20
+ import { createServer } from 'node:http';
21
+ import { homedir } from 'node:os';
22
+ import { dirname, join, normalize, resolve, sep } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { createRequire } from 'node:module';
25
+ const HERE = dirname(fileURLToPath(import.meta.url));
26
+ const PET_PROFILE_NAME = 'pet';
27
+ const ASSET_SCHEME = 'pet-asset';
28
+ /** Bounded wait for the profile child's graceful shutdown before it is left to the OS. */
29
+ const SHUTDOWN_TIMEOUT_MS = 10_000;
30
+ /** The dsh home this carrier shares with the driver (manifest, settings). */
31
+ const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh');
32
+ /**
33
+ * The character the pet page displays. Keep in sync with
34
+ * src/renderer/main.ts (no shared module: the renderer is a separate vite
35
+ * build; the catalog scan and the /action validation must cover the SAME
36
+ * character the renderer can actually play).
37
+ */
38
+ const CHARACTER = 'whale';
39
+ /**
40
+ * Derive the action catalog from the media tree: categories are the
41
+ * directories under characters/<id>/videos, clips are the .webm files inside
42
+ * them (one nested level is flattened — events/balance). Scanning disk (not
43
+ * a checked-in index) keeps an asset-package update from requiring a code
44
+ * change. Failure degrades to an empty catalog — the pet then advertises
45
+ * only the universal `celebrate` alias, i.e. yesterday's behavior.
46
+ */
47
+ async function scanActionCatalog() {
48
+ const videosRoot = join(resolveAssetsRoot(), CHARACTER, 'videos');
49
+ const entries = [];
50
+ const seen = new Set();
51
+ const collect = async (dir, relativeDir) => {
52
+ const items = await readdir(dir, { withFileTypes: true }).catch(() => null);
53
+ if (items === null)
54
+ return;
55
+ for (const item of items) {
56
+ if (item.isDirectory()) {
57
+ await collect(join(dir, item.name), relativeDir === '' ? item.name : `${relativeDir}/${item.name}`);
58
+ continue;
59
+ }
60
+ if (!item.name.toLowerCase().endsWith('.webm'))
61
+ continue;
62
+ const id = item.name.slice(0, -5);
63
+ if (id === '' || seen.has(id))
64
+ continue; // first wins; clip ids are unique in practice
65
+ seen.add(id);
66
+ entries.push({ id, dir: relativeDir, file: id });
67
+ }
68
+ };
69
+ const categories = await readdir(videosRoot, { withFileTypes: true }).catch(() => null);
70
+ if (categories === null)
71
+ return entries; // no media: celebrate-only catalog
72
+ for (const category of categories) {
73
+ if (category.isDirectory())
74
+ await collect(join(videosRoot, category.name), category.name);
75
+ }
76
+ return entries;
77
+ }
78
+ /**
79
+ * The window's one canonical size: creation and every drag setBounds pin it.
80
+ * A position-only setPosition re-sends whatever size DWM last re-measured the
81
+ * transparent frameless window to, which ratchets +1px per call; an explicit
82
+ * size can at worst oscillate, never accumulate.
83
+ */
84
+ const WINDOW_SIZE = { width: 460, height: 560 };
85
+ /** A rect from a loose payload; null unless all four members are finite
86
+ * numbers (defensive extraction — the /status, /world and persistence paths
87
+ * share it). */
88
+ function rectOf(value) {
89
+ if (typeof value !== 'object' || value === null)
90
+ return null;
91
+ const record = value;
92
+ const num = (key) => typeof record[key] === 'number' && Number.isFinite(record[key]) ? record[key] : null;
93
+ const x = num('x');
94
+ const y = num('y');
95
+ const width = num('width');
96
+ const height = num('height');
97
+ return x !== null && y !== null && width !== null && height !== null ? { x, y, width, height } : null;
98
+ }
99
+ /** Open-interval intersection: touching edges share no area. */
100
+ function rectsIntersect(a, b) {
101
+ return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
102
+ }
103
+ /** Clamp a scalar into [low, high]; a degenerate range (smaller world than
104
+ * the window) collapses to its low edge so the rect still pins inside. */
105
+ function clampInto(value, low, high) {
106
+ return high < low ? low : Math.min(Math.max(value, low), high);
107
+ }
108
+ process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true';
109
+ // ── boot logger (diagnostic injection, remove after incident) ──────────────
110
+ import { appendFileSync as __afs } from 'node:fs';
111
+ const __bootLog = (line) => {
112
+ try {
113
+ __afs('C:/Users/beyondguo/.dsh/carrier-boot.log', `${new Date().toISOString()} ${line}\n`);
114
+ }
115
+ catch { /* ignore */ }
116
+ };
117
+ __bootLog(`BOOT pid=${process.pid} argv=${JSON.stringify(process.argv.slice(1))} runAsNode=${process.env.ELECTRON_RUN_AS_NODE ?? 'unset'} nodeOptions=${process.env.NODE_OPTIONS ?? 'unset'} dshPetUrl=${process.env.DSH_PET_URL ? 'set' : 'unset'} cwd=${process.cwd()}`);
118
+ process.on('uncaughtException', (e) => { __bootLog(`UNCAUGHT ${e.stack ?? String(e)}`); });
119
+ process.on('unhandledRejection', (e) => { __bootLog(`REJECTION ${String(e)}`); });
120
+ // ── boot logger end ─────────────────────────────────────────────────────────
121
+ // Route main-process crashes to the log instead of the dialog: an uncaught
122
+ // exception in an IPC handler or poller otherwise dies as a modal error box
123
+ // with the stack truncated, leaving nothing to diagnose from.
124
+ process.on('uncaughtException', (error) => {
125
+ console.error(`[pet] uncaughtException: ${error.stack ?? String(error)}`);
126
+ });
127
+ process.on('unhandledRejection', (reason) => {
128
+ console.error(`[pet] unhandledRejection: ${String(reason)}`);
129
+ });
130
+ // CDP diagnostics (page state reads, synthetic input), requested by the
131
+ // DSH_PET_CDP env or the settings page's debug switch (read synchronously —
132
+ // this runs pre-ready; the switch only takes hold on the next launch).
133
+ let cdpRequested = process.env.DSH_PET_CDP === '1';
134
+ try {
135
+ const settings = JSON.parse(readFileSync(join(DSH_HOME, 'pet-settings.json'), 'utf8'));
136
+ if (settings.debug?.cdp === true)
137
+ cdpRequested = true;
138
+ }
139
+ catch {
140
+ // Absent or unreadable settings: env is the only request channel.
141
+ }
142
+ if (cdpRequested) {
143
+ app.commandLine.appendSwitch('remote-debugging-port', '9222');
144
+ }
145
+ // Registered before app ready, per Electron protocol registration rules. The
146
+ // scheme carries the pet's media under its path; media elements load it
147
+ // no-cors, so no CORS privilege is required.
148
+ protocol.registerSchemesAsPrivileged([
149
+ // corsEnabled + an ACAO header on every response: the WebGL stage uploads
150
+ // the video frames as textures, and a cross-origin media element without a
151
+ // CORS-clean response taints the context (texImage2D SecurityError).
152
+ { scheme: ASSET_SCHEME, privileges: { supportFetchAPI: true, stream: true, corsEnabled: true } },
153
+ ]);
154
+ if (!app.requestSingleInstanceLock()) {
155
+ app.quit();
156
+ }
157
+ else {
158
+ app.on('second-instance', () => {
159
+ const window = BrowserWindow.getAllWindows()[0];
160
+ window?.show();
161
+ });
162
+ void main();
163
+ }
164
+ /** The local directory the pet's animation media is read from. */
165
+ function resolveAssetsRoot() {
166
+ const configured = process.env.DSH_PET_ASSETS_DIR;
167
+ if (configured !== undefined && configured !== '')
168
+ return resolve(configured);
169
+ // Packaged layout: the carrier ships beside an assets directory (a junction
170
+ // or copy staged next to lib/).
171
+ const besideCarrier = resolve(HERE, '..', 'assets', 'characters');
172
+ if (existsSync(besideCarrier))
173
+ return besideCarrier;
174
+ // npm assets package: dsh-agentic-pet-assets ships assets/characters as
175
+ // plain files — the media channel for plugin installs from the registry
176
+ // (the 50MB media deliberately stays out of the pet-app package itself).
177
+ try {
178
+ const require = createRequire(import.meta.url);
179
+ const pkg = require.resolve('dsh-agentic-pet-assets/package.json');
180
+ const packaged = resolve(dirname(pkg), 'assets', 'characters');
181
+ if (existsSync(packaged))
182
+ return packaged;
183
+ }
184
+ catch {
185
+ // assets package not installed: fall through
186
+ }
187
+ // Source-checkout fallback: the media lives in the bundle's carrier tree
188
+ // (gitignored, staged locally). lib/ → apps/pet → apps → deepseek-harness,
189
+ // then into packages/bundle/pet-app/carrier.
190
+ return resolve(HERE, '..', '..', '..', 'packages', 'bundle', 'pet-app', 'carrier', 'assets', 'characters');
191
+ }
192
+ function registerAssetProtocol() {
193
+ const assetsRoot = resolveAssetsRoot();
194
+ protocol.handle(ASSET_SCHEME, (request) => {
195
+ const url = new URL(request.url);
196
+ const relative = decodeURIComponent(url.pathname);
197
+ const target = resolve(normalize(join(assetsRoot, relative)));
198
+ if (target !== assetsRoot && !target.startsWith(assetsRoot + sep)) {
199
+ return new Response(null, { status: 403 });
200
+ }
201
+ return netFetchFile(target);
202
+ });
203
+ }
204
+ async function netFetchFile(target) {
205
+ try {
206
+ const bytes = await readFile(target);
207
+ return new Response(bytes, {
208
+ headers: {
209
+ 'content-type': mimeOf(target),
210
+ 'access-control-allow-origin': '*',
211
+ },
212
+ });
213
+ }
214
+ catch {
215
+ return new Response(null, { status: 404 });
216
+ }
217
+ }
218
+ function mimeOf(target) {
219
+ switch (target.split('.').pop()?.toLowerCase()) {
220
+ case 'webm': return 'video/webm';
221
+ case 'gif': return 'image/gif';
222
+ case 'png': return 'image/png';
223
+ case 'json': return 'application/json';
224
+ default: return 'application/octet-stream';
225
+ }
226
+ }
227
+ /**
228
+ * Spawn the pet profile child (Electron binary in Node mode running the
229
+ * profile runner) and resolve its authenticated URL. The child owns the
230
+ * Cordis tree; the carrier only supervises it.
231
+ */
232
+ function spawnHostChild() {
233
+ const hostChild = join(HERE, 'host-child.js');
234
+ if (!existsSync(hostChild)) {
235
+ throw new Error('pet: this carrier build has no self-spawn support (host-child.js absent); it expects DSH_PET_URL from a spawning runtime');
236
+ }
237
+ const child = spawn(process.execPath, [hostChild], {
238
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
239
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
240
+ });
241
+ let stderr = '';
242
+ child.stderr?.setEncoding('utf8');
243
+ child.stderr?.on('data', (chunk) => { stderr += chunk; });
244
+ // Forward the profile's console (the readiness URL line is the carrier
245
+ // user's entry point to the Web GUI) instead of letting it sit in the pipe.
246
+ child.stdout?.setEncoding('utf8');
247
+ child.stdout?.on('data', (chunk) => { process.stdout.write(chunk); });
248
+ child.stderr?.on('data', (chunk) => { process.stderr.write(chunk); });
249
+ const url = new Promise((resolveUrl, rejectUrl) => {
250
+ const timer = setTimeout(() => {
251
+ rejectUrl(new Error(`pet host: the profile child did not report ready within 120s\n${stderr.slice(-2000)}`));
252
+ }, 120_000);
253
+ child.on('message', (message) => {
254
+ if (message?.type !== 'ready')
255
+ return;
256
+ clearTimeout(timer);
257
+ resolveUrl(String(message.url));
258
+ });
259
+ child.on('exit', (code) => {
260
+ clearTimeout(timer);
261
+ rejectUrl(new Error(`pet host: the profile child exited with code ${String(code)} before ready\n${stderr.slice(-2000)}`));
262
+ });
263
+ });
264
+ return {
265
+ child,
266
+ url,
267
+ shutdown: () => new Promise((resolveShutdown) => {
268
+ if (child.exitCode !== null || child.signalCode !== null) {
269
+ resolveShutdown();
270
+ return;
271
+ }
272
+ const timer = setTimeout(() => {
273
+ child.kill();
274
+ resolveShutdown();
275
+ }, SHUTDOWN_TIMEOUT_MS);
276
+ child.on('message', (message) => {
277
+ if (message?.type !== 'shutdown-complete')
278
+ return;
279
+ clearTimeout(timer);
280
+ resolveShutdown();
281
+ });
282
+ child.send({ type: 'shutdown' });
283
+ }),
284
+ };
285
+ }
286
+ /**
287
+ * The pet-surface locator: pet-app bundles inside a running runtime publish
288
+ * their authenticated root here (the desktop shell keeps its URL in process
289
+ * memory only). A published URL wins over spawning a dedicated profile, so the
290
+ * pet follows whichever live runtime serves /pet/ — the user's desktop when it
291
+ * runs, otherwise the last publisher still alive.
292
+ */
293
+ async function readLocatorUrl() {
294
+ const path = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'pet-surface-url.json');
295
+ try {
296
+ const record = JSON.parse(await readFile(path, 'utf8'));
297
+ if (typeof record.url !== 'string' || record.url === '')
298
+ return undefined;
299
+ if (typeof record.pid === 'number') {
300
+ try {
301
+ process.kill(record.pid, 0);
302
+ }
303
+ catch {
304
+ return undefined;
305
+ } // publisher gone
306
+ }
307
+ return record.url;
308
+ }
309
+ catch {
310
+ return undefined; // absent or unreadable: no live publisher
311
+ }
312
+ }
313
+ /**
314
+ * The pet surface URL for an authenticated web root: path `/pet/` with the
315
+ * token **dropped** — authorizeIndex mints the root cookie from the token and
316
+ * rejects any token-carrying request on a subpath, so the pet page itself is
317
+ * entered cookie-only after the root mint.
318
+ */
319
+ function petSurfaceUrl(authenticated) {
320
+ const parsed = new URL(authenticated);
321
+ parsed.pathname = '/pet/';
322
+ parsed.search = '';
323
+ parsed.hash = '';
324
+ return parsed.toString();
325
+ }
326
+ /** The remembered window position, validated against the current displays so
327
+ * a stale save (monitor unplugged, resolution change) cannot spawn the pet
328
+ * off-screen. The WHOLE window rectangle must fit inside some work area —
329
+ * a corner check admits positions whose body hangs past the edge, which is
330
+ * how x=2125 (DIP work area 1707 wide) passed and the pet vanished. When no
331
+ * display fits it, clamp fully into the primary work area. Undefined =
332
+ * default placement. */
333
+ function readSavedPosition() {
334
+ try {
335
+ const parsed = JSON.parse(readFileSync(join(DSH_HOME, 'pet-window.json'), 'utf8'));
336
+ const x = typeof parsed.x === 'number' ? Math.round(parsed.x) : Number.NaN;
337
+ const y = typeof parsed.y === 'number' ? Math.round(parsed.y) : Number.NaN;
338
+ if (!Number.isFinite(x) || !Number.isFinite(y))
339
+ return undefined;
340
+ const fits = (area) => x >= area.x && y >= area.y
341
+ && x + WINDOW_SIZE.width <= area.x + area.width
342
+ && y + WINDOW_SIZE.height <= area.y + area.height;
343
+ if (screen.getAllDisplays().some(display => fits(display.workArea)))
344
+ return { x, y };
345
+ const primary = screen.getPrimaryDisplay().workArea;
346
+ return {
347
+ x: Math.min(primary.x + primary.width - WINDOW_SIZE.width, Math.max(primary.x, x)),
348
+ y: Math.min(primary.y + primary.height - WINDOW_SIZE.height, Math.max(primary.y, y)),
349
+ };
350
+ }
351
+ catch {
352
+ return undefined;
353
+ }
354
+ }
355
+ /** The persisted roaming area (§6 /world): `pet-world.json` holds the
356
+ * effective rect or an explicit null (unset). Anything unreadable or
357
+ * mistyped degrades to unset — the default work-area clamping applies. */
358
+ function readSavedWorld() {
359
+ try {
360
+ return rectOf(JSON.parse(readFileSync(join(DSH_HOME, 'pet-world.json'), 'utf8')));
361
+ }
362
+ catch {
363
+ return null;
364
+ }
365
+ }
366
+ function createPetWindow() {
367
+ const saved = readSavedPosition();
368
+ const window = new BrowserWindow({
369
+ ...(saved === undefined ? {} : { x: saved.x, y: saved.y }),
370
+ width: WINDOW_SIZE.width,
371
+ height: WINDOW_SIZE.height,
372
+ transparent: true,
373
+ frame: false,
374
+ hasShadow: false,
375
+ alwaysOnTop: true,
376
+ skipTaskbar: true,
377
+ resizable: false,
378
+ backgroundColor: '#00000000',
379
+ webPreferences: {
380
+ preload: join(HERE, 'preload.js'),
381
+ // ESM preloads require the sandbox off; contextIsolation stays on.
382
+ sandbox: false,
383
+ contextIsolation: true,
384
+ nodeIntegration: false,
385
+ webSecurity: true,
386
+ },
387
+ });
388
+ window.setAlwaysOnTop(true, 'screen-saver');
389
+ // Mouse events start forwarded: the main-process poller toggles ignore off
390
+ // while the cursor is over the pet body or a bubble, so the desktop
391
+ // underneath stays clickable everywhere else.
392
+ window.setIgnoreMouseEvents(true, { forward: true });
393
+ return window;
394
+ }
395
+ async function main() {
396
+ // protocol.handle touches the default session: registration must follow
397
+ // app ready (registerSchemesAsPrivileged above is the only pre-ready call).
398
+ await app.whenReady();
399
+ // The runtime re-attaches to a fresh random port every boot, but cookies
400
+ // ignore ports and pile up on the shared 127.0.0.1 domain — hundreds of
401
+ // mints later the Cookie header outgrows the server's 16KB limit and every
402
+ // page request is rejected (observed: 431 accumulated cookies). The boot's
403
+ // token URL re-mints everything the page needs, so persistence is worthless.
404
+ await session.defaultSession.clearStorageData({ storages: ['cookies'] });
405
+ registerAssetProtocol();
406
+ const actionCatalog = await scanActionCatalog();
407
+ // The advertised menu: the universal celebrate alias first, then every
408
+ // clip id the media tree holds.
409
+ const actionIds = ['celebrate', ...actionCatalog.map(entry => entry.id)];
410
+ console.log(`[pet] assets root: ${resolveAssetsRoot()} actions=${String(actionCatalog.length)}`);
411
+ let handle;
412
+ const attachUrl = process.env.DSH_PET_URL ?? await readLocatorUrl();
413
+ let authenticated;
414
+ try {
415
+ if (attachUrl !== undefined && attachUrl !== '') {
416
+ console.log(`[pet] attaching to a published pet surface: ${new URL(attachUrl).origin}`);
417
+ authenticated = attachUrl;
418
+ }
419
+ else {
420
+ // The self-spawn branch alone needs the profile machinery; the packaged
421
+ // carrier always arrives with DSH_PET_URL from its spawning runtime and
422
+ // carries none of these dependencies.
423
+ const [{ initProfile }, { dshHomePath }, { PROFILE_TEMPLATES }] = await Promise.all([
424
+ import('@deepseek-ai/dsh-app-boot'),
425
+ import('@deepseek-ai/dsh-home-paths'),
426
+ import('@deepseek-ai/dsh-official-bundles'),
427
+ ]);
428
+ const template = PROFILE_TEMPLATES[PET_PROFILE_NAME];
429
+ if (template === undefined)
430
+ throw new Error('pet: no shipped profile template named "pet"');
431
+ initProfile(dshHomePath('profiles', PET_PROFILE_NAME), template.bundles);
432
+ handle = spawnHostChild();
433
+ authenticated = await handle.url;
434
+ }
435
+ }
436
+ catch (error) {
437
+ new BrowserWindow({ width: 520, height: 320 }).loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(`<pre style="font-family:monospace;white-space:pre-wrap">dsh pet 载体启动失败:\n\n${String(error instanceof Error ? error.message : error)}` +
438
+ `\n\n素材目录(可配置 DSH_PET_ASSETS_DIR):${resolveAssetsRoot()}</pre>`));
439
+ return;
440
+ }
441
+ const window = createPetWindow();
442
+ window.on('resize', () => {
443
+ console.log(`[pet] resized: ${JSON.stringify(window.getBounds())}`);
444
+ });
445
+ window.on('moved', () => {
446
+ console.log(`[pet] moved: ${JSON.stringify(window.getBounds())}`);
447
+ });
448
+ const primary = screen.getPrimaryDisplay();
449
+ console.log(`[pet] window bounds: ${JSON.stringify(window.getBounds())} scaleFactor=${primary.scaleFactor} workarea=${JSON.stringify(primary.workArea)}`);
450
+ // Forward the renderer console: the pet page's own diagnostics (connection,
451
+ // shader, video failures) never reach a terminal otherwise.
452
+ window.webContents.on('console-message', (_event, _level, message, line, source) => {
453
+ console.log(`[pet-renderer] ${source}:${line} ${message}`);
454
+ });
455
+ window.webContents.on('did-fail-load', (_event, code, description, url) => {
456
+ console.log(`[pet-renderer] did-fail-load ${code} ${description} ${url}`);
457
+ });
458
+ // Right-click anywhere on the window (pet body or bubble — the click-through
459
+ // poller lets those through) opens the close menu: the MVP has no taskbar
460
+ // entry or tray, so this is the only self-service exit.
461
+ window.webContents.on('context-menu', () => {
462
+ Menu.buildFromTemplate([
463
+ { label: '退出 dsh pet', click: () => { window.close(); } },
464
+ ]).popup({ window });
465
+ });
466
+ // Connection's token exchange is root-path-only (authorizeIndex admits
467
+ // `/?token=…`, never a subpath): load the authenticated root once to mint
468
+ // the browser cookie, then enter /pet/ with it. The root load starts the
469
+ // Web GUI; the second navigation aborts it, so the web app is at most a
470
+ // flash.
471
+ await window.loadURL(authenticated).catch(() => { });
472
+ await window.loadURL(petSurfaceUrl(authenticated)).catch((error) => {
473
+ console.error('[pet] the pet surface failed to load:', String(error));
474
+ });
475
+ // Hand the renderer the action catalog (its /action name resolution and
476
+ // idle theater both consume it). The page has finished loading here, so
477
+ // its listener is registered and nothing is lost.
478
+ window.webContents.send('pet:catalog', { character: CHARACTER, actions: actionCatalog });
479
+ // Click-through is decided by the MAIN process: setIgnoreMouseEvents(forward)
480
+ // does not deliver forwarded moves on this Electron/Windows combination, so
481
+ // the renderer never sees the cursor. The main process knows the window
482
+ // bounds and the pet/bubble rects (they mirror the renderer CSS), polls the
483
+ // cursor, and toggles ignore accordingly — no renderer participation.
484
+ // The pet/bubble rects mirror the renderer CSS (pet: 340x200 bottom-center,
485
+ // 12px margin; bubble: 400x300 above it), computed against the CURRENT
486
+ // window size every poll — a fixed rect desyncs the moment the window
487
+ // changes size for any reason.
488
+ const PET_SIZE = { width: 340, height: 200 };
489
+ const BUBBLE_SIZE = { width: 400, height: 300 };
490
+ let bubbleVisible = false;
491
+ // The v3 action witness, ONE object: acceptance writes id/action/
492
+ // requestedBy/startedAt (endedAt/durationMs null), the renderer's clip-end
493
+ // report backfills endedAt/durationMs IN PLACE against the same id — the
494
+ // four acceptance fields never move, so an id-matching poller cannot
495
+ // misread a newer action's record. Consumers poll this instead of the
496
+ // renderer's override, which may set and clear within a tick. A
497
+ // pet-local one-shot (touch reaction, work-landing celebrate) writes its
498
+ // record on completion with id: null. null = no record yet (constant
499
+ // output — the field is always present in /status).
500
+ let lastAction = null;
501
+ // Tool-initiated /action requests awaiting their completion report: the
502
+ // renderer echoes the requestId when the clip ends, so the witness can
503
+ // carry the REQUESTED action name (not the clip file) and the actor.
504
+ // Swept on each acceptance: a report that never comes (a clip id the
505
+ // renderer cannot resolve) must not let the map grow without bound.
506
+ const pendingActionRequests = new Map();
507
+ // The v2 roaming area (§6 /world): null = unset (default work-area
508
+ // clamping applies). Absent/unsupported is NOT expressible here — this
509
+ // implementation always supports /world, so /status reports an explicit
510
+ // null until one is set.
511
+ let activeWorld = readSavedWorld();
512
+ let lastIgnore;
513
+ const updateClickThrough = () => {
514
+ try {
515
+ const bounds = window.getBounds();
516
+ const cursor = screen.getCursorScreenPoint();
517
+ const localX = cursor.x - bounds.x;
518
+ const localY = cursor.y - bounds.y;
519
+ const centeredX = (width) => (bounds.width - width) / 2;
520
+ const petRect = { x: centeredX(PET_SIZE.width), y: bounds.height - 12 - PET_SIZE.height, ...PET_SIZE };
521
+ const bubbleRect = { x: centeredX(BUBBLE_SIZE.width), y: bounds.height - 224 - BUBBLE_SIZE.height, ...BUBBLE_SIZE };
522
+ const inRect = (rect) => localX >= rect.x && localX <= rect.x + rect.width
523
+ && localY >= rect.y && localY <= rect.y + rect.height;
524
+ const overInteractive = inRect(petRect) || (bubbleVisible && inRect(bubbleRect));
525
+ if (lastIgnore !== overInteractive) {
526
+ console.log(`[pet] ignore -> ${String(!overInteractive)} cursorDIP=${cursor.x},${cursor.y} bounds=${JSON.stringify(bounds)}`);
527
+ lastIgnore = overInteractive;
528
+ }
529
+ window.setIgnoreMouseEvents(!overInteractive);
530
+ }
531
+ catch (error) {
532
+ console.error(`[pet] click-through poll failed: ${String(error)}`);
533
+ }
534
+ };
535
+ ipcMain.on('pet:bubble-visibility', (event, visible) => {
536
+ try {
537
+ if (event.sender !== window.webContents)
538
+ return;
539
+ bubbleVisible = visible === true;
540
+ }
541
+ catch (error) {
542
+ console.error('[pet] bubble-visibility failed:', String(error));
543
+ }
544
+ });
545
+ setInterval(updateClickThrough, 120);
546
+ // ── manual drag ───────────────────────────────────────────────────────────
547
+ // The believed origin: initialized once after creation and updated only by
548
+ // our own setBounds — never re-read from getBounds(), which reports whatever
549
+ // DWM last re-measured and would import the ratchet drift into the math.
550
+ let windowOrigin = { x: window.getBounds().x, y: window.getBounds().y };
551
+ let dragAnchor;
552
+ ipcMain.on('pet:drag-start', (event, screenX, screenY) => {
553
+ try {
554
+ if (event.sender !== window.webContents)
555
+ return;
556
+ if (typeof screenX !== 'number' || typeof screenY !== 'number'
557
+ || !Number.isFinite(screenX) || !Number.isFinite(screenY))
558
+ return;
559
+ dragAnchor = { pointerX: screenX, pointerY: screenY, originX: windowOrigin.x, originY: windowOrigin.y };
560
+ }
561
+ catch (error) {
562
+ console.error('[pet] drag-start failed:', String(error), 'args:', JSON.stringify([screenX, screenY]));
563
+ }
564
+ });
565
+ ipcMain.on('pet:drag-move', (event, screenX, screenY) => {
566
+ try {
567
+ const anchor = dragAnchor;
568
+ if (event.sender !== window.webContents || anchor === undefined)
569
+ return;
570
+ if (typeof screenX !== 'number' || typeof screenY !== 'number'
571
+ || !Number.isFinite(screenX) || !Number.isFinite(screenY)) {
572
+ console.error(`[pet] drag-move rejected args: ${JSON.stringify([screenX, screenY])}`);
573
+ return;
574
+ }
575
+ const next = {
576
+ x: Math.round(anchor.originX + (screenX - anchor.pointerX)),
577
+ y: Math.round(anchor.originY + (screenY - anchor.pointerY)),
578
+ width: WINDOW_SIZE.width,
579
+ height: WINDOW_SIZE.height,
580
+ };
581
+ // The roaming area binds the DRAG too (§6: location always sits inside
582
+ // a set world) — an unset world leaves the default work-area regime.
583
+ if (activeWorld !== null) {
584
+ next.x = clampInto(next.x, activeWorld.x, activeWorld.x + activeWorld.width - WINDOW_SIZE.width);
585
+ next.y = clampInto(next.y, activeWorld.y, activeWorld.y + activeWorld.height - WINDOW_SIZE.height);
586
+ }
587
+ if (next.x === windowOrigin.x && next.y === windowOrigin.y)
588
+ return;
589
+ windowOrigin = { x: next.x, y: next.y };
590
+ window.setBounds(next);
591
+ }
592
+ catch (error) {
593
+ console.error('[pet] drag-move failed:', String(error), 'args:', JSON.stringify([screenX, screenY]));
594
+ }
595
+ });
596
+ ipcMain.on('pet:drag-end', (event) => {
597
+ try {
598
+ if (event.sender !== window.webContents)
599
+ return;
600
+ dragAnchor = undefined;
601
+ // Remember where the user parked the pet; restored on the next launch
602
+ // and clearable from the settings page.
603
+ void writeFile(join(DSH_HOME, 'pet-window.json'), `${JSON.stringify(windowOrigin)}\n`, 'utf8')
604
+ .catch((error) => { console.error('[pet] could not save the window position:', String(error)); });
605
+ }
606
+ catch (error) {
607
+ console.error('[pet] drag-end failed:', String(error));
608
+ }
609
+ });
610
+ // ── roaming-area helpers (§6 /world) ──────────────────────────────────────
611
+ const currentLocation = () => {
612
+ const bounds = window.getBounds();
613
+ return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
614
+ };
615
+ const writeWorld = () => {
616
+ void writeFile(join(DSH_HOME, 'pet-world.json'), `${JSON.stringify(activeWorld)}\n`, 'utf8')
617
+ .catch((error) => { console.error('[pet] could not save the roaming area:', String(error)); });
618
+ };
619
+ // Pull the window rect fully inside the roaming area (a world smaller than
620
+ // the window pins the low edge). Returns whether the window moved.
621
+ const clampWindowIntoWorld = () => {
622
+ if (activeWorld === null)
623
+ return false;
624
+ const bounds = window.getBounds();
625
+ const x = clampInto(bounds.x, activeWorld.x, activeWorld.x + activeWorld.width - WINDOW_SIZE.width);
626
+ const y = clampInto(bounds.y, activeWorld.y, activeWorld.y + activeWorld.height - WINDOW_SIZE.height);
627
+ if (x === bounds.x && y === bounds.y)
628
+ return false;
629
+ windowOrigin = { x, y };
630
+ window.setBounds({ x, y, width: WINDOW_SIZE.width, height: WINDOW_SIZE.height });
631
+ return true;
632
+ };
633
+ // The default regime (world unset): the whole window rect must fit some
634
+ // display's work area (readSavedPosition's rule); when none does, clamp
635
+ // fully into the primary work area.
636
+ const clampWindowIntoDefaultArea = () => {
637
+ const bounds = window.getBounds();
638
+ const fits = (area) => bounds.x >= area.x && bounds.y >= area.y
639
+ && bounds.x + bounds.width <= area.x + area.width
640
+ && bounds.y + bounds.height <= area.y + area.height;
641
+ if (screen.getAllDisplays().some(display => fits(display.workArea)))
642
+ return false;
643
+ const primary = screen.getPrimaryDisplay().workArea;
644
+ const x = clampInto(bounds.x, primary.x, primary.x + primary.width - WINDOW_SIZE.width);
645
+ const y = clampInto(bounds.y, primary.y, primary.y + primary.height - WINDOW_SIZE.height);
646
+ if (x === bounds.x && y === bounds.y)
647
+ return false;
648
+ windowOrigin = { x, y };
649
+ window.setBounds({ x, y, width: WINDOW_SIZE.width, height: WINDOW_SIZE.height });
650
+ return true;
651
+ };
652
+ // ── control interface ─────────────────────────────────────────────────────
653
+ // The driver's four operations (§6 of DSH-PET.md) as local HTTP, hosted by
654
+ // the main process (renderers cannot listen); the renderer consumes them
655
+ // over IPC. v1 is loopback without auth — the callers are this machine's
656
+ // runtime driver and petctl.
657
+ const pendingInteractions = new Map();
658
+ // The renderer's latest self-report; the /status mirror serves it beside the
659
+ // main process's own facts.
660
+ let statusSnapshot = { state: 'idle' };
661
+ // §6 chat 上行:每宠至多一条在途——该事件被 /events 响应实际携出前
662
+ // 不收新输入(输入卡模态),携出即发送成功。
663
+ let pendingChatSeq = null;
664
+ const touchEvents = [];
665
+ let touchSeq = 0;
666
+ // Parked GET /events?hold=N requests, woken the moment an event lands.
667
+ const eventWaiters = new Set();
668
+ const wakeEventWaiters = () => {
669
+ for (const waiter of [...eventWaiters])
670
+ waiter();
671
+ };
672
+ ipcMain.on('pet:touch', (event, touch) => {
673
+ try {
674
+ if (event.sender !== window.webContents)
675
+ return;
676
+ const part = touch?.part === 'head' || touch?.part === 'body' ? touch.part : 'unknown';
677
+ touchSeq += 1;
678
+ touchEvents.push({ seq: touchSeq, type: 'touch', part, at: Date.now() });
679
+ if (touchEvents.length > 200)
680
+ touchEvents.splice(0, touchEvents.length - 200);
681
+ wakeEventWaiters();
682
+ console.log(`[pet] touch #${String(touchSeq)} (${part})`);
683
+ }
684
+ catch (error) {
685
+ console.error('[pet] touch failed:', String(error));
686
+ }
687
+ });
688
+ ipcMain.handle('pet:chat', (event, message) => {
689
+ try {
690
+ if (event.sender !== window.webContents)
691
+ return { ok: false };
692
+ const text = typeof message?.text === 'string' ? message.text.trim().slice(0, 2000) : '';
693
+ if (text === '')
694
+ return { ok: false, reason: 'empty' };
695
+ if (pendingChatSeq !== null)
696
+ return { ok: false, reason: 'busy' };
697
+ touchSeq += 1;
698
+ pendingChatSeq = touchSeq;
699
+ touchEvents.push({ seq: touchSeq, type: 'chat', text, at: Date.now() });
700
+ if (touchEvents.length > 200)
701
+ touchEvents.splice(0, touchEvents.length - 200);
702
+ wakeEventWaiters();
703
+ console.log(`[pet] chat #${String(touchSeq)} (${String(text.length)} chars)`);
704
+ return { ok: true };
705
+ }
706
+ catch (error) {
707
+ console.error('[pet] chat failed:', String(error));
708
+ return { ok: false };
709
+ }
710
+ });
711
+ ipcMain.on('pet:status-report', (event, snapshot) => {
712
+ try {
713
+ if (event.sender !== window.webContents)
714
+ return;
715
+ statusSnapshot = snapshot;
716
+ }
717
+ catch (error) {
718
+ console.error('[pet] status-report failed:', String(error));
719
+ }
720
+ });
721
+ ipcMain.on('pet:action-done', (event, done) => {
722
+ try {
723
+ if (event.sender !== window.webContents)
724
+ return;
725
+ if (typeof done?.action !== 'string')
726
+ return;
727
+ const requestId = typeof done.requestId === 'string' ? done.requestId : undefined;
728
+ const durationMs = typeof done.durationMs === 'number' && Number.isFinite(done.durationMs)
729
+ ? done.durationMs
730
+ : null;
731
+ const at = Date.now();
732
+ const pending = requestId !== undefined ? pendingActionRequests.get(requestId) : undefined;
733
+ if (pending !== undefined && requestId !== undefined)
734
+ pendingActionRequests.delete(requestId);
735
+ if (requestId !== undefined && lastAction !== null && lastAction.id === requestId && lastAction.endedAt === null) {
736
+ // Completion backfill IN PLACE: the four acceptance fields stay
737
+ // frozen, so an id-matching poller never misreads this record.
738
+ lastAction.durationMs = durationMs;
739
+ lastAction.endedAt = at;
740
+ return;
741
+ }
742
+ // A pet-local one-shot (touch reaction, work-landing celebrate) never
743
+ // had an /action acceptance: write the witness directly, the clip's
744
+ // own duration approximating its start.
745
+ lastAction = {
746
+ id: requestId ?? null,
747
+ action: pending !== undefined ? pending.requested : done.action,
748
+ requestedBy: pending !== undefined ? 'tool' : 'user',
749
+ startedAt: durationMs !== null ? at - durationMs : at,
750
+ durationMs,
751
+ endedAt: at,
752
+ };
753
+ }
754
+ catch (error) {
755
+ console.error('[pet] action-done failed:', String(error));
756
+ }
757
+ });
758
+ ipcMain.on('pet:interaction-answer', (event, message) => {
759
+ try {
760
+ if (event.sender !== window.webContents)
761
+ return;
762
+ if (typeof message?.id !== 'string' || !pendingInteractions.has(message.id))
763
+ return;
764
+ const response = pendingInteractions.get(message.id);
765
+ pendingInteractions.delete(message.id);
766
+ response?.end(` ${JSON.stringify({ ok: true, answer: message.answer ?? null })}`);
767
+ }
768
+ catch (error) {
769
+ console.error('[pet] interaction-answer failed:', String(error));
770
+ }
771
+ });
772
+ const control = createServer((req, res) => {
773
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
774
+ const send = (status, body) => {
775
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
776
+ res.end(JSON.stringify(body));
777
+ };
778
+ if (req.method === 'GET' && url.pathname === '/events') {
779
+ // The poll endpoint for driver-side consumption: everything the user
780
+ // did to the pet since `since`, plus the newest seq (a smaller newest
781
+ // than the caller's last means the pet restarted; re-pull from 0).
782
+ // v4 long-poll: `hold` (seconds, clamped ≤30) parks the request while
783
+ // the queue is empty and answers the instant an event lands or the
784
+ // budget lapses (an empty batch — the caller re-arms). Backlog and a
785
+ // rewind (`latest < since` = the pet restarted) always answer
786
+ // immediately, hold or not.
787
+ const since = Number(url.searchParams.get('since') ?? '0');
788
+ const rawHold = Number(url.searchParams.get('hold') ?? '0');
789
+ const hold = Number.isFinite(rawHold) ? Math.min(30, Math.max(0, rawHold)) : 0;
790
+ const answer = () => {
791
+ const events = touchEvents.filter(entry => entry.seq > since);
792
+ // chat 模态释放:事件被本响应实际携出 = 发送成功(§6 修订)
793
+ if (pendingChatSeq !== null && events.some(entry => entry.seq === pendingChatSeq)) {
794
+ pendingChatSeq = null;
795
+ }
796
+ send(200, { events, latest: touchSeq });
797
+ };
798
+ const hasBacklog = touchEvents.some(entry => entry.seq > since);
799
+ if (hold <= 0 || touchSeq < since || hasBacklog) {
800
+ answer();
801
+ return;
802
+ }
803
+ let settled = false;
804
+ const finish = () => {
805
+ if (settled)
806
+ return;
807
+ settled = true;
808
+ eventWaiters.delete(finish);
809
+ clearTimeout(timer);
810
+ answer();
811
+ };
812
+ const timer = setTimeout(finish, hold * 1000);
813
+ eventWaiters.add(finish);
814
+ res.on('close', () => {
815
+ // The caller left (restart, crash, re-discovery): release without
816
+ // writing to the dead socket.
817
+ if (settled)
818
+ return;
819
+ settled = true;
820
+ eventWaiters.delete(finish);
821
+ clearTimeout(timer);
822
+ });
823
+ return;
824
+ }
825
+ if (req.method === 'GET' && url.pathname === '/capabilities') {
826
+ // v4: the endpoints/events word-lists retired — the version field
827
+ // carries endpoint existence, the event body's own `type` field
828
+ // carries event-type tolerance (§6 revision record).
829
+ send(200, {
830
+ contractVersion: 4,
831
+ states: ['idle', 'thinking', 'working', 'error'],
832
+ // The pet's own action menu, scanned from the media tree at boot —
833
+ // celebrate first (the universal alias), then every playable clip.
834
+ actions: actionIds,
835
+ interactions: ['approval', 'question'],
836
+ });
837
+ return;
838
+ }
839
+ if (url.pathname === '/status') {
840
+ // The mirror: what the pet currently is doing, where it stands. v2:
841
+ // `location` (the pet-body rect) + `world` (the roaming area, null =
842
+ // unset) replace the retired `window`; v3: `lastAction` is ONE object
843
+ // — accepted-on-write, completion backfilled in place, null = no
844
+ // record yet (constant output). `bubble` is the delivery receipt for
845
+ // /say; a conditionally-absent field is a JSON.stringify footgun, so
846
+ // both witnesses stay always-present.
847
+ const bounds = window.getBounds();
848
+ send(200, {
849
+ ...statusSnapshot,
850
+ bubble: bubbleVisible,
851
+ lastAction,
852
+ interaction: pendingInteractions.size > 0,
853
+ location: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
854
+ world: activeWorld,
855
+ visible: window.isVisible(),
856
+ pid: process.pid,
857
+ });
858
+ return;
859
+ }
860
+ if (req.method !== 'POST') {
861
+ send(405, { error: 'POST only' });
862
+ return;
863
+ }
864
+ let raw = '';
865
+ req.setEncoding('utf8');
866
+ req.on('data', (chunk) => { raw += chunk; });
867
+ req.on('end', () => {
868
+ let body;
869
+ try {
870
+ body = JSON.parse(raw);
871
+ }
872
+ catch {
873
+ send(400, { error: 'invalid json' });
874
+ return;
875
+ }
876
+ if (url.pathname === '/state') {
877
+ console.log(`[pet] control /state ${JSON.stringify(body)}`);
878
+ window.webContents.send('pet:set-state', body);
879
+ send(200, { ok: true });
880
+ return;
881
+ }
882
+ if (url.pathname === '/say') {
883
+ // Fire-and-forget: the driver says something through the pet; the
884
+ // bubble self-dismisses and never blocks a turn.
885
+ const text = typeof body.text === 'string' ? body.text.slice(0, 200) : '';
886
+ window.webContents.send('pet:say', { text });
887
+ send(200, { ok: true });
888
+ return;
889
+ }
890
+ if (url.pathname === '/action') {
891
+ // Validate at the boundary: the driver pre-validates against
892
+ // /capabilities, but a raw consumer gets this pet's own menu here.
893
+ // A rejected request is NOT recorded as an action (no requestId, no
894
+ // forwarding) so the wait machinery never waits on nothing.
895
+ const requested = typeof body.action === 'string' ? body.action : 'unknown';
896
+ if (requested !== 'celebrate' && !actionCatalog.some(entry => entry.id === requested)) {
897
+ send(422, { ok: false, error: `unknown action "${requested}"`, available: actionIds });
898
+ return;
899
+ }
900
+ // Record the request observably: one-shot playback is fire-and-forget
901
+ // and the renderer's override is not a reliable witness (it may
902
+ // set/clear within one tick) — consumers poll the v3 witness instead
903
+ // of guessing from override. The requestId threads through the
904
+ // renderer's completion report, so the witness names the REQUESTED
905
+ // action and pet_do(wait) can match precisely.
906
+ const requestId = randomUUID();
907
+ const startedAt = Date.now();
908
+ lastAction = {
909
+ id: requestId,
910
+ action: requested,
911
+ requestedBy: 'tool',
912
+ startedAt,
913
+ durationMs: null,
914
+ endedAt: null,
915
+ };
916
+ for (const [key, value] of pendingActionRequests) {
917
+ if (startedAt - value.at > 120_000)
918
+ pendingActionRequests.delete(key);
919
+ }
920
+ pendingActionRequests.set(requestId, { requested, at: startedAt });
921
+ window.webContents.send('pet:action', { ...body, requestId });
922
+ send(200, { ok: true, action: requested, requestId });
923
+ return;
924
+ }
925
+ if (url.pathname === '/interaction') {
926
+ // Long-poll: the response IS the user's choice. A driver that gives
927
+ // up aborts the connection; the close handler withdraws the bubble.
928
+ const id = randomUUID();
929
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
930
+ pendingInteractions.set(id, res);
931
+ // Leading whitespace is valid JSON framing: keepalive spaces stop an
932
+ // idle-body timeout on the driver's fetch without touching the answer.
933
+ const keepalive = setInterval(() => { res.write(' '); }, 60_000);
934
+ res.on('close', () => {
935
+ clearInterval(keepalive);
936
+ if (pendingInteractions.delete(id)) {
937
+ window.webContents.send('pet:interaction-cancel', { id });
938
+ }
939
+ });
940
+ window.webContents.send('pet:interaction', { id, ...body });
941
+ return;
942
+ }
943
+ if (url.pathname === '/world') {
944
+ // §6 v2: set the roaming area. Effective IMMEDIATELY — a pet outside
945
+ // the rect is clamped in before the response leaves. `{"reset":true}`
946
+ // returns to default work-area clamping. The 200 body carries the
947
+ // EFFECTIVE rect (not a request echo — an implementation may shrink
948
+ // or clamp) and the post-clamp location. A rect that is not four
949
+ // finite positive numbers, or that intersects no display's work
950
+ // area, is a 422, not a silent no-op.
951
+ if (body.reset === true) {
952
+ activeWorld = null;
953
+ writeWorld();
954
+ const moved = clampWindowIntoDefaultArea();
955
+ send(200, { ok: true, world: null, location: currentLocation(), moved });
956
+ return;
957
+ }
958
+ const rect = rectOf(body);
959
+ if (rect === null || rect.width <= 0 || rect.height <= 0
960
+ || !screen.getAllDisplays().some(display => rectsIntersect(rect, display.workArea))) {
961
+ send(422, { ok: false, error: 'invalid world: expected {x,y,width,height} intersecting a display work area, or {"reset":true}' });
962
+ return;
963
+ }
964
+ activeWorld = rect;
965
+ writeWorld();
966
+ const moved = clampWindowIntoWorld();
967
+ send(200, { ok: true, world: activeWorld, location: currentLocation(), moved });
968
+ return;
969
+ }
970
+ send(404, { error: 'unknown endpoint' });
971
+ });
972
+ });
973
+ control.listen(Number(process.env.DSH_PET_HTTP_PORT ?? 0), '127.0.0.1', () => {
974
+ const address = control.address();
975
+ const port = typeof address === 'object' && address !== null ? address.port : 0;
976
+ console.log(`[pet] control interface on 127.0.0.1:${String(port)}`);
977
+ // Registry publish: one file per pet under pet-http.d/ — no shared name,
978
+ // no last-writer-wins race (the incident class that produced repeated
979
+ // swap blindness). The legacy single file keeps being written during the
980
+ // transition so pre-registry readers still see this carrier.
981
+ const manifest = `${JSON.stringify({ port, pid: process.pid, since: Date.now(), pet: 'electron' })}\n`;
982
+ const registryDir = join(DSH_HOME, 'pet-http.d');
983
+ try {
984
+ mkdirSync(registryDir, { recursive: true });
985
+ }
986
+ catch (error) {
987
+ console.error('[pet] could not create the pet registry:', String(error));
988
+ }
989
+ const writeManifest = () => {
990
+ void writeFile(join(registryDir, 'electron.json'), manifest, 'utf8')
991
+ .catch((error) => { console.error('[pet] could not write the registry entry:', String(error)); });
992
+ void writeFile(join(DSH_HOME, 'pet-http-electron.json'), manifest, 'utf8')
993
+ .catch(() => { });
994
+ };
995
+ writeManifest();
996
+ // Re-assert on a cadence so a stale publisher cannot shadow a live pet.
997
+ setInterval(writeManifest, 10_000);
998
+ });
999
+ // Runtime watchdog: a driver-spawned pet outlives nothing. Dispose-time
1000
+ // kill only runs on the runtime's graceful shutdown; a terminated runtime
1001
+ // leaves a detached pet orphaned on the desktop. Poll the spawning
1002
+ // runtime's pid and exit with it.
1003
+ const runtimePid = Number(process.env.DSH_PET_RUNTIME_PID);
1004
+ if (Number.isInteger(runtimePid) && runtimePid > 0) {
1005
+ setInterval(() => {
1006
+ try {
1007
+ process.kill(runtimePid, 0);
1008
+ }
1009
+ catch {
1010
+ console.log('[pet] the spawning runtime is gone; exiting');
1011
+ app.quit();
1012
+ }
1013
+ }, 10_000);
1014
+ }
1015
+ window.on('closed', () => {
1016
+ // The window is the carrier's whole surface: closing it tears the profile down.
1017
+ void handle?.shutdown();
1018
+ });
1019
+ app.on('before-quit', (event) => {
1020
+ // Withdraw our registry entry (and the legacy mirror) so consumers never
1021
+ // find a dead port in pet-http.d/ — stale entries were the "旧端点残留"
1022
+ // incident class. Best effort: a killed carrier leaves the file, and the
1023
+ // readers' pid+probe validation is the backstop.
1024
+ for (const file of ['pet-http.d/electron.json', 'pet-http-electron.json']) {
1025
+ void rm(join(DSH_HOME, file), { force: true }).catch(() => { });
1026
+ }
1027
+ if (handle === undefined)
1028
+ return;
1029
+ event.preventDefault();
1030
+ const closing = handle.shutdown();
1031
+ handle = undefined;
1032
+ void closing.then(() => app.quit());
1033
+ });
1034
+ }
1035
+ app.on('window-all-closed', () => {
1036
+ app.quit();
1037
+ });
1038
+ //# sourceMappingURL=main.js.map