@luv1211/dsh-pet 0.1.1-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.i18n.yaml +6 -0
- package/README.md +35 -0
- package/README.zh.md +35 -0
- package/assets/deepseek-whale/pet.json +12 -0
- package/assets/deepseek-whale/spritesheet.webp +0 -0
- package/lib/index.js +1613 -0
- package/lib/invariant.js +19 -0
- package/lib/types/activity.d.ts +29 -0
- package/lib/types/activity.js +63 -0
- package/lib/types/catalog.d.ts +67 -0
- package/lib/types/catalog.js +258 -0
- package/lib/types/client.d.ts +13 -0
- package/lib/types/client.js +10 -0
- package/lib/types/host-image.d.ts +11 -0
- package/lib/types/host-image.js +48 -0
- package/lib/types/host-native.d.ts +17 -0
- package/lib/types/host-native.js +50 -0
- package/lib/types/index.d.ts +114 -0
- package/lib/types/index.js +631 -0
- package/lib/types/invariant.d.ts +9 -0
- package/lib/types/invariant.js +18 -0
- package/lib/types/path-opener.d.ts +50 -0
- package/lib/types/path-opener.js +161 -0
- package/lib/types/renderer.d.ts +42 -0
- package/lib/types/renderer.js +54 -0
- package/lib/types/runtime.d.ts +155 -0
- package/lib/types/runtime.js +314 -0
- package/lib/types/types.d.ts +147 -0
- package/lib/types/types.js +7 -0
- package/package.json +111 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1613 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
4
|
+
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
5
|
+
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
9
|
+
import { DEFAULT_PET_ANIMATIONS, FRAME_AT_SOURCE, frameAt, parsePetPackage } from "@luv1211/dsh-pet-compat";
|
|
10
|
+
import { imageDimensionsFromData } from "image-dimensions";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess";
|
|
14
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
15
|
+
import { release } from "node:os";
|
|
16
|
+
import { runNativeCommand } from "@deepseek-ai/dsh-native-command";
|
|
17
|
+
//#region lib/types/activity.js
|
|
18
|
+
/** Host activity projection adapter consumed by the pet domain. */
|
|
19
|
+
/**
|
|
20
|
+
* Owns detached host activity records and publishes whole replacements. When
|
|
21
|
+
* constructed with a Context, it supplies the existing session lifecycle as
|
|
22
|
+
* the default host producer; a richer host projection can instead be
|
|
23
|
+
* provided to PetService through the `petActivity` service key.
|
|
24
|
+
*/
|
|
25
|
+
var PetActivityProjection = class {
|
|
26
|
+
records = /* @__PURE__ */ new Map();
|
|
27
|
+
listeners = /* @__PURE__ */ new Set();
|
|
28
|
+
/**
|
|
29
|
+
* @param ctx - optional host context for the default session producer.
|
|
30
|
+
*/
|
|
31
|
+
constructor(ctx) {
|
|
32
|
+
if (ctx === void 0) return;
|
|
33
|
+
ctx.on("session/event", (session, event) => {
|
|
34
|
+
this.observe(session, event);
|
|
35
|
+
}, { global: true });
|
|
36
|
+
ctx.on("session/disposed", (session) => {
|
|
37
|
+
this.forget(session);
|
|
38
|
+
}, { global: true });
|
|
39
|
+
}
|
|
40
|
+
/** Read a detached activity projection. */
|
|
41
|
+
getSnapshot() {
|
|
42
|
+
return [...this.records.values()].map((record) => ({ ...record }));
|
|
43
|
+
}
|
|
44
|
+
/** Subscribe to whole detached projection replacements. */
|
|
45
|
+
subscribe(listener) {
|
|
46
|
+
this.listeners.add(listener);
|
|
47
|
+
return () => {
|
|
48
|
+
this.listeners.delete(listener);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Publish one host-owned replacement after its producer commits.
|
|
52
|
+
* @param records - detached records replacing the current projection.
|
|
53
|
+
*/
|
|
54
|
+
publish(records) {
|
|
55
|
+
this.records.clear();
|
|
56
|
+
for (const record of records) this.records.set(String(record.sessionId), { ...record });
|
|
57
|
+
this.notify();
|
|
58
|
+
}
|
|
59
|
+
observe(session, event) {
|
|
60
|
+
if (event.type !== "turn/start" && event.type !== "turn/end") return;
|
|
61
|
+
const record = event.type === "turn/start" ? {
|
|
62
|
+
sessionId: session.id,
|
|
63
|
+
title: String(session.id),
|
|
64
|
+
status: "running",
|
|
65
|
+
since: event.time,
|
|
66
|
+
completed: false
|
|
67
|
+
} : {
|
|
68
|
+
sessionId: session.id,
|
|
69
|
+
title: String(session.id),
|
|
70
|
+
status: event.data.reason.kind === "blocked" || event.data.reason.kind === "error" ? "blocked" : "idle",
|
|
71
|
+
since: event.time,
|
|
72
|
+
completed: event.data.reason.kind !== "blocked" && event.data.reason.kind !== "error"
|
|
73
|
+
};
|
|
74
|
+
this.records.set(String(session.id), record);
|
|
75
|
+
this.notify();
|
|
76
|
+
}
|
|
77
|
+
forget(session) {
|
|
78
|
+
if (this.records.delete(String(session.id))) this.notify();
|
|
79
|
+
}
|
|
80
|
+
notify() {
|
|
81
|
+
const snapshot = this.getSnapshot();
|
|
82
|
+
for (const listener of this.listeners) listener(snapshot);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region lib/types/runtime.js
|
|
87
|
+
/** Browser-safe pet constants, validation, presentation selection, and timing. */
|
|
88
|
+
/** Version of the durable pet preference document. */
|
|
89
|
+
const PET_PREFERENCE_VERSION = 3;
|
|
90
|
+
/** Built-in identifier selected by a fresh preference document. */
|
|
91
|
+
const DEFAULT_PET_ID = "deepseek-whale";
|
|
92
|
+
/** Default logical CSS height of one compatible atlas cell. */
|
|
93
|
+
const DEFAULT_PET_SIZE_PX = 112;
|
|
94
|
+
/** Minimum logical CSS height accepted by the pet preference validator. */
|
|
95
|
+
const MIN_PET_SIZE_PX = 80;
|
|
96
|
+
/** Maximum logical CSS height accepted by the pet preference validator. */
|
|
97
|
+
const MAX_PET_SIZE_PX = 224;
|
|
98
|
+
/** Compatible atlas geometry owned by the DSH renderer. */
|
|
99
|
+
const PET_COMPAT_ATLAS = Object.freeze({
|
|
100
|
+
width: 1536,
|
|
101
|
+
height: 1872,
|
|
102
|
+
cellWidth: 192,
|
|
103
|
+
cellHeight: 208,
|
|
104
|
+
columns: 8,
|
|
105
|
+
rows: 9
|
|
106
|
+
});
|
|
107
|
+
/** Stable validation error that callers can diagnose without parsing messages. */
|
|
108
|
+
var PetValidationError = class extends TypeError {
|
|
109
|
+
/** Machine-readable validation category. */
|
|
110
|
+
code = "invalid-pet-package";
|
|
111
|
+
constructor(message) {
|
|
112
|
+
super(message);
|
|
113
|
+
this.name = "PetValidationError";
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
/** Numeric status precedence, where lower values are selected first. */
|
|
117
|
+
const ACTIVITY_PRIORITY = {
|
|
118
|
+
"needs-input": 0,
|
|
119
|
+
blocked: 1,
|
|
120
|
+
ready: 2,
|
|
121
|
+
running: 3
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Sort activity records by user-action urgency, then newest state transition,
|
|
125
|
+
* then their stable opaque session ids.
|
|
126
|
+
* @param left - the first activity record.
|
|
127
|
+
* @param right - the second activity record.
|
|
128
|
+
* @returns a standard ascending sort comparison result.
|
|
129
|
+
*/
|
|
130
|
+
function comparePetActivities(left, right) {
|
|
131
|
+
const priority = ACTIVITY_PRIORITY[left.status] - ACTIVITY_PRIORITY[right.status];
|
|
132
|
+
if (priority !== 0) return priority;
|
|
133
|
+
if (left.since !== right.since) return right.since - left.since;
|
|
134
|
+
return left.sessionId < right.sessionId ? -1 : left.sessionId > right.sessionId ? 1 : 0;
|
|
135
|
+
}
|
|
136
|
+
/** Resolve a missing preference or validate a current preference document.
|
|
137
|
+
* @param value - decoded preference value from the settings provider.
|
|
138
|
+
* @returns the validated v3 preference.
|
|
139
|
+
*/
|
|
140
|
+
function resolvePetPreference(value) {
|
|
141
|
+
if (value === void 0 || value === null) return defaultPetPreference();
|
|
142
|
+
if (!isRecord(value) || typeof value.version !== "number") throw new TypeError("pet preference must be an object with a numeric version");
|
|
143
|
+
if (value.version !== 3) throw new TypeError(`pet preference version ${String(value.version)} is unsupported (expected 3)`);
|
|
144
|
+
return {
|
|
145
|
+
version: 3,
|
|
146
|
+
selectedPetId: requirePetId(value.selectedPetId),
|
|
147
|
+
awake: requireBoolean(value.awake, "awake"),
|
|
148
|
+
sizePx: validatePetSize(value.sizePx)
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Return the fresh v3 preference defaults.
|
|
152
|
+
* @returns a new v3 preference document.
|
|
153
|
+
*/
|
|
154
|
+
function defaultPetPreference() {
|
|
155
|
+
return {
|
|
156
|
+
version: 3,
|
|
157
|
+
selectedPetId: DEFAULT_PET_ID,
|
|
158
|
+
awake: true,
|
|
159
|
+
sizePx: 112
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** Validate one logical CSS height.
|
|
163
|
+
* @param sizePx - candidate logical CSS height.
|
|
164
|
+
* @returns the validated height.
|
|
165
|
+
*/
|
|
166
|
+
function validatePetSize(sizePx) {
|
|
167
|
+
if (typeof sizePx !== "number" || !Number.isSafeInteger(sizePx) || sizePx < 80 || sizePx > 224) throw new TypeError(`pet preference sizePx must be between 80 and 224`);
|
|
168
|
+
return sizePx;
|
|
169
|
+
}
|
|
170
|
+
/** Return true only after pointer movement exceeds the shared four-pixel drag threshold.
|
|
171
|
+
* @param deltaX - horizontal pointer displacement.
|
|
172
|
+
* @param deltaY - vertical pointer displacement.
|
|
173
|
+
* @param threshold - minimum Euclidean displacement.
|
|
174
|
+
* @returns whether the displacement is a drag.
|
|
175
|
+
*/
|
|
176
|
+
function isDragMovement(deltaX, deltaY, threshold = 4) {
|
|
177
|
+
return Number.isFinite(deltaX) && Number.isFinite(deltaY) && Number.isFinite(threshold) && threshold >= 0 && Math.hypot(deltaX, deltaY) > threshold;
|
|
178
|
+
}
|
|
179
|
+
/** Derive the logical CSS width from one validated atlas-cell height.
|
|
180
|
+
* @param sizePx - validated logical CSS height.
|
|
181
|
+
* @returns the corresponding logical CSS width.
|
|
182
|
+
*/
|
|
183
|
+
function petWidthForSize(sizePx) {
|
|
184
|
+
return Math.round(validatePetSize(sizePx) * PET_COMPAT_ATLAS.cellWidth / PET_COMPAT_ATLAS.cellHeight);
|
|
185
|
+
}
|
|
186
|
+
/** Pick one of sixteen clockwise look-direction cells from a relative target.
|
|
187
|
+
* @param target - relative pointer target, or `undefined` for neutral direction.
|
|
188
|
+
* @returns a direction index from zero through fifteen.
|
|
189
|
+
*/
|
|
190
|
+
function selectLookDirection(target) {
|
|
191
|
+
if (target === void 0 || !Number.isFinite(target.x) || !Number.isFinite(target.y)) return 0;
|
|
192
|
+
if (Math.abs(target.x) <= 1 && Math.abs(target.y) <= 1) return 0;
|
|
193
|
+
const angle = Math.atan2(target.x, -target.y);
|
|
194
|
+
const normalized = angle < 0 ? angle + Math.PI * 2 : angle;
|
|
195
|
+
return Math.floor((normalized + Math.PI / 16) / (Math.PI / 8)) % 16;
|
|
196
|
+
}
|
|
197
|
+
/** Select the state and first frame used to render one presentation update.
|
|
198
|
+
* @param input - current host, pointer, motion, and wake state.
|
|
199
|
+
* @returns the renderer-independent presentation selection.
|
|
200
|
+
*/
|
|
201
|
+
function selectPetPresentation(input) {
|
|
202
|
+
const lookDirection = selectLookDirection(input.lookTarget);
|
|
203
|
+
if (!input.awake) return {
|
|
204
|
+
state: "tucked",
|
|
205
|
+
row: 0,
|
|
206
|
+
frame: 0,
|
|
207
|
+
lookDirection,
|
|
208
|
+
lookDirectionActive: false,
|
|
209
|
+
animate: false
|
|
210
|
+
};
|
|
211
|
+
let state = statusToAnimationState(input.status);
|
|
212
|
+
if (input.hover) state = "jumping";
|
|
213
|
+
else if (input.status === "running" && input.dragDirection === "left") state = "running-left";
|
|
214
|
+
else if (input.status === "running" && input.dragDirection === "right") state = "running-right";
|
|
215
|
+
const spriteIndex = DEFAULT_PET_ANIMATIONS[state]?.frames[0]?.spriteIndex ?? 0;
|
|
216
|
+
return {
|
|
217
|
+
state,
|
|
218
|
+
row: Math.floor(spriteIndex / PET_COMPAT_ATLAS.columns),
|
|
219
|
+
frame: spriteIndex % PET_COMPAT_ATLAS.columns,
|
|
220
|
+
lookDirection,
|
|
221
|
+
lookDirectionActive: false,
|
|
222
|
+
animate: !input.reducedMotion
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** Validate a compatible manifest and its WebP dimensions without Node or filesystem APIs.
|
|
226
|
+
* @param manifestBytes - UTF-8 pet.json bytes.
|
|
227
|
+
* @param spritesheetBytes - WebP atlas bytes.
|
|
228
|
+
* @param options - catalog origin, optional asset URL, and byte limits.
|
|
229
|
+
* @returns a sanitized descriptor suitable for client transport.
|
|
230
|
+
*/
|
|
231
|
+
function validatePetPackage(manifestBytes, spritesheetBytes, options) {
|
|
232
|
+
return validatePetPackageFiles(manifestBytes, spritesheetBytes, options).descriptor;
|
|
233
|
+
}
|
|
234
|
+
/** Validate package bytes and retain the manifest-relative asset location for host storage.
|
|
235
|
+
* @param manifestBytes - UTF-8 pet.json bytes.
|
|
236
|
+
* @param spritesheetBytes - WebP atlas bytes.
|
|
237
|
+
* @param options - catalog origin, optional asset URL, and byte limits.
|
|
238
|
+
* @returns sanitized client metadata and the validated relative spritesheet path.
|
|
239
|
+
*/
|
|
240
|
+
function validatePetPackageFiles(manifestBytes, spritesheetBytes, options) {
|
|
241
|
+
const maxManifestBytes = options.maxManifestBytes ?? 16384;
|
|
242
|
+
const maxSpriteBytes = options.maxSpriteBytes ?? 16777216;
|
|
243
|
+
if (!Number.isSafeInteger(maxManifestBytes) || maxManifestBytes <= 0) throw new PetValidationError("manifest byte limit is invalid");
|
|
244
|
+
if (!Number.isSafeInteger(maxSpriteBytes) || maxSpriteBytes <= 0) throw new PetValidationError("spritesheet byte limit is invalid");
|
|
245
|
+
if (manifestBytes.byteLength > maxManifestBytes) throw new PetValidationError("pet manifest exceeds the configured byte limit");
|
|
246
|
+
if (spritesheetBytes.byteLength === 0 || spritesheetBytes.byteLength > maxSpriteBytes) throw new PetValidationError("pet spritesheet exceeds the configured byte limit");
|
|
247
|
+
parseManifest(manifestBytes, {
|
|
248
|
+
width: PET_COMPAT_ATLAS.width,
|
|
249
|
+
height: PET_COMPAT_ATLAS.height
|
|
250
|
+
});
|
|
251
|
+
const pet = parseManifest(manifestBytes, webpDimensions(spritesheetBytes));
|
|
252
|
+
const assetUrl = options.assetUrl ?? "";
|
|
253
|
+
if (assetUrl !== "" && !isOriginRelativePathname(assetUrl)) throw new PetValidationError("pet assetUrl must be an origin-relative pathname");
|
|
254
|
+
const descriptor = Object.freeze({
|
|
255
|
+
id: pet.id,
|
|
256
|
+
source: options.source,
|
|
257
|
+
displayName: pet.displayName,
|
|
258
|
+
...pet.description === "" ? {} : { description: pet.description },
|
|
259
|
+
frame: pet.frame,
|
|
260
|
+
animations: pet.animations,
|
|
261
|
+
assetUrl
|
|
262
|
+
});
|
|
263
|
+
return Object.freeze({
|
|
264
|
+
descriptor,
|
|
265
|
+
spritesheetPath: pet.spritesheetPath
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/** Resolve the safe relative spritesheet location before reading the image file.
|
|
269
|
+
* @param manifestBytes - bounded UTF-8 pet.json bytes.
|
|
270
|
+
* @returns the manifest-relative spritesheet path.
|
|
271
|
+
*/
|
|
272
|
+
function petSpritesheetPath(manifestBytes) {
|
|
273
|
+
return parseManifest(manifestBytes, {
|
|
274
|
+
width: PET_COMPAT_ATLAS.width,
|
|
275
|
+
height: PET_COMPAT_ATLAS.height
|
|
276
|
+
}).spritesheetPath;
|
|
277
|
+
}
|
|
278
|
+
/** Convert a host activity record into the pet's display status.
|
|
279
|
+
* @param record - detached host activity record.
|
|
280
|
+
* @returns the display status, or `undefined` when the record is idle.
|
|
281
|
+
*/
|
|
282
|
+
function petStatusForHostActivity(record) {
|
|
283
|
+
if (record.pendingInteraction !== void 0) return "needs-input";
|
|
284
|
+
if (record.status === "blocked") return "blocked";
|
|
285
|
+
if (record.completed) return "ready";
|
|
286
|
+
if (record.status === "running") return "running";
|
|
287
|
+
}
|
|
288
|
+
function statusToAnimationState(status) {
|
|
289
|
+
switch (status) {
|
|
290
|
+
case "needs-input": return "waiting";
|
|
291
|
+
case "blocked": return "failed";
|
|
292
|
+
case "ready": return "review";
|
|
293
|
+
case "running": return "running";
|
|
294
|
+
default: return "idle";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function requirePetId(value) {
|
|
298
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 64) throw new TypeError("pet preference selectedPetId must be a non-empty string");
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
function requireBoolean(value, name) {
|
|
302
|
+
if (typeof value !== "boolean") throw new TypeError(`pet preference ${name} must be boolean`);
|
|
303
|
+
return value;
|
|
304
|
+
}
|
|
305
|
+
function isRecord(value) {
|
|
306
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
307
|
+
}
|
|
308
|
+
function webpDimensions(bytes) {
|
|
309
|
+
assertCompleteWebp(bytes);
|
|
310
|
+
const dimensions = imageDimensionsFromData(bytes);
|
|
311
|
+
if (dimensions?.type !== "webp") throw new PetValidationError("pet spritesheet must be a valid WebP image");
|
|
312
|
+
return {
|
|
313
|
+
width: dimensions.width,
|
|
314
|
+
height: dimensions.height
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function assertCompleteWebp(bytes) {
|
|
318
|
+
if (bytes.length < 20 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WEBP") throw new PetValidationError("pet spritesheet must be a valid WebP image");
|
|
319
|
+
if (readUint32(bytes, 4) + 8 !== bytes.length) throw new PetValidationError("pet spritesheet must be a valid WebP image");
|
|
320
|
+
let offset = 12;
|
|
321
|
+
let hasImagePayload = false;
|
|
322
|
+
while (offset + 8 <= bytes.length) {
|
|
323
|
+
const chunk = ascii(bytes, offset, 4);
|
|
324
|
+
const size = readUint32(bytes, offset + 4);
|
|
325
|
+
const data = offset + 8;
|
|
326
|
+
if (data + size > bytes.length) throw new PetValidationError("pet spritesheet must be a valid WebP image");
|
|
327
|
+
if (chunk === "VP8 " && size >= 10 && (readByte(bytes, data) & 1) === 0 && bytes[data + 3] === 157 && bytes[data + 4] === 1 && bytes[data + 5] === 42) hasImagePayload = true;
|
|
328
|
+
if (chunk === "VP8L" && size >= 5 && bytes[data] === 47) hasImagePayload = true;
|
|
329
|
+
offset = data + size + size % 2;
|
|
330
|
+
}
|
|
331
|
+
if (offset !== bytes.length || !hasImagePayload) throw new PetValidationError("pet spritesheet must be a valid WebP image");
|
|
332
|
+
}
|
|
333
|
+
function parseManifest(manifestBytes, dimensions) {
|
|
334
|
+
let value;
|
|
335
|
+
try {
|
|
336
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(manifestBytes));
|
|
337
|
+
} catch {
|
|
338
|
+
throw new PetValidationError("pet manifest must be valid UTF-8 JSON");
|
|
339
|
+
}
|
|
340
|
+
if (!isRecord(value) || Array.isArray(value)) throw new PetValidationError("pet manifest must be a JSON object");
|
|
341
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
342
|
+
"id",
|
|
343
|
+
"displayName",
|
|
344
|
+
"description",
|
|
345
|
+
"spritesheetPath",
|
|
346
|
+
"frame",
|
|
347
|
+
"animations",
|
|
348
|
+
"kind",
|
|
349
|
+
"spriteVersionNumber"
|
|
350
|
+
]);
|
|
351
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new PetValidationError(`pet manifest contains unsupported field ${key}`);
|
|
352
|
+
const id = value.id;
|
|
353
|
+
if (typeof id !== "string" || !/^[\p{L}][\p{L}\p{N}._-]{0,63}$/u.test(id)) throw new PetValidationError("pet manifest id is invalid");
|
|
354
|
+
const displayName = value.displayName;
|
|
355
|
+
if (typeof displayName !== "string" || displayName.length === 0 || displayName.length > 80) throw new PetValidationError("pet manifest displayName is invalid");
|
|
356
|
+
const description = value.description;
|
|
357
|
+
if (description !== void 0 && (typeof description !== "string" || description.length > 500)) throw new PetValidationError("pet manifest description is invalid");
|
|
358
|
+
const parsed = parsePetPackage({
|
|
359
|
+
...value,
|
|
360
|
+
spritesheetDimensions: dimensions
|
|
361
|
+
});
|
|
362
|
+
if (!parsed.accepted) throw new PetValidationError(`pet package is incompatible: ${parsed.reason}`);
|
|
363
|
+
return parsed.pet;
|
|
364
|
+
}
|
|
365
|
+
function ascii(bytes, offset, length) {
|
|
366
|
+
return String.fromCharCode(...bytes.subarray(offset, offset + length));
|
|
367
|
+
}
|
|
368
|
+
function readUint32(bytes, offset) {
|
|
369
|
+
return readByte(bytes, offset) + (readByte(bytes, offset + 1) << 8) + (readByte(bytes, offset + 2) << 16) + readByte(bytes, offset + 3) * 16777216;
|
|
370
|
+
}
|
|
371
|
+
function readByte(bytes, offset) {
|
|
372
|
+
const value = bytes[offset];
|
|
373
|
+
if (value === void 0) throw new PetValidationError("pet spritesheet has a truncated chunk");
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
function isOriginRelativePathname(value) {
|
|
377
|
+
if (!value.startsWith("/") || value.startsWith("//")) return false;
|
|
378
|
+
const parsed = new URL(value, "http://dsh.local");
|
|
379
|
+
return parsed.origin === "http://dsh.local" && parsed.pathname === value && parsed.search === "" && parsed.hash === "";
|
|
380
|
+
}
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region lib/types/host-image.js
|
|
383
|
+
/** Host-only complete WebP decoding for package publication. */
|
|
384
|
+
const SHARP_ENTRY_URL = pathToFileURL(createRequire(import.meta.url).resolve("sharp")).href;
|
|
385
|
+
const DECODE_SCRIPT = `
|
|
386
|
+
import fs from 'node:fs';
|
|
387
|
+
import sharp from ${JSON.stringify(SHARP_ENTRY_URL)};
|
|
388
|
+
const input = fs.readFileSync(0);
|
|
389
|
+
try {
|
|
390
|
+
const result = await sharp(input).raw().toBuffer({ resolveWithObject: true });
|
|
391
|
+
process.stdout.write(JSON.stringify({ width: result.info.width, height: result.info.height }));
|
|
392
|
+
} catch (error) {
|
|
393
|
+
process.stderr.write(error instanceof Error ? error.message : String(error));
|
|
394
|
+
process.exitCode = 1;
|
|
395
|
+
}
|
|
396
|
+
`;
|
|
397
|
+
/** Decode every pixel in a bounded WebP inside an isolated process.
|
|
398
|
+
* @param bytes - already byte-limited candidate WebP data.
|
|
399
|
+
* @param timeoutMs - positive host-configured decode deadline.
|
|
400
|
+
* @returns dimensions reported by the successful decoder.
|
|
401
|
+
*/
|
|
402
|
+
function decodeWebpDimensions(bytes, timeoutMs) {
|
|
403
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new PetValidationError("pet spritesheet decode timeout must be positive");
|
|
404
|
+
const result = spawnSync(process.execPath, [
|
|
405
|
+
"--input-type=module",
|
|
406
|
+
"--eval",
|
|
407
|
+
DECODE_SCRIPT
|
|
408
|
+
], {
|
|
409
|
+
input: bytes,
|
|
410
|
+
encoding: "utf8",
|
|
411
|
+
env: scrubbedParentEnv(),
|
|
412
|
+
maxBuffer: 65536,
|
|
413
|
+
timeout: timeoutMs,
|
|
414
|
+
windowsHide: true
|
|
415
|
+
});
|
|
416
|
+
if (result.status !== 0) throw new PetValidationError("pet spritesheet must be a decodable WebP image");
|
|
417
|
+
try {
|
|
418
|
+
const dimensions = JSON.parse(result.stdout);
|
|
419
|
+
if (!Number.isSafeInteger(dimensions.width) || !Number.isSafeInteger(dimensions.height)) throw new Error("invalid decoder result");
|
|
420
|
+
return {
|
|
421
|
+
width: dimensions.width,
|
|
422
|
+
height: dimensions.height
|
|
423
|
+
};
|
|
424
|
+
} catch {
|
|
425
|
+
throw new PetValidationError("pet spritesheet decoder returned invalid dimensions");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
//#endregion
|
|
429
|
+
//#region lib/types/catalog.js
|
|
430
|
+
/** Host-side compatible pet catalog and transactional user-package storage. */
|
|
431
|
+
/** One validated built-in or user package with detached client metadata. */
|
|
432
|
+
var PetCatalogStore = class {
|
|
433
|
+
/** Absolute DSH-owned directory for user-installed pet packages. */
|
|
434
|
+
petRoot;
|
|
435
|
+
options;
|
|
436
|
+
records = /* @__PURE__ */ new Map();
|
|
437
|
+
listeners = /* @__PURE__ */ new Set();
|
|
438
|
+
/**
|
|
439
|
+
* Load the embedded package and the configured DSH user root.
|
|
440
|
+
* @param options - package root and validation limits.
|
|
441
|
+
*/
|
|
442
|
+
constructor(options = {}) {
|
|
443
|
+
this.options = { ...options };
|
|
444
|
+
this.petRoot = resolve(options.petRoot ?? join(resolveDshHome(options.dshHome), "pets"));
|
|
445
|
+
this.reload();
|
|
446
|
+
}
|
|
447
|
+
/** Read a detached deterministic catalog.
|
|
448
|
+
* @returns a stable descriptor list without filesystem references.
|
|
449
|
+
*/
|
|
450
|
+
getCatalog() {
|
|
451
|
+
return Object.freeze({ pets: Object.freeze([...this.records.values()].map((record) => ({ ...record.descriptor }))) });
|
|
452
|
+
}
|
|
453
|
+
/** Return a detached validated sprite for one catalog-owned id.
|
|
454
|
+
* @param id - catalog id to resolve.
|
|
455
|
+
* @returns a copied sprite byte array, or `undefined` for an unknown id.
|
|
456
|
+
*/
|
|
457
|
+
getAsset(id) {
|
|
458
|
+
const record = this.records.get(id);
|
|
459
|
+
return record === void 0 ? void 0 : new Uint8Array(record.spriteBytes);
|
|
460
|
+
}
|
|
461
|
+
/** Subscribe to catalog publication and return its disposer.
|
|
462
|
+
* @param listener - callback receiving each detached catalog.
|
|
463
|
+
* @returns a disposer that removes the listener.
|
|
464
|
+
*/
|
|
465
|
+
subscribe(listener) {
|
|
466
|
+
this.listeners.add(listener);
|
|
467
|
+
return () => {
|
|
468
|
+
this.listeners.delete(listener);
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/** Check that an id names a loaded package.
|
|
472
|
+
* @param id - catalog id to test.
|
|
473
|
+
* @returns whether the id is currently loaded.
|
|
474
|
+
*/
|
|
475
|
+
has(id) {
|
|
476
|
+
return this.records.has(id);
|
|
477
|
+
}
|
|
478
|
+
/** Create the configured user root before a host action opens it. */
|
|
479
|
+
ensureRoot() {
|
|
480
|
+
mkdirSync(this.petRoot, { recursive: true });
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Atomically publish one validated package under the user root.
|
|
484
|
+
* @param manifestBytes - UTF-8 compatible pet.json bytes.
|
|
485
|
+
* @param spritesheetBytes - validated WebP bytes.
|
|
486
|
+
* @returns the newly published descriptor.
|
|
487
|
+
*/
|
|
488
|
+
importPackage(manifestBytes, spritesheetBytes) {
|
|
489
|
+
const firstPass = validatePetPackageFiles(manifestBytes, spritesheetBytes, {
|
|
490
|
+
...this.options,
|
|
491
|
+
assetUrl: "",
|
|
492
|
+
source: "user"
|
|
493
|
+
});
|
|
494
|
+
decodeWebpDimensions(spritesheetBytes, this.options.decodeTimeoutMs ?? 1e4);
|
|
495
|
+
if (firstPass.descriptor.id === "deepseek-whale") throw new Error(`pet id ${DEFAULT_PET_ID} is reserved for the built-in package`);
|
|
496
|
+
if (this.records.has(firstPass.descriptor.id)) throw new Error(`pet id ${firstPass.descriptor.id} is already registered`);
|
|
497
|
+
mkdirSync(this.petRoot, { recursive: true });
|
|
498
|
+
const target = join(this.petRoot, firstPass.descriptor.id);
|
|
499
|
+
if (existsSync(target)) throw new Error(`pet package directory ${firstPass.descriptor.id} already exists`);
|
|
500
|
+
const temporary = join(this.petRoot, `.${firstPass.descriptor.id}.${randomUUID()}.tmp`);
|
|
501
|
+
try {
|
|
502
|
+
mkdirSync(temporary);
|
|
503
|
+
writeFileSync(join(temporary, "pet.json"), manifestBytes, { flag: "wx" });
|
|
504
|
+
const spriteTarget = join(temporary, firstPass.spritesheetPath.replaceAll("\\", "/"));
|
|
505
|
+
mkdirSync(dirname(spriteTarget), { recursive: true });
|
|
506
|
+
writeFileSync(spriteTarget, spritesheetBytes, { flag: "wx" });
|
|
507
|
+
renameSync(temporary, target);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
rmSync(temporary, {
|
|
510
|
+
recursive: true,
|
|
511
|
+
force: true
|
|
512
|
+
});
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
this.reload();
|
|
516
|
+
const published = this.records.get(firstPass.descriptor.id)?.descriptor;
|
|
517
|
+
if (published === void 0) throw new Error(`pet package ${firstPass.descriptor.id} was not published after atomic rename`);
|
|
518
|
+
return { ...published };
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Atomically replace one existing user package's content in place.
|
|
522
|
+
* @param manifestBytes - UTF-8 compatible pet.json bytes whose id names a loaded user package.
|
|
523
|
+
* @param spritesheetBytes - validated WebP bytes.
|
|
524
|
+
* @returns the freshly published descriptor.
|
|
525
|
+
*/
|
|
526
|
+
replacePackage(manifestBytes, spritesheetBytes) {
|
|
527
|
+
const firstPass = validatePetPackageFiles(manifestBytes, spritesheetBytes, {
|
|
528
|
+
...this.options,
|
|
529
|
+
assetUrl: "",
|
|
530
|
+
source: "user"
|
|
531
|
+
});
|
|
532
|
+
decodeWebpDimensions(spritesheetBytes, this.options.decodeTimeoutMs ?? 1e4);
|
|
533
|
+
const id = firstPass.descriptor.id;
|
|
534
|
+
if (this.records.get(id)?.source !== "user") throw new TypeError(`pet package ${id} is not an updatable user package`);
|
|
535
|
+
mkdirSync(this.petRoot, { recursive: true });
|
|
536
|
+
this.sweepTemporaryDirectories(id);
|
|
537
|
+
const target = join(this.petRoot, id);
|
|
538
|
+
const staged = join(this.petRoot, `.${id}.${randomUUID()}.tmp`);
|
|
539
|
+
const aside = join(this.petRoot, `.${id}.${randomUUID()}.tmp`);
|
|
540
|
+
try {
|
|
541
|
+
mkdirSync(staged);
|
|
542
|
+
writeFileSync(join(staged, "pet.json"), manifestBytes, { flag: "wx" });
|
|
543
|
+
const spriteTarget = join(staged, firstPass.spritesheetPath.replaceAll("\\", "/"));
|
|
544
|
+
mkdirSync(dirname(spriteTarget), { recursive: true });
|
|
545
|
+
writeFileSync(spriteTarget, spritesheetBytes, { flag: "wx" });
|
|
546
|
+
renameSync(target, aside);
|
|
547
|
+
try {
|
|
548
|
+
renameSync(staged, target);
|
|
549
|
+
} catch (error) {
|
|
550
|
+
renameSync(aside, target);
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
} catch (error) {
|
|
554
|
+
rmSync(staged, {
|
|
555
|
+
recursive: true,
|
|
556
|
+
force: true
|
|
557
|
+
});
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
rmSync(aside, {
|
|
561
|
+
recursive: true,
|
|
562
|
+
force: true
|
|
563
|
+
});
|
|
564
|
+
this.reload();
|
|
565
|
+
const published = this.records.get(id)?.descriptor;
|
|
566
|
+
if (published === void 0) throw new Error(`pet package ${id} was not published after replacement`);
|
|
567
|
+
return { ...published };
|
|
568
|
+
}
|
|
569
|
+
/** Dispose all update listeners owned by the catalog service. */
|
|
570
|
+
dispose() {
|
|
571
|
+
this.listeners.clear();
|
|
572
|
+
this.records.clear();
|
|
573
|
+
}
|
|
574
|
+
/** Delete the `.tmp` residue of earlier interrupted imports or replacements of one id. */
|
|
575
|
+
sweepTemporaryDirectories(id) {
|
|
576
|
+
for (const entry of readdirSync(this.petRoot, { withFileTypes: true })) if (entry.name.startsWith(`.${id}.`) && entry.name.endsWith(".tmp")) rmSync(join(this.petRoot, entry.name), {
|
|
577
|
+
recursive: true,
|
|
578
|
+
force: true
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/** Reload validated package records from the embedded assets and the user root, then publish one detached catalog. */
|
|
582
|
+
reload() {
|
|
583
|
+
const next = /* @__PURE__ */ new Map();
|
|
584
|
+
const builtin = readValidatedPackage(new URL("../assets/deepseek-whale/pet.json", import.meta.url), this.options, "builtin");
|
|
585
|
+
next.set(DEFAULT_PET_ID, builtin);
|
|
586
|
+
if (existsSync(this.petRoot) && isDirectory(this.petRoot)) for (const entry of readdirSync(this.petRoot, { withFileTypes: true })) {
|
|
587
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name.startsWith(".")) continue;
|
|
588
|
+
const directory = join(this.petRoot, entry.name);
|
|
589
|
+
if (entry.name === "deepseek-whale") continue;
|
|
590
|
+
try {
|
|
591
|
+
const record = readValidatedPackage(join(directory, "pet.json"), this.options, "user");
|
|
592
|
+
if (next.has(record.descriptor.id)) throw new Error(`pet id ${record.descriptor.id} is duplicated`);
|
|
593
|
+
next.set(record.descriptor.id, record);
|
|
594
|
+
} catch {}
|
|
595
|
+
}
|
|
596
|
+
this.records.clear();
|
|
597
|
+
for (const id of [DEFAULT_PET_ID, ...[...next.keys()].filter((key) => key !== DEFAULT_PET_ID).sort()]) {
|
|
598
|
+
const record = next.get(id);
|
|
599
|
+
if (record !== void 0) this.records.set(id, record);
|
|
600
|
+
}
|
|
601
|
+
const catalog = this.getCatalog();
|
|
602
|
+
for (const listener of this.listeners) listener(catalog);
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
function readValidatedPackage(manifest, options, source) {
|
|
606
|
+
const maxManifestBytes = options.maxManifestBytes ?? 16384;
|
|
607
|
+
const maxSpriteBytes = options.maxSpriteBytes ?? 16777216;
|
|
608
|
+
const manifestBytes = readRegularFile(manifest, "pet manifest", maxManifestBytes);
|
|
609
|
+
const relativeSprite = petSpritesheetPath(manifestBytes);
|
|
610
|
+
const spriteLocation = manifest instanceof URL ? new URL(relativeSprite.replaceAll("\\", "/"), manifest) : join(dirname(manifest), relativeSprite.replaceAll("\\", "/"));
|
|
611
|
+
if (typeof manifest === "string" && typeof spriteLocation === "string") assertContainedFile(dirname(manifest), spriteLocation);
|
|
612
|
+
const spriteBytes = readRegularFile(spriteLocation, "pet spritesheet", maxSpriteBytes);
|
|
613
|
+
const validated = validatePetPackageFiles(manifestBytes, spriteBytes, {
|
|
614
|
+
...options,
|
|
615
|
+
assetUrl: "",
|
|
616
|
+
source
|
|
617
|
+
});
|
|
618
|
+
if (source === "user") decodeWebpDimensions(spriteBytes, options.decodeTimeoutMs ?? 1e4);
|
|
619
|
+
const id = validated.descriptor.id;
|
|
620
|
+
const descriptor = Object.freeze({
|
|
621
|
+
...validated.descriptor,
|
|
622
|
+
assetUrl: `/__dsh/pet/assets/${id}/spritesheet.webp`
|
|
623
|
+
});
|
|
624
|
+
if (descriptor.id !== id) throw new Error(`pet package id ${descriptor.id} does not match its catalog key ${id}`);
|
|
625
|
+
return {
|
|
626
|
+
descriptor,
|
|
627
|
+
spriteBytes,
|
|
628
|
+
source
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function readRegularFile(path, label, maxBytes) {
|
|
632
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error(`${label} byte limit is invalid`);
|
|
633
|
+
const stat = lstatSync(path);
|
|
634
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${label} must be a regular file`);
|
|
635
|
+
if (stat.size > maxBytes) throw new Error(`${label} exceeds the configured byte limit`);
|
|
636
|
+
const handle = openSync(path, "r");
|
|
637
|
+
try {
|
|
638
|
+
const openedStat = fstatSync(handle);
|
|
639
|
+
if (!openedStat.isFile() || openedStat.size > maxBytes) throw new Error(`${label} exceeds the configured byte limit`);
|
|
640
|
+
const bytes = new Uint8Array(openedStat.size + 1);
|
|
641
|
+
let offset = 0;
|
|
642
|
+
while (offset < bytes.byteLength) {
|
|
643
|
+
const count = readSync(handle, bytes, offset, bytes.byteLength - offset, null);
|
|
644
|
+
if (count === 0) break;
|
|
645
|
+
offset += count;
|
|
646
|
+
}
|
|
647
|
+
if (offset !== openedStat.size) throw new Error(`${label} changed while it was being read`);
|
|
648
|
+
return bytes.subarray(0, offset);
|
|
649
|
+
} finally {
|
|
650
|
+
closeSync(handle);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function assertContainedFile(packageRoot, candidate) {
|
|
654
|
+
const canonicalRoot = realpathSync(packageRoot);
|
|
655
|
+
const canonicalCandidate = realpathSync(candidate);
|
|
656
|
+
const fromRoot = relative(canonicalRoot, canonicalCandidate);
|
|
657
|
+
if (fromRoot.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || fromRoot === ".." || isAbsolute(fromRoot)) throw new Error("pet spritesheet must stay inside its package directory");
|
|
658
|
+
}
|
|
659
|
+
function isDirectory(path) {
|
|
660
|
+
try {
|
|
661
|
+
return lstatSync(path).isDirectory();
|
|
662
|
+
} catch {
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
//#endregion
|
|
667
|
+
//#region lib/types/path-opener.js
|
|
668
|
+
/**
|
|
669
|
+
* Cross-platform native path and text-document openers used by the local GUI
|
|
670
|
+
* carrier.
|
|
671
|
+
*
|
|
672
|
+
* The default intent prefers the default browser for documents it renders when
|
|
673
|
+
* the platform can name one, then falls back to the default application. WSL
|
|
674
|
+
* translates every path for the Windows desktop instead of assuming a Linux
|
|
675
|
+
* GUI. The text-editor intent never consults the browser.
|
|
676
|
+
*/
|
|
677
|
+
/** Documents a browser renders, as opposed to ones an editor merely edits. */
|
|
678
|
+
const BROWSER_DOCUMENTS = /* @__PURE__ */ new Set([
|
|
679
|
+
".html",
|
|
680
|
+
".htm",
|
|
681
|
+
".xhtml",
|
|
682
|
+
".svg"
|
|
683
|
+
]);
|
|
684
|
+
/**
|
|
685
|
+
* The macOS bundle registered for `https` — the default browser, as
|
|
686
|
+
* LaunchServices records it. The nested version dict is stripped first
|
|
687
|
+
* because it carries its own `LSHandlerRoleAll`.
|
|
688
|
+
*/
|
|
689
|
+
function macBundleForHttps(plist) {
|
|
690
|
+
const stripped = plist.replace(/LSHandlerPreferredVersions\s*=\s*\{[^}]*\};/g, "");
|
|
691
|
+
const block = /\{[^{}]*LSHandlerURLScheme\s*=\s*"?https"?;[^{}]*\}/.exec(stripped)?.[0];
|
|
692
|
+
if (block === void 0) return void 0;
|
|
693
|
+
return /LSHandlerRoleAll\s*=\s*"?([\w.-]+)"?;/.exec(block)?.[1];
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Open one browser-renderable document with the default browser.
|
|
697
|
+
* @returns true when a browser took it; false when this platform cannot name
|
|
698
|
+
* one, or naming it failed — the caller then uses the default application.
|
|
699
|
+
*/
|
|
700
|
+
async function openInBrowser(path, signal, platform, run, env) {
|
|
701
|
+
if (platform === "darwin") {
|
|
702
|
+
let bundle;
|
|
703
|
+
try {
|
|
704
|
+
const { stdout } = await run("defaults", ["read", "com.apple.LaunchServices/com.apple.launchservices.secure"], signal);
|
|
705
|
+
bundle = macBundleForHttps(stdout);
|
|
706
|
+
} catch {
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
if (bundle === void 0) return false;
|
|
710
|
+
await run("open", [
|
|
711
|
+
"-b",
|
|
712
|
+
bundle,
|
|
713
|
+
path
|
|
714
|
+
], signal);
|
|
715
|
+
return true;
|
|
716
|
+
}
|
|
717
|
+
if (platform === "linux") {
|
|
718
|
+
const browser = env.BROWSER;
|
|
719
|
+
if (browser === void 0 || browser === "") return false;
|
|
720
|
+
await run(browser, [path], signal);
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
/** PowerShell single-quoted literal (doubles embedded quotes). */
|
|
726
|
+
function powershellLiteral(path) {
|
|
727
|
+
return `'${path.replace(/'/g, "''")}'`;
|
|
728
|
+
}
|
|
729
|
+
/** Whether one environment marker is set to a non-empty value. */
|
|
730
|
+
function present(value) {
|
|
731
|
+
return value !== void 0 && value !== "";
|
|
732
|
+
}
|
|
733
|
+
/** Distinguish WSL from desktop Linux using its process and kernel markers. */
|
|
734
|
+
function isWsl(internals) {
|
|
735
|
+
const env = internals.env ?? process.env;
|
|
736
|
+
if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true;
|
|
737
|
+
return (internals.osRelease ?? release()).toLowerCase().includes("microsoft");
|
|
738
|
+
}
|
|
739
|
+
/** Open one Windows-resolvable path through its registered desktop application. */
|
|
740
|
+
async function openWindowsPath(path, signal, run) {
|
|
741
|
+
await run("powershell.exe", [
|
|
742
|
+
"-NoProfile",
|
|
743
|
+
"-Command",
|
|
744
|
+
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`
|
|
745
|
+
], signal);
|
|
746
|
+
}
|
|
747
|
+
/** Translate a WSL path before handing it to the Windows desktop. */
|
|
748
|
+
async function openWslPath(path, signal, run) {
|
|
749
|
+
const translated = await run("wslpath", ["-w", path], signal);
|
|
750
|
+
signal.throwIfAborted();
|
|
751
|
+
const windowsPath = translated.stdout.replace(/[\r\n]+$/, "");
|
|
752
|
+
if (windowsPath === "") throw new Error("wslpath returned no Windows path");
|
|
753
|
+
await openWindowsPath(windowsPath, signal, run);
|
|
754
|
+
}
|
|
755
|
+
/** Dispatch one shell-free platform command for the requested open intent. */
|
|
756
|
+
async function openNativePathWithIntent(path, signal, intent, internals = {}) {
|
|
757
|
+
const platform = internals.platform ?? process.platform;
|
|
758
|
+
const run = internals.run ?? runNativeCommand;
|
|
759
|
+
const env = internals.env ?? process.env;
|
|
760
|
+
const wsl = platform === "linux" && isWsl(internals);
|
|
761
|
+
if (!wsl && intent === "default" && BROWSER_DOCUMENTS.has(extname(path).toLowerCase()) && await openInBrowser(path, signal, platform, run, env)) return;
|
|
762
|
+
if (platform === "darwin") {
|
|
763
|
+
await run("open", intent === "text-editor" ? ["-t", path] : [path], signal);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (platform === "win32") {
|
|
767
|
+
await openWindowsPath(path, signal, run);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (platform === "linux") {
|
|
771
|
+
if (wsl) {
|
|
772
|
+
await openWslPath(path, signal, run);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
await run("xdg-open", [path], signal);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
throw new Error(`native path opener is unsupported on ${platform}`);
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Open a filesystem path with the operating system's default application, or
|
|
782
|
+
* with the default browser when the path names a document a browser renders.
|
|
783
|
+
* @param path - absolute or host-resolvable path (caller owns resolution).
|
|
784
|
+
* @param signal - caller/connection lifetime; abort terminates the native command.
|
|
785
|
+
* @param internals - Platform, environment, and runner hooks for deterministic tests.
|
|
786
|
+
*/
|
|
787
|
+
function openNativePath(path, signal, internals = {}) {
|
|
788
|
+
return openNativePathWithIntent(path, signal, "default", internals);
|
|
789
|
+
}
|
|
790
|
+
//#endregion
|
|
791
|
+
//#region lib/types/host-native.js
|
|
792
|
+
/** Host-native pet package and folder operations assembled from existing host seams. */
|
|
793
|
+
/** Assemble pet actions only when the composed directory picker is native.
|
|
794
|
+
* @param capability - composed directory picker capability.
|
|
795
|
+
* @param internals - optional filesystem and host-open test seams.
|
|
796
|
+
* @returns native pet actions, or `undefined` for browser-only pickers.
|
|
797
|
+
*/
|
|
798
|
+
function createPetNativeActions(capability, internals = {}) {
|
|
799
|
+
if (capability?.kind !== "native") return void 0;
|
|
800
|
+
const read = internals.readFile ?? (async (path) => new Uint8Array(await readFile(path)));
|
|
801
|
+
return {
|
|
802
|
+
async pickPetPackage() {
|
|
803
|
+
const selected = await capability.pick(new AbortController().signal);
|
|
804
|
+
if (selected === null) return null;
|
|
805
|
+
const directory = await packageDirectory(selected);
|
|
806
|
+
const manifestBytes = await read(join(directory, "pet.json"));
|
|
807
|
+
const spritesheetPath = petSpritesheetPath(manifestBytes);
|
|
808
|
+
return {
|
|
809
|
+
manifestBytes,
|
|
810
|
+
spritesheetBytes: await read(join(directory, spritesheetPath.replaceAll("\\", "/")))
|
|
811
|
+
};
|
|
812
|
+
},
|
|
813
|
+
openPetFolder: internals.openPath ?? ((path) => openNativePath(path, new AbortController().signal))
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
/** Resolve a picker result to one package directory and reject links or unrelated files. */
|
|
817
|
+
async function packageDirectory(selected) {
|
|
818
|
+
const target = resolve(selected);
|
|
819
|
+
const targetStat = await lstat(target);
|
|
820
|
+
if (targetStat.isDirectory() && !targetStat.isSymbolicLink()) return target;
|
|
821
|
+
if (!targetStat.isFile() || targetStat.isSymbolicLink()) throw new Error("pet package selection must be a directory or package file");
|
|
822
|
+
const name = basename(target);
|
|
823
|
+
if (name !== "pet.json" && name !== "spritesheet.webp") throw new Error("pet package selection must name pet.json or spritesheet.webp");
|
|
824
|
+
const directory = dirname(target);
|
|
825
|
+
const directoryStat = await lstat(directory);
|
|
826
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error("pet package directory is invalid");
|
|
827
|
+
return directory;
|
|
828
|
+
}
|
|
829
|
+
//#endregion
|
|
830
|
+
//#region lib/types/renderer.js
|
|
831
|
+
/** Browser-safe sprite frame projection shared by Web and desktop documents. */
|
|
832
|
+
/** Resolve one elapsed presentation into a frame and CSS background projection.
|
|
833
|
+
* @param assetUrl - validated origin-relative sprite URL.
|
|
834
|
+
* @param sizePx - validated logical CSS cell height.
|
|
835
|
+
* @param presentation - renderer state selected for this update.
|
|
836
|
+
* @param elapsedMs - elapsed time since the selected state began.
|
|
837
|
+
* @param animations - validated package animation tracks, when available.
|
|
838
|
+
* @returns the selected atlas cell and its CSS background projection.
|
|
839
|
+
*/
|
|
840
|
+
function petSpriteFrame(assetUrl, sizePx, presentation, elapsedMs, animations) {
|
|
841
|
+
const frame = presentation.state === "tucked" ? {
|
|
842
|
+
state: "idle",
|
|
843
|
+
row: 0,
|
|
844
|
+
column: 0,
|
|
845
|
+
done: true
|
|
846
|
+
} : (() => {
|
|
847
|
+
const selection = frameAt(animations ?? DEFAULT_PET_ANIMATIONS, presentation.state, elapsedMs, !presentation.animate);
|
|
848
|
+
const spriteIndex = selection?.spriteIndex ?? 0;
|
|
849
|
+
return {
|
|
850
|
+
state: presentation.state,
|
|
851
|
+
row: Math.floor(spriteIndex / PET_COMPAT_ATLAS.columns),
|
|
852
|
+
column: spriteIndex % PET_COMPAT_ATLAS.columns,
|
|
853
|
+
done: selection?.animation === "idle" && presentation.state !== "idle"
|
|
854
|
+
};
|
|
855
|
+
})();
|
|
856
|
+
const width = petWidthForSize(sizePx);
|
|
857
|
+
return {
|
|
858
|
+
frame,
|
|
859
|
+
style: {
|
|
860
|
+
width,
|
|
861
|
+
height: sizePx,
|
|
862
|
+
backgroundImage: `url(${assetUrl})`,
|
|
863
|
+
backgroundPosition: `${-frame.column * width}px ${-frame.row * sizePx}px`,
|
|
864
|
+
backgroundSize: `${petWidthForSize(sizePx) * PET_COMPAT_ATLAS.columns}px ${sizePx * PET_COMPAT_ATLAS.rows}px`
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
/** Resolve the static first atlas cell into a CSS background projection at any display height.
|
|
869
|
+
* Unlike {@link petSpriteFrame} this projection is decoupled from the validated overlay size range,
|
|
870
|
+
* so fixed-size list avatars can render smaller than the wake-state minimum.
|
|
871
|
+
* @param assetUrl - validated origin-relative sprite URL.
|
|
872
|
+
* @param heightPx - display cell height in CSS pixels, chosen by the caller.
|
|
873
|
+
* @returns the frame-zero cell and its CSS background projection.
|
|
874
|
+
*/
|
|
875
|
+
function petSpriteAvatar(assetUrl, heightPx) {
|
|
876
|
+
const width = Math.round(heightPx * PET_COMPAT_ATLAS.cellWidth / PET_COMPAT_ATLAS.cellHeight);
|
|
877
|
+
return {
|
|
878
|
+
width,
|
|
879
|
+
height: heightPx,
|
|
880
|
+
backgroundImage: `url(${assetUrl})`,
|
|
881
|
+
backgroundPosition: "0px 0px",
|
|
882
|
+
backgroundSize: `${width * PET_COMPAT_ATLAS.columns}px ${heightPx * PET_COMPAT_ATLAS.rows}px`
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region lib/types/index.js
|
|
887
|
+
/**
|
|
888
|
+
* Global desktop-pet domain: durable user preferences and a live,
|
|
889
|
+
* session-event-derived activity read model for companion clients.
|
|
890
|
+
* @module @luv1211/dsh-pet
|
|
891
|
+
*/
|
|
892
|
+
var __runInitializers = function(thisArg, initializers, value) {
|
|
893
|
+
var useValue = arguments.length > 2;
|
|
894
|
+
for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
895
|
+
return useValue ? value : void 0;
|
|
896
|
+
};
|
|
897
|
+
var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
898
|
+
function accept(f) {
|
|
899
|
+
if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
|
|
900
|
+
return f;
|
|
901
|
+
}
|
|
902
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
903
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
904
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
905
|
+
var _, done = false;
|
|
906
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
907
|
+
var context = {};
|
|
908
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
909
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
910
|
+
context.addInitializer = function(f) {
|
|
911
|
+
if (done) throw new TypeError("Cannot add initializers after decoration has completed");
|
|
912
|
+
extraInitializers.push(accept(f || null));
|
|
913
|
+
};
|
|
914
|
+
var result = (0, decorators[i])(kind === "accessor" ? {
|
|
915
|
+
get: descriptor.get,
|
|
916
|
+
set: descriptor.set
|
|
917
|
+
} : descriptor[key], context);
|
|
918
|
+
if (kind === "accessor") {
|
|
919
|
+
if (result === void 0) continue;
|
|
920
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
921
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
922
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
923
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
924
|
+
} else if (_ = accept(result)) {
|
|
925
|
+
if (kind === "field") initializers.unshift(_);
|
|
926
|
+
else descriptor[key] = _;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
930
|
+
done = true;
|
|
931
|
+
};
|
|
932
|
+
/** Schema of the durable `pet` settings namespace. */
|
|
933
|
+
const petPreferenceSchema = z.transform(z.object({
|
|
934
|
+
version: z.number().default(3),
|
|
935
|
+
selectedPetId: z.string().default(DEFAULT_PET_ID),
|
|
936
|
+
awake: z.boolean().default(true),
|
|
937
|
+
sizePx: z.number().default(112)
|
|
938
|
+
}), (value) => resolvePetPreference(value), true).default({});
|
|
939
|
+
/** Reject a preference that cannot select a package or preserve its document meaning. */
|
|
940
|
+
function validatePetPreference(value) {
|
|
941
|
+
if (value.selectedPetId.length === 0) throw new TypeError("pet preference selectedPetId must not be empty");
|
|
942
|
+
validatePetSize(value.sizePx);
|
|
943
|
+
}
|
|
944
|
+
/** Pet service (`ctx.pets`): one durable preference writer and activity aggregator. */
|
|
945
|
+
let PetService = (() => {
|
|
946
|
+
let _classSuper = TypertRemoteService;
|
|
947
|
+
let _instanceExtraInitializers = [];
|
|
948
|
+
let _getSnapshot_decorators;
|
|
949
|
+
let _getCatalog_decorators;
|
|
950
|
+
let _importPetPackage_decorators;
|
|
951
|
+
let _refreshCatalog_decorators;
|
|
952
|
+
let _updatePetPackage_decorators;
|
|
953
|
+
let _openPetFolder_decorators;
|
|
954
|
+
let _selectPet_decorators;
|
|
955
|
+
let _setSize_decorators;
|
|
956
|
+
let _setAwake_decorators;
|
|
957
|
+
return class PetService extends _classSuper {
|
|
958
|
+
static {
|
|
959
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
960
|
+
_getSnapshot_decorators = [Remote("getSnapshot")];
|
|
961
|
+
_getCatalog_decorators = [Remote("getCatalog")];
|
|
962
|
+
_importPetPackage_decorators = [Remote("importPetPackage")];
|
|
963
|
+
_refreshCatalog_decorators = [Remote("refreshCatalog")];
|
|
964
|
+
_updatePetPackage_decorators = [Remote("updatePetPackage")];
|
|
965
|
+
_openPetFolder_decorators = [Remote("openPetFolder")];
|
|
966
|
+
_selectPet_decorators = [Remote("selectPet")];
|
|
967
|
+
_setSize_decorators = [Remote("setSize")];
|
|
968
|
+
_setAwake_decorators = [Remote("setAwake")];
|
|
969
|
+
__esDecorate(this, null, _getSnapshot_decorators, {
|
|
970
|
+
kind: "method",
|
|
971
|
+
name: "getSnapshot",
|
|
972
|
+
static: false,
|
|
973
|
+
private: false,
|
|
974
|
+
access: {
|
|
975
|
+
has: (obj) => "getSnapshot" in obj,
|
|
976
|
+
get: (obj) => obj.getSnapshot
|
|
977
|
+
},
|
|
978
|
+
metadata: _metadata
|
|
979
|
+
}, null, _instanceExtraInitializers);
|
|
980
|
+
__esDecorate(this, null, _getCatalog_decorators, {
|
|
981
|
+
kind: "method",
|
|
982
|
+
name: "getCatalog",
|
|
983
|
+
static: false,
|
|
984
|
+
private: false,
|
|
985
|
+
access: {
|
|
986
|
+
has: (obj) => "getCatalog" in obj,
|
|
987
|
+
get: (obj) => obj.getCatalog
|
|
988
|
+
},
|
|
989
|
+
metadata: _metadata
|
|
990
|
+
}, null, _instanceExtraInitializers);
|
|
991
|
+
__esDecorate(this, null, _importPetPackage_decorators, {
|
|
992
|
+
kind: "method",
|
|
993
|
+
name: "importPetPackage",
|
|
994
|
+
static: false,
|
|
995
|
+
private: false,
|
|
996
|
+
access: {
|
|
997
|
+
has: (obj) => "importPetPackage" in obj,
|
|
998
|
+
get: (obj) => obj.importPetPackage
|
|
999
|
+
},
|
|
1000
|
+
metadata: _metadata
|
|
1001
|
+
}, null, _instanceExtraInitializers);
|
|
1002
|
+
__esDecorate(this, null, _refreshCatalog_decorators, {
|
|
1003
|
+
kind: "method",
|
|
1004
|
+
name: "refreshCatalog",
|
|
1005
|
+
static: false,
|
|
1006
|
+
private: false,
|
|
1007
|
+
access: {
|
|
1008
|
+
has: (obj) => "refreshCatalog" in obj,
|
|
1009
|
+
get: (obj) => obj.refreshCatalog
|
|
1010
|
+
},
|
|
1011
|
+
metadata: _metadata
|
|
1012
|
+
}, null, _instanceExtraInitializers);
|
|
1013
|
+
__esDecorate(this, null, _updatePetPackage_decorators, {
|
|
1014
|
+
kind: "method",
|
|
1015
|
+
name: "updatePetPackage",
|
|
1016
|
+
static: false,
|
|
1017
|
+
private: false,
|
|
1018
|
+
access: {
|
|
1019
|
+
has: (obj) => "updatePetPackage" in obj,
|
|
1020
|
+
get: (obj) => obj.updatePetPackage
|
|
1021
|
+
},
|
|
1022
|
+
metadata: _metadata
|
|
1023
|
+
}, null, _instanceExtraInitializers);
|
|
1024
|
+
__esDecorate(this, null, _openPetFolder_decorators, {
|
|
1025
|
+
kind: "method",
|
|
1026
|
+
name: "openPetFolder",
|
|
1027
|
+
static: false,
|
|
1028
|
+
private: false,
|
|
1029
|
+
access: {
|
|
1030
|
+
has: (obj) => "openPetFolder" in obj,
|
|
1031
|
+
get: (obj) => obj.openPetFolder
|
|
1032
|
+
},
|
|
1033
|
+
metadata: _metadata
|
|
1034
|
+
}, null, _instanceExtraInitializers);
|
|
1035
|
+
__esDecorate(this, null, _selectPet_decorators, {
|
|
1036
|
+
kind: "method",
|
|
1037
|
+
name: "selectPet",
|
|
1038
|
+
static: false,
|
|
1039
|
+
private: false,
|
|
1040
|
+
access: {
|
|
1041
|
+
has: (obj) => "selectPet" in obj,
|
|
1042
|
+
get: (obj) => obj.selectPet
|
|
1043
|
+
},
|
|
1044
|
+
metadata: _metadata
|
|
1045
|
+
}, null, _instanceExtraInitializers);
|
|
1046
|
+
__esDecorate(this, null, _setSize_decorators, {
|
|
1047
|
+
kind: "method",
|
|
1048
|
+
name: "setSize",
|
|
1049
|
+
static: false,
|
|
1050
|
+
private: false,
|
|
1051
|
+
access: {
|
|
1052
|
+
has: (obj) => "setSize" in obj,
|
|
1053
|
+
get: (obj) => obj.setSize
|
|
1054
|
+
},
|
|
1055
|
+
metadata: _metadata
|
|
1056
|
+
}, null, _instanceExtraInitializers);
|
|
1057
|
+
__esDecorate(this, null, _setAwake_decorators, {
|
|
1058
|
+
kind: "method",
|
|
1059
|
+
name: "setAwake",
|
|
1060
|
+
static: false,
|
|
1061
|
+
private: false,
|
|
1062
|
+
access: {
|
|
1063
|
+
has: (obj) => "setAwake" in obj,
|
|
1064
|
+
get: (obj) => obj.setAwake
|
|
1065
|
+
},
|
|
1066
|
+
metadata: _metadata
|
|
1067
|
+
}, null, _instanceExtraInitializers);
|
|
1068
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
1069
|
+
enumerable: true,
|
|
1070
|
+
configurable: true,
|
|
1071
|
+
writable: true,
|
|
1072
|
+
value: _metadata
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
static inject = ["settings"];
|
|
1076
|
+
static Config = z.object({
|
|
1077
|
+
dshHome: z.string(),
|
|
1078
|
+
petRoot: z.string(),
|
|
1079
|
+
maxManifestBytes: z.natural().default(16384),
|
|
1080
|
+
maxSpriteBytes: z.natural().default(16777216),
|
|
1081
|
+
decodeTimeoutMs: z.natural().min(1).default(1e4)
|
|
1082
|
+
});
|
|
1083
|
+
scope = __runInitializers(this, _instanceExtraInitializers);
|
|
1084
|
+
preference;
|
|
1085
|
+
catalogStore;
|
|
1086
|
+
catalog;
|
|
1087
|
+
activities = /* @__PURE__ */ new Map();
|
|
1088
|
+
tail = Promise.resolve();
|
|
1089
|
+
constructor(ctx, config = {}) {
|
|
1090
|
+
super(ctx, "pets");
|
|
1091
|
+
this.catalogStore = new PetCatalogStore(config);
|
|
1092
|
+
this.catalog = this.catalogStore.getCatalog();
|
|
1093
|
+
this.scope = ctx.settings.register(settingsNamespace("pet"), petPreferenceSchema, { validate: validatePetPreference });
|
|
1094
|
+
this.preference = this.scope.get();
|
|
1095
|
+
if (!this.catalogStore.has(this.preference.selectedPetId)) throw new TypeError(`pet ${this.preference.selectedPetId} was not found in the pet catalog`);
|
|
1096
|
+
ctx.effect(() => this.scope.watch((next) => {
|
|
1097
|
+
this.preference = next;
|
|
1098
|
+
this.publish();
|
|
1099
|
+
}), "dsh-pet: preference watch");
|
|
1100
|
+
ctx.effect(() => this.catalogStore.subscribe((catalog) => {
|
|
1101
|
+
this.catalog = catalog;
|
|
1102
|
+
this.publish();
|
|
1103
|
+
}), "dsh-pet: catalog watch");
|
|
1104
|
+
const activitySource = ctx.get("petActivity") ?? new PetActivityProjection(ctx);
|
|
1105
|
+
this.applyActivity(activitySource.getSnapshot());
|
|
1106
|
+
ctx.effect(() => activitySource.subscribe((records) => {
|
|
1107
|
+
this.applyActivity(records);
|
|
1108
|
+
}), "dsh-pet: activity projection");
|
|
1109
|
+
const desktopCompanion = ctx.get("desktopCompanion");
|
|
1110
|
+
const webServer = ctx.get("webServer");
|
|
1111
|
+
if (desktopCompanion !== void 0 && webServer !== void 0) {
|
|
1112
|
+
ctx.effect(() => desktopCompanion.register({
|
|
1113
|
+
id: "pet",
|
|
1114
|
+
entryPath: "/__dsh/pet/overlay",
|
|
1115
|
+
width: petWidthForSize(112),
|
|
1116
|
+
height: 112,
|
|
1117
|
+
capabilities: {
|
|
1118
|
+
drag: true,
|
|
1119
|
+
pointerInteraction: true,
|
|
1120
|
+
resize: {
|
|
1121
|
+
minWidth: petWidthForSize(80),
|
|
1122
|
+
maxWidth: petWidthForSize(224),
|
|
1123
|
+
minHeight: 80,
|
|
1124
|
+
maxHeight: 224
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}), "dsh-pet: desktop companion");
|
|
1128
|
+
ctx.effect(() => webServer.register({
|
|
1129
|
+
kind: "exact",
|
|
1130
|
+
path: "/__dsh/pet/overlay",
|
|
1131
|
+
handler: (_req, res) => {
|
|
1132
|
+
res.writeHead(200, {
|
|
1133
|
+
"content-type": "text/html; charset=utf-8",
|
|
1134
|
+
"cache-control": "no-store"
|
|
1135
|
+
});
|
|
1136
|
+
res.end(createPetOverlayHtml());
|
|
1137
|
+
}
|
|
1138
|
+
}), "dsh-pet: companion page");
|
|
1139
|
+
ctx.effect(() => webServer.register({
|
|
1140
|
+
kind: "exact",
|
|
1141
|
+
path: "/__dsh/pet/overlay-state",
|
|
1142
|
+
handler: (_req, res) => {
|
|
1143
|
+
res.writeHead(200, {
|
|
1144
|
+
"content-type": "application/json; charset=utf-8",
|
|
1145
|
+
"cache-control": "no-store"
|
|
1146
|
+
});
|
|
1147
|
+
res.end(JSON.stringify(this.getSnapshot()));
|
|
1148
|
+
}
|
|
1149
|
+
}), "dsh-pet: companion state");
|
|
1150
|
+
ctx.effect(() => webServer.register({
|
|
1151
|
+
kind: "exact",
|
|
1152
|
+
path: "/__dsh/pet/overlay-awake",
|
|
1153
|
+
handler: (req, res) => {
|
|
1154
|
+
this.handleOverlayAwake(req, res);
|
|
1155
|
+
}
|
|
1156
|
+
}), "dsh-pet: companion awake write");
|
|
1157
|
+
}
|
|
1158
|
+
if (webServer !== void 0) ctx.effect(() => webServer.register({
|
|
1159
|
+
kind: "prefix",
|
|
1160
|
+
path: "/__dsh/pet/assets",
|
|
1161
|
+
handler: (req, res) => {
|
|
1162
|
+
let pathname;
|
|
1163
|
+
try {
|
|
1164
|
+
pathname = decodeURIComponent(new URL(req.url ?? "/", "http://dsh.local").pathname);
|
|
1165
|
+
} catch {
|
|
1166
|
+
res.writeHead(404);
|
|
1167
|
+
res.end();
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
const match = /^\/__dsh\/pet\/assets\/([^/]+)\/spritesheet\.webp$/.exec(pathname);
|
|
1171
|
+
const asset = match?.[1] === void 0 ? void 0 : this.catalogStore.getAsset(match[1]);
|
|
1172
|
+
if (asset === void 0) {
|
|
1173
|
+
res.writeHead(404);
|
|
1174
|
+
res.end();
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
res.writeHead(200, {
|
|
1178
|
+
"content-type": "image/webp",
|
|
1179
|
+
"cache-control": "no-store"
|
|
1180
|
+
});
|
|
1181
|
+
res.end(Buffer.from(asset));
|
|
1182
|
+
}
|
|
1183
|
+
}), "dsh-pet: catalog assets");
|
|
1184
|
+
ctx.effect(() => {
|
|
1185
|
+
return () => {
|
|
1186
|
+
this.catalogStore.dispose();
|
|
1187
|
+
};
|
|
1188
|
+
}, "dsh-pet: catalog dispose");
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* The host-native operations the current composition provides. Resolved
|
|
1192
|
+
* per access, not captured at construction: the `petNative` provider mounts
|
|
1193
|
+
* as a later tree row than this service, and the composed directory picker
|
|
1194
|
+
* can also enter the context later. An explicit `petNative` provider wins;
|
|
1195
|
+
* otherwise the actions derive from the composed directory picker when it
|
|
1196
|
+
* serves the native backend.
|
|
1197
|
+
* @returns the native operations, or `undefined` in browser-only compositions.
|
|
1198
|
+
*/
|
|
1199
|
+
get nativeActions() {
|
|
1200
|
+
return this.ctx.get("petNative") ?? createPetNativeActions(this.ctx.get("directoryPicker")?.capability());
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Read the latest durable preference and every current activity record.
|
|
1204
|
+
* @returns a detached, deterministically ordered snapshot.
|
|
1205
|
+
*/
|
|
1206
|
+
getSnapshot() {
|
|
1207
|
+
const activities = [...this.activities.values()].sort(comparePetActivities).map((activity) => ({ ...activity }));
|
|
1208
|
+
const selected = activities[0];
|
|
1209
|
+
return {
|
|
1210
|
+
preference: { ...this.preference },
|
|
1211
|
+
catalog: { pets: this.catalog.pets.map((pet) => ({ ...pet })) },
|
|
1212
|
+
petRoot: this.catalogStore.petRoot,
|
|
1213
|
+
capabilities: {
|
|
1214
|
+
canImport: this.nativeActions !== void 0,
|
|
1215
|
+
canOpenFolder: this.nativeActions !== void 0
|
|
1216
|
+
},
|
|
1217
|
+
activities,
|
|
1218
|
+
...selected === void 0 ? {} : { selectedActivity: { ...selected } }
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Read the current validated built-in and user package descriptors.
|
|
1223
|
+
* @returns a detached catalog of validated package descriptors.
|
|
1224
|
+
*/
|
|
1225
|
+
getCatalog() {
|
|
1226
|
+
return { pets: this.catalog.pets.map((pet) => ({ ...pet })) };
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Import one validated package selected by the native host, without accepting a client path.
|
|
1230
|
+
* @returns the publication, cancellation, or host-availability result.
|
|
1231
|
+
*/
|
|
1232
|
+
async importPetPackage() {
|
|
1233
|
+
const native = this.nativeActions;
|
|
1234
|
+
if (native === void 0) return { outcome: "host-unavailable" };
|
|
1235
|
+
const selected = await native.pickPetPackage();
|
|
1236
|
+
if (selected === null) return { outcome: "cancelled" };
|
|
1237
|
+
return {
|
|
1238
|
+
outcome: "published",
|
|
1239
|
+
pet: this.catalogStore.importPackage(selected.manifestBytes, selected.spritesheetBytes)
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Rescan the user package root and republish the catalog, so packages added
|
|
1244
|
+
* or removed on disk appear without a restart.
|
|
1245
|
+
* @returns the fresh snapshot after the rescan.
|
|
1246
|
+
*/
|
|
1247
|
+
refreshCatalog() {
|
|
1248
|
+
this.catalogStore.reload();
|
|
1249
|
+
return this.getSnapshot();
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Replace one existing user package's content with bytes selected by the
|
|
1253
|
+
* native host. The picked manifest must name the requested package, and a
|
|
1254
|
+
* mismatch or a non-user target fails before anything is written.
|
|
1255
|
+
* @param petId - user package identifier to replace.
|
|
1256
|
+
* @returns the replacement, cancellation, or host-availability result.
|
|
1257
|
+
*/
|
|
1258
|
+
async updatePetPackage(petId) {
|
|
1259
|
+
const native = this.nativeActions;
|
|
1260
|
+
if (native === void 0) return { outcome: "host-unavailable" };
|
|
1261
|
+
const selected = await native.pickPetPackage();
|
|
1262
|
+
if (selected === null) return { outcome: "cancelled" };
|
|
1263
|
+
const picked = validatePetPackage(selected.manifestBytes, selected.spritesheetBytes, { source: "user" });
|
|
1264
|
+
if (picked.id !== petId) throw new TypeError(`selected pet package ${picked.id} does not match update target ${petId}`);
|
|
1265
|
+
return {
|
|
1266
|
+
outcome: "published",
|
|
1267
|
+
pet: this.catalogStore.replacePackage(selected.manifestBytes, selected.spritesheetBytes)
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Ask the native host to open the configured DSH pet directory.
|
|
1272
|
+
* @returns the opened or host-availability result.
|
|
1273
|
+
*/
|
|
1274
|
+
async openPetFolder() {
|
|
1275
|
+
const native = this.nativeActions;
|
|
1276
|
+
if (native === void 0) return { outcome: "host-unavailable" };
|
|
1277
|
+
this.catalogStore.ensureRoot();
|
|
1278
|
+
await native.openPetFolder(this.catalogStore.petRoot);
|
|
1279
|
+
return { outcome: "opened" };
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Select a built-in or previously imported pet package.
|
|
1283
|
+
* @param selectedPetId - non-empty package identifier.
|
|
1284
|
+
* @returns the committed fresh snapshot.
|
|
1285
|
+
*/
|
|
1286
|
+
async selectPet(selectedPetId) {
|
|
1287
|
+
if (selectedPetId.length === 0) throw new TypeError("pet preference selectedPetId must not be empty");
|
|
1288
|
+
if (!this.catalogStore.has(selectedPetId)) throw new TypeError(`pet ${selectedPetId} was not found in the pet catalog`);
|
|
1289
|
+
return this.commit((preference) => ({
|
|
1290
|
+
...preference,
|
|
1291
|
+
selectedPetId
|
|
1292
|
+
}));
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Persist one validated logical CSS height for the selected companion.
|
|
1296
|
+
* @param sizePx - logical CSS height between the configured pet limits.
|
|
1297
|
+
* @returns the committed fresh snapshot.
|
|
1298
|
+
*/
|
|
1299
|
+
async setSize(sizePx) {
|
|
1300
|
+
validatePetSize(sizePx);
|
|
1301
|
+
return this.commit((preference) => ({
|
|
1302
|
+
...preference,
|
|
1303
|
+
sizePx
|
|
1304
|
+
}));
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Wake or tuck the selected companion.
|
|
1308
|
+
* @param awake - whether companion clients render the selected pet awake.
|
|
1309
|
+
* @returns the committed fresh snapshot.
|
|
1310
|
+
*/
|
|
1311
|
+
setAwake(awake) {
|
|
1312
|
+
return this.commit((preference) => ({
|
|
1313
|
+
...preference,
|
|
1314
|
+
awake
|
|
1315
|
+
}));
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Serve the companion page's one awake write (`POST {awake: boolean}`),
|
|
1319
|
+
* answering with the committed snapshot so the page applies it immediately.
|
|
1320
|
+
* The cross-site write fence matches the API gateway: only the JSON media
|
|
1321
|
+
* type is accepted, which forces a CORS preflight this loopback server never
|
|
1322
|
+
* answers, so a "simple" cross-site POST cannot tuck the pet blind.
|
|
1323
|
+
* @param req - the raw request; method, media type, and body are validated here.
|
|
1324
|
+
* @param res - the raw response; every failure path answers a status without a body.
|
|
1325
|
+
*/
|
|
1326
|
+
async handleOverlayAwake(req, res) {
|
|
1327
|
+
const fail = (status) => {
|
|
1328
|
+
res.writeHead(status);
|
|
1329
|
+
res.end();
|
|
1330
|
+
};
|
|
1331
|
+
if (req.method !== "POST") {
|
|
1332
|
+
fail(405);
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (req.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") {
|
|
1336
|
+
fail(415);
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
let payload;
|
|
1340
|
+
try {
|
|
1341
|
+
payload = JSON.parse(await readBoundedBody(req, OVERLAY_AWAKE_BODY_LIMIT_BYTES));
|
|
1342
|
+
} catch (error) {
|
|
1343
|
+
fail(error instanceof CompanionBodyLimitError ? 413 : 400);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
1347
|
+
fail(400);
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
const keys = Object.keys(payload);
|
|
1351
|
+
if (keys.length !== 1 || keys[0] !== "awake") {
|
|
1352
|
+
fail(400);
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
const awake = payload.awake;
|
|
1356
|
+
if (typeof awake !== "boolean") {
|
|
1357
|
+
fail(400);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
let snapshot;
|
|
1361
|
+
try {
|
|
1362
|
+
snapshot = await this.setAwake(awake);
|
|
1363
|
+
} catch {
|
|
1364
|
+
fail(500);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
res.writeHead(200, {
|
|
1368
|
+
"content-type": "application/json; charset=utf-8",
|
|
1369
|
+
"cache-control": "no-store"
|
|
1370
|
+
});
|
|
1371
|
+
res.end(JSON.stringify(snapshot));
|
|
1372
|
+
}
|
|
1373
|
+
/** Replace the local adapter state from one detached host projection. */
|
|
1374
|
+
applyActivity(records) {
|
|
1375
|
+
const next = /* @__PURE__ */ new Map();
|
|
1376
|
+
for (const record of records) {
|
|
1377
|
+
const status = petStatusForHostActivity(record);
|
|
1378
|
+
if (status === void 0) continue;
|
|
1379
|
+
next.set(String(record.sessionId), {
|
|
1380
|
+
sessionId: record.sessionId,
|
|
1381
|
+
title: record.title,
|
|
1382
|
+
status,
|
|
1383
|
+
since: record.since
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
this.activities.clear();
|
|
1387
|
+
for (const [sessionId, activity] of next) this.activities.set(sessionId, activity);
|
|
1388
|
+
this.publish();
|
|
1389
|
+
}
|
|
1390
|
+
/** Serialize one preference write and return only after durable persistence. */
|
|
1391
|
+
commit(mutate) {
|
|
1392
|
+
const run = async () => {
|
|
1393
|
+
const next = mutate(this.preference);
|
|
1394
|
+
try {
|
|
1395
|
+
await this.scope.replace({ ...next });
|
|
1396
|
+
this.preference = next;
|
|
1397
|
+
} catch (error) {
|
|
1398
|
+
this.preference = this.scope.get();
|
|
1399
|
+
throw error;
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
const attempt = this.tail.then(run);
|
|
1403
|
+
this.tail = attempt.then(() => void 0, () => void 0);
|
|
1404
|
+
return attempt.then(() => this.getSnapshot());
|
|
1405
|
+
}
|
|
1406
|
+
/** Publish a detached read model after a preference commit or activity transition. */
|
|
1407
|
+
publish() {
|
|
1408
|
+
this.ctx.emit("pet/update", this.getSnapshot());
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
})();
|
|
1412
|
+
const PET_OVERLAY_RUNTIME_CONFIG = JSON.stringify({ atlas: PET_COMPAT_ATLAS });
|
|
1413
|
+
/** Maximum accepted companion write body in bytes; the payload is one boolean. */
|
|
1414
|
+
const OVERLAY_AWAKE_BODY_LIMIT_BYTES = 1024;
|
|
1415
|
+
/** Marker for a request body that drained past `OVERLAY_AWAKE_BODY_LIMIT_BYTES`. */
|
|
1416
|
+
var CompanionBodyLimitError = class extends Error {};
|
|
1417
|
+
/**
|
|
1418
|
+
* Read one request body as UTF-8. Chunks past the byte cap are drained but
|
|
1419
|
+
* discarded — bounded memory, and the client still receives the error status.
|
|
1420
|
+
* @param req - the request whose body is draining.
|
|
1421
|
+
* @param limitBytes - inclusive byte cap before the read turns lossy.
|
|
1422
|
+
* @returns the full body, or rejects with `CompanionBodyLimitError` when it passed the cap.
|
|
1423
|
+
*/
|
|
1424
|
+
function readBoundedBody(req, limitBytes) {
|
|
1425
|
+
return new Promise((resolve, reject) => {
|
|
1426
|
+
const chunks = [];
|
|
1427
|
+
let total = 0;
|
|
1428
|
+
let overLimit = false;
|
|
1429
|
+
req.on("data", (chunk) => {
|
|
1430
|
+
total += chunk.length;
|
|
1431
|
+
if (total > limitBytes) overLimit = true;
|
|
1432
|
+
else chunks.push(chunk);
|
|
1433
|
+
});
|
|
1434
|
+
req.on("end", () => {
|
|
1435
|
+
if (overLimit) reject(new CompanionBodyLimitError());
|
|
1436
|
+
else resolve(Buffer.concat(chunks).toString("utf8"));
|
|
1437
|
+
});
|
|
1438
|
+
req.on("error", reject);
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
/** Return a static safe DOM document; live snapshot values arrive through JSON. */
|
|
1442
|
+
function createPetOverlayHtml() {
|
|
1443
|
+
return `<!doctype html>
|
|
1444
|
+
<meta name="color-scheme" content="dark">
|
|
1445
|
+
<style>
|
|
1446
|
+
html,body{margin:0;overflow:hidden;background:transparent;font:12px system-ui;color:white}
|
|
1447
|
+
#pet-root{position:fixed;inset:0;pointer-events:none}
|
|
1448
|
+
#pet-button{position:absolute;left:0;top:0;border:0;padding:0;background:transparent;cursor:grab;color:inherit;pointer-events:auto;user-select:none;-webkit-user-select:none}
|
|
1449
|
+
#pet-button:active{cursor:grabbing}
|
|
1450
|
+
#pet-sprite{display:block;background-repeat:no-repeat;image-rendering:pixelated;filter:drop-shadow(0 2px 3px #000)}
|
|
1451
|
+
#pet-label{display:block;text-align:center;text-shadow:0 1px 2px #000;white-space:nowrap}
|
|
1452
|
+
#pet-menu{position:fixed;z-index:2;border:1px solid #4a4f5a;border-radius:6px;background:#242832;box-shadow:0 4px 10px #000a;padding:3px;pointer-events:auto}
|
|
1453
|
+
#pet-menu button{display:block;border:0;background:transparent;color:inherit;font:inherit;text-align:left;padding:4px 12px;border-radius:4px;cursor:default;white-space:nowrap}
|
|
1454
|
+
#pet-menu button:hover{background:#3b4152}
|
|
1455
|
+
</style>
|
|
1456
|
+
<main id="pet-root"><button id="pet-button" type="button"><span id="pet-sprite" aria-hidden="true"></span><span id="pet-label"></span></button><div id="pet-menu" role="menu" hidden><button id="pet-menu-close" type="button" role="menuitem">关闭宠物</button></div></main>
|
|
1457
|
+
<script>
|
|
1458
|
+
const config=${PET_OVERLAY_RUNTIME_CONFIG};
|
|
1459
|
+
const frameAt=${FRAME_AT_SOURCE};
|
|
1460
|
+
const button=document.getElementById('pet-button');
|
|
1461
|
+
const sprite=document.getElementById('pet-sprite');
|
|
1462
|
+
const label=document.getElementById('pet-label');
|
|
1463
|
+
const menu=document.getElementById('pet-menu');
|
|
1464
|
+
const menuClose=document.getElementById('pet-menu-close');
|
|
1465
|
+
const api=window.dshDesktopCompanion;
|
|
1466
|
+
let current=null;
|
|
1467
|
+
let hover=false;
|
|
1468
|
+
let drag=null;
|
|
1469
|
+
let menuOpen=false;
|
|
1470
|
+
let moved=false;
|
|
1471
|
+
let animationName='idle';
|
|
1472
|
+
let activityAnimationName='idle';
|
|
1473
|
+
let frameStarted=performance.now();
|
|
1474
|
+
const motionQuery=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):null;
|
|
1475
|
+
let reducedMotion=motionQuery?.matches===true;
|
|
1476
|
+
motionQuery?.addEventListener?.('change',event=>{reducedMotion=event.matches;frameStarted=performance.now()});
|
|
1477
|
+
function activityState(status){
|
|
1478
|
+
if(status==='needs-input')return 'waiting';
|
|
1479
|
+
if(status==='blocked')return 'failed';
|
|
1480
|
+
if(status==='ready')return 'review';
|
|
1481
|
+
if(status==='running')return 'running';
|
|
1482
|
+
return 'idle';
|
|
1483
|
+
}
|
|
1484
|
+
function selectedPet(){
|
|
1485
|
+
if(current===null)return null;
|
|
1486
|
+
return current.catalog.pets.find(pet=>pet.id===current.preference.selectedPetId)||current.catalog.pets[0]||null;
|
|
1487
|
+
}
|
|
1488
|
+
function applySnapshot(next){
|
|
1489
|
+
const previousPet=current===null?null:selectedPet();
|
|
1490
|
+
const previousActivity=activityAnimationName;
|
|
1491
|
+
const previousSize=current?.preference.sizePx;
|
|
1492
|
+
const nextActivity=activityState(next.selectedActivity?.status);
|
|
1493
|
+
current=next;
|
|
1494
|
+
const pet=selectedPet();
|
|
1495
|
+
const awake=next.preference.awake&&pet!==null;
|
|
1496
|
+
if(previousActivity!==nextActivity||previousPet?.id!==pet?.id||previousPet?.assetUrl!==pet?.assetUrl||previousSize!==next.preference.sizePx){
|
|
1497
|
+
activityAnimationName=nextActivity;
|
|
1498
|
+
animationName=nextActivity;
|
|
1499
|
+
frameStarted=performance.now();
|
|
1500
|
+
}
|
|
1501
|
+
// Keep the Electron companion window sized to the sprite: the shell clamps
|
|
1502
|
+
// the request into the registered resize capability (74..207 x 80..224), and
|
|
1503
|
+
// the first sample (previousSize===undefined) reconciles a window restored at
|
|
1504
|
+
// a stale height with the current preference.
|
|
1505
|
+
if(previousSize!==next.preference.sizePx&&api){
|
|
1506
|
+
void api.resize({width:Math.round(next.preference.sizePx*config.atlas.cellWidth/config.atlas.cellHeight),height:next.preference.sizePx}).catch(()=>{});
|
|
1507
|
+
}
|
|
1508
|
+
button.hidden=!awake;
|
|
1509
|
+
if(!awake){
|
|
1510
|
+
closeMenu();
|
|
1511
|
+
applyPointerInteraction();
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
button.style.width=String(Math.round(next.preference.sizePx*config.atlas.cellWidth/config.atlas.cellHeight))+'px';
|
|
1515
|
+
button.style.height=String(next.preference.sizePx)+'px';
|
|
1516
|
+
label.textContent=next.selectedActivity?next.selectedActivity.status:'ready';
|
|
1517
|
+
button.setAttribute('aria-label','DeepSeek Harness pet: '+label.textContent);
|
|
1518
|
+
}
|
|
1519
|
+
// The companion window must not eat clicks while nothing visible can receive
|
|
1520
|
+
// them: interactive only while the pet is awake and the pointer is over the
|
|
1521
|
+
// pet, a menu is open, or a drag owns the pointer.
|
|
1522
|
+
function applyPointerInteraction(){
|
|
1523
|
+
const interactive=current!==null&¤t.preference.awake&&(hover||menuOpen||drag!==null);
|
|
1524
|
+
if(api)void api.setPointerInteraction({interactive}).catch(()=>{});
|
|
1525
|
+
}
|
|
1526
|
+
function openMenu(x,y){
|
|
1527
|
+
menu.hidden=false;
|
|
1528
|
+
menuOpen=true;
|
|
1529
|
+
menu.style.left=String(Math.max(0,Math.min(x,window.innerWidth-menu.offsetWidth)))+'px';
|
|
1530
|
+
menu.style.top=String(Math.max(0,Math.min(y,window.innerHeight-menu.offsetHeight)))+'px';
|
|
1531
|
+
applyPointerInteraction();
|
|
1532
|
+
}
|
|
1533
|
+
function closeMenu(){
|
|
1534
|
+
if(!menuOpen)return;
|
|
1535
|
+
menu.hidden=true;
|
|
1536
|
+
menuOpen=false;
|
|
1537
|
+
applyPointerInteraction();
|
|
1538
|
+
}
|
|
1539
|
+
async function closePet(){
|
|
1540
|
+
closeMenu();
|
|
1541
|
+
try{
|
|
1542
|
+
const response=await fetch('/__dsh/pet/overlay-awake',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({awake:false})});
|
|
1543
|
+
if(response.ok)applySnapshot(await response.json());
|
|
1544
|
+
}catch{
|
|
1545
|
+
// Write failure: the menu closing is the only immediate effect; the next
|
|
1546
|
+
// overlay-state sync re-applies the authoritative preference.
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
function renderFrame(time){
|
|
1550
|
+
const pet=selectedPet();
|
|
1551
|
+
if(current!==null&&pet!==null&¤t.preference.awake){
|
|
1552
|
+
const name=hover?'jumping':animationName;
|
|
1553
|
+
const elapsed=time-frameStarted;
|
|
1554
|
+
const selection=frameAt(pet.animations,name,elapsed,reducedMotion,new Set());
|
|
1555
|
+
const spriteIndex=selection?.spriteIndex??0;
|
|
1556
|
+
const displayRow=Math.floor(spriteIndex/config.atlas.columns);
|
|
1557
|
+
const displayColumn=spriteIndex%config.atlas.columns;
|
|
1558
|
+
sprite.style.width=String(Math.round(current.preference.sizePx*config.atlas.cellWidth/config.atlas.cellHeight))+'px';
|
|
1559
|
+
sprite.style.height=String(current.preference.sizePx)+'px';
|
|
1560
|
+
sprite.style.backgroundImage='url('+pet.assetUrl+')';
|
|
1561
|
+
sprite.style.backgroundSize=String(Math.round(current.preference.sizePx*config.atlas.columns*config.atlas.cellWidth/config.atlas.cellHeight))+'px '+String(current.preference.sizePx*config.atlas.rows)+'px';
|
|
1562
|
+
sprite.style.backgroundPosition=String(-displayColumn*Math.round(current.preference.sizePx*config.atlas.cellWidth/config.atlas.cellHeight))+'px '+String(-displayRow*current.preference.sizePx)+'px';
|
|
1563
|
+
}
|
|
1564
|
+
requestAnimationFrame(renderFrame);
|
|
1565
|
+
}
|
|
1566
|
+
async function syncSnapshot(){
|
|
1567
|
+
if(drag!==null)return;
|
|
1568
|
+
try{
|
|
1569
|
+
const response=await fetch('/__dsh/pet/overlay-state',{cache:'no-store'});
|
|
1570
|
+
if(response.ok)applySnapshot(await response.json());
|
|
1571
|
+
}catch{}
|
|
1572
|
+
}
|
|
1573
|
+
button.addEventListener('pointerenter',()=>{hover=true;applyPointerInteraction()});
|
|
1574
|
+
button.addEventListener('pointerleave',()=>{hover=false;applyPointerInteraction()});
|
|
1575
|
+
button.addEventListener('pointerdown',event=>{
|
|
1576
|
+
if(event.button!==0)return;
|
|
1577
|
+
event.preventDefault();
|
|
1578
|
+
button.setPointerCapture(event.pointerId);
|
|
1579
|
+
const pending={pointerId:event.pointerId,startX:event.clientX,startY:event.clientY,sequence:0};
|
|
1580
|
+
drag=pending;moved=false;
|
|
1581
|
+
if(api)void api.startDrag({pointerId:event.pointerId,screenX:event.screenX,screenY:event.screenY}).then(result=>{if(drag===pending)pending.dragId=result.dragId}).catch(()=>{if(drag===pending)drag=null});
|
|
1582
|
+
});
|
|
1583
|
+
button.addEventListener('pointermove',event=>{
|
|
1584
|
+
if(drag===null||drag.pointerId!==event.pointerId)return;
|
|
1585
|
+
if(Math.hypot(event.clientX-drag.startX,event.clientY-drag.startY)>4)moved=true;
|
|
1586
|
+
if(api&&drag.dragId!==undefined){drag.sequence+=1;void api.moveDrag({dragId:drag.dragId,pointerId:drag.pointerId,sequence:drag.sequence,screenX:event.screenX,screenY:event.screenY}).then(result=>{if(result.accepted&&result.direction!=='neutral')animationName=result.direction==='left'?'running-left':'running-right'}).catch(()=>{})}
|
|
1587
|
+
});
|
|
1588
|
+
button.addEventListener('pointerup',event=>{
|
|
1589
|
+
const pending=drag;if(pending===null||pending.pointerId!==event.pointerId)return;
|
|
1590
|
+
drag=null;
|
|
1591
|
+
if(api&&pending.dragId!==undefined){pending.sequence+=1;void api.endDrag({dragId:pending.dragId,pointerId:pending.pointerId,sequence:pending.sequence,screenX:event.screenX,screenY:event.screenY}).catch(()=>{})}
|
|
1592
|
+
});
|
|
1593
|
+
button.addEventListener('pointercancel',event=>{
|
|
1594
|
+
const pending=drag;if(pending===null||pending.pointerId!==event.pointerId)return;
|
|
1595
|
+
drag=null;
|
|
1596
|
+
if(api&&pending.dragId!==undefined)void api.cancelDrag({dragId:pending.dragId,pointerId:pending.pointerId}).catch(()=>{});
|
|
1597
|
+
});
|
|
1598
|
+
button.addEventListener('click',()=>{if(moved){moved=false;return}if(api)void api.focusMain().catch(()=>{})});
|
|
1599
|
+
button.addEventListener('contextmenu',event=>{
|
|
1600
|
+
event.preventDefault();
|
|
1601
|
+
if(drag!==null)return;
|
|
1602
|
+
openMenu(event.clientX,event.clientY);
|
|
1603
|
+
});
|
|
1604
|
+
menuClose.addEventListener('click',()=>{void closePet()});
|
|
1605
|
+
window.addEventListener('pointerdown',event=>{if(menuOpen&&!menu.contains(event.target))closeMenu()},true);
|
|
1606
|
+
window.addEventListener('keydown',event=>{if(event.key==='Escape')closeMenu()});
|
|
1607
|
+
void syncSnapshot();
|
|
1608
|
+
setInterval(()=>void syncSnapshot(),750);
|
|
1609
|
+
requestAnimationFrame(renderFrame);
|
|
1610
|
+
<\/script>`;
|
|
1611
|
+
}
|
|
1612
|
+
//#endregion
|
|
1613
|
+
export { DEFAULT_PET_ID, DEFAULT_PET_SIZE_PX, MAX_PET_SIZE_PX, MIN_PET_SIZE_PX, PET_PREFERENCE_VERSION, PetService, PetService as default, comparePetActivities, defaultPetPreference, isDragMovement, petSpriteAvatar, petSpriteFrame, petStatusForHostActivity, petWidthForSize, resolvePetPreference, selectLookDirection, selectPetPresentation, validatePetPackage, validatePetSize };
|