desktop-pet-app 0.3.4 → 0.3.13
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/README.md +210 -21
- package/package.json +2 -55
- package/bin/cli.js +0 -173
- package/out/main/chunks/tray-iconTemplate-BIK86F-0.png +0 -0
- package/out/main/chunks/tray-iconTemplate@2x-B19HpcaE.png +0 -0
- package/out/main/index.js +0 -3259
- package/out/mcp-server/index.js +0 -1457
- package/out/mcp-server/package.json +0 -1
- package/out/mcp-server/remote.js +0 -122
- package/out/mcp-server/tools.js +0 -21
- package/out/preload/index.js +0 -38
- package/out/renderer/assets/hoot-C7XVSf5y.webp +0 -0
- package/out/renderer/assets/index-CHc4qvDr.js +0 -1684
- package/out/renderer/assets/p2p-LRPQ9Jzg.js +0 -374
- package/out/renderer/assets/robot-blue-CFH9bWjZ.png +0 -0
- package/out/renderer/index.html +0 -156
- package/out/renderer/p2p.html +0 -6
package/out/main/index.js
DELETED
|
@@ -1,3259 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __create = Object.create;
|
|
3
|
-
var __defProp = Object.defineProperty;
|
|
4
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
-
var __copyProps = (to, from, except, desc) => {
|
|
9
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
-
for (let key of __getOwnPropNames(from))
|
|
11
|
-
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
-
}
|
|
14
|
-
return to;
|
|
15
|
-
};
|
|
16
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
-
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
-
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
-
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
-
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
-
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
-
mod
|
|
23
|
-
));
|
|
24
|
-
const electron = require("electron");
|
|
25
|
-
const node_fs = require("node:fs");
|
|
26
|
-
const node_path = require("node:path");
|
|
27
|
-
const path = require("path");
|
|
28
|
-
const os = require("os");
|
|
29
|
-
const node_crypto = require("node:crypto");
|
|
30
|
-
const node_http = require("node:http");
|
|
31
|
-
const node_os = require("node:os");
|
|
32
|
-
const crypto = require("crypto");
|
|
33
|
-
const WebSocket = require("ws");
|
|
34
|
-
const fs = require("fs");
|
|
35
|
-
const promises = require("node:fs/promises");
|
|
36
|
-
const node_stream = require("node:stream");
|
|
37
|
-
const promises$1 = require("node:stream/promises");
|
|
38
|
-
const node_child_process = require("node:child_process");
|
|
39
|
-
const http = require("http");
|
|
40
|
-
const ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
41
|
-
class Logger {
|
|
42
|
-
constructor(minLevel = "info", write = console.log) {
|
|
43
|
-
this.minLevel = minLevel;
|
|
44
|
-
this.write = write;
|
|
45
|
-
}
|
|
46
|
-
debug(msg, fields) {
|
|
47
|
-
this.log("debug", msg, fields);
|
|
48
|
-
}
|
|
49
|
-
info(msg, fields) {
|
|
50
|
-
this.log("info", msg, fields);
|
|
51
|
-
}
|
|
52
|
-
warn(msg, fields) {
|
|
53
|
-
this.log("warn", msg, fields);
|
|
54
|
-
}
|
|
55
|
-
error(msg, fields) {
|
|
56
|
-
this.log("error", msg, fields);
|
|
57
|
-
}
|
|
58
|
-
log(level, msg, fields) {
|
|
59
|
-
if (ORDER[level] < ORDER[this.minLevel]) return;
|
|
60
|
-
this.write(
|
|
61
|
-
JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), level, msg, ...fields })
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
function levelFromEnv() {
|
|
66
|
-
const v = (process.env.DESKTOP_PET_LOG_LEVEL ?? "info").toLowerCase();
|
|
67
|
-
return v === "debug" || v === "warn" || v === "error" ? v : "info";
|
|
68
|
-
}
|
|
69
|
-
function mainLogDirectory() {
|
|
70
|
-
return node_path.join(electron.app.getPath("userData"), "logs");
|
|
71
|
-
}
|
|
72
|
-
function writeMainLog(line) {
|
|
73
|
-
console.log(line);
|
|
74
|
-
try {
|
|
75
|
-
const directory = mainLogDirectory();
|
|
76
|
-
node_fs.mkdirSync(directory, { recursive: true });
|
|
77
|
-
node_fs.appendFileSync(node_path.join(directory, "main.log"), `${line}
|
|
78
|
-
`, "utf8");
|
|
79
|
-
} catch {
|
|
80
|
-
}
|
|
81
|
-
}
|
|
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");
|
|
84
|
-
const LOCAL_PET_PROTOCOL_VERSION = "1.0";
|
|
85
|
-
const LOCAL_PET_MAX_BODY_BYTES = 64 * 1024;
|
|
86
|
-
const MAX_PETS = 4;
|
|
87
|
-
const PET_WINDOW = { width: 200, height: 190 };
|
|
88
|
-
const PET_WINDOW_BY_SKIN = {
|
|
89
|
-
"robot-blue": { width: 260, height: 320 }
|
|
90
|
-
};
|
|
91
|
-
const GREET_DISTANCE = 260;
|
|
92
|
-
const GREET_COOLDOWN_MS = 15e3;
|
|
93
|
-
const PROXIMITY_SCAN_MS = 500;
|
|
94
|
-
const SKIN_IDS = [
|
|
95
|
-
"codex",
|
|
96
|
-
"claude",
|
|
97
|
-
"minecraft",
|
|
98
|
-
"hoot",
|
|
99
|
-
"cat",
|
|
100
|
-
"dog",
|
|
101
|
-
"bird",
|
|
102
|
-
"lion",
|
|
103
|
-
"tiger",
|
|
104
|
-
"whale",
|
|
105
|
-
"penguin",
|
|
106
|
-
"robot-blue"
|
|
107
|
-
];
|
|
108
|
-
const SKIN_LABELS = {
|
|
109
|
-
codex: "Codex 小宠",
|
|
110
|
-
claude: "Claude 小宠",
|
|
111
|
-
minecraft: "我的世界小宠",
|
|
112
|
-
hoot: "Professor Hoot",
|
|
113
|
-
cat: "橘猫",
|
|
114
|
-
dog: "小狗",
|
|
115
|
-
bird: "小鸟",
|
|
116
|
-
lion: "狮子",
|
|
117
|
-
tiger: "老虎",
|
|
118
|
-
whale: "蓝鲸",
|
|
119
|
-
penguin: "企鹅",
|
|
120
|
-
"robot-blue": "蓝色协作机器人"
|
|
121
|
-
};
|
|
122
|
-
const PET_EVENT = "pet:event";
|
|
123
|
-
const PET_MOVE = "pet:move";
|
|
124
|
-
const PET_DRAG = "pet:drag";
|
|
125
|
-
const PET_MENU = "pet:menu";
|
|
126
|
-
const PET_MOUSE_PASSTHROUGH = "pet:mouse-passthrough";
|
|
127
|
-
const P2P_SIGNAL_IN = "p2p:signal-in";
|
|
128
|
-
const P2P_SIGNAL_OUT = "p2p:signal-out";
|
|
129
|
-
const P2P_CONFIG = "p2p:config";
|
|
130
|
-
const P2P_CHAT_SEND = "p2p:chat-send";
|
|
131
|
-
const P2P_CHAT_EVENT = "p2p:chat-event";
|
|
132
|
-
const windows = /* @__PURE__ */ new Map();
|
|
133
|
-
function registerPetWindow(id, win) {
|
|
134
|
-
windows.set(id, win);
|
|
135
|
-
win.on("closed", () => windows.delete(id));
|
|
136
|
-
}
|
|
137
|
-
function getPetWindow(id) {
|
|
138
|
-
const win = windows.get(id);
|
|
139
|
-
return win && !win.isDestroyed() ? win : void 0;
|
|
140
|
-
}
|
|
141
|
-
function listPetIds() {
|
|
142
|
-
return [...windows.keys()].filter((id) => {
|
|
143
|
-
const win = windows.get(id);
|
|
144
|
-
return win && !win.isDestroyed();
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
function dispatch(event, targetId) {
|
|
148
|
-
if (targetId) {
|
|
149
|
-
const win = windows.get(targetId);
|
|
150
|
-
if (win && !win.isDestroyed()) win.webContents.send(PET_EVENT, event);
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
for (const win of windows.values()) {
|
|
154
|
-
if (!win.isDestroyed()) win.webContents.send(PET_EVENT, event);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
const busy = /* @__PURE__ */ new Set();
|
|
158
|
-
const homePos = /* @__PURE__ */ new Map();
|
|
159
|
-
function handleQuest(cmd, targetId) {
|
|
160
|
-
const id = listPetIds()[0];
|
|
161
|
-
if (!id) {
|
|
162
|
-
logger.warn("quest ignored: no pet window");
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
void runQuest(id, cmd);
|
|
166
|
-
}
|
|
167
|
-
async function runQuest(petId, cmd) {
|
|
168
|
-
if (busy.has(petId)) {
|
|
169
|
-
logger.warn("quest already running", { petId });
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
const win = getPetWindow(petId);
|
|
173
|
-
if (!win || win.isDestroyed()) return;
|
|
174
|
-
busy.add(petId);
|
|
175
|
-
try {
|
|
176
|
-
if (cmd.phase === "leave") {
|
|
177
|
-
await leave(win, petId, cmd.friendName, cmd.leaveText);
|
|
178
|
-
} else {
|
|
179
|
-
await comeBack(win, petId, cmd.returnText, cmd.fish ?? 5);
|
|
180
|
-
}
|
|
181
|
-
} catch (err) {
|
|
182
|
-
logger.error("quest failed", { petId, error: String(err) });
|
|
183
|
-
restoreHome(win, petId);
|
|
184
|
-
} finally {
|
|
185
|
-
busy.delete(petId);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
async function leave(win, petId, friendName, leaveText) {
|
|
189
|
-
const [x, y] = win.getPosition();
|
|
190
|
-
homePos.set(petId, { x, y });
|
|
191
|
-
const friend = friendName ?? "小白";
|
|
192
|
-
dispatch({ type: "state", state: "excited", ttl: 5 }, petId);
|
|
193
|
-
dispatch({
|
|
194
|
-
type: "bubble",
|
|
195
|
-
text: leaveText ?? `我去找 ${friend} 帮忙,20 分钟回来~`,
|
|
196
|
-
ttl: 3800
|
|
197
|
-
}, petId);
|
|
198
|
-
await sleep(900);
|
|
199
|
-
if (win.isDestroyed()) return;
|
|
200
|
-
dispatch({ type: "state", state: "walk", ttl: 40 }, petId);
|
|
201
|
-
const [w] = win.getSize();
|
|
202
|
-
const area = electron.screen.getDisplayNearestPoint({ x, y }).workArea;
|
|
203
|
-
const exitX = area.x + area.width + w;
|
|
204
|
-
await animatePosition(win, x, y, exitX, y, 1100);
|
|
205
|
-
if (!win.isDestroyed()) win.hide();
|
|
206
|
-
}
|
|
207
|
-
async function comeBack(win, petId, returnText, fish = 5) {
|
|
208
|
-
const home = homePos.get(petId);
|
|
209
|
-
const [cw, ch] = win.isDestroyed() ? [200, 190] : win.getSize();
|
|
210
|
-
const area = electron.screen.getPrimaryDisplay().workArea;
|
|
211
|
-
const hx = home?.x ?? area.x + area.width - cw - 120;
|
|
212
|
-
const hy = home?.y ?? area.y + area.height - ch - 60;
|
|
213
|
-
if (win.isDestroyed()) return;
|
|
214
|
-
const enterX = area.x - cw - 10;
|
|
215
|
-
win.setPosition(Math.round(enterX), Math.round(hy));
|
|
216
|
-
win.show();
|
|
217
|
-
win.setAlwaysOnTop(true, "screen-saver");
|
|
218
|
-
dispatch({ type: "state", state: "excited", ttl: 14 }, petId);
|
|
219
|
-
dispatch({
|
|
220
|
-
type: "bubble",
|
|
221
|
-
text: returnText ?? "我回来啦!朋友帮了大忙 📄",
|
|
222
|
-
ttl: 9e3
|
|
223
|
-
}, petId);
|
|
224
|
-
await animatePosition(win, enterX, hy, hx, hy, 1100);
|
|
225
|
-
if (win.isDestroyed()) return;
|
|
226
|
-
dispatch({ type: "reward", amount: fish, label: "小鱼干" }, petId);
|
|
227
|
-
homePos.delete(petId);
|
|
228
|
-
}
|
|
229
|
-
function restoreHome(win, petId) {
|
|
230
|
-
const home = homePos.get(petId);
|
|
231
|
-
if (!home || win.isDestroyed()) return;
|
|
232
|
-
win.setPosition(home.x, home.y);
|
|
233
|
-
win.show();
|
|
234
|
-
homePos.delete(petId);
|
|
235
|
-
}
|
|
236
|
-
function animatePosition(win, x0, y0, x1, y1, durationMs) {
|
|
237
|
-
return new Promise((resolve) => {
|
|
238
|
-
const start = Date.now();
|
|
239
|
-
const tick = () => {
|
|
240
|
-
if (win.isDestroyed()) {
|
|
241
|
-
resolve();
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
const t = Math.min(1, (Date.now() - start) / durationMs);
|
|
245
|
-
const e = easeInOut(t);
|
|
246
|
-
win.setPosition(Math.round(x0 + (x1 - x0) * e), Math.round(y0 + (y1 - y0) * e));
|
|
247
|
-
if (t < 1) setTimeout(tick, 16);
|
|
248
|
-
else resolve();
|
|
249
|
-
};
|
|
250
|
-
tick();
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
function easeInOut(t) {
|
|
254
|
-
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
|
255
|
-
}
|
|
256
|
-
function sleep(ms) {
|
|
257
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
258
|
-
}
|
|
259
|
-
function isPetOnQuest(petId) {
|
|
260
|
-
return busy.has(petId);
|
|
261
|
-
}
|
|
262
|
-
function canSendAwayPet(identity) {
|
|
263
|
-
return identity.kind === "remote-friend";
|
|
264
|
-
}
|
|
265
|
-
function presentPetIdentity(identity, localUsername) {
|
|
266
|
-
if (identity.kind === "remote-friend") {
|
|
267
|
-
return {
|
|
268
|
-
badge: `好友 @${identity.username}`,
|
|
269
|
-
menuHeader: `远端好友 @${identity.username} · 来访形象`,
|
|
270
|
-
greeting: `我是远端好友 @${identity.username},不是本机账号的分身`
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
const account = localUsername ? `@${localUsername}` : "未登录";
|
|
274
|
-
return {
|
|
275
|
-
badge: `本机主宠 · ${account}`,
|
|
276
|
-
menuHeader: `本机主宠 · ${account}`,
|
|
277
|
-
greeting: localUsername ? `你好呀,我是 ${account} 的本机主宠!` : "右键我,登录你的账号吧!"
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
class PetManager {
|
|
281
|
-
pets = /* @__PURE__ */ new Map();
|
|
282
|
-
lastGreetAt = /* @__PURE__ */ new Map();
|
|
283
|
-
timer;
|
|
284
|
-
petsVisible = true;
|
|
285
|
-
register(id, win, skin, identity) {
|
|
286
|
-
this.pets.set(id, { id, win, skin, identity });
|
|
287
|
-
win.on("closed", () => this.pets.delete(id));
|
|
288
|
-
if (!this.petsVisible) win.hide();
|
|
289
|
-
if (!this.timer) this.timer = setInterval(() => this.checkProximity(), PROXIMITY_SCAN_MS);
|
|
290
|
-
logger.info("pet registered", { id, skin, total: this.pets.size });
|
|
291
|
-
}
|
|
292
|
-
count() {
|
|
293
|
-
return this.pets.size;
|
|
294
|
-
}
|
|
295
|
-
hasPrimaryPet() {
|
|
296
|
-
return [...this.pets.values()].some(
|
|
297
|
-
(pet) => !pet.win.isDestroyed() && pet.identity.kind === "primary"
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
|
-
arePetsVisible() {
|
|
301
|
-
return this.petsVisible;
|
|
302
|
-
}
|
|
303
|
-
setPetsVisible(visible) {
|
|
304
|
-
this.petsVisible = visible;
|
|
305
|
-
for (const pet of this.pets.values()) {
|
|
306
|
-
if (pet.win.isDestroyed()) continue;
|
|
307
|
-
if (visible) pet.win.showInactive();
|
|
308
|
-
else pet.win.hide();
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
findRemoteFriend(username) {
|
|
312
|
-
const normalized = username.trim().toLowerCase();
|
|
313
|
-
return [...this.pets.values()].find(
|
|
314
|
-
(pet) => !pet.win.isDestroyed() && pet.identity.kind === "remote-friend" && pet.identity.username.toLowerCase() === normalized
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
refreshLocalIdentityBadges(username) {
|
|
318
|
-
for (const pet of this.pets.values()) {
|
|
319
|
-
if (pet.win.isDestroyed() || pet.identity.kind === "remote-friend") continue;
|
|
320
|
-
const presentation = presentPetIdentity(pet.identity, username);
|
|
321
|
-
dispatch({ type: "identity", label: presentation.badge, kind: pet.identity.kind }, pet.id);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
checkProximity() {
|
|
325
|
-
const arr = [...this.pets.values()].filter((p) => !p.win.isDestroyed());
|
|
326
|
-
for (let i = 0; i < arr.length; i++) {
|
|
327
|
-
for (let j = i + 1; j < arr.length; j++) {
|
|
328
|
-
const a = center(arr[i].win.getBounds());
|
|
329
|
-
const b = center(arr[j].win.getBounds());
|
|
330
|
-
const dist = Math.hypot(a.x - b.x, a.y - b.y);
|
|
331
|
-
if (dist >= GREET_DISTANCE) continue;
|
|
332
|
-
const key = [arr[i].id, arr[j].id].sort().join("|");
|
|
333
|
-
const now = Date.now();
|
|
334
|
-
if (now - (this.lastGreetAt.get(key) ?? 0) < GREET_COOLDOWN_MS) continue;
|
|
335
|
-
this.lastGreetAt.set(key, now);
|
|
336
|
-
dispatch({ type: "interact", kind: "greet" }, arr[i].id);
|
|
337
|
-
dispatch({ type: "interact", kind: "greet" }, arr[j].id);
|
|
338
|
-
dispatch({ type: "interact", kind: "approach", dir: a.x < b.x ? 1 : -1 }, arr[i].id);
|
|
339
|
-
logger.debug("pets greeted", { a: arr[i].id, b: arr[j].id });
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
function center(bounds) {
|
|
345
|
-
return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
|
|
346
|
-
}
|
|
347
|
-
const petManager = new PetManager();
|
|
348
|
-
function credentialPath() {
|
|
349
|
-
return node_path.join(electron.app.getPath("userData"), "device-credentials.enc");
|
|
350
|
-
}
|
|
351
|
-
function saveDeviceCredentials(credentials) {
|
|
352
|
-
validate(credentials);
|
|
353
|
-
if (!electron.safeStorage.isEncryptionAvailable()) {
|
|
354
|
-
throw new Error("系统钥匙串当前不可用,无法安全保存设备凭据");
|
|
355
|
-
}
|
|
356
|
-
const encrypted = electron.safeStorage.encryptString(JSON.stringify(credentials));
|
|
357
|
-
node_fs.writeFileSync(credentialPath(), encrypted, { mode: 384 });
|
|
358
|
-
node_fs.chmodSync(credentialPath(), 384);
|
|
359
|
-
}
|
|
360
|
-
function loadDeviceCredentials() {
|
|
361
|
-
const path2 = credentialPath();
|
|
362
|
-
if (!node_fs.existsSync(path2)) return void 0;
|
|
363
|
-
if (!electron.safeStorage.isEncryptionAvailable()) return void 0;
|
|
364
|
-
const credentials = JSON.parse(electron.safeStorage.decryptString(node_fs.readFileSync(path2)));
|
|
365
|
-
validate(credentials);
|
|
366
|
-
return credentials;
|
|
367
|
-
}
|
|
368
|
-
function clearDeviceCredentials() {
|
|
369
|
-
node_fs.rmSync(credentialPath(), { force: true });
|
|
370
|
-
}
|
|
371
|
-
function validate(credentials) {
|
|
372
|
-
if (!credentials.serverUrl || !credentials.userId || !credentials.deviceId || credentials.refreshToken.length < 32) {
|
|
373
|
-
throw new Error("无效的桌宠设备配置");
|
|
374
|
-
}
|
|
375
|
-
const url = new URL(credentials.serverUrl);
|
|
376
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
377
|
-
throw new Error("设备配置的 serverUrl 必须使用 http 或 https");
|
|
378
|
-
}
|
|
379
|
-
}
|
|
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);
|
|
385
|
-
}
|
|
386
|
-
function relayURLFromHost(value) {
|
|
387
|
-
const source = value.includes("://") ? value : `${isLocalHost(value) ? "http" : "https"}://${value}`;
|
|
388
|
-
let url;
|
|
389
|
-
try {
|
|
390
|
-
url = new URL(source);
|
|
391
|
-
} catch {
|
|
392
|
-
throw new Error("DESKTOP_PET_HOST 必须是有效的服务地址");
|
|
393
|
-
}
|
|
394
|
-
if (url.protocol === "https:") url.protocol = "wss:";
|
|
395
|
-
else if (url.protocol === "http:") url.protocol = "ws:";
|
|
396
|
-
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
|
397
|
-
throw new Error("DESKTOP_PET_HOST 必须使用 http:// 或 https://");
|
|
398
|
-
}
|
|
399
|
-
url.pathname = "/ws";
|
|
400
|
-
url.search = "";
|
|
401
|
-
url.hash = "";
|
|
402
|
-
return url.toString();
|
|
403
|
-
}
|
|
404
|
-
function isLocalHost(value) {
|
|
405
|
-
return /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/i.test(value.trim());
|
|
406
|
-
}
|
|
407
|
-
const TASK_PROTOCOL_VERSION = "1.0";
|
|
408
|
-
const sensitivePathPatterns = [
|
|
409
|
-
/(^|\/)\.env(?:\.|$)/i,
|
|
410
|
-
/(^|\/)(?:id_rsa|id_ed25519|known_hosts|credentials)(?:$|\/)/i,
|
|
411
|
-
/(^|\/)\.ssh(?:$|\/)/i,
|
|
412
|
-
/(^|\/)\.gnupg(?:$|\/)/i,
|
|
413
|
-
/(^|\/)node_modules(?:$|\/)/i,
|
|
414
|
-
/(^|\/)\.git(?:$|\/)/i,
|
|
415
|
-
/(^|\/)(?:dist|out|build|coverage)(?:$|\/)/i
|
|
416
|
-
];
|
|
417
|
-
function isSafeWorkspacePath(path2) {
|
|
418
|
-
if (!path2 || node_path.isAbsolute(path2) || path2.includes("\0")) return false;
|
|
419
|
-
const clean = node_path.normalize(path2);
|
|
420
|
-
return clean !== ".." && !clean.startsWith(`..${node_path.sep}`);
|
|
421
|
-
}
|
|
422
|
-
function isSensitiveContextPath(path2) {
|
|
423
|
-
const slashPath = path2.replaceAll("\\", "/");
|
|
424
|
-
return sensitivePathPatterns.some((pattern) => pattern.test(slashPath));
|
|
425
|
-
}
|
|
426
|
-
function sha256Text(text) {
|
|
427
|
-
return node_crypto.createHash("sha256").update(text, "utf8").digest("hex");
|
|
428
|
-
}
|
|
429
|
-
function inlineContextItem(input) {
|
|
430
|
-
if (input.path && (!isSafeWorkspacePath(input.path) || isSensitiveContextPath(input.path))) {
|
|
431
|
-
throw new Error(`unsafe or sensitive context path: ${input.path}`);
|
|
432
|
-
}
|
|
433
|
-
const size = Buffer.byteLength(input.text, "utf8");
|
|
434
|
-
return {
|
|
435
|
-
id: node_crypto.randomUUID(),
|
|
436
|
-
kind: input.kind ?? (input.path ? "file" : "text"),
|
|
437
|
-
path: input.path,
|
|
438
|
-
mimeType: input.mimeType ?? "text/plain; charset=utf-8",
|
|
439
|
-
size,
|
|
440
|
-
sha256: sha256Text(input.text),
|
|
441
|
-
encoding: "utf8",
|
|
442
|
-
transport: "inline",
|
|
443
|
-
inlineText: input.text,
|
|
444
|
-
required: input.required ?? true
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
function inlineResultArtifact(input) {
|
|
448
|
-
if (input.path && !isSafeWorkspacePath(input.path)) throw new Error(`unsafe result path: ${input.path}`);
|
|
449
|
-
if (input.kind === "patch" && !input.baseCommit) throw new Error("patch artifact requires baseCommit");
|
|
450
|
-
return {
|
|
451
|
-
id: node_crypto.randomUUID(),
|
|
452
|
-
kind: input.kind,
|
|
453
|
-
path: input.path,
|
|
454
|
-
mimeType: input.mimeType ?? "text/plain; charset=utf-8",
|
|
455
|
-
size: Buffer.byteLength(input.text, "utf8"),
|
|
456
|
-
sha256: sha256Text(input.text),
|
|
457
|
-
baseCommit: input.baseCommit,
|
|
458
|
-
transport: "inline",
|
|
459
|
-
inlineText: input.text
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
function isTerminalTaskStatus(status2) {
|
|
463
|
-
return status2 === "completed" || status2 === "failed" || status2 === "cancelled" || status2 === "expired";
|
|
464
|
-
}
|
|
465
|
-
const DEFAULT_EXECUTION_CAPABILITIES = [
|
|
466
|
-
"filesystem.read",
|
|
467
|
-
"filesystem.write",
|
|
468
|
-
"shell",
|
|
469
|
-
"network",
|
|
470
|
-
"browser",
|
|
471
|
-
"document.create"
|
|
472
|
-
];
|
|
473
|
-
function executionCapabilities(configured = process.env.DESKTOP_PET_EXECUTION_CAPABILITIES) {
|
|
474
|
-
const source = configured?.trim() || DEFAULT_EXECUTION_CAPABILITIES.join(",");
|
|
475
|
-
return [...new Set(source.split(",").map((value) => value.trim()).filter(Boolean))];
|
|
476
|
-
}
|
|
477
|
-
function showDesktopNotification(title, body) {
|
|
478
|
-
if (!electron.Notification.isSupported()) return;
|
|
479
|
-
new electron.Notification({ title, body }).show();
|
|
480
|
-
}
|
|
481
|
-
function p2pSigningPayload(message, fromDeviceId) {
|
|
482
|
-
const signalJSON = JSON.stringify(message.signal ?? null);
|
|
483
|
-
const signalHash = node_crypto.createHash("sha256").update(signalJSON).digest("hex");
|
|
484
|
-
return [
|
|
485
|
-
message.type,
|
|
486
|
-
message.taskId ?? "",
|
|
487
|
-
message.sessionId ?? "",
|
|
488
|
-
fromDeviceId,
|
|
489
|
-
message.toDeviceId ?? "",
|
|
490
|
-
message.expiresAt ?? "",
|
|
491
|
-
signalHash
|
|
492
|
-
].join("\n");
|
|
493
|
-
}
|
|
494
|
-
function verifyP2PSignal(message) {
|
|
495
|
-
if (!message.signature || !message.publicKey || !message.fromDeviceId || !message.taskId || !message.sessionId || !message.toDeviceId || !message.expiresAt) return false;
|
|
496
|
-
try {
|
|
497
|
-
return node_crypto.verify(
|
|
498
|
-
null,
|
|
499
|
-
Buffer.from(p2pSigningPayload(message, message.fromDeviceId)),
|
|
500
|
-
message.publicKey,
|
|
501
|
-
Buffer.from(message.signature, "base64")
|
|
502
|
-
);
|
|
503
|
-
} catch {
|
|
504
|
-
return false;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
let serverURL = configuredRelayURL();
|
|
508
|
-
let userID = process.env.DESKTOP_PET_USER ?? os.hostname();
|
|
509
|
-
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() || "";
|
|
512
|
-
let signingPrivateKey = "";
|
|
513
|
-
const pendingTasks = /* @__PURE__ */ new Map();
|
|
514
|
-
const pendingAnswers = /* @__PURE__ */ new Map();
|
|
515
|
-
const pendingTaskWaiters = /* @__PURE__ */ new Set();
|
|
516
|
-
const pendingRPCs = /* @__PURE__ */ new Map();
|
|
517
|
-
const availableTaskWaiters = /* @__PURE__ */ new Set();
|
|
518
|
-
const resultWaiters = /* @__PURE__ */ new Map();
|
|
519
|
-
const p2pSignalListeners = /* @__PURE__ */ new Set();
|
|
520
|
-
const leaseTimers = /* @__PURE__ */ new Map();
|
|
521
|
-
const taskSequences = /* @__PURE__ */ new Map();
|
|
522
|
-
let ws = null;
|
|
523
|
-
let connectPromise = null;
|
|
524
|
-
let intentionalClose = false;
|
|
525
|
-
let protocolV1Available = false;
|
|
526
|
-
let mcpRegistration;
|
|
527
|
-
function getAuthenticatedRelayUserId() {
|
|
528
|
-
return authToken ? userID : void 0;
|
|
529
|
-
}
|
|
530
|
-
function isProtocolV1Available() {
|
|
531
|
-
return protocolV1Available;
|
|
532
|
-
}
|
|
533
|
-
function getRelayHTTPAuth() {
|
|
534
|
-
if (!authToken) throw new Error("relay device access token is unavailable");
|
|
535
|
-
const url = new URL(serverURL);
|
|
536
|
-
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
|
537
|
-
url.pathname = "";
|
|
538
|
-
url.search = "";
|
|
539
|
-
url.hash = "";
|
|
540
|
-
return { baseURL: url.toString().replace(/\/$/, ""), accessToken: authToken, deviceId: deviceID };
|
|
541
|
-
}
|
|
542
|
-
function configureRelayIdentity(config) {
|
|
543
|
-
if (ws || connectPromise) throw new Error("stop relay before changing identity");
|
|
544
|
-
serverURL = config.serverUrl;
|
|
545
|
-
userID = config.userId;
|
|
546
|
-
deviceID = config.deviceId;
|
|
547
|
-
authToken = config.accessToken;
|
|
548
|
-
signingPrivateKey = config.signingPrivateKey ?? "";
|
|
549
|
-
}
|
|
550
|
-
async function registerMcpClient(agentHost, capabilities = {}) {
|
|
551
|
-
mcpRegistration = { agentHost, capabilities };
|
|
552
|
-
await ensureConnected();
|
|
553
|
-
ws?.send(
|
|
554
|
-
JSON.stringify({
|
|
555
|
-
type: "device.capabilities",
|
|
556
|
-
protocolVersion: TASK_PROTOCOL_VERSION,
|
|
557
|
-
messageId: crypto.randomUUID(),
|
|
558
|
-
capabilities: relayCapabilities()
|
|
559
|
-
})
|
|
560
|
-
);
|
|
561
|
-
}
|
|
562
|
-
function startRelay() {
|
|
563
|
-
intentionalClose = false;
|
|
564
|
-
void ensureConnected().catch((err) => {
|
|
565
|
-
logger.warn("relay connect failed on startup", { error: String(err) });
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
function stopRelay() {
|
|
569
|
-
intentionalClose = true;
|
|
570
|
-
protocolV1Available = false;
|
|
571
|
-
connectPromise = null;
|
|
572
|
-
if (ws) {
|
|
573
|
-
ws.close();
|
|
574
|
-
ws = null;
|
|
575
|
-
}
|
|
576
|
-
for (const [id, p] of pendingAnswers) {
|
|
577
|
-
clearTimeout(p.timer);
|
|
578
|
-
p.resolve({
|
|
579
|
-
text: "连接已关闭",
|
|
580
|
-
resultKind: "text",
|
|
581
|
-
from: "",
|
|
582
|
-
questionId: id,
|
|
583
|
-
timedOut: true
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
pendingAnswers.clear();
|
|
587
|
-
for (const waiter of pendingTaskWaiters) {
|
|
588
|
-
clearTimeout(waiter.timer);
|
|
589
|
-
waiter.resolve(null);
|
|
590
|
-
}
|
|
591
|
-
pendingTaskWaiters.clear();
|
|
592
|
-
for (const pending of pendingRPCs.values()) {
|
|
593
|
-
clearTimeout(pending.timer);
|
|
594
|
-
pending.reject(new Error("relay connection closed"));
|
|
595
|
-
}
|
|
596
|
-
pendingRPCs.clear();
|
|
597
|
-
for (const wake of availableTaskWaiters) wake();
|
|
598
|
-
availableTaskWaiters.clear();
|
|
599
|
-
for (const waiters of resultWaiters.values()) {
|
|
600
|
-
for (const waiter of waiters) {
|
|
601
|
-
clearTimeout(waiter.timer);
|
|
602
|
-
waiter.resolve(null);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
resultWaiters.clear();
|
|
606
|
-
for (const timer of leaseTimers.values()) clearInterval(timer);
|
|
607
|
-
leaseTimers.clear();
|
|
608
|
-
}
|
|
609
|
-
function ensureConnected() {
|
|
610
|
-
if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve();
|
|
611
|
-
if (connectPromise) return connectPromise;
|
|
612
|
-
connectPromise = new Promise((resolve, reject) => {
|
|
613
|
-
let settled = false;
|
|
614
|
-
const sock = new WebSocket(serverURL, {
|
|
615
|
-
...authToken ? { headers: { Authorization: `Bearer ${authToken}` } } : {}
|
|
616
|
-
});
|
|
617
|
-
ws = sock;
|
|
618
|
-
sock.on("open", () => {
|
|
619
|
-
sock.send(
|
|
620
|
-
JSON.stringify({
|
|
621
|
-
type: "register",
|
|
622
|
-
protocolVersion: TASK_PROTOCOL_VERSION,
|
|
623
|
-
userId: userID,
|
|
624
|
-
deviceId: deviceID,
|
|
625
|
-
capabilities: relayCapabilities(),
|
|
626
|
-
skin: SKIN
|
|
627
|
-
})
|
|
628
|
-
);
|
|
629
|
-
});
|
|
630
|
-
sock.on("message", (data) => {
|
|
631
|
-
let msg;
|
|
632
|
-
try {
|
|
633
|
-
msg = JSON.parse(String(data));
|
|
634
|
-
} catch {
|
|
635
|
-
return;
|
|
636
|
-
}
|
|
637
|
-
if (msg.type === "registered") {
|
|
638
|
-
protocolV1Available = msg.protocolVersion === TASK_PROTOCOL_VERSION;
|
|
639
|
-
logger.info("registered to relay", { userId: userID, server: serverURL });
|
|
640
|
-
if (!settled) {
|
|
641
|
-
settled = true;
|
|
642
|
-
resolve();
|
|
643
|
-
}
|
|
644
|
-
} else if (msg.type === "error" && msg.messageId) {
|
|
645
|
-
const pending = pendingRPCs.get(msg.messageId);
|
|
646
|
-
if (pending) {
|
|
647
|
-
pendingRPCs.delete(msg.messageId);
|
|
648
|
-
clearTimeout(pending.timer);
|
|
649
|
-
const err = new Error(msg.message ?? msg.code ?? "relay request failed");
|
|
650
|
-
err.code = msg.code;
|
|
651
|
-
pending.reject(err);
|
|
652
|
-
}
|
|
653
|
-
} else if (msg.messageId && pendingRPCs.has(msg.messageId)) {
|
|
654
|
-
const pending = pendingRPCs.get(msg.messageId);
|
|
655
|
-
pendingRPCs.delete(msg.messageId);
|
|
656
|
-
clearTimeout(pending.timer);
|
|
657
|
-
pending.resolve(msg);
|
|
658
|
-
} else if (msg.type === "task.available") {
|
|
659
|
-
const sender = msg.from ?? msg.task?.envelope.from.userId;
|
|
660
|
-
if (sender) showRemoteFriendVisitor(sender, msg.skin, "有新的远端任务");
|
|
661
|
-
if (msg.taskId && sock.readyState === WebSocket.OPEN) {
|
|
662
|
-
const seq = (taskSequences.get(msg.taskId) ?? 0) + 1;
|
|
663
|
-
taskSequences.set(msg.taskId, seq);
|
|
664
|
-
sock.send(
|
|
665
|
-
JSON.stringify({
|
|
666
|
-
type: "task.delivered",
|
|
667
|
-
protocolVersion: TASK_PROTOCOL_VERSION,
|
|
668
|
-
messageId: crypto.randomUUID(),
|
|
669
|
-
taskId: msg.taskId,
|
|
670
|
-
seq,
|
|
671
|
-
sentAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
672
|
-
fromDeviceId: deviceID,
|
|
673
|
-
traceId: msg.traceId ?? crypto.randomUUID()
|
|
674
|
-
})
|
|
675
|
-
);
|
|
676
|
-
}
|
|
677
|
-
for (const wake of availableTaskWaiters) wake();
|
|
678
|
-
availableTaskWaiters.clear();
|
|
679
|
-
} else if (msg.type === "task.capability.unavailable") {
|
|
680
|
-
showCapabilityUnavailable(msg);
|
|
681
|
-
} else if (msg.type === "task.updated" && msg.task) {
|
|
682
|
-
if (isTerminalTaskStatus(msg.task.status) || msg.task.status === "cancelling") stopLease(msg.task.taskId);
|
|
683
|
-
if (msg.task.status === "running" && msg.task.assignedDeviceId === deviceID) startLease(msg.task.taskId);
|
|
684
|
-
showCapabilityGapResult(msg.task);
|
|
685
|
-
notifyResultWaiters(msg.task);
|
|
686
|
-
} else if (msg.type.startsWith("p2p.") && msg.type !== "p2p.forwarded") {
|
|
687
|
-
if (!verifyP2PSignal(msg)) {
|
|
688
|
-
logger.warn("received invalid p2p signal", {
|
|
689
|
-
type: msg.type,
|
|
690
|
-
taskId: msg.taskId,
|
|
691
|
-
sessionId: msg.sessionId,
|
|
692
|
-
fromDeviceId: msg.fromDeviceId
|
|
693
|
-
});
|
|
694
|
-
} else {
|
|
695
|
-
for (const listener of p2pSignalListeners) listener(msg);
|
|
696
|
-
}
|
|
697
|
-
} else if (msg.type === "help.request" && msg.from) {
|
|
698
|
-
dispatch({ type: "state", state: "excited", ttl: 10 });
|
|
699
|
-
dispatch({ type: "bubble", text: `@${msg.from} 想请你帮忙,右键我可以同意或婉拒`, ttl: 12e3 });
|
|
700
|
-
showDesktopNotification("桌宠好友求助", `@${msg.from} 想请你帮忙,右键桌宠可以同意或婉拒`);
|
|
701
|
-
} else if (msg.type === "help.updated" && msg.to) {
|
|
702
|
-
dispatch({ type: "state", state: msg.status === "accepted" ? "success" : "failed", ttl: 10 });
|
|
703
|
-
dispatch({
|
|
704
|
-
type: "bubble",
|
|
705
|
-
text: msg.status === "accepted" ? `@${msg.to} 同意帮你了,可以让 Agent 派任务啦!` : `@${msg.to} 婉拒了你的求助`,
|
|
706
|
-
ttl: 1e4
|
|
707
|
-
});
|
|
708
|
-
} else if (msg.type === "chat.message" && msg.from && msg.text) {
|
|
709
|
-
dispatch({ type: "state", state: "excited", ttl: 6 });
|
|
710
|
-
dispatch({ type: "bubble", text: `${msg.from}:${msg.text}`, ttl: 1e4 });
|
|
711
|
-
showDesktopNotification(`来自 ${msg.from} 的消息`, msg.text);
|
|
712
|
-
} else if (msg.type === "ask") {
|
|
713
|
-
onAsk(msg);
|
|
714
|
-
} else if (msg.type === "answer") {
|
|
715
|
-
onAnswer(msg);
|
|
716
|
-
} else if (msg.type === "error" && msg.questionId) {
|
|
717
|
-
const pending = pendingAnswers.get(msg.questionId);
|
|
718
|
-
if (pending) {
|
|
719
|
-
pendingAnswers.delete(msg.questionId);
|
|
720
|
-
clearTimeout(pending.timer);
|
|
721
|
-
pending.resolve({
|
|
722
|
-
text: msg.message ?? "对方不在线",
|
|
723
|
-
resultKind: "text",
|
|
724
|
-
from: "",
|
|
725
|
-
questionId: msg.questionId,
|
|
726
|
-
timedOut: true
|
|
727
|
-
});
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
});
|
|
731
|
-
sock.on("error", (err) => {
|
|
732
|
-
logger.warn("relay socket error", { error: String(err) });
|
|
733
|
-
if (!settled) {
|
|
734
|
-
settled = true;
|
|
735
|
-
if (ws === sock) {
|
|
736
|
-
connectPromise = null;
|
|
737
|
-
}
|
|
738
|
-
reject(err);
|
|
739
|
-
}
|
|
740
|
-
});
|
|
741
|
-
sock.on("close", (code, reason) => {
|
|
742
|
-
const isCurrentSocket = ws === sock;
|
|
743
|
-
logger.warn("relay connection closed", {
|
|
744
|
-
server: serverURL,
|
|
745
|
-
code,
|
|
746
|
-
reason: reason.toString(),
|
|
747
|
-
isCurrentSocket
|
|
748
|
-
});
|
|
749
|
-
if (isCurrentSocket) {
|
|
750
|
-
ws = null;
|
|
751
|
-
connectPromise = null;
|
|
752
|
-
}
|
|
753
|
-
if (!settled) {
|
|
754
|
-
settled = true;
|
|
755
|
-
reject(new Error(`relay connection closed before registration (${code})`));
|
|
756
|
-
}
|
|
757
|
-
if (isCurrentSocket && !intentionalClose) {
|
|
758
|
-
setTimeout(() => {
|
|
759
|
-
void ensureConnected().catch(() => {
|
|
760
|
-
});
|
|
761
|
-
}, 3e3);
|
|
762
|
-
}
|
|
763
|
-
});
|
|
764
|
-
});
|
|
765
|
-
return connectPromise;
|
|
766
|
-
}
|
|
767
|
-
async function askFriend(opts) {
|
|
768
|
-
await ensureConnected();
|
|
769
|
-
const questionId = crypto.randomUUID();
|
|
770
|
-
const timeoutMs = opts.timeoutMs ?? 3e5;
|
|
771
|
-
const friendName = opts.to;
|
|
772
|
-
handleQuest({
|
|
773
|
-
type: "quest",
|
|
774
|
-
phase: "leave",
|
|
775
|
-
friendName,
|
|
776
|
-
leaveText: `任务交给我!我去找 ${friendName} 帮忙~`
|
|
777
|
-
});
|
|
778
|
-
return new Promise((resolve) => {
|
|
779
|
-
const timer = setTimeout(() => {
|
|
780
|
-
pendingAnswers.delete(questionId);
|
|
781
|
-
handleQuest({
|
|
782
|
-
type: "quest",
|
|
783
|
-
phase: "return",
|
|
784
|
-
returnText: `对方暂时没理我(${Math.round(timeoutMs / 1e3)} 秒)`,
|
|
785
|
-
fish: 1
|
|
786
|
-
});
|
|
787
|
-
resolve({
|
|
788
|
-
questionId,
|
|
789
|
-
from: "",
|
|
790
|
-
text: `对方暂时没理我(${Math.round(timeoutMs / 1e3)} 秒未回复)`,
|
|
791
|
-
resultKind: "text",
|
|
792
|
-
timedOut: true
|
|
793
|
-
});
|
|
794
|
-
}, timeoutMs);
|
|
795
|
-
pendingAnswers.set(questionId, {
|
|
796
|
-
friendName,
|
|
797
|
-
timer,
|
|
798
|
-
resolve: (value) => {
|
|
799
|
-
handleQuest({
|
|
800
|
-
type: "quest",
|
|
801
|
-
phase: "return",
|
|
802
|
-
returnText: value.timedOut ? value.text.slice(0, 80) : `我回来啦!${value.from || friendName} 那边有结果了`,
|
|
803
|
-
fish: value.timedOut ? 1 : 5
|
|
804
|
-
});
|
|
805
|
-
resolve(value);
|
|
806
|
-
}
|
|
807
|
-
});
|
|
808
|
-
ws.send(
|
|
809
|
-
JSON.stringify({
|
|
810
|
-
type: "ask",
|
|
811
|
-
to: opts.to,
|
|
812
|
-
questionId,
|
|
813
|
-
title: opts.title,
|
|
814
|
-
text: opts.text,
|
|
815
|
-
context: opts.context,
|
|
816
|
-
tools: opts.tools
|
|
817
|
-
})
|
|
818
|
-
);
|
|
819
|
-
logger.info("ask friend", { to: opts.to, questionId });
|
|
820
|
-
});
|
|
821
|
-
}
|
|
822
|
-
async function getCollaborationState() {
|
|
823
|
-
const response = await relayRPC({ type: "help.list" }, 1e4);
|
|
824
|
-
return { friends: response.friends ?? [], helpRequests: response.helpRequests ?? [] };
|
|
825
|
-
}
|
|
826
|
-
async function requestFriendHelp(to) {
|
|
827
|
-
const response = await relayRPC({ type: "help.request", to }, 1e4);
|
|
828
|
-
return response.helpRequests?.[0];
|
|
829
|
-
}
|
|
830
|
-
async function respondFriendHelp(requestId, accept) {
|
|
831
|
-
await relayRPC({ type: "help.respond", requestId, status: accept ? "accepted" : "rejected" }, 1e4);
|
|
832
|
-
}
|
|
833
|
-
async function sendFriendChat(to, text) {
|
|
834
|
-
await relayRPC({ type: "chat.send", to, text }, 1e4);
|
|
835
|
-
}
|
|
836
|
-
function listPendingTasks() {
|
|
837
|
-
return [...pendingTasks.values()].sort((a, b) => b.createdAt - a.createdAt);
|
|
838
|
-
}
|
|
839
|
-
function waitForPendingTask(timeoutMs) {
|
|
840
|
-
const existing = listPendingTasks()[0];
|
|
841
|
-
if (existing) return Promise.resolve(existing);
|
|
842
|
-
const waitMs = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) ? Math.min(Math.max(timeoutMs, 1e3), 55e3) : 45e3;
|
|
843
|
-
return new Promise((resolve) => {
|
|
844
|
-
const waiter = {
|
|
845
|
-
resolve,
|
|
846
|
-
timer: setTimeout(() => {
|
|
847
|
-
pendingTaskWaiters.delete(waiter);
|
|
848
|
-
resolve(null);
|
|
849
|
-
}, waitMs)
|
|
850
|
-
};
|
|
851
|
-
pendingTaskWaiters.add(waiter);
|
|
852
|
-
});
|
|
853
|
-
}
|
|
854
|
-
async function answerFriend(questionId, text, resultKind = "text") {
|
|
855
|
-
await ensureConnected();
|
|
856
|
-
const task = pendingTasks.get(questionId);
|
|
857
|
-
if (!task) {
|
|
858
|
-
const err = new Error("问题不存在或已被回答");
|
|
859
|
-
err.code = "UNKNOWN_QUESTION";
|
|
860
|
-
throw err;
|
|
861
|
-
}
|
|
862
|
-
pendingTasks.delete(questionId);
|
|
863
|
-
ws.send(JSON.stringify({ type: "answer", questionId, text, resultKind }));
|
|
864
|
-
logger.info("answered friend task", { questionId, resultKind });
|
|
865
|
-
dispatch({
|
|
866
|
-
type: "bubble",
|
|
867
|
-
text: `结果已送回给 ${task.from}~`,
|
|
868
|
-
ttl: 6e3
|
|
869
|
-
});
|
|
870
|
-
dispatch({ type: "state", state: "excited", ttl: 6 });
|
|
871
|
-
}
|
|
872
|
-
async function delegateTask(envelope) {
|
|
873
|
-
const authenticatedEnvelope = {
|
|
874
|
-
...envelope,
|
|
875
|
-
from: { userId: userID, deviceId: deviceID }
|
|
876
|
-
};
|
|
877
|
-
const response = await relayRPC(
|
|
878
|
-
{ type: "task.create", protocolVersion: TASK_PROTOCOL_VERSION, envelope: authenticatedEnvelope },
|
|
879
|
-
15e3
|
|
880
|
-
);
|
|
881
|
-
if (!response.task) throw new Error("relay did not return the created task");
|
|
882
|
-
if (response.warning) {
|
|
883
|
-
logger.warn("task created with delivery warning", { warning: response.warning });
|
|
884
|
-
dispatch({ type: "state", state: "failed", ttl: 10 });
|
|
885
|
-
dispatch({ type: "bubble", text: `暂时无法派发任务:${response.warning}`, ttl: 16e3 });
|
|
886
|
-
showDesktopNotification("桌宠任务暂未派发", response.warning);
|
|
887
|
-
}
|
|
888
|
-
return { task: response.task, warning: response.warning };
|
|
889
|
-
}
|
|
890
|
-
function relayCapabilities() {
|
|
891
|
-
return {
|
|
892
|
-
agentHost: mcpRegistration?.agentHost ?? (process.env.DESKTOP_PET_AGENT_HOST || "desktop-pet-mcp"),
|
|
893
|
-
mcp: {
|
|
894
|
-
tools: true,
|
|
895
|
-
sampling: false,
|
|
896
|
-
roots: true,
|
|
897
|
-
tasks: true,
|
|
898
|
-
...mcpRegistration?.capabilities ?? {}
|
|
899
|
-
},
|
|
900
|
-
execution: executionCapabilities(),
|
|
901
|
-
platform: `${process.platform}-${process.arch}`
|
|
902
|
-
};
|
|
903
|
-
}
|
|
904
|
-
function showCapabilityUnavailable(msg) {
|
|
905
|
-
const required = msg.requiredCapabilities ?? [];
|
|
906
|
-
const available = new Set(executionCapabilities());
|
|
907
|
-
const missing = required.filter((capability) => !available.has(capability));
|
|
908
|
-
const named = (missing.length > 0 ? missing : required).join("、") || "所需工具";
|
|
909
|
-
const sender = msg.from ? `@${msg.from} 的` : "这项";
|
|
910
|
-
const text = `暂时无法帮忙:${sender}任务需要 ${named}。请在当前 Agent 中安装或启用对应工具后重新连接桌宠。`;
|
|
911
|
-
dispatch({ type: "state", state: "failed", ttl: 12 });
|
|
912
|
-
dispatch({ type: "bubble", text, ttl: 18e3 });
|
|
913
|
-
showDesktopNotification("桌宠暂时无法接单", text);
|
|
914
|
-
}
|
|
915
|
-
async function getRemoteTask(taskId) {
|
|
916
|
-
const response = await relayRPC(
|
|
917
|
-
{ type: "task.get", protocolVersion: TASK_PROTOCOL_VERSION, taskId },
|
|
918
|
-
1e4
|
|
919
|
-
);
|
|
920
|
-
if (!response.task) throw new Error("relay did not return task details");
|
|
921
|
-
return response.task;
|
|
922
|
-
}
|
|
923
|
-
async function listRemoteTasks(options) {
|
|
924
|
-
const response = await relayRPC(
|
|
925
|
-
{
|
|
926
|
-
type: "task.list",
|
|
927
|
-
protocolVersion: TASK_PROTOCOL_VERSION,
|
|
928
|
-
direction: options.direction,
|
|
929
|
-
status: options.status,
|
|
930
|
-
limit: options.limit
|
|
931
|
-
},
|
|
932
|
-
1e4
|
|
933
|
-
);
|
|
934
|
-
return response.tasks ?? [];
|
|
935
|
-
}
|
|
936
|
-
async function claimRemoteTask(timeoutMs = 45e3) {
|
|
937
|
-
const immediate = await claimOnce();
|
|
938
|
-
if (immediate) {
|
|
939
|
-
startLease(immediate.taskId);
|
|
940
|
-
dispatch({ type: "state", state: "working", ttl: 30 });
|
|
941
|
-
return immediate;
|
|
942
|
-
}
|
|
943
|
-
const waitMs = Math.min(Math.max(timeoutMs, 1e3), 55e3);
|
|
944
|
-
const available = await new Promise((resolve) => {
|
|
945
|
-
let done = false;
|
|
946
|
-
const wake = () => {
|
|
947
|
-
if (done) return;
|
|
948
|
-
done = true;
|
|
949
|
-
clearTimeout(timer);
|
|
950
|
-
availableTaskWaiters.delete(wake);
|
|
951
|
-
resolve(true);
|
|
952
|
-
};
|
|
953
|
-
const timer = setTimeout(() => {
|
|
954
|
-
if (done) return;
|
|
955
|
-
done = true;
|
|
956
|
-
availableTaskWaiters.delete(wake);
|
|
957
|
-
resolve(false);
|
|
958
|
-
}, waitMs);
|
|
959
|
-
availableTaskWaiters.add(wake);
|
|
960
|
-
});
|
|
961
|
-
if (!available) return null;
|
|
962
|
-
const claimed = await claimOnce();
|
|
963
|
-
if (claimed) {
|
|
964
|
-
startLease(claimed.taskId);
|
|
965
|
-
dispatch({ type: "state", state: "working", ttl: 30 });
|
|
966
|
-
}
|
|
967
|
-
return claimed;
|
|
968
|
-
}
|
|
969
|
-
async function waitRemoteTaskResult(taskId, timeoutMs = 45e3) {
|
|
970
|
-
const current = await getRemoteTask(taskId);
|
|
971
|
-
if (isTerminalTaskStatus(current.status) || current.status === "needs_context") return current;
|
|
972
|
-
const waitMs = Math.min(Math.max(timeoutMs, 1e3), 55e3);
|
|
973
|
-
return new Promise((resolve) => {
|
|
974
|
-
const waiters = resultWaiters.get(taskId) ?? /* @__PURE__ */ new Set();
|
|
975
|
-
const waiter = {
|
|
976
|
-
resolve: (task) => resolve(task ?? current),
|
|
977
|
-
timer: setTimeout(() => {
|
|
978
|
-
waiters.delete(waiter);
|
|
979
|
-
if (waiters.size === 0) resultWaiters.delete(taskId);
|
|
980
|
-
resolve(current);
|
|
981
|
-
}, waitMs)
|
|
982
|
-
};
|
|
983
|
-
waiters.add(waiter);
|
|
984
|
-
resultWaiters.set(taskId, waiters);
|
|
985
|
-
});
|
|
986
|
-
}
|
|
987
|
-
async function submitRemoteTaskResult(result) {
|
|
988
|
-
await relayRPC(
|
|
989
|
-
{
|
|
990
|
-
type: "task.result",
|
|
991
|
-
protocolVersion: TASK_PROTOCOL_VERSION,
|
|
992
|
-
taskId: result.taskId,
|
|
993
|
-
result
|
|
994
|
-
},
|
|
995
|
-
15e3
|
|
996
|
-
);
|
|
997
|
-
stopLease(result.taskId);
|
|
998
|
-
dispatch({ type: "state", state: result.status === "completed" ? "success" : "failed", ttl: 12 });
|
|
999
|
-
const gap = result.diagnostics.find((diagnostic) => diagnostic.code === "CAPABILITY_UNAVAILABLE");
|
|
1000
|
-
if (gap) {
|
|
1001
|
-
dispatch({ type: "bubble", text: `暂时无法帮忙:${gap.message}`, ttl: 18e3 });
|
|
1002
|
-
showDesktopNotification("桌宠暂时无法接单", gap.message);
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
function showCapabilityGapResult(task) {
|
|
1006
|
-
const gap = task.result?.diagnostics.find((diagnostic) => diagnostic.code === "CAPABILITY_UNAVAILABLE");
|
|
1007
|
-
if (!gap) return;
|
|
1008
|
-
const text = `@${task.envelope.to.userId} 暂时无法帮助:${gap.message}`;
|
|
1009
|
-
dispatch({ type: "state", state: "failed", ttl: 12 });
|
|
1010
|
-
dispatch({ type: "bubble", text, ttl: 18e3 });
|
|
1011
|
-
showDesktopNotification("桌宠任务无法完成", text);
|
|
1012
|
-
}
|
|
1013
|
-
async function cancelRemoteTask(taskId) {
|
|
1014
|
-
const response = await relayRPC(
|
|
1015
|
-
{ type: "task.cancel", protocolVersion: TASK_PROTOCOL_VERSION, taskId },
|
|
1016
|
-
1e4
|
|
1017
|
-
);
|
|
1018
|
-
return response.task;
|
|
1019
|
-
}
|
|
1020
|
-
function onP2PSignal(listener) {
|
|
1021
|
-
p2pSignalListeners.add(listener);
|
|
1022
|
-
return () => p2pSignalListeners.delete(listener);
|
|
1023
|
-
}
|
|
1024
|
-
async function sendP2PSignal(message) {
|
|
1025
|
-
if (!signingPrivateKey) throw new Error("设备未配置 P2P 签名密钥");
|
|
1026
|
-
const outgoing = { ...message, protocolVersion: TASK_PROTOCOL_VERSION };
|
|
1027
|
-
const signature = crypto.sign(null, Buffer.from(p2pSigningPayload(outgoing, deviceID)), signingPrivateKey).toString("base64");
|
|
1028
|
-
await relayRPC({ ...outgoing, signature }, 1e4);
|
|
1029
|
-
}
|
|
1030
|
-
async function requestRemoteTaskContext(taskId, request) {
|
|
1031
|
-
const response = await relayRPC(
|
|
1032
|
-
{ type: "task.context.request", protocolVersion: TASK_PROTOCOL_VERSION, taskId, payload: request },
|
|
1033
|
-
1e4
|
|
1034
|
-
);
|
|
1035
|
-
return response.task;
|
|
1036
|
-
}
|
|
1037
|
-
async function supplyRemoteTaskContext(taskId, items) {
|
|
1038
|
-
const response = await relayRPC(
|
|
1039
|
-
{ type: "task.context.supply", protocolVersion: TASK_PROTOCOL_VERSION, taskId, payload: { items } },
|
|
1040
|
-
15e3
|
|
1041
|
-
);
|
|
1042
|
-
return response.task;
|
|
1043
|
-
}
|
|
1044
|
-
async function claimOnce() {
|
|
1045
|
-
const response = await relayRPC(
|
|
1046
|
-
{ type: "task.claim", protocolVersion: TASK_PROTOCOL_VERSION },
|
|
1047
|
-
1e4
|
|
1048
|
-
);
|
|
1049
|
-
return response.task ?? null;
|
|
1050
|
-
}
|
|
1051
|
-
async function relayRPC(message, timeoutMs) {
|
|
1052
|
-
await ensureConnected();
|
|
1053
|
-
const messageId = crypto.randomUUID();
|
|
1054
|
-
return new Promise((resolve, reject) => {
|
|
1055
|
-
const timer = setTimeout(() => {
|
|
1056
|
-
pendingRPCs.delete(messageId);
|
|
1057
|
-
reject(new Error(`relay request timed out: ${message.type}`));
|
|
1058
|
-
}, timeoutMs);
|
|
1059
|
-
pendingRPCs.set(messageId, { resolve, reject, timer });
|
|
1060
|
-
const taskKey = message.taskId ?? message.envelope?.taskId;
|
|
1061
|
-
const seq = taskKey ? (taskSequences.get(taskKey) ?? 0) + 1 : 0;
|
|
1062
|
-
if (taskKey) taskSequences.set(taskKey, seq);
|
|
1063
|
-
ws.send(
|
|
1064
|
-
JSON.stringify({
|
|
1065
|
-
...message,
|
|
1066
|
-
messageId,
|
|
1067
|
-
seq,
|
|
1068
|
-
sentAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1069
|
-
fromDeviceId: deviceID,
|
|
1070
|
-
traceId: message.traceId ?? crypto.randomUUID()
|
|
1071
|
-
})
|
|
1072
|
-
);
|
|
1073
|
-
});
|
|
1074
|
-
}
|
|
1075
|
-
function notifyResultWaiters(task) {
|
|
1076
|
-
const waiters = resultWaiters.get(task.taskId);
|
|
1077
|
-
if (!waiters) return;
|
|
1078
|
-
resultWaiters.delete(task.taskId);
|
|
1079
|
-
for (const waiter of waiters) {
|
|
1080
|
-
clearTimeout(waiter.timer);
|
|
1081
|
-
waiter.resolve(task);
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
function startLease(taskId) {
|
|
1085
|
-
stopLease(taskId);
|
|
1086
|
-
const timer = setInterval(() => {
|
|
1087
|
-
void relayRPC({ type: "task.lease", protocolVersion: TASK_PROTOCOL_VERSION, taskId }, 1e4).catch(() => {
|
|
1088
|
-
stopLease(taskId);
|
|
1089
|
-
});
|
|
1090
|
-
}, 3e4);
|
|
1091
|
-
leaseTimers.set(taskId, timer);
|
|
1092
|
-
}
|
|
1093
|
-
function stopLease(taskId) {
|
|
1094
|
-
const timer = leaseTimers.get(taskId);
|
|
1095
|
-
if (timer) clearInterval(timer);
|
|
1096
|
-
leaseTimers.delete(taskId);
|
|
1097
|
-
}
|
|
1098
|
-
function onAsk(msg) {
|
|
1099
|
-
if (!msg.questionId || !msg.from || !msg.text) return;
|
|
1100
|
-
const task = {
|
|
1101
|
-
questionId: msg.questionId,
|
|
1102
|
-
from: msg.from,
|
|
1103
|
-
title: msg.title,
|
|
1104
|
-
text: msg.text,
|
|
1105
|
-
context: msg.context,
|
|
1106
|
-
tools: msg.tools,
|
|
1107
|
-
createdAt: Date.now()
|
|
1108
|
-
};
|
|
1109
|
-
pendingTasks.set(msg.questionId, task);
|
|
1110
|
-
notifyTaskWaiter(task);
|
|
1111
|
-
logger.info("received friend task", { from: msg.from, questionId: msg.questionId });
|
|
1112
|
-
const preview = (msg.title || msg.text).slice(0, 40);
|
|
1113
|
-
dispatch({ type: "state", state: "excited", ttl: 12 });
|
|
1114
|
-
dispatch({
|
|
1115
|
-
type: "bubble",
|
|
1116
|
-
text: `有任务了!来自 ${msg.from}:${preview}`,
|
|
1117
|
-
ttl: 16e3
|
|
1118
|
-
});
|
|
1119
|
-
setTimeout(() => {
|
|
1120
|
-
dispatch({
|
|
1121
|
-
type: "bubble",
|
|
1122
|
-
text: "跟 Agent 说:帮我朋友一下",
|
|
1123
|
-
ttl: 8e3
|
|
1124
|
-
});
|
|
1125
|
-
}, 2500);
|
|
1126
|
-
showRemoteFriendVisitor(msg.from, msg.skin, "又发来了一项任务");
|
|
1127
|
-
}
|
|
1128
|
-
function showRemoteFriendVisitor(username, requestedSkin, message = "发来了一项任务") {
|
|
1129
|
-
const existingVisitor = petManager.findRemoteFriend(username);
|
|
1130
|
-
if (existingVisitor) {
|
|
1131
|
-
dispatch({ type: "state", state: "excited", ttl: 12 }, existingVisitor.id);
|
|
1132
|
-
dispatch({ type: "bubble", text: `@${username} ${message}`, ttl: 8e3 }, existingVisitor.id);
|
|
1133
|
-
return;
|
|
1134
|
-
}
|
|
1135
|
-
const skin = requestedSkin && SKIN_IDS.includes(requestedSkin) ? requestedSkin : "claude";
|
|
1136
|
-
void Promise.resolve().then(() => petWindow).then(
|
|
1137
|
-
({ createPetWindow: createPetWindow2 }) => createPetWindow2(skin, { identity: { kind: "remote-friend", username } })
|
|
1138
|
-
);
|
|
1139
|
-
}
|
|
1140
|
-
function notifyTaskWaiter(task) {
|
|
1141
|
-
const waiter = pendingTaskWaiters.values().next().value;
|
|
1142
|
-
if (!waiter) return;
|
|
1143
|
-
pendingTaskWaiters.delete(waiter);
|
|
1144
|
-
clearTimeout(waiter.timer);
|
|
1145
|
-
waiter.resolve(task);
|
|
1146
|
-
}
|
|
1147
|
-
function onAnswer(msg) {
|
|
1148
|
-
if (!msg.questionId) return;
|
|
1149
|
-
const pending = pendingAnswers.get(msg.questionId);
|
|
1150
|
-
if (!pending) return;
|
|
1151
|
-
pendingAnswers.delete(msg.questionId);
|
|
1152
|
-
clearTimeout(pending.timer);
|
|
1153
|
-
const kind = normalizeResultKind$1(msg.resultKind);
|
|
1154
|
-
pending.resolve({
|
|
1155
|
-
questionId: msg.questionId,
|
|
1156
|
-
from: msg.from ?? "",
|
|
1157
|
-
text: msg.text ?? "",
|
|
1158
|
-
resultKind: kind
|
|
1159
|
-
});
|
|
1160
|
-
}
|
|
1161
|
-
function normalizeResultKind$1(v) {
|
|
1162
|
-
if (v === "code" || v === "patch" || v === "markdown" || v === "files" || v === "text") return v;
|
|
1163
|
-
return "text";
|
|
1164
|
-
}
|
|
1165
|
-
let refreshTimer;
|
|
1166
|
-
let deviceIceServers = [];
|
|
1167
|
-
function getDeviceIceServers() {
|
|
1168
|
-
return deviceIceServers;
|
|
1169
|
-
}
|
|
1170
|
-
async function activateStoredDevice() {
|
|
1171
|
-
if (process.env.DESKTOP_PET_TOKEN?.trim()) {
|
|
1172
|
-
startRelay();
|
|
1173
|
-
return true;
|
|
1174
|
-
}
|
|
1175
|
-
const credentials = loadDeviceCredentials();
|
|
1176
|
-
if (!credentials) return false;
|
|
1177
|
-
await activateDevice(credentials, false);
|
|
1178
|
-
return true;
|
|
1179
|
-
}
|
|
1180
|
-
async function configureAndActivateDevice(credentials) {
|
|
1181
|
-
const signedCredentials = withSigningKeys(credentials);
|
|
1182
|
-
saveDeviceCredentials(signedCredentials);
|
|
1183
|
-
await activateDevice(signedCredentials, true);
|
|
1184
|
-
}
|
|
1185
|
-
async function activateDevice(credentials, restart) {
|
|
1186
|
-
if (!credentials.publicKey || !credentials.privateKey || !credentials.encryptionPublicKey || !credentials.encryptionPrivateKey) {
|
|
1187
|
-
credentials = withSigningKeys(credentials);
|
|
1188
|
-
saveDeviceCredentials(credentials);
|
|
1189
|
-
}
|
|
1190
|
-
const base = credentials.serverUrl.replace(/\/$/, "");
|
|
1191
|
-
const response = await fetch(`${base}/api/auth/device-token`, {
|
|
1192
|
-
method: "POST",
|
|
1193
|
-
headers: { "content-type": "application/json" },
|
|
1194
|
-
body: JSON.stringify({
|
|
1195
|
-
deviceId: credentials.deviceId,
|
|
1196
|
-
refreshToken: credentials.refreshToken,
|
|
1197
|
-
publicKey: credentials.publicKey,
|
|
1198
|
-
encryptionPublicKey: credentials.encryptionPublicKey
|
|
1199
|
-
})
|
|
1200
|
-
});
|
|
1201
|
-
const body = await response.json();
|
|
1202
|
-
if (!response.ok || body.code !== 0 || !body.data?.token) {
|
|
1203
|
-
throw new Error(body.message || "设备令牌刷新失败");
|
|
1204
|
-
}
|
|
1205
|
-
if (body.data.user.username.toLowerCase() !== credentials.userId.toLowerCase()) {
|
|
1206
|
-
throw new Error("设备凭据用户与配置用户不一致");
|
|
1207
|
-
}
|
|
1208
|
-
const iceResponse = await fetch(`${base}/api/p2p/ice-config`, {
|
|
1209
|
-
headers: { Authorization: `Bearer ${body.data.token}` }
|
|
1210
|
-
}).catch(() => void 0);
|
|
1211
|
-
if (iceResponse?.ok) {
|
|
1212
|
-
const iceBody = await iceResponse.json();
|
|
1213
|
-
deviceIceServers = iceBody.data?.iceServers ?? [];
|
|
1214
|
-
}
|
|
1215
|
-
if (restart) stopRelay();
|
|
1216
|
-
const httpURL = new URL(base);
|
|
1217
|
-
httpURL.protocol = httpURL.protocol === "https:" ? "wss:" : "ws:";
|
|
1218
|
-
httpURL.pathname = "/ws";
|
|
1219
|
-
configureRelayIdentity({
|
|
1220
|
-
serverUrl: httpURL.toString(),
|
|
1221
|
-
userId: credentials.userId,
|
|
1222
|
-
deviceId: credentials.deviceId,
|
|
1223
|
-
accessToken: body.data.token,
|
|
1224
|
-
signingPrivateKey: credentials.privateKey
|
|
1225
|
-
});
|
|
1226
|
-
startRelay();
|
|
1227
|
-
clearTimeout(refreshTimer);
|
|
1228
|
-
const refreshAt = Math.max(6e4, new Date(body.data.expiresAt).getTime() - Date.now() - 5 * 6e4);
|
|
1229
|
-
refreshTimer = setTimeout(() => {
|
|
1230
|
-
void activateDevice(credentials, true).catch(
|
|
1231
|
-
(err) => logger.warn("device access token refresh failed", { error: String(err) })
|
|
1232
|
-
);
|
|
1233
|
-
}, refreshAt);
|
|
1234
|
-
}
|
|
1235
|
-
function withSigningKeys(credentials) {
|
|
1236
|
-
let next = credentials;
|
|
1237
|
-
if (!next.publicKey || !next.privateKey) {
|
|
1238
|
-
const { publicKey, privateKey } = node_crypto.generateKeyPairSync("ed25519", {
|
|
1239
|
-
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
1240
|
-
privateKeyEncoding: { type: "pkcs8", format: "pem" }
|
|
1241
|
-
});
|
|
1242
|
-
next = { ...next, publicKey, privateKey };
|
|
1243
|
-
}
|
|
1244
|
-
if (!next.encryptionPublicKey || !next.encryptionPrivateKey) {
|
|
1245
|
-
const { publicKey, privateKey } = node_crypto.generateKeyPairSync("rsa", {
|
|
1246
|
-
modulusLength: 2048,
|
|
1247
|
-
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
1248
|
-
privateKeyEncoding: { type: "pkcs8", format: "pem" }
|
|
1249
|
-
});
|
|
1250
|
-
next = { ...next, encryptionPublicKey: publicKey, encryptionPrivateKey: privateKey };
|
|
1251
|
-
}
|
|
1252
|
-
return next;
|
|
1253
|
-
}
|
|
1254
|
-
const deviceAuth = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
1255
|
-
__proto__: null,
|
|
1256
|
-
activateStoredDevice,
|
|
1257
|
-
configureAndActivateDevice,
|
|
1258
|
-
getDeviceIceServers
|
|
1259
|
-
}, Symbol.toStringTag, { value: "Module" }));
|
|
1260
|
-
function defaultAccountServerURL(relayURL = configuredRelayURL()) {
|
|
1261
|
-
const url = new URL(relayURL);
|
|
1262
|
-
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
|
1263
|
-
url.pathname = "";
|
|
1264
|
-
url.search = "";
|
|
1265
|
-
url.hash = "";
|
|
1266
|
-
return url.toString().replace(/\/$/, "");
|
|
1267
|
-
}
|
|
1268
|
-
function defaultAccountPortalURL(serverUrl) {
|
|
1269
|
-
const override = process.env.DESKTOP_PET_PORTAL_URL?.trim();
|
|
1270
|
-
return override ? normalizeServerURL(override) : serverUrl;
|
|
1271
|
-
}
|
|
1272
|
-
async function bindDesktopPetToWebSession(server, accessToken) {
|
|
1273
|
-
const serverUrl = normalizeServerURL(server);
|
|
1274
|
-
if (!accessToken) throw new Error("网页没有返回有效的登录会话");
|
|
1275
|
-
const session = await get(serverUrl, "/api/auth/me", accessToken);
|
|
1276
|
-
if (!session.user?.username) throw new Error("服务器没有返回有效的账号信息");
|
|
1277
|
-
const device = await post(
|
|
1278
|
-
serverUrl,
|
|
1279
|
-
"/api/devices",
|
|
1280
|
-
{
|
|
1281
|
-
name: `Desktop Pet · ${node_os.hostname()}`,
|
|
1282
|
-
capabilities: {
|
|
1283
|
-
agentHost: process.env.DESKTOP_PET_AGENT_HOST || "unknown-mcp-host",
|
|
1284
|
-
mcp: { tools: true, sampling: false, roots: true, tasks: true },
|
|
1285
|
-
execution: executionCapabilities(),
|
|
1286
|
-
platform: `${process.platform}-${process.arch}`
|
|
1287
|
-
}
|
|
1288
|
-
},
|
|
1289
|
-
accessToken
|
|
1290
|
-
);
|
|
1291
|
-
if (!device.device?.id || !device.refreshToken) throw new Error("服务器没有返回有效的桌宠设备凭据");
|
|
1292
|
-
await configureAndActivateDevice({
|
|
1293
|
-
serverUrl,
|
|
1294
|
-
userId: session.user.username,
|
|
1295
|
-
deviceId: device.device.id,
|
|
1296
|
-
refreshToken: device.refreshToken
|
|
1297
|
-
});
|
|
1298
|
-
return { username: session.user.username };
|
|
1299
|
-
}
|
|
1300
|
-
function normalizeServerURL(value) {
|
|
1301
|
-
let url;
|
|
1302
|
-
try {
|
|
1303
|
-
url = new URL(value.trim());
|
|
1304
|
-
} catch {
|
|
1305
|
-
throw new Error("中转服务器地址无效");
|
|
1306
|
-
}
|
|
1307
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1308
|
-
throw new Error("中转服务器地址必须以 http:// 或 https:// 开头");
|
|
1309
|
-
}
|
|
1310
|
-
url.pathname = "";
|
|
1311
|
-
url.search = "";
|
|
1312
|
-
url.hash = "";
|
|
1313
|
-
return url.toString().replace(/\/$/, "");
|
|
1314
|
-
}
|
|
1315
|
-
async function post(serverUrl, path2, body, token) {
|
|
1316
|
-
let response;
|
|
1317
|
-
try {
|
|
1318
|
-
response = await fetch(`${serverUrl}${path2}`, {
|
|
1319
|
-
method: "POST",
|
|
1320
|
-
headers: {
|
|
1321
|
-
"content-type": "application/json",
|
|
1322
|
-
...token ? { authorization: `Bearer ${token}` } : {}
|
|
1323
|
-
},
|
|
1324
|
-
body: JSON.stringify(body)
|
|
1325
|
-
});
|
|
1326
|
-
} catch {
|
|
1327
|
-
throw new Error("无法连接中转服务器");
|
|
1328
|
-
}
|
|
1329
|
-
let envelope;
|
|
1330
|
-
try {
|
|
1331
|
-
envelope = await response.json();
|
|
1332
|
-
} catch {
|
|
1333
|
-
throw new Error("中转服务器返回了无效响应");
|
|
1334
|
-
}
|
|
1335
|
-
if (!response.ok || envelope.code !== 0 || !envelope.data) {
|
|
1336
|
-
throw new Error(envelope.message || "请求失败");
|
|
1337
|
-
}
|
|
1338
|
-
return envelope.data;
|
|
1339
|
-
}
|
|
1340
|
-
async function get(serverUrl, path2, token) {
|
|
1341
|
-
let response;
|
|
1342
|
-
try {
|
|
1343
|
-
response = await fetch(`${serverUrl}${path2}`, { headers: { authorization: `Bearer ${token}` } });
|
|
1344
|
-
} catch {
|
|
1345
|
-
throw new Error("无法连接中转服务器");
|
|
1346
|
-
}
|
|
1347
|
-
let envelope;
|
|
1348
|
-
try {
|
|
1349
|
-
envelope = await response.json();
|
|
1350
|
-
} catch {
|
|
1351
|
-
throw new Error("中转服务器返回了无效响应");
|
|
1352
|
-
}
|
|
1353
|
-
if (!response.ok || envelope.code !== 0 || !envelope.data) {
|
|
1354
|
-
throw new Error(envelope.message || "登录会话已失效");
|
|
1355
|
-
}
|
|
1356
|
-
return envelope.data;
|
|
1357
|
-
}
|
|
1358
|
-
const pendingServers = /* @__PURE__ */ new Set();
|
|
1359
|
-
let callbackServer;
|
|
1360
|
-
let callbackPort;
|
|
1361
|
-
let callbackNonce;
|
|
1362
|
-
async function startAccountCallbackRouter() {
|
|
1363
|
-
if (callbackServer) return;
|
|
1364
|
-
callbackNonce = node_crypto.randomBytes(24).toString("base64url");
|
|
1365
|
-
callbackServer = node_http.createServer((req, res) => {
|
|
1366
|
-
if (req.method !== "POST" || req.url !== "/auth-callback") {
|
|
1367
|
-
res.writeHead(404).end();
|
|
1368
|
-
return;
|
|
1369
|
-
}
|
|
1370
|
-
let body = "";
|
|
1371
|
-
req.setEncoding("utf8");
|
|
1372
|
-
req.on("data", (chunk) => {
|
|
1373
|
-
body += chunk;
|
|
1374
|
-
if (body.length > 16 * 1024) req.destroy();
|
|
1375
|
-
});
|
|
1376
|
-
req.on("end", () => {
|
|
1377
|
-
try {
|
|
1378
|
-
const payload = JSON.parse(body);
|
|
1379
|
-
if (payload.nonce !== callbackNonce || typeof payload.url !== "string") {
|
|
1380
|
-
res.writeHead(403).end();
|
|
1381
|
-
return;
|
|
1382
|
-
}
|
|
1383
|
-
res.writeHead(204).end();
|
|
1384
|
-
void finishWebAuthentication(payload.url);
|
|
1385
|
-
} catch {
|
|
1386
|
-
res.writeHead(400).end();
|
|
1387
|
-
}
|
|
1388
|
-
});
|
|
1389
|
-
});
|
|
1390
|
-
await new Promise((resolve, reject) => {
|
|
1391
|
-
callbackServer.once("error", reject);
|
|
1392
|
-
callbackServer.listen(0, "127.0.0.1", () => {
|
|
1393
|
-
callbackServer.removeListener("error", reject);
|
|
1394
|
-
const address = callbackServer.address();
|
|
1395
|
-
if (!address || typeof address === "string") {
|
|
1396
|
-
reject(new Error("本地认证回调端口不可用"));
|
|
1397
|
-
return;
|
|
1398
|
-
}
|
|
1399
|
-
callbackPort = address.port;
|
|
1400
|
-
resolve();
|
|
1401
|
-
});
|
|
1402
|
-
});
|
|
1403
|
-
}
|
|
1404
|
-
function stopAccountCallbackRouter() {
|
|
1405
|
-
callbackServer?.close();
|
|
1406
|
-
callbackServer = void 0;
|
|
1407
|
-
callbackPort = void 0;
|
|
1408
|
-
callbackNonce = void 0;
|
|
1409
|
-
}
|
|
1410
|
-
function openAccountWindow(mode) {
|
|
1411
|
-
const serverUrl = storedServerURL() ?? defaultAccountServerURL();
|
|
1412
|
-
let portalBase;
|
|
1413
|
-
try {
|
|
1414
|
-
portalBase = defaultAccountPortalURL(serverUrl);
|
|
1415
|
-
} catch (error) {
|
|
1416
|
-
electron.dialog.showErrorBox("门户地址无效", error instanceof Error ? error.message : "请检查 DESKTOP_PET_PORTAL_URL 配置");
|
|
1417
|
-
return;
|
|
1418
|
-
}
|
|
1419
|
-
const portalURL = new URL("/", `${portalBase}/`);
|
|
1420
|
-
portalURL.searchParams.set("desktopPet", "1");
|
|
1421
|
-
portalURL.searchParams.set("desktopPetMode", mode);
|
|
1422
|
-
const callbackURL = new URL("desktop-pet://auth-complete");
|
|
1423
|
-
callbackURL.searchParams.set("server", serverUrl);
|
|
1424
|
-
if (callbackPort && callbackNonce) {
|
|
1425
|
-
callbackURL.searchParams.set("callbackPort", String(callbackPort));
|
|
1426
|
-
callbackURL.searchParams.set("callbackNonce", callbackNonce);
|
|
1427
|
-
}
|
|
1428
|
-
portalURL.searchParams.set("desktopPetReturn", callbackURL.toString());
|
|
1429
|
-
pendingServers.add(serverUrl);
|
|
1430
|
-
dispatch({ type: "bubble", text: "已打开默认浏览器,请完成账号认证~", ttl: 5e3 });
|
|
1431
|
-
void electron.shell.openExternal(portalURL.toString()).catch((error) => {
|
|
1432
|
-
pendingServers.delete(serverUrl);
|
|
1433
|
-
electron.dialog.showErrorBox("无法打开浏览器", error instanceof Error ? error.message : "请检查默认浏览器设置");
|
|
1434
|
-
});
|
|
1435
|
-
}
|
|
1436
|
-
function registerAccountProtocol() {
|
|
1437
|
-
if (process.defaultApp && process.argv[1]) {
|
|
1438
|
-
electron.app.setAsDefaultProtocolClient("desktop-pet", process.execPath, [process.argv[1]]);
|
|
1439
|
-
} else {
|
|
1440
|
-
electron.app.setAsDefaultProtocolClient("desktop-pet");
|
|
1441
|
-
}
|
|
1442
|
-
electron.app.on("open-url", (event, url) => {
|
|
1443
|
-
event.preventDefault();
|
|
1444
|
-
void handleAccountCallback(url);
|
|
1445
|
-
});
|
|
1446
|
-
}
|
|
1447
|
-
function handleAccountCallback(url) {
|
|
1448
|
-
const callback = parseDesktopPetCallback(url);
|
|
1449
|
-
if (callback?.callbackPort && callback.callbackNonce) {
|
|
1450
|
-
void forwardAuthenticationCallback(callback.callbackPort, callback.callbackNonce, url);
|
|
1451
|
-
return;
|
|
1452
|
-
}
|
|
1453
|
-
void finishWebAuthentication(url);
|
|
1454
|
-
}
|
|
1455
|
-
function logoutDesktopPet() {
|
|
1456
|
-
stopRelay();
|
|
1457
|
-
clearDeviceCredentials();
|
|
1458
|
-
petManager.refreshLocalIdentityBadges();
|
|
1459
|
-
dispatch({ type: "bubble", text: "已退出当前账号" });
|
|
1460
|
-
}
|
|
1461
|
-
function currentAccountUsername() {
|
|
1462
|
-
try {
|
|
1463
|
-
return loadDeviceCredentials()?.userId ?? getAuthenticatedRelayUserId();
|
|
1464
|
-
} catch {
|
|
1465
|
-
return getAuthenticatedRelayUserId();
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1468
|
-
function storedServerURL() {
|
|
1469
|
-
try {
|
|
1470
|
-
return loadDeviceCredentials()?.serverUrl;
|
|
1471
|
-
} catch {
|
|
1472
|
-
return void 0;
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
function parseDesktopPetCallback(value) {
|
|
1476
|
-
try {
|
|
1477
|
-
const callback = new URL(value);
|
|
1478
|
-
if (callback.protocol !== "desktop-pet:" || callback.hostname !== "auth-complete") return void 0;
|
|
1479
|
-
const serverUrl = callback.searchParams.get("server");
|
|
1480
|
-
const token = new URLSearchParams(callback.hash.slice(1)).get("token");
|
|
1481
|
-
if (!serverUrl || !token) return void 0;
|
|
1482
|
-
const rawPort = callback.searchParams.get("callbackPort");
|
|
1483
|
-
const callbackPort2 = rawPort ? Number(rawPort) : void 0;
|
|
1484
|
-
const callbackNonce2 = callback.searchParams.get("callbackNonce") ?? void 0;
|
|
1485
|
-
if (callbackPort2 && (!Number.isInteger(callbackPort2) || callbackPort2 < 1 || callbackPort2 > 65535)) return void 0;
|
|
1486
|
-
return { serverUrl, token, callbackPort: callbackPort2, callbackNonce: callbackNonce2 };
|
|
1487
|
-
} catch {
|
|
1488
|
-
return void 0;
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
async function forwardAuthenticationCallback(port, nonce, url) {
|
|
1492
|
-
try {
|
|
1493
|
-
const response = await fetch(`http://127.0.0.1:${port}/auth-callback`, {
|
|
1494
|
-
method: "POST",
|
|
1495
|
-
headers: { "content-type": "application/json" },
|
|
1496
|
-
body: JSON.stringify({ nonce, url })
|
|
1497
|
-
});
|
|
1498
|
-
if (!response.ok) throw new Error(`本地认证回调被拒绝 (${response.status})`);
|
|
1499
|
-
} catch (error) {
|
|
1500
|
-
electron.dialog.showErrorBox("无法回传登录结果", error instanceof Error ? error.message : "请重新在对应桌宠窗口发起登录");
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1503
|
-
async function finishWebAuthentication(callbackURL) {
|
|
1504
|
-
const callback = parseDesktopPetCallback(callbackURL);
|
|
1505
|
-
if (!callback || !pendingServers.has(callback.serverUrl)) return;
|
|
1506
|
-
pendingServers.delete(callback.serverUrl);
|
|
1507
|
-
try {
|
|
1508
|
-
const result = await bindDesktopPetToWebSession(callback.serverUrl, callback.token);
|
|
1509
|
-
petManager.refreshLocalIdentityBadges(result.username);
|
|
1510
|
-
dispatch({ type: "bubble", text: `已登录 @${result.username}` });
|
|
1511
|
-
} catch (error) {
|
|
1512
|
-
const message = error instanceof Error ? error.message : "登录后的桌宠绑定失败";
|
|
1513
|
-
dispatch({ type: "bubble", text: `登录失败:${message}` });
|
|
1514
|
-
electron.dialog.showErrorBox("Desktop Pet 登录失败", message);
|
|
1515
|
-
}
|
|
1516
|
-
}
|
|
1517
|
-
function copyMcpConfig() {
|
|
1518
|
-
const serverEntry = path.join(electron.app.getAppPath(), "out", "mcp-server", "index.js");
|
|
1519
|
-
if (!fs.existsSync(serverEntry)) {
|
|
1520
|
-
return { ok: false, message: "MCP server 尚未构建,请先在 app 目录执行 npm run build" };
|
|
1521
|
-
}
|
|
1522
|
-
const customPortFile = process.env.DESKTOP_PET_PORT_FILE?.trim();
|
|
1523
|
-
const config = {
|
|
1524
|
-
mcpServers: {
|
|
1525
|
-
"desktop-pet": {
|
|
1526
|
-
command: "node",
|
|
1527
|
-
args: [serverEntry],
|
|
1528
|
-
...customPortFile ? { env: { DESKTOP_PET_PORT_FILE: PORT_FILE } } : {}
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
};
|
|
1532
|
-
electron.clipboard.writeText(JSON.stringify(config, null, 2));
|
|
1533
|
-
return { ok: true, message: "MCP 配置已复制,直接粘贴到 Agent 的 MCP 配置里即可" };
|
|
1534
|
-
}
|
|
1535
|
-
const P2P_CHAT_CONNECT_TIMEOUT_MS = 2500;
|
|
1536
|
-
let transportWindow;
|
|
1537
|
-
let transportReady = false;
|
|
1538
|
-
let removeRelayListener;
|
|
1539
|
-
const pendingChats = /* @__PURE__ */ new Map();
|
|
1540
|
-
function startP2PWindow(iceServers = []) {
|
|
1541
|
-
if (transportWindow) return;
|
|
1542
|
-
const win = new electron.BrowserWindow({
|
|
1543
|
-
show: false,
|
|
1544
|
-
width: 1,
|
|
1545
|
-
height: 1,
|
|
1546
|
-
webPreferences: {
|
|
1547
|
-
preload: node_path.join(__dirname, "../preload/index.js"),
|
|
1548
|
-
contextIsolation: true
|
|
1549
|
-
}
|
|
1550
|
-
});
|
|
1551
|
-
transportWindow = win;
|
|
1552
|
-
transportReady = false;
|
|
1553
|
-
win.webContents.once("did-finish-load", () => {
|
|
1554
|
-
transportReady = true;
|
|
1555
|
-
win.webContents.send(P2P_CONFIG, { iceServers });
|
|
1556
|
-
});
|
|
1557
|
-
if (process.env.ELECTRON_RENDERER_URL) {
|
|
1558
|
-
win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/p2p.html`);
|
|
1559
|
-
} else {
|
|
1560
|
-
win.loadFile(node_path.join(__dirname, "../renderer/p2p.html"));
|
|
1561
|
-
}
|
|
1562
|
-
removeRelayListener = onP2PSignal((message) => {
|
|
1563
|
-
if (!win.isDestroyed()) win.webContents.send(P2P_SIGNAL_IN, message);
|
|
1564
|
-
});
|
|
1565
|
-
electron.ipcMain.on(P2P_SIGNAL_OUT, onRendererSignal);
|
|
1566
|
-
electron.ipcMain.on(P2P_CHAT_EVENT, onChatEvent);
|
|
1567
|
-
win.on("closed", () => {
|
|
1568
|
-
transportWindow = void 0;
|
|
1569
|
-
transportReady = false;
|
|
1570
|
-
removeRelayListener?.();
|
|
1571
|
-
removeRelayListener = void 0;
|
|
1572
|
-
electron.ipcMain.removeListener(P2P_SIGNAL_OUT, onRendererSignal);
|
|
1573
|
-
electron.ipcMain.removeListener(P2P_CHAT_EVENT, onChatEvent);
|
|
1574
|
-
});
|
|
1575
|
-
}
|
|
1576
|
-
function stopP2PWindow() {
|
|
1577
|
-
removeRelayListener?.();
|
|
1578
|
-
removeRelayListener = void 0;
|
|
1579
|
-
electron.ipcMain.removeListener(P2P_SIGNAL_OUT, onRendererSignal);
|
|
1580
|
-
electron.ipcMain.removeListener(P2P_CHAT_EVENT, onChatEvent);
|
|
1581
|
-
transportWindow?.destroy();
|
|
1582
|
-
transportWindow = void 0;
|
|
1583
|
-
transportReady = false;
|
|
1584
|
-
for (const [operationId, pending] of pendingChats) {
|
|
1585
|
-
clearTimeout(pending.timer);
|
|
1586
|
-
pending.reject(new Error("P2P 服务已停止"));
|
|
1587
|
-
pendingChats.delete(operationId);
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
async function sendFriendChatPreferred(input) {
|
|
1591
|
-
const text = input.text.trim();
|
|
1592
|
-
if (!text || text.length > 500) throw new Error("消息内容须为 1–500 个字符");
|
|
1593
|
-
if (!transportWindow || !transportReady || !input.toDeviceId) {
|
|
1594
|
-
await sendFriendChat(input.to, text);
|
|
1595
|
-
return "relay";
|
|
1596
|
-
}
|
|
1597
|
-
const operationId = node_crypto.randomUUID();
|
|
1598
|
-
return new Promise((resolve, reject) => {
|
|
1599
|
-
const pending = {
|
|
1600
|
-
to: input.to,
|
|
1601
|
-
text,
|
|
1602
|
-
resolve,
|
|
1603
|
-
reject,
|
|
1604
|
-
timer: setTimeout(() => void fallbackChat(operationId), P2P_CHAT_CONNECT_TIMEOUT_MS + 1e3)
|
|
1605
|
-
};
|
|
1606
|
-
pendingChats.set(operationId, pending);
|
|
1607
|
-
transportWindow.webContents.send(P2P_CHAT_SEND, {
|
|
1608
|
-
operationId,
|
|
1609
|
-
taskId: `chat.${operationId}`,
|
|
1610
|
-
toDeviceId: input.toDeviceId,
|
|
1611
|
-
toUsername: input.to,
|
|
1612
|
-
text
|
|
1613
|
-
});
|
|
1614
|
-
});
|
|
1615
|
-
}
|
|
1616
|
-
function onRendererSignal(event, message) {
|
|
1617
|
-
if (!transportWindow || event.sender !== transportWindow.webContents) return;
|
|
1618
|
-
void sendP2PSignal(message).then(() => {
|
|
1619
|
-
if (message.type !== "p2p.ice-candidate") {
|
|
1620
|
-
logger.info("p2p signal forwarded", {
|
|
1621
|
-
type: message.type,
|
|
1622
|
-
taskId: message.taskId,
|
|
1623
|
-
sessionId: message.sessionId
|
|
1624
|
-
});
|
|
1625
|
-
}
|
|
1626
|
-
}).catch((err) => {
|
|
1627
|
-
logger.warn("p2p signal failed", { type: message.type, taskId: message.taskId, error: String(err) });
|
|
1628
|
-
if (message.type === "p2p.offer" && message.taskId.startsWith("chat.")) {
|
|
1629
|
-
void fallbackChat(message.taskId.slice("chat.".length));
|
|
1630
|
-
}
|
|
1631
|
-
});
|
|
1632
|
-
}
|
|
1633
|
-
function onChatEvent(event, message) {
|
|
1634
|
-
if (!transportWindow || event.sender !== transportWindow.webContents || !message || typeof message !== "object") return;
|
|
1635
|
-
const value = message;
|
|
1636
|
-
if (value.type === "diagnostic") {
|
|
1637
|
-
logger.info("p2p diagnostic", {
|
|
1638
|
-
stage: value.stage,
|
|
1639
|
-
sessionId: value.sessionId,
|
|
1640
|
-
signalType: value.signalType,
|
|
1641
|
-
connectionState: value.connectionState,
|
|
1642
|
-
iceConnectionState: value.iceConnectionState,
|
|
1643
|
-
iceGatheringState: value.iceGatheringState,
|
|
1644
|
-
error: value.error
|
|
1645
|
-
});
|
|
1646
|
-
return;
|
|
1647
|
-
}
|
|
1648
|
-
if (value.type === "received" && value.text) {
|
|
1649
|
-
dispatch({ type: "state", state: "excited", ttl: 6 });
|
|
1650
|
-
dispatch({ type: "bubble", text: `${value.from ?? "好友"}:${value.text}`, ttl: 1e4 });
|
|
1651
|
-
showDesktopNotification(`来自 ${value.from ?? "好友"} 的消息`, value.text);
|
|
1652
|
-
return;
|
|
1653
|
-
}
|
|
1654
|
-
if (!value.operationId) return;
|
|
1655
|
-
const pending = pendingChats.get(value.operationId);
|
|
1656
|
-
if (!pending) return;
|
|
1657
|
-
if (value.type === "sent") {
|
|
1658
|
-
clearTimeout(pending.timer);
|
|
1659
|
-
pendingChats.delete(value.operationId);
|
|
1660
|
-
logger.info("friend chat sent", { to: pending.to, transport: "p2p" });
|
|
1661
|
-
pending.resolve("p2p");
|
|
1662
|
-
} else if (value.type === "failed") {
|
|
1663
|
-
void fallbackChat(value.operationId);
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
async function fallbackChat(operationId) {
|
|
1667
|
-
const pending = pendingChats.get(operationId);
|
|
1668
|
-
if (!pending) return;
|
|
1669
|
-
clearTimeout(pending.timer);
|
|
1670
|
-
pendingChats.delete(operationId);
|
|
1671
|
-
try {
|
|
1672
|
-
await sendFriendChat(pending.to, pending.text);
|
|
1673
|
-
logger.info("friend chat sent", { to: pending.to, transport: "relay" });
|
|
1674
|
-
pending.resolve("relay");
|
|
1675
|
-
} catch (error) {
|
|
1676
|
-
pending.reject(error instanceof Error ? error : new Error("消息发送失败"));
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
const REQUEST_TIMEOUT_MS = 6e3;
|
|
1680
|
-
const DOWNLOAD_TIMEOUT_MS = 10 * 6e4;
|
|
1681
|
-
const LEGACY_OFFICIAL_HTTP_HOST = "47.94.20.104";
|
|
1682
|
-
const MAC_BUNDLE_ID = "com.xinshu.desktoppet";
|
|
1683
|
-
const MAC_INSTALLED_APP = "/Applications/Desktop Pet.app";
|
|
1684
|
-
let status = { kind: "idle" };
|
|
1685
|
-
function updateCheckURL() {
|
|
1686
|
-
const override = process.env.DESKTOP_PET_UPDATE_API?.trim();
|
|
1687
|
-
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";
|
|
1692
|
-
url.search = "";
|
|
1693
|
-
url.hash = "";
|
|
1694
|
-
return validateUpdateAPIURL(url.toString()).toString();
|
|
1695
|
-
}
|
|
1696
|
-
function getUpdateStatus() {
|
|
1697
|
-
return status;
|
|
1698
|
-
}
|
|
1699
|
-
async function checkForUpdates(currentVersion = electron.app.getVersion()) {
|
|
1700
|
-
if (status.kind === "downloading") return status;
|
|
1701
|
-
status = { kind: "checking" };
|
|
1702
|
-
try {
|
|
1703
|
-
const endpoint = new URL(updateCheckURL());
|
|
1704
|
-
endpoint.searchParams.set("platform", process.platform);
|
|
1705
|
-
endpoint.searchParams.set("arch", process.arch);
|
|
1706
|
-
endpoint.searchParams.set("currentVersion", currentVersion);
|
|
1707
|
-
const response = await electron.net.fetch(endpoint.toString(), {
|
|
1708
|
-
headers: updateCheckHeaders(endpoint),
|
|
1709
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1710
|
-
cache: "no-store"
|
|
1711
|
-
});
|
|
1712
|
-
const body = await response.json();
|
|
1713
|
-
if (!response.ok || body.code !== 0 || !body.data) {
|
|
1714
|
-
throw new Error(body.message || "更新服务器返回异常");
|
|
1715
|
-
}
|
|
1716
|
-
const update = parseUpdate(body.data.update);
|
|
1717
|
-
if (!update || compareVersions(update.version, currentVersion) <= 0) {
|
|
1718
|
-
status = { kind: "up-to-date", checkedAt: Date.now() };
|
|
1719
|
-
return status;
|
|
1720
|
-
}
|
|
1721
|
-
const cachedArtifactPath = await verifiedCachedArtifact(update);
|
|
1722
|
-
if (cachedArtifactPath) {
|
|
1723
|
-
status = { kind: "downloaded", update, artifactPath: cachedArtifactPath, downloadedAt: Date.now() };
|
|
1724
|
-
logger.info("using cached verified update", { version: update.version });
|
|
1725
|
-
return status;
|
|
1726
|
-
}
|
|
1727
|
-
status = { kind: "available", update, checkedAt: Date.now() };
|
|
1728
|
-
logger.info("update available", { currentVersion, version: update.version });
|
|
1729
|
-
return status;
|
|
1730
|
-
} catch (error) {
|
|
1731
|
-
const message = humanizeUpdateError(error);
|
|
1732
|
-
status = { kind: "error", message, checkedAt: Date.now() };
|
|
1733
|
-
logger.warn("update check failed", { error: String(error) });
|
|
1734
|
-
return status;
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
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
|
-
async function downloadAvailableUpdate() {
|
|
1749
|
-
const available = status.kind === "available" ? status.update : void 0;
|
|
1750
|
-
if (!available) return status;
|
|
1751
|
-
status = { kind: "downloading", update: available, downloadedBytes: 0 };
|
|
1752
|
-
const updateDirectory = node_path.join(electron.app.getPath("userData"), "updates");
|
|
1753
|
-
const artifactPath = updateArtifactPath(available);
|
|
1754
|
-
const temporaryPath = `${artifactPath}.${node_crypto.randomUUID()}.download`;
|
|
1755
|
-
try {
|
|
1756
|
-
await promises.mkdir(updateDirectory, { recursive: true });
|
|
1757
|
-
const response = await electron.net.fetch(available.releaseUrl, {
|
|
1758
|
-
headers: { accept: "application/octet-stream, application/wasm, */*" },
|
|
1759
|
-
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
1760
|
-
cache: "no-store",
|
|
1761
|
-
redirect: "follow"
|
|
1762
|
-
});
|
|
1763
|
-
if (!response.ok) throw new Error(`下载更新失败(${response.status})`);
|
|
1764
|
-
if (!response.body) throw new Error("更新包为空,请稍后重试");
|
|
1765
|
-
if (response.url && new URL(response.url).protocol !== "https:") {
|
|
1766
|
-
throw new Error("更新下载地址必须使用 HTTPS");
|
|
1767
|
-
}
|
|
1768
|
-
const totalBytes = parseContentLength(response.headers.get("content-length"));
|
|
1769
|
-
let downloadedBytes = 0;
|
|
1770
|
-
status = { kind: "downloading", update: available, downloadedBytes, totalBytes };
|
|
1771
|
-
const digest = await saveAndHash(response.body, temporaryPath, (chunkBytes) => {
|
|
1772
|
-
downloadedBytes += chunkBytes;
|
|
1773
|
-
status = { kind: "downloading", update: available, downloadedBytes, totalBytes };
|
|
1774
|
-
});
|
|
1775
|
-
if (digest !== available.sha256) throw new Error("更新包校验失败,文件未安装");
|
|
1776
|
-
await promises.rm(artifactPath, { force: true });
|
|
1777
|
-
await moveFile(temporaryPath, artifactPath);
|
|
1778
|
-
status = { kind: "downloaded", update: available, artifactPath, downloadedAt: Date.now() };
|
|
1779
|
-
logger.info("update downloaded and verified", { version: available.version });
|
|
1780
|
-
return status;
|
|
1781
|
-
} catch (error) {
|
|
1782
|
-
await promises.rm(temporaryPath, { force: true }).catch(() => void 0);
|
|
1783
|
-
const message = humanizeUpdateError(error);
|
|
1784
|
-
status = { kind: "error", message, checkedAt: Date.now() };
|
|
1785
|
-
logger.warn("update download failed", { error: String(error) });
|
|
1786
|
-
return status;
|
|
1787
|
-
}
|
|
1788
|
-
}
|
|
1789
|
-
async function installDownloadedUpdate() {
|
|
1790
|
-
if (status.kind !== "downloaded") return void 0;
|
|
1791
|
-
if (!supportsInAppInstall()) throw new Error("当前运行方式不支持应用内安装,请使用正式发布版");
|
|
1792
|
-
if (process.platform !== "darwin" && process.platform !== "win32") {
|
|
1793
|
-
throw new Error("当前平台暂不支持应用内安装,请下载对应安装包更新");
|
|
1794
|
-
}
|
|
1795
|
-
const downloaded = status;
|
|
1796
|
-
const helperPath = await writeUpdateHelper();
|
|
1797
|
-
const child = node_child_process.spawn(process.execPath, [
|
|
1798
|
-
helperPath,
|
|
1799
|
-
String(process.pid),
|
|
1800
|
-
downloaded.artifactPath,
|
|
1801
|
-
downloaded.update.sha256,
|
|
1802
|
-
process.platform,
|
|
1803
|
-
downloaded.update.version,
|
|
1804
|
-
process.execPath,
|
|
1805
|
-
JSON.stringify(restartArgumentsAfterUpdate())
|
|
1806
|
-
], {
|
|
1807
|
-
detached: true,
|
|
1808
|
-
stdio: "ignore",
|
|
1809
|
-
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }
|
|
1810
|
-
});
|
|
1811
|
-
child.unref();
|
|
1812
|
-
status = { kind: "installing", update: downloaded.update };
|
|
1813
|
-
logger.info("update installation requested", { version: downloaded.update.version });
|
|
1814
|
-
electron.app.quit();
|
|
1815
|
-
return downloaded.update;
|
|
1816
|
-
}
|
|
1817
|
-
function supportsInAppInstall() {
|
|
1818
|
-
return electron.app.isPackaged || process.env.DESKTOP_PET_NPM_DISTRIBUTION === "1";
|
|
1819
|
-
}
|
|
1820
|
-
function restartArgumentsAfterUpdate() {
|
|
1821
|
-
return !electron.app.isPackaged && process.argv[1] ? [process.argv[1]] : [];
|
|
1822
|
-
}
|
|
1823
|
-
function updateArtifactPath(update) {
|
|
1824
|
-
return node_path.join(electron.app.getPath("userData"), "updates", `desktop-pet-${update.version}${installerExtension(update.releaseUrl)}`);
|
|
1825
|
-
}
|
|
1826
|
-
async function verifiedCachedArtifact(update) {
|
|
1827
|
-
const artifactPath = updateArtifactPath(update);
|
|
1828
|
-
try {
|
|
1829
|
-
return await sha256File(artifactPath) === update.sha256 ? artifactPath : void 0;
|
|
1830
|
-
} catch (error) {
|
|
1831
|
-
if (error.code !== "ENOENT") {
|
|
1832
|
-
logger.warn("could not verify cached update", { version: update.version, error: String(error) });
|
|
1833
|
-
}
|
|
1834
|
-
return void 0;
|
|
1835
|
-
}
|
|
1836
|
-
}
|
|
1837
|
-
function sha256File(path2) {
|
|
1838
|
-
return new Promise((resolve, reject) => {
|
|
1839
|
-
const hash = node_crypto.createHash("sha256");
|
|
1840
|
-
const input = node_fs.createReadStream(path2);
|
|
1841
|
-
input.on("data", (chunk) => hash.update(chunk));
|
|
1842
|
-
input.on("error", reject);
|
|
1843
|
-
input.on("end", () => resolve(hash.digest("hex")));
|
|
1844
|
-
});
|
|
1845
|
-
}
|
|
1846
|
-
async function saveAndHash(body, destination, onChunk) {
|
|
1847
|
-
const hash = node_crypto.createHash("sha256");
|
|
1848
|
-
const meter = new node_stream.Transform({
|
|
1849
|
-
transform(chunk, _encoding, done) {
|
|
1850
|
-
hash.update(chunk);
|
|
1851
|
-
onChunk(chunk.length);
|
|
1852
|
-
done(null, chunk);
|
|
1853
|
-
}
|
|
1854
|
-
});
|
|
1855
|
-
await promises$1.pipeline(
|
|
1856
|
-
node_stream.Readable.fromWeb(body),
|
|
1857
|
-
meter,
|
|
1858
|
-
node_fs.createWriteStream(destination, { flags: "wx" })
|
|
1859
|
-
);
|
|
1860
|
-
return hash.digest("hex");
|
|
1861
|
-
}
|
|
1862
|
-
function parseContentLength(value) {
|
|
1863
|
-
if (!value) return void 0;
|
|
1864
|
-
const bytes = Number(value);
|
|
1865
|
-
return Number.isSafeInteger(bytes) && bytes > 0 ? bytes : void 0;
|
|
1866
|
-
}
|
|
1867
|
-
function downloadProgressText(progress) {
|
|
1868
|
-
if (progress.totalBytes) {
|
|
1869
|
-
const percent = Math.min(100, Math.floor(progress.downloadedBytes / progress.totalBytes * 100));
|
|
1870
|
-
return `${percent}%`;
|
|
1871
|
-
}
|
|
1872
|
-
return formatBytes(progress.downloadedBytes);
|
|
1873
|
-
}
|
|
1874
|
-
function formatBytes(bytes) {
|
|
1875
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
1876
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1877
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1878
|
-
}
|
|
1879
|
-
async function moveFile(source, destination) {
|
|
1880
|
-
const { rename } = await import("node:fs/promises");
|
|
1881
|
-
await rename(source, destination);
|
|
1882
|
-
}
|
|
1883
|
-
async function writeUpdateHelper() {
|
|
1884
|
-
const helperPath = node_path.join(electron.app.getPath("userData"), "updates", "install-downloaded-update.cjs");
|
|
1885
|
-
await promises.writeFile(helperPath, UPDATE_HELPER_SOURCE, { mode: 448 });
|
|
1886
|
-
return helperPath;
|
|
1887
|
-
}
|
|
1888
|
-
const UPDATE_HELPER_SOURCE = String.raw`const fs = require('node:fs/promises')
|
|
1889
|
-
const { createHash } = require('node:crypto')
|
|
1890
|
-
const { createReadStream } = require('node:fs')
|
|
1891
|
-
const { dirname, join } = require('node:path')
|
|
1892
|
-
const { execFile, spawn } = require('node:child_process')
|
|
1893
|
-
const { promisify } = require('node:util')
|
|
1894
|
-
|
|
1895
|
-
const [, , parentPidText, sourcePath, expectedSHA256, platform, targetVersion, fallbackExecutable, fallbackArgsJSON] = process.argv
|
|
1896
|
-
const parentPid = Number(parentPidText)
|
|
1897
|
-
const fallbackArgs = JSON.parse(fallbackArgsJSON || '[]')
|
|
1898
|
-
const run = promisify(execFile)
|
|
1899
|
-
|
|
1900
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
1901
|
-
const parentIsRunning = () => {
|
|
1902
|
-
try { process.kill(parentPid, 0); return true } catch { return false }
|
|
1903
|
-
}
|
|
1904
|
-
const sha256 = (path) => new Promise((resolve, reject) => {
|
|
1905
|
-
const hash = createHash('sha256')
|
|
1906
|
-
const input = createReadStream(path)
|
|
1907
|
-
input.on('data', (chunk) => hash.update(chunk))
|
|
1908
|
-
input.on('error', reject)
|
|
1909
|
-
input.on('end', () => resolve(hash.digest('hex')))
|
|
1910
|
-
})
|
|
1911
|
-
const restartFallback = () => {
|
|
1912
|
-
const env = { ...process.env }
|
|
1913
|
-
delete env.ELECTRON_RUN_AS_NODE
|
|
1914
|
-
const child = spawn(fallbackExecutable, fallbackArgs, { detached: true, stdio: 'ignore', env })
|
|
1915
|
-
child.on('error', () => undefined)
|
|
1916
|
-
child.unref()
|
|
1917
|
-
}
|
|
1918
|
-
const findInstalledMacApp = async () => {
|
|
1919
|
-
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) {
|
|
1923
|
-
throw new Error('installed app identity or version does not match the verified update')
|
|
1924
|
-
}
|
|
1925
|
-
return '${MAC_INSTALLED_APP}'
|
|
1926
|
-
}
|
|
1927
|
-
const restartInstalledApp = async () => {
|
|
1928
|
-
if (platform === 'darwin') {
|
|
1929
|
-
// PKG 禁止重定位;安装后再次核对固定路径的 bundle ID 与版本再启动。
|
|
1930
|
-
const installedApp = await findInstalledMacApp()
|
|
1931
|
-
await run('/usr/bin/open', ['-n', installedApp])
|
|
1932
|
-
return
|
|
1933
|
-
}
|
|
1934
|
-
restartFallback()
|
|
1935
|
-
}
|
|
1936
|
-
const shellQuote = (value) => "'" + value.replace(/'/g, "'\\''") + "'"
|
|
1937
|
-
|
|
1938
|
-
async function install() {
|
|
1939
|
-
if (platform === 'darwin') {
|
|
1940
|
-
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'])
|
|
1942
|
-
return
|
|
1943
|
-
}
|
|
1944
|
-
if (platform === 'win32') {
|
|
1945
|
-
// 发布端须提供支持 /S 静默安装的签名 NSIS 安装器。
|
|
1946
|
-
await run(sourcePath, ['/S'])
|
|
1947
|
-
return
|
|
1948
|
-
}
|
|
1949
|
-
throw new Error('unsupported platform')
|
|
1950
|
-
}
|
|
1951
|
-
|
|
1952
|
-
async function main() {
|
|
1953
|
-
while (parentIsRunning()) await sleep(200)
|
|
1954
|
-
|
|
1955
|
-
let installed = false
|
|
1956
|
-
try {
|
|
1957
|
-
if (await sha256(sourcePath) !== expectedSHA256) throw new Error('downloaded update hash changed')
|
|
1958
|
-
await install()
|
|
1959
|
-
installed = true
|
|
1960
|
-
} catch (error) {
|
|
1961
|
-
await fs.writeFile(join(dirname(sourcePath), 'last-install-error.log'), String(error)).catch(() => undefined)
|
|
1962
|
-
} finally {
|
|
1963
|
-
if (installed) {
|
|
1964
|
-
try {
|
|
1965
|
-
await restartInstalledApp()
|
|
1966
|
-
} catch (error) {
|
|
1967
|
-
await fs.writeFile(join(dirname(sourcePath), 'last-restart-error.log'), String(error)).catch(() => undefined)
|
|
1968
|
-
restartFallback()
|
|
1969
|
-
}
|
|
1970
|
-
} else {
|
|
1971
|
-
restartFallback()
|
|
1972
|
-
}
|
|
1973
|
-
}
|
|
1974
|
-
}
|
|
1975
|
-
|
|
1976
|
-
main().catch(() => restartFallback())
|
|
1977
|
-
`;
|
|
1978
|
-
function installerExtension(releaseUrl) {
|
|
1979
|
-
const pathname = new URL(releaseUrl).pathname.toLowerCase();
|
|
1980
|
-
if (pathname.endsWith(".pkg")) return ".pkg";
|
|
1981
|
-
if (pathname.endsWith(".exe")) return ".exe";
|
|
1982
|
-
return ".installer";
|
|
1983
|
-
}
|
|
1984
|
-
function storedRelayURL() {
|
|
1985
|
-
try {
|
|
1986
|
-
return loadDeviceCredentials()?.serverUrl;
|
|
1987
|
-
} catch (error) {
|
|
1988
|
-
logger.warn("could not read stored server URL for update check", { error: String(error) });
|
|
1989
|
-
return void 0;
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
1992
|
-
function updateRelayURL() {
|
|
1993
|
-
const stored = storedRelayURL();
|
|
1994
|
-
if (stored && !isLegacyOfficialHTTPURL(stored)) return stored;
|
|
1995
|
-
return configuredRelayURL();
|
|
1996
|
-
}
|
|
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;
|
|
2003
|
-
}
|
|
2004
|
-
}
|
|
2005
|
-
function validateUpdateAPIURL(value) {
|
|
2006
|
-
const url = new URL(value);
|
|
2007
|
-
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)) {
|
|
2009
|
-
throw new Error("更新服务器必须使用 HTTPS");
|
|
2010
|
-
}
|
|
2011
|
-
return url;
|
|
2012
|
-
}
|
|
2013
|
-
function parseUpdate(value) {
|
|
2014
|
-
if (value === null || value === void 0) return void 0;
|
|
2015
|
-
if (!isRecord(value)) throw new Error("更新服务器返回了无效的更新信息");
|
|
2016
|
-
const version = asString(value.version);
|
|
2017
|
-
const releaseUrl = asString(value.releaseUrl);
|
|
2018
|
-
const sha256 = asString(value.sha256);
|
|
2019
|
-
if (!isVersion(version) || !releaseUrl || !/^[a-f0-9]{64}$/i.test(sha256)) {
|
|
2020
|
-
throw new Error("更新信息缺少版本、下载地址或校验值");
|
|
2021
|
-
}
|
|
2022
|
-
const url = new URL(releaseUrl);
|
|
2023
|
-
if (url.protocol !== "https:") throw new Error("更新下载地址必须使用 HTTPS");
|
|
2024
|
-
return {
|
|
2025
|
-
version,
|
|
2026
|
-
releaseUrl: url.toString(),
|
|
2027
|
-
sha256: sha256.toLowerCase(),
|
|
2028
|
-
notes: optionalString(value.notes),
|
|
2029
|
-
publishedAt: optionalString(value.publishedAt),
|
|
2030
|
-
mandatory: value.mandatory === true
|
|
2031
|
-
};
|
|
2032
|
-
}
|
|
2033
|
-
function asString(value) {
|
|
2034
|
-
return typeof value === "string" ? value.trim() : "";
|
|
2035
|
-
}
|
|
2036
|
-
function optionalString(value) {
|
|
2037
|
-
const normalized = asString(value);
|
|
2038
|
-
return normalized || void 0;
|
|
2039
|
-
}
|
|
2040
|
-
function isRecord(value) {
|
|
2041
|
-
return typeof value === "object" && value !== null;
|
|
2042
|
-
}
|
|
2043
|
-
function isVersion(value) {
|
|
2044
|
-
return /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
|
|
2045
|
-
}
|
|
2046
|
-
function compareVersions(a, b) {
|
|
2047
|
-
const parse = (value) => {
|
|
2048
|
-
const [, major, minor, patch, prerelease] = value.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/) ?? [];
|
|
2049
|
-
return { major: Number(major), minor: Number(minor), patch: Number(patch), prerelease };
|
|
2050
|
-
};
|
|
2051
|
-
const left = parse(a);
|
|
2052
|
-
const right = parse(b);
|
|
2053
|
-
for (const key of ["major", "minor", "patch"]) {
|
|
2054
|
-
if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1;
|
|
2055
|
-
}
|
|
2056
|
-
if (!left.prerelease && right.prerelease) return 1;
|
|
2057
|
-
if (left.prerelease && !right.prerelease) return -1;
|
|
2058
|
-
if (!left.prerelease || !right.prerelease) return 0;
|
|
2059
|
-
return left.prerelease.localeCompare(right.prerelease, void 0, { numeric: true });
|
|
2060
|
-
}
|
|
2061
|
-
function humanizeUpdateError(error) {
|
|
2062
|
-
if (error instanceof DOMException && error.name === "TimeoutError") return "检查更新超时,请稍后重试";
|
|
2063
|
-
if (error instanceof TypeError && error.message === "fetch failed") {
|
|
2064
|
-
return "无法连接更新服务器,请检查网络或系统代理";
|
|
2065
|
-
}
|
|
2066
|
-
if (error instanceof Error) return error.message;
|
|
2067
|
-
return "暂时无法检查更新";
|
|
2068
|
-
}
|
|
2069
|
-
const SKINS = SKIN_IDS.map((id) => ({ id, label: SKIN_LABELS[id] }));
|
|
2070
|
-
let nextId = 1;
|
|
2071
|
-
function createPetWindow(skin, options) {
|
|
2072
|
-
if (petManager.count() >= MAX_PETS) {
|
|
2073
|
-
logger.warn("pet limit reached", { max: MAX_PETS });
|
|
2074
|
-
return null;
|
|
2075
|
-
}
|
|
2076
|
-
const id = `pet-${nextId++}`;
|
|
2077
|
-
const identity = options.identity;
|
|
2078
|
-
let currentSkin = skin;
|
|
2079
|
-
const workArea = electron.screen.getPrimaryDisplay().workArea;
|
|
2080
|
-
const initialSize = PET_WINDOW_BY_SKIN[skin] ?? PET_WINDOW;
|
|
2081
|
-
let W = initialSize.width;
|
|
2082
|
-
let H = initialSize.height;
|
|
2083
|
-
const x = options.near?.x ?? workArea.x + workArea.width - W - 120;
|
|
2084
|
-
const y = options.near?.y ?? workArea.y + workArea.height - H - 60;
|
|
2085
|
-
const win = new electron.BrowserWindow({
|
|
2086
|
-
show: petManager.arePetsVisible(),
|
|
2087
|
-
width: W,
|
|
2088
|
-
height: H,
|
|
2089
|
-
x,
|
|
2090
|
-
y,
|
|
2091
|
-
transparent: true,
|
|
2092
|
-
frame: false,
|
|
2093
|
-
resizable: false,
|
|
2094
|
-
alwaysOnTop: true,
|
|
2095
|
-
skipTaskbar: true,
|
|
2096
|
-
hasShadow: false,
|
|
2097
|
-
backgroundColor: "#00000000",
|
|
2098
|
-
webPreferences: {
|
|
2099
|
-
preload: path.join(__dirname, "../preload/index.js"),
|
|
2100
|
-
contextIsolation: true
|
|
2101
|
-
}
|
|
2102
|
-
});
|
|
2103
|
-
win.setAlwaysOnTop(true, "screen-saver");
|
|
2104
|
-
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
2105
|
-
win.setIgnoreMouseEvents(true, { forward: true });
|
|
2106
|
-
registerPetWindow(id, win);
|
|
2107
|
-
petManager.register(id, win, skin, identity);
|
|
2108
|
-
if (process.env.ELECTRON_RENDERER_URL) {
|
|
2109
|
-
win.loadURL(process.env.ELECTRON_RENDERER_URL);
|
|
2110
|
-
} else {
|
|
2111
|
-
win.loadFile(path.join(__dirname, "../renderer/index.html"));
|
|
2112
|
-
}
|
|
2113
|
-
win.webContents.on("did-finish-load", () => {
|
|
2114
|
-
dispatch({ type: "skin", skin: currentSkin }, id);
|
|
2115
|
-
const presentation = publishIdentity();
|
|
2116
|
-
dispatch({ type: "bubble", text: presentation.greeting }, id);
|
|
2117
|
-
});
|
|
2118
|
-
const publishIdentity = () => {
|
|
2119
|
-
const presentation = presentPetIdentity(identity, currentAccountUsername());
|
|
2120
|
-
dispatch({ type: "identity", label: presentation.badge, kind: identity.kind }, id);
|
|
2121
|
-
return presentation;
|
|
2122
|
-
};
|
|
2123
|
-
const onMove = (e, dx, dy) => {
|
|
2124
|
-
if (isPetOnQuest(id)) return;
|
|
2125
|
-
if (!win.isDestroyed() && e.sender === win.webContents) moveBy(win, dx, dy);
|
|
2126
|
-
};
|
|
2127
|
-
const onDrag = (e, phase, dx = 0, dy = 0) => {
|
|
2128
|
-
if (isPetOnQuest(id)) return;
|
|
2129
|
-
if (!win.isDestroyed() && e.sender === win.webContents && phase === "move") {
|
|
2130
|
-
moveBy(win, dx, dy);
|
|
2131
|
-
}
|
|
2132
|
-
};
|
|
2133
|
-
const onMenu = (e) => {
|
|
2134
|
-
if (win.isDestroyed() || e.sender !== win.webContents) return;
|
|
2135
|
-
void showPetMenu();
|
|
2136
|
-
};
|
|
2137
|
-
const onMousePassthrough = (e, enabled) => {
|
|
2138
|
-
if (win.isDestroyed() || e.sender !== win.webContents || typeof enabled !== "boolean") return;
|
|
2139
|
-
win.setIgnoreMouseEvents(enabled, { forward: enabled });
|
|
2140
|
-
};
|
|
2141
|
-
const showPetMenu = async () => {
|
|
2142
|
-
const accountUsername = currentAccountUsername();
|
|
2143
|
-
const identityPresentation = publishIdentity();
|
|
2144
|
-
const collaboration = accountUsername ? await loadCollaborationState() : void 0;
|
|
2145
|
-
if (win.isDestroyed()) return;
|
|
2146
|
-
const incoming = collaboration?.helpRequests.filter(
|
|
2147
|
-
(request) => request.toUsername === accountUsername && request.status === "pending"
|
|
2148
|
-
) ?? [];
|
|
2149
|
-
const friendItems = collaboration?.friends.map(
|
|
2150
|
-
(friend) => friendMenuItem(friend, collaboration, accountUsername ?? "", id)
|
|
2151
|
-
) ?? [];
|
|
2152
|
-
const menu = electron.Menu.buildFromTemplate([
|
|
2153
|
-
{
|
|
2154
|
-
label: identityPresentation.menuHeader,
|
|
2155
|
-
enabled: false
|
|
2156
|
-
},
|
|
2157
|
-
{ type: "separator" },
|
|
2158
|
-
{
|
|
2159
|
-
label: accountUsername ? `本机账号 @${accountUsername}(点击切换)` : "登录本机账号",
|
|
2160
|
-
click: () => openAccountWindow("login")
|
|
2161
|
-
},
|
|
2162
|
-
{
|
|
2163
|
-
label: "注册账号",
|
|
2164
|
-
click: () => openAccountWindow("register")
|
|
2165
|
-
},
|
|
2166
|
-
...accountUsername ? [
|
|
2167
|
-
{
|
|
2168
|
-
label: "退出当前账号",
|
|
2169
|
-
click: () => logoutDesktopPet()
|
|
2170
|
-
}
|
|
2171
|
-
] : [],
|
|
2172
|
-
{ type: "separator" },
|
|
2173
|
-
{
|
|
2174
|
-
label: "切换形象",
|
|
2175
|
-
submenu: SKINS.map((s) => ({
|
|
2176
|
-
label: s.label,
|
|
2177
|
-
type: "radio",
|
|
2178
|
-
checked: s.id === currentSkin,
|
|
2179
|
-
click: () => {
|
|
2180
|
-
currentSkin = s.id;
|
|
2181
|
-
const nextSize = PET_WINDOW_BY_SKIN[s.id] ?? PET_WINDOW;
|
|
2182
|
-
W = nextSize.width;
|
|
2183
|
-
H = nextSize.height;
|
|
2184
|
-
win.setSize(W, H, true);
|
|
2185
|
-
dispatch({ type: "skin", skin: s.id }, id);
|
|
2186
|
-
}
|
|
2187
|
-
}))
|
|
2188
|
-
},
|
|
2189
|
-
{ type: "separator" },
|
|
2190
|
-
{
|
|
2191
|
-
label: `好友状态${collaboration ? `(${collaboration.friends.length})` : ""}`,
|
|
2192
|
-
enabled: Boolean(accountUsername),
|
|
2193
|
-
submenu: collaboration ? friendItems.length > 0 ? friendItems : [{ label: "还没有好友,请先到个人后台添加", enabled: false }] : [{ label: accountUsername ? "暂时无法连接服务器" : "请先登录账号", enabled: false }]
|
|
2194
|
-
},
|
|
2195
|
-
{
|
|
2196
|
-
label: `要我帮忙${incoming.length ? `(${incoming.length})` : ""}`,
|
|
2197
|
-
enabled: Boolean(accountUsername),
|
|
2198
|
-
submenu: incoming.length ? incoming.map((request) => ({
|
|
2199
|
-
label: `@${request.fromUsername} 想请你帮忙`,
|
|
2200
|
-
submenu: [
|
|
2201
|
-
{
|
|
2202
|
-
label: "同意帮 TA",
|
|
2203
|
-
click: () => runMenuAction(respondFriendHelp(request.id, true), `已同意帮 @${request.fromUsername},TA 的 Agent 可以向你派任务了`, id)
|
|
2204
|
-
},
|
|
2205
|
-
{
|
|
2206
|
-
label: "婉拒",
|
|
2207
|
-
click: () => runMenuAction(respondFriendHelp(request.id, false), `已婉拒 @${request.fromUsername} 的求助`, id)
|
|
2208
|
-
}
|
|
2209
|
-
]
|
|
2210
|
-
})) : [{ label: "暂无待处理的求助", enabled: false }]
|
|
2211
|
-
},
|
|
2212
|
-
{ type: "separator" },
|
|
2213
|
-
{
|
|
2214
|
-
label: "复制 MCP 配置",
|
|
2215
|
-
click: () => {
|
|
2216
|
-
const result = copyMcpConfig();
|
|
2217
|
-
dispatch({ type: "bubble", text: result.message, ttl: 6e3 }, id);
|
|
2218
|
-
}
|
|
2219
|
-
},
|
|
2220
|
-
{
|
|
2221
|
-
label: "打开日志文件夹",
|
|
2222
|
-
click: () => {
|
|
2223
|
-
void electron.shell.openPath(mainLogDirectory()).then((error) => {
|
|
2224
|
-
if (error) dispatch({ type: "bubble", text: "无法打开日志文件夹", ttl: 6e3 }, id);
|
|
2225
|
-
});
|
|
2226
|
-
}
|
|
2227
|
-
},
|
|
2228
|
-
{
|
|
2229
|
-
label: updateMenuLabel(),
|
|
2230
|
-
submenu: updateMenuItems(id)
|
|
2231
|
-
},
|
|
2232
|
-
{ type: "separator" },
|
|
2233
|
-
{ label: "隐藏所有桌面宠物", click: () => petManager.setPetsVisible(false) },
|
|
2234
|
-
...canSendAwayPet(identity) ? [{ label: "送走这只宠物", click: () => win.close() }] : [],
|
|
2235
|
-
{ label: "退出", click: () => electron.app.quit() }
|
|
2236
|
-
]);
|
|
2237
|
-
menu.popup({ window: win });
|
|
2238
|
-
};
|
|
2239
|
-
electron.ipcMain.on(PET_MOVE, onMove);
|
|
2240
|
-
electron.ipcMain.on(PET_DRAG, onDrag);
|
|
2241
|
-
electron.ipcMain.on(PET_MENU, onMenu);
|
|
2242
|
-
electron.ipcMain.on(PET_MOUSE_PASSTHROUGH, onMousePassthrough);
|
|
2243
|
-
win.on("closed", () => {
|
|
2244
|
-
electron.ipcMain.removeListener(PET_MOVE, onMove);
|
|
2245
|
-
electron.ipcMain.removeListener(PET_DRAG, onDrag);
|
|
2246
|
-
electron.ipcMain.removeListener(PET_MENU, onMenu);
|
|
2247
|
-
electron.ipcMain.removeListener(PET_MOUSE_PASSTHROUGH, onMousePassthrough);
|
|
2248
|
-
});
|
|
2249
|
-
return id;
|
|
2250
|
-
}
|
|
2251
|
-
function updateMenuLabel() {
|
|
2252
|
-
const status2 = getUpdateStatus();
|
|
2253
|
-
if (status2.kind === "available") return `软件更新(v${status2.update.version} 可下载)`;
|
|
2254
|
-
if (status2.kind === "downloading") {
|
|
2255
|
-
return `软件更新(v${status2.update.version} 下载 ${downloadProgressText(status2)})`;
|
|
2256
|
-
}
|
|
2257
|
-
if (status2.kind === "downloaded") return `软件更新(v${status2.update.version} 已下载)`;
|
|
2258
|
-
return "软件更新";
|
|
2259
|
-
}
|
|
2260
|
-
function updateMenuItems(petId) {
|
|
2261
|
-
const status2 = getUpdateStatus();
|
|
2262
|
-
const items = [
|
|
2263
|
-
{ label: `当前版本 v${electron.app.getVersion()}`, enabled: false },
|
|
2264
|
-
{
|
|
2265
|
-
label: "检查更新",
|
|
2266
|
-
click: () => {
|
|
2267
|
-
void checkForUpdates().then((result) => {
|
|
2268
|
-
if (result.kind === "available") {
|
|
2269
|
-
dispatch({ type: "bubble", text: `发现 v${result.update.version},可从“软件更新”安装`, ttl: 6e3 }, petId);
|
|
2270
|
-
} else if (result.kind === "up-to-date") {
|
|
2271
|
-
dispatch({ type: "bubble", text: "已经是最新版本啦", ttl: 4500 }, petId);
|
|
2272
|
-
} else if (result.kind === "downloading") {
|
|
2273
|
-
dispatch({
|
|
2274
|
-
type: "bubble",
|
|
2275
|
-
text: `v${result.update.version} 正在下载:${downloadProgressText(result)}`,
|
|
2276
|
-
ttl: 4500
|
|
2277
|
-
}, petId);
|
|
2278
|
-
} else if (result.kind === "error") {
|
|
2279
|
-
dispatch({ type: "bubble", text: result.message, ttl: 7e3 }, petId);
|
|
2280
|
-
}
|
|
2281
|
-
});
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
2284
|
-
];
|
|
2285
|
-
if (status2.kind === "available") {
|
|
2286
|
-
items.splice(1, 0, {
|
|
2287
|
-
label: `请求下载 v${status2.update.version}`,
|
|
2288
|
-
click: () => {
|
|
2289
|
-
dispatch({ type: "bubble", text: `正在下载 v${status2.update.version},完成后会提示你安装`, ttl: 5e3 }, petId);
|
|
2290
|
-
void downloadAvailableUpdate().then((result) => {
|
|
2291
|
-
if (result.kind === "downloaded") {
|
|
2292
|
-
dispatch({ type: "bubble", text: `v${result.update.version} 已下载,右键选择“安装并重启”`, ttl: 7e3 }, petId);
|
|
2293
|
-
} else if (result.kind === "error") {
|
|
2294
|
-
dispatch({ type: "bubble", text: result.message, ttl: 7e3 }, petId);
|
|
2295
|
-
}
|
|
2296
|
-
});
|
|
2297
|
-
}
|
|
2298
|
-
});
|
|
2299
|
-
}
|
|
2300
|
-
if (status2.kind === "downloading") {
|
|
2301
|
-
items.splice(1, 0, {
|
|
2302
|
-
label: `正在下载 v${status2.update.version} · ${downloadProgressText(status2)}`,
|
|
2303
|
-
enabled: false
|
|
2304
|
-
});
|
|
2305
|
-
}
|
|
2306
|
-
if (status2.kind === "downloaded") {
|
|
2307
|
-
items.splice(1, 0, {
|
|
2308
|
-
label: `安装 v${status2.update.version} 并重启`,
|
|
2309
|
-
click: () => {
|
|
2310
|
-
void installDownloadedUpdate().catch((error) => {
|
|
2311
|
-
const message = error instanceof Error ? error.message : "无法安装更新,请稍后重试";
|
|
2312
|
-
dispatch({ type: "bubble", text: message, ttl: 7e3 }, petId);
|
|
2313
|
-
});
|
|
2314
|
-
}
|
|
2315
|
-
});
|
|
2316
|
-
}
|
|
2317
|
-
return items;
|
|
2318
|
-
}
|
|
2319
|
-
async function loadCollaborationState() {
|
|
2320
|
-
try {
|
|
2321
|
-
return await Promise.race([
|
|
2322
|
-
getCollaborationState(),
|
|
2323
|
-
new Promise((resolve) => setTimeout(() => resolve(void 0), 1800))
|
|
2324
|
-
]);
|
|
2325
|
-
} catch {
|
|
2326
|
-
return void 0;
|
|
2327
|
-
}
|
|
2328
|
-
}
|
|
2329
|
-
function friendMenuItem(friend, state, currentUsername, petId) {
|
|
2330
|
-
const outboundHelp = state.helpRequests.find(
|
|
2331
|
-
(request) => request.fromUsername === currentUsername && request.toUsername === friend.username
|
|
2332
|
-
);
|
|
2333
|
-
const activeHelp = state.helpRequests.find(
|
|
2334
|
-
(request) => request.status === "accepted" && (request.fromUsername === currentUsername && request.toUsername === friend.username || request.fromUsername === friend.username && request.toUsername === currentUsername)
|
|
2335
|
-
);
|
|
2336
|
-
const helpLabel = outboundHelp?.status === "accepted" ? " · TA 已同意帮我" : outboundHelp?.status === "pending" ? " · 求助待 TA 接受" : activeHelp ? " · 我正在帮 TA" : "";
|
|
2337
|
-
const chatMessages = ["你好呀!", "现在方便聊聊吗?", "谢谢你的帮助!"];
|
|
2338
|
-
return {
|
|
2339
|
-
label: `${friend.online ? "●" : "○"} ${friend.displayName} (@${friend.username})${helpLabel}`,
|
|
2340
|
-
submenu: [
|
|
2341
|
-
{
|
|
2342
|
-
label: outboundHelp?.status === "accepted" ? "TA 已同意帮我(可让 Agent 派任务)" : outboundHelp?.status === "pending" ? "求助已发出,等 TA 接受" : "请 TA 帮我",
|
|
2343
|
-
enabled: outboundHelp?.status !== "accepted" && outboundHelp?.status !== "pending",
|
|
2344
|
-
click: () => runMenuAction(requestFriendHelp(friend.username), `已向 @${friend.username} 发出求助,等待对方接受`, petId)
|
|
2345
|
-
},
|
|
2346
|
-
{
|
|
2347
|
-
label: "发送消息",
|
|
2348
|
-
enabled: friend.online,
|
|
2349
|
-
submenu: [
|
|
2350
|
-
{
|
|
2351
|
-
label: "发送剪贴板文字",
|
|
2352
|
-
click: () => {
|
|
2353
|
-
const text = electron.clipboard.readText().trim();
|
|
2354
|
-
const action = text ? sendFriendChatPreferred({
|
|
2355
|
-
to: friend.username,
|
|
2356
|
-
toDeviceId: friend.deviceId,
|
|
2357
|
-
text: text.slice(0, 500)
|
|
2358
|
-
}) : Promise.reject(new Error("剪贴板里没有文字"));
|
|
2359
|
-
runMenuAction(action, `消息已发送给 ${friend.username}`, petId);
|
|
2360
|
-
}
|
|
2361
|
-
},
|
|
2362
|
-
{ type: "separator" },
|
|
2363
|
-
...chatMessages.map((text) => ({
|
|
2364
|
-
label: text,
|
|
2365
|
-
click: () => runMenuAction(
|
|
2366
|
-
sendFriendChatPreferred({
|
|
2367
|
-
to: friend.username,
|
|
2368
|
-
toDeviceId: friend.deviceId,
|
|
2369
|
-
text
|
|
2370
|
-
}),
|
|
2371
|
-
`消息已发送给 ${friend.username}`,
|
|
2372
|
-
petId
|
|
2373
|
-
)
|
|
2374
|
-
}))
|
|
2375
|
-
]
|
|
2376
|
-
}
|
|
2377
|
-
]
|
|
2378
|
-
};
|
|
2379
|
-
}
|
|
2380
|
-
function runMenuAction(action, successText, petId) {
|
|
2381
|
-
void action.then(() => {
|
|
2382
|
-
dispatch({ type: "state", state: "excited", ttl: 6 }, petId);
|
|
2383
|
-
dispatch({ type: "bubble", text: successText, ttl: 6e3 }, petId);
|
|
2384
|
-
}).catch((error) => {
|
|
2385
|
-
const message = error instanceof Error ? error.message : "操作失败";
|
|
2386
|
-
dispatch({ type: "state", state: "failed", ttl: 8 }, petId);
|
|
2387
|
-
dispatch({ type: "bubble", text: message, ttl: 9e3 }, petId);
|
|
2388
|
-
});
|
|
2389
|
-
}
|
|
2390
|
-
function moveBy(win, dx, dy) {
|
|
2391
|
-
if (win.isDestroyed()) return;
|
|
2392
|
-
const [x, y] = win.getPosition();
|
|
2393
|
-
const [w, h] = win.getSize();
|
|
2394
|
-
const area = electron.screen.getDisplayNearestPoint({ x, y }).workArea;
|
|
2395
|
-
const nx = Math.min(Math.max(x + dx, area.x), area.x + area.width - w);
|
|
2396
|
-
const ny = Math.min(Math.max(y + dy, area.y), area.y + area.height - h);
|
|
2397
|
-
win.setPosition(nx, ny);
|
|
2398
|
-
}
|
|
2399
|
-
const petWindow = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
2400
|
-
__proto__: null,
|
|
2401
|
-
createPetWindow
|
|
2402
|
-
}, Symbol.toStringTag, { value: "Module" }));
|
|
2403
|
-
const ErrorCodes = {
|
|
2404
|
-
BAD_EVENT: "BAD_EVENT",
|
|
2405
|
-
TOO_MANY_PETS: "TOO_MANY_PETS",
|
|
2406
|
-
PET_NOT_RUNNING: "PET_NOT_RUNNING",
|
|
2407
|
-
PET_REJECTED: "PET_REJECTED",
|
|
2408
|
-
RELAY_UNREACHABLE: "RELAY_UNREACHABLE",
|
|
2409
|
-
UNKNOWN_QUESTION: "UNKNOWN_QUESTION",
|
|
2410
|
-
QUESTION_NOT_ASSIGNED: "QUESTION_NOT_ASSIGNED",
|
|
2411
|
-
HELP_NOT_ACCEPTED: "HELP_NOT_ACCEPTED",
|
|
2412
|
-
NOT_FRIENDS: "NOT_FRIENDS",
|
|
2413
|
-
USER_NOT_ONLINE: "USER_NOT_ONLINE",
|
|
2414
|
-
BAD_MESSAGE: "BAD_MESSAGE",
|
|
2415
|
-
MUST_REGISTER: "MUST_REGISTER",
|
|
2416
|
-
UNKNOWN_TYPE: "UNKNOWN_TYPE",
|
|
2417
|
-
INTERNAL: "INTERNAL",
|
|
2418
|
-
UNSUPPORTED_PROTOCOL: "UNSUPPORTED_PROTOCOL",
|
|
2419
|
-
TASK_NOT_FOUND: "TASK_NOT_FOUND"
|
|
2420
|
-
};
|
|
2421
|
-
const ERROR_CODE_VALUES = new Set(Object.values(ErrorCodes));
|
|
2422
|
-
function isErrorCode(value) {
|
|
2423
|
-
return typeof value === "string" && ERROR_CODE_VALUES.has(value);
|
|
2424
|
-
}
|
|
2425
|
-
class AppError extends Error {
|
|
2426
|
-
constructor(code, message) {
|
|
2427
|
-
super(message);
|
|
2428
|
-
this.code = code;
|
|
2429
|
-
this.name = "AppError";
|
|
2430
|
-
}
|
|
2431
|
-
static badEvent(detail) {
|
|
2432
|
-
return new AppError(ErrorCodes.BAD_EVENT, detail);
|
|
2433
|
-
}
|
|
2434
|
-
}
|
|
2435
|
-
const PET_STATES = [
|
|
2436
|
-
"idle",
|
|
2437
|
-
"walk",
|
|
2438
|
-
"sleep",
|
|
2439
|
-
"excited",
|
|
2440
|
-
"drag",
|
|
2441
|
-
"working",
|
|
2442
|
-
"success",
|
|
2443
|
-
"failed"
|
|
2444
|
-
];
|
|
2445
|
-
function parseIncomingEvent(raw) {
|
|
2446
|
-
if (!raw || typeof raw !== "object") throw AppError.badEvent("event must be an object");
|
|
2447
|
-
const ev = raw;
|
|
2448
|
-
switch (ev.type) {
|
|
2449
|
-
case "state":
|
|
2450
|
-
if (!PET_STATES.includes(ev.state)) {
|
|
2451
|
-
throw AppError.badEvent(`invalid state: ${String(ev.state)}`);
|
|
2452
|
-
}
|
|
2453
|
-
return { type: "state", state: ev.state, ttl: optNumber(ev.ttl) };
|
|
2454
|
-
case "bubble":
|
|
2455
|
-
if (typeof ev.text !== "string" || ev.text.length === 0 || ev.text.length > 500) {
|
|
2456
|
-
throw AppError.badEvent("bubble text must be a string of 1..500 chars");
|
|
2457
|
-
}
|
|
2458
|
-
return { type: "bubble", text: ev.text, ttl: optNumber(ev.ttl) };
|
|
2459
|
-
case "skin":
|
|
2460
|
-
if (typeof ev.skin !== "string" || ev.skin.length === 0 || ev.skin.length > 64) {
|
|
2461
|
-
throw AppError.badEvent("skin must be a string of 1..64 chars");
|
|
2462
|
-
}
|
|
2463
|
-
return { type: "skin", skin: ev.skin };
|
|
2464
|
-
case "reward": {
|
|
2465
|
-
const amount = ev.amount;
|
|
2466
|
-
if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0 || amount > 9999) {
|
|
2467
|
-
throw AppError.badEvent("reward amount must be a number in 1..9999");
|
|
2468
|
-
}
|
|
2469
|
-
const label = typeof ev.label === "string" && ev.label.length > 0 && ev.label.length <= 16 ? ev.label : void 0;
|
|
2470
|
-
return { type: "reward", amount, label };
|
|
2471
|
-
}
|
|
2472
|
-
case "quest":
|
|
2473
|
-
return parseQuest(ev);
|
|
2474
|
-
default:
|
|
2475
|
-
throw AppError.badEvent(`unknown event type: ${String(ev.type)}`);
|
|
2476
|
-
}
|
|
2477
|
-
}
|
|
2478
|
-
function parseQuest(ev) {
|
|
2479
|
-
const phase = ev.phase;
|
|
2480
|
-
const friendName = optString(ev.friendName, 32);
|
|
2481
|
-
const leaveText = optString(ev.leaveText, 120);
|
|
2482
|
-
const returnText = optString(ev.returnText, 200);
|
|
2483
|
-
const fish = optNumber(ev.fish);
|
|
2484
|
-
if (phase === "leave") {
|
|
2485
|
-
return { type: "quest", phase: "leave", friendName, leaveText };
|
|
2486
|
-
}
|
|
2487
|
-
if (phase === "return") {
|
|
2488
|
-
return { type: "quest", phase: "return", returnText, fish };
|
|
2489
|
-
}
|
|
2490
|
-
throw AppError.badEvent("quest phase must be leave | return");
|
|
2491
|
-
}
|
|
2492
|
-
function optNumber(v) {
|
|
2493
|
-
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
2494
|
-
}
|
|
2495
|
-
function optString(v, max) {
|
|
2496
|
-
return typeof v === "string" && v.length > 0 && v.length <= max ? v : void 0;
|
|
2497
|
-
}
|
|
2498
|
-
function isRelayCommand(raw) {
|
|
2499
|
-
if (!raw || typeof raw !== "object") return false;
|
|
2500
|
-
const t = raw.type;
|
|
2501
|
-
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
|
-
}
|
|
2503
|
-
async function uploadRelayBlob(input) {
|
|
2504
|
-
if (input.kind === "patch" && !input.baseCommit) throw new Error("patch artifact requires baseCommit");
|
|
2505
|
-
const auth = getRelayHTTPAuth();
|
|
2506
|
-
const peersResponse = await fetch(`${auth.baseURL}/api/tasks/${encodeURIComponent(input.taskId)}/peer-keys`, {
|
|
2507
|
-
headers: { Authorization: `Bearer ${auth.accessToken}` }
|
|
2508
|
-
});
|
|
2509
|
-
const peersBody = await peersResponse.json();
|
|
2510
|
-
if (!peersResponse.ok) throw new Error(peersBody.message || "unable to load peer encryption keys");
|
|
2511
|
-
const peers = peersBody.data?.devices ?? [];
|
|
2512
|
-
if (!peers.length) throw new Error("no active peer device has an encryption key");
|
|
2513
|
-
const encrypted = encryptForRecipients(input.bytes, peers);
|
|
2514
|
-
const artifactId = input.artifactId ?? node_crypto.randomUUID();
|
|
2515
|
-
const sha256 = node_crypto.createHash("sha256").update(input.bytes).digest("hex");
|
|
2516
|
-
const response = await fetch(
|
|
2517
|
-
`${auth.baseURL}/api/tasks/${encodeURIComponent(input.taskId)}/artifacts/${encodeURIComponent(artifactId)}`,
|
|
2518
|
-
{
|
|
2519
|
-
method: "PUT",
|
|
2520
|
-
headers: {
|
|
2521
|
-
Authorization: `Bearer ${auth.accessToken}`,
|
|
2522
|
-
"Content-Type": "application/octet-stream",
|
|
2523
|
-
"X-Artifact-Size": String(input.bytes.byteLength),
|
|
2524
|
-
"X-Artifact-SHA256": sha256,
|
|
2525
|
-
"X-Artifact-Direction": input.direction,
|
|
2526
|
-
"X-Artifact-Kind": input.kind,
|
|
2527
|
-
"X-Artifact-Mime": input.mimeType,
|
|
2528
|
-
"X-Artifact-Key-Wraps": Buffer.from(JSON.stringify(encrypted.keyWraps)).toString("base64url")
|
|
2529
|
-
},
|
|
2530
|
-
body: Buffer.from(encrypted.ciphertext)
|
|
2531
|
-
}
|
|
2532
|
-
);
|
|
2533
|
-
if (!response.ok) throw new Error(`relay blob upload failed (${response.status})`);
|
|
2534
|
-
return {
|
|
2535
|
-
id: artifactId,
|
|
2536
|
-
kind: normalizeResultKind(input.kind),
|
|
2537
|
-
path: input.path,
|
|
2538
|
-
mimeType: input.mimeType,
|
|
2539
|
-
size: input.bytes.byteLength,
|
|
2540
|
-
sha256,
|
|
2541
|
-
baseCommit: input.baseCommit,
|
|
2542
|
-
transport: "relay-blob",
|
|
2543
|
-
artifactRef: `relay:${artifactId}`
|
|
2544
|
-
};
|
|
2545
|
-
}
|
|
2546
|
-
async function downloadRelayBlob(taskId, artifact) {
|
|
2547
|
-
if (artifact.transport !== "relay-blob") throw new Error("artifact is not a relay blob");
|
|
2548
|
-
const credentials = loadDeviceCredentials();
|
|
2549
|
-
if (!credentials?.encryptionPrivateKey) throw new Error("device decryption key is unavailable");
|
|
2550
|
-
const auth = getRelayHTTPAuth();
|
|
2551
|
-
const response = await fetch(
|
|
2552
|
-
`${auth.baseURL}/api/tasks/${encodeURIComponent(taskId)}/artifacts/${encodeURIComponent(artifact.id)}`,
|
|
2553
|
-
{ headers: { Authorization: `Bearer ${auth.accessToken}` } }
|
|
2554
|
-
);
|
|
2555
|
-
if (!response.ok) throw new Error(`relay blob download failed (${response.status})`);
|
|
2556
|
-
const metadataEncoded = response.headers.get("x-artifact-metadata") ?? "";
|
|
2557
|
-
const metadata = JSON.parse(Buffer.from(metadataEncoded, "base64url").toString("utf8"));
|
|
2558
|
-
const bytes = decryptForDevice(new Uint8Array(await response.arrayBuffer()), metadata.keyWraps, auth.deviceId, credentials.encryptionPrivateKey);
|
|
2559
|
-
const digest = node_crypto.createHash("sha256").update(bytes).digest("hex");
|
|
2560
|
-
if (bytes.byteLength !== artifact.size || digest !== artifact.sha256) throw new Error("relay artifact hash mismatch");
|
|
2561
|
-
return bytes;
|
|
2562
|
-
}
|
|
2563
|
-
function encryptForRecipients(bytes, peers) {
|
|
2564
|
-
const key = node_crypto.randomBytes(32);
|
|
2565
|
-
const iv = node_crypto.randomBytes(12);
|
|
2566
|
-
const cipher = node_crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
2567
|
-
const encrypted = Buffer.concat([cipher.update(bytes), cipher.final()]);
|
|
2568
|
-
const ciphertext = Buffer.concat([Buffer.from([1]), iv, cipher.getAuthTag(), encrypted]);
|
|
2569
|
-
const keyWraps = peers.map((peer) => ({
|
|
2570
|
-
deviceId: peer.id,
|
|
2571
|
-
wrappedKey: node_crypto.publicEncrypt(
|
|
2572
|
-
{ key: peer.encryptionPublicKey, padding: node_crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
|
|
2573
|
-
key
|
|
2574
|
-
).toString("base64")
|
|
2575
|
-
}));
|
|
2576
|
-
return { ciphertext, keyWraps };
|
|
2577
|
-
}
|
|
2578
|
-
function decryptForDevice(ciphertext, wraps, deviceId, privateKey) {
|
|
2579
|
-
if (ciphertext.byteLength < 30 || ciphertext[0] !== 1) throw new Error("invalid relay ciphertext");
|
|
2580
|
-
const wrap = wraps.find((candidate) => candidate.deviceId === deviceId);
|
|
2581
|
-
if (!wrap) throw new Error("artifact key is not wrapped for this device");
|
|
2582
|
-
const key = node_crypto.privateDecrypt(
|
|
2583
|
-
{ key: privateKey, padding: node_crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
|
|
2584
|
-
Buffer.from(wrap.wrappedKey, "base64")
|
|
2585
|
-
);
|
|
2586
|
-
const iv = ciphertext.slice(1, 13);
|
|
2587
|
-
const tag = ciphertext.slice(13, 29);
|
|
2588
|
-
const decipher = node_crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
2589
|
-
decipher.setAuthTag(tag);
|
|
2590
|
-
return Buffer.concat([decipher.update(ciphertext.slice(29)), decipher.final()]);
|
|
2591
|
-
}
|
|
2592
|
-
function normalizeResultKind(kind) {
|
|
2593
|
-
return kind === "text" || kind === "markdown" || kind === "patch" ? kind : "file";
|
|
2594
|
-
}
|
|
2595
|
-
function writeJSON(res, status2, body) {
|
|
2596
|
-
res.writeHead(status2, { "content-type": "application/json" });
|
|
2597
|
-
res.end(JSON.stringify(body));
|
|
2598
|
-
}
|
|
2599
|
-
function ok(res, data) {
|
|
2600
|
-
writeJSON(res, 200, { code: 0, message: "ok", ...data !== void 0 ? { data } : {} });
|
|
2601
|
-
}
|
|
2602
|
-
function fail(res, status2, message, errorCode) {
|
|
2603
|
-
writeJSON(res, status2, {
|
|
2604
|
-
code: status2,
|
|
2605
|
-
message,
|
|
2606
|
-
...errorCode ? { errorCode } : {}
|
|
2607
|
-
});
|
|
2608
|
-
}
|
|
2609
|
-
class BodyTooLargeError extends Error {
|
|
2610
|
-
}
|
|
2611
|
-
function hasJSONContentType(req) {
|
|
2612
|
-
const value = req.headers["content-type"];
|
|
2613
|
-
return typeof value === "string" && value.toLowerCase().startsWith("application/json");
|
|
2614
|
-
}
|
|
2615
|
-
function hasValidSessionToken(req, sessionToken) {
|
|
2616
|
-
const authorization = req.headers.authorization;
|
|
2617
|
-
if (!authorization?.startsWith("Bearer ")) return false;
|
|
2618
|
-
const supplied = authorization.slice("Bearer ".length);
|
|
2619
|
-
const expected = Buffer.from(sessionToken);
|
|
2620
|
-
const actual = Buffer.from(supplied);
|
|
2621
|
-
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
|
2622
|
-
}
|
|
2623
|
-
async function readBody(req, maxBytes) {
|
|
2624
|
-
return new Promise((resolve2, reject) => {
|
|
2625
|
-
const declaredSize = Number(req.headers["content-length"]);
|
|
2626
|
-
if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
|
|
2627
|
-
req.resume();
|
|
2628
|
-
reject(new BodyTooLargeError());
|
|
2629
|
-
return;
|
|
2630
|
-
}
|
|
2631
|
-
const chunks = [];
|
|
2632
|
-
let size = 0;
|
|
2633
|
-
let rejected = false;
|
|
2634
|
-
req.on("data", (chunk) => {
|
|
2635
|
-
if (rejected) return;
|
|
2636
|
-
size += chunk.length;
|
|
2637
|
-
if (size > maxBytes) {
|
|
2638
|
-
rejected = true;
|
|
2639
|
-
req.resume();
|
|
2640
|
-
reject(new BodyTooLargeError());
|
|
2641
|
-
return;
|
|
2642
|
-
}
|
|
2643
|
-
chunks.push(chunk);
|
|
2644
|
-
});
|
|
2645
|
-
req.on("end", () => {
|
|
2646
|
-
if (!rejected) resolve2(Buffer.concat(chunks).toString("utf8"));
|
|
2647
|
-
});
|
|
2648
|
-
req.on("error", reject);
|
|
2649
|
-
});
|
|
2650
|
-
}
|
|
2651
|
-
let eventServer;
|
|
2652
|
-
function startEventServer(appVersion = "0.0.0") {
|
|
2653
|
-
if (eventServer) return;
|
|
2654
|
-
const sessionToken = crypto.randomBytes(32).toString("base64url");
|
|
2655
|
-
const server = http.createServer((req, res) => {
|
|
2656
|
-
void (async () => {
|
|
2657
|
-
if (req.method !== "POST") {
|
|
2658
|
-
fail(res, 404, "not found");
|
|
2659
|
-
return;
|
|
2660
|
-
}
|
|
2661
|
-
if (!hasJSONContentType(req)) {
|
|
2662
|
-
fail(res, 415, "content-type must be application/json");
|
|
2663
|
-
return;
|
|
2664
|
-
}
|
|
2665
|
-
if (!hasValidSessionToken(req, sessionToken)) {
|
|
2666
|
-
fail(res, 401, "invalid local pet session");
|
|
2667
|
-
return;
|
|
2668
|
-
}
|
|
2669
|
-
try {
|
|
2670
|
-
const raw = JSON.parse(await readBody(req, LOCAL_PET_MAX_BODY_BYTES));
|
|
2671
|
-
if (isRelayCommand(raw)) {
|
|
2672
|
-
await handleRelay(raw, res);
|
|
2673
|
-
return;
|
|
2674
|
-
}
|
|
2675
|
-
const event = parseIncomingEvent(raw);
|
|
2676
|
-
if (event.type === "quest") {
|
|
2677
|
-
handleQuest(event);
|
|
2678
|
-
logger.info("quest started", { phase: event.phase });
|
|
2679
|
-
} else {
|
|
2680
|
-
dispatch(event);
|
|
2681
|
-
logger.debug("event dispatched", { type: event.type });
|
|
2682
|
-
}
|
|
2683
|
-
ok(res);
|
|
2684
|
-
} catch (err) {
|
|
2685
|
-
if (err instanceof AppError) {
|
|
2686
|
-
fail(res, 400, err.message, err.code);
|
|
2687
|
-
} else if (err instanceof BodyTooLargeError) {
|
|
2688
|
-
fail(res, 413, `request body exceeds ${LOCAL_PET_MAX_BODY_BYTES} bytes`);
|
|
2689
|
-
} else if (err instanceof SyntaxError) {
|
|
2690
|
-
fail(res, 400, "bad json");
|
|
2691
|
-
} else {
|
|
2692
|
-
const relayCode = err.code;
|
|
2693
|
-
if (isErrorCode(relayCode) && relayCode !== ErrorCodes.INTERNAL) {
|
|
2694
|
-
fail(res, relayErrorHTTPStatus(relayCode), err instanceof Error ? err.message : relayCode, relayCode);
|
|
2695
|
-
} else {
|
|
2696
|
-
logger.error("event server error", { error: String(err) });
|
|
2697
|
-
fail(res, 500, "internal error");
|
|
2698
|
-
}
|
|
2699
|
-
}
|
|
2700
|
-
}
|
|
2701
|
-
})();
|
|
2702
|
-
});
|
|
2703
|
-
eventServer = server;
|
|
2704
|
-
server.listen(0, "127.0.0.1", () => {
|
|
2705
|
-
const addr = server.address();
|
|
2706
|
-
if (addr && typeof addr === "object") {
|
|
2707
|
-
const endpoint = {
|
|
2708
|
-
protocolVersion: LOCAL_PET_PROTOCOL_VERSION,
|
|
2709
|
-
appVersion,
|
|
2710
|
-
port: addr.port,
|
|
2711
|
-
pid: process.pid,
|
|
2712
|
-
sessionToken,
|
|
2713
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2714
|
-
};
|
|
2715
|
-
fs.writeFileSync(PORT_FILE, JSON.stringify(endpoint), { mode: 384 });
|
|
2716
|
-
fs.chmodSync(PORT_FILE, 384);
|
|
2717
|
-
logger.info("event server listening", { port: addr.port });
|
|
2718
|
-
}
|
|
2719
|
-
});
|
|
2720
|
-
}
|
|
2721
|
-
function relayErrorHTTPStatus(code) {
|
|
2722
|
-
if (code === ErrorCodes.TASK_NOT_FOUND || code === ErrorCodes.UNKNOWN_QUESTION) return 404;
|
|
2723
|
-
if (code === ErrorCodes.HELP_NOT_ACCEPTED || code === ErrorCodes.NOT_FRIENDS || code === ErrorCodes.USER_NOT_ONLINE || code === ErrorCodes.QUESTION_NOT_ASSIGNED) {
|
|
2724
|
-
return 409;
|
|
2725
|
-
}
|
|
2726
|
-
return 400;
|
|
2727
|
-
}
|
|
2728
|
-
async function handleRelay(cmd, res) {
|
|
2729
|
-
switch (cmd.type) {
|
|
2730
|
-
case "ask": {
|
|
2731
|
-
if (!cmd.to?.trim() || !cmd.text?.trim()) {
|
|
2732
|
-
fail(res, 400, "ask requires to and text", ErrorCodes.BAD_EVENT);
|
|
2733
|
-
return;
|
|
2734
|
-
}
|
|
2735
|
-
if (isProtocolV1Available()) {
|
|
2736
|
-
const now = /* @__PURE__ */ new Date();
|
|
2737
|
-
const envelope = {
|
|
2738
|
-
protocolVersion: TASK_PROTOCOL_VERSION,
|
|
2739
|
-
taskId: crypto.randomUUID(),
|
|
2740
|
-
idempotencyKey: crypto.randomUUID(),
|
|
2741
|
-
from: { userId: "authenticated-local-user", deviceId: "" },
|
|
2742
|
-
to: { userId: cmd.to.trim() },
|
|
2743
|
-
createdAt: now.toISOString(),
|
|
2744
|
-
deadlineAt: new Date(now.getTime() + (cmd.timeoutMs ?? 3e5)).toISOString(),
|
|
2745
|
-
objective: cmd.text.trim(),
|
|
2746
|
-
title: cmd.title,
|
|
2747
|
-
constraints: [],
|
|
2748
|
-
acceptanceCriteria: [],
|
|
2749
|
-
context: {
|
|
2750
|
-
summary: cmd.context ?? "",
|
|
2751
|
-
items: cmd.context ? [inlineContextItem({ kind: "text", text: cmd.context })] : [],
|
|
2752
|
-
redactions: []
|
|
2753
|
-
},
|
|
2754
|
-
executionPolicy: {
|
|
2755
|
-
capabilities: ["filesystem.read"],
|
|
2756
|
-
writableScopes: [],
|
|
2757
|
-
networkPolicy: "ask",
|
|
2758
|
-
approvalMode: "manual",
|
|
2759
|
-
maxRuntimeMs: cmd.timeoutMs ?? 3e5
|
|
2760
|
-
},
|
|
2761
|
-
outputContract: {
|
|
2762
|
-
acceptedKinds: ["text", "markdown", "patch", "files"],
|
|
2763
|
-
maxTotalBytes: 10 * 1024 * 1024,
|
|
2764
|
-
requireTests: false,
|
|
2765
|
-
includeReasoning: false
|
|
2766
|
-
}
|
|
2767
|
-
};
|
|
2768
|
-
const created = await delegateTask(envelope);
|
|
2769
|
-
const completed = await waitRemoteTaskResult(created.task.taskId, Math.min(cmd.timeoutMs ?? 55e3, 55e3));
|
|
2770
|
-
const firstText = completed.result?.artifacts.find((artifact) => artifact.inlineText)?.inlineText;
|
|
2771
|
-
const fallback = completed.result ? `任务状态:${completed.status}` : `任务状态:${completed.status}${created.warning ? `(${created.warning})` : ""}`;
|
|
2772
|
-
ok(res, {
|
|
2773
|
-
questionId: completed.taskId,
|
|
2774
|
-
from: completed.envelope.to.userId,
|
|
2775
|
-
text: firstText ?? completed.result?.summary ?? fallback,
|
|
2776
|
-
resultKind: completed.result?.artifacts[0]?.kind ?? "text",
|
|
2777
|
-
timedOut: !completed.result
|
|
2778
|
-
});
|
|
2779
|
-
return;
|
|
2780
|
-
}
|
|
2781
|
-
const result = await askFriend({
|
|
2782
|
-
to: cmd.to.trim(),
|
|
2783
|
-
title: cmd.title,
|
|
2784
|
-
text: cmd.text,
|
|
2785
|
-
context: cmd.context,
|
|
2786
|
-
tools: cmd.tools,
|
|
2787
|
-
timeoutMs: cmd.timeoutMs
|
|
2788
|
-
});
|
|
2789
|
-
ok(res, result);
|
|
2790
|
-
return;
|
|
2791
|
-
}
|
|
2792
|
-
case "list_tasks": {
|
|
2793
|
-
if (isProtocolV1Available()) {
|
|
2794
|
-
const taskV1 = await claimRemoteTask(1e3);
|
|
2795
|
-
ok(res, { taskV1, tasks: [] });
|
|
2796
|
-
return;
|
|
2797
|
-
}
|
|
2798
|
-
ok(res, { tasks: listPendingTasks() });
|
|
2799
|
-
return;
|
|
2800
|
-
}
|
|
2801
|
-
case "wait_task": {
|
|
2802
|
-
if (isProtocolV1Available()) {
|
|
2803
|
-
const taskV1 = await claimRemoteTask(cmd.timeoutMs);
|
|
2804
|
-
ok(res, { taskV1 });
|
|
2805
|
-
return;
|
|
2806
|
-
}
|
|
2807
|
-
const task = await waitForPendingTask(cmd.timeoutMs);
|
|
2808
|
-
ok(res, { task });
|
|
2809
|
-
return;
|
|
2810
|
-
}
|
|
2811
|
-
case "answer": {
|
|
2812
|
-
if (!cmd.questionId?.trim() || !cmd.text?.trim()) {
|
|
2813
|
-
fail(res, 400, "answer requires questionId and text", ErrorCodes.BAD_EVENT);
|
|
2814
|
-
return;
|
|
2815
|
-
}
|
|
2816
|
-
try {
|
|
2817
|
-
if (isProtocolV1Available()) {
|
|
2818
|
-
const task = await getRemoteTask(cmd.questionId);
|
|
2819
|
-
const artifact = inlineResultArtifact({
|
|
2820
|
-
kind: cmd.resultKind === "patch" ? "patch" : cmd.resultKind === "markdown" ? "markdown" : "text",
|
|
2821
|
-
text: cmd.text,
|
|
2822
|
-
baseCommit: cmd.resultKind === "patch" ? task.envelope.workspace?.baseCommit : void 0
|
|
2823
|
-
});
|
|
2824
|
-
const result = {
|
|
2825
|
-
protocolVersion: TASK_PROTOCOL_VERSION,
|
|
2826
|
-
taskId: cmd.questionId,
|
|
2827
|
-
status: "completed",
|
|
2828
|
-
summary: cmd.text.slice(0, 1e3),
|
|
2829
|
-
artifacts: [artifact],
|
|
2830
|
-
diagnostics: [],
|
|
2831
|
-
tests: [],
|
|
2832
|
-
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2833
|
-
};
|
|
2834
|
-
await submitRemoteTaskResult(result);
|
|
2835
|
-
ok(res, { status: "ok" });
|
|
2836
|
-
return;
|
|
2837
|
-
}
|
|
2838
|
-
await answerFriend(cmd.questionId, cmd.text, normalizeKind(cmd.resultKind));
|
|
2839
|
-
ok(res, { status: "ok" });
|
|
2840
|
-
} catch (err) {
|
|
2841
|
-
const code = err.code;
|
|
2842
|
-
if (code === "UNKNOWN_QUESTION") {
|
|
2843
|
-
fail(res, 404, "问题不存在或已被回答", ErrorCodes.UNKNOWN_QUESTION);
|
|
2844
|
-
return;
|
|
2845
|
-
}
|
|
2846
|
-
throw err;
|
|
2847
|
-
}
|
|
2848
|
-
return;
|
|
2849
|
-
}
|
|
2850
|
-
case "delegate_task": {
|
|
2851
|
-
const { task, warning } = await delegateTask(cmd.envelope);
|
|
2852
|
-
ok(res, { task, warning });
|
|
2853
|
-
return;
|
|
2854
|
-
}
|
|
2855
|
-
case "register_mcp_client": {
|
|
2856
|
-
const agentHost = cmd.agentHost.trim();
|
|
2857
|
-
if (!agentHost || agentHost.length > 200) {
|
|
2858
|
-
fail(res, 400, "register_mcp_client requires agentHost", ErrorCodes.BAD_EVENT);
|
|
2859
|
-
return;
|
|
2860
|
-
}
|
|
2861
|
-
await registerMcpClient(agentHost, cmd.mcpCapabilities ?? {});
|
|
2862
|
-
ok(res);
|
|
2863
|
-
return;
|
|
2864
|
-
}
|
|
2865
|
-
case "get_task": {
|
|
2866
|
-
const task = await getRemoteTask(cmd.taskId);
|
|
2867
|
-
ok(res, { task });
|
|
2868
|
-
return;
|
|
2869
|
-
}
|
|
2870
|
-
case "wait_task_result": {
|
|
2871
|
-
const task = await waitRemoteTaskResult(cmd.taskId, cmd.timeoutMs);
|
|
2872
|
-
ok(res, { task });
|
|
2873
|
-
return;
|
|
2874
|
-
}
|
|
2875
|
-
case "claim_task": {
|
|
2876
|
-
const task = await claimRemoteTask(cmd.timeoutMs);
|
|
2877
|
-
ok(res, { task });
|
|
2878
|
-
return;
|
|
2879
|
-
}
|
|
2880
|
-
case "submit_task_result": {
|
|
2881
|
-
await submitRemoteTaskResult(cmd.result);
|
|
2882
|
-
ok(res, { status: "ok" });
|
|
2883
|
-
return;
|
|
2884
|
-
}
|
|
2885
|
-
case "cancel_task": {
|
|
2886
|
-
const task = await cancelRemoteTask(cmd.taskId);
|
|
2887
|
-
ok(res, { task });
|
|
2888
|
-
return;
|
|
2889
|
-
}
|
|
2890
|
-
case "list_remote_tasks": {
|
|
2891
|
-
const tasks = await listRemoteTasks({
|
|
2892
|
-
direction: cmd.direction,
|
|
2893
|
-
status: cmd.status,
|
|
2894
|
-
limit: cmd.limit
|
|
2895
|
-
});
|
|
2896
|
-
ok(res, { tasks });
|
|
2897
|
-
return;
|
|
2898
|
-
}
|
|
2899
|
-
case "request_task_context": {
|
|
2900
|
-
const task = await requestRemoteTaskContext(cmd.taskId, {
|
|
2901
|
-
paths: cmd.paths,
|
|
2902
|
-
questions: cmd.questions
|
|
2903
|
-
});
|
|
2904
|
-
ok(res, { task });
|
|
2905
|
-
return;
|
|
2906
|
-
}
|
|
2907
|
-
case "supply_task_context": {
|
|
2908
|
-
const task = await supplyRemoteTaskContext(cmd.taskId, cmd.items);
|
|
2909
|
-
ok(res, { task });
|
|
2910
|
-
return;
|
|
2911
|
-
}
|
|
2912
|
-
case "configure_device": {
|
|
2913
|
-
const { configureAndActivateDevice: configureAndActivateDevice2 } = await Promise.resolve().then(() => deviceAuth);
|
|
2914
|
-
await configureAndActivateDevice2({
|
|
2915
|
-
serverUrl: cmd.serverUrl,
|
|
2916
|
-
userId: cmd.userId,
|
|
2917
|
-
deviceId: cmd.deviceId,
|
|
2918
|
-
refreshToken: cmd.refreshToken
|
|
2919
|
-
});
|
|
2920
|
-
ok(res, { status: "configured" });
|
|
2921
|
-
return;
|
|
2922
|
-
}
|
|
2923
|
-
case "upload_relay_artifact": {
|
|
2924
|
-
const source = resolveWorkspaceFile(cmd.workspaceRoot, cmd.path, true);
|
|
2925
|
-
const bytes = fs.readFileSync(source);
|
|
2926
|
-
const artifact = await uploadRelayBlob({
|
|
2927
|
-
taskId: cmd.taskId,
|
|
2928
|
-
bytes,
|
|
2929
|
-
direction: cmd.direction,
|
|
2930
|
-
kind: cmd.kind,
|
|
2931
|
-
mimeType: cmd.mimeType,
|
|
2932
|
-
path: cmd.path,
|
|
2933
|
-
baseCommit: cmd.baseCommit
|
|
2934
|
-
});
|
|
2935
|
-
ok(res, { artifact });
|
|
2936
|
-
return;
|
|
2937
|
-
}
|
|
2938
|
-
case "download_relay_artifact": {
|
|
2939
|
-
const destination = resolveWorkspaceFile(cmd.workspaceRoot, cmd.destinationPath, false);
|
|
2940
|
-
if (fs.existsSync(destination)) throw new Error("目标文件已存在;relay artifact 不会覆盖现有文件");
|
|
2941
|
-
const bytes = await downloadRelayBlob(cmd.taskId, cmd.artifact);
|
|
2942
|
-
fs.writeFileSync(destination, bytes, { flag: "wx", mode: 384 });
|
|
2943
|
-
ok(res, { path: destination, size: bytes.byteLength });
|
|
2944
|
-
return;
|
|
2945
|
-
}
|
|
2946
|
-
}
|
|
2947
|
-
}
|
|
2948
|
-
function resolveWorkspaceFile(workspaceRoot, relativePath, mustExist) {
|
|
2949
|
-
if (!workspaceRoot || !relativePath || relativePath.includes("\0")) throw new Error("无效工作区路径");
|
|
2950
|
-
const root = fs.realpathSync(workspaceRoot);
|
|
2951
|
-
const candidate = node_path.resolve(root, relativePath);
|
|
2952
|
-
if (candidate !== root && !candidate.startsWith(root + node_path.sep)) throw new Error("路径越出工作区");
|
|
2953
|
-
if (mustExist) {
|
|
2954
|
-
const actual = fs.realpathSync(candidate);
|
|
2955
|
-
if (actual !== root && !actual.startsWith(root + node_path.sep)) throw new Error("符号链接越出工作区");
|
|
2956
|
-
if (!fs.statSync(actual).isFile()) throw new Error("artifact 来源必须是普通文件");
|
|
2957
|
-
return actual;
|
|
2958
|
-
}
|
|
2959
|
-
const parent = fs.realpathSync(node_path.dirname(candidate));
|
|
2960
|
-
if (parent !== root && !parent.startsWith(root + node_path.sep)) throw new Error("目标目录越出工作区");
|
|
2961
|
-
return candidate;
|
|
2962
|
-
}
|
|
2963
|
-
function normalizeKind(v) {
|
|
2964
|
-
if (v === "code" || v === "patch" || v === "markdown" || v === "files" || v === "text") return v;
|
|
2965
|
-
return "text";
|
|
2966
|
-
}
|
|
2967
|
-
async function stopEventServer() {
|
|
2968
|
-
fs.rmSync(PORT_FILE, { force: true });
|
|
2969
|
-
const server = eventServer;
|
|
2970
|
-
eventServer = void 0;
|
|
2971
|
-
if (!server) return;
|
|
2972
|
-
await new Promise((resolve2, reject) => {
|
|
2973
|
-
server.close((err) => err ? reject(err) : resolve2());
|
|
2974
|
-
});
|
|
2975
|
-
}
|
|
2976
|
-
function mapProviderEvent(event) {
|
|
2977
|
-
if (!event || typeof event !== "object") return null;
|
|
2978
|
-
const value = event;
|
|
2979
|
-
if ("reasoning" in value || "tool_input" in value || "tool_output" in value || "environment" in value) {
|
|
2980
|
-
return null;
|
|
2981
|
-
}
|
|
2982
|
-
if (value.type === "final" && typeof value.text === "string") {
|
|
2983
|
-
return { type: "final", text: sanitize(value.text) };
|
|
2984
|
-
}
|
|
2985
|
-
if (value.type === "progress" && typeof value.message === "string") {
|
|
2986
|
-
return { type: "progress", message: sanitize(value.message).slice(0, 500) };
|
|
2987
|
-
}
|
|
2988
|
-
if (value.type === "error" && typeof value.message === "string") {
|
|
2989
|
-
return { type: "error", message: sanitize(value.message).slice(0, 1e3) };
|
|
2990
|
-
}
|
|
2991
|
-
if (value.type === "artifact" && typeof value.path === "string" && typeof value.mimeType === "string") {
|
|
2992
|
-
return { type: "artifact", path: value.path, mimeType: value.mimeType };
|
|
2993
|
-
}
|
|
2994
|
-
return null;
|
|
2995
|
-
}
|
|
2996
|
-
function sanitize(text) {
|
|
2997
|
-
return text.replace(/\b(?:sk|pk|api)[-_][A-Za-z0-9_-]{16,}\b/g, "[REDACTED_TOKEN]").replace(/(authorization|api[_-]?key)\s*[:=]\s*\S+/gi, "$1=[REDACTED]");
|
|
2998
|
-
}
|
|
2999
|
-
class CodexCLIAdapter {
|
|
3000
|
-
constructor(workspaceRoot) {
|
|
3001
|
-
this.workspaceRoot = workspaceRoot;
|
|
3002
|
-
}
|
|
3003
|
-
id = "codex-cli";
|
|
3004
|
-
runs = /* @__PURE__ */ new Map();
|
|
3005
|
-
async detect() {
|
|
3006
|
-
return new Promise((resolve) => {
|
|
3007
|
-
const child = node_child_process.spawn("codex", ["--version"], { stdio: "ignore" });
|
|
3008
|
-
child.once("error", () => resolve(false));
|
|
3009
|
-
child.once("exit", (code) => resolve(code === 0));
|
|
3010
|
-
});
|
|
3011
|
-
}
|
|
3012
|
-
async startTask(input) {
|
|
3013
|
-
if (process.env.DESKTOP_PET_RUNNER_ADAPTERS !== "1") {
|
|
3014
|
-
throw new Error("runner adapters are disabled");
|
|
3015
|
-
}
|
|
3016
|
-
const runId = node_crypto.randomUUID();
|
|
3017
|
-
const prompt = publicTaskPrompt(input);
|
|
3018
|
-
const child = node_child_process.spawn("codex", ["exec", "--json", "--sandbox", "workspace-write", prompt], {
|
|
3019
|
-
cwd: this.workspaceRoot,
|
|
3020
|
-
env: runnerEnvironment(),
|
|
3021
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
3022
|
-
});
|
|
3023
|
-
const result = collectResult(child, input);
|
|
3024
|
-
this.runs.set(runId, { child, result });
|
|
3025
|
-
return { runId };
|
|
3026
|
-
}
|
|
3027
|
-
async waitForResult(runId, signal) {
|
|
3028
|
-
const run = this.runs.get(runId);
|
|
3029
|
-
if (!run) throw new Error("unknown runner runId");
|
|
3030
|
-
const abort = () => run.child.kill("SIGTERM");
|
|
3031
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
3032
|
-
try {
|
|
3033
|
-
return await run.result;
|
|
3034
|
-
} finally {
|
|
3035
|
-
signal.removeEventListener("abort", abort);
|
|
3036
|
-
this.runs.delete(runId);
|
|
3037
|
-
}
|
|
3038
|
-
}
|
|
3039
|
-
async cancel(runId) {
|
|
3040
|
-
this.runs.get(runId)?.child.kill("SIGTERM");
|
|
3041
|
-
}
|
|
3042
|
-
}
|
|
3043
|
-
function runnerEnvironment() {
|
|
3044
|
-
const environment = { ...process.env };
|
|
3045
|
-
for (const key of Object.keys(environment)) {
|
|
3046
|
-
if (/^DESKTOP_PET_.*(?:TOKEN|SECRET|PASSWORD|REFRESH|TURN)/i.test(key)) {
|
|
3047
|
-
delete environment[key];
|
|
3048
|
-
}
|
|
3049
|
-
}
|
|
3050
|
-
return environment;
|
|
3051
|
-
}
|
|
3052
|
-
function publicTaskPrompt(input) {
|
|
3053
|
-
return [
|
|
3054
|
-
input.objective,
|
|
3055
|
-
input.constraints.length ? `Constraints:
|
|
3056
|
-
- ${input.constraints.join("\n- ")}` : "",
|
|
3057
|
-
input.acceptanceCriteria.length ? `Acceptance criteria:
|
|
3058
|
-
- ${input.acceptanceCriteria.join("\n- ")}` : "",
|
|
3059
|
-
"Return only the final result and concise test summary. Do not expose hidden reasoning, credentials, or raw environment data."
|
|
3060
|
-
].filter(Boolean).join("\n\n");
|
|
3061
|
-
}
|
|
3062
|
-
function collectResult(child, task) {
|
|
3063
|
-
return new Promise((resolve) => {
|
|
3064
|
-
const publicEvents = [];
|
|
3065
|
-
let buffered = "";
|
|
3066
|
-
child.stdout.setEncoding("utf8");
|
|
3067
|
-
child.stdout.on("data", (chunk) => {
|
|
3068
|
-
buffered += chunk;
|
|
3069
|
-
const lines = buffered.split("\n");
|
|
3070
|
-
buffered = lines.pop() ?? "";
|
|
3071
|
-
for (const line of lines) {
|
|
3072
|
-
try {
|
|
3073
|
-
const mapped = mapProviderEvent(JSON.parse(line));
|
|
3074
|
-
if (mapped) publicEvents.push(mapped);
|
|
3075
|
-
} catch {
|
|
3076
|
-
}
|
|
3077
|
-
}
|
|
3078
|
-
});
|
|
3079
|
-
child.once("exit", (code) => {
|
|
3080
|
-
const final = [...publicEvents].reverse().find((event) => event.type === "final");
|
|
3081
|
-
const text = final?.type === "final" ? final.text : code === 0 ? "Runner completed without a public final event." : "Runner failed.";
|
|
3082
|
-
resolve({
|
|
3083
|
-
protocolVersion: TASK_PROTOCOL_VERSION,
|
|
3084
|
-
taskId: task.taskId,
|
|
3085
|
-
status: code === 0 ? "completed" : "failed",
|
|
3086
|
-
summary: text.slice(0, 8e3),
|
|
3087
|
-
artifacts: [inlineResultArtifact({ kind: "text", text })],
|
|
3088
|
-
diagnostics: code === 0 ? [] : [{ level: "error", code: "RUNNER_EXIT", message: `runner exited with code ${code}` }],
|
|
3089
|
-
tests: [],
|
|
3090
|
-
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3091
|
-
});
|
|
3092
|
-
});
|
|
3093
|
-
});
|
|
3094
|
-
}
|
|
3095
|
-
let stopped = true;
|
|
3096
|
-
let activeAbort;
|
|
3097
|
-
async function startRunnerManager() {
|
|
3098
|
-
if (process.env.DESKTOP_PET_RUNNER_ADAPTERS !== "1" || !stopped) return;
|
|
3099
|
-
const configuredRoot = process.env.DESKTOP_PET_RUNNER_WORKSPACE?.trim();
|
|
3100
|
-
if (!configuredRoot) {
|
|
3101
|
-
logger.warn("runner adapters enabled without DESKTOP_PET_RUNNER_WORKSPACE");
|
|
3102
|
-
return;
|
|
3103
|
-
}
|
|
3104
|
-
const adapter = new CodexCLIAdapter(node_path.resolve(configuredRoot));
|
|
3105
|
-
if (!await adapter.detect()) {
|
|
3106
|
-
logger.warn("Codex CLI runner adapter is unavailable; MCP duty mode remains active");
|
|
3107
|
-
return;
|
|
3108
|
-
}
|
|
3109
|
-
stopped = false;
|
|
3110
|
-
void dutyLoop(adapter);
|
|
3111
|
-
}
|
|
3112
|
-
function stopRunnerManager() {
|
|
3113
|
-
stopped = true;
|
|
3114
|
-
activeAbort?.abort();
|
|
3115
|
-
activeAbort = void 0;
|
|
3116
|
-
}
|
|
3117
|
-
async function dutyLoop(adapter) {
|
|
3118
|
-
while (!stopped) {
|
|
3119
|
-
try {
|
|
3120
|
-
const task = await claimRemoteTask(45e3);
|
|
3121
|
-
if (!task || stopped) continue;
|
|
3122
|
-
const run = await adapter.startTask(task.envelope);
|
|
3123
|
-
const abort = new AbortController();
|
|
3124
|
-
activeAbort = abort;
|
|
3125
|
-
const cancellationPoll = setInterval(() => {
|
|
3126
|
-
void getRemoteTask(task.taskId).then((latest) => {
|
|
3127
|
-
if (latest.status === "cancelling" || latest.status === "cancelled") abort.abort();
|
|
3128
|
-
}).catch(() => {
|
|
3129
|
-
});
|
|
3130
|
-
}, 3e3);
|
|
3131
|
-
try {
|
|
3132
|
-
const result = await adapter.waitForResult(run.runId, abort.signal);
|
|
3133
|
-
await submitRemoteTaskResult(result);
|
|
3134
|
-
} finally {
|
|
3135
|
-
clearInterval(cancellationPoll);
|
|
3136
|
-
activeAbort = void 0;
|
|
3137
|
-
}
|
|
3138
|
-
} catch (error) {
|
|
3139
|
-
logger.warn("runner duty iteration failed", { error: String(error) });
|
|
3140
|
-
await new Promise((resolveDelay) => setTimeout(resolveDelay, 2e3));
|
|
3141
|
-
}
|
|
3142
|
-
}
|
|
3143
|
-
}
|
|
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
|
-
const trayIcon1xPath = path.join(__dirname, "./chunks/tray-iconTemplate-BIK86F-0.png");
|
|
3171
|
-
const trayIcon2xPath = path.join(__dirname, "./chunks/tray-iconTemplate@2x-B19HpcaE.png");
|
|
3172
|
-
let tray;
|
|
3173
|
-
function createMenuBar() {
|
|
3174
|
-
if (process.platform !== "darwin" || tray) return;
|
|
3175
|
-
tray = new electron.Tray(createPetTemplateImage());
|
|
3176
|
-
tray.setToolTip("Desktop Pet");
|
|
3177
|
-
tray.on("click", showMenu);
|
|
3178
|
-
tray.on("right-click", showMenu);
|
|
3179
|
-
}
|
|
3180
|
-
function destroyMenuBar() {
|
|
3181
|
-
tray?.destroy();
|
|
3182
|
-
tray = void 0;
|
|
3183
|
-
}
|
|
3184
|
-
function showMenu() {
|
|
3185
|
-
tray?.popUpContextMenu(electron.Menu.buildFromTemplate([
|
|
3186
|
-
{ label: `Desktop Pet v${electron.app.getVersion()}`, enabled: false },
|
|
3187
|
-
{ type: "separator" },
|
|
3188
|
-
{
|
|
3189
|
-
label: petManager.arePetsVisible() ? "隐藏所有桌面宠物" : "显示所有桌面宠物",
|
|
3190
|
-
click: () => petManager.setPetsVisible(!petManager.arePetsVisible())
|
|
3191
|
-
},
|
|
3192
|
-
{ type: "separator" },
|
|
3193
|
-
{ label: "退出 Desktop Pet", click: () => electron.app.quit() }
|
|
3194
|
-
]));
|
|
3195
|
-
}
|
|
3196
|
-
function createPetTemplateImage() {
|
|
3197
|
-
let image = electron.nativeImage.createFromBuffer(node_fs.readFileSync(trayIcon2xPath), { scaleFactor: 2 });
|
|
3198
|
-
if (image.isEmpty()) image = electron.nativeImage.createFromPath(trayIcon1xPath);
|
|
3199
|
-
if (image.isEmpty()) throw new Error("无法加载 macOS 菜单栏图标");
|
|
3200
|
-
image.setTemplateImage(true);
|
|
3201
|
-
return image;
|
|
3202
|
-
}
|
|
3203
|
-
configureRuntimeIdentity();
|
|
3204
|
-
registerAccountProtocol();
|
|
3205
|
-
const singleInstance = electron.app.requestSingleInstanceLock();
|
|
3206
|
-
if (!singleInstance) {
|
|
3207
|
-
logger.warn("another instance is running, quitting");
|
|
3208
|
-
electron.app.quit();
|
|
3209
|
-
} else {
|
|
3210
|
-
electron.app.on("second-instance", (_event, argv) => {
|
|
3211
|
-
const callback = argv.find((value) => value.startsWith("desktop-pet://"));
|
|
3212
|
-
if (callback) handleAccountCallback(callback);
|
|
3213
|
-
petManager.setPetsVisible(true);
|
|
3214
|
-
ensurePrimaryPet();
|
|
3215
|
-
});
|
|
3216
|
-
}
|
|
3217
|
-
function ensurePrimaryPet() {
|
|
3218
|
-
if (petManager.hasPrimaryPet()) return null;
|
|
3219
|
-
return createPetWindow(process.env.DESKTOP_PET_SKIN || "codex", {
|
|
3220
|
-
identity: { kind: "primary" }
|
|
3221
|
-
});
|
|
3222
|
-
}
|
|
3223
|
-
electron.app.whenReady().then(async () => {
|
|
3224
|
-
createMenuBar();
|
|
3225
|
-
await startAccountCallbackRouter();
|
|
3226
|
-
startEventServer(electron.app.getVersion());
|
|
3227
|
-
const activated = await activateStoredDevice().catch((err) => {
|
|
3228
|
-
logger.warn("device authentication failed", { error: String(err) });
|
|
3229
|
-
return false;
|
|
3230
|
-
});
|
|
3231
|
-
if (!activated) startRelay();
|
|
3232
|
-
if (process.env.DESKTOP_PET_P2P !== "0") startP2PWindow(getDeviceIceServers());
|
|
3233
|
-
void startRunnerManager();
|
|
3234
|
-
const id = ensurePrimaryPet();
|
|
3235
|
-
setTimeout(() => {
|
|
3236
|
-
void checkForUpdates().then((result) => {
|
|
3237
|
-
if (id && result.kind === "available") {
|
|
3238
|
-
dispatch({
|
|
3239
|
-
type: "bubble",
|
|
3240
|
-
text: `发现 v${result.update.version},右键“软件更新”可请求下载`,
|
|
3241
|
-
ttl: 7e3
|
|
3242
|
-
}, id);
|
|
3243
|
-
}
|
|
3244
|
-
});
|
|
3245
|
-
}, 1200);
|
|
3246
|
-
logger.info("app started", { firstPet: id });
|
|
3247
|
-
});
|
|
3248
|
-
electron.app.on("window-all-closed", () => {
|
|
3249
|
-
if (process.platform !== "darwin") electron.app.quit();
|
|
3250
|
-
});
|
|
3251
|
-
electron.app.on("quit", () => {
|
|
3252
|
-
destroyMenuBar();
|
|
3253
|
-
stopAccountCallbackRouter();
|
|
3254
|
-
stopRelay();
|
|
3255
|
-
stopP2PWindow();
|
|
3256
|
-
stopRunnerManager();
|
|
3257
|
-
void stopEventServer().catch((err) => logger.warn("event server shutdown failed", { error: String(err) }));
|
|
3258
|
-
logger.info("app quit");
|
|
3259
|
-
});
|