desktop-pet-app 0.3.5 → 0.3.24

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
@@ -80,7 +80,17 @@ function writeMainLog(line) {
80
80
  }
81
81
  }
82
82
  const logger = new Logger(levelFromEnv(), writeMainLog);
83
- const PORT_FILE = process.env.DESKTOP_PET_PORT_FILE?.trim() || path.join(os.homedir(), ".desktop-pet-port");
83
+ function resolvePortFile(env = process.env, homeDirectory = os.homedir()) {
84
+ const explicit = env.DESKTOP_PET_PORT_FILE?.trim();
85
+ if (explicit) return path.resolve(explicit);
86
+ const profile = env.DESKTOP_PET_PROFILE?.trim();
87
+ if (profile) return `${path.resolve(profile)}.port`;
88
+ if (env.NODE_ENV_ELECTRON_VITE === "development") {
89
+ return path.join(homeDirectory, ".desktop-pet-development.port");
90
+ }
91
+ return path.join(homeDirectory, ".desktop-pet-port");
92
+ }
93
+ const PORT_FILE = resolvePortFile();
84
94
  const LOCAL_PET_PROTOCOL_VERSION = "1.0";
85
95
  const LOCAL_PET_MAX_BODY_BYTES = 64 * 1024;
86
96
  const MAX_PETS = 4;
@@ -345,6 +355,17 @@ function center(bounds) {
345
355
  return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
346
356
  }
347
357
  const petManager = new PetManager();
358
+ const OFFICIAL_SERVER_ROOT = "https://api.xinbit.top/desktop-pet";
359
+ const OFFICIAL_PORTAL_ORIGIN = "https://deskpalai.com";
360
+ const LEGACY_OFFICIAL_SERVER_HOSTS = "47.94.20.104".split(",").map((host) => host.trim()).filter(Boolean);
361
+ function isLegacyOfficialServerURL(value) {
362
+ try {
363
+ const url = new URL(value);
364
+ return url.protocol === "http:" && LEGACY_OFFICIAL_SERVER_HOSTS.includes(url.hostname);
365
+ } catch {
366
+ return false;
367
+ }
368
+ }
348
369
  function credentialPath() {
349
370
  return node_path.join(electron.app.getPath("userData"), "device-credentials.enc");
350
371
  }
@@ -361,10 +382,18 @@ function loadDeviceCredentials() {
361
382
  const path2 = credentialPath();
362
383
  if (!node_fs.existsSync(path2)) return void 0;
363
384
  if (!electron.safeStorage.isEncryptionAvailable()) return void 0;
364
- const credentials = JSON.parse(electron.safeStorage.decryptString(node_fs.readFileSync(path2)));
385
+ const credentials = migrateLegacyOfficialServer(
386
+ JSON.parse(electron.safeStorage.decryptString(node_fs.readFileSync(path2)))
387
+ );
365
388
  validate(credentials);
366
389
  return credentials;
367
390
  }
391
+ function migrateLegacyOfficialServer(credentials) {
392
+ if (isLegacyOfficialServerURL(credentials.serverUrl)) {
393
+ return { ...credentials, serverUrl: OFFICIAL_SERVER_ROOT };
394
+ }
395
+ return credentials;
396
+ }
368
397
  function clearDeviceCredentials() {
369
398
  node_fs.rmSync(credentialPath(), { force: true });
370
399
  }
@@ -377,11 +406,39 @@ function validate(credentials) {
377
406
  throw new Error("设备配置的 serverUrl 必须使用 http 或 https");
378
407
  }
379
408
  }
380
- const DEFAULT_DESKTOP_PET_HOST = "https://deskpalai.com";
381
- function configuredRelayURL() {
382
- const host = process.env.DESKTOP_PET_HOST?.trim();
383
- if (host) return relayURLFromHost(host);
384
- return process.env.DESKTOP_PET_SERVER?.trim() || relayURLFromHost(DEFAULT_DESKTOP_PET_HOST);
409
+ function configuredRelayURL(options = {}) {
410
+ if (options.allowRuntimeOverrides !== false) {
411
+ const host = process.env.DESKTOP_PET_HOST?.trim();
412
+ if (host) return relayURLFromHost(host);
413
+ const server = process.env.DESKTOP_PET_SERVER?.trim();
414
+ if (server) return server;
415
+ }
416
+ return relayURLFromHost(OFFICIAL_SERVER_ROOT);
417
+ }
418
+ function hasRuntimeServerOverride(options = {}) {
419
+ return options.allowRuntimeOverrides !== false && Boolean(
420
+ process.env.DESKTOP_PET_HOST?.trim() || process.env.DESKTOP_PET_SERVER?.trim()
421
+ );
422
+ }
423
+ function relayHTTPRootURL(relayURL = configuredRelayURL()) {
424
+ const url = new URL(relayURL);
425
+ if (url.protocol === "wss:") url.protocol = "https:";
426
+ else if (url.protocol === "ws:") url.protocol = "http:";
427
+ else throw new Error("中转地址必须使用 ws:// 或 wss://");
428
+ url.pathname = url.pathname.replace(/\/ws\/?$/, "").replace(/\/+$/, "");
429
+ url.search = "";
430
+ url.hash = "";
431
+ return url.toString().replace(/\/$/, "");
432
+ }
433
+ function canonicalHTTPRootURL(value) {
434
+ const url = new URL(value);
435
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
436
+ throw new Error("设备服务器地址必须使用 http:// 或 https://");
437
+ }
438
+ url.pathname = url.pathname.replace(/\/+$/, "");
439
+ url.search = "";
440
+ url.hash = "";
441
+ return url.toString().replace(/\/$/, "");
385
442
  }
386
443
  function relayURLFromHost(value) {
387
444
  const source = value.includes("://") ? value : `${isLocalHost(value) ? "http" : "https"}://${value}`;
@@ -396,7 +453,7 @@ function relayURLFromHost(value) {
396
453
  if (url.protocol !== "ws:" && url.protocol !== "wss:") {
397
454
  throw new Error("DESKTOP_PET_HOST 必须使用 http:// 或 https://");
398
455
  }
399
- url.pathname = "/ws";
456
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/ws`;
400
457
  url.search = "";
401
458
  url.hash = "";
402
459
  return url.toString();
@@ -405,9 +462,20 @@ function isLocalHost(value) {
405
462
  return /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/i.test(value.trim());
406
463
  }
407
464
  const TASK_PROTOCOL_VERSION = "1.0";
465
+ const WORKSPACE_SNAPSHOT_MIME = "application/vnd.desktop-pet.git-workspace-bundle";
466
+ function taskParticipantRole(task, authenticatedUserId) {
467
+ const current = authenticatedUserId.trim().toLowerCase();
468
+ if (!current) return void 0;
469
+ if (task.envelope.from.userId.trim().toLowerCase() === current) return "requester";
470
+ if (task.envelope.to.userId.trim().toLowerCase() === current) return "executor";
471
+ return void 0;
472
+ }
408
473
  const sensitivePathPatterns = [
409
474
  /(^|\/)\.env(?:\.|$)/i,
475
+ /(^|\/)(?:\.npmrc|\.pypirc|\.netrc)(?:$|\/)/i,
476
+ /(^|\/)(?:\.aws|\.azure|\.kube)(?:$|\/)/i,
410
477
  /(^|\/)(?:id_rsa|id_ed25519|known_hosts|credentials)(?:$|\/)/i,
478
+ /(?:^|\/)[^/]+\.(?:pem|key|p12|pfx|keystore)(?:$|\/)/i,
411
479
  /(^|\/)\.ssh(?:$|\/)/i,
412
480
  /(^|\/)\.gnupg(?:$|\/)/i,
413
481
  /(^|\/)node_modules(?:$|\/)/i,
@@ -459,6 +527,9 @@ function inlineResultArtifact(input) {
459
527
  inlineText: input.text
460
528
  };
461
529
  }
530
+ function isWorkspaceSnapshotContextItem(item) {
531
+ return item.kind === "file" && item.mimeType === WORKSPACE_SNAPSHOT_MIME && item.encoding === "binary" && item.transport === "relay-blob" && item.required && Boolean(item.artifactRef);
532
+ }
462
533
  function isTerminalTaskStatus(status2) {
463
534
  return status2 === "completed" || status2 === "failed" || status2 === "cancelled" || status2 === "expired";
464
535
  }
@@ -478,6 +549,37 @@ function showDesktopNotification(title, body) {
478
549
  if (!electron.Notification.isSupported()) return;
479
550
  new electron.Notification({ title, body }).show();
480
551
  }
552
+ const PRODUCT_DATA_DIRECTORY = "Desktop Pet";
553
+ const DEVELOPMENT_DATA_DIRECTORY = "Desktop Pet Development";
554
+ const LEGACY_NPM_DATA_DIRECTORY = "desktop-pet-app";
555
+ function developmentRuntimeOverridesAllowed() {
556
+ return !electron.app?.isPackaged || Boolean(process.env.DESKTOP_PET_PROFILE?.trim());
557
+ }
558
+ function configureRuntimeIdentity() {
559
+ const profileDirectory = process.env.DESKTOP_PET_PROFILE?.trim();
560
+ if (profileDirectory) {
561
+ electron.app.setPath("userData", node_path.resolve(profileDirectory));
562
+ return;
563
+ }
564
+ const appDataDirectory = electron.app.getPath("appData");
565
+ if (electron.app.isPackaged || process.env.DESKTOP_PET_NPM_DISTRIBUTION === "1") {
566
+ electron.app.setPath("userData", node_path.join(appDataDirectory, PRODUCT_DATA_DIRECTORY));
567
+ return;
568
+ }
569
+ const developmentDirectory = node_path.join(appDataDirectory, DEVELOPMENT_DATA_DIRECTORY);
570
+ migrateLegacyData(node_path.join(appDataDirectory, LEGACY_NPM_DATA_DIRECTORY), developmentDirectory);
571
+ electron.app.setPath("userData", developmentDirectory);
572
+ }
573
+ function migrateLegacyData(legacyDirectory, canonicalDirectory) {
574
+ if (!node_fs.existsSync(legacyDirectory) || node_fs.existsSync(canonicalDirectory)) return false;
575
+ try {
576
+ node_fs.renameSync(legacyDirectory, canonicalDirectory);
577
+ return true;
578
+ } catch (error) {
579
+ console.warn(`无法迁移 Desktop Pet 用户数据:${String(error)}`);
580
+ return false;
581
+ }
582
+ }
481
583
  function p2pSigningPayload(message, fromDeviceId) {
482
584
  const signalJSON = JSON.stringify(message.signal ?? null);
483
585
  const signalHash = node_crypto.createHash("sha256").update(signalJSON).digest("hex");
@@ -504,11 +606,12 @@ function verifyP2PSignal(message) {
504
606
  return false;
505
607
  }
506
608
  }
507
- let serverURL = configuredRelayURL();
508
- let userID = process.env.DESKTOP_PET_USER ?? os.hostname();
609
+ const allowDevelopmentOverrides = developmentRuntimeOverridesAllowed();
610
+ let serverURL = configuredRelayURL({ allowRuntimeOverrides: allowDevelopmentOverrides });
611
+ let userID = allowDevelopmentOverrides ? process.env.DESKTOP_PET_USER ?? os.hostname() : os.hostname();
509
612
  const SKIN = process.env.DESKTOP_PET_SKIN ?? "";
510
- let authToken = process.env.DESKTOP_PET_TOKEN?.trim();
511
- let deviceID = process.env.DESKTOP_PET_DEVICE_ID?.trim() || "";
613
+ let authToken = allowDevelopmentOverrides ? process.env.DESKTOP_PET_TOKEN?.trim() : void 0;
614
+ let deviceID = allowDevelopmentOverrides ? process.env.DESKTOP_PET_DEVICE_ID?.trim() || "" : "";
512
615
  let signingPrivateKey = "";
513
616
  const pendingTasks = /* @__PURE__ */ new Map();
514
617
  const pendingAnswers = /* @__PURE__ */ new Map();
@@ -523,13 +626,41 @@ let ws = null;
523
626
  let connectPromise = null;
524
627
  let intentionalClose = false;
525
628
  let protocolV1Available = false;
629
+ let workspaceSnapshotStagingAvailable = false;
526
630
  let mcpRegistration;
631
+ const RETRY_BASE_DELAY_MS = 3e3;
632
+ const RETRY_MAX_DELAY_MS = 5 * 6e4;
633
+ let reconnectTimer = null;
634
+ let retryAttempt = 0;
635
+ function scheduleRelayReconnect() {
636
+ if (reconnectTimer) return;
637
+ const delay = Math.min(RETRY_BASE_DELAY_MS * 2 ** Math.min(retryAttempt, 10), RETRY_MAX_DELAY_MS);
638
+ retryAttempt += 1;
639
+ logger.info("relay reconnect scheduled", { attempt: retryAttempt, delayMs: delay });
640
+ reconnectTimer = setTimeout(() => {
641
+ reconnectTimer = null;
642
+ void ensureConnected().catch(() => {
643
+ });
644
+ }, delay);
645
+ }
646
+ function cancelRelayReconnect() {
647
+ if (reconnectTimer) {
648
+ clearTimeout(reconnectTimer);
649
+ reconnectTimer = null;
650
+ }
651
+ }
527
652
  function getAuthenticatedRelayUserId() {
528
653
  return authToken ? userID : void 0;
529
654
  }
655
+ function getPresentedRelayUserId() {
656
+ return authToken || allowDevelopmentOverrides && process.env.DESKTOP_PET_USER?.trim() ? userID : void 0;
657
+ }
530
658
  function isProtocolV1Available() {
531
659
  return protocolV1Available;
532
660
  }
661
+ function isWorkspaceSnapshotStagingAvailable() {
662
+ return protocolV1Available && workspaceSnapshotStagingAvailable;
663
+ }
533
664
  function getRelayHTTPAuth() {
534
665
  if (!authToken) throw new Error("relay device access token is unavailable");
535
666
  const url = new URL(serverURL);
@@ -547,6 +678,14 @@ function configureRelayIdentity(config) {
547
678
  authToken = config.accessToken;
548
679
  signingPrivateKey = config.signingPrivateKey ?? "";
549
680
  }
681
+ function clearRelayIdentity() {
682
+ if (ws || connectPromise) throw new Error("stop relay before clearing identity");
683
+ serverURL = configuredRelayURL({ allowRuntimeOverrides: allowDevelopmentOverrides });
684
+ userID = allowDevelopmentOverrides ? process.env.DESKTOP_PET_USER ?? os.hostname() : os.hostname();
685
+ deviceID = allowDevelopmentOverrides ? process.env.DESKTOP_PET_DEVICE_ID?.trim() || "" : "";
686
+ authToken = allowDevelopmentOverrides ? process.env.DESKTOP_PET_TOKEN?.trim() : void 0;
687
+ signingPrivateKey = "";
688
+ }
550
689
  async function registerMcpClient(agentHost, capabilities = {}) {
551
690
  mcpRegistration = { agentHost, capabilities };
552
691
  await ensureConnected();
@@ -561,6 +700,7 @@ async function registerMcpClient(agentHost, capabilities = {}) {
561
700
  }
562
701
  function startRelay() {
563
702
  intentionalClose = false;
703
+ retryAttempt = 0;
564
704
  void ensureConnected().catch((err) => {
565
705
  logger.warn("relay connect failed on startup", { error: String(err) });
566
706
  });
@@ -568,6 +708,9 @@ function startRelay() {
568
708
  function stopRelay() {
569
709
  intentionalClose = true;
570
710
  protocolV1Available = false;
711
+ workspaceSnapshotStagingAvailable = false;
712
+ cancelRelayReconnect();
713
+ retryAttempt = 0;
571
714
  connectPromise = null;
572
715
  if (ws) {
573
716
  ws.close();
@@ -611,6 +754,7 @@ function ensureConnected() {
611
754
  if (connectPromise) return connectPromise;
612
755
  connectPromise = new Promise((resolve, reject) => {
613
756
  let settled = false;
757
+ let authRejected = false;
614
758
  const sock = new WebSocket(serverURL, {
615
759
  ...authToken ? { headers: { Authorization: `Bearer ${authToken}` } } : {}
616
760
  });
@@ -634,8 +778,12 @@ function ensureConnected() {
634
778
  } catch {
635
779
  return;
636
780
  }
781
+ if (msg.task) msg.task = withParticipantRole(msg.task);
782
+ if (msg.tasks) msg.tasks = msg.tasks.map(withParticipantRole);
637
783
  if (msg.type === "registered") {
784
+ retryAttempt = 0;
638
785
  protocolV1Available = msg.protocolVersion === TASK_PROTOCOL_VERSION;
786
+ workspaceSnapshotStagingAvailable = msg.capabilities?.workspaceSnapshotStaging === true;
639
787
  logger.info("registered to relay", { userId: userID, server: serverURL });
640
788
  if (!settled) {
641
789
  settled = true;
@@ -729,6 +877,7 @@ function ensureConnected() {
729
877
  }
730
878
  });
731
879
  sock.on("error", (err) => {
880
+ if (/Unexpected server response: 40\d/.test(String(err))) authRejected = true;
732
881
  logger.warn("relay socket error", { error: String(err) });
733
882
  if (!settled) {
734
883
  settled = true;
@@ -755,10 +904,14 @@ function ensureConnected() {
755
904
  reject(new Error(`relay connection closed before registration (${code})`));
756
905
  }
757
906
  if (isCurrentSocket && !intentionalClose) {
758
- setTimeout(() => {
759
- void ensureConnected().catch(() => {
907
+ if (authRejected) {
908
+ logger.warn("relay rejected by server authentication; auto reconnect paused until identity changes", {
909
+ server: serverURL,
910
+ authenticated: Boolean(authToken)
760
911
  });
761
- }, 3e3);
912
+ } else {
913
+ scheduleRelayReconnect();
914
+ }
762
915
  }
763
916
  });
764
917
  });
@@ -887,6 +1040,14 @@ async function delegateTask(envelope) {
887
1040
  }
888
1041
  return { task: response.task, warning: response.warning };
889
1042
  }
1043
+ async function finalizeRemoteTaskWorkspace(taskId) {
1044
+ const response = await relayRPC(
1045
+ { type: "task.workspace.ready", protocolVersion: TASK_PROTOCOL_VERSION, taskId },
1046
+ 15e3
1047
+ );
1048
+ if (!response.task) throw new Error("relay did not return the finalized task");
1049
+ return { task: response.task, warning: response.warning };
1050
+ }
890
1051
  function relayCapabilities() {
891
1052
  return {
892
1053
  agentHost: mcpRegistration?.agentHost ?? (process.env.DESKTOP_PET_AGENT_HOST || "desktop-pet-mcp"),
@@ -1095,6 +1256,9 @@ function stopLease(taskId) {
1095
1256
  if (timer) clearInterval(timer);
1096
1257
  leaseTimers.delete(taskId);
1097
1258
  }
1259
+ function withParticipantRole(task) {
1260
+ return { ...task, participantRole: taskParticipantRole(task, userID) };
1261
+ }
1098
1262
  function onAsk(msg) {
1099
1263
  if (!msg.questionId || !msg.from || !msg.text) return;
1100
1264
  const task = {
@@ -1168,11 +1332,21 @@ function getDeviceIceServers() {
1168
1332
  return deviceIceServers;
1169
1333
  }
1170
1334
  async function activateStoredDevice() {
1171
- if (process.env.DESKTOP_PET_TOKEN?.trim()) {
1335
+ const allowRuntimeOverrides = developmentRuntimeOverridesAllowed();
1336
+ if (allowRuntimeOverrides && process.env.DESKTOP_PET_TOKEN?.trim()) {
1172
1337
  startRelay();
1173
1338
  return true;
1174
1339
  }
1175
1340
  const credentials = loadDeviceCredentials();
1341
+ const configuredServer = configuredRelayURL({ allowRuntimeOverrides });
1342
+ if (!allowRuntimeOverrides && credentials && canonicalHTTPRootURL(credentials.serverUrl) !== relayHTTPRootURL(configuredServer)) {
1343
+ logger.warn("stored device credentials belong to a different runtime environment");
1344
+ return false;
1345
+ }
1346
+ if (hasRuntimeServerOverride({ allowRuntimeOverrides }) && (!credentials || canonicalHTTPRootURL(credentials.serverUrl) !== relayHTTPRootURL(configuredServer))) {
1347
+ startRelay();
1348
+ return true;
1349
+ }
1176
1350
  if (!credentials) return false;
1177
1351
  await activateDevice(credentials, false);
1178
1352
  return true;
@@ -1215,7 +1389,7 @@ async function activateDevice(credentials, restart) {
1215
1389
  if (restart) stopRelay();
1216
1390
  const httpURL = new URL(base);
1217
1391
  httpURL.protocol = httpURL.protocol === "https:" ? "wss:" : "ws:";
1218
- httpURL.pathname = "/ws";
1392
+ httpURL.pathname = `${httpURL.pathname.replace(/\/+$/, "")}/ws`;
1219
1393
  configureRelayIdentity({
1220
1394
  serverUrl: httpURL.toString(),
1221
1395
  userId: credentials.userId,
@@ -1260,14 +1434,20 @@ const deviceAuth = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.definePr
1260
1434
  function defaultAccountServerURL(relayURL = configuredRelayURL()) {
1261
1435
  const url = new URL(relayURL);
1262
1436
  url.protocol = url.protocol === "wss:" ? "https:" : "http:";
1263
- url.pathname = "";
1437
+ url.pathname = url.pathname.replace(/\/ws$/, "");
1264
1438
  url.search = "";
1265
1439
  url.hash = "";
1266
1440
  return url.toString().replace(/\/$/, "");
1267
1441
  }
1268
- function defaultAccountPortalURL(serverUrl) {
1269
- const override = process.env.DESKTOP_PET_PORTAL_URL?.trim();
1270
- return override ? normalizeServerURL(override) : serverUrl;
1442
+ function defaultAccountPortalURL(allowRuntimeOverrides = true) {
1443
+ const override = allowRuntimeOverrides ? process.env.DESKTOP_PET_PORTAL_URL?.trim() : void 0;
1444
+ if (override) return normalizeServerURL(override);
1445
+ return hasRuntimeServerOverride({ allowRuntimeOverrides }) ? defaultAccountServerURL(configuredRelayURL({ allowRuntimeOverrides })) : OFFICIAL_PORTAL_ORIGIN;
1446
+ }
1447
+ function preferredAccountServerURL(storedServerURL2, allowRuntimeOverrides = true) {
1448
+ const configured = defaultAccountServerURL(configuredRelayURL({ allowRuntimeOverrides }));
1449
+ if (!allowRuntimeOverrides) return configured;
1450
+ return hasRuntimeServerOverride({ allowRuntimeOverrides }) ? configured : storedServerURL2 ?? configured;
1271
1451
  }
1272
1452
  async function bindDesktopPetToWebSession(server, accessToken) {
1273
1453
  const serverUrl = normalizeServerURL(server);
@@ -1307,7 +1487,7 @@ function normalizeServerURL(value) {
1307
1487
  if (url.protocol !== "http:" && url.protocol !== "https:") {
1308
1488
  throw new Error("中转服务器地址必须以 http:// 或 https:// 开头");
1309
1489
  }
1310
- url.pathname = "";
1490
+ url.pathname = url.pathname.replace(/\/+$/, "");
1311
1491
  url.search = "";
1312
1492
  url.hash = "";
1313
1493
  return url.toString().replace(/\/$/, "");
@@ -1408,10 +1588,11 @@ function stopAccountCallbackRouter() {
1408
1588
  callbackNonce = void 0;
1409
1589
  }
1410
1590
  function openAccountWindow(mode) {
1411
- const serverUrl = storedServerURL() ?? defaultAccountServerURL();
1591
+ const allowRuntimeOverrides = developmentRuntimeOverridesAllowed();
1592
+ const serverUrl = preferredAccountServerURL(storedServerURL(), allowRuntimeOverrides);
1412
1593
  let portalBase;
1413
1594
  try {
1414
- portalBase = defaultAccountPortalURL(serverUrl);
1595
+ portalBase = defaultAccountPortalURL(allowRuntimeOverrides);
1415
1596
  } catch (error) {
1416
1597
  electron.dialog.showErrorBox("门户地址无效", error instanceof Error ? error.message : "请检查 DESKTOP_PET_PORTAL_URL 配置");
1417
1598
  return;
@@ -1454,15 +1635,16 @@ function handleAccountCallback(url) {
1454
1635
  }
1455
1636
  function logoutDesktopPet() {
1456
1637
  stopRelay();
1638
+ clearRelayIdentity();
1457
1639
  clearDeviceCredentials();
1458
1640
  petManager.refreshLocalIdentityBadges();
1459
1641
  dispatch({ type: "bubble", text: "已退出当前账号" });
1460
1642
  }
1461
1643
  function currentAccountUsername() {
1462
1644
  try {
1463
- return loadDeviceCredentials()?.userId ?? getAuthenticatedRelayUserId();
1645
+ return getPresentedRelayUserId() ?? loadDeviceCredentials()?.userId ?? getAuthenticatedRelayUserId();
1464
1646
  } catch {
1465
- return getAuthenticatedRelayUserId();
1647
+ return getPresentedRelayUserId() ?? getAuthenticatedRelayUserId();
1466
1648
  }
1467
1649
  }
1468
1650
  function storedServerURL() {
@@ -1515,22 +1697,27 @@ async function finishWebAuthentication(callbackURL) {
1515
1697
  }
1516
1698
  }
1517
1699
  function copyMcpConfig() {
1518
- const serverEntry = path.join(electron.app.getAppPath(), "out", "mcp-server", "index.js");
1700
+ const serverEntry = resolveMcpServerEntry(electron.app.isPackaged, electron.app.getAppPath(), process.resourcesPath);
1519
1701
  if (!fs.existsSync(serverEntry)) {
1520
1702
  return { ok: false, message: "MCP server 尚未构建,请先在 app 目录执行 npm run build" };
1521
1703
  }
1522
- const customPortFile = process.env.DESKTOP_PET_PORT_FILE?.trim();
1523
- const config = {
1704
+ const config = createMcpConfig(serverEntry, PORT_FILE);
1705
+ electron.clipboard.writeText(JSON.stringify(config, null, 2));
1706
+ return { ok: true, message: "MCP 配置已复制,直接粘贴到 Agent 的 MCP 配置里即可" };
1707
+ }
1708
+ function createMcpConfig(serverEntry, portFile) {
1709
+ return {
1524
1710
  mcpServers: {
1525
1711
  "desktop-pet": {
1526
1712
  command: "node",
1527
1713
  args: [serverEntry],
1528
- ...customPortFile ? { env: { DESKTOP_PET_PORT_FILE: PORT_FILE } } : {}
1714
+ env: { DESKTOP_PET_PORT_FILE: portFile }
1529
1715
  }
1530
1716
  }
1531
1717
  };
1532
- electron.clipboard.writeText(JSON.stringify(config, null, 2));
1533
- return { ok: true, message: "MCP 配置已复制,直接粘贴到 Agent 的 MCP 配置里即可" };
1718
+ }
1719
+ function resolveMcpServerEntry(packaged, appPath, resourcesPath) {
1720
+ return packaged ? path.join(resourcesPath, "mcp-server", "index.js") : path.join(appPath, "out", "mcp-server", "index.js");
1534
1721
  }
1535
1722
  const P2P_CHAT_CONNECT_TIMEOUT_MS = 2500;
1536
1723
  let transportWindow;
@@ -1678,17 +1865,15 @@ async function fallbackChat(operationId) {
1678
1865
  }
1679
1866
  const REQUEST_TIMEOUT_MS = 6e3;
1680
1867
  const DOWNLOAD_TIMEOUT_MS = 10 * 6e4;
1681
- const LEGACY_OFFICIAL_HTTP_HOST = "47.94.20.104";
1868
+ const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024;
1682
1869
  const MAC_BUNDLE_ID = "com.xinshu.desktoppet";
1683
1870
  const MAC_INSTALLED_APP = "/Applications/Desktop Pet.app";
1684
1871
  let status = { kind: "idle" };
1685
1872
  function updateCheckURL() {
1686
- const override = process.env.DESKTOP_PET_UPDATE_API?.trim();
1873
+ const override = !electron.app.isPackaged ? process.env.DESKTOP_PET_UPDATE_API?.trim() : void 0;
1687
1874
  if (override) return validateUpdateAPIURL(override).toString();
1688
- const relayURL = updateRelayURL();
1689
- const url = new URL(relayURL);
1690
- url.protocol = url.protocol === "wss:" ? "https:" : "http:";
1691
- url.pathname = "/api/updates/check";
1875
+ const url = updateServiceRootURL(updateServiceURL());
1876
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/api/updates/check`;
1692
1877
  url.search = "";
1693
1878
  url.hash = "";
1694
1879
  return validateUpdateAPIURL(url.toString()).toString();
@@ -1704,8 +1889,10 @@ async function checkForUpdates(currentVersion = electron.app.getVersion()) {
1704
1889
  endpoint.searchParams.set("platform", process.platform);
1705
1890
  endpoint.searchParams.set("arch", process.arch);
1706
1891
  endpoint.searchParams.set("currentVersion", currentVersion);
1892
+ endpoint.searchParams.set("channel", updateChannel());
1893
+ endpoint.searchParams.set("installationId", await updateInstallationID());
1707
1894
  const response = await electron.net.fetch(endpoint.toString(), {
1708
- headers: updateCheckHeaders(endpoint),
1895
+ headers: { accept: "application/json" },
1709
1896
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1710
1897
  cache: "no-store"
1711
1898
  });
@@ -1714,6 +1901,7 @@ async function checkForUpdates(currentVersion = electron.app.getVersion()) {
1714
1901
  throw new Error(body.message || "更新服务器返回异常");
1715
1902
  }
1716
1903
  const update = parseUpdate(body.data.update);
1904
+ if (update) verifyManifestSignature(update);
1717
1905
  if (!update || compareVersions(update.version, currentVersion) <= 0) {
1718
1906
  status = { kind: "up-to-date", checkedAt: Date.now() };
1719
1907
  return status;
@@ -1734,20 +1922,13 @@ async function checkForUpdates(currentVersion = electron.app.getVersion()) {
1734
1922
  return status;
1735
1923
  }
1736
1924
  }
1737
- function updateCheckHeaders(endpoint) {
1738
- const headers = { accept: "application/json" };
1739
- try {
1740
- const auth = getRelayHTTPAuth();
1741
- if (new URL(auth.baseURL).origin === endpoint.origin) {
1742
- headers.authorization = `Bearer ${auth.accessToken}`;
1743
- }
1744
- } catch {
1745
- }
1746
- return headers;
1747
- }
1748
1925
  async function downloadAvailableUpdate() {
1749
1926
  const available = status.kind === "available" ? status.update : void 0;
1750
1927
  if (!available) return status;
1928
+ if (available.signature && (!available.expiresAt || Date.parse(available.expiresAt) < Date.now() - 6e4)) {
1929
+ status = { kind: "error", message: "更新信息已过期,请重新检查", checkedAt: Date.now() };
1930
+ return status;
1931
+ }
1751
1932
  status = { kind: "downloading", update: available, downloadedBytes: 0 };
1752
1933
  const updateDirectory = node_path.join(electron.app.getPath("userData"), "updates");
1753
1934
  const artifactPath = updateArtifactPath(available);
@@ -1755,7 +1936,7 @@ async function downloadAvailableUpdate() {
1755
1936
  try {
1756
1937
  await promises.mkdir(updateDirectory, { recursive: true });
1757
1938
  const response = await electron.net.fetch(available.releaseUrl, {
1758
- headers: { accept: "application/octet-stream, application/wasm, */*" },
1939
+ headers: { accept: "application/octet-stream, application/wasm, */*", "accept-encoding": "identity" },
1759
1940
  signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
1760
1941
  cache: "no-store",
1761
1942
  redirect: "follow"
@@ -1766,13 +1947,17 @@ async function downloadAvailableUpdate() {
1766
1947
  throw new Error("更新下载地址必须使用 HTTPS");
1767
1948
  }
1768
1949
  const totalBytes = parseContentLength(response.headers.get("content-length"));
1950
+ if (totalBytes && available.sizeBytes && totalBytes !== available.sizeBytes) {
1951
+ throw new Error("更新包大小与发布清单不一致");
1952
+ }
1769
1953
  let downloadedBytes = 0;
1770
1954
  status = { kind: "downloading", update: available, downloadedBytes, totalBytes };
1771
- const digest = await saveAndHash(response.body, temporaryPath, (chunkBytes) => {
1955
+ const saved = await saveAndHash(response.body, temporaryPath, available.sizeBytes, (chunkBytes) => {
1772
1956
  downloadedBytes += chunkBytes;
1773
1957
  status = { kind: "downloading", update: available, downloadedBytes, totalBytes };
1774
1958
  });
1775
- if (digest !== available.sha256) throw new Error("更新包校验失败,文件未安装");
1959
+ if (available.sizeBytes && saved.bytes !== available.sizeBytes) throw new Error("更新包大小与发布清单不一致");
1960
+ if (saved.sha256 !== available.sha256) throw new Error("更新包校验失败,文件未安装");
1776
1961
  await promises.rm(artifactPath, { force: true });
1777
1962
  await moveFile(temporaryPath, artifactPath);
1778
1963
  status = { kind: "downloaded", update: available, artifactPath, downloadedAt: Date.now() };
@@ -1843,10 +2028,16 @@ function sha256File(path2) {
1843
2028
  input.on("end", () => resolve(hash.digest("hex")));
1844
2029
  });
1845
2030
  }
1846
- async function saveAndHash(body, destination, onChunk) {
2031
+ async function saveAndHash(body, destination, expectedBytes, onChunk) {
1847
2032
  const hash = node_crypto.createHash("sha256");
2033
+ let bytes = 0;
1848
2034
  const meter = new node_stream.Transform({
1849
2035
  transform(chunk, _encoding, done) {
2036
+ bytes += chunk.length;
2037
+ if (bytes > (expectedBytes ?? MAX_ARTIFACT_BYTES)) {
2038
+ done(new Error("更新包超过发布清单声明的大小"));
2039
+ return;
2040
+ }
1850
2041
  hash.update(chunk);
1851
2042
  onChunk(chunk.length);
1852
2043
  done(null, chunk);
@@ -1857,7 +2048,7 @@ async function saveAndHash(body, destination, onChunk) {
1857
2048
  meter,
1858
2049
  node_fs.createWriteStream(destination, { flags: "wx" })
1859
2050
  );
1860
- return hash.digest("hex");
2051
+ return { sha256: hash.digest("hex"), bytes };
1861
2052
  }
1862
2053
  function parseContentLength(value) {
1863
2054
  if (!value) return void 0;
@@ -1895,12 +2086,50 @@ const { promisify } = require('node:util')
1895
2086
  const [, , parentPidText, sourcePath, expectedSHA256, platform, targetVersion, fallbackExecutable, fallbackArgsJSON] = process.argv
1896
2087
  const parentPid = Number(parentPidText)
1897
2088
  const fallbackArgs = JSON.parse(fallbackArgsJSON || '[]')
1898
- const run = promisify(execFile)
2089
+ const tracePath = join(dirname(sourcePath), 'update-helper.log')
2090
+ const execute = promisify(execFile)
2091
+
2092
+ const trace = (event, fields = {}) => fs.appendFile(
2093
+ tracePath,
2094
+ JSON.stringify({ ts: new Date().toISOString(), pid: process.pid, event, ...fields }) + '\n'
2095
+ ).catch(() => undefined)
2096
+
2097
+ const outputTail = (value) => String(value || '').slice(-2000)
2098
+ const run = async (name, executable, args) => {
2099
+ await trace('command-start', { name, executable, args })
2100
+ try {
2101
+ const result = await execute(executable, args)
2102
+ await trace('command-success', {
2103
+ name,
2104
+ stdout: outputTail(result.stdout),
2105
+ stderr: outputTail(result.stderr)
2106
+ })
2107
+ return result
2108
+ } catch (error) {
2109
+ await trace('command-failed', {
2110
+ name,
2111
+ error: String(error),
2112
+ stdout: outputTail(error && error.stdout),
2113
+ stderr: outputTail(error && error.stderr)
2114
+ })
2115
+ throw error
2116
+ }
2117
+ }
1899
2118
 
1900
2119
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
1901
2120
  const parentIsRunning = () => {
1902
2121
  try { process.kill(parentPid, 0); return true } catch { return false }
1903
2122
  }
2123
+ const waitForParentExit = async () => {
2124
+ const startedAt = Date.now()
2125
+ let checks = 0
2126
+ await trace('waiting-for-parent-exit', { parentPid })
2127
+ while (parentIsRunning()) {
2128
+ checks += 1
2129
+ await sleep(200)
2130
+ }
2131
+ await trace('parent-exited', { parentPid, waitMs: Date.now() - startedAt, checks })
2132
+ }
1904
2133
  const sha256 = (path) => new Promise((resolve, reject) => {
1905
2134
  const hash = createHash('sha256')
1906
2135
  const input = createReadStream(path)
@@ -1909,17 +2138,23 @@ const sha256 = (path) => new Promise((resolve, reject) => {
1909
2138
  input.on('end', () => resolve(hash.digest('hex')))
1910
2139
  })
1911
2140
  const restartFallback = () => {
2141
+ void trace('fallback-spawn-requested', { fallbackExecutable, fallbackArgs })
1912
2142
  const env = { ...process.env }
1913
2143
  delete env.ELECTRON_RUN_AS_NODE
1914
2144
  const child = spawn(fallbackExecutable, fallbackArgs, { detached: true, stdio: 'ignore', env })
1915
- child.on('error', () => undefined)
2145
+ child.on('spawn', () => { void trace('fallback-spawned', { childPid: child.pid }) })
2146
+ child.on('error', (error) => { void trace('fallback-spawn-failed', { error: String(error) }) })
1916
2147
  child.unref()
1917
2148
  }
1918
2149
  const findInstalledMacApp = async () => {
1919
2150
  const plist = join('${MAC_INSTALLED_APP}', 'Contents', 'Info.plist')
1920
- const bundle = await run('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', plist])
1921
- const version = await run('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleShortVersionString', plist])
1922
- if (bundle.stdout.trim() !== '${MAC_BUNDLE_ID}' || version.stdout.trim() !== targetVersion) {
2151
+ await trace('installed-app-check-start', { plist, expectedBundle: '${MAC_BUNDLE_ID}', expectedVersion: targetVersion })
2152
+ const bundle = await run('read-bundle-id', '/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', plist])
2153
+ const version = await run('read-bundle-version', '/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleShortVersionString', plist])
2154
+ const actualBundle = bundle.stdout.trim()
2155
+ const actualVersion = version.stdout.trim()
2156
+ await trace('installed-app-check-result', { actualBundle, actualVersion })
2157
+ if (actualBundle !== '${MAC_BUNDLE_ID}' || actualVersion !== targetVersion) {
1923
2158
  throw new Error('installed app identity or version does not match the verified update')
1924
2159
  }
1925
2160
  return '${MAC_INSTALLED_APP}'
@@ -1928,7 +2163,18 @@ const restartInstalledApp = async () => {
1928
2163
  if (platform === 'darwin') {
1929
2164
  // PKG 禁止重定位;安装后再次核对固定路径的 bundle ID 与版本再启动。
1930
2165
  const installedApp = await findInstalledMacApp()
1931
- await run('/usr/bin/open', ['-n', installedApp])
2166
+ // 不要使用 open -n:它会强制创建第二个实例,而主进程的单实例锁
2167
+ // 会让这个新进程立即退出,表现为 Dock 图标闪一下就消失。
2168
+ // 后台更新助手调用 LaunchServices 时可能只返回成功但没有真正拉起应用。
2169
+ // 直接启动已经核对过的 bundle executable,避免依赖 open 的 GUI 会话。
2170
+ const executable = join(installedApp, 'Contents', 'MacOS', 'Desktop Pet')
2171
+ await trace('direct-launch-requested', { installedApp, executable })
2172
+ const env = { ...process.env }
2173
+ delete env.ELECTRON_RUN_AS_NODE
2174
+ const child = spawn(executable, [], { detached: true, stdio: 'ignore', env })
2175
+ child.on('spawn', () => { void trace('direct-launch-spawned', { childPid: child.pid }) })
2176
+ child.on('error', (error) => { void trace('direct-launch-failed', { error: String(error) }) })
2177
+ child.unref()
1932
2178
  return
1933
2179
  }
1934
2180
  restartFallback()
@@ -1938,32 +2184,50 @@ const shellQuote = (value) => "'" + value.replace(/'/g, "'\\''") + "'"
1938
2184
  async function install() {
1939
2185
  if (platform === 'darwin') {
1940
2186
  const command = '/usr/sbin/installer -pkg ' + shellQuote(sourcePath) + ' -target /'
1941
- await run('/usr/bin/osascript', ['-e', 'do shell script ' + JSON.stringify(command) + ' with administrator privileges'])
2187
+ await run('install-macos-pkg', '/usr/bin/osascript', ['-e', 'do shell script ' + JSON.stringify(command) + ' with administrator privileges'])
1942
2188
  return
1943
2189
  }
1944
2190
  if (platform === 'win32') {
1945
2191
  // 发布端须提供支持 /S 静默安装的签名 NSIS 安装器。
1946
- await run(sourcePath, ['/S'])
2192
+ await run('install-windows-exe', sourcePath, ['/S'])
1947
2193
  return
1948
2194
  }
1949
2195
  throw new Error('unsupported platform')
1950
2196
  }
1951
2197
 
1952
2198
  async function main() {
1953
- while (parentIsRunning()) await sleep(200)
2199
+ await trace('helper-start', {
2200
+ parentPid,
2201
+ sourcePath,
2202
+ expectedSHA256,
2203
+ platform,
2204
+ targetVersion,
2205
+ fallbackExecutable,
2206
+ fallbackArgs
2207
+ })
2208
+ await waitForParentExit()
1954
2209
 
1955
2210
  let installed = false
1956
2211
  try {
1957
- if (await sha256(sourcePath) !== expectedSHA256) throw new Error('downloaded update hash changed')
2212
+ await trace('hash-check-start', { sourcePath })
2213
+ const actualSHA256 = await sha256(sourcePath)
2214
+ await trace('hash-check-result', { matches: actualSHA256 === expectedSHA256 })
2215
+ if (actualSHA256 !== expectedSHA256) throw new Error('downloaded update hash changed')
2216
+ await trace('install-start', { platform, targetVersion })
1958
2217
  await install()
1959
2218
  installed = true
2219
+ await trace('install-success', { platform, targetVersion })
1960
2220
  } catch (error) {
2221
+ await trace('install-failed', { error: String(error) })
1961
2222
  await fs.writeFile(join(dirname(sourcePath), 'last-install-error.log'), String(error)).catch(() => undefined)
1962
2223
  } finally {
1963
2224
  if (installed) {
1964
2225
  try {
2226
+ await trace('restart-start', { platform, targetVersion })
1965
2227
  await restartInstalledApp()
2228
+ await trace('restart-command-finished', { platform, targetVersion })
1966
2229
  } catch (error) {
2230
+ await trace('restart-failed', { error: String(error) })
1967
2231
  await fs.writeFile(join(dirname(sourcePath), 'last-restart-error.log'), String(error)).catch(() => undefined)
1968
2232
  restartFallback()
1969
2233
  }
@@ -1973,7 +2237,10 @@ async function main() {
1973
2237
  }
1974
2238
  }
1975
2239
 
1976
- main().catch(() => restartFallback())
2240
+ main().catch(async (error) => {
2241
+ await trace('helper-failed', { error: String(error) })
2242
+ restartFallback()
2243
+ })
1977
2244
  `;
1978
2245
  function installerExtension(releaseUrl) {
1979
2246
  const pathname = new URL(releaseUrl).pathname.toLowerCase();
@@ -1989,23 +2256,30 @@ function storedRelayURL() {
1989
2256
  return void 0;
1990
2257
  }
1991
2258
  }
1992
- function updateRelayURL() {
2259
+ function updateServiceURL() {
2260
+ if (electron.app.isPackaged) return OFFICIAL_SERVER_ROOT;
2261
+ if (hasRuntimeServerOverride()) return configuredRelayURL();
1993
2262
  const stored = storedRelayURL();
1994
- if (stored && !isLegacyOfficialHTTPURL(stored)) return stored;
2263
+ if (stored && !isLegacyOfficialServerURL(stored)) return stored;
1995
2264
  return configuredRelayURL();
1996
2265
  }
1997
- function isLegacyOfficialHTTPURL(value) {
1998
- try {
1999
- const url = new URL(value);
2000
- return url.protocol === "http:" && url.hostname === LEGACY_OFFICIAL_HTTP_HOST;
2001
- } catch {
2002
- return false;
2266
+ function updateServiceRootURL(value) {
2267
+ const url = new URL(value);
2268
+ if (url.protocol === "wss:") url.protocol = "https:";
2269
+ else if (url.protocol === "ws:") url.protocol = "http:";
2270
+ else if (url.protocol !== "https:" && url.protocol !== "http:") {
2271
+ throw new Error("更新服务器地址格式无效");
2003
2272
  }
2273
+ url.pathname = url.pathname.replace(/\/ws\/?$/, "").replace(/\/+$/, "");
2274
+ url.search = "";
2275
+ url.hash = "";
2276
+ return url;
2004
2277
  }
2005
2278
  function validateUpdateAPIURL(value) {
2006
2279
  const url = new URL(value);
2007
2280
  const local = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1";
2008
- if (url.protocol !== "https:" && !(url.protocol === "http:" && local && !electron.app.isPackaged)) {
2281
+ const officialHTTPTunnel = isLegacyOfficialServerURL(value);
2282
+ if (url.protocol !== "https:" && !(officialHTTPTunnel || url.protocol === "http:" && local && !electron.app.isPackaged)) {
2009
2283
  throw new Error("更新服务器必须使用 HTTPS");
2010
2284
  }
2011
2285
  return url;
@@ -2021,15 +2295,128 @@ function parseUpdate(value) {
2021
2295
  }
2022
2296
  const url = new URL(releaseUrl);
2023
2297
  if (url.protocol !== "https:") throw new Error("更新下载地址必须使用 HTTPS");
2298
+ const sizeBytes = optionalPositiveInteger(value.sizeBytes);
2299
+ if (sizeBytes && sizeBytes > MAX_ARTIFACT_BYTES) throw new Error("更新包超过客户端允许的最大大小");
2300
+ const signature = parseManifestSignature(value.signature);
2024
2301
  return {
2302
+ releaseId: optionalString(value.releaseId),
2303
+ channel: optionalString(value.channel),
2304
+ platform: optionalString(value.platform),
2305
+ arch: optionalString(value.arch),
2025
2306
  version,
2026
2307
  releaseUrl: url.toString(),
2027
2308
  sha256: sha256.toLowerCase(),
2309
+ sizeBytes,
2028
2310
  notes: optionalString(value.notes),
2029
2311
  publishedAt: optionalString(value.publishedAt),
2030
- mandatory: value.mandatory === true
2312
+ expiresAt: optionalString(value.expiresAt),
2313
+ mandatory: value.mandatory === true,
2314
+ signature
2031
2315
  };
2032
2316
  }
2317
+ function optionalPositiveInteger(value) {
2318
+ if (value === void 0 || value === null) return void 0;
2319
+ if (!Number.isSafeInteger(value) || Number(value) <= 0) {
2320
+ throw new Error("更新信息包含无效的安装包大小");
2321
+ }
2322
+ return Number(value);
2323
+ }
2324
+ function parseManifestSignature(value) {
2325
+ if (value === void 0 || value === null) return void 0;
2326
+ if (!isRecord(value)) throw new Error("更新清单签名格式无效");
2327
+ const keyId = asString(value.keyId);
2328
+ const algorithm = asString(value.algorithm);
2329
+ const signature = asString(value.value);
2330
+ if (!keyId || algorithm !== "ed25519" || !/^[A-Za-z0-9+/]{86}==$/.test(signature)) {
2331
+ throw new Error("更新清单签名格式无效");
2332
+ }
2333
+ return { keyId, algorithm, value: signature };
2334
+ }
2335
+ function verifyManifestSignature(update) {
2336
+ const trustedKeys = trustedManifestPublicKeys();
2337
+ if (!update.signature) {
2338
+ if (electron.app.isPackaged || Object.keys(trustedKeys).length > 0) throw new Error("更新清单缺少可信签名");
2339
+ return;
2340
+ }
2341
+ if (!update.releaseId || !update.channel || !update.platform || !update.arch || !update.sizeBytes || !update.publishedAt || !update.expiresAt) {
2342
+ throw new Error("签名更新清单缺少必要字段");
2343
+ }
2344
+ if (update.channel !== updateChannel() || update.platform !== process.platform || update.arch !== process.arch) {
2345
+ throw new Error("更新清单与当前渠道或平台不匹配");
2346
+ }
2347
+ const expiresAt = Date.parse(update.expiresAt);
2348
+ if (!Number.isFinite(expiresAt) || expiresAt < Date.now() - 6e4) {
2349
+ throw new Error("更新清单已过期,请重新检查");
2350
+ }
2351
+ const encodedKey = trustedKeys[update.signature.keyId];
2352
+ if (!encodedKey) throw new Error(`更新清单使用了不受信任的签名密钥:${update.signature.keyId}`);
2353
+ try {
2354
+ const publicKey = node_crypto.createPublicKey({ key: Buffer.from(encodedKey, "base64"), format: "der", type: "spki" });
2355
+ const payload = JSON.stringify([
2356
+ update.releaseId,
2357
+ update.channel,
2358
+ update.platform,
2359
+ update.arch,
2360
+ update.version,
2361
+ update.releaseUrl,
2362
+ update.sha256,
2363
+ update.sizeBytes,
2364
+ update.notes ?? "",
2365
+ update.publishedAt,
2366
+ update.expiresAt,
2367
+ update.mandatory
2368
+ ]).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
2369
+ if (!node_crypto.verify(null, Buffer.from(payload), publicKey, Buffer.from(update.signature.value, "base64"))) {
2370
+ throw new Error("signature mismatch");
2371
+ }
2372
+ } catch (error) {
2373
+ logger.warn("update manifest signature rejected", { error: String(error), keyId: update.signature.keyId });
2374
+ throw new Error("更新清单签名验证失败");
2375
+ }
2376
+ }
2377
+ function trustedManifestPublicKeys() {
2378
+ const embedded = '{"primary-2026":"MCowBQYDK2VwAyEAtXVi15v8FHIVJeGKf4Fjm94PGr1GzRFAStC7N2utTkc="}';
2379
+ const encoded = !electron.app.isPackaged && embedded === "{}" ? process.env.DESKTOP_PET_UPDATE_PUBLIC_KEYS_JSON?.trim() || embedded : embedded;
2380
+ try {
2381
+ const parsed = JSON.parse(encoded);
2382
+ if (!isRecord(parsed)) return {};
2383
+ return Object.fromEntries(Object.entries(parsed).filter((entry) => entry[0].length > 0 && typeof entry[1] === "string" && entry[1].length > 0));
2384
+ } catch {
2385
+ return {};
2386
+ }
2387
+ }
2388
+ function updateChannel() {
2389
+ const channel = process.env.DESKTOP_PET_UPDATE_CHANNEL?.trim() || "stable";
2390
+ return /^[a-z][a-z0-9-]{0,31}$/.test(channel) ? channel : "stable";
2391
+ }
2392
+ let cachedInstallationID;
2393
+ async function updateInstallationID() {
2394
+ if (cachedInstallationID) return cachedInstallationID;
2395
+ const path2 = node_path.join(electron.app.getPath("userData"), "update-installation-id");
2396
+ let replaceInvalidFile = false;
2397
+ try {
2398
+ const stored = (await promises.readFile(path2, "utf8")).trim();
2399
+ if (/^[0-9a-f-]{36}$/i.test(stored)) {
2400
+ cachedInstallationID = stored;
2401
+ return stored;
2402
+ }
2403
+ replaceInvalidFile = true;
2404
+ } catch (error) {
2405
+ if (error.code !== "ENOENT") {
2406
+ logger.warn("could not read update installation id", { error: String(error) });
2407
+ }
2408
+ }
2409
+ const created = node_crypto.randomUUID();
2410
+ await promises.mkdir(electron.app.getPath("userData"), { recursive: true });
2411
+ try {
2412
+ await promises.writeFile(path2, created, { flag: replaceInvalidFile ? "w" : "wx", mode: 384 });
2413
+ cachedInstallationID = created;
2414
+ } catch (error) {
2415
+ if (error.code !== "EEXIST") throw error;
2416
+ cachedInstallationID = (await promises.readFile(path2, "utf8")).trim();
2417
+ }
2418
+ return cachedInstallationID;
2419
+ }
2033
2420
  function asString(value) {
2034
2421
  return typeof value === "string" ? value.trim() : "";
2035
2422
  }
@@ -2250,7 +2637,9 @@ function createPetWindow(skin, options) {
2250
2637
  }
2251
2638
  function updateMenuLabel() {
2252
2639
  const status2 = getUpdateStatus();
2253
- if (status2.kind === "available") return `软件更新(v${status2.update.version} 可下载)`;
2640
+ if (status2.kind === "available") {
2641
+ return status2.update.mandatory ? `软件更新(必须升级到 v${status2.update.version})` : `软件更新(v${status2.update.version} 可下载)`;
2642
+ }
2254
2643
  if (status2.kind === "downloading") {
2255
2644
  return `软件更新(v${status2.update.version} 下载 ${downloadProgressText(status2)})`;
2256
2645
  }
@@ -2266,7 +2655,8 @@ function updateMenuItems(petId) {
2266
2655
  click: () => {
2267
2656
  void checkForUpdates().then((result) => {
2268
2657
  if (result.kind === "available") {
2269
- dispatch({ type: "bubble", text: `发现 v${result.update.version},可从“软件更新”安装`, ttl: 6e3 }, petId);
2658
+ const message = result.update.mandatory ? `v${result.update.version} 是必须安装的重要更新` : `发现 v${result.update.version},可从“软件更新”安装`;
2659
+ dispatch({ type: "bubble", text: message, ttl: result.update.mandatory ? 9e3 : 6e3 }, petId);
2270
2660
  } else if (result.kind === "up-to-date") {
2271
2661
  dispatch({ type: "bubble", text: "已经是最新版本啦", ttl: 4500 }, petId);
2272
2662
  } else if (result.kind === "downloading") {
@@ -2500,11 +2890,13 @@ function isRelayCommand(raw) {
2500
2890
  const t = raw.type;
2501
2891
  return t === "ask" || t === "list_tasks" || t === "wait_task" || t === "answer" || t === "delegate_task" || t === "get_task" || t === "wait_task_result" || t === "claim_task" || t === "submit_task_result" || t === "cancel_task" || t === "list_remote_tasks" || t === "request_task_context" || t === "supply_task_context" || t === "configure_device" || t === "register_mcp_client" || t === "upload_relay_artifact" || t === "download_relay_artifact";
2502
2892
  }
2893
+ const RELAY_BLOB_TRANSFER_TIMEOUT_MS = 10 * 6e4;
2503
2894
  async function uploadRelayBlob(input) {
2504
2895
  if (input.kind === "patch" && !input.baseCommit) throw new Error("patch artifact requires baseCommit");
2505
2896
  const auth = getRelayHTTPAuth();
2506
2897
  const peersResponse = await fetch(`${auth.baseURL}/api/tasks/${encodeURIComponent(input.taskId)}/peer-keys`, {
2507
- headers: { Authorization: `Bearer ${auth.accessToken}` }
2898
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
2899
+ signal: AbortSignal.timeout(15e3)
2508
2900
  });
2509
2901
  const peersBody = await peersResponse.json();
2510
2902
  if (!peersResponse.ok) throw new Error(peersBody.message || "unable to load peer encryption keys");
@@ -2513,6 +2905,7 @@ async function uploadRelayBlob(input) {
2513
2905
  const encrypted = encryptForRecipients(input.bytes, peers);
2514
2906
  const artifactId = input.artifactId ?? node_crypto.randomUUID();
2515
2907
  const sha256 = node_crypto.createHash("sha256").update(input.bytes).digest("hex");
2908
+ const normalizedKind = normalizeResultKind(input.kind);
2516
2909
  const response = await fetch(
2517
2910
  `${auth.baseURL}/api/tasks/${encodeURIComponent(input.taskId)}/artifacts/${encodeURIComponent(artifactId)}`,
2518
2911
  {
@@ -2523,17 +2916,18 @@ async function uploadRelayBlob(input) {
2523
2916
  "X-Artifact-Size": String(input.bytes.byteLength),
2524
2917
  "X-Artifact-SHA256": sha256,
2525
2918
  "X-Artifact-Direction": input.direction,
2526
- "X-Artifact-Kind": input.kind,
2919
+ "X-Artifact-Kind": normalizedKind,
2527
2920
  "X-Artifact-Mime": input.mimeType,
2528
2921
  "X-Artifact-Key-Wraps": Buffer.from(JSON.stringify(encrypted.keyWraps)).toString("base64url")
2529
2922
  },
2530
- body: Buffer.from(encrypted.ciphertext)
2923
+ body: Buffer.from(encrypted.ciphertext),
2924
+ signal: AbortSignal.timeout(RELAY_BLOB_TRANSFER_TIMEOUT_MS)
2531
2925
  }
2532
2926
  );
2533
2927
  if (!response.ok) throw new Error(`relay blob upload failed (${response.status})`);
2534
2928
  return {
2535
2929
  id: artifactId,
2536
- kind: normalizeResultKind(input.kind),
2930
+ kind: normalizedKind,
2537
2931
  path: input.path,
2538
2932
  mimeType: input.mimeType,
2539
2933
  size: input.bytes.byteLength,
@@ -2550,7 +2944,10 @@ async function downloadRelayBlob(taskId, artifact) {
2550
2944
  const auth = getRelayHTTPAuth();
2551
2945
  const response = await fetch(
2552
2946
  `${auth.baseURL}/api/tasks/${encodeURIComponent(taskId)}/artifacts/${encodeURIComponent(artifact.id)}`,
2553
- { headers: { Authorization: `Bearer ${auth.accessToken}` } }
2947
+ {
2948
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
2949
+ signal: AbortSignal.timeout(RELAY_BLOB_TRANSFER_TIMEOUT_MS)
2950
+ }
2554
2951
  );
2555
2952
  if (!response.ok) throw new Error(`relay blob download failed (${response.status})`);
2556
2953
  const metadataEncoded = response.headers.get("x-artifact-metadata") ?? "";
@@ -2848,8 +3245,59 @@ async function handleRelay(cmd, res) {
2848
3245
  return;
2849
3246
  }
2850
3247
  case "delegate_task": {
2851
- const { task, warning } = await delegateTask(cmd.envelope);
2852
- ok(res, { task, warning });
3248
+ if (cmd.workspaceSnapshot && !isWorkspaceSnapshotStagingAvailable()) {
3249
+ throw new Error("当前中转服务器尚未支持安全的工作区快照门控,请先升级服务端");
3250
+ }
3251
+ const created = await delegateTask(cmd.envelope);
3252
+ if (!cmd.workspaceSnapshot) {
3253
+ ok(res, created);
3254
+ return;
3255
+ }
3256
+ if (created.task.taskId !== cmd.envelope.taskId) {
3257
+ if (created.task.status !== "created") {
3258
+ ok(res, created);
3259
+ return;
3260
+ }
3261
+ throw new Error("idempotencyKey 对应的旧任务仍在等待原工作区快照;请使用新的 idempotencyKey 重新派发");
3262
+ }
3263
+ if (created.task.status !== "created") {
3264
+ await cancelRemoteTask(created.task.taskId).catch(() => void 0);
3265
+ throw new Error("当前中转服务器不支持工作区快照上传前门控;已取消任务,请先升级服务端");
3266
+ }
3267
+ const declared = created.task.envelope.context.items.find(isWorkspaceSnapshotContextItem);
3268
+ if (!declared || declared.id !== cmd.workspaceSnapshot.artifactId) {
3269
+ await cancelRemoteTask(created.task.taskId).catch(() => void 0);
3270
+ throw new Error("本地工作区快照与任务声明不一致");
3271
+ }
3272
+ try {
3273
+ const source = resolveWorkspaceFile(
3274
+ cmd.workspaceSnapshot.workspaceRoot,
3275
+ cmd.workspaceSnapshot.path,
3276
+ true
3277
+ );
3278
+ const uploaded = await uploadRelayBlob({
3279
+ taskId: created.task.taskId,
3280
+ artifactId: cmd.workspaceSnapshot.artifactId,
3281
+ bytes: fs.readFileSync(source),
3282
+ direction: "context",
3283
+ kind: "file",
3284
+ mimeType: declared.mimeType,
3285
+ path: declared.path
3286
+ });
3287
+ if (uploaded.id !== declared.id || uploaded.size !== declared.size || uploaded.sha256 !== declared.sha256 || uploaded.artifactRef !== declared.artifactRef) {
3288
+ throw new Error("加密工作区快照上传结果与任务声明不一致");
3289
+ }
3290
+ const finalized = await finalizeRemoteTaskWorkspace(created.task.taskId);
3291
+ ok(res, finalized);
3292
+ } catch (err) {
3293
+ const current = await getRemoteTask(created.task.taskId).catch(() => void 0);
3294
+ if (current && current.status !== "created") {
3295
+ ok(res, { task: current });
3296
+ return;
3297
+ }
3298
+ await cancelRemoteTask(created.task.taskId).catch(() => void 0);
3299
+ throw err;
3300
+ }
2853
3301
  return;
2854
3302
  }
2855
3303
  case "register_mcp_client": {
@@ -3141,32 +3589,6 @@ async function dutyLoop(adapter) {
3141
3589
  }
3142
3590
  }
3143
3591
  }
3144
- const PRODUCT_DATA_DIRECTORY = "Desktop Pet";
3145
- const LEGACY_NPM_DATA_DIRECTORY = "desktop-pet-app";
3146
- function configureRuntimeIdentity() {
3147
- const profileDirectory = process.env.DESKTOP_PET_PROFILE?.trim();
3148
- if (profileDirectory) {
3149
- electron.app.setPath("userData", node_path.resolve(profileDirectory));
3150
- return;
3151
- }
3152
- if (!electron.app.isPackaged && process.env.DESKTOP_PET_NPM_DISTRIBUTION !== "1") return;
3153
- const appDataDirectory = electron.app.getPath("appData");
3154
- const canonicalDirectory = node_path.join(appDataDirectory, PRODUCT_DATA_DIRECTORY);
3155
- if (process.env.DESKTOP_PET_NPM_DISTRIBUTION === "1") {
3156
- migrateLegacyNpmData(node_path.join(appDataDirectory, LEGACY_NPM_DATA_DIRECTORY), canonicalDirectory);
3157
- }
3158
- electron.app.setPath("userData", canonicalDirectory);
3159
- }
3160
- function migrateLegacyNpmData(legacyDirectory, canonicalDirectory) {
3161
- if (!node_fs.existsSync(legacyDirectory) || node_fs.existsSync(canonicalDirectory)) return false;
3162
- try {
3163
- node_fs.renameSync(legacyDirectory, canonicalDirectory);
3164
- return true;
3165
- } catch (error) {
3166
- console.warn(`无法迁移 Desktop Pet 用户数据:${String(error)}`);
3167
- return false;
3168
- }
3169
- }
3170
3592
  const trayIcon1xPath = path.join(__dirname, "./chunks/tray-iconTemplate-BIK86F-0.png");
3171
3593
  const trayIcon2xPath = path.join(__dirname, "./chunks/tray-iconTemplate@2x-B19HpcaE.png");
3172
3594
  let tray;
@@ -3201,20 +3623,46 @@ function createPetTemplateImage() {
3201
3623
  return image;
3202
3624
  }
3203
3625
  configureRuntimeIdentity();
3626
+ function errorFields(error) {
3627
+ if (error instanceof Error) return { error: error.toString(), stack: error.stack };
3628
+ return { error: String(error) };
3629
+ }
3630
+ logger.info("app bootstrap", {
3631
+ pid: process.pid,
3632
+ ppid: process.ppid,
3633
+ platform: process.platform,
3634
+ arch: process.arch,
3635
+ isPackaged: electron.app.isPackaged,
3636
+ execPath: process.execPath,
3637
+ resourcesPath: process.resourcesPath,
3638
+ argv: process.argv.slice(1),
3639
+ userData: electron.app.getPath("userData")
3640
+ });
3641
+ process.on("uncaughtException", (error) => {
3642
+ logger.error("uncaught exception", errorFields(error));
3643
+ });
3644
+ process.on("unhandledRejection", (reason) => {
3645
+ logger.error("unhandled rejection", errorFields(reason));
3646
+ });
3204
3647
  if (process.platform !== "win32") {
3205
3648
  process.once("SIGTERM", () => electron.app.quit());
3206
3649
  }
3207
3650
  registerAccountProtocol();
3208
3651
  const singleInstance = electron.app.requestSingleInstanceLock();
3652
+ let activationRequestedBeforeReady = false;
3209
3653
  if (!singleInstance) {
3210
- logger.warn("another instance is running, quitting");
3654
+ logger.warn("another instance is running, quitting", {
3655
+ pid: process.pid,
3656
+ execPath: process.execPath,
3657
+ argv: process.argv.slice(1)
3658
+ });
3211
3659
  electron.app.quit();
3212
3660
  } else {
3213
3661
  electron.app.on("second-instance", (_event, argv) => {
3662
+ logger.warn("second instance received", { argv });
3214
3663
  const callback = argv.find((value) => value.startsWith("desktop-pet://"));
3215
3664
  if (callback) handleAccountCallback(callback);
3216
- petManager.setPetsVisible(true);
3217
- ensurePrimaryPet();
3665
+ restorePetsOnAppActivation();
3218
3666
  });
3219
3667
  }
3220
3668
  function ensurePrimaryPet() {
@@ -3223,30 +3671,84 @@ function ensurePrimaryPet() {
3223
3671
  identity: { kind: "primary" }
3224
3672
  });
3225
3673
  }
3674
+ function hideMacDockIcon() {
3675
+ if (process.platform !== "darwin") return;
3676
+ if (electron.app.dock.isVisible()) electron.app.dock.hide();
3677
+ }
3678
+ function restorePetsOnAppActivation() {
3679
+ if (!electron.app.isReady()) {
3680
+ activationRequestedBeforeReady = true;
3681
+ logger.info("app activation deferred until ready");
3682
+ return;
3683
+ }
3684
+ const visibleBefore = petManager.arePetsVisible();
3685
+ const petsBefore = petManager.count();
3686
+ const appHiddenBefore = process.platform === "darwin" ? electron.app.isHidden() : void 0;
3687
+ if (process.platform === "darwin") electron.app.show();
3688
+ petManager.setPetsVisible(true);
3689
+ const firstPet = ensurePrimaryPet();
3690
+ hideMacDockIcon();
3691
+ logger.info("pets restored on app activation", {
3692
+ appHiddenBefore,
3693
+ appHiddenAfter: process.platform === "darwin" ? electron.app.isHidden() : void 0,
3694
+ dockVisibleAfter: process.platform === "darwin" ? electron.app.dock.isVisible() : void 0,
3695
+ visibleBefore,
3696
+ visibleAfter: petManager.arePetsVisible(),
3697
+ petsBefore,
3698
+ petsAfter: petManager.count(),
3699
+ firstPet
3700
+ });
3701
+ }
3702
+ if (process.platform === "darwin") {
3703
+ electron.app.on("activate", () => {
3704
+ logger.info("app activated");
3705
+ restorePetsOnAppActivation();
3706
+ });
3707
+ }
3226
3708
  electron.app.whenReady().then(async () => {
3709
+ logger.info("app ready");
3710
+ hideMacDockIcon();
3711
+ logger.info("macOS dock icon state", {
3712
+ visible: process.platform === "darwin" ? electron.app.dock.isVisible() : void 0
3713
+ });
3227
3714
  createMenuBar();
3715
+ logger.info("menu bar created");
3228
3716
  await startAccountCallbackRouter();
3717
+ logger.info("account callback router started");
3229
3718
  startEventServer(electron.app.getVersion());
3719
+ logger.info("event server start requested");
3230
3720
  const activated = await activateStoredDevice().catch((err) => {
3231
3721
  logger.warn("device authentication failed", { error: String(err) });
3232
3722
  return false;
3233
3723
  });
3724
+ logger.info("device activation finished", { activated });
3234
3725
  if (!activated) startRelay();
3726
+ logger.info("relay start requested", { authenticated: activated });
3235
3727
  if (process.env.DESKTOP_PET_P2P !== "0") startP2PWindow(getDeviceIceServers());
3728
+ logger.info("p2p start requested", { enabled: process.env.DESKTOP_PET_P2P !== "0" });
3236
3729
  void startRunnerManager();
3237
3730
  const id = ensurePrimaryPet();
3731
+ logger.info("primary pet initialization finished", { firstPet: id });
3732
+ if (activationRequestedBeforeReady) {
3733
+ activationRequestedBeforeReady = false;
3734
+ restorePetsOnAppActivation();
3735
+ }
3238
3736
  setTimeout(() => {
3239
3737
  void checkForUpdates().then((result) => {
3738
+ logger.info("update check finished", { kind: result.kind });
3240
3739
  if (id && result.kind === "available") {
3241
3740
  dispatch({
3242
3741
  type: "bubble",
3243
- text: `发现 v${result.update.version},右键“软件更新”可请求下载`,
3244
- ttl: 7e3
3742
+ text: result.update.mandatory ? `v${result.update.version} 是必须安装的重要更新,请从右键“软件更新”完成升级` : `发现 v${result.update.version},右键“软件更新”可请求下载`,
3743
+ ttl: result.update.mandatory ? 12e3 : 7e3
3245
3744
  }, id);
3246
3745
  }
3247
3746
  });
3248
3747
  }, 1200);
3249
3748
  logger.info("app started", { firstPet: id });
3749
+ }).catch((error) => {
3750
+ logger.error("app startup failed", errorFields(error));
3751
+ electron.app.exit(1);
3250
3752
  });
3251
3753
  electron.app.on("window-all-closed", () => {
3252
3754
  if (process.platform !== "darwin") electron.app.quit();
@@ -3260,3 +3762,10 @@ electron.app.on("quit", () => {
3260
3762
  void stopEventServer().catch((err) => logger.warn("event server shutdown failed", { error: String(err) }));
3261
3763
  logger.info("app quit");
3262
3764
  });
3765
+ electron.app.on("render-process-gone", (_event, webContents, details) => {
3766
+ logger.error("render process gone", {
3767
+ reason: details.reason,
3768
+ exitCode: details.exitCode,
3769
+ url: webContents.getURL()
3770
+ });
3771
+ });