desktop-pet-app 0.1.1 → 0.2.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/out/main/index.js CHANGED
@@ -110,6 +110,7 @@ const PET_EVENT = "pet:event";
110
110
  const PET_MOVE = "pet:move";
111
111
  const PET_DRAG = "pet:drag";
112
112
  const PET_MENU = "pet:menu";
113
+ const PET_MOUSE_PASSTHROUGH = "pet:mouse-passthrough";
113
114
  const P2P_SIGNAL_IN = "p2p:signal-in";
114
115
  const P2P_SIGNAL_OUT = "p2p:signal-out";
115
116
  const P2P_CONFIG = "p2p:config";
@@ -342,7 +343,7 @@ function validate(credentials) {
342
343
  throw new Error("设备配置的 serverUrl 必须使用 http 或 https");
343
344
  }
344
345
  }
345
- const DEFAULT_DESKTOP_PET_HOST = "http://47.94.20.104";
346
+ const DEFAULT_DESKTOP_PET_HOST = "https://deskpalai.com";
346
347
  function configuredRelayURL() {
347
348
  const host = process.env.DESKTOP_PET_HOST?.trim();
348
349
  if (host) return relayURLFromHost(host);
@@ -443,6 +444,32 @@ function showDesktopNotification(title, body) {
443
444
  if (!electron.Notification.isSupported()) return;
444
445
  new electron.Notification({ title, body }).show();
445
446
  }
447
+ function p2pSigningPayload(message, fromDeviceId) {
448
+ const signalJSON = JSON.stringify(message.signal ?? null);
449
+ const signalHash = node_crypto.createHash("sha256").update(signalJSON).digest("hex");
450
+ return [
451
+ message.type,
452
+ message.taskId ?? "",
453
+ message.sessionId ?? "",
454
+ fromDeviceId,
455
+ message.toDeviceId ?? "",
456
+ message.expiresAt ?? "",
457
+ signalHash
458
+ ].join("\n");
459
+ }
460
+ function verifyP2PSignal(message) {
461
+ if (!message.signature || !message.publicKey || !message.fromDeviceId || !message.taskId || !message.sessionId || !message.toDeviceId || !message.expiresAt) return false;
462
+ try {
463
+ return node_crypto.verify(
464
+ null,
465
+ Buffer.from(p2pSigningPayload(message, message.fromDeviceId)),
466
+ message.publicKey,
467
+ Buffer.from(message.signature, "base64")
468
+ );
469
+ } catch {
470
+ return false;
471
+ }
472
+ }
446
473
  let serverURL = configuredRelayURL();
447
474
  let userID = process.env.DESKTOP_PET_USER ?? os.hostname();
448
475
  const SKIN = process.env.DESKTOP_PET_SKIN ?? "";
@@ -623,7 +650,16 @@ function ensureConnected() {
623
650
  showCapabilityGapResult(msg.task);
624
651
  notifyResultWaiters(msg.task);
625
652
  } else if (msg.type.startsWith("p2p.") && msg.type !== "p2p.forwarded") {
626
- for (const listener of p2pSignalListeners) listener(msg);
653
+ if (!verifyP2PSignal(msg)) {
654
+ logger.warn("received invalid p2p signal", {
655
+ type: msg.type,
656
+ taskId: msg.taskId,
657
+ sessionId: msg.sessionId,
658
+ fromDeviceId: msg.fromDeviceId
659
+ });
660
+ } else {
661
+ for (const listener of p2pSignalListeners) listener(msg);
662
+ }
627
663
  } else if (msg.type === "help.request" && msg.from) {
628
664
  dispatch({ type: "state", state: "excited", ttl: 10 });
629
665
  dispatch({ type: "bubble", text: `@${msg.from} 想请你帮忙,右键我可以同意或婉拒`, ttl: 12e3 });
@@ -954,14 +990,9 @@ function onP2PSignal(listener) {
954
990
  async function sendP2PSignal(message) {
955
991
  if (!signingPrivateKey) throw new Error("设备未配置 P2P 签名密钥");
956
992
  const outgoing = { ...message, protocolVersion: TASK_PROTOCOL_VERSION };
957
- const signature = crypto.sign(null, Buffer.from(p2pSigningPayload(outgoing)), signingPrivateKey).toString("base64");
993
+ const signature = crypto.sign(null, Buffer.from(p2pSigningPayload(outgoing, deviceID)), signingPrivateKey).toString("base64");
958
994
  await relayRPC({ ...outgoing, signature }, 1e4);
959
995
  }
960
- function p2pSigningPayload(message) {
961
- const signalJSON = JSON.stringify(message.signal ?? null);
962
- const signalHash = crypto.createHash("sha256").update(signalJSON).digest("hex");
963
- return [message.type, message.taskId, message.sessionId, deviceID, message.toDeviceId, message.expiresAt, signalHash].join("\n");
964
- }
965
996
  async function requestRemoteTaskContext(taskId, request) {
966
997
  const response = await relayRPC(
967
998
  { type: "task.context.request", protocolVersion: TASK_PROTOCOL_VERSION, taskId, payload: request },
@@ -1550,7 +1581,15 @@ async function sendFriendChatPreferred(input) {
1550
1581
  }
1551
1582
  function onRendererSignal(event, message) {
1552
1583
  if (!transportWindow || event.sender !== transportWindow.webContents) return;
1553
- void sendP2PSignal(message).catch((err) => {
1584
+ void sendP2PSignal(message).then(() => {
1585
+ if (message.type !== "p2p.ice-candidate") {
1586
+ logger.info("p2p signal forwarded", {
1587
+ type: message.type,
1588
+ taskId: message.taskId,
1589
+ sessionId: message.sessionId
1590
+ });
1591
+ }
1592
+ }).catch((err) => {
1554
1593
  logger.warn("p2p signal failed", { type: message.type, taskId: message.taskId, error: String(err) });
1555
1594
  if (message.type === "p2p.offer" && message.taskId.startsWith("chat.")) {
1556
1595
  void fallbackChat(message.taskId.slice("chat.".length));
@@ -1560,6 +1599,18 @@ function onRendererSignal(event, message) {
1560
1599
  function onChatEvent(event, message) {
1561
1600
  if (!transportWindow || event.sender !== transportWindow.webContents || !message || typeof message !== "object") return;
1562
1601
  const value = message;
1602
+ if (value.type === "diagnostic") {
1603
+ logger.info("p2p diagnostic", {
1604
+ stage: value.stage,
1605
+ sessionId: value.sessionId,
1606
+ signalType: value.signalType,
1607
+ connectionState: value.connectionState,
1608
+ iceConnectionState: value.iceConnectionState,
1609
+ iceGatheringState: value.iceGatheringState,
1610
+ error: value.error
1611
+ });
1612
+ return;
1613
+ }
1563
1614
  if (value.type === "received" && value.text) {
1564
1615
  dispatch({ type: "state", state: "excited", ttl: 6 });
1565
1616
  dispatch({ type: "bubble", text: `${value.from ?? "好友"}:${value.text}`, ttl: 1e4 });
@@ -1899,6 +1950,7 @@ function createPetWindow(skin, options) {
1899
1950
  });
1900
1951
  win.setAlwaysOnTop(true, "screen-saver");
1901
1952
  win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
1953
+ win.setIgnoreMouseEvents(true, { forward: true });
1902
1954
  registerPetWindow(id, win);
1903
1955
  petManager.register(id, win, skin, identity);
1904
1956
  if (process.env.ELECTRON_RENDERER_URL) {
@@ -1930,6 +1982,10 @@ function createPetWindow(skin, options) {
1930
1982
  if (win.isDestroyed() || e.sender !== win.webContents) return;
1931
1983
  void showPetMenu();
1932
1984
  };
1985
+ const onMousePassthrough = (e, enabled) => {
1986
+ if (win.isDestroyed() || e.sender !== win.webContents || typeof enabled !== "boolean") return;
1987
+ win.setIgnoreMouseEvents(enabled, { forward: enabled });
1988
+ };
1933
1989
  const showPetMenu = async () => {
1934
1990
  const accountUsername = currentAccountUsername();
1935
1991
  const identityPresentation = publishIdentity();
@@ -2022,10 +2078,12 @@ function createPetWindow(skin, options) {
2022
2078
  electron.ipcMain.on(PET_MOVE, onMove);
2023
2079
  electron.ipcMain.on(PET_DRAG, onDrag);
2024
2080
  electron.ipcMain.on(PET_MENU, onMenu);
2081
+ electron.ipcMain.on(PET_MOUSE_PASSTHROUGH, onMousePassthrough);
2025
2082
  win.on("closed", () => {
2026
2083
  electron.ipcMain.removeListener(PET_MOVE, onMove);
2027
2084
  electron.ipcMain.removeListener(PET_DRAG, onDrag);
2028
2085
  electron.ipcMain.removeListener(PET_MENU, onMenu);
2086
+ electron.ipcMain.removeListener(PET_MOUSE_PASSTHROUGH, onMousePassthrough);
2029
2087
  });
2030
2088
  return id;
2031
2089
  }
@@ -4,6 +4,7 @@ const PET_EVENT = "pet:event";
4
4
  const PET_MOVE = "pet:move";
5
5
  const PET_DRAG = "pet:drag";
6
6
  const PET_MENU = "pet:menu";
7
+ const PET_MOUSE_PASSTHROUGH = "pet:mouse-passthrough";
7
8
  const P2P_SIGNAL_IN = "p2p:signal-in";
8
9
  const P2P_SIGNAL_OUT = "p2p:signal-out";
9
10
  const P2P_START = "p2p:start";
@@ -13,6 +14,7 @@ const P2P_CHAT_EVENT = "p2p:chat-event";
13
14
  electron.contextBridge.exposeInMainWorld("petApi", {
14
15
  move: (dx, dy) => electron.ipcRenderer.send(PET_MOVE, dx, dy),
15
16
  drag: (phase, dx = 0, dy = 0) => electron.ipcRenderer.send(PET_DRAG, phase, dx, dy),
17
+ setMousePassthrough: (enabled) => electron.ipcRenderer.send(PET_MOUSE_PASSTHROUGH, enabled),
16
18
  openMenu: () => electron.ipcRenderer.send(PET_MENU),
17
19
  onEvent: (cb) => {
18
20
  electron.ipcRenderer.on(PET_EVENT, (_e, ev) => cb(ev));
@@ -1618,9 +1618,28 @@ window.petApi.onEvent((ev) => {
1618
1618
  let dragging = false;
1619
1619
  let lastX = 0;
1620
1620
  let lastY = 0;
1621
+ let mousePassthrough = false;
1622
+ function updateMousePassthrough(clientX, clientY) {
1623
+ const rect = canvas.getBoundingClientRect();
1624
+ const x = Math.floor((clientX - rect.left) * canvas.width / rect.width);
1625
+ const y = Math.floor((clientY - rect.top) * canvas.height / rect.height);
1626
+ let overPet = false;
1627
+ if (x >= 0 && x < canvas.width && y >= 0 && y < canvas.height) {
1628
+ const alpha = canvas.getContext("2d").getImageData(x, y, 1, 1).data[3];
1629
+ overPet = alpha > 16;
1630
+ }
1631
+ const next = !dragging && !overPet;
1632
+ if (next === mousePassthrough) return;
1633
+ mousePassthrough = next;
1634
+ window.petApi.setMousePassthrough(next);
1635
+ }
1621
1636
  canvas.addEventListener("mousedown", (e) => {
1622
1637
  if (e.button !== 0) return;
1623
1638
  dragging = true;
1639
+ if (mousePassthrough) {
1640
+ mousePassthrough = false;
1641
+ window.petApi.setMousePassthrough(false);
1642
+ }
1624
1643
  lastX = e.screenX;
1625
1644
  lastY = e.screenY;
1626
1645
  animator.setDragging(true);
@@ -1628,21 +1647,24 @@ canvas.addEventListener("mousedown", (e) => {
1628
1647
  draw();
1629
1648
  });
1630
1649
  window.addEventListener("mousemove", (e) => {
1631
- if (!dragging) return;
1632
- const dx = e.screenX - lastX;
1633
- const dy = e.screenY - lastY;
1634
- if (dx !== 0 || dy !== 0) window.petApi.drag("move", dx, dy);
1635
- lastX = e.screenX;
1636
- lastY = e.screenY;
1650
+ if (dragging) {
1651
+ const dx = e.screenX - lastX;
1652
+ const dy = e.screenY - lastY;
1653
+ if (dx !== 0 || dy !== 0) window.petApi.drag("move", dx, dy);
1654
+ lastX = e.screenX;
1655
+ lastY = e.screenY;
1656
+ }
1657
+ updateMousePassthrough(e.clientX, e.clientY);
1637
1658
  });
1638
- window.addEventListener("mouseup", () => {
1659
+ window.addEventListener("mouseup", (e) => {
1639
1660
  if (!dragging) return;
1640
1661
  dragging = false;
1641
1662
  window.petApi.drag("end");
1642
1663
  animator.setDragging(false);
1643
1664
  draw();
1665
+ updateMousePassthrough(e.clientX, e.clientY);
1644
1666
  });
1645
- window.addEventListener("contextmenu", (e) => {
1667
+ canvas.addEventListener("contextmenu", (e) => {
1646
1668
  e.preventDefault();
1647
1669
  if (dragging) {
1648
1670
  dragging = false;
@@ -1659,3 +1681,4 @@ window.addEventListener("blur", () => {
1659
1681
  animator.setDragging(false);
1660
1682
  draw();
1661
1683
  });
1684
+ window.petApi.setMousePassthrough(true);
@@ -53,11 +53,16 @@ function decodeArtifactChunk(buffer) {
53
53
  };
54
54
  }
55
55
  class IceCandidateBuffer {
56
- constructor(maxPerSession = 64) {
56
+ constructor(maxPerSession = 64, maxSessions = 64) {
57
57
  this.maxPerSession = maxPerSession;
58
+ this.maxSessions = maxSessions;
58
59
  }
59
60
  candidates = /* @__PURE__ */ new Map();
60
61
  add(sessionId, candidate) {
62
+ if (!this.candidates.has(sessionId) && this.candidates.size >= this.maxSessions) {
63
+ const oldestSessionId = this.candidates.keys().next().value;
64
+ if (oldestSessionId) this.candidates.delete(oldestSessionId);
65
+ }
61
66
  const queued = this.candidates.get(sessionId) ?? [];
62
67
  if (queued.length < this.maxPerSession) queued.push(candidate);
63
68
  this.candidates.set(sessionId, queued);
@@ -211,7 +216,13 @@ let iceServers = parseIceServers();
211
216
  window.p2pApi.onStart((request) => void createOffer(request));
212
217
  window.p2pApi.onChatSend((request) => void createOffer(request));
213
218
  window.p2pApi.onSignal((message) => {
214
- void receiveSignal(message).catch(() => failSession(message.sessionId));
219
+ void receiveSignal(message).catch((error) => {
220
+ reportDiagnostic("signal-error", message.sessionId, {
221
+ signalType: message.type,
222
+ error: error instanceof Error ? `${error.name}: ${error.message}` : String(error)
223
+ });
224
+ failSession(message.sessionId);
225
+ });
215
226
  });
216
227
  window.p2pApi.onConfig((config) => {
217
228
  if (config.iceServers?.length) iceServers = config.iceServers;
@@ -228,24 +239,28 @@ async function createOffer(request) {
228
239
  const session = sessions.get(sessionId);
229
240
  session.channel = new ReliableArtifactChannel(channel);
230
241
  session.operationId = request.operationId;
231
- wireChatChannel(channel, request.toUsername, request.operationId, request.text);
242
+ wireChatChannel(channel, sessionId, request.toUsername, request.operationId, request.text);
232
243
  const offer = await peer.createOffer();
233
244
  await peer.setLocalDescription(offer);
245
+ reportDiagnostic("offer-created", sessionId, peerState(peer));
234
246
  send({ type: "p2p.offer", taskId: request.taskId, toDeviceId: request.toDeviceId, sessionId, signal: offer });
235
247
  }
236
248
  async function receiveSignal(message) {
237
249
  if (new Date(message.expiresAt).getTime() <= Date.now()) return;
238
- if (!await verifySignal(message)) return;
250
+ if (message.type !== "p2p.ice-candidate") {
251
+ reportDiagnostic("signal-received", message.sessionId, { signalType: message.type });
252
+ }
239
253
  if (message.type === "p2p.offer" && message.fromDeviceId && message.signal) {
240
254
  const peer = createPeer(message.taskId, message.sessionId, message.fromDeviceId);
241
255
  peer.ondatachannel = (event) => {
242
256
  sessions.get(message.sessionId).channel = new ReliableArtifactChannel(event.channel);
243
- wireChatChannel(event.channel, message.from);
257
+ wireChatChannel(event.channel, message.sessionId, message.from);
244
258
  };
245
259
  await peer.setRemoteDescription(message.signal);
246
260
  await flushPendingIceCandidates(message.sessionId, peer);
247
261
  const answer = await peer.createAnswer();
248
262
  await peer.setLocalDescription(answer);
263
+ reportDiagnostic("answer-created", message.sessionId, peerState(peer));
249
264
  send({
250
265
  type: "p2p.answer",
251
266
  taskId: message.taskId,
@@ -270,40 +285,10 @@ async function receiveSignal(message) {
270
285
  closeSession(message.sessionId);
271
286
  }
272
287
  }
273
- async function verifySignal(message) {
274
- if (!message.signature || !message.publicKey || !message.fromDeviceId) return false;
275
- try {
276
- const der = pemToBytes(message.publicKey);
277
- const key = await crypto.subtle.importKey("spki", der, { name: "Ed25519" }, false, ["verify"]);
278
- const signalJSON = JSON.stringify(message.signal ?? null);
279
- const digest2 = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(signalJSON));
280
- const hash = [...new Uint8Array(digest2)].map((value) => value.toString(16).padStart(2, "0")).join("");
281
- const payload = [
282
- message.type,
283
- message.taskId,
284
- message.sessionId,
285
- message.fromDeviceId,
286
- message.toDeviceId,
287
- message.expiresAt,
288
- hash
289
- ].join("\n");
290
- return crypto.subtle.verify(
291
- "Ed25519",
292
- key,
293
- Uint8Array.from(atob(message.signature), (char) => char.charCodeAt(0)),
294
- new TextEncoder().encode(payload)
295
- );
296
- } catch {
297
- return false;
298
- }
299
- }
300
- function pemToBytes(value) {
301
- const base64 = value.replace(/-----[^-]+-----/g, "").replace(/\s/g, "");
302
- return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)).buffer;
303
- }
304
288
  function createPeer(taskId, sessionId, toDeviceId, timeoutMs = P2P_CONNECT_TIMEOUT_MS) {
305
289
  const peer = new RTCPeerConnection({ iceServers });
306
290
  const timer = setTimeout(() => {
291
+ reportDiagnostic("connect-timeout", sessionId, peerState(peer));
307
292
  const operationId = sessions.get(sessionId)?.operationId;
308
293
  if (operationId) window.p2pApi.emitChatEvent({ type: "failed", operationId });
309
294
  send({ type: "p2p.failed", taskId, sessionId, toDeviceId });
@@ -316,6 +301,7 @@ function createPeer(taskId, sessionId, toDeviceId, timeoutMs = P2P_CONNECT_TIMEO
316
301
  }
317
302
  };
318
303
  peer.onconnectionstatechange = () => {
304
+ reportDiagnostic("connection-state", sessionId, peerState(peer));
319
305
  if (peer.connectionState === "connected") {
320
306
  clearTimeout(timer);
321
307
  send({ type: "p2p.ready", taskId, sessionId, toDeviceId });
@@ -327,9 +313,10 @@ function createPeer(taskId, sessionId, toDeviceId, timeoutMs = P2P_CONNECT_TIMEO
327
313
  };
328
314
  return peer;
329
315
  }
330
- function wireChatChannel(channel, peerUsername, operationId, outgoingText) {
316
+ function wireChatChannel(channel, sessionId, peerUsername, operationId, outgoingText) {
331
317
  if (operationId && outgoingText) {
332
318
  channel.addEventListener("open", () => {
319
+ reportDiagnostic("channel-open", sessionId);
333
320
  channel.send(JSON.stringify({ type: "chat.message", text: outgoingText }));
334
321
  window.p2pApi.emitChatEvent({ type: "sent", operationId });
335
322
  }, { once: true });
@@ -366,6 +353,16 @@ async function flushPendingIceCandidates(sessionId, peer) {
366
353
  function send(message) {
367
354
  window.p2pApi.sendSignal({ ...message, expiresAt: new Date(Date.now() + 5 * 6e4).toISOString() });
368
355
  }
356
+ function peerState(peer) {
357
+ return {
358
+ connectionState: peer.connectionState,
359
+ iceConnectionState: peer.iceConnectionState,
360
+ iceGatheringState: peer.iceGatheringState
361
+ };
362
+ }
363
+ function reportDiagnostic(stage, sessionId, fields = {}) {
364
+ window.p2pApi.emitChatEvent({ type: "diagnostic", stage, sessionId, ...fields });
365
+ }
369
366
  function parseIceServers() {
370
367
  try {
371
368
  const env = __vite_import_meta_env__;
@@ -147,7 +147,7 @@
147
147
  }
148
148
  }
149
149
  </style>
150
- <script type="module" crossorigin src="./assets/index-DqBgThfZ.js"></script>
150
+ <script type="module" crossorigin src="./assets/index-CHc4qvDr.js"></script>
151
151
  </head>
152
152
  <body>
153
153
  <canvas id="pet"></canvas>
@@ -1,6 +1,6 @@
1
1
  <!doctype html>
2
2
  <html lang="en">
3
- <head><meta charset="UTF-8" /><title>Desktop Pet P2P</title> <script type="module" crossorigin src="./assets/p2p-Ch5g1MsK.js"></script>
3
+ <head><meta charset="UTF-8" /><title>Desktop Pet P2P</title> <script type="module" crossorigin src="./assets/p2p-LRPQ9Jzg.js"></script>
4
4
  </head>
5
5
  <body></body>
6
6
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "desktop-pet-app",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "AI desktop pet with MCP support and a self-hosted relay server",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -34,7 +34,9 @@
34
34
  "test": "vitest run",
35
35
  "package:mac": "npm run build && electron-builder --mac pkg --publish never",
36
36
  "pack:check": "npm pack --dry-run",
37
- "prepack": "npm run build",
37
+ "prepack": "npm run build && node scripts/prepare-npm-package.mjs",
38
+ "postpack": "node scripts/prepare-npm-package.mjs --restore",
39
+ "recover:npm-package": "node scripts/prepare-npm-package.mjs --restore",
38
40
  "prepublishOnly": "npm run typecheck && npm test && npm run pack:check"
39
41
  },
40
42
  "build": {
@@ -65,8 +67,8 @@
65
67
  "vitest": "^3.2.7"
66
68
  },
67
69
  "dependencies": {
68
- "@modelcontextprotocol/sdk": "^1.30.0",
69
70
  "electron": "^33.2.0",
71
+ "@modelcontextprotocol/sdk": "^1.30.0",
70
72
  "ws": "^8.21.3",
71
73
  "zod": "^4.5.4"
72
74
  }