desktop-pet-app 0.3.27 → 0.4.3

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
@@ -29,13 +29,13 @@ const os = require("os");
29
29
  const node_crypto = require("node:crypto");
30
30
  const node_http = require("node:http");
31
31
  const node_os = require("node:os");
32
+ const promises = require("node:fs/promises");
32
33
  const crypto = require("crypto");
33
34
  const WebSocket = require("ws");
35
+ const node_child_process = require("node:child_process");
34
36
  const fs = require("fs");
35
- const promises = require("node:fs/promises");
36
37
  const node_stream = require("node:stream");
37
38
  const promises$1 = require("node:stream/promises");
38
- const node_child_process = require("node:child_process");
39
39
  const http = require("http");
40
40
  const ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
41
41
  class Logger {
@@ -183,7 +183,9 @@ const SKIN_IDS = [
183
183
  "tiger",
184
184
  "whale",
185
185
  "penguin",
186
- "robot-blue"
186
+ "robot-blue",
187
+ "yier",
188
+ "bubu"
187
189
  ];
188
190
  const SKIN_LABELS = {
189
191
  codex: "Codex 小宠",
@@ -197,7 +199,9 @@ const SKIN_LABELS = {
197
199
  tiger: "老虎",
198
200
  whale: "蓝鲸",
199
201
  penguin: "企鹅",
200
- "robot-blue": "蓝色协作机器人"
202
+ "robot-blue": "蓝色协作机器人",
203
+ yier: "一二(白色)",
204
+ bubu: "布布(棕色)"
201
205
  };
202
206
  const PET_EVENT = "pet:event";
203
207
  const PET_MOVE = "pet:move";
@@ -364,6 +368,7 @@ class PetManager {
364
368
  petsVisible = true;
365
369
  localDuty = false;
366
370
  remoteDuty = /* @__PURE__ */ new Set();
371
+ remoteDutyKnown = false;
367
372
  localAgentConnectionStatus = "disconnected";
368
373
  remoteAgentConnectionStatuses = /* @__PURE__ */ new Map();
369
374
  observableTasks = /* @__PURE__ */ new Map();
@@ -410,9 +415,20 @@ class PetManager {
410
415
  return this.localAgentConnectionStatus;
411
416
  }
412
417
  setRemoteDuty(usernames) {
418
+ this.remoteDutyKnown = true;
413
419
  this.remoteDuty = new Set(usernames.map((username) => username.trim().toLowerCase()));
414
420
  this.publishActivities();
415
421
  }
422
+ /** undefined 表示尚未收到实时 presence,调用方可回退到后台快照。 */
423
+ getRemoteDuty(username) {
424
+ if (!this.remoteDutyKnown) return void 0;
425
+ return this.remoteDuty.has(username.trim().toLowerCase());
426
+ }
427
+ resetRemoteDuty() {
428
+ this.remoteDutyKnown = false;
429
+ this.remoteDuty.clear();
430
+ this.publishActivities();
431
+ }
416
432
  setRemoteAgentConnectionStatuses(statuses) {
417
433
  this.remoteAgentConnectionStatuses = new Map(
418
434
  Object.entries(statuses).map(([username, status2]) => [username.trim().toLowerCase(), status2])
@@ -517,6 +533,54 @@ function center(bounds) {
517
533
  return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
518
534
  }
519
535
  const petManager = new PetManager();
536
+ const MAX_MENU_TASKS = 8;
537
+ const MENU_REFRESH_INTERVAL_MS = 5e3;
538
+ class BackgroundSnapshot {
539
+ constructor(now = Date.now) {
540
+ this.now = now;
541
+ }
542
+ entry;
543
+ pending;
544
+ read(key) {
545
+ return this.entry && Object.is(this.entry.key, key) ? this.entry.value : void 0;
546
+ }
547
+ refresh(key, loader, minIntervalMs = 0) {
548
+ if (this.entry && Object.is(this.entry.key, key) && this.now() - this.entry.refreshedAt < minIntervalMs) {
549
+ return Promise.resolve(this.entry.value);
550
+ }
551
+ if (this.pending && Object.is(this.pending.key, key)) return this.pending.promise;
552
+ const token = Symbol("menu-refresh");
553
+ const promise = Promise.resolve().then(loader).then((value) => {
554
+ if (this.pending?.token === token) {
555
+ this.entry = { key, value, refreshedAt: this.now() };
556
+ }
557
+ return value;
558
+ }).catch(() => this.read(key)).finally(() => {
559
+ if (this.pending?.token === token) this.pending = void 0;
560
+ });
561
+ this.pending = { key, token, promise };
562
+ return promise;
563
+ }
564
+ clear() {
565
+ this.entry = void 0;
566
+ this.pending = void 0;
567
+ }
568
+ }
569
+ function compactMenuText(value, maxLength) {
570
+ const compact = value.replace(/\s+/g, " ").trim();
571
+ if (compact.length <= maxLength) return compact;
572
+ if (maxLength <= 1) return compact.slice(0, maxLength);
573
+ return compact.slice(0, maxLength - 1).trimEnd() + "…";
574
+ }
575
+ function resolveFriendDuty(snapshotDuty, liveDuty) {
576
+ return liveDuty ?? snapshotDuty ?? false;
577
+ }
578
+ function menuTasks(tasks, limit = MAX_MENU_TASKS) {
579
+ return [...tasks].sort((a, b) => {
580
+ const activeDelta = Number(isActiveTask(b)) - Number(isActiveTask(a));
581
+ return activeDelta || Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
582
+ }).slice(0, limit);
583
+ }
520
584
  const OFFICIAL_SERVER_ROOT = "https://api.xinbit.top/desktop-pet";
521
585
  const OFFICIAL_PORTAL_ORIGIN = "https://deskpalai.com";
522
586
  const LEGACY_OFFICIAL_SERVER_HOSTS = "47.94.20.104".split(",").map((host) => host.trim()).filter(Boolean);
@@ -623,6 +687,38 @@ function relayURLFromHost(value) {
623
687
  function isLocalHost(value) {
624
688
  return /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/i.test(value.trim());
625
689
  }
690
+ const INSTALLATION_ID_FILE = "update-installation-id";
691
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
692
+ let cachedInstallationID;
693
+ async function installationID() {
694
+ if (cachedInstallationID) return cachedInstallationID;
695
+ const path2 = node_path.join(electron.app.getPath("userData"), INSTALLATION_ID_FILE);
696
+ let replaceInvalidFile = false;
697
+ try {
698
+ const stored = (await promises.readFile(path2, "utf8")).trim();
699
+ if (UUID_PATTERN.test(stored)) {
700
+ cachedInstallationID = stored;
701
+ return stored;
702
+ }
703
+ replaceInvalidFile = true;
704
+ } catch (error) {
705
+ if (error.code !== "ENOENT") {
706
+ logger.warn("could not read installation id", { error: String(error) });
707
+ }
708
+ }
709
+ const created = node_crypto.randomUUID();
710
+ await promises.mkdir(electron.app.getPath("userData"), { recursive: true });
711
+ try {
712
+ await promises.writeFile(path2, created, { flag: replaceInvalidFile ? "w" : "wx", mode: 384 });
713
+ cachedInstallationID = created;
714
+ } catch (error) {
715
+ if (error.code !== "EEXIST") throw error;
716
+ const stored = (await promises.readFile(path2, "utf8")).trim();
717
+ if (!UUID_PATTERN.test(stored)) throw new Error("设备安装标识无效");
718
+ cachedInstallationID = stored;
719
+ }
720
+ return cachedInstallationID;
721
+ }
626
722
  const TASK_PROTOCOL_VERSION = "1.0";
627
723
  const WORKSPACE_SNAPSHOT_MIME = "application/vnd.desktop-pet.git-workspace-bundle";
628
724
  function taskParticipantRole(task, authenticatedUserId) {
@@ -726,16 +822,67 @@ const CAPABILITY_ALIASES = {
726
822
  "web.request": "network",
727
823
  "web.browser": "browser",
728
824
  "document.write": "document.create",
729
- "docs.create": "document.create"
825
+ "docs.create": "document.create",
826
+ node: "toolchain.node",
827
+ go: "toolchain.go",
828
+ golang: "toolchain.go",
829
+ git: "toolchain.git",
830
+ python: "toolchain.python",
831
+ python3: "toolchain.python",
832
+ npm: "toolchain.npm",
833
+ pnpm: "toolchain.pnpm",
834
+ yarn: "toolchain.yarn",
835
+ bun: "toolchain.bun",
836
+ rust: "toolchain.rust",
837
+ cargo: "toolchain.cargo",
838
+ java: "toolchain.java",
839
+ maven: "toolchain.maven",
840
+ mvn: "toolchain.maven",
841
+ gradle: "toolchain.gradle"
730
842
  };
731
- function executionCapabilities(configured = process.env.DESKTOP_PET_EXECUTION_CAPABILITIES) {
732
- const source = configured?.trim() || DEFAULT_EXECUTION_CAPABILITIES.join(",");
843
+ const TOOLCHAIN_PROBES = [
844
+ ["git", "toolchain.git"],
845
+ ["go", "toolchain.go"],
846
+ ["python3", "toolchain.python"],
847
+ ["npm", "toolchain.npm"],
848
+ ["pnpm", "toolchain.pnpm"],
849
+ ["yarn", "toolchain.yarn"],
850
+ ["bun", "toolchain.bun"],
851
+ ["rustc", "toolchain.rust"],
852
+ ["cargo", "toolchain.cargo"],
853
+ ["java", "toolchain.java"],
854
+ ["mvn", "toolchain.maven"],
855
+ ["gradle", "toolchain.gradle"]
856
+ ];
857
+ let cachedDetectedCapabilities;
858
+ function commandAvailable(command) {
859
+ const result = node_child_process.spawnSync(command, ["--version"], {
860
+ stdio: "ignore",
861
+ timeout: 1e3
862
+ });
863
+ return !result.error && result.status === 0;
864
+ }
865
+ function detectEnvironmentCapabilities(probe = commandAvailable) {
866
+ return [
867
+ "toolchain.node",
868
+ ...TOOLCHAIN_PROBES.filter(([command]) => probe(command)).map(([, capability]) => capability)
869
+ ];
870
+ }
871
+ function detectedEnvironmentCapabilities() {
872
+ if (!cachedDetectedCapabilities) cachedDetectedCapabilities = detectEnvironmentCapabilities();
873
+ return cachedDetectedCapabilities;
874
+ }
875
+ function normalizeCapabilities(values) {
733
876
  return [
734
877
  ...new Set(
735
- source.split(",").map((value) => value.trim().toLowerCase()).filter(Boolean).map((value) => CAPABILITY_ALIASES[value] ?? value)
878
+ values.map((value) => value.trim().toLowerCase()).filter(Boolean).map((value) => CAPABILITY_ALIASES[value] ?? value)
736
879
  )
737
880
  ];
738
881
  }
882
+ function executionCapabilities(configured = process.env.DESKTOP_PET_EXECUTION_CAPABILITIES, detected = detectedEnvironmentCapabilities(), additional = process.env.DESKTOP_PET_ADDITIONAL_CAPABILITIES) {
883
+ const base = configured?.trim() ? configured.split(",") : [...DEFAULT_EXECUTION_CAPABILITIES, ...detected];
884
+ return normalizeCapabilities([...base, ...additional?.split(",") ?? []]);
885
+ }
739
886
  function showDesktopNotification(title, body) {
740
887
  if (!electron.Notification.isSupported()) return;
741
888
  new electron.Notification({ title, body }).show();
@@ -821,6 +968,7 @@ let observedMcpBridgeVersion;
821
968
  let ws = null;
822
969
  let connectPromise = null;
823
970
  let intentionalClose = false;
971
+ let authenticationBlocked = !allowDevelopmentOverrides && !authToken;
824
972
  let protocolV1Available = false;
825
973
  let workspaceSnapshotStagingAvailable = false;
826
974
  let mcpRegistration;
@@ -868,6 +1016,7 @@ function configureRelayIdentity(config) {
868
1016
  deviceID = config.deviceId;
869
1017
  authToken = config.accessToken;
870
1018
  signingPrivateKey = config.signingPrivateKey ?? "";
1019
+ authenticationBlocked = false;
871
1020
  }
872
1021
  function clearRelayIdentity() {
873
1022
  if (ws || connectPromise) throw new Error("stop relay before clearing identity");
@@ -876,9 +1025,22 @@ function clearRelayIdentity() {
876
1025
  deviceID = allowDevelopmentOverrides ? process.env.DESKTOP_PET_DEVICE_ID?.trim() || "" : "";
877
1026
  authToken = allowDevelopmentOverrides ? process.env.DESKTOP_PET_TOKEN?.trim() : void 0;
878
1027
  signingPrivateKey = "";
1028
+ authenticationBlocked = !allowDevelopmentOverrides && !authToken;
879
1029
  }
880
- async function registerMcpClient(agentHost, capabilities = {}) {
881
- mcpRegistration = { agentHost, capabilities };
1030
+ function isRelayAuthenticationBlocked() {
1031
+ return authenticationBlocked;
1032
+ }
1033
+ async function registerMcpClient(agentHost, capabilities = {}, hostExecutionCapabilities) {
1034
+ const sanitizedExecutionCapabilities = hostExecutionCapabilities ? [
1035
+ ...new Set(
1036
+ hostExecutionCapabilities.map((value) => value.trim().toLowerCase()).filter((value) => /^[a-z0-9][a-z0-9._-]{0,63}$/.test(value))
1037
+ )
1038
+ ].slice(0, 100) : void 0;
1039
+ mcpRegistration = {
1040
+ agentHost,
1041
+ capabilities,
1042
+ executionCapabilities: sanitizedExecutionCapabilities
1043
+ };
882
1044
  await ensureConnected();
883
1045
  ws?.send(
884
1046
  JSON.stringify({
@@ -917,6 +1079,7 @@ function stopRelay() {
917
1079
  protocolV1Available = false;
918
1080
  workspaceSnapshotStagingAvailable = false;
919
1081
  dutyPublished = false;
1082
+ petManager.resetRemoteDuty();
920
1083
  cancelRelayReconnect();
921
1084
  retryAttempt = 0;
922
1085
  connectPromise = null;
@@ -960,6 +1123,7 @@ function stopRelay() {
960
1123
  function ensureConnected() {
961
1124
  if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve();
962
1125
  if (connectPromise) return connectPromise;
1126
+ if (authenticationBlocked) return Promise.reject(relayAuthenticationError());
963
1127
  connectPromise = new Promise((resolve, reject) => {
964
1128
  let settled = false;
965
1129
  let authRejected = false;
@@ -989,6 +1153,7 @@ function ensureConnected() {
989
1153
  if (msg.task) msg.task = withParticipantRole(msg.task);
990
1154
  if (msg.tasks) msg.tasks = msg.tasks.map(withParticipantRole);
991
1155
  if (msg.type === "registered") {
1156
+ authenticationBlocked = false;
992
1157
  retryAttempt = 0;
993
1158
  protocolV1Available = msg.protocolVersion === TASK_PROTOCOL_VERSION;
994
1159
  workspaceSnapshotStagingAvailable = msg.capabilities?.workspaceSnapshotStaging === true;
@@ -1092,13 +1257,13 @@ function ensureConnected() {
1092
1257
  }
1093
1258
  });
1094
1259
  sock.on("error", (err) => {
1095
- if (/Unexpected server response: 40\d/.test(String(err))) authRejected = true;
1260
+ if (/Unexpected server response: (?:401|403)/.test(String(err))) {
1261
+ authRejected = true;
1262
+ if (ws === sock) authenticationBlocked = true;
1263
+ }
1096
1264
  logger.warn("relay socket error", { error: String(err) });
1097
1265
  if (!settled) {
1098
1266
  settled = true;
1099
- if (ws === sock) {
1100
- connectPromise = null;
1101
- }
1102
1267
  reject(err);
1103
1268
  }
1104
1269
  });
@@ -1120,6 +1285,7 @@ function ensureConnected() {
1120
1285
  }
1121
1286
  if (isCurrentSocket && !intentionalClose) {
1122
1287
  if (authRejected) {
1288
+ authenticationBlocked = true;
1123
1289
  logger.warn("relay rejected by server authentication; auto reconnect paused until identity changes", {
1124
1290
  server: serverURL,
1125
1291
  authenticated: Boolean(authToken)
@@ -1132,6 +1298,13 @@ function ensureConnected() {
1132
1298
  });
1133
1299
  return connectPromise;
1134
1300
  }
1301
+ function relayAuthenticationError() {
1302
+ const error = new Error(
1303
+ authToken ? "中转服务认证已失效,请重新登录桌宠账号" : "请先登录桌宠账号后再使用协作功能"
1304
+ );
1305
+ error.code = "RELAY_AUTH_REQUIRED";
1306
+ return error;
1307
+ }
1135
1308
  async function askFriend(opts) {
1136
1309
  await ensureConnected();
1137
1310
  const questionId = crypto.randomUUID();
@@ -1190,6 +1363,9 @@ async function askFriend(opts) {
1190
1363
  async function getCollaborationState() {
1191
1364
  const response = await relayRPC({ type: "help.list" }, 1e4);
1192
1365
  const friends = response.friends ?? [];
1366
+ petManager.setRemoteDuty(
1367
+ friends.filter((friend) => friend.duty).map((friend) => friend.username)
1368
+ );
1193
1369
  petManager.setRemoteAgentConnectionStatuses(
1194
1370
  Object.fromEntries(
1195
1371
  friends.flatMap((friend) => friend.agentStatus ? [[friend.username, friend.agentStatus]] : [])
@@ -1313,10 +1489,13 @@ function relayCapabilities() {
1313
1489
  connectionStatus: mcpConnectionStatus,
1314
1490
  ...observedMcpBridgeVersion ? { bridgeProtocolVersion: observedMcpBridgeVersion } : {}
1315
1491
  },
1316
- execution: executionCapabilities(),
1492
+ execution: currentExecutionCapabilities(),
1317
1493
  platform: `${process.platform}-${process.arch}`
1318
1494
  };
1319
1495
  }
1496
+ function currentExecutionCapabilities() {
1497
+ return mcpRegistration?.executionCapabilities ?? executionCapabilities();
1498
+ }
1320
1499
  function publishDeviceCapabilities() {
1321
1500
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
1322
1501
  ws.send(
@@ -1330,11 +1509,11 @@ function publishDeviceCapabilities() {
1330
1509
  }
1331
1510
  function showCapabilityUnavailable(msg) {
1332
1511
  const required = msg.requiredCapabilities ?? [];
1333
- const available = new Set(executionCapabilities());
1512
+ const available = new Set(currentExecutionCapabilities());
1334
1513
  const missing = required.filter((capability) => !available.has(capability));
1335
1514
  const named = (missing.length > 0 ? missing : required).join("、") || "所需工具";
1336
1515
  const sender = msg.from ? `@${msg.from} 的` : "这项";
1337
- const text = `暂时无法匹配:${sender}任务带有语义能力标签 ${named},但当前设备未声明兼容标签。可移除任务的硬筛选,或更新设备的能力映射后重连。`;
1516
+ const text = `暂时无法匹配:${sender}任务强制要求 ${named},但当前设备未通过对应能力声明。可让发起方改为 ignore,或补齐工具链/服务后重连。`;
1338
1517
  dispatch({ type: "state", state: "failed", ttl: 12 });
1339
1518
  dispatch({ type: "bubble", text, ttl: 18e3 });
1340
1519
  showDesktopNotification("桌宠暂时无法接单", text);
@@ -1665,13 +1844,13 @@ async function activateStoredDevice() {
1665
1844
  return true;
1666
1845
  }
1667
1846
  async function configureAndActivateDevice(credentials) {
1668
- const signedCredentials = withSigningKeys(credentials);
1847
+ const signedCredentials = { ...credentials, ...ensureDeviceKeyMaterial(credentials) };
1669
1848
  saveDeviceCredentials(signedCredentials);
1670
1849
  await activateDevice(signedCredentials, true);
1671
1850
  }
1672
1851
  async function activateDevice(credentials, restart) {
1673
1852
  if (!credentials.publicKey || !credentials.privateKey || !credentials.encryptionPublicKey || !credentials.encryptionPrivateKey) {
1674
- credentials = withSigningKeys(credentials);
1853
+ credentials = { ...credentials, ...ensureDeviceKeyMaterial(credentials) };
1675
1854
  saveDeviceCredentials(credentials);
1676
1855
  }
1677
1856
  const base = credentials.serverUrl.replace(/\/$/, "");
@@ -1681,6 +1860,7 @@ async function activateDevice(credentials, restart) {
1681
1860
  body: JSON.stringify({
1682
1861
  deviceId: credentials.deviceId,
1683
1862
  refreshToken: credentials.refreshToken,
1863
+ installationId: await installationID(),
1684
1864
  publicKey: credentials.publicKey,
1685
1865
  encryptionPublicKey: credentials.encryptionPublicKey
1686
1866
  })
@@ -1719,29 +1899,35 @@ async function activateDevice(credentials, restart) {
1719
1899
  );
1720
1900
  }, refreshAt);
1721
1901
  }
1722
- function withSigningKeys(credentials) {
1723
- let next = credentials;
1724
- if (!next.publicKey || !next.privateKey) {
1725
- const { publicKey, privateKey } = node_crypto.generateKeyPairSync("ed25519", {
1902
+ function ensureDeviceKeyMaterial(credentials = {}) {
1903
+ let publicKey = credentials.publicKey;
1904
+ let privateKey = credentials.privateKey;
1905
+ let encryptionPublicKey = credentials.encryptionPublicKey;
1906
+ let encryptionPrivateKey = credentials.encryptionPrivateKey;
1907
+ if (!publicKey || !privateKey) {
1908
+ const generated = node_crypto.generateKeyPairSync("ed25519", {
1726
1909
  publicKeyEncoding: { type: "spki", format: "pem" },
1727
1910
  privateKeyEncoding: { type: "pkcs8", format: "pem" }
1728
1911
  });
1729
- next = { ...next, publicKey, privateKey };
1912
+ publicKey = generated.publicKey;
1913
+ privateKey = generated.privateKey;
1730
1914
  }
1731
- if (!next.encryptionPublicKey || !next.encryptionPrivateKey) {
1732
- const { publicKey, privateKey } = node_crypto.generateKeyPairSync("rsa", {
1915
+ if (!encryptionPublicKey || !encryptionPrivateKey) {
1916
+ const generated = node_crypto.generateKeyPairSync("rsa", {
1733
1917
  modulusLength: 2048,
1734
1918
  publicKeyEncoding: { type: "spki", format: "pem" },
1735
1919
  privateKeyEncoding: { type: "pkcs8", format: "pem" }
1736
1920
  });
1737
- next = { ...next, encryptionPublicKey: publicKey, encryptionPrivateKey: privateKey };
1921
+ encryptionPublicKey = generated.publicKey;
1922
+ encryptionPrivateKey = generated.privateKey;
1738
1923
  }
1739
- return next;
1924
+ return { publicKey, privateKey, encryptionPublicKey, encryptionPrivateKey };
1740
1925
  }
1741
1926
  const deviceAuth = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
1742
1927
  __proto__: null,
1743
1928
  activateStoredDevice,
1744
1929
  configureAndActivateDevice,
1930
+ ensureDeviceKeyMaterial,
1745
1931
  getDeviceIceServers
1746
1932
  }, Symbol.toStringTag, { value: "Module" }));
1747
1933
  function defaultAccountServerURL(relayURL = configuredRelayURL()) {
@@ -1767,11 +1953,16 @@ async function bindDesktopPetToWebSession(server, accessToken) {
1767
1953
  if (!accessToken) throw new Error("网页没有返回有效的登录会话");
1768
1954
  const session = await get(serverUrl, "/api/auth/me", accessToken);
1769
1955
  if (!session.user?.username) throw new Error("服务器没有返回有效的账号信息");
1956
+ const previousCredentials = reusableCredentials(serverUrl, session.user.username);
1957
+ const keyMaterial = ensureDeviceKeyMaterial(previousCredentials);
1770
1958
  const device = await post(
1771
1959
  serverUrl,
1772
1960
  "/api/devices",
1773
1961
  {
1774
1962
  name: `Desktop Pet · ${node_os.hostname()}`,
1963
+ installationId: await installationID(),
1964
+ publicKey: keyMaterial.publicKey,
1965
+ encryptionPublicKey: keyMaterial.encryptionPublicKey,
1775
1966
  capabilities: {
1776
1967
  agentHost: process.env.DESKTOP_PET_AGENT_HOST || "unknown-mcp-host",
1777
1968
  mcp: { tools: true, sampling: false, roots: true, tasks: true },
@@ -1786,10 +1977,20 @@ async function bindDesktopPetToWebSession(server, accessToken) {
1786
1977
  serverUrl,
1787
1978
  userId: session.user.username,
1788
1979
  deviceId: device.device.id,
1789
- refreshToken: device.refreshToken
1980
+ refreshToken: device.refreshToken,
1981
+ ...keyMaterial
1790
1982
  });
1791
1983
  return { username: session.user.username };
1792
1984
  }
1985
+ function reusableCredentials(serverUrl, username) {
1986
+ try {
1987
+ const credentials = loadDeviceCredentials();
1988
+ if (!credentials || credentials.userId.toLowerCase() !== username.toLowerCase()) return void 0;
1989
+ return normalizeServerURL(credentials.serverUrl) === serverUrl ? credentials : void 0;
1990
+ } catch {
1991
+ return void 0;
1992
+ }
1993
+ }
1793
1994
  function normalizeServerURL(value) {
1794
1995
  let url;
1795
1996
  try {
@@ -2203,7 +2404,7 @@ async function checkForUpdates(currentVersion = electron.app.getVersion()) {
2203
2404
  endpoint.searchParams.set("arch", process.arch);
2204
2405
  endpoint.searchParams.set("currentVersion", currentVersion);
2205
2406
  endpoint.searchParams.set("channel", updateChannel());
2206
- endpoint.searchParams.set("installationId", await updateInstallationID());
2407
+ endpoint.searchParams.set("installationId", await installationID());
2207
2408
  const response = await electron.net.fetch(endpoint.toString(), {
2208
2409
  headers: { accept: "application/json" },
2209
2410
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
@@ -2702,34 +2903,6 @@ function updateChannel() {
2702
2903
  const channel = process.env.DESKTOP_PET_UPDATE_CHANNEL?.trim() || "stable";
2703
2904
  return /^[a-z][a-z0-9-]{0,31}$/.test(channel) ? channel : "stable";
2704
2905
  }
2705
- let cachedInstallationID;
2706
- async function updateInstallationID() {
2707
- if (cachedInstallationID) return cachedInstallationID;
2708
- const path2 = node_path.join(electron.app.getPath("userData"), "update-installation-id");
2709
- let replaceInvalidFile = false;
2710
- try {
2711
- const stored = (await promises.readFile(path2, "utf8")).trim();
2712
- if (/^[0-9a-f-]{36}$/i.test(stored)) {
2713
- cachedInstallationID = stored;
2714
- return stored;
2715
- }
2716
- replaceInvalidFile = true;
2717
- } catch (error) {
2718
- if (error.code !== "ENOENT") {
2719
- logger.warn("could not read update installation id", { error: String(error) });
2720
- }
2721
- }
2722
- const created = node_crypto.randomUUID();
2723
- await promises.mkdir(electron.app.getPath("userData"), { recursive: true });
2724
- try {
2725
- await promises.writeFile(path2, created, { flag: replaceInvalidFile ? "w" : "wx", mode: 384 });
2726
- cachedInstallationID = created;
2727
- } catch (error) {
2728
- if (error.code !== "EEXIST") throw error;
2729
- cachedInstallationID = (await promises.readFile(path2, "utf8")).trim();
2730
- }
2731
- return cachedInstallationID;
2732
- }
2733
2906
  function asString(value) {
2734
2907
  return typeof value === "string" ? value.trim() : "";
2735
2908
  }
@@ -2767,6 +2940,7 @@ function humanizeUpdateError(error) {
2767
2940
  return "暂时无法检查更新";
2768
2941
  }
2769
2942
  const SKINS = SKIN_IDS.map((id) => ({ id, label: SKIN_LABELS[id] }));
2943
+ const collaborationSnapshot = new BackgroundSnapshot();
2770
2944
  let nextId = 1;
2771
2945
  function createPetWindow(skin, options) {
2772
2946
  if (petManager.count() >= MAX_PETS) {
@@ -2780,6 +2954,7 @@ function createPetWindow(skin, options) {
2780
2954
  const initialSize = PET_WINDOW_BY_SKIN[skin] ?? PET_WINDOW;
2781
2955
  let W = initialSize.width;
2782
2956
  let H = initialSize.height;
2957
+ let menuOpen = false;
2783
2958
  const x = options.near?.x ?? workArea.x + workArea.width - W - 120;
2784
2959
  const y = options.near?.y ?? workArea.y + workArea.height - H - 60;
2785
2960
  const win = new electron.BrowserWindow({
@@ -2815,6 +2990,7 @@ function createPetWindow(skin, options) {
2815
2990
  const presentation = publishIdentity();
2816
2991
  dispatch({ type: "bubble", text: presentation.greeting }, id);
2817
2992
  petManager.replayActivity(id);
2993
+ refreshCollaborationSnapshot();
2818
2994
  });
2819
2995
  const publishIdentity = () => {
2820
2996
  const presentation = presentPetIdentity(identity, currentAccountUsername());
@@ -2832,17 +3008,30 @@ function createPetWindow(skin, options) {
2832
3008
  }
2833
3009
  };
2834
3010
  const onMenu = (e) => {
2835
- if (win.isDestroyed() || e.sender !== win.webContents) return;
2836
- void showPetMenu();
3011
+ if (win.isDestroyed() || e.sender !== win.webContents || menuOpen) return;
3012
+ showPetMenu();
2837
3013
  };
2838
3014
  const onMousePassthrough = (e, enabled) => {
2839
3015
  if (win.isDestroyed() || e.sender !== win.webContents || typeof enabled !== "boolean") return;
2840
3016
  win.setIgnoreMouseEvents(enabled, { forward: enabled });
2841
3017
  };
2842
- const showPetMenu = async () => {
3018
+ const refreshCollaborationSnapshot = () => {
3019
+ const username = currentAccountUsername();
3020
+ if (!username) {
3021
+ collaborationSnapshot.clear();
3022
+ return;
3023
+ }
3024
+ void collaborationSnapshot.refresh(
3025
+ username,
3026
+ getCollaborationState,
3027
+ MENU_REFRESH_INTERVAL_MS
3028
+ );
3029
+ };
3030
+ const showPetMenu = () => {
2843
3031
  const accountUsername = currentAccountUsername();
2844
3032
  const identityPresentation = publishIdentity();
2845
- const collaboration = accountUsername ? await loadCollaborationState() : void 0;
3033
+ const collaboration = accountUsername ? collaborationSnapshot.read(accountUsername) : void 0;
3034
+ refreshCollaborationSnapshot();
2846
3035
  if (win.isDestroyed()) return;
2847
3036
  const incoming = collaboration?.helpRequests.filter(
2848
3037
  (request) => request.toUsername === accountUsername && request.status === "pending"
@@ -2894,7 +3083,7 @@ function createPetWindow(skin, options) {
2894
3083
  enabled: Boolean(accountUsername),
2895
3084
  submenu: collaboration ? collaborationMenuItems(collaboration.tasks) : [
2896
3085
  {
2897
- label: petManager.getLocalAgentConnectionStatus() === "update-required" ? "本机 Agent 需要重连以加载新版 MCP" : petManager.isLocalOnDuty() ? "本机正在驻守 · 服务器状态暂不可用" : "暂时无法读取协作状态",
3086
+ label: petManager.getLocalAgentConnectionStatus() === "update-required" ? "本机 Agent 需要重连以加载新版 MCP" : isRelayAuthenticationBlocked() ? "账号连接已失效,请重新登录" : petManager.isLocalOnDuty() ? "本机正在驻守 · 服务器状态暂不可用" : "正在刷新协作状态,请稍后再打开菜单",
2898
3087
  enabled: false
2899
3088
  }
2900
3089
  ]
@@ -2902,7 +3091,10 @@ function createPetWindow(skin, options) {
2902
3091
  {
2903
3092
  label: `好友状态${collaboration ? `(${collaboration.friends.length})` : ""}`,
2904
3093
  enabled: Boolean(accountUsername),
2905
- submenu: collaboration ? friendItems.length > 0 ? friendItems : [{ label: "还没有好友,请先到个人后台添加", enabled: false }] : [{ label: accountUsername ? "暂时无法连接服务器" : "请先登录账号", enabled: false }]
3094
+ submenu: collaboration ? friendItems.length > 0 ? friendItems : [{ label: "还没有好友,请先到个人后台添加", enabled: false }] : [{
3095
+ label: accountUsername ? isRelayAuthenticationBlocked() ? "账号认证已失效,请点击上方账号重新登录" : "正在刷新好友状态,请稍后再打开菜单" : "请先登录账号",
3096
+ enabled: false
3097
+ }]
2906
3098
  },
2907
3099
  {
2908
3100
  label: `要我帮忙${incoming.length ? `(${incoming.length})` : ""}`,
@@ -2946,6 +3138,10 @@ function createPetWindow(skin, options) {
2946
3138
  ...canSendAwayPet(identity) ? [{ label: "送走这只宠物", click: () => win.close() }] : [],
2947
3139
  { label: "退出", click: () => electron.app.quit() }
2948
3140
  ]);
3141
+ menuOpen = true;
3142
+ menu.once("menu-will-close", () => {
3143
+ menuOpen = false;
3144
+ });
2949
3145
  menu.popup({ window: win });
2950
3146
  };
2951
3147
  electron.ipcMain.on(PET_MOVE, onMove);
@@ -3031,16 +3227,6 @@ function updateMenuItems(petId) {
3031
3227
  }
3032
3228
  return items;
3033
3229
  }
3034
- async function loadCollaborationState() {
3035
- try {
3036
- return await Promise.race([
3037
- getCollaborationState(),
3038
- new Promise((resolve) => setTimeout(() => resolve(void 0), 1800))
3039
- ]);
3040
- } catch {
3041
- return void 0;
3042
- }
3043
- }
3044
3230
  function friendMenuItem(friend, state, currentUsername, petId) {
3045
3231
  const activeTask = state.tasks.filter((task) => isActiveTask(task) && taskPeer(task).toLowerCase() === friend.username.toLowerCase()).sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0];
3046
3232
  const outboundHelp = state.helpRequests.find(
@@ -3050,13 +3236,14 @@ function friendMenuItem(friend, state, currentUsername, petId) {
3050
3236
  (request) => request.status === "accepted" && (request.fromUsername === currentUsername && request.toUsername === friend.username || request.fromUsername === friend.username && request.toUsername === currentUsername)
3051
3237
  );
3052
3238
  const helpLabel = outboundHelp?.status === "accepted" ? " · TA 已同意帮我" : outboundHelp?.status === "pending" ? " · 求助待 TA 接受" : activeHelp ? " · 我正在帮 TA" : "";
3053
- const availability = friend.agentStatus === "update-required" ? " · Agent 需重连" : activeTask ? " · " + taskActivity(activeTask).label.split(" · ").at(-1) : friend.duty ? " · 已驻守" : friend.online && friend.agentStatus === "disconnected" ? " · Agent 未连接" : friend.online ? " · 在线,未驻守" : " · 离线";
3239
+ const friendOnDuty = resolveFriendDuty(friend.duty, petManager.getRemoteDuty(friend.username));
3240
+ const availability = friend.agentStatus === "update-required" ? " · Agent 需重连" : activeTask ? " · " + taskActivity(activeTask).label.split(" · ").at(-1) : friendOnDuty ? " · 已驻守" : friend.online && friend.agentStatus === "disconnected" ? " · Agent 未连接" : friend.online ? " · 在线,未驻守" : " · 离线";
3054
3241
  const chatMessages = ["你好呀!", "现在方便聊聊吗?", "谢谢你的帮助!"];
3055
3242
  return {
3056
3243
  label: friend.displayName + " (@" + friend.username + ")" + availability + helpLabel,
3057
3244
  submenu: [
3058
3245
  {
3059
- label: friend.agentStatus === "update-required" ? "Desktop Pet 已更新,请 TA 重启或重新加载 Agent 的 MCP 连接" : activeTask ? "当前任务:" + taskActivity(activeTask).detail : friend.duty ? "Agent 正在驻守,等待好友任务" : friend.online && friend.agentStatus === "disconnected" ? "桌宠在线,但 Agent 尚未连接 MCP" : friend.online ? "桌宠在线,但 Agent 尚未驻守" : "桌宠当前离线",
3246
+ label: friend.agentStatus === "update-required" ? "Desktop Pet 已更新,请 TA 重启或重新加载 Agent 的 MCP 连接" : activeTask ? "当前任务:" + taskActivity(activeTask).detail : friendOnDuty ? "Agent 正在驻守,等待好友任务" : friend.online && friend.agentStatus === "disconnected" ? "桌宠在线,但 Agent 尚未连接 MCP" : friend.online ? "桌宠在线,但 Agent 尚未驻守" : "桌宠当前离线",
3060
3247
  enabled: false
3061
3248
  },
3062
3249
  { type: "separator" },
@@ -3110,17 +3297,17 @@ function collaborationMenuItems(tasks) {
3110
3297
  return [
3111
3298
  duty,
3112
3299
  { type: "separator" },
3113
- ...tasks.slice(0, 12).map((task) => {
3300
+ ...menuTasks(tasks).map((task) => {
3114
3301
  const view = taskActivity(task);
3115
3302
  const measured = view.current != null && view.total != null ? " · " + view.current + "/" + view.total : "";
3116
- const summary = task.result?.summary?.replace(/\s+/g, " ").slice(0, 80);
3303
+ const summary = task.result?.summary ? compactMenuText(task.result.summary, 80) : void 0;
3117
3304
  return {
3118
3305
  label: taskMenuLabel(task),
3119
3306
  submenu: [
3120
3307
  { label: "状态:" + view.label + measured, enabled: false },
3121
- { label: "当前步骤:" + view.detail, enabled: false },
3308
+ { label: "当前步骤:" + compactMenuText(view.detail, 120), enabled: false },
3122
3309
  { label: "协作好友:@" + taskPeer(task), enabled: false },
3123
- { label: "任务:" + taskTitle(task).replace(/\s+/g, " ").slice(0, 90), enabled: false },
3310
+ { label: "任务:" + compactMenuText(taskTitle(task), 90), enabled: false },
3124
3311
  { label: "最近更新:" + new Date(task.updatedAt).toLocaleString(), enabled: false },
3125
3312
  ...summary ? [{ label: "结果:" + summary, enabled: false }] : []
3126
3313
  ]
@@ -3540,6 +3727,7 @@ async function handleRelay(cmd, res) {
3540
3727
  },
3541
3728
  executionPolicy: {
3542
3729
  capabilities: ["filesystem.read"],
3730
+ capabilityMode: "ignore",
3543
3731
  writableScopes: [],
3544
3732
  networkPolicy: "ask",
3545
3733
  approvalMode: "manual",
@@ -3702,7 +3890,7 @@ async function handleRelay(cmd, res) {
3702
3890
  fail(res, 400, "register_mcp_client requires agentHost", ErrorCodes.BAD_EVENT);
3703
3891
  return;
3704
3892
  }
3705
- await registerMcpClient(agentHost, cmd.mcpCapabilities ?? {});
3893
+ await registerMcpClient(agentHost, cmd.mcpCapabilities ?? {}, cmd.executionCapabilities);
3706
3894
  ok(res);
3707
3895
  return;
3708
3896
  }
@@ -4129,8 +4317,8 @@ electron.app.whenReady().then(async () => {
4129
4317
  return false;
4130
4318
  });
4131
4319
  logger.info("device activation finished", { activated });
4132
- if (!activated) startRelay();
4133
- logger.info("relay start requested", { authenticated: activated });
4320
+ if (activated) logger.info("relay start requested", { authenticated: true });
4321
+ else logger.info("relay waiting for account authentication");
4134
4322
  if (process.env.DESKTOP_PET_P2P !== "0") startP2PWindow(getDeviceIceServers());
4135
4323
  logger.info("p2p start requested", { enabled: process.env.DESKTOP_PET_P2P !== "0" });
4136
4324
  void startRunnerManager();