@co0ontty/wand 1.10.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/dist/server.js CHANGED
@@ -1,6 +1,7 @@
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
7
  import { exec, spawn } from "node:child_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,
@@ -480,8 +691,55 @@ export async function startServer(config, configPath) {
480
691
  hasCert: existsSync(certPaths.keyPath) && existsSync(certPaths.certPath),
481
692
  updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
482
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
+ },
707
+ });
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,
483
730
  });
484
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
+ });
485
743
  app.post("/api/settings/config", async (req, res) => {
486
744
  const body = req.body;
487
745
  const allowedFields = ["host", "port", "https", "defaultMode", "defaultCwd", "shell", "language"];
@@ -521,14 +779,21 @@ export async function startServer(config, configPath) {
521
779
  changed = true;
522
780
  }
523
781
  }
782
+ // Handle cardDefaults separately (nested object, no restart needed)
783
+ if (body.cardDefaults !== undefined) {
784
+ config.cardDefaults = normalizeCardDefaults(body.cardDefaults);
785
+ changed = true;
786
+ }
524
787
  if (!changed) {
525
788
  res.status(400).json({ error: "没有可更新的配置字段。" });
526
789
  return;
527
790
  }
791
+ // cardDefaults-only changes don't need restart
792
+ const restartRequired = allowedFields.some((f) => f in body && body[f] !== undefined);
528
793
  try {
529
794
  await saveConfig(configPath, config);
530
795
  const { password: _pw, ...safeConfig } = config;
531
- res.json({ ok: true, config: safeConfig, restartRequired: true });
796
+ res.json({ ok: true, config: safeConfig, restartRequired });
532
797
  }
533
798
  catch (error) {
534
799
  res.status(500).json({ error: getErrorMessage(error, "保存配置失败。") });
@@ -878,7 +1143,7 @@ export async function startServer(config, configPath) {
878
1143
  })()
879
1144
  : createHttpServer(app);
880
1145
  const wss = new WebSocketServer({ server, path: "/ws" });
881
- const wsManager = new WsBroadcastManager(wss);
1146
+ const wsManager = new WsBroadcastManager(wss, () => config.cardDefaults ?? {});
882
1147
  wsManager.setup((id) => structuredSessions.get(id) ?? processes.get(id));
883
1148
  // Wire process events to WebSocket broadcast
884
1149
  processes.on("process", (event) => {
@@ -16,6 +16,8 @@ export declare class StructuredSessionManager {
16
16
  constructor(storage: WandStorage, config: WandConfig);
17
17
  setEventEmitter(emitEvent: (event: ProcessEvent) => void): void;
18
18
  list(): SessionSnapshot[];
19
+ /** Return lightweight snapshots for the session list (no output/messages). */
20
+ listSlim(): SessionSnapshot[];
19
21
  get(id: string): SessionSnapshot | null;
20
22
  createSession(options: CreateStructuredSessionOptions): SessionSnapshot;
21
23
  sendMessage(id: string, input: string): Promise<SessionSnapshot>;
@@ -80,6 +80,16 @@ export class StructuredSessionManager {
80
80
  .map(withSummary)
81
81
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
82
82
  }
83
+ /** Return lightweight snapshots for the session list (no output/messages). */
84
+ listSlim() {
85
+ return Array.from(this.sessions.values())
86
+ .map((s) => {
87
+ const enriched = withSummary(s);
88
+ const { output: _o, messages: _m, ...slim } = enriched;
89
+ return { ...slim, output: "" };
90
+ })
91
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
92
+ }
83
93
  get(id) {
84
94
  const s = this.sessions.get(id);
85
95
  return s ? withSummary(s) : null;
package/dist/types.d.ts CHANGED
@@ -47,6 +47,23 @@ export interface StructuredChatPersonaConfig {
47
47
  user?: StructuredChatPersonaRoleConfig;
48
48
  assistant?: StructuredChatPersonaRoleConfig;
49
49
  }
50
+ export interface CardExpandDefaults {
51
+ /** Edit/Write/MultiEdit diff cards (default: false) */
52
+ editCards?: boolean;
53
+ /** Read/Glob/Grep/WebFetch/WebSearch inline tools (default: false) */
54
+ inlineTools?: boolean;
55
+ /** Bash terminal output (default: false) */
56
+ terminal?: boolean;
57
+ /** Thinking blocks (default: false) */
58
+ thinking?: boolean;
59
+ /** Tool groups (default: false) */
60
+ toolGroup?: boolean;
61
+ }
62
+ export interface AndroidApkConfig {
63
+ enabled?: boolean;
64
+ apkDir?: string;
65
+ currentApkFile?: string;
66
+ }
50
67
  export interface WandConfig {
51
68
  host: string;
52
69
  port: number;
@@ -64,6 +81,11 @@ export interface WandConfig {
64
81
  shortcutLogMaxBytes?: number;
65
82
  /** Preferred response language for Claude (e.g. "中文", "English"). Empty string means no override. */
66
83
  language?: string;
84
+ /** Per-instance secret for app connection code encryption. Auto-generated on first run. */
85
+ appSecret?: string;
86
+ android?: AndroidApkConfig;
87
+ /** Default expand/collapse state for card types in structured chat view */
88
+ cardDefaults?: CardExpandDefaults;
67
89
  }
68
90
  interface WorktreeInfo {
69
91
  branch: string;
@@ -167,6 +189,10 @@ export interface ToolResultBlock {
167
189
  [key: string]: unknown;
168
190
  }>;
169
191
  is_error?: boolean;
192
+ /** When true, content has been truncated for transport. Client should fetch full content via API. */
193
+ _truncated?: boolean;
194
+ /** Original content size in bytes, provided when truncated. */
195
+ _originalSize?: number;
170
196
  }
171
197
  export type ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock;
172
198
  export interface ConversationTurn {
@@ -247,6 +273,8 @@ export interface SessionSnapshot {
247
273
  };
248
274
  /** 会话摘要:从首条用户消息或当前任务提取 */
249
275
  summary?: string;
276
+ /** 当前正在执行的任务标题(用于会话列表展示) */
277
+ currentTaskTitle?: string;
250
278
  }
251
279
  export type SessionLifecycleState = "initializing" | "running" | "idle" | "thinking" | "waiting-input" | "archived";
252
280
  export interface SessionLifecycle {