opencube 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/main.js ADDED
@@ -0,0 +1,1282 @@
1
+ const { app, BrowserWindow, Menu, screen } = require("electron")
2
+ const fs = require("node:fs")
3
+ const http = require("node:http")
4
+ const os = require("node:os")
5
+ const path = require("node:path")
6
+
7
+ const { DEFAULT_SESSION_COLORS, pixelPetSvg } = require("./pixel-pet-reference.cjs")
8
+
9
+ const APP_NAME = "opencode pet"
10
+ const HOST = "127.0.0.1"
11
+ const PORT = Number(process.env.OPENCODE_PET_PORT || 47832)
12
+ const DATA_DIR = path.join(os.homedir(), ".local", "share", "opencode-pet")
13
+ const STATE_FILE = path.join(DATA_DIR, "state.json")
14
+ const PET_HTML_FILE = path.join(DATA_DIR, "pet.html")
15
+ const ICON_PATH = process.env.OPENCODE_PET_ICON || path.join(__dirname, "..", "assets", "opencode-icon.png")
16
+ const MAX_EVENTS = 100
17
+ const IDLE_TTL_MS = 5 * 60 * 1000
18
+
19
+ let petWindow = null
20
+ let panelWindow = null
21
+ let server = null
22
+ let events = []
23
+ let petSignals = []
24
+ let sessionMap = new Map()
25
+ let cleanupTimer = null
26
+
27
+ function ensureDataDir() {
28
+ fs.mkdirSync(DATA_DIR, { recursive: true })
29
+ }
30
+
31
+ function writeState(extra = {}) {
32
+ ensureDataDir()
33
+ fs.writeFileSync(
34
+ STATE_FILE,
35
+ JSON.stringify(
36
+ {
37
+ pid: process.pid,
38
+ startedAt: Date.now(),
39
+ iconPath: ICON_PATH,
40
+ ...extra,
41
+ },
42
+ null,
43
+ 2,
44
+ ),
45
+ )
46
+ }
47
+
48
+ function shouldQuit(argv = process.argv) {
49
+ return argv.includes("--quit") || argv.includes("--stop") || argv.includes("stop") || argv.includes("quit")
50
+ }
51
+
52
+ function randomBetween(min, max) {
53
+ return min + Math.random() * (max - min)
54
+ }
55
+
56
+ function createBounceDynamics() {
57
+ const bounds = { left: 42, top: 44, right: 154, bottom: 156 }
58
+ const half = 8
59
+ const speed = randomBetween(74, 122)
60
+ const angle = randomBetween(-Math.PI, Math.PI)
61
+ return {
62
+ x: randomBetween(bounds.left + half, bounds.right - half),
63
+ y: randomBetween(bounds.top + half, bounds.bottom - half),
64
+ vx: Math.cos(angle) * speed,
65
+ vy: Math.sin(angle) * speed,
66
+ speed,
67
+ bounds,
68
+ }
69
+ }
70
+
71
+ function json(res, status, body) {
72
+ const text = JSON.stringify(body)
73
+ res.writeHead(status, {
74
+ "content-type": "application/json; charset=utf-8",
75
+ "access-control-allow-origin": "*",
76
+ "access-control-allow-methods": "GET,POST,OPTIONS",
77
+ "access-control-allow-headers": "content-type",
78
+ })
79
+ res.end(text)
80
+ }
81
+
82
+ function readRequestJson(req) {
83
+ return new Promise((resolve, reject) => {
84
+ let raw = ""
85
+ req.on("data", (chunk) => {
86
+ raw += chunk
87
+ if (raw.length > 1024 * 1024) {
88
+ reject(new Error("request body too large"))
89
+ req.destroy()
90
+ }
91
+ })
92
+ req.on("end", () => {
93
+ if (!raw.trim()) return resolve({})
94
+ try {
95
+ resolve(JSON.parse(raw))
96
+ } catch (error) {
97
+ reject(error)
98
+ }
99
+ })
100
+ req.on("error", reject)
101
+ })
102
+ }
103
+
104
+ function recordEvent(event) {
105
+ const item = {
106
+ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
107
+ receivedAt: Date.now(),
108
+ ...event,
109
+ }
110
+ applySessionEvent(item)
111
+ if (item.type === "hello" || item.type === "fancy_hello") {
112
+ petSignals.push({
113
+ id: item.id,
114
+ type: "hello",
115
+ mode: item.type === "fancy_hello" ? "fancy" : "single",
116
+ receivedAt: item.receivedAt,
117
+ sessionID: item.sessionID,
118
+ })
119
+ petSignals = petSignals.slice(-20)
120
+ }
121
+ events.unshift(item)
122
+ events = events.slice(0, MAX_EVENTS)
123
+ updatePanel()
124
+ updatePet()
125
+ return item
126
+ }
127
+
128
+ function applySessionEvent(event) {
129
+ if (!event || typeof event.sessionID !== "string") return
130
+ const now = event.receivedAt || Date.now()
131
+ const current = sessionMap.get(event.sessionID)
132
+
133
+ if (event.type === "session.busy") {
134
+ sessionMap.set(event.sessionID, {
135
+ sessionID: event.sessionID,
136
+ state: "busy",
137
+ busyAt: current?.state === "busy" ? current.busyAt : now,
138
+ idleAt: undefined,
139
+ lastAt: now,
140
+ orbitTouchedAt: now,
141
+ dynamics: current?.dynamics || createBounceDynamics(),
142
+ status: event.status || "busy",
143
+ })
144
+ return
145
+ }
146
+
147
+ if (event.type === "session.idle") {
148
+ sessionMap.set(event.sessionID, {
149
+ sessionID: event.sessionID,
150
+ state: "idle",
151
+ busyAt: current?.busyAt,
152
+ idleAt: now,
153
+ lastAt: now,
154
+ orbitTouchedAt: current?.orbitTouchedAt,
155
+ dynamics: current?.dynamics || createBounceDynamics(),
156
+ status: event.status || "idle",
157
+ })
158
+ }
159
+ }
160
+
161
+ function pruneIdleSessions(refresh = true) {
162
+ const now = Date.now()
163
+ let changed = false
164
+ for (const [sessionID, session] of sessionMap) {
165
+ const expiresFrom = session.idleAt || session.lastAt
166
+ if (session.state === "idle" && expiresFrom && now - expiresFrom > IDLE_TTL_MS) {
167
+ sessionMap.delete(sessionID)
168
+ changed = true
169
+ }
170
+ }
171
+ if (changed && refresh) updatePet()
172
+ }
173
+
174
+ function getPetState() {
175
+ pruneIdleSessions(false)
176
+ let busyIndex = 0
177
+ let idleIndex = 0
178
+ return {
179
+ now: Date.now(),
180
+ layout: {
181
+ width: 256,
182
+ height: 256,
183
+ bodyViewBox: { width: 256, height: 256 },
184
+ busyJuggle: { centerX: 108, baseY: 82, radiusX: 48, liftY: 58 },
185
+ idlePile: { x: 194, y: 186, stepX: 17, stepY: 15, columns: 2 },
186
+ ball: { size: 14 },
187
+ },
188
+ sessions: Array.from(sessionMap.values()).map((session, index) => ({
189
+ sessionID: session.sessionID,
190
+ state: session.state,
191
+ busyAt: session.busyAt,
192
+ idleAt: session.idleAt,
193
+ lastAt: session.lastAt,
194
+ orbitTouchedAt: session.orbitTouchedAt,
195
+ dynamics: session.dynamics,
196
+ index,
197
+ busyIndex: session.state === "busy" ? busyIndex++ : undefined,
198
+ idleIndex: session.state === "idle" ? idleIndex++ : undefined,
199
+ color: DEFAULT_SESSION_COLORS[index % DEFAULT_SESSION_COLORS.length],
200
+ })),
201
+ signals: petSignals,
202
+ }
203
+ }
204
+
205
+ function escapeHtml(value) {
206
+ return String(value)
207
+ .replaceAll("&", "&")
208
+ .replaceAll("<", "&lt;")
209
+ .replaceAll(">", "&gt;")
210
+ .replaceAll('"', "&quot;")
211
+ .replaceAll("'", "&#39;")
212
+ }
213
+
214
+ function createIconDataUrl() {
215
+ try {
216
+ const png = fs.readFileSync(ICON_PATH)
217
+ return `data:image/png;base64,${png.toString("base64")}`
218
+ } catch {
219
+ const fallback = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="20" fill="#111"/><rect x="30" y="18" width="36" height="60" fill="#f4f4ef"/><rect x="40" y="32" width="16" height="32" fill="#050505"/></svg>`
220
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(fallback)}`
221
+ }
222
+ }
223
+
224
+ function panelHtml() {
225
+ const rows = events
226
+ .map((event) => {
227
+ const time = new Date(event.receivedAt).toLocaleTimeString()
228
+ const payload = JSON.stringify(event, null, 2)
229
+ return `<div class="event"><div class="meta">${escapeHtml(time)} · ${escapeHtml(event.type || "event")}</div><pre>${escapeHtml(payload)}</pre></div>`
230
+ })
231
+ .join("")
232
+
233
+ return `<!doctype html>
234
+ <html>
235
+ <head>
236
+ <meta charset="utf-8" />
237
+ <style>
238
+ html, body { margin: 0; width: 100%; height: 100%; background: transparent; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
239
+ .panel { box-sizing: border-box; width: 100%; height: 100%; padding: 12px; border-radius: 16px; background: rgba(24, 24, 27, 0.94); color: white; box-shadow: 0 14px 42px rgba(0,0,0,.28); overflow: hidden; }
240
+ .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 13px; font-weight: 700; }
241
+ .hint { color: rgba(255,255,255,.6); font-size: 11px; font-weight: 500; }
242
+ .list { height: 292px; overflow: auto; padding-right: 4px; }
243
+ .empty { color: rgba(255,255,255,.6); font-size: 13px; padding: 18px 4px; }
244
+ .event { border: 1px solid rgba(255,255,255,.12); border-radius: 10px; padding: 8px; margin-bottom: 8px; background: rgba(255,255,255,.06); }
245
+ .meta { color: #a7f3d0; font-size: 11px; margin-bottom: 5px; }
246
+ pre { margin: 0; color: rgba(255,255,255,.88); white-space: pre-wrap; word-break: break-word; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
247
+ </style>
248
+ </head>
249
+ <body>
250
+ <div class="panel">
251
+ <div class="header"><span>opencode pet inbox</span><span class="hint">${events.length}/${MAX_EVENTS}</span></div>
252
+ <div class="list">${rows || `<div class="empty">还没有收到事件。试试 /pet 或 /pet_stop。</div>`}</div>
253
+ </div>
254
+ </body>
255
+ </html>`
256
+ }
257
+
258
+ function updatePanel() {
259
+ if (!panelWindow || panelWindow.isDestroyed()) return
260
+ panelWindow.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(panelHtml()))
261
+ }
262
+
263
+ function petHtml() {
264
+ const petBodySvg = pixelPetSvg({ sessionCount: 0, showCaption: false })
265
+ const initialStateJson = JSON.stringify(getPetState()).replaceAll("<", "\\u003c")
266
+ const colorJson = JSON.stringify(DEFAULT_SESSION_COLORS)
267
+ return `<!doctype html>
268
+ <html>
269
+ <head>
270
+ <meta charset="utf-8" />
271
+ <style>
272
+ html, body {
273
+ margin: 0;
274
+ width: 100%;
275
+ height: 100%;
276
+ background: transparent;
277
+ overflow: hidden;
278
+ }
279
+ .stage {
280
+ box-sizing: border-box;
281
+ width: 100%;
282
+ height: 100%;
283
+ position: relative;
284
+ background: transparent;
285
+ -webkit-app-region: drag;
286
+ user-select: none;
287
+ image-rendering: pixelated;
288
+ }
289
+ .pet-art {
290
+ position: absolute;
291
+ inset: 0;
292
+ width: 256px;
293
+ height: 256px;
294
+ transform-origin: 106px 156px;
295
+ transition: transform 120ms steps(2, end), filter 120ms steps(2, end);
296
+ }
297
+ .pet-art svg {
298
+ display: block;
299
+ width: 256px;
300
+ height: 256px;
301
+ shape-rendering: crispEdges;
302
+ }
303
+ .stage.has-busy .pet-art {
304
+ animation: pet-work-bob 620ms steps(2, end) infinite;
305
+ filter: drop-shadow(0 4px 0 rgba(0,0,0,.14));
306
+ }
307
+ #ball-layer {
308
+ position: absolute;
309
+ inset: 0;
310
+ pointer-events: none;
311
+ }
312
+ .session-ball-runtime {
313
+ position: absolute;
314
+ left: 0;
315
+ top: 0;
316
+ width: 14px;
317
+ height: 14px;
318
+ margin-left: -7px;
319
+ margin-top: -7px;
320
+ box-sizing: border-box;
321
+ background: var(--ball-color, #ff5d73);
322
+ box-shadow: 0 3px 0 rgba(0,0,0,.32);
323
+ image-rendering: pixelated;
324
+ will-change: transform, opacity;
325
+ }
326
+ .session-ball-runtime::before {
327
+ content: "";
328
+ position: absolute;
329
+ left: 4px;
330
+ top: 2px;
331
+ width: 4px;
332
+ height: 4px;
333
+ background: rgba(255,255,255,.72);
334
+ }
335
+ .session-ball-runtime.busy {
336
+ z-index: 3;
337
+ }
338
+ .session-ball-runtime.idle {
339
+ z-index: 1;
340
+ box-shadow: 0 2px 0 rgba(0,0,0,.2);
341
+ }
342
+ @keyframes pet-work-bob {
343
+ 0%, 100% { transform: translateY(0); }
344
+ 50% { transform: translateY(-4px); }
345
+ }
346
+ </style>
347
+ </head>
348
+ <body>
349
+ <div class="stage" title="opencode pet:拖拽移动,右键打开菜单">
350
+ <div class="pet-art" aria-label="pixel opencode pet">${petBodySvg}</div>
351
+ <div id="ball-layer"></div>
352
+ </div>
353
+ <script>
354
+ window.__PET_STATE = ${initialStateJson}
355
+ const SESSION_COLORS = ${colorJson}
356
+ const stage = document.querySelector(".stage")
357
+ const layer = document.getElementById("ball-layer")
358
+ const physics = {
359
+ idleTtl: ${IDLE_TTL_MS},
360
+ busy: { leftHandX: 30, rightHandX: 184, handY: 112, peakY: 34, stepPx: 2, periodMs: 1180 },
361
+ idlePile: { x: 194, y: 188, stepX: 17, stepY: 15, columns: 2 },
362
+ }
363
+ const balls = new Map()
364
+ let lastFrame = performance.now()
365
+ let snapshot = window.__PET_STATE || { sessions: [] }
366
+ let latestDebug = { now: Date.now(), busy: 0, idle: 0, balls: [] }
367
+
368
+ function ease(current, target, factor) {
369
+ return current + (target - current) * factor
370
+ }
371
+
372
+ function clamp(value, min, max) {
373
+ return Math.max(min, Math.min(max, value))
374
+ }
375
+
376
+ function snapPixel(value) {
377
+ return Math.round(value / physics.busy.stepPx) * physics.busy.stepPx
378
+ }
379
+
380
+ function colorFor(session, index) {
381
+ return session.color || SESSION_COLORS[index % SESSION_COLORS.length]
382
+ }
383
+
384
+ function ensureBall(session, index) {
385
+ let ball = balls.get(session.sessionID)
386
+ if (ball) {
387
+ ball.el.style.setProperty("--ball-color", colorFor(session, index))
388
+ return ball
389
+ }
390
+ const el = document.createElement("div")
391
+ el.className = "session-ball-runtime"
392
+ el.title = session.sessionID
393
+ el.style.setProperty("--ball-color", colorFor(session, index))
394
+ layer.appendChild(el)
395
+ ball = {
396
+ id: session.sessionID,
397
+ el,
398
+ x: 108,
399
+ y: 86,
400
+ alpha: 0,
401
+ scale: 0.86,
402
+ leaving: false,
403
+ state: session.state,
404
+ }
405
+ balls.set(session.sessionID, ball)
406
+ return ball
407
+ }
408
+
409
+ function setSnapshot(next) {
410
+ snapshot = next || { sessions: [] }
411
+ const live = new Set(snapshot.sessions.map((session) => session.sessionID))
412
+ for (const ball of balls.values()) {
413
+ if (!live.has(ball.id)) ball.leaving = true
414
+ }
415
+ }
416
+
417
+ window.__setPetState = setSnapshot
418
+ window.__getPetDebug = () => latestDebug
419
+
420
+ function busyTarget(busyIndex, busyCount, now) {
421
+ const count = Math.max(1, busyCount)
422
+ const cycle = now / physics.busy.periodMs + busyIndex / count
423
+ const halfCycle = Math.floor(cycle)
424
+ const t = cycle - halfCycle
425
+ const leftToRight = halfCycle % 2 === 0
426
+ const fromX = leftToRight ? physics.busy.leftHandX : physics.busy.rightHandX
427
+ const toX = leftToRight ? physics.busy.rightHandX : physics.busy.leftHandX
428
+ const handY = physics.busy.handY
429
+ const peakLift = handY - physics.busy.peakY
430
+ const arc = 4 * t * (1 - t)
431
+ const laneOffset = (busyIndex - (count - 1) / 2) * Math.min(8, 2 + count * 2)
432
+ const catchDip = Math.abs(t - 0.5) > 0.43 ? 4 : 0
433
+ return {
434
+ x: snapPixel(fromX + (toX - fromX) * t + laneOffset),
435
+ y: snapPixel(handY - arc * peakLift + catchDip),
436
+ }
437
+ }
438
+
439
+ function idleTarget(idleIndex) {
440
+ const col = idleIndex % physics.idlePile.columns
441
+ const row = Math.floor(idleIndex / physics.idlePile.columns)
442
+ return {
443
+ x: physics.idlePile.x + col * physics.idlePile.stepX,
444
+ y: physics.idlePile.y - row * physics.idlePile.stepY + (col ? 5 : 0),
445
+ }
446
+ }
447
+
448
+ function tick(now) {
449
+ const dt = Math.min(48, now - lastFrame) / 1000
450
+ lastFrame = now
451
+ const sessions = snapshot.sessions || []
452
+ const busySessions = sessions.filter((session) => session.state === "busy")
453
+ const idleSessions = sessions.filter((session) => session.state === "idle")
454
+ const order = new Map()
455
+ busySessions.forEach((session, busyIndex) => order.set(session.sessionID, { kind: "busy", busyIndex, index: session.index ?? busyIndex, session }))
456
+ idleSessions.forEach((session, idleIndex) => order.set(session.sessionID, { kind: "idle", idleIndex, index: session.index ?? idleIndex, session }))
457
+
458
+ stage.classList.toggle("has-busy", busySessions.length > 0)
459
+ stage.classList.toggle("has-sessions", sessions.length > 0)
460
+
461
+ for (const session of sessions) ensureBall(session, session.index || 0)
462
+ const debugBalls = []
463
+
464
+ for (const [id, ball] of balls) {
465
+ const info = order.get(id)
466
+ let targetX = ball.x
467
+ let targetY = ball.y
468
+ let targetScale = 0.96
469
+ let targetAlpha = 0.9
470
+ let className = "session-ball-runtime"
471
+
472
+ if (ball.leaving || !info) {
473
+ targetScale = 0.2
474
+ targetAlpha = 0
475
+ } else if (info.kind === "busy") {
476
+ const target = busyTarget(info.busyIndex, busySessions.length, now)
477
+ targetX = target.x
478
+ targetY = target.y
479
+ targetScale = 1
480
+ targetAlpha = 1
481
+ className += " busy"
482
+ } else {
483
+ const target = idleTarget(info.idleIndex)
484
+ targetX = target.x
485
+ targetY = target.y
486
+ const expiresFrom = info.session?.idleAt || info.session?.lastAt || Date.now()
487
+ const remaining = expiresFrom + physics.idleTtl - Date.now()
488
+ const fade = clamp(remaining / physics.idleTtl, 0, 1)
489
+ targetAlpha = 0.88 * fade
490
+ targetScale = 0.92
491
+ className += " idle"
492
+ }
493
+
494
+ const factor = 1 - Math.pow(0.06, dt)
495
+ ball.x = ease(ball.x, targetX, factor)
496
+ ball.y = ease(ball.y, targetY, factor)
497
+ ball.scale = ease(ball.scale, targetScale, factor)
498
+ ball.alpha = ease(ball.alpha, targetAlpha, factor)
499
+ ball.el.className = className
500
+ ball.el.style.transform = "translate3d(" + snapPixel(ball.x) + "px, " + snapPixel(ball.y) + "px, 0) scale(" + ball.scale + ")"
501
+ ball.el.style.opacity = String(ball.alpha)
502
+ debugBalls.push({
503
+ id,
504
+ state: info?.kind || "leaving",
505
+ x: snapPixel(ball.x),
506
+ y: snapPixel(ball.y),
507
+ alpha: Number(ball.alpha.toFixed(3)),
508
+ scale: Number(ball.scale.toFixed(3)),
509
+ className,
510
+ })
511
+
512
+ if (ball.leaving && ball.alpha < 0.03) {
513
+ ball.el.remove()
514
+ balls.delete(id)
515
+ }
516
+ }
517
+
518
+ latestDebug = { now: Date.now(), busy: busySessions.length, idle: idleSessions.length, balls: debugBalls }
519
+
520
+ requestAnimationFrame(tick)
521
+ }
522
+
523
+ requestAnimationFrame(tick)
524
+ </script>
525
+ </body>
526
+ </html>`
527
+ }
528
+
529
+ function petHtml3D() {
530
+ const initialStateJson = JSON.stringify(getPetState()).replaceAll("<", "\\u003c")
531
+ const iconUrl = createIconDataUrl()
532
+ const threeCjsPath = JSON.stringify(path.join(path.dirname(require.resolve("three")), "three.cjs"))
533
+ return `<!doctype html>
534
+ <html>
535
+ <head>
536
+ <meta charset="utf-8" />
537
+ <style>
538
+ html, body {
539
+ margin: 0;
540
+ width: 100%;
541
+ height: 100%;
542
+ background: transparent;
543
+ overflow: hidden;
544
+ }
545
+ .stage {
546
+ box-sizing: border-box;
547
+ width: 100%;
548
+ height: 100%;
549
+ position: relative;
550
+ background: transparent;
551
+ -webkit-app-region: drag;
552
+ user-select: none;
553
+ }
554
+ #scene {
555
+ position: absolute;
556
+ inset: 0;
557
+ width: 100%;
558
+ height: 100%;
559
+ pointer-events: none;
560
+ }
561
+ </style>
562
+ </head>
563
+ <body>
564
+ <div class="stage" title="opencode pet:拖拽移动,右键打开菜单">
565
+ <canvas id="scene" aria-label="3D opencode pet"></canvas>
566
+ </div>
567
+ <script>
568
+ window.__PET_BOOT_ERROR = null
569
+ window.addEventListener("error", (event) => {
570
+ window.__PET_BOOT_ERROR = { ok: false, error: String(event.error?.stack || event.message || event), source: event.filename, line: event.lineno, column: event.colno }
571
+ })
572
+ window.addEventListener("unhandledrejection", (event) => {
573
+ window.__PET_BOOT_ERROR = { ok: false, error: String(event.reason?.stack || event.reason || event) }
574
+ })
575
+ </script>
576
+ <script>
577
+ const THREE = require(${threeCjsPath})
578
+
579
+ window.__PET_STATE = ${initialStateJson}
580
+ const stage = document.querySelector(".stage")
581
+ const faceOrder = ["front", "right", "top", "back", "left", "bottom"]
582
+ const sessionFaceMap = new Map()
583
+ const sessionColorMap = new Map()
584
+ const handledSignalIDs = new Set()
585
+ const faceFlashes = new Map()
586
+ const colorReleaseSpeed = 90
587
+ const faceMeshes = new Map()
588
+ const glowMeshes = new Map()
589
+ let snapshot = window.__PET_STATE || { sessions: [] }
590
+ let lastFrame = performance.now()
591
+ let rotation = { x: -14, y: -28, z: 0 }
592
+ let angularVelocity = { x: 0, y: 0, z: 0 }
593
+ let torque = { x: 0, y: 0, z: 0 }
594
+ let nextTorqueAt = 0
595
+ let wasBusy = false
596
+ let latestDebug = { now: Date.now(), busy: 0, rotation, angularVelocity, torque, speed: 0, faceRotations: {} }
597
+
598
+ const canvas = document.getElementById("scene")
599
+ const scene = new THREE.Scene()
600
+ const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 20)
601
+ camera.position.set(0, 0.04, 3.25)
602
+ const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true })
603
+ renderer.setClearColor(0x000000, 0)
604
+ renderer.setPixelRatio(Math.min((window.devicePixelRatio || 1) * 1.5, 3))
605
+ renderer.outputColorSpace = THREE.SRGBColorSpace
606
+
607
+ const cubeGroup = new THREE.Group()
608
+ scene.add(cubeGroup)
609
+ const iconTexture = new THREE.TextureLoader().load("${iconUrl}")
610
+ iconTexture.colorSpace = THREE.SRGBColorSpace
611
+ iconTexture.generateMipmaps = false
612
+ iconTexture.minFilter = THREE.LinearFilter
613
+ iconTexture.magFilter = THREE.LinearFilter
614
+ iconTexture.anisotropy = 8
615
+ const iconMaterial = new THREE.MeshBasicMaterial({
616
+ map: iconTexture,
617
+ transparent: true,
618
+ alphaTest: 0.02,
619
+ side: THREE.DoubleSide,
620
+ })
621
+ const glowTexture = createGlowTexture()
622
+ const faceGeometry = new THREE.PlaneGeometry(0.60, 0.60)
623
+ const glowGeometry = new THREE.PlaneGeometry(1.18, 1.18)
624
+ const rad = THREE.MathUtils.degToRad
625
+
626
+ function createGlowTexture() {
627
+ const canvas = document.createElement("canvas")
628
+ canvas.width = 256
629
+ canvas.height = 256
630
+ const ctx = canvas.getContext("2d")
631
+ const gradient = ctx.createRadialGradient(128, 128, 4, 128, 128, 124)
632
+ gradient.addColorStop(0, "rgba(255,255,255,1)")
633
+ gradient.addColorStop(0.18, "rgba(255,255,255,1)")
634
+ gradient.addColorStop(0.46, "rgba(255,255,255,.62)")
635
+ gradient.addColorStop(0.80, "rgba(255,255,255,.22)")
636
+ gradient.addColorStop(1, "rgba(255,255,255,0)")
637
+ ctx.fillStyle = gradient
638
+ ctx.fillRect(0, 0, 256, 256)
639
+ const texture = new THREE.CanvasTexture(canvas)
640
+ texture.colorSpace = THREE.SRGBColorSpace
641
+ return texture
642
+ }
643
+
644
+ function resize() {
645
+ const width = Math.max(1, window.innerWidth)
646
+ const height = Math.max(1, window.innerHeight)
647
+ renderer.setSize(width, height, false)
648
+ camera.aspect = width / height
649
+ camera.updateProjectionMatrix()
650
+ }
651
+ window.addEventListener("resize", resize)
652
+ resize()
653
+
654
+ function randomBetween(min, max) {
655
+ return min + Math.random() * (max - min)
656
+ }
657
+
658
+ function randomSign() {
659
+ return Math.random() > 0.5 ? 1 : -1
660
+ }
661
+
662
+ function randomTorque() {
663
+ return {
664
+ x: randomBetween(45, 130) * randomSign(),
665
+ y: randomBetween(90, 260) * randomSign(),
666
+ z: randomBetween(18, 80) * randomSign(),
667
+ }
668
+ }
669
+
670
+ function randomChoice(items) {
671
+ return items[Math.floor(Math.random() * items.length)]
672
+ }
673
+
674
+ function hslToRgb(h, s, l) {
675
+ const c = (1 - Math.abs(2 * l - 1)) * s
676
+ const hp = h / 60
677
+ const x = c * (1 - Math.abs((hp % 2) - 1))
678
+ let r = 0
679
+ let g = 0
680
+ let b = 0
681
+ if (hp < 1) [r, g, b] = [c, x, 0]
682
+ else if (hp < 2) [r, g, b] = [x, c, 0]
683
+ else if (hp < 3) [r, g, b] = [0, c, x]
684
+ else if (hp < 4) [r, g, b] = [0, x, c]
685
+ else if (hp < 5) [r, g, b] = [x, 0, c]
686
+ else [r, g, b] = [c, 0, x]
687
+ const m = l - c / 2
688
+ return {
689
+ r: Math.round((r + m) * 255),
690
+ g: Math.round((g + m) * 255),
691
+ b: Math.round((b + m) * 255),
692
+ }
693
+ }
694
+
695
+ function randomSessionGlowColor() {
696
+ const hue = randomBetween(0, 360)
697
+ const saturation = randomBetween(0.68, 0.94)
698
+ const lightness = randomBetween(0.50, 0.66)
699
+ const rgb = hslToRgb(hue, saturation, lightness)
700
+ return {
701
+ ...rgb,
702
+ name: "random-" + Math.round(hue),
703
+ }
704
+ }
705
+
706
+ function makeFaceRotations() {
707
+ const quarterTurns = [0, 90, 180, 270]
708
+ return {
709
+ front: Math.random() < 0.7 ? 0 : randomChoice(quarterTurns),
710
+ back: randomChoice(quarterTurns),
711
+ right: randomChoice(quarterTurns),
712
+ left: randomChoice(quarterTurns),
713
+ top: randomChoice(quarterTurns),
714
+ bottom: randomChoice(quarterTurns),
715
+ }
716
+ }
717
+
718
+ const faceRotations = makeFaceRotations()
719
+ createFace("front", [0, 0, 0.30], [0, 0, rad(faceRotations.front || 0)], [0, 0, 0.314])
720
+ createFace("back", [0, 0, -0.30], [0, Math.PI, rad(faceRotations.back || 0)], [0, 0, -0.314])
721
+ createFace("right", [0.30, 0, 0], [0, Math.PI / 2, rad(faceRotations.right || 0)], [0.314, 0, 0])
722
+ createFace("left", [-0.30, 0, 0], [0, -Math.PI / 2, rad(faceRotations.left || 0)], [-0.314, 0, 0])
723
+ createFace("top", [0, 0.30, 0], [-Math.PI / 2, 0, rad(faceRotations.top || 0)], [0, 0.314, 0])
724
+ createFace("bottom", [0, -0.30, 0], [Math.PI / 2, 0, rad(faceRotations.bottom || 0)], [0, -0.314, 0])
725
+
726
+ function createFace(name, position, rotation, glowPosition) {
727
+ const face = new THREE.Mesh(faceGeometry, iconMaterial.clone())
728
+ face.position.set(...position)
729
+ face.rotation.set(...rotation)
730
+ cubeGroup.add(face)
731
+ faceMeshes.set(name, face)
732
+
733
+ const glow = new THREE.Mesh(
734
+ glowGeometry,
735
+ new THREE.MeshBasicMaterial({
736
+ map: glowTexture,
737
+ color: 0x1fdccd,
738
+ transparent: true,
739
+ opacity: 0,
740
+ blending: THREE.AdditiveBlending,
741
+ depthWrite: false,
742
+ side: THREE.DoubleSide,
743
+ }),
744
+ )
745
+ glow.position.set(...glowPosition)
746
+ glow.rotation.set(...rotation)
747
+ cubeGroup.add(glow)
748
+ glowMeshes.set(name, glow)
749
+ }
750
+
751
+ function magnitude(vector) {
752
+ return Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z)
753
+ }
754
+
755
+ function clampMagnitude(vector, max) {
756
+ const length = magnitude(vector)
757
+ if (length <= max || length === 0) return vector
758
+ const scale = max / length
759
+ vector.x *= scale
760
+ vector.y *= scale
761
+ vector.z *= scale
762
+ return vector
763
+ }
764
+
765
+ function setNextTorque(now) {
766
+ torque = randomTorque()
767
+ nextTorqueAt = now + randomBetween(4800, 5200)
768
+ }
769
+
770
+ function setSnapshot(next) {
771
+ snapshot = next || { sessions: [] }
772
+ }
773
+
774
+ function syncBusyFaces(sessions, speed) {
775
+ const busySessions = sessions
776
+ .filter((session) => session.state === "busy" && typeof session.sessionID === "string")
777
+ .sort((a, b) => (b.busyAt || b.lastAt || 0) - (a.busyAt || a.lastAt || 0))
778
+ const busyIDs = busySessions.map((session) => session.sessionID)
779
+ const busySet = new Set(busyIDs)
780
+
781
+ if (busyIDs.length === 0) {
782
+ if (speed < colorReleaseSpeed) {
783
+ sessionFaceMap.clear()
784
+ sessionColorMap.clear()
785
+ }
786
+ } else {
787
+ for (const sessionID of Array.from(sessionColorMap.keys())) {
788
+ if (!busySet.has(sessionID)) sessionColorMap.delete(sessionID)
789
+ }
790
+ }
791
+
792
+ for (const sessionID of busyIDs) {
793
+ if (!sessionColorMap.has(sessionID)) {
794
+ sessionColorMap.set(sessionID, randomSessionGlowColor())
795
+ }
796
+ }
797
+
798
+ if (busyIDs.length > 0) {
799
+ sessionFaceMap.clear()
800
+ for (const [index, session] of busySessions.slice(0, faceOrder.length).entries()) {
801
+ sessionFaceMap.set(session.sessionID, faceOrder[index])
802
+ }
803
+ }
804
+
805
+ const faceColors = new Map()
806
+ for (const [sessionID, faceName] of sessionFaceMap) {
807
+ faceColors.set(faceName, sessionColorMap.get(sessionID) || randomSessionGlowColor())
808
+ }
809
+ for (const faceName of faceOrder) {
810
+ const color = faceColors.get(faceName)
811
+ const face = faceMeshes.get(faceName)
812
+ const glow = glowMeshes.get(faceName)
813
+ if (color) {
814
+ const threeColor = new THREE.Color(color.r / 255, color.g / 255, color.b / 255)
815
+ face.material.color.copy(threeColor).lerp(new THREE.Color(0xffffff), 0.58)
816
+ glow.material.color.setRGB(color.r / 255, color.g / 255, color.b / 255)
817
+ glow.material.opacity = 0.98
818
+ glow.scale.setScalar(1.24)
819
+ } else {
820
+ face.material.color.setRGB(1, 1, 1)
821
+ glow.material.opacity = 0
822
+ }
823
+ }
824
+ return Object.fromEntries(Array.from(sessionFaceMap.entries()).map(([sessionID, faceName]) => {
825
+ const color = sessionColorMap.get(sessionID) || randomSessionGlowColor()
826
+ return [sessionID, { face: faceName, color: color.name, rgb: [color.r, color.g, color.b] }]
827
+ }))
828
+ }
829
+
830
+ function chooseUnlitFace() {
831
+ const litFaces = new Set(sessionFaceMap.values())
832
+ const candidates = faceOrder.filter((faceName) => !litFaces.has(faceName) && !faceFlashes.has(faceName))
833
+ if (candidates.length === 0) return undefined
834
+ return randomChoice(candidates)
835
+ }
836
+
837
+ function processSignals(signals, now) {
838
+ for (const signal of signals || []) {
839
+ if (!signal?.id || handledSignalIDs.has(signal.id)) continue
840
+ handledSignalIDs.add(signal.id)
841
+ if (signal.type !== "hello") continue
842
+
843
+ if (signal.mode === "fancy") {
844
+ const litFaces = new Set(sessionFaceMap.values())
845
+ const candidates = faceOrder.filter((faceName) => !litFaces.has(faceName) && !faceFlashes.has(faceName))
846
+ for (const faceName of candidates) {
847
+ const burstCount = Math.floor(randomBetween(8, 15))
848
+ let cursor = randomBetween(0, 140)
849
+ const bursts = []
850
+ for (let index = 0; index < burstCount; index++) {
851
+ const duration = randomBetween(150, 300)
852
+ bursts.push({
853
+ at: cursor,
854
+ duration,
855
+ color: randomSessionGlowColor(),
856
+ peak: randomBetween(0.82, 1.24),
857
+ })
858
+ cursor += duration + randomBetween(35, 150)
859
+ }
860
+ faceFlashes.set(faceName, {
861
+ signalID: signal.id,
862
+ mode: "fancy",
863
+ startedAt: now,
864
+ duration: cursor + 260,
865
+ bursts,
866
+ })
867
+ }
868
+ continue
869
+ }
870
+
871
+ const faceName = chooseUnlitFace()
872
+ if (!faceName) continue
873
+ faceFlashes.set(faceName, {
874
+ signalID: signal.id,
875
+ mode: "single",
876
+ startedAt: now,
877
+ duration: 1320,
878
+ bursts: [{ at: 0, duration: 1320, color: randomSessionGlowColor(), peak: 1 }],
879
+ })
880
+ }
881
+
882
+ // Keep the handled set bounded; only the latest signals are sent by the host.
883
+ if (handledSignalIDs.size > 80) {
884
+ const liveIDs = new Set((signals || []).map((signal) => signal?.id).filter(Boolean))
885
+ for (const id of Array.from(handledSignalIDs)) {
886
+ if (!liveIDs.has(id)) handledSignalIDs.delete(id)
887
+ }
888
+ }
889
+ }
890
+
891
+ function applyFlashFaces(now) {
892
+ const litFaces = new Set(sessionFaceMap.values())
893
+ const active = {}
894
+ for (const [faceName, flash] of Array.from(faceFlashes.entries())) {
895
+ const elapsed = now - flash.startedAt
896
+ if (elapsed >= flash.duration || litFaces.has(faceName)) {
897
+ faceFlashes.delete(faceName)
898
+ continue
899
+ }
900
+
901
+ const progress = elapsed / flash.duration
902
+ let strength = 0
903
+ let color = undefined
904
+ for (const burst of flash.bursts || []) {
905
+ const burstElapsed = elapsed - burst.at
906
+ if (burstElapsed < 0 || burstElapsed > burst.duration) continue
907
+ const burstProgress = burstElapsed / burst.duration
908
+ const pulses = flash.mode === "single"
909
+ ? Math.sin(progress * Math.PI * 6)
910
+ : Math.sin(burstProgress * Math.PI)
911
+ const burstStrength = Math.max(0, pulses) * (burst.peak || 1) * (1 - progress * 0.08)
912
+ if (burstStrength > strength) {
913
+ strength = burstStrength
914
+ color = burst.color
915
+ }
916
+ }
917
+ if (strength <= 0.01) {
918
+ active[faceName] = { strength: 0, signalID: flash.signalID }
919
+ continue
920
+ }
921
+
922
+ const face = faceMeshes.get(faceName)
923
+ const glow = glowMeshes.get(faceName)
924
+ color ??= randomSessionGlowColor()
925
+ const threeColor = new THREE.Color(color.r / 255, color.g / 255, color.b / 255)
926
+ face.material.color.copy(threeColor).lerp(new THREE.Color(0xffffff), 0.70)
927
+ glow.material.color.setRGB(color.r / 255, color.g / 255, color.b / 255)
928
+ glow.material.opacity = 0.18 + strength * 0.82
929
+ glow.scale.setScalar(1.00 + strength * 0.32)
930
+ active[faceName] = { strength, signalID: flash.signalID }
931
+ }
932
+ return active
933
+ }
934
+
935
+ window.__setPetState = setSnapshot
936
+ window.__getPetDebug = () => latestDebug
937
+
938
+ function renderCube() {
939
+ cubeGroup.rotation.x = rad(rotation.x)
940
+ cubeGroup.rotation.y = rad(rotation.y)
941
+ cubeGroup.rotation.z = rad(rotation.z)
942
+ renderer.render(scene, camera)
943
+ }
944
+
945
+ function tick(now) {
946
+ const dt = Math.min(64, now - lastFrame) / 1000
947
+ lastFrame = now
948
+ const sessions = snapshot.sessions || []
949
+ const busyCount = sessions.filter((session) => session.state === "busy").length
950
+ const isBusy = busyCount > 0
951
+
952
+ if (isBusy && !wasBusy) setNextTorque(now)
953
+ if (isBusy && now >= nextTorqueAt) setNextTorque(now)
954
+ if (!isBusy) torque = { x: 0, y: 0, z: 0 }
955
+ wasBusy = isBusy
956
+ stage.classList.toggle("has-busy", isBusy)
957
+ stage.classList.toggle("has-sessions", sessions.length > 0)
958
+
959
+ const inertia = 1.18
960
+ const friction = isBusy ? 0.58 : 2.85
961
+ angularVelocity.x += (torque.x / inertia) * dt
962
+ angularVelocity.y += (torque.y / inertia) * dt
963
+ angularVelocity.z += (torque.z / inertia) * dt
964
+ const damping = Math.exp(-friction * dt)
965
+ angularVelocity.x *= damping
966
+ angularVelocity.y *= damping
967
+ angularVelocity.z *= damping
968
+ clampMagnitude(angularVelocity, 1400)
969
+
970
+ rotation.x += angularVelocity.x * dt
971
+ rotation.y += angularVelocity.y * dt
972
+ rotation.z += angularVelocity.z * dt
973
+
974
+ const speed = magnitude(angularVelocity)
975
+ const speedRatio = Math.min(1, speed / 1400)
976
+ const glow = Math.pow(speedRatio, 2.3)
977
+ const glowR = Math.round(92 + (0 - 92) * glow)
978
+ const glowG = Math.round(255 + (190 - 255) * glow)
979
+ const glowB = Math.round(232 + (210 - 232) * glow)
980
+ const busyFaces = syncBusyFaces(sessions, speed)
981
+ processSignals(snapshot.signals || [], now)
982
+ const helloFlashes = applyFlashFaces(now)
983
+ renderCube()
984
+ latestDebug = {
985
+ now: Date.now(),
986
+ busy: busyCount,
987
+ rotation: { ...rotation },
988
+ angularVelocity: { ...angularVelocity },
989
+ torque: { ...torque },
990
+ speed,
991
+ speedRatio,
992
+ nextTorqueAt,
993
+ glow,
994
+ colorReleaseSpeed,
995
+ glowColor: { r: glowR, g: glowG, b: glowB },
996
+ faceRotations,
997
+ busyFaces,
998
+ helloFlashes,
999
+ }
1000
+ requestAnimationFrame(tick)
1001
+ }
1002
+
1003
+ renderCube()
1004
+ requestAnimationFrame(tick)
1005
+ </script>
1006
+ </body>
1007
+ </html>`
1008
+ }
1009
+
1010
+ function writePetHtmlFile() {
1011
+ ensureDataDir()
1012
+ fs.writeFileSync(PET_HTML_FILE, petHtml3D(), "utf8")
1013
+ return PET_HTML_FILE
1014
+ }
1015
+
1016
+ function updatePet() {
1017
+ if (!petWindow || petWindow.isDestroyed()) return
1018
+ const stateJson = JSON.stringify(getPetState()).replaceAll("<", "\\u003c")
1019
+ petWindow.webContents.executeJavaScript(`window.__setPetState?.(${stateJson})`).catch(() => {})
1020
+ }
1021
+
1022
+ function restorePosition(win) {
1023
+ try {
1024
+ const raw = fs.readFileSync(STATE_FILE, "utf8")
1025
+ const state = JSON.parse(raw)
1026
+ if (typeof state.x === "number" && typeof state.y === "number") {
1027
+ win.setPosition(state.x, state.y, false)
1028
+ return
1029
+ }
1030
+ } catch {
1031
+ // no previous position
1032
+ }
1033
+
1034
+ const display = screen.getPrimaryDisplay().workArea
1035
+ const bounds = win.getBounds()
1036
+ const x = display.x + 24
1037
+ const y = display.y + Math.round(display.height * 0.62 - bounds.height / 2)
1038
+ win.setPosition(x, Math.max(display.y + 8, Math.min(y, display.y + display.height - bounds.height - 8)), false)
1039
+ }
1040
+
1041
+ function createPetWindow() {
1042
+ if (petWindow && !petWindow.isDestroyed()) return petWindow
1043
+ petWindow = new BrowserWindow({
1044
+ width: 120,
1045
+ height: 120,
1046
+ show: false,
1047
+ frame: false,
1048
+ resizable: false,
1049
+ transparent: true,
1050
+ alwaysOnTop: true,
1051
+ skipTaskbar: true,
1052
+ movable: true,
1053
+ hasShadow: false,
1054
+ title: APP_NAME,
1055
+ webPreferences: {
1056
+ contextIsolation: false,
1057
+ nodeIntegration: true,
1058
+ },
1059
+ })
1060
+ petWindow.setAlwaysOnTop(true, "floating")
1061
+ petWindow.loadFile(writePetHtmlFile())
1062
+ petWindow.webContents.on("context-menu", () => buildMenu().popup({ window: petWindow }))
1063
+ petWindow.on("moved", () => {
1064
+ const [x, y] = petWindow.getPosition()
1065
+ writeState({ visible: true, x, y })
1066
+ positionPanel()
1067
+ })
1068
+ petWindow.on("closed", () => {
1069
+ petWindow = null
1070
+ })
1071
+ restorePosition(petWindow)
1072
+ return petWindow
1073
+ }
1074
+
1075
+ function createPanelWindow() {
1076
+ if (panelWindow && !panelWindow.isDestroyed()) return panelWindow
1077
+
1078
+ panelWindow = new BrowserWindow({
1079
+ width: 360,
1080
+ height: 360,
1081
+ show: false,
1082
+ frame: false,
1083
+ resizable: false,
1084
+ transparent: true,
1085
+ alwaysOnTop: true,
1086
+ skipTaskbar: true,
1087
+ hasShadow: false,
1088
+ title: `${APP_NAME} inbox`,
1089
+ webPreferences: {
1090
+ contextIsolation: true,
1091
+ nodeIntegration: false,
1092
+ },
1093
+ })
1094
+ panelWindow.setAlwaysOnTop(true, "floating")
1095
+ panelWindow.on("closed", () => {
1096
+ panelWindow = null
1097
+ })
1098
+ updatePanel()
1099
+ return panelWindow
1100
+ }
1101
+
1102
+ function positionPanel() {
1103
+ if (!petWindow || petWindow.isDestroyed() || !panelWindow || panelWindow.isDestroyed()) return
1104
+ const [x, y] = petWindow.getPosition()
1105
+ const petBounds = petWindow.getBounds()
1106
+ const panelBounds = panelWindow.getBounds()
1107
+ const display = screen.getDisplayNearestPoint({ x, y }).workArea
1108
+ let nextX = x - panelBounds.width - 10
1109
+ let nextY = y
1110
+ if (nextX < display.x) nextX = x + petBounds.width + 10
1111
+ if (nextX + panelBounds.width > display.x + display.width) nextX = display.x + display.width - panelBounds.width - 8
1112
+ if (nextY + panelBounds.height > display.y + display.height) nextY = display.y + display.height - panelBounds.height - 8
1113
+ if (nextY < display.y) nextY = display.y + 8
1114
+ panelWindow.setPosition(Math.round(nextX), Math.round(nextY), false)
1115
+ }
1116
+
1117
+ function showPanel() {
1118
+ showPet()
1119
+ const win = createPanelWindow()
1120
+ updatePanel()
1121
+ positionPanel()
1122
+ win.show()
1123
+ win.moveTop()
1124
+ }
1125
+
1126
+ function hidePanel() {
1127
+ if (panelWindow && !panelWindow.isDestroyed()) panelWindow.hide()
1128
+ }
1129
+
1130
+ function togglePanel() {
1131
+ const win = createPanelWindow()
1132
+ if (win.isVisible()) hidePanel()
1133
+ else showPanel()
1134
+ }
1135
+
1136
+ function showPet() {
1137
+ const win = createPetWindow()
1138
+ win.show()
1139
+ win.moveTop()
1140
+ writeState({ visible: true })
1141
+ }
1142
+
1143
+ function hidePet() {
1144
+ if (petWindow && !petWindow.isDestroyed()) petWindow.hide()
1145
+ writeState({ visible: false })
1146
+ }
1147
+
1148
+ function buildMenu() {
1149
+ return Menu.buildFromTemplate([
1150
+ { label: "Show Pet", click: showPet },
1151
+ { label: "Show Inbox", click: showPanel },
1152
+ { label: "Hide Pet", click: hidePet },
1153
+ { label: "Hide Inbox", click: hidePanel },
1154
+ { type: "separator" },
1155
+ { label: "Quit Pet", click: () => app.quit() },
1156
+ ])
1157
+ }
1158
+
1159
+ function startServer() {
1160
+ if (server) return
1161
+ server = http.createServer(async (req, res) => {
1162
+ try {
1163
+ if (req.method === "OPTIONS") return json(res, 200, { ok: true })
1164
+ const url = new URL(req.url || "/", `http://${HOST}:${PORT}`)
1165
+
1166
+ if (req.method === "GET" && url.pathname === "/health") {
1167
+ return json(res, 200, {
1168
+ status: "good",
1169
+ pid: process.pid,
1170
+ port: PORT,
1171
+ events: events.length,
1172
+ pet: "pixel-opencode-pet",
1173
+ sessions: getPetState().sessions.map(({ sessionID, state, busyIndex, idleIndex, color }) => ({
1174
+ sessionID,
1175
+ state,
1176
+ busyIndex,
1177
+ idleIndex,
1178
+ color,
1179
+ })),
1180
+ })
1181
+ }
1182
+
1183
+ if (req.method === "GET" && url.pathname === "/state") {
1184
+ return json(res, 200, getPetState())
1185
+ }
1186
+
1187
+ if (req.method === "GET" && url.pathname === "/snapshot") {
1188
+ const win = createPetWindow()
1189
+ const image = await win.webContents.capturePage()
1190
+ const png = image.toPNG()
1191
+ res.writeHead(200, {
1192
+ "content-type": "image/png",
1193
+ "access-control-allow-origin": "*",
1194
+ })
1195
+ res.end(png)
1196
+ return
1197
+ }
1198
+
1199
+ if (req.method === "GET" && url.pathname === "/debug-render") {
1200
+ const win = createPetWindow()
1201
+ const debug = await win.webContents.executeJavaScript("window.__getPetDebug?.() || window.__PET_BOOT_ERROR", true).catch(() => undefined)
1202
+ return json(res, 200, debug || { ok: false, error: "debug renderer not ready" })
1203
+ }
1204
+
1205
+ if (req.method === "POST" && url.pathname === "/event") {
1206
+ const body = await readRequestJson(req)
1207
+ const item = recordEvent(body)
1208
+ return json(res, 200, { ok: true, event: item })
1209
+ }
1210
+
1211
+ if (req.method === "POST" && url.pathname === "/show") {
1212
+ showPet()
1213
+ return json(res, 200, { ok: true })
1214
+ }
1215
+
1216
+ if (req.method === "POST" && url.pathname === "/toggle-panel") {
1217
+ togglePanel()
1218
+ return json(res, 200, { ok: true, visible: panelWindow?.isVisible() === true })
1219
+ }
1220
+
1221
+ if (req.method === "POST" && url.pathname === "/quit") {
1222
+ recordEvent({ type: "command.quit", source: "http" })
1223
+ json(res, 200, { ok: true, quitting: true })
1224
+ setTimeout(() => app.quit(), 50)
1225
+ return
1226
+ }
1227
+
1228
+ return json(res, 404, { ok: false, error: "not found" })
1229
+ } catch (error) {
1230
+ return json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
1231
+ }
1232
+ })
1233
+ server.listen(PORT, HOST, () => {
1234
+ writeState({ visible: petWindow?.isVisible() === true, mode: "floating", port: PORT })
1235
+ })
1236
+ }
1237
+
1238
+ function start() {
1239
+ app.dock?.hide()
1240
+ writeState({ visible: false, mode: "floating" })
1241
+ startServer()
1242
+ cleanupTimer = setInterval(() => pruneIdleSessions(true), 30 * 1000)
1243
+ cleanupTimer.unref?.()
1244
+ showPet()
1245
+ }
1246
+
1247
+ const lock = app.requestSingleInstanceLock()
1248
+
1249
+ if (!lock) {
1250
+ app.quit()
1251
+ } else {
1252
+ app.on("second-instance", (_event, argv) => {
1253
+ if (shouldQuit(argv)) {
1254
+ app.quit()
1255
+ return
1256
+ }
1257
+ showPet()
1258
+ recordEvent({ type: "app.second-instance", argv })
1259
+ })
1260
+
1261
+ app.whenReady().then(() => {
1262
+ if (shouldQuit()) {
1263
+ app.quit()
1264
+ return
1265
+ }
1266
+ start()
1267
+ })
1268
+
1269
+ app.on("window-all-closed", (event) => {
1270
+ event.preventDefault()
1271
+ })
1272
+
1273
+ app.on("before-quit", () => {
1274
+ try {
1275
+ server?.close()
1276
+ if (cleanupTimer) clearInterval(cleanupTimer)
1277
+ writeState({ stoppedAt: Date.now(), visible: false, mode: "floating" })
1278
+ } catch {
1279
+ // ignore shutdown errors
1280
+ }
1281
+ })
1282
+ }