@howaboua/pi-pet 0.1.1
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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/SECURITY.md +24 -0
- package/authoring/PET-GUIDE.md +65 -0
- package/authoring/hatch/HATCH-GUIDE.md +136 -0
- package/authoring/hatch/LICENSE.txt +201 -0
- package/authoring/hatch/NOTICE.md +5 -0
- package/authoring/hatch/pyproject.toml +8 -0
- package/authoring/hatch/references/animation-rows.md +29 -0
- package/authoring/hatch/references/final-assembly.md +67 -0
- package/authoring/hatch/references/look-directions.md +35 -0
- package/authoring/hatch/references/pet-contract.md +40 -0
- package/authoring/hatch/references/qa-rubric.md +34 -0
- package/authoring/hatch/references/reviewer-protocol.md +72 -0
- package/authoring/hatch/references/standard-assembly.md +21 -0
- package/authoring/hatch/references/visual-generation.md +59 -0
- package/authoring/hatch/scripts/approve_cardinals.py +110 -0
- package/authoring/hatch/scripts/assemble_extended_atlas.py +684 -0
- package/authoring/hatch/scripts/combine_direction_blind_verdicts.py +59 -0
- package/authoring/hatch/scripts/compose_atlas.py +171 -0
- package/authoring/hatch/scripts/compose_cardinal_anchor_strip.py +45 -0
- package/authoring/hatch/scripts/derive_running_left_from_running_right.py +164 -0
- package/authoring/hatch/scripts/despill_chroma_edges.py +312 -0
- package/authoring/hatch/scripts/extract_cardinal_anchors.py +153 -0
- package/authoring/hatch/scripts/extract_strip_frames.py +409 -0
- package/authoring/hatch/scripts/inspect_frames.py +278 -0
- package/authoring/hatch/scripts/make_contact_sheet.py +116 -0
- package/authoring/hatch/scripts/make_direction_blind_qa_sheet.py +179 -0
- package/authoring/hatch/scripts/make_direction_qa_sheet.py +172 -0
- package/authoring/hatch/scripts/measure_direction_continuity.py +201 -0
- package/authoring/hatch/scripts/prepare_pet_run.py +1207 -0
- package/authoring/hatch/scripts/render_animation_previews.py +80 -0
- package/authoring/hatch/scripts/validate_atlas.py +299 -0
- package/authoring/hatch/scripts/validate_direction_blind_verdicts.py +100 -0
- package/authoring/hatch/uv.lock +99 -0
- package/authoring/references/import-existing-pet.md +47 -0
- package/authoring/references/pet-format.md +88 -0
- package/changelog.ts +327 -0
- package/deploy/pi-pet-hyprland.conf +10 -0
- package/desktop/build.mjs +194 -0
- package/desktop/package-lock.json +887 -0
- package/desktop/package.json +16 -0
- package/dist/authoring/prepare.mjs +3 -0
- package/dist/authoring/rebuild.mjs +4 -0
- package/dist/authoring/validate.mjs +2 -0
- package/dist/desktop/main.mjs +5 -0
- package/dist/desktop/preload.cjs +1 -0
- package/dist/web/app.js +1 -0
- package/dist/web/catalog.json +1 -0
- package/dist/web/index.html +100 -0
- package/dist/web/manifest.webmanifest +16 -0
- package/dist/web/pet-icon.svg +6 -0
- package/dist/web/spritesheet.webp +0 -0
- package/dist/web/styles.css +592 -0
- package/docs/architecture.md +41 -0
- package/docs/pi-pet.png +0 -0
- package/docs/research.md +17 -0
- package/extensions/desktop-command.ts +249 -0
- package/extensions/desktop-config.ts +152 -0
- package/extensions/desktop-launcher.ts +380 -0
- package/extensions/gippity.ts +24 -0
- package/extensions/index.ts +110 -0
- package/extensions/remote-app.ts +61 -0
- package/package.json +101 -0
- package/pets/clawa/PET.md +27 -0
- package/pets/clawa/pet.json +7 -0
- package/pets/clawa/spritesheet.webp +0 -0
- package/pets/clawa/validation.json +808 -0
- package/src/desktop/attention.ts +87 -0
- package/src/desktop/bridge.ts +31 -0
- package/src/desktop/config.ts +145 -0
- package/src/desktop/cursor-provider.ts +150 -0
- package/src/desktop/main.ts +459 -0
- package/src/desktop/preload.ts +19 -0
- package/src/pet-image-size.ts +72 -0
- package/src/pet-loader.ts +278 -0
- package/src/pet-storage.ts +240 -0
- package/src/protocol/index.ts +81 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { createReadStream, type ReadStream } from "node:fs";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { app, BrowserWindow, Menu, screen, session } from "electron";
|
|
6
|
+
import {
|
|
7
|
+
type AttentionPreferences,
|
|
8
|
+
defaultAttentionPreferences,
|
|
9
|
+
loadAttentionPreferences,
|
|
10
|
+
type PetSize,
|
|
11
|
+
remainingSnoozeMs,
|
|
12
|
+
saveAttentionPreferences,
|
|
13
|
+
snoozeUntilTomorrow,
|
|
14
|
+
} from "./attention.ts";
|
|
15
|
+
import { DESKTOP_CURSOR_CHANNEL, type DesktopCursorPosition } from "./bridge.ts";
|
|
16
|
+
import { type DesktopConfig, desktopDisplayUrl, desktopStateDirectory, loadDesktopConfig } from "./config.ts";
|
|
17
|
+
import { createCursorReader, createHyprlandBoundsReader, type DesktopWindowBounds } from "./cursor-provider.ts";
|
|
18
|
+
|
|
19
|
+
const WINDOW_MARGIN = 20;
|
|
20
|
+
const RETRY_DELAY_MS = 3_000;
|
|
21
|
+
const SCREENSHOT_DELAY_MS = 1_500;
|
|
22
|
+
const CURSOR_POLL_MS = 100;
|
|
23
|
+
const CURSOR_PROXIMITY_MARGIN = 220;
|
|
24
|
+
const CURSOR_RETRY_MIN_MS = 500;
|
|
25
|
+
const CURSOR_RETRY_MAX_MS = 10_000;
|
|
26
|
+
const WINDOW_BOUNDS_POLL_MS = 1_000;
|
|
27
|
+
const WINDOW_SIZES: Readonly<Record<PetSize, { width: number; height: number }>> = Object.freeze({
|
|
28
|
+
small: { width: 180, height: 195 },
|
|
29
|
+
medium: { width: 240, height: 260 },
|
|
30
|
+
large: { width: 320, height: 347 },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
let mainWindow: BrowserWindow | undefined;
|
|
34
|
+
let retryTimer: NodeJS.Timeout | undefined;
|
|
35
|
+
let wakeTimer: NodeJS.Timeout | undefined;
|
|
36
|
+
let cursorTimer: NodeJS.Timeout | undefined;
|
|
37
|
+
let cursorBoundsTimer: NodeJS.Timeout | undefined;
|
|
38
|
+
let desktopConfig: DesktopConfig | undefined;
|
|
39
|
+
let attention = defaultAttentionPreferences();
|
|
40
|
+
let attentionWrites = Promise.resolve();
|
|
41
|
+
let signalShutdownStarted = false;
|
|
42
|
+
let ownerPipe: ReadStream | undefined;
|
|
43
|
+
let ownerClosing = false;
|
|
44
|
+
|
|
45
|
+
app.setName("Pi Pet Desktop");
|
|
46
|
+
app.setPath("userData", desktopStateDirectory());
|
|
47
|
+
|
|
48
|
+
if (process.platform === "linux") {
|
|
49
|
+
app.disableHardwareAcceleration();
|
|
50
|
+
app.commandLine.appendSwitch("disable-gpu");
|
|
51
|
+
app.commandLine.appendSwitch("disable-gpu-compositing");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pinWindow(window: BrowserWindow): void {
|
|
55
|
+
const level = process.platform === "darwin" ? "floating" : "screen-saver";
|
|
56
|
+
window.setAlwaysOnTop(true, level, 1);
|
|
57
|
+
window.moveTop();
|
|
58
|
+
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function attentionPath(): string {
|
|
62
|
+
return join(app.getPath("userData"), "attention.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function watchOwner(): void {
|
|
66
|
+
const fd = Number(process.env["PI_PET_OWNER_FD"]);
|
|
67
|
+
if (!Number.isSafeInteger(fd) || fd < 3 || fd > 9) return;
|
|
68
|
+
const ownerGone = () => {
|
|
69
|
+
if (!ownerClosing) app.quit();
|
|
70
|
+
};
|
|
71
|
+
ownerPipe = createReadStream("", { fd, autoClose: false });
|
|
72
|
+
ownerPipe.once("end", ownerGone);
|
|
73
|
+
ownerPipe.once("close", ownerGone);
|
|
74
|
+
ownerPipe.once("error", ownerGone);
|
|
75
|
+
ownerPipe.resume();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function currentDisplayUrl(): string {
|
|
79
|
+
if (!desktopConfig) throw new Error("Pi Pet desktop configuration is not loaded.");
|
|
80
|
+
return desktopDisplayUrl(desktopConfig, attention.mode);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function updateAttention(update: (current: AttentionPreferences) => AttentionPreferences): Promise<void> {
|
|
84
|
+
const operation = attentionWrites.then(async () => {
|
|
85
|
+
const next = update(attention);
|
|
86
|
+
await saveAttentionPreferences(attentionPath(), next);
|
|
87
|
+
attention = next;
|
|
88
|
+
});
|
|
89
|
+
attentionWrites = operation.catch(() => undefined);
|
|
90
|
+
return operation;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function reportDesktopError(error: unknown): void {
|
|
94
|
+
process.stderr.write(`Pi Pet desktop: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isConfiguredGippityOrigin(value: string | undefined): boolean {
|
|
98
|
+
if (!(desktopConfig && value)) return false;
|
|
99
|
+
try {
|
|
100
|
+
return new URL(value).origin === new URL(desktopConfig.gippityUrl).origin;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function configurePermissions(): void {
|
|
107
|
+
session.defaultSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
|
|
108
|
+
return (
|
|
109
|
+
permission === "media" &&
|
|
110
|
+
details.isMainFrame &&
|
|
111
|
+
details.mediaType === "audio" &&
|
|
112
|
+
webContents !== null &&
|
|
113
|
+
isConfiguredGippityOrigin(webContents.getURL()) &&
|
|
114
|
+
isConfiguredGippityOrigin(requestingOrigin)
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
|
118
|
+
const mediaTypes = "mediaTypes" in details ? details.mediaTypes : undefined;
|
|
119
|
+
callback(
|
|
120
|
+
permission === "media" &&
|
|
121
|
+
details.isMainFrame &&
|
|
122
|
+
mediaTypes?.length === 1 &&
|
|
123
|
+
mediaTypes[0] === "audio" &&
|
|
124
|
+
isConfiguredGippityOrigin(webContents.getURL()) &&
|
|
125
|
+
isConfiguredGippityOrigin(details.requestingUrl),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function configureCertificateVerification(): void {
|
|
131
|
+
session.defaultSession.setCertificateVerifyProc((request, callback) => {
|
|
132
|
+
const configuredHost = desktopConfig ? new URL(desktopConfig.gippityUrl).hostname : undefined;
|
|
133
|
+
const authorityInvalid =
|
|
134
|
+
request.errorCode === -202 ||
|
|
135
|
+
request.verificationResult === "CERT_AUTHORITY_INVALID" ||
|
|
136
|
+
request.verificationResult === "net::ERR_CERT_AUTHORITY_INVALID";
|
|
137
|
+
callback(request.hostname === configuredHost && authorityInvalid ? 0 : request.errorCode);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
|
142
|
+
try {
|
|
143
|
+
if (
|
|
144
|
+
desktopConfig &&
|
|
145
|
+
error === "net::ERR_CERT_AUTHORITY_INVALID" &&
|
|
146
|
+
new URL(url).origin === new URL(desktopConfig.gippityUrl).origin
|
|
147
|
+
) {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
callback(true);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// Reject malformed certificate targets below.
|
|
154
|
+
}
|
|
155
|
+
callback(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
function showAwakeWindow(window: BrowserWindow): void {
|
|
159
|
+
if (remainingSnoozeMs(attention) > 0) {
|
|
160
|
+
window.hide();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
pinWindow(window);
|
|
164
|
+
window.show();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function scheduleWake(window: BrowserWindow): void {
|
|
168
|
+
if (wakeTimer) clearTimeout(wakeTimer);
|
|
169
|
+
wakeTimer = undefined;
|
|
170
|
+
const remaining = remainingSnoozeMs(attention);
|
|
171
|
+
if (remaining === 0) {
|
|
172
|
+
showAwakeWindow(window);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
window.hide();
|
|
176
|
+
wakeTimer = setTimeout(() => {
|
|
177
|
+
wakeTimer = undefined;
|
|
178
|
+
void updateAttention((current) => ({ ...current, snoozedUntil: null }))
|
|
179
|
+
.then(() => showAwakeWindow(window))
|
|
180
|
+
.catch(reportDesktopError);
|
|
181
|
+
}, remaining);
|
|
182
|
+
wakeTimer.unref();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function snooze(window: BrowserWindow, until: Date): Promise<void> {
|
|
186
|
+
await updateAttention((current) => ({ ...current, snoozedUntil: until.toISOString() }));
|
|
187
|
+
scheduleWake(window);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function setQuietMode(window: BrowserWindow, quiet: boolean): Promise<void> {
|
|
191
|
+
await updateAttention((current) => ({ ...current, mode: quiet ? "quiet" : "normal" }));
|
|
192
|
+
await loadDisplay(window, currentDisplayUrl());
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function setPetSize(window: BrowserWindow, petSize: PetSize): Promise<void> {
|
|
196
|
+
await updateAttention((current) => ({ ...current, petSize }));
|
|
197
|
+
const bounds = window.getBounds();
|
|
198
|
+
const next = WINDOW_SIZES[petSize];
|
|
199
|
+
window.setBounds({
|
|
200
|
+
x: bounds.x + bounds.width - next.width,
|
|
201
|
+
y: bounds.y + bounds.height - next.height,
|
|
202
|
+
...next,
|
|
203
|
+
});
|
|
204
|
+
pinWindow(window);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function initialBounds(petSize: PetSize): Electron.Rectangle {
|
|
208
|
+
const workArea = screen.getPrimaryDisplay().workArea;
|
|
209
|
+
const size = WINDOW_SIZES[petSize];
|
|
210
|
+
return {
|
|
211
|
+
x: workArea.x + workArea.width - size.width - WINDOW_MARGIN,
|
|
212
|
+
y: workArea.y + workArea.height - size.height - WINDOW_MARGIN,
|
|
213
|
+
...size,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function startCursorTracking(window: BrowserWindow): void {
|
|
218
|
+
let cursorWasNear = false;
|
|
219
|
+
let reading = false;
|
|
220
|
+
let warned = false;
|
|
221
|
+
let retryDelay = CURSOR_RETRY_MIN_MS;
|
|
222
|
+
let retryAt = 0;
|
|
223
|
+
const readCursor = createCursorReader(process.env, () => screen.getCursorScreenPoint());
|
|
224
|
+
const readHyprlandBounds = createHyprlandBoundsReader(process.env, process.pid);
|
|
225
|
+
let compositorBounds: DesktopWindowBounds | undefined;
|
|
226
|
+
let boundsWarned = false;
|
|
227
|
+
const refreshBounds = async (): Promise<void> => {
|
|
228
|
+
if (!readHyprlandBounds) return;
|
|
229
|
+
try {
|
|
230
|
+
compositorBounds = await readHyprlandBounds();
|
|
231
|
+
boundsWarned = false;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (!boundsWarned) {
|
|
234
|
+
boundsWarned = true;
|
|
235
|
+
reportDesktopError(error);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
const publish = async (): Promise<void> => {
|
|
240
|
+
if (reading || Date.now() < retryAt || window.isDestroyed() || remainingSnoozeMs(attention) > 0) return;
|
|
241
|
+
reading = true;
|
|
242
|
+
let cursor: DesktopCursorPosition;
|
|
243
|
+
try {
|
|
244
|
+
cursor = await readCursor();
|
|
245
|
+
warned = false;
|
|
246
|
+
retryDelay = CURSOR_RETRY_MIN_MS;
|
|
247
|
+
retryAt = 0;
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (!warned) {
|
|
250
|
+
warned = true;
|
|
251
|
+
reportDesktopError(error);
|
|
252
|
+
}
|
|
253
|
+
retryAt = Date.now() + retryDelay;
|
|
254
|
+
retryDelay = Math.min(retryDelay * 2, CURSOR_RETRY_MAX_MS);
|
|
255
|
+
reading = false;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const bounds = compositorBounds || window.getBounds();
|
|
259
|
+
const x = cursor.x - bounds.x;
|
|
260
|
+
const y = cursor.y - bounds.y;
|
|
261
|
+
const dx = x - bounds.width / 2;
|
|
262
|
+
const dy = y - bounds.height / 2;
|
|
263
|
+
const radius = Math.max(bounds.width, bounds.height) / 2 + CURSOR_PROXIMITY_MARGIN;
|
|
264
|
+
const near = Math.hypot(dx, dy) <= radius;
|
|
265
|
+
if (near) {
|
|
266
|
+
const position: DesktopCursorPosition = { x, y };
|
|
267
|
+
window.webContents.send(DESKTOP_CURSOR_CHANNEL, position);
|
|
268
|
+
} else if (cursorWasNear) {
|
|
269
|
+
window.webContents.send(DESKTOP_CURSOR_CHANNEL, null);
|
|
270
|
+
}
|
|
271
|
+
cursorWasNear = near;
|
|
272
|
+
reading = false;
|
|
273
|
+
};
|
|
274
|
+
cursorTimer = setInterval(() => void publish(), CURSOR_POLL_MS);
|
|
275
|
+
cursorTimer.unref();
|
|
276
|
+
cursorBoundsTimer = setInterval(() => void refreshBounds(), WINDOW_BOUNDS_POLL_MS);
|
|
277
|
+
cursorBoundsTimer.unref();
|
|
278
|
+
void refreshBounds().then(publish);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function secureWebContents(window: BrowserWindow, gippityOrigin: string): void {
|
|
282
|
+
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
283
|
+
const confineNavigation = (event: Electron.Event, target: string): void => {
|
|
284
|
+
try {
|
|
285
|
+
if (new URL(target).origin !== gippityOrigin) event.preventDefault();
|
|
286
|
+
} catch {
|
|
287
|
+
event.preventDefault();
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
window.webContents.on("will-navigate", confineNavigation);
|
|
291
|
+
window.webContents.on("will-redirect", confineNavigation);
|
|
292
|
+
window.webContents.on("context-menu", () => {
|
|
293
|
+
Menu.buildFromTemplate([
|
|
294
|
+
{ label: "Use arrow keys and Enter", enabled: false },
|
|
295
|
+
{ type: "separator" },
|
|
296
|
+
{ label: "Reload pet", click: () => window.webContents.reload() },
|
|
297
|
+
{
|
|
298
|
+
label: "Quiet mode",
|
|
299
|
+
type: "checkbox",
|
|
300
|
+
checked: attention.mode === "quiet",
|
|
301
|
+
click: (item) => void setQuietMode(window, item.checked).catch(reportDesktopError),
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
label: "Snooze",
|
|
305
|
+
submenu: [
|
|
306
|
+
{
|
|
307
|
+
label: "For 15 minutes",
|
|
308
|
+
click: () => void snooze(window, new Date(Date.now() + 15 * 60 * 1000)).catch(reportDesktopError),
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
label: "For 1 hour",
|
|
312
|
+
click: () => void snooze(window, new Date(Date.now() + 60 * 60 * 1000)).catch(reportDesktopError),
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
label: "Until tomorrow",
|
|
316
|
+
click: () => void snooze(window, snoozeUntilTomorrow()).catch(reportDesktopError),
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
label: "Pet size",
|
|
322
|
+
submenu: (["small", "medium", "large"] as const).map((petSize) => ({
|
|
323
|
+
label: petSize[0]?.toUpperCase() + petSize.slice(1),
|
|
324
|
+
type: "radio" as const,
|
|
325
|
+
checked: attention.petSize === petSize,
|
|
326
|
+
click: () => void setPetSize(window, petSize).catch(reportDesktopError),
|
|
327
|
+
})),
|
|
328
|
+
},
|
|
329
|
+
{ type: "separator" },
|
|
330
|
+
{ label: "Quit for this Pi session", click: () => app.quit() },
|
|
331
|
+
]).popup({ window });
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function captureAndQuit(window: BrowserWindow, path: string): Promise<void> {
|
|
336
|
+
await new Promise((resolve) => setTimeout(resolve, SCREENSHOT_DELAY_MS));
|
|
337
|
+
const image = await window.capturePage();
|
|
338
|
+
await writeFile(path, image.toPNG());
|
|
339
|
+
app.quit();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function scheduleLoad(window: BrowserWindow, url: string): void {
|
|
343
|
+
retryTimer = setTimeout(() => {
|
|
344
|
+
retryTimer = undefined;
|
|
345
|
+
void loadDisplay(window, url);
|
|
346
|
+
}, RETRY_DELAY_MS);
|
|
347
|
+
retryTimer.unref();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function loadDisplay(window: BrowserWindow, url: string): Promise<void> {
|
|
351
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
352
|
+
retryTimer = undefined;
|
|
353
|
+
try {
|
|
354
|
+
await window.loadURL(url);
|
|
355
|
+
if (window.isDestroyed()) return;
|
|
356
|
+
showAwakeWindow(window);
|
|
357
|
+
const screenshotPath = process.env["PI_PET_DESKTOP_SCREENSHOT"];
|
|
358
|
+
if (screenshotPath) await captureAndQuit(window, screenshotPath);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (window.isDestroyed()) return;
|
|
361
|
+
process.stderr.write(
|
|
362
|
+
`Pi Pet desktop could not reach GipPity: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
363
|
+
);
|
|
364
|
+
scheduleLoad(window, url);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function createWindow(): Promise<BrowserWindow> {
|
|
369
|
+
desktopConfig = await loadDesktopConfig();
|
|
370
|
+
attention = await loadAttentionPreferences(attentionPath());
|
|
371
|
+
const url = currentDisplayUrl();
|
|
372
|
+
const gippityOrigin = new URL(url).origin;
|
|
373
|
+
const window = new BrowserWindow({
|
|
374
|
+
...initialBounds(attention.petSize),
|
|
375
|
+
frame: false,
|
|
376
|
+
transparent: true,
|
|
377
|
+
hasShadow: false,
|
|
378
|
+
backgroundColor: "#00000000",
|
|
379
|
+
alwaysOnTop: true,
|
|
380
|
+
maximizable: false,
|
|
381
|
+
minimizable: false,
|
|
382
|
+
movable: true,
|
|
383
|
+
resizable: false,
|
|
384
|
+
show: false,
|
|
385
|
+
skipTaskbar: true,
|
|
386
|
+
title: "Pi Pet",
|
|
387
|
+
webPreferences: {
|
|
388
|
+
contextIsolation: true,
|
|
389
|
+
devTools: process.env["PI_PET_DESKTOP_DEVTOOLS"] === "1",
|
|
390
|
+
nodeIntegration: false,
|
|
391
|
+
preload: join(dirname(fileURLToPath(import.meta.url)), "preload.cjs"),
|
|
392
|
+
sandbox: true,
|
|
393
|
+
webSecurity: true,
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
secureWebContents(window, gippityOrigin);
|
|
397
|
+
pinWindow(window);
|
|
398
|
+
window.setFullScreenable(false);
|
|
399
|
+
window.on("blur", () => pinWindow(window));
|
|
400
|
+
window.on("show", () => pinWindow(window));
|
|
401
|
+
window.on("closed", () => {
|
|
402
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
403
|
+
if (wakeTimer) clearTimeout(wakeTimer);
|
|
404
|
+
if (cursorTimer) clearInterval(cursorTimer);
|
|
405
|
+
if (cursorBoundsTimer) clearInterval(cursorBoundsTimer);
|
|
406
|
+
retryTimer = undefined;
|
|
407
|
+
wakeTimer = undefined;
|
|
408
|
+
cursorTimer = undefined;
|
|
409
|
+
cursorBoundsTimer = undefined;
|
|
410
|
+
if (mainWindow === window) mainWindow = undefined;
|
|
411
|
+
});
|
|
412
|
+
void loadDisplay(window, url);
|
|
413
|
+
startCursorTracking(window);
|
|
414
|
+
if (remainingSnoozeMs(attention) > 0) scheduleWake(window);
|
|
415
|
+
return window;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
app
|
|
419
|
+
.whenReady()
|
|
420
|
+
.then(async () => {
|
|
421
|
+
Menu.setApplicationMenu(null);
|
|
422
|
+
watchOwner();
|
|
423
|
+
configurePermissions();
|
|
424
|
+
configureCertificateVerification();
|
|
425
|
+
mainWindow = await createWindow();
|
|
426
|
+
app.on("activate", () => {
|
|
427
|
+
if (!mainWindow) {
|
|
428
|
+
void createWindow().then((window) => {
|
|
429
|
+
mainWindow = window;
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
})
|
|
434
|
+
.catch((error: unknown) => {
|
|
435
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
436
|
+
app.quit();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
app.on("window-all-closed", () => {
|
|
440
|
+
if (process.platform !== "darwin") app.quit();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
app.on("will-quit", () => {
|
|
444
|
+
ownerClosing = true;
|
|
445
|
+
ownerPipe?.destroy();
|
|
446
|
+
ownerPipe = undefined;
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
async function shutdownForSignal(): Promise<void> {
|
|
450
|
+
if (signalShutdownStarted) return;
|
|
451
|
+
signalShutdownStarted = true;
|
|
452
|
+
const forcedExit = setTimeout(() => process.exit(0), 1_500);
|
|
453
|
+
forcedExit.unref();
|
|
454
|
+
await attentionWrites;
|
|
455
|
+
app.quit();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
process.once("SIGINT", () => void shutdownForSignal());
|
|
459
|
+
process.once("SIGTERM", () => void shutdownForSignal());
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { contextBridge, type IpcRendererEvent, ipcRenderer } from "electron";
|
|
2
|
+
import {
|
|
3
|
+
DESKTOP_CURSOR_CHANNEL,
|
|
4
|
+
type DesktopCursorPosition,
|
|
5
|
+
type PiPetDesktopBridge,
|
|
6
|
+
parseDesktopCursorPosition,
|
|
7
|
+
} from "./bridge.ts";
|
|
8
|
+
|
|
9
|
+
const bridge: PiPetDesktopBridge = Object.freeze({
|
|
10
|
+
onCursorPosition(listener: (position: DesktopCursorPosition | null) => void) {
|
|
11
|
+
const receive = (_event: IpcRendererEvent, value: unknown): void => {
|
|
12
|
+
listener(parseDesktopCursorPosition(value));
|
|
13
|
+
};
|
|
14
|
+
ipcRenderer.on(DESKTOP_CURSOR_CHANNEL, receive);
|
|
15
|
+
return () => ipcRenderer.removeListener(DESKTOP_CURSOR_CHANNEL, receive);
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
contextBridge.exposeInMainWorld("piPetDesktop", bridge);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export interface ImageSize {
|
|
4
|
+
format: "png" | "webp";
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function extendedWebpSize(buffer: Buffer, data: number, size: number): ImageSize | undefined {
|
|
10
|
+
if (size < 10) return undefined;
|
|
11
|
+
return { format: "webp", width: 1 + buffer.readUIntLE(data + 4, 3), height: 1 + buffer.readUIntLE(data + 7, 3) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function lossyWebpSize(buffer: Buffer, data: number, size: number): ImageSize | undefined {
|
|
15
|
+
if (size < 10 || buffer[data + 3] !== 0x9d || buffer[data + 4] !== 0x01 || buffer[data + 5] !== 0x2a)
|
|
16
|
+
return undefined;
|
|
17
|
+
return {
|
|
18
|
+
format: "webp",
|
|
19
|
+
width: buffer.readUInt16LE(data + 6) & 0x3fff,
|
|
20
|
+
height: buffer.readUInt16LE(data + 8) & 0x3fff,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function losslessWebpSize(buffer: Buffer, data: number, size: number): ImageSize | undefined {
|
|
25
|
+
if (size < 5 || buffer[data] !== 0x2f) return undefined;
|
|
26
|
+
const b1 = buffer[data + 1] ?? 0;
|
|
27
|
+
const b2 = buffer[data + 2] ?? 0;
|
|
28
|
+
const b3 = buffer[data + 3] ?? 0;
|
|
29
|
+
const b4 = buffer[data + 4] ?? 0;
|
|
30
|
+
return {
|
|
31
|
+
format: "webp",
|
|
32
|
+
width: 1 + ((b1 | (b2 << 8)) & 0x3fff),
|
|
33
|
+
height: 1 + (((b2 >> 6) | (b3 << 2) | (b4 << 10)) & 0x3fff),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function webpChunkSize(buffer: Buffer, type: string, data: number, size: number): ImageSize | undefined {
|
|
38
|
+
if (type === "VP8X") return extendedWebpSize(buffer, data, size);
|
|
39
|
+
if (type === "VP8 ") return lossyWebpSize(buffer, data, size);
|
|
40
|
+
if (type === "VP8L") return losslessWebpSize(buffer, data, size);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function webpSize(buffer: Buffer): ImageSize | undefined {
|
|
45
|
+
if (buffer.length < 30 || buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WEBP")
|
|
46
|
+
return undefined;
|
|
47
|
+
let offset = 12;
|
|
48
|
+
while (offset + 8 <= buffer.length) {
|
|
49
|
+
const type = buffer.toString("ascii", offset, offset + 4);
|
|
50
|
+
const size = buffer.readUInt32LE(offset + 4);
|
|
51
|
+
const data = offset + 8;
|
|
52
|
+
if (data + size > buffer.length) throw new Error("Truncated WebP chunk.");
|
|
53
|
+
const dimensions = webpChunkSize(buffer, type, data, size);
|
|
54
|
+
if (dimensions) return dimensions;
|
|
55
|
+
offset = data + size + (size % 2);
|
|
56
|
+
}
|
|
57
|
+
throw new Error("WebP dimensions were not found.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function imageSize(buffer: Buffer): ImageSize {
|
|
61
|
+
const pngSignature = "89504e470d0a1a0a";
|
|
62
|
+
if (buffer.length >= 24 && buffer.subarray(0, 8).toString("hex") === pngSignature) {
|
|
63
|
+
return { format: "png", width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
64
|
+
}
|
|
65
|
+
const webp = webpSize(buffer);
|
|
66
|
+
if (webp) return webp;
|
|
67
|
+
throw new Error("Asset must be a PNG or WebP image.");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function readImageSize(path: string): Promise<ImageSize> {
|
|
71
|
+
return imageSize(await readFile(path));
|
|
72
|
+
}
|