@co0ontty/wand 1.9.0 → 1.14.2

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.
@@ -10,168 +10,6 @@ export function hasRealConversationMessages(messages) {
10
10
  && turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
11
11
  return hasUser && hasAssistant;
12
12
  }
13
- export function hasRuntimeConversationSignal(messages) {
14
- if (!messages || messages.length === 0) {
15
- return false;
16
- }
17
- const hasUser = messages.some((turn) => turn.role === "user"
18
- && turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
19
- const hasAssistant = messages.some((turn) => turn.role === "assistant");
20
- return hasUser && hasAssistant;
21
- }
22
- export function hasStoredConversationHistory(messages) {
23
- return hasRealConversationMessages(messages);
24
- }
25
- export function shouldBindClaudeSessionId(record) {
26
- return hasRuntimeConversationSignal(record.messages);
27
- }
28
- export function shouldAllowResume(record) {
29
- return Boolean(record.claudeSessionId) && hasStoredConversationHistory(record.messages);
30
- }
31
- export function shouldBackfillFromStoredHistory(record) {
32
- return hasStoredConversationHistory(record.messages);
33
- }
34
- export function shouldDisplayResumeAction(messages) {
35
- return hasStoredConversationHistory(messages);
36
- }
37
- export function shouldAutoResumeMessages(messages) {
38
- return hasStoredConversationHistory(messages);
39
- }
40
- export function shouldBackfillMessages(messages) {
41
- return hasStoredConversationHistory(messages);
42
- }
43
- export function shouldPromoteProjectSessionId(record) {
44
- return shouldBindClaudeSessionId(record);
45
- }
46
- export function shouldPromoteStoredSessionId(record) {
47
- return shouldBackfillMessages(record.messages);
48
- }
49
- export function shouldPromoteUiSessionId(messages) {
50
- return shouldDisplayResumeAction(messages);
51
- }
52
- export function shouldPromoteResumeSessionId(messages) {
53
- return shouldAutoResumeMessages(messages);
54
- }
55
- export function hasBindableConversation(messages) {
56
- return shouldBindFromRuntimeMessages({ messages: messages ?? [] });
57
- }
58
- export function hasBackfillableConversation(messages) {
59
- return shouldBackfillMessages(messages);
60
- }
61
- export function hasUiConversation(messages) {
62
- return shouldPromoteUiSessionId(messages);
63
- }
64
- export function hasResumeConversation(messages) {
65
- return shouldPromoteResumeSessionId(messages);
66
- }
67
- export function isRuntimeConversationReady(messages) {
68
- return hasBindableConversation(messages);
69
- }
70
- export function isStoredConversationReady(messages) {
71
- return hasBackfillableConversation(messages);
72
- }
73
- export function isResumeConversationReady(messages) {
74
- return hasResumeConversation(messages);
75
- }
76
- export function shouldBindFromRuntimeMessages(record) {
77
- return isRuntimeConversationReady(record.messages);
78
- }
79
- export function shouldAllowUiResume(messages) {
80
- return hasUiConversation(messages);
81
- }
82
- export function shouldPromoteResumeAction(record) {
83
- return shouldAllowResume(record);
84
- }
85
- export function shouldBackfillClaudeSessionIdFromDisk(record) {
86
- return isStoredConversationReady(record.messages);
87
- }
88
- export function shouldUseProjectCandidate(record) {
89
- return shouldBindFromRuntimeMessages(record);
90
- }
91
- export function shouldResumeProjectCandidate(record) {
92
- return shouldPromoteResumeAction(record);
93
- }
94
- export function shouldBackfillProjectCandidate(record) {
95
- return shouldBackfillClaudeSessionIdFromDisk(record);
96
- }
97
- export function hasMinimumRuntimeConversation(messages) {
98
- return shouldBindFromRuntimeMessages({ messages: messages ?? [] });
99
- }
100
- export function hasMinimumStoredConversation(messages) {
101
- return shouldAllowUiResume(messages);
102
- }
103
- export function hasMinimumResumeConversation(messages) {
104
- return isResumeConversationReady(messages);
105
- }
106
- export function hasMinimumBackfillConversation(messages) {
107
- return isStoredConversationReady(messages);
108
- }
109
- export function hasProjectConversationSignal(messages) {
110
- return hasMinimumRuntimeConversation(messages);
111
- }
112
- export function hasStoredProjectConversationSignal(messages) {
113
- return hasMinimumBackfillConversation(messages);
114
- }
115
- export function hasUiProjectConversationSignal(messages) {
116
- return hasMinimumStoredConversation(messages);
117
- }
118
- export function hasResumeProjectConversationSignal(messages) {
119
- return hasMinimumResumeConversation(messages);
120
- }
121
- export function canBindFromProjectConversation(messages) {
122
- return hasProjectConversationSignal(messages);
123
- }
124
- export function canBackfillFromProjectConversation(messages) {
125
- return hasStoredProjectConversationSignal(messages);
126
- }
127
- export function canShowUiProjectConversation(messages) {
128
- return hasUiProjectConversationSignal(messages);
129
- }
130
- export function canResumeProjectConversation(messages) {
131
- return hasResumeProjectConversationSignal(messages);
132
- }
133
- export function shouldUseRuntimeProjectConversation(messages) {
134
- return canBindFromProjectConversation(messages);
135
- }
136
- export function shouldUseStoredProjectConversation(messages) {
137
- return canBackfillFromProjectConversation(messages);
138
- }
139
- export function shouldUseUiProjectConversation(messages) {
140
- return canShowUiProjectConversation(messages);
141
- }
142
- export function shouldUseResumeProjectConversation(messages) {
143
- return canResumeProjectConversation(messages);
144
- }
145
- export function hasProjectConversationForBinding(messages) {
146
- return shouldUseRuntimeProjectConversation(messages);
147
- }
148
- export function hasProjectConversationForBackfill(messages) {
149
- return shouldUseStoredProjectConversation(messages);
150
- }
151
- export function hasProjectConversationForUi(messages) {
152
- return shouldUseUiProjectConversation(messages);
153
- }
154
- export function hasProjectConversationForResume(messages) {
155
- return shouldUseResumeProjectConversation(messages);
156
- }
157
- export function isBindableProjectConversation(messages) {
158
- return hasProjectConversationForBinding(messages);
159
- }
160
- export function isBackfillableProjectConversation(messages) {
161
- return hasProjectConversationForBackfill(messages);
162
- }
163
- export function isUiProjectConversation(messages) {
164
- return hasProjectConversationForUi(messages);
165
- }
166
- export function isResumeProjectConversation(messages) {
167
- return hasProjectConversationForResume(messages);
168
- }
169
- export function hasLiveProjectConversation(messages) {
170
- return isBindableProjectConversation(messages);
171
- }
172
- export function hasStoredProjectConversation(messages) {
173
- return isBackfillableProjectConversation(messages);
174
- }
175
13
  export function getResumeCommandSessionId(command) {
176
14
  const match = RESUME_COMMAND_ID_PATTERN.exec(command);
177
15
  return match?.[1] ?? null;
@@ -71,6 +71,11 @@ function listAllSessions(processes, structured) {
71
71
  return [...structured.list(), ...processes.list()]
72
72
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
73
73
  }
74
+ /** Lightweight session list — omits output and messages to reduce payload. */
75
+ function listAllSessionsSlim(processes, structured) {
76
+ return [...structured.listSlim(), ...processes.listSlim()]
77
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
78
+ }
74
79
  function requireWorktreeSession(snapshot) {
75
80
  if (!snapshot) {
76
81
  throw new Error("未找到该会话。");
@@ -133,7 +138,7 @@ function isMergeActionAllowed(snapshot) {
133
138
  }
134
139
  export function registerSessionRoutes(app, processes, structured, storage, defaultMode) {
135
140
  app.get("/api/sessions", (_req, res) => {
136
- const all = listAllSessions(processes, structured);
141
+ const all = listAllSessionsSlim(processes, structured);
137
142
  console.log("[WAND] GET /api/sessions count:", all.length, "sessions:", all.map(s => ({ id: s.id.substring(0, 8), kind: s.sessionKind, runner: s.runner, status: s.status })));
138
143
  res.json(all);
139
144
  });
@@ -178,6 +183,29 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
178
183
  res.status(400).json({ error: getErrorMessage(error, "无法发送结构化消息。") });
179
184
  }
180
185
  });
186
+ // ── Tool content lazy-load endpoint ──
187
+ app.get("/api/sessions/:id/tool-content/:toolUseId", (req, res) => {
188
+ const snapshot = getSessionById(processes, structured, req.params.id);
189
+ if (!snapshot) {
190
+ res.status(404).json({ error: "未找到该会话。" });
191
+ return;
192
+ }
193
+ const toolUseId = req.params.toolUseId;
194
+ const messages = snapshot.messages ?? [];
195
+ for (const turn of messages) {
196
+ for (const block of turn.content) {
197
+ if (block.type === "tool_result" && block.tool_use_id === toolUseId) {
198
+ res.json({
199
+ tool_use_id: block.tool_use_id,
200
+ content: block.content,
201
+ is_error: block.is_error || false,
202
+ });
203
+ return;
204
+ }
205
+ }
206
+ }
207
+ res.status(404).json({ error: "未找到该工具结果。" });
208
+ });
181
209
  app.post("/api/sessions/:id/worktree/merge/check", (req, res) => {
182
210
  try {
183
211
  const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));
package/dist/server.js CHANGED
@@ -1,9 +1,10 @@
1
+ import crypto from "node:crypto";
1
2
  import express from "express";
2
- import { readdir, readFile, stat } from "node:fs/promises";
3
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { createReadStream, existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { mkdir, readdir, readFile, stat } from "node:fs/promises";
4
5
  import { createServer as createHttpServer } from "node:http";
5
6
  import { createServer as createHttpsServer } from "node:https";
6
- import { exec } from "node:child_process";
7
+ import { exec, spawn } from "node:child_process";
7
8
  import { promisify } from "node:util";
8
9
  import path from "node:path";
9
10
  import process from "node:process";
@@ -11,7 +12,7 @@ import { WebSocketServer } from "ws";
11
12
  import { ensureAvatarSeed, getAvatarSvg } from "./avatar.js";
12
13
  import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
13
14
  import { ensureCertificates } from "./cert.js";
14
- import { isExecutionMode, resolveConfigDir, saveConfig } from "./config.js";
15
+ import { isExecutionMode, normalizeCardDefaults, resolveConfigDir, saveConfig } from "./config.js";
15
16
  import { ProcessManager } from "./process-manager.js";
16
17
  import { StructuredSessionManager } from "./structured-session-manager.js";
17
18
  import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
@@ -65,6 +66,65 @@ function compareSemver(a, b) {
65
66
  }
66
67
  return 0;
67
68
  }
69
+ let cachedGitHubApk = null;
70
+ let gitHubApkCacheTs = 0;
71
+ const GITHUB_APK_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
72
+ async function fetchGitHubLatestApk(forceRefresh = false) {
73
+ const now = Date.now();
74
+ if (!forceRefresh && cachedGitHubApk && (now - gitHubApkCacheTs < GITHUB_APK_CACHE_TTL)) {
75
+ return cachedGitHubApk;
76
+ }
77
+ try {
78
+ const apiUrl = PKG_REPO_URL.replace("github.com", "api.github.com/repos") + "/releases/latest";
79
+ const resp = await fetch(apiUrl, {
80
+ headers: { "Accept": "application/vnd.github.v3+json", "User-Agent": "wand-server" },
81
+ signal: AbortSignal.timeout(10000),
82
+ });
83
+ if (!resp.ok)
84
+ return cachedGitHubApk ?? null;
85
+ const release = await resp.json();
86
+ const apkAsset = release.assets.find(a => a.name.toLowerCase().endsWith(".apk"));
87
+ if (!apkAsset)
88
+ return cachedGitHubApk ?? null;
89
+ const version = extractAndroidApkVersion(release.tag_name) ?? release.tag_name.replace(/^v/, "");
90
+ cachedGitHubApk = {
91
+ version,
92
+ downloadUrl: apkAsset.browser_download_url,
93
+ fileName: apkAsset.name,
94
+ size: apkAsset.size,
95
+ };
96
+ gitHubApkCacheTs = now;
97
+ return cachedGitHubApk;
98
+ }
99
+ catch {
100
+ return cachedGitHubApk ?? null;
101
+ }
102
+ }
103
+ async function resolveLatestApkVersion(configDir, config) {
104
+ // Priority 1: local APK file
105
+ const localApk = await resolveAndroidApkAsset(configDir, config);
106
+ if (localApk && localApk.version) {
107
+ return {
108
+ version: localApk.version,
109
+ downloadUrl: localApk.downloadUrl,
110
+ fileName: localApk.fileName,
111
+ size: localApk.size,
112
+ source: "local",
113
+ };
114
+ }
115
+ // Priority 2: GitHub Release
116
+ const ghApk = await fetchGitHubLatestApk();
117
+ if (ghApk) {
118
+ return {
119
+ version: ghApk.version,
120
+ downloadUrl: ghApk.downloadUrl,
121
+ fileName: ghApk.fileName,
122
+ size: ghApk.size,
123
+ source: "github",
124
+ };
125
+ }
126
+ return null;
127
+ }
68
128
  function isExternalAvatarSource(value) {
69
129
  return /^(https?:|data:)/i.test(value);
70
130
  }
@@ -238,9 +298,99 @@ function readSessionCookie(req) {
238
298
  const match = cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith("wand_session="));
239
299
  return match?.slice("wand_session=".length);
240
300
  }
301
+ // ── App connection token helpers ──
302
+ function generateAppToken(password, secret) {
303
+ return crypto.createHmac("sha256", secret).update(password).digest("hex");
304
+ }
305
+ function verifyAppToken(token, password, secret) {
306
+ const expected = generateAppToken(password, secret);
307
+ return crypto.timingSafeEqual(Buffer.from(token, "hex"), Buffer.from(expected, "hex"));
308
+ }
309
+ function encodeConnectCode(url, token) {
310
+ return Buffer.from(`${url}#${token}`).toString("base64");
311
+ }
312
+ function decodeConnectCode(code) {
313
+ try {
314
+ const decoded = Buffer.from(code, "base64").toString("utf8");
315
+ const hashIdx = decoded.lastIndexOf("#");
316
+ if (hashIdx < 1)
317
+ return null;
318
+ const url = decoded.substring(0, hashIdx);
319
+ const token = decoded.substring(hashIdx + 1);
320
+ if (!url.startsWith("http") || token.length < 16)
321
+ return null;
322
+ return { url, token };
323
+ }
324
+ catch {
325
+ return null;
326
+ }
327
+ }
241
328
  function normalizeMode(input, fallback) {
242
329
  return isExecutionMode(input) ? input : fallback;
243
330
  }
331
+ function resolveAndroidApkDir(configDir, config) {
332
+ const configuredDir = config.android?.apkDir?.trim();
333
+ if (!configuredDir) {
334
+ return path.join(configDir, "android");
335
+ }
336
+ return path.isAbsolute(configuredDir) ? configuredDir : path.resolve(configDir, configuredDir);
337
+ }
338
+ function extractAndroidApkVersion(fileName) {
339
+ const nameWithoutExt = fileName.replace(/\.apk$/i, "");
340
+ const match = nameWithoutExt.match(/(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)/);
341
+ return match ? match[1] : null;
342
+ }
343
+ async function resolveAndroidApkAsset(configDir, config) {
344
+ if (config.android?.enabled !== true)
345
+ return null;
346
+ const apkDir = resolveAndroidApkDir(configDir, config);
347
+ await mkdir(apkDir, { recursive: true });
348
+ const configuredFile = config.android?.currentApkFile?.trim();
349
+ if (configuredFile) {
350
+ const filePath = path.join(apkDir, path.basename(configuredFile));
351
+ try {
352
+ const fileStat = await stat(filePath);
353
+ if (!fileStat.isFile())
354
+ return null;
355
+ return {
356
+ fileName: path.basename(filePath),
357
+ filePath,
358
+ size: fileStat.size,
359
+ updatedAt: fileStat.mtime.toISOString(),
360
+ version: extractAndroidApkVersion(path.basename(filePath)),
361
+ downloadUrl: "/android/download",
362
+ source: "local",
363
+ };
364
+ }
365
+ catch {
366
+ return null;
367
+ }
368
+ }
369
+ const entries = await readdir(apkDir, { withFileTypes: true });
370
+ const apkFiles = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".apk"));
371
+ if (apkFiles.length === 0)
372
+ return null;
373
+ const candidates = await Promise.all(apkFiles.map(async (entry) => {
374
+ const filePath = path.join(apkDir, entry.name);
375
+ const fileStat = await stat(filePath);
376
+ return {
377
+ entry,
378
+ filePath,
379
+ fileStat,
380
+ };
381
+ }));
382
+ candidates.sort((a, b) => b.fileStat.mtimeMs - a.fileStat.mtimeMs);
383
+ const selected = candidates[0];
384
+ return {
385
+ fileName: selected.entry.name,
386
+ filePath: selected.filePath,
387
+ size: selected.fileStat.size,
388
+ updatedAt: selected.fileStat.mtime.toISOString(),
389
+ version: extractAndroidApkVersion(selected.entry.name),
390
+ downloadUrl: "/android/download",
391
+ source: "local",
392
+ };
393
+ }
244
394
  async function listPathSuggestions(input, fallbackCwd) {
245
395
  const normalizedInput = input.trim();
246
396
  const baseInput = normalizedInput || fallbackCwd;
@@ -415,13 +565,26 @@ export async function startServer(config, configPath) {
415
565
  res.status(429).json({ error: "登录尝试次数过多,请在 15 分钟后再试。" });
416
566
  return;
417
567
  }
418
- const { password } = req.body;
568
+ const { password, appToken } = req.body;
419
569
  const dbPassword = storage.getPassword();
420
570
  const effectivePassword = dbPassword ?? config.password;
421
- if (password !== effectivePassword) {
422
- recordFailedLogin(clientIp);
423
- res.status(401).json({ error: "密码错误,请重试。" });
424
- return;
571
+ // App token login — derived from password, so password change invalidates it
572
+ let authenticated = false;
573
+ if (appToken) {
574
+ try {
575
+ authenticated = verifyAppToken(appToken, effectivePassword, config.appSecret ?? "");
576
+ }
577
+ catch {
578
+ authenticated = false;
579
+ }
580
+ }
581
+ if (!authenticated) {
582
+ if (password !== effectivePassword) {
583
+ recordFailedLogin(clientIp);
584
+ res.status(401).json({ error: "密码错误,请重试。" });
585
+ return;
586
+ }
587
+ authenticated = true;
425
588
  }
426
589
  resetRateLimit(clientIp);
427
590
  const token = createSession();
@@ -447,6 +610,44 @@ export async function startServer(config, configPath) {
447
610
  storage.setPassword(password);
448
611
  res.json({ ok: true });
449
612
  });
613
+ // ── Android APK update & download (no auth required) ──
614
+ app.get("/api/android-apk-update", async (req, res) => {
615
+ const currentVersion = req.query.currentVersion?.trim();
616
+ if (!currentVersion) {
617
+ res.status(400).json({ error: "Missing currentVersion query parameter." });
618
+ return;
619
+ }
620
+ const latest = await resolveLatestApkVersion(configDir, config);
621
+ if (!latest) {
622
+ res.json({ updateAvailable: false, currentVersion, latestVersion: null, downloadUrl: null, source: null });
623
+ return;
624
+ }
625
+ const updateAvailable = compareSemver(latest.version, currentVersion) > 0;
626
+ res.json({
627
+ updateAvailable,
628
+ currentVersion,
629
+ latestVersion: latest.version,
630
+ downloadUrl: updateAvailable ? latest.downloadUrl : null,
631
+ fileName: updateAvailable ? latest.fileName : null,
632
+ size: updateAvailable ? latest.size : null,
633
+ source: latest.source,
634
+ });
635
+ });
636
+ app.get("/android/download", async (_req, res) => {
637
+ const androidApk = await resolveAndroidApkAsset(configDir, config);
638
+ if (config.android?.enabled !== true) {
639
+ res.status(404).json({ error: "Android APK 下载未启用。" });
640
+ return;
641
+ }
642
+ if (!androidApk) {
643
+ res.status(404).json({ error: "当前没有可下载的 APK 文件。" });
644
+ return;
645
+ }
646
+ res.setHeader("Content-Type", "application/vnd.android.package-archive");
647
+ res.setHeader("Content-Length", String(androidApk.size));
648
+ res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(androidApk.fileName)}"`);
649
+ createReadStream(androidApk.filePath).pipe(res);
650
+ });
450
651
  app.use("/api", requireAuth);
451
652
  // ── Config & Session info ──
452
653
  app.get("/api/config", async (_req, res) => {
@@ -459,18 +660,28 @@ export async function startServer(config, configPath) {
459
660
  commandPresets: config.commandPresets,
460
661
  structuredRunners: [{ label: "Claude Structured", runner: "claude-cli-print" }],
461
662
  structuredChatPersona,
663
+ cardDefaults: config.cardDefaults,
462
664
  updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
463
665
  latestVersion: cachedUpdateInfo?.latest ?? null,
464
666
  currentVersion: PKG_VERSION,
465
667
  });
466
668
  });
467
669
  // ── Settings endpoints ──
468
- app.get("/api/settings", (_req, res) => {
670
+ app.get("/api/settings", async (_req, res) => {
469
671
  const certPaths = {
470
672
  keyPath: path.join(configDir, "server.key"),
471
673
  certPath: path.join(configDir, "server.crt"),
472
674
  };
473
675
  const { password: _pw, ...safeConfig } = config;
676
+ const localApk = await resolveAndroidApkAsset(configDir, config);
677
+ const ghApk = await fetchGitHubLatestApk();
678
+ const apkDir = resolveAndroidApkDir(configDir, config);
679
+ // Backward-compatible: pick best available for hasApk/version/downloadUrl
680
+ const resolvedApk = localApk
681
+ ? { hasApk: true, fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl, source: "local" }
682
+ : ghApk
683
+ ? { hasApk: true, fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, updatedAt: null, downloadUrl: ghApk.downloadUrl, source: "github" }
684
+ : null;
474
685
  res.json({
475
686
  version: PKG_VERSION,
476
687
  packageName: PKG_NAME,
@@ -478,8 +689,57 @@ export async function startServer(config, configPath) {
478
689
  repoUrl: PKG_REPO_URL,
479
690
  config: safeConfig,
480
691
  hasCert: existsSync(certPaths.keyPath) && existsSync(certPaths.certPath),
692
+ updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
693
+ latestVersion: cachedUpdateInfo?.latest ?? null,
694
+ androidApk: {
695
+ enabled: config.android?.enabled === true,
696
+ apkDir,
697
+ hasApk: resolvedApk?.hasApk ?? false,
698
+ fileName: resolvedApk?.fileName ?? null,
699
+ version: resolvedApk?.version ?? null,
700
+ size: resolvedApk?.size ?? null,
701
+ updatedAt: resolvedApk?.updatedAt ?? null,
702
+ downloadUrl: resolvedApk?.downloadUrl ?? null,
703
+ source: resolvedApk?.source ?? null,
704
+ local: localApk ? { fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl } : null,
705
+ github: ghApk ? { fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, downloadUrl: ghApk.downloadUrl } : null,
706
+ },
481
707
  });
482
708
  });
709
+ app.get("/api/android-apk", async (_req, res) => {
710
+ const localApk = await resolveAndroidApkAsset(configDir, config);
711
+ const ghApk = await fetchGitHubLatestApk();
712
+ const apkDir = resolveAndroidApkDir(configDir, config);
713
+ const resolvedApk = localApk
714
+ ? { hasApk: true, fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl, source: "local" }
715
+ : ghApk
716
+ ? { hasApk: true, fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, updatedAt: null, downloadUrl: ghApk.downloadUrl, source: "github" }
717
+ : null;
718
+ res.json({
719
+ enabled: config.android?.enabled === true,
720
+ apkDir,
721
+ hasApk: resolvedApk?.hasApk ?? false,
722
+ fileName: resolvedApk?.fileName ?? null,
723
+ version: resolvedApk?.version ?? null,
724
+ size: resolvedApk?.size ?? null,
725
+ updatedAt: resolvedApk?.updatedAt ?? null,
726
+ downloadUrl: resolvedApk?.downloadUrl ?? null,
727
+ source: resolvedApk?.source ?? null,
728
+ local: localApk ? { fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl } : null,
729
+ github: ghApk ? { fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, downloadUrl: ghApk.downloadUrl } : null,
730
+ });
731
+ });
732
+ app.get("/api/app-connect-code", requireAuth, (req, res) => {
733
+ const dbPassword = storage.getPassword();
734
+ const effectivePassword = dbPassword ?? config.password;
735
+ const protocol = useHttps ? "https" : "http";
736
+ const host = req.headers.host || `${config.host}:${config.port}`;
737
+ const serverUrl = `${protocol}://${host}`;
738
+ const appSecret = config.appSecret ?? "";
739
+ const token = generateAppToken(effectivePassword, appSecret);
740
+ const code = encodeConnectCode(serverUrl, token);
741
+ res.json({ code });
742
+ });
483
743
  app.post("/api/settings/config", async (req, res) => {
484
744
  const body = req.body;
485
745
  const allowedFields = ["host", "port", "https", "defaultMode", "defaultCwd", "shell", "language"];
@@ -519,48 +779,21 @@ export async function startServer(config, configPath) {
519
779
  changed = true;
520
780
  }
521
781
  }
522
- if (body.uiPreferences && typeof body.uiPreferences === "object") {
523
- const defaultPanelStateInput = body.uiPreferences.defaultPanelState;
524
- if (defaultPanelStateInput && typeof defaultPanelStateInput === "object") {
525
- const nextDefaultPanelState = {
526
- ...config.uiPreferences?.defaultPanelState,
527
- };
528
- let panelChanged = false;
529
- const panelFields = [
530
- "sessionsDrawerOpen",
531
- "filePanelOpen",
532
- "shortcutsExpanded",
533
- "claudeHistoryExpanded",
534
- "chatMessageExpanded",
535
- "structuredThinkingExpanded",
536
- "structuredToolGroupExpanded",
537
- "structuredInlineToolExpanded",
538
- "structuredTerminalExpanded",
539
- "structuredToolCardExpanded"
540
- ];
541
- for (const field of panelFields) {
542
- if (field in defaultPanelStateInput && typeof defaultPanelStateInput[field] === "boolean") {
543
- nextDefaultPanelState[field] = defaultPanelStateInput[field];
544
- panelChanged = true;
545
- }
546
- }
547
- if (panelChanged) {
548
- config.uiPreferences = {
549
- ...config.uiPreferences,
550
- defaultPanelState: nextDefaultPanelState,
551
- };
552
- changed = true;
553
- }
554
- }
782
+ // Handle cardDefaults separately (nested object, no restart needed)
783
+ if (body.cardDefaults !== undefined) {
784
+ config.cardDefaults = normalizeCardDefaults(body.cardDefaults);
785
+ changed = true;
555
786
  }
556
787
  if (!changed) {
557
788
  res.status(400).json({ error: "没有可更新的配置字段。" });
558
789
  return;
559
790
  }
791
+ // cardDefaults-only changes don't need restart
792
+ const restartRequired = allowedFields.some((f) => f in body && body[f] !== undefined);
560
793
  try {
561
794
  await saveConfig(configPath, config);
562
795
  const { password: _pw, ...safeConfig } = config;
563
- res.json({ ok: true, config: safeConfig, restartRequired: true });
796
+ res.json({ ok: true, config: safeConfig, restartRequired });
564
797
  }
565
798
  catch (error) {
566
799
  res.status(500).json({ error: getErrorMessage(error, "保存配置失败。") });
@@ -910,7 +1143,7 @@ export async function startServer(config, configPath) {
910
1143
  })()
911
1144
  : createHttpServer(app);
912
1145
  const wss = new WebSocketServer({ server, path: "/ws" });
913
- const wsManager = new WsBroadcastManager(wss);
1146
+ const wsManager = new WsBroadcastManager(wss, () => config.cardDefaults ?? {});
914
1147
  wsManager.setup((id) => structuredSessions.get(id) ?? processes.get(id));
915
1148
  // Wire process events to WebSocket broadcast
916
1149
  processes.on("process", (event) => {
@@ -919,6 +1152,30 @@ export async function startServer(config, configPath) {
919
1152
  structuredSessions.setEventEmitter((event) => {
920
1153
  wsManager.emitEvent(event);
921
1154
  });
1155
+ // ── Restart endpoint (needs server + wss in scope) ──
1156
+ app.post("/api/restart", async (_req, res) => {
1157
+ res.json({ ok: true, message: "服务正在重启..." });
1158
+ wsManager.emitEvent({
1159
+ type: "notification",
1160
+ sessionId: "__system__",
1161
+ data: { kind: "restart" },
1162
+ });
1163
+ setTimeout(() => {
1164
+ // Close all WebSocket connections first
1165
+ wss.clients.forEach((client) => client.close());
1166
+ server.close(() => {
1167
+ spawn(process.execPath, process.argv.slice(1), {
1168
+ detached: true,
1169
+ stdio: "inherit",
1170
+ cwd: process.cwd(),
1171
+ env: process.env,
1172
+ }).unref();
1173
+ process.exit(0);
1174
+ });
1175
+ // Force exit after 5s if graceful shutdown stalls
1176
+ setTimeout(() => process.exit(0), 5000);
1177
+ }, 600);
1178
+ });
922
1179
  await new Promise((resolve, reject) => {
923
1180
  server.listen(config.port, config.host, () => {
924
1181
  const listenAddr = config.host === "0.0.0.0" ? "0.0.0.0 (所有接口)" : config.host;