@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.
- package/README.md +48 -12
- package/dist/config.d.ts +2 -1
- package/dist/config.js +51 -40
- package/dist/message-truncator.d.ts +16 -0
- package/dist/message-truncator.js +76 -0
- package/dist/process-manager.d.ts +4 -0
- package/dist/process-manager.js +74 -21
- package/dist/resume-policy.d.ts +0 -77
- package/dist/resume-policy.js +0 -162
- package/dist/server-session-routes.js +29 -1
- package/dist/server.js +302 -45
- package/dist/storage.js +38 -112
- package/dist/structured-session-manager.d.ts +2 -0
- package/dist/structured-session-manager.js +10 -0
- package/dist/types.d.ts +27 -16
- package/dist/web-ui/content/scripts.js +1587 -780
- package/dist/web-ui/content/styles.css +677 -734
- package/dist/web-ui/scripts.js +3 -6
- package/dist/ws-broadcast.d.ts +3 -2
- package/dist/ws-broadcast.js +8 -2
- package/package.json +1 -1
package/dist/resume-policy.js
CHANGED
|
@@ -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 =
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
|
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;
|