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.
- package/README.i18n.yaml +4 -0
- package/README.md +49 -2
- package/README.zh.md +50 -0
- package/carrier/dist-renderer/assets/index-BHG2MI7t.css +1 -0
- package/carrier/dist-renderer/assets/index-C61Q_dyJ.js +1 -0
- package/carrier/dist-renderer/index.html +19 -0
- package/carrier/lib/host-child.js +49 -0
- package/carrier/lib/main.js +1038 -0
- package/carrier/lib/preload.js +63 -0
- package/carrier/package.json +8 -0
- package/client/pet-settings.js +261 -0
- package/cordis.patch.yml +11 -0
- package/lib/index.js +589 -0
- package/lib/service-Vj-lLpIv.js +6258 -0
- package/lib/tools.js +41 -0
- package/lib/types/actions.d.ts +42 -0
- package/lib/types/driver.d.ts +57 -0
- package/lib/types/index.d.ts +64 -0
- package/lib/types/inner-voice.d.ts +103 -0
- package/lib/types/registry.d.ts +54 -0
- package/lib/types/service.d.ts +159 -0
- package/lib/types/settings-page.d.ts +22 -0
- package/lib/types/tools.d.ts +29 -0
- package/package.json +58 -3
- package/presets/pet-inner-voice.patch.yml +53 -0
- package/presets/pet.patch.yml +142 -0
|
@@ -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
|