-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
512 lines (476 loc) · 21 KB
/
Copy pathserver.js
File metadata and controls
512 lines (476 loc) · 21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
/**
* AgentPets 软件主体 —— 大脑 + HTTP 服务
* ----------------------------------------------------------------------------
* 独立 Node 进程(无 Electron 依赖):`node server.js` 即可无头运行;
* Electron(main.js)只是「启动本文件 + 开窗口指向同一端口」的薄壳。
*
* 职责(从原 Ulanzi 插件主服务剥离):
* - 数据源采集(discover 10s / track 1s 双层循环)+ 全局宠物池(Coordinator)
* - 配置持久化:~/.agentpets/config.json(先读后写;首次启动自动迁移旧
* UlanziDeck global_settings.json 中的插件配置)
* - HTTP 服务(仅 127.0.0.1):静态页(web/)+ 共享渲染模块(/shared/* 映射
* 插件 actions 目录)+ JSON API(/api/state、/api/action、/api/pickFolder)
*
* 客户端:Ulanzi 瘦插件(轮询 /api/state 渲染按键)与本软件的 Web 页面。
* 数据版本号 version:任何池/源变化时自增,客户端 ?since=<v> 增量轮询。
*/
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import { dirname, join, extname, normalize, sep } from 'path';
import { createServer } from 'http';
import { readFile, readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, unlinkSync } from 'fs';
import { execFile } from 'child_process';
import { homedir } from 'os';
const require = createRequire(import.meta.url);
const APP_DIR = dirname(fileURLToPath(import.meta.url)); // <agentpets>/
const WEB_DIR = join(APP_DIR, 'web');
const CORE_DIR = join(APP_DIR, 'core');
// 共享渲染模块 + 资源目录解析(coordinator 依赖 stages/petArt/…;页面经 /shared/、/assets/ 加载)。
// 优先级:环境变量 > 同级插件目录(dev,可实时编辑)> 本地内聚副本(打包后的自包含 .app)。
const ENV_PLUGIN = process.env.AGENTPETS_PLUGIN_DIR ? process.env.AGENTPETS_PLUGIN_DIR : null;
const SIBLING_ACTIONS = join(APP_DIR, '..', 'com.ulanzi.tokenpet.ulanziPlugin', 'plugin', 'actions');
const SIBLING_ASSETS = join(APP_DIR, '..', 'com.ulanzi.tokenpet.ulanziPlugin', 'assets');
function firstDir(cands) { for (const c of cands) { try { if (c && existsSync(c)) return c; } catch (_) {} } return cands[cands.length - 1]; }
const SHARED_DIR = firstDir([ENV_PLUGIN ? join(ENV_PLUGIN, 'actions') : null, SIBLING_ACTIONS, join(APP_DIR, 'shared')]);
const ASSETS_DIR = firstDir([ENV_PLUGIN ? join(ENV_PLUGIN, '..', 'assets') : null, SIBLING_ASSETS, join(APP_DIR, 'assets')]);
// 宠物数据 + GIF 资产目录(数据驱动:pets/all_pets.json + pets/<species>/pet.json + *.gif)。
// 始终随包(agentpets/pets),dev 与打包后路径一致;可用环境变量覆盖。
const PETS_DIR = process.env.AGENTPETS_PETS_DIR || join(APP_DIR, 'pets');
const HOME_DIR = process.env.AGENTPETS_HOME || join(homedir(), '.agentpets');
const CONFIG_FILE = join(HOME_DIR, 'config.json');
const SERVER_FILE = join(HOME_DIR, 'server.json');
const LEGACY_CONFIG = process.env.AGENTPETS_LEGACY_CONFIG ||
join(homedir(), 'Library', 'Application Support', 'Ulanzi', 'UlanziDeck', 'config', 'global_settings.json');
const LEGACY_UUID = 'com.ulanzi.ulanzistudio.tokenpet';
const PORT_BASE = Number(process.env.AGENTPETS_PORT) || 17375;
const PORT_SCAN = 10;
// ---- 渲染 shim:接入真实 canvas,让共享渲染模块在服务端直接出图(设备端不再本地渲染)----
const { createCanvas, GlobalFonts } = require('@napi-rs/canvas');
const RENDER_SIZE = 144;
globalThis.window = globalThis;
globalThis.document = { createElement: (t) => (t === 'canvas' ? createCanvas(RENDER_SIZE, RENDER_SIZE) : {}) };
try { GlobalFonts.registerFromPath(join(ASSETS_DIR, 'fonts', 'SourceHanSansSC-Regular.otf'), 'Source Han Sans SC'); } catch (_) {}
// 按依赖顺序加载:共享渲染模块 → 核心采集/池模块(均写入 window.*)
// petData(数据加载器)在 evolution/artSets 之前;petGif(GIF 引擎)在 Pet 之前。
for (const f of ['log.js', 'stages.js', 'petData.js', 'petArt.js', 'petSpecies.js', 'artSets.js', 'evolution.js', 'backgrounds.js', 'petGif.js', 'Pet.js', 'summaryView.js']) {
require(join(SHARED_DIR, f));
}
for (const f of ['demo.js', 'fsaccess.js', 'sources.js', 'tracker.js', 'coordinator.js']) {
require(join(CORE_DIR, f));
}
// 加载宠物数据(构建形态树/美工表/物种表)+ 指定 GIF 资产根目录。
// 必须在 loadConfig()(会调 Coord.restore -> Ev.advance)之前完成。
// dev 时 shared 模块在插件目录,无法解析 agentpets/ 的 npm 包,故由此注入 canvas/gifuct。
try { window.PetGif.setDeps(require('@napi-rs/canvas'), require('gifuct-js')); } catch (_) {}
window.PetData.load(PETS_DIR);
window.PetGif.setBase(PETS_DIR);
const Log = window.PetLog;
const Coord = window.PetCoordinator;
const Sources = window.PetSources;
const Tracker = window.PetTracker;
// ---- 服务端渲染(设备按键图像)----
const PetView = window.PetView;
const SummaryView = window.PetSummaryView;
const Bg = window.PetBackgrounds;
const petView = new PetView(null); // 复用实例:渲染宠物/空槽/重复位
const summaryView = new SummaryView(null); // 复用实例:渲染总览仪表盘
const BG_THEMES = (Bg && Bg.themes ? Bg.themes.filter((t) => t !== 'none') : []);
// 背景随宠物随机生成:按宠物身份(key/sid)稳定哈希选主题(同一宠物恒定、不同宠物各异、不可指定)。
// 空槽位不画背景(没有宠物就没有背景),由调用方传 'none'。
function bgForSeed(seed) {
if (!BG_THEMES.length) return 'none';
const s = String(seed || '');
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
return BG_THEMES[h % BG_THEMES.length];
}
process.on('uncaughtException', (e) => { try { Log.error('uncaughtException', (e && e.stack) || String(e)); } catch (_) {} });
process.on('unhandledRejection', (e) => { try { Log.error('unhandledRejection', (e && e.stack) || String(e)); } catch (_) {} });
function nowMs() { return Date.now(); }
// ---- 数据源采集 ----
const SM = new Sources.SourceManager();
const DEFAULT_SOURCES = [
// Demo 为「显式测试开关」:默认关闭,用户可在数据源页开启用于演示/截图。
{ id: 'demo', kind: 'demo', location: 'demo', enabled: false, count: 4, pollMs: 2000 },
];
let currentSourcesConfig = DEFAULT_SOURCES.slice();
function rebuildSources() {
SM.setSources(Sources.buildSources(currentSourcesConfig));
}
// Demo 是「不可删除」的数据源(UI 上只是勾选开关)。确保配置里始终有一条 demo。
function ensureDemoSource(list) {
const arr = Array.isArray(list) ? list.slice() : [];
const hasDemo = arr.some((s) => s && ((s.kind || s.agent || '').toLowerCase() === 'demo'));
if (!hasDemo) arr.unshift({ id: 'demo', kind: 'demo', location: 'demo', enabled: false, count: 4, pollMs: 2000 });
return arr;
}
// 应用一份新的数据源配置:热重载采集源 + 把已移除源的宠物软忽略 + 落盘
function applySourcesConfig(list) {
currentSourcesConfig = ensureDemoSource(list);
rebuildSources();
const prefixes = Sources.enabledPrefixes(currentSourcesConfig);
Tracker.retain(prefixes);
Coord.ignoreOutsideSources(prefixes, nowMs());
bumpVersion();
}
// ---- 持久化(~/.agentpets/config.json;先读后写)----
let configLoaded = false;
let saveTimer = null;
const SAVE_DEBOUNCE_MS = 2000;
const SAVE_EVERY_MS = 8000;
function loadConfig() {
if (configLoaded) return;
try { mkdirSync(HOME_DIR, { recursive: true }); } catch (_) {}
let data = null;
try {
data = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
Log.info('config read', { file: CONFIG_FILE, hasData: !!data });
} catch (e) {
// 首次启动:尝试迁移旧 UlanziDeck 全局设置中的插件配置(宠物池/忽略名单/数据源)
try {
const all = JSON.parse(readFileSync(LEGACY_CONFIG, 'utf8'));
data = all && all[LEGACY_UUID];
if (data) Log.info('migrated legacy global_settings', { file: LEGACY_CONFIG });
} catch (_) { /* 无旧配置:全新安装,走内存默认 */ }
}
configLoaded = true;
applyGlobalConfig(data);
}
function applyGlobalConfig(data) {
Log.info('applyGlobalConfig', { hasData: !!data, sources: data && Array.isArray(data.sources) ? data.sources.length : null });
if (!data) { rebuildSources(); return; }
if (Array.isArray(data.sources) && JSON.stringify(data.sources) !== JSON.stringify(currentSourcesConfig)) {
applySourcesConfig(data.sources);
} else {
rebuildSources();
}
if (Coord.restore(data, nowMs())) {
Coord.ignoreOutsideSources(Sources.enabledPrefixes(currentSourcesConfig), nowMs());
Tracker.seedFromPool(Coord.pool, nowMs());
discoverTick(); // 立即发现一次,给 restore 宠物补 fp
}
}
function saveConfig() {
if (!configLoaded) return;
try {
mkdirSync(HOME_DIR, { recursive: true });
const data = Object.assign({}, Coord.serialize(), { sources: currentSourcesConfig });
const tmp = CONFIG_FILE + '.tmp';
writeFileSync(tmp, JSON.stringify(data));
renameSync(tmp, CONFIG_FILE); // 原子替换,避免半截文件
} catch (e) {
Log.error('saveConfig failed', String((e && e.message) || e));
}
}
// 结构变化后:尽快落盘(2s debounce)+ bump 数据版本,客户端增量轮询可见
function saveConfigSoon() {
bumpVersion();
if (saveTimer) return;
saveTimer = setTimeout(() => { saveTimer = null; saveConfig(); }, SAVE_DEBOUNCE_MS);
}
// ---- 数据版本(客户端 ?since= 增量轮询)----
let version = 0;
function bumpVersion() { version++; }
// ---- 采集循环 ----
const DISCOVER_MS = 10000;
const TRACK_TICK_MS = 1000;
const DISCOVER_OVERLAP_MS = 5000;
let discoverSince = 0;
let lastPeriodicSave = 0;
async function discoverTick() {
if (!configLoaded) return;
try {
const now = nowMs();
const since = discoverSince;
const fullPass = (since === 0);
const descs = await SM.discover(since);
Coord.expireIgnored(now);
Tracker.onDiscover(descs, now, (k) => Coord.isIgnored(k));
if (fullPass) {
const dis = Tracker.markUnlocatedDisconnected(now);
for (const d of dis) Coord.setDisconnected(d.key, d.value);
}
discoverSince = now - DISCOVER_MS - DISCOVER_OVERLAP_MS;
if (Coord.dirty) { Coord.dirty = false; saveConfigSoon(); }
Log.debug('discoverTick ok', { since, fullPass, found: descs ? descs.length : 0 });
} catch (e) {
Log.error('discoverTick error', (e && e.stack) || String(e));
}
}
async function trackTick() {
if (!configLoaded) return;
try {
const now = nowMs();
Coord.updateArt(now); // 推进每只宠物的美工组(错峰随机换装)
const items = Tracker.dueItems(now);
if (items.length) {
const results = await SM.fetchStates(items, now);
const r = Tracker.applyStates(results, now);
for (const d of r.disconnect) Coord.setDisconnected(d.key, d.value);
for (const k of r.drops) Coord.dropKey(k);
if (r.records.length) Coord.reconcile(r.records, now);
if (r.records.length || r.disconnect.length || r.drops.length) bumpVersion();
}
if (Coord.sweepIdle(now)) bumpVersion(); // 空闲兜底判完成(独立于采集轮询)
if (Coord.dirty) { Coord.dirty = false; saveConfigSoon(); }
if (now - lastPeriodicSave >= SAVE_EVERY_MS) { lastPeriodicSave = now; saveConfig(); }
} catch (e) {
Log.error('trackTick error', (e && e.stack) || String(e));
}
}
// ---- 总览模型(与插件 SummaryView 的 model 同构)----
function buildSourcesView() {
const summaries = Coord.summaries(nowMs());
const bySrc = {};
const byLoc = {};
for (const m of summaries) {
const loc = (m.key && m.key.indexOf(':') >= 0) ? m.key.slice(0, m.key.indexOf(':')) : '';
byLoc[loc] = (byLoc[loc] || 0) + 1;
bySrc[loc + ':' + (m.agent || '')] = (bySrc[loc + ':' + (m.agent || '')] || 0) + 1;
}
return SM.getStatus().map((st) => ({
id: st.id,
agent: st.agent,
location: st.location,
enabled: st.enabled !== false,
ok: st.ok,
error: st.error,
count: st.count || 0,
petCount: (st.agent === 'Demo')
? (byLoc[st.location || ''] || 0)
: (bySrc[(st.location || '') + ':' + (st.agent || '')] || 0),
}));
}
// 权威等级:统一走 PetEvolution.levelOf(唯一真相源;token 阶梯 + 形态封顶)。
// 由软件统一计算并下发,插件不再本地现算,避免两端不同步。
function levelOf(m) {
if (!m) return 0;
return window.PetEvolution.levelOf(m.form, m.tokens || 0, m.species);
}
// 全量快照:插件渲染按键(meta 与原 Coord.get(slot) 同构 + 权威 level)+ 页面/PI 用精简摘要
function buildState() {
const now = nowMs();
return {
version: version,
now: now,
slots: Coord.slotStates(),
pool: Coord.pool.map((m, i) => (m ? Object.assign({ slot: i }, m, { level: levelOf(m), bg: bgForSeed(m.key || m.sid || String(i)) }) : null)),
summaries: Coord.summaries(now),
sessions: Coord.sessionsList(now).map((s) => Object.assign({}, s, { bg: bgForSeed(s.key || s.sid) })),
sources: buildSourcesView(),
sourcesConfig: currentSourcesConfig,
ignored: Coord.ignoredCount(),
ignoredList: Coord.ignoredList(),
};
}
// ---- 设备按键帧渲染(服务端出图,插件仅取图推送)----
function buildOverviewModel(now) {
return { slots: Coord.slotStates(), sources: buildSourcesView() };
}
function renderFrame(key, now) {
const phase = now / 1000;
const hold = (key.hold && typeof key.hold.progress === 'number')
? { progress: key.hold.progress, visible: !!key.hold.visible } : null;
const lang = key.lang || 'zh';
if (key.type === 'settings') {
summaryView.setLang(lang);
return { img: summaryView.render(buildOverviewModel(now), now, hold), key: null, hasPet: false };
}
// pet 键
const slot = (typeof key.slot === 'number' && key.slot >= 0) ? key.slot : 0;
const raw = Coord.get(slot); // 该槽位宠物 meta 或 null(空槽)
const dup = !!key.dup;
petView.setLang(lang);
petView.slot = slot; // 供空槽/重复位显示 #slot+1
// 背景随宠物:有宠物且非重复位时按宠物身份取主题;空槽/重复位不画背景
petView.bg = (raw && !dup) ? bgForSeed(raw.key || raw.sid || String(slot)) : 'none';
const meta = raw ? Object.assign({}, raw, { level: levelOf(raw) }) : null;
const img = petView.render(meta, now, phase, dup, hold);
return { img: img, key: (meta && meta.key) || null, hasPet: !!meta };
}
function handleFrames(p) {
const now = nowMs();
const keys = (p && Array.isArray(p.keys)) ? p.keys : [];
const frames = {};
for (const k of keys) {
if (!k || !k.context) continue;
try { frames[k.context] = renderFrame(k, now); }
catch (e) { Log.error('renderFrame error', String((e && e.message) || e)); }
}
return { version: version, now: now, frames: frames };
}
// ---- 动作(与原插件 onSendToPlugin 消息类型一致)----
function handleAction(p) {
if (!p || !p.type) return { ok: false, error: 'bad request' };
const now = nowMs();
if (p.type === 'deletePet' && p.key) {
Coord.ignore(p.key, now);
Tracker.drop(p.key);
saveConfigSoon();
return { ok: true };
}
if (p.type === 'restorePet' && p.key) {
if (Coord.unignore(p.key)) saveConfigSoon();
return { ok: true };
}
if (p.type === 'spawnPet' && p.key) {
const ok = Coord.spawnFromSession(p.key, now);
if (ok) saveConfigSoon();
return { ok: true, spawned: ok };
}
if (p.type === 'clearIgnored') {
Coord.clearIgnored();
saveConfigSoon();
return { ok: true };
}
if (p.type === 'reorderPet' && Array.isArray(p.order)) {
const changed = Coord.reorderSlots(p.order);
if (changed) saveConfigSoon();
return { ok: true, changed: changed };
}
if (p.type === 'setSources' && Array.isArray(p.sources)) {
applySourcesConfig(p.sources);
saveConfigSoon();
return { ok: true };
}
if (p.type === 'compact') {
const changed = Coord.compact();
if (changed) saveConfigSoon();
return { ok: true, changed: changed };
}
return { ok: false, error: 'unknown action' };
}
// ---- HTTP 服务 ----
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
'.otf': 'font/otf', '.ttf': 'font/ttf', '.woff': 'font/woff', '.woff2': 'font/woff2',
};
function sendJson(res, code, obj) {
const buf = Buffer.from(JSON.stringify(obj));
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache' });
res.end(buf);
}
// 静态文件:root 限定 + 防目录穿越
function serveFile(res, root, relPath) {
const rel = normalize(relPath).replace(/^[/\\]+/, '');
const full = join(root, rel);
if (full !== root && !full.startsWith(root + sep)) {
res.writeHead(403); res.end('forbidden'); return;
}
readFile(full, (err, buf) => {
if (err) { res.writeHead(404); res.end('not found'); return; }
res.writeHead(200, { 'Content-Type': MIME[extname(full).toLowerCase()] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
res.end(buf);
});
}
function readBody(req, cb) {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => cb(Buffer.concat(chunks).toString('utf8')));
req.on('error', () => cb(''));
}
// macOS 原生目录选择器(设置页 Pick folder);取消/失败回 {path:null}
function pickFolder(res) {
execFile('osascript', ['-e', 'POSIX path of (choose folder)'], { timeout: 120000 }, (err, stdout) => {
if (err) { sendJson(res, 200, { path: null }); return; }
sendJson(res, 200, { path: String(stdout || '').trim() || null });
});
}
function apiHandler(req, res, u) {
if (u.pathname === '/api/state' && req.method === 'GET') {
const since = Number(u.searchParams.get('since'));
if (Number.isFinite(since) && since === version) {
sendJson(res, 200, { version: version, changed: false, now: nowMs() });
return true;
}
const st = buildState();
st.changed = true;
sendJson(res, 200, st);
return true;
}
if (u.pathname === '/api/action' && req.method === 'POST') {
readBody(req, (raw) => {
let p = null;
try { p = JSON.parse(raw || '{}'); } catch (_) {}
sendJson(res, 200, handleAction(p));
});
return true;
}
if (u.pathname === '/api/frames' && req.method === 'POST') {
readBody(req, (raw) => {
let p = null;
try { p = JSON.parse(raw || '{}'); } catch (_) {}
sendJson(res, 200, handleFrames(p));
});
return true;
}
if (u.pathname === '/api/pickFolder' && req.method === 'POST') {
pickFolder(res);
return true;
}
// 宠物数据模型(归一化 MODEL):供浏览器构建形态树/缩略图/进化树,无需读文件系统。
if (u.pathname === '/api/petdata' && req.method === 'GET') {
sendJson(res, 200, window.PetData.serialize());
return true;
}
return false;
}
function webHandler(req, res) {
try {
const u = new URL(req.url, 'http://127.0.0.1');
if (u.pathname.startsWith('/api/')) {
if (!apiHandler(req, res, u)) sendJson(res, 404, { ok: false, error: 'not found' });
return;
}
let p = decodeURIComponent(u.pathname);
if (p === '/' || p === '') { serveFile(res, WEB_DIR, 'index.html'); return; }
if (p.startsWith('/shared/')) { serveFile(res, SHARED_DIR, p.slice('/shared/'.length)); return; }
if (p.startsWith('/assets/')) { serveFile(res, ASSETS_DIR, p.slice('/assets/'.length)); return; }
if (p.startsWith('/pets/')) { serveFile(res, PETS_DIR, p.slice('/pets/'.length)); return; }
// 目录路径(以 / 结尾或无扩展名)自动补 index.html
if (p.endsWith('/') || !extname(p)) p = p.replace(/\/$/, '') + '/index.html';
serveFile(res, WEB_DIR, p);
} catch (e) {
res.writeHead(500); res.end('error');
}
}
function startHttp() {
const server = createServer(webHandler);
let port = PORT_BASE;
server.on('error', (e) => {
if (e && e.code === 'EADDRINUSE' && port < PORT_BASE + PORT_SCAN) {
port++;
server.listen(port, '127.0.0.1'); // 端口被占:顺延重试
return;
}
try { Log.error('http error', String((e && e.message) || e)); } catch (_) {}
});
server.on('listening', () => {
const actual = server.address().port;
try {
mkdirSync(HOME_DIR, { recursive: true });
let appBundle = null;
if (process.versions && process.versions.electron) {
const i = process.execPath.indexOf('.app/');
if (i >= 0) appBundle = process.execPath.slice(0, i + 4); // 截到 .app 结尾的应用包路径
}
writeFileSync(SERVER_FILE, JSON.stringify({ port: actual, pid: process.pid, startedAt: nowMs(), app: appBundle }));
} catch (_) {}
Log.info('AgentPets listening', 'http://127.0.0.1:' + actual + '/');
console.log('[AgentPets] listening on http://127.0.0.1:' + actual + '/');
});
server.listen(port, '127.0.0.1');
}
// ---- 启动 ----
loadConfig();
Coord.init(nowMs());
try { unlinkSync(SERVER_FILE); } catch (_) {} // 删除上次残留:main.js 只等本次 listen 后写入的新文件,避免冷启动竞态
startHttp();
setInterval(discoverTick, DISCOVER_MS);
setInterval(trackTick, TRACK_TICK_MS);
discoverTick(); // 启动即发现一次(sinceMs=0 全量)