@steerable/agent-shell 0.6.52 → 0.6.55

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.
Files changed (60) hide show
  1. package/dist/brand.js +1 -1
  2. package/dist/host/pack-assembly.d.ts +6 -16
  3. package/dist/host/pack-assembly.js +3 -4
  4. package/dist/host/runtime.js +2 -0
  5. package/dist/i18n.d.ts +23 -0
  6. package/dist/i18n.js +87 -0
  7. package/dist/image-attachment.d.ts +31 -4
  8. package/dist/image-attachment.js +13 -18
  9. package/dist/json-store.js +3 -6
  10. package/dist/local-backend/portable-bundle.d.ts +16 -2
  11. package/dist/local-backend/portable-bundle.js +29 -16
  12. package/dist/local-backend/portable-service.js +25 -11
  13. package/dist/local-backend/router.js +56 -17
  14. package/dist/local-backend/skills/10-goal/SKILL.md +1 -1
  15. package/dist/local-backend/skills/11-loop/SKILL.md +1 -1
  16. package/dist/local-backend/skills/12-create-skill/SKILL.md +2 -2
  17. package/dist/local-executor.d.ts +22 -0
  18. package/dist/local-executor.js +118 -1
  19. package/dist/local-script-registry.js +2 -2
  20. package/dist/mcp-executor.d.ts +42 -10
  21. package/dist/mcp-executor.js +248 -41
  22. package/dist/mcp-server-registry.d.ts +59 -11
  23. package/dist/mcp-server-registry.js +193 -28
  24. package/dist/product-config.d.ts +24 -14
  25. package/dist/project-registry.d.ts +3 -3
  26. package/dist/project-registry.js +2 -2
  27. package/dist/runtime.d.ts +8 -54
  28. package/dist/runtime.js +15 -129
  29. package/dist/scenario/pack.d.ts +1 -1
  30. package/dist/scenario/registry.d.ts +4 -5
  31. package/dist/scenario/registry.js +2 -10
  32. package/dist/server/start.js +1 -2
  33. package/dist/shell-exec.d.ts +62 -0
  34. package/dist/shell-exec.js +147 -0
  35. package/dist/shell-process.d.ts +3 -0
  36. package/dist/shell-process.js +137 -0
  37. package/dist/sidecar/boot.js +4 -0
  38. package/dist/sidecar/reverse-spawn.js +6 -15
  39. package/dist/sidecar/supervisor.d.ts +0 -2
  40. package/dist/sidecar/supervisor.js +7 -30
  41. package/dist/storage/index.js +1 -1
  42. package/dist/storage/write-lease-error.d.ts +2 -11
  43. package/dist/storage/write-lease-error.js +3 -8
  44. package/dist/tool-router.js +21 -6
  45. package/package.json +7 -14
  46. package/dist/app-update-electron.d.ts +0 -5
  47. package/dist/app-update-electron.js +0 -66
  48. package/dist/app-update.d.ts +0 -92
  49. package/dist/app-update.js +0 -213
  50. package/dist/host/ipc.d.ts +0 -11
  51. package/dist/host/ipc.js +0 -20
  52. package/dist/main.d.ts +0 -1
  53. package/dist/main.js +0 -967
  54. package/dist/preload-default.d.ts +0 -1
  55. package/dist/preload-default.js +0 -9
  56. package/dist/preload.cjs +0 -425
  57. package/dist/preload.d.ts +0 -20
  58. package/dist/preload.js +0 -442
  59. package/dist/single-instance.d.ts +0 -11
  60. package/dist/single-instance.js +0 -21
@@ -42,7 +42,6 @@ export declare class SidecarSupervisor extends EventEmitter {
42
42
  private healthTimer;
43
43
  private failedPings;
44
44
  private shuttingDown;
45
- private quitListener;
46
45
  private reverseHandlers;
47
46
  private constructor();
48
47
  /** Spawn the sidecar and wait for the ready marker. */
@@ -190,7 +189,6 @@ export declare class SidecarSupervisor extends EventEmitter {
190
189
  private wrapLinux;
191
190
  private wrapWindows;
192
191
  private attachListeners;
193
- private installAppQuitHook;
194
192
  private waitForReady;
195
193
  private handleStdoutChunk;
196
194
  private handleStderrChunk;
@@ -16,7 +16,6 @@ import { homedir } from 'node:os';
16
16
  import { dirname, join } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { promisify } from 'node:util';
19
- import { onAppWillQuit, offAppWillQuit } from '../runtime.js';
20
19
  const __filename = fileURLToPath(import.meta.url);
21
20
  const __dirname = dirname(__filename);
22
21
  import { resolveWinSpawnHelperPath } from './reverse-spawn.js';
@@ -68,7 +67,6 @@ export class SidecarSupervisor extends EventEmitter {
68
67
  healthTimer = null;
69
68
  failedPings = 0;
70
69
  shuttingDown = false;
71
- quitListener = null;
72
70
  reverseHandlers = new Map();
73
71
  constructor(options) {
74
72
  super();
@@ -351,10 +349,6 @@ export class SidecarSupervisor extends EventEmitter {
351
349
  return;
352
350
  this.shuttingDown = true;
353
351
  this.stopHealthTimer();
354
- if (this.quitListener) {
355
- offAppWillQuit(this.quitListener);
356
- this.quitListener = null;
357
- }
358
352
  const child = this.child;
359
353
  if (!child)
360
354
  return;
@@ -409,7 +403,6 @@ export class SidecarSupervisor extends EventEmitter {
409
403
  this.child = null;
410
404
  throw err;
411
405
  }
412
- this.installAppQuitHook();
413
406
  this.startHealthTimer();
414
407
  this.emit('ready', this.readyHealth);
415
408
  }
@@ -641,13 +634,6 @@ export class SidecarSupervisor extends EventEmitter {
641
634
  this.emit('error', err);
642
635
  });
643
636
  }
644
- installAppQuitHook() {
645
- const hook = () => {
646
- void this.shutdown();
647
- };
648
- this.quitListener = hook;
649
- onAppWillQuit(hook);
650
- }
651
637
  async waitForReady(timeoutMs) {
652
638
  return new Promise((resolve, reject) => {
653
639
  const timer = setTimeout(() => {
@@ -903,22 +889,13 @@ export function resolveSidecarPython(pythonExecutable) {
903
889
  // build_sidecar.py 的产物布局是 <platform>/python/<exe>(Windows:
904
890
  // python/python.exe;POSIX:python/bin/python3,见 build_sidecar.py 的
905
891
  // python_binary())。保留无 python/ 层的旧布局候选做向后兼容。
906
- const runtimeBases = [];
907
- try {
908
- const resourcesPath = process.resourcesPath;
909
- if (resourcesPath) {
910
- runtimeBases.push(join(resourcesPath, 'python-runtime'));
911
- }
912
- }
913
- catch { /* not in Electron */ }
914
- runtimeBases.push(join(__dirname, '..', '..', 'python-runtime'));
915
- const candidates = [];
916
- for (const base of runtimeBases) {
917
- candidates.push(join(base, platformTag, 'python', binaryName));
918
- candidates.push(join(base, platformTag, 'python', 'bin', binaryName));
919
- candidates.push(join(base, platformTag, binaryName));
920
- candidates.push(join(base, platformTag, 'bin', binaryName));
921
- }
892
+ const runtimeBase = join(__dirname, '..', '..', 'python-runtime');
893
+ const candidates = [
894
+ join(runtimeBase, platformTag, 'python', binaryName),
895
+ join(runtimeBase, platformTag, 'python', 'bin', binaryName),
896
+ join(runtimeBase, platformTag, binaryName),
897
+ join(runtimeBase, platformTag, 'bin', binaryName),
898
+ ];
922
899
  // Dev-layout convenience: the framework repo root .venv (uv sync). The
923
900
  // shell lives inside the framework repo (packages/agent-shell/ts), so the
924
901
  // venv is an in-repo ancestor — no sibling-checkout probing. Without this,
@@ -316,7 +316,7 @@ export class SqliteScopedStore {
316
316
  // context_summary / context_summary_upto / context_compaction_delegated
317
317
  // 列成为无人读写的孤儿列(SQLite 不易 drop,保留无害)。
318
318
  // 项目模式(增量迁移):chat 可绑定 ProjectRegistry 里的项目 id。
319
- // 项目记录本身存在 electron-store(agent-projects.json),这里只存外键;
319
+ // 项目记录本身存在 json-store(agent-projects.json),这里只存外键;
320
320
  // 删项目时由路由层把本列置 NULL(会话降级为无项目对话)。
321
321
  this.ensureColumn('chat_sessions', 'project_id', 'TEXT');
322
322
  // 任务编排(增量迁移):depends_on 存 JSON string[]——本任务等待的
@@ -1,11 +1,2 @@
1
- interface ElectronExit {
2
- app: {
3
- exit(code: number): void;
4
- };
5
- dialog: {
6
- showErrorBox(title: string, message: string): void;
7
- };
8
- }
9
- /** Maps write-lease contention to the desktop's user-facing startup failure. */
10
- export declare function acquireWriteLeaseOrExit<T>(acquire: () => T, loadElectron?: () => Promise<ElectronExit>, exitProcess?: (code: number) => void): T;
11
- export {};
1
+ /** Maps write-lease contention to a readable startup failure and process exit. */
2
+ export declare function acquireWriteLeaseOrExit<T>(acquire: () => T, exitProcess?: (code: number) => void): T;
@@ -1,6 +1,6 @@
1
1
  import { StoreAlreadyOwnedError } from './write-lease.js';
2
- /** Maps write-lease contention to the desktop's user-facing startup failure. */
3
- export function acquireWriteLeaseOrExit(acquire, loadElectron = () => import('electron'), exitProcess = (code) => process.exit(code)) {
2
+ /** Maps write-lease contention to a readable startup failure and process exit. */
3
+ export function acquireWriteLeaseOrExit(acquire, exitProcess = (code) => process.exit(code)) {
4
4
  try {
5
5
  return acquire();
6
6
  }
@@ -9,12 +9,7 @@ export function acquireWriteLeaseOrExit(acquire, loadElectron = () => import('el
9
9
  console.error(`[bs] 本地数据库正在被另一个进程使用(${error.lockPath})。\n` +
10
10
  '[bs] 再打开一次桌面应用会回到已经打开的窗口。\n' +
11
11
  '[bs] 如果终端里的 pnpm dev:bs 占着数据库,请先在那个终端按 Ctrl+C 停掉,再重新打开。');
12
- void loadElectron()
13
- .then(({ app, dialog }) => {
14
- dialog.showErrorBox('无法再开一个', '再打开一次应用时,会回到已经打开的窗口。\n\n这次没有回到那个窗口,是因为本地数据库正被另一个进程占用。如果终端里还在跑 pnpm dev:bs,先停掉它,再重新打开。');
15
- app.exit(1);
16
- })
17
- .catch(() => exitProcess(1));
12
+ queueMicrotask(() => exitProcess(1));
18
13
  }
19
14
  throw error;
20
15
  }
@@ -172,7 +172,8 @@ export class ToolRouter {
172
172
  return [
173
173
  {
174
174
  name: 'local_exec_shell',
175
- description: 'Execute a local shell command',
175
+ description: 'Execute a shell command in a pseudo-terminal. Set pty to false for a plain pipe. ' +
176
+ 'When this host is configured with a remote endpoint, the command runs on that host instead of this machine.',
176
177
  mode: 'destructive',
177
178
  inputSchema: {
178
179
  type: 'object',
@@ -180,6 +181,7 @@ export class ToolRouter {
180
181
  command: { type: 'string' },
181
182
  cwd: { type: 'string' },
182
183
  timeout: { type: 'number', description: 'Timeout in milliseconds (e.g. 30000). If a small number like 15 or 30 is provided, it is assumed to be in seconds and automatically multiplied by 1000.' },
184
+ pty: { type: 'boolean', description: 'Run in a pseudo-terminal. Defaults to true.' },
183
185
  },
184
186
  required: ['command'],
185
187
  },
@@ -377,32 +379,35 @@ export class ToolRouter {
377
379
  },
378
380
  {
379
381
  name: 'mcp_list_tools',
380
- description: 'List tools from one MCP server',
382
+ description: 'List tools from one MCP server. Pass command for stdio or url for unauthenticated Streamable HTTP.',
381
383
  mode: 'external',
382
384
  inputSchema: {
383
385
  type: 'object',
384
386
  properties: {
385
387
  command: { type: 'string' },
388
+ url: { type: 'string' },
386
389
  args: { type: 'array', items: { type: 'string' } },
387
390
  cwd: { type: 'string' },
388
391
  },
389
- required: ['command'],
392
+ additionalProperties: false,
390
393
  },
391
394
  },
392
395
  {
393
396
  name: 'mcp_tool_exec',
394
- description: 'Execute one MCP tool',
397
+ description: 'Execute one MCP tool. Pass command for stdio or url for unauthenticated Streamable HTTP.',
395
398
  mode: 'external',
396
399
  inputSchema: {
397
400
  type: 'object',
398
401
  properties: {
399
402
  command: { type: 'string' },
403
+ url: { type: 'string' },
400
404
  args: { type: 'array', items: { type: 'string' } },
401
405
  cwd: { type: 'string' },
402
406
  toolName: { type: 'string' },
403
407
  toolArgs: { type: 'object' },
404
408
  },
405
- required: ['command', 'toolName'],
409
+ required: ['toolName'],
410
+ additionalProperties: false,
406
411
  },
407
412
  },
408
413
  // deferred 层的发现缝(Wave 2 工具分层):MCP 动态工具不占每轮
@@ -890,6 +895,7 @@ export class ToolRouter {
890
895
  command: String(args.command || ''),
891
896
  cwd: typeof args.cwd === 'string' ? args.cwd : undefined,
892
897
  timeout: typeof args.timeout === 'number' ? args.timeout : undefined,
898
+ pty: args.pty !== false,
893
899
  }, projectRoot, context?.additionalWriteRoots ?? null);
894
900
  case 'local_read_file':
895
901
  return await this.localExecutor.readLocalFile({
@@ -1257,8 +1263,17 @@ export class ToolRouter {
1257
1263
  return { path: resolved };
1258
1264
  }
1259
1265
  resolveMcpConfig(args) {
1266
+ const command = typeof args.command === 'string' ? args.command.trim() : '';
1267
+ const url = typeof args.url === 'string' ? args.url.trim() : '';
1268
+ if (command && url)
1269
+ throw new Error('MCP 调用只能提供 command 或 url 之一');
1270
+ if (url)
1271
+ return { transport: 'streamable-http', url };
1272
+ if (!command)
1273
+ throw new Error('MCP 调用需要 command(stdio)或 url(Streamable HTTP)');
1260
1274
  return {
1261
- command: String(args.command || ''),
1275
+ transport: 'stdio',
1276
+ command,
1262
1277
  args: Array.isArray(args.args) ? args.args.map(item => String(item)) : undefined,
1263
1278
  cwd: typeof args.cwd === 'string' ? args.cwd : undefined,
1264
1279
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.6.52",
3
+ "version": "0.6.55",
4
4
  "description": "Steerable framework — product-neutral Node HostRuntime and headless HTTP server shared by Tauri desktop and browser deployments; scenario packs extend it through @steerable/pack-sdk.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
@@ -45,39 +45,32 @@
45
45
  "node": ">=20"
46
46
  },
47
47
  "dependencies": {
48
- "@modelcontextprotocol/sdk": "^1.29.0",
48
+ "@modelcontextprotocol/sdk": "1.30.0",
49
49
  "better-sqlite3": "^11.9.1",
50
50
  "conf": "10.2.0",
51
51
  "dotenv": "^17.4.2",
52
52
  "electron-log": "^5.4.3",
53
- "electron-store": "^8.1.0",
54
- "electron-updater": "^6.8.9",
55
53
  "he": "^1.2.0",
56
54
  "node-pty": "^1.1.0",
57
- "semver": "^7.8.0",
58
- "@steerable/agent-harness": "0.6.52",
59
- "@steerable/agent-protocol": "0.6.52",
60
- "@steerable/pack-sdk": "0.6.52"
55
+ "@steerable/agent-harness": "0.6.55",
56
+ "@steerable/pack-sdk": "0.6.55",
57
+ "@steerable/agent-protocol": "0.6.55"
61
58
  },
62
59
  "devDependencies": {
63
60
  "@types/better-sqlite3": "^7.6.13",
64
61
  "@types/he": "^1.2.3",
65
62
  "@types/node": "^22.10.5",
66
- "@types/semver": "^7.8.0",
67
- "electron": "^28.3.3",
68
- "esbuild": "^0.25.0",
69
63
  "typescript": "^5.8.3",
70
64
  "vitest": "^3.2.4"
71
65
  },
72
66
  "scripts": {
73
67
  "gen": "echo agent-shell-ts",
74
- "build": "tsc -p tsconfig.json && node copy-skills.mjs && node scripts/build-preload.mjs",
68
+ "build": "tsc -p tsconfig.json && node copy-skills.mjs",
75
69
  "test": "vitest run",
76
70
  "lint": "tsc -p tsconfig.json --noEmit && node scripts/check-shell-neutral.mjs && node scripts/check-storage-boundaries.mjs",
77
71
  "check:drift": "echo ok",
78
72
  "shell:neutral": "node scripts/check-shell-neutral.mjs",
79
73
  "storage:boundaries": "node scripts/check-storage-boundaries.mjs",
80
- "web": "DEEPPATH_WEB_DIST=${DEEPPATH_WEB_DIST:-../web/app/dist} node dist/server/index.js",
81
- "client": "DEEPPATH_FORCE_BUNDLED_WEB=1 DEEPPATH_WEB_DIST=${DEEPPATH_WEB_DIST:-../web/app/dist} node node_modules/electron/cli.js dist/main.js"
74
+ "web": "DEEPPATH_WEB_DIST=${DEEPPATH_WEB_DIST:-../web/app/dist} node dist/server/index.js"
82
75
  }
83
76
  }
@@ -1,5 +0,0 @@
1
- /**
2
- * electron-updater 适配。只在桌面主进程启用更新时加载。
3
- */
4
- import type { AppUpdaterPort } from './app-update.js';
5
- export declare function createElectronAppUpdater(): AppUpdaterPort;
@@ -1,66 +0,0 @@
1
- /**
2
- * electron-updater 适配。只在桌面主进程启用更新时加载。
3
- */
4
- import log from 'electron-log';
5
- import electronUpdater from 'electron-updater';
6
- export function createElectronAppUpdater() {
7
- const { autoUpdater } = electronUpdater;
8
- autoUpdater.logger = log;
9
- return {
10
- get autoDownload() {
11
- return autoUpdater.autoDownload;
12
- },
13
- set autoDownload(value) {
14
- autoUpdater.autoDownload = value;
15
- },
16
- get autoInstallOnAppQuit() {
17
- return autoUpdater.autoInstallOnAppQuit;
18
- },
19
- set autoInstallOnAppQuit(value) {
20
- autoUpdater.autoInstallOnAppQuit = value;
21
- },
22
- get allowDowngrade() {
23
- return autoUpdater.allowDowngrade;
24
- },
25
- set allowDowngrade(value) {
26
- autoUpdater.allowDowngrade = value;
27
- },
28
- setFeedURL(options) {
29
- autoUpdater.setFeedURL(options);
30
- },
31
- async checkForUpdates() {
32
- const result = await autoUpdater.checkForUpdates();
33
- if (!result)
34
- return null;
35
- return {
36
- updateInfo: { version: result.updateInfo.version },
37
- isUpdateAvailable: result.isUpdateAvailable,
38
- };
39
- },
40
- quitAndInstall(isSilent, isForceRunAfter) {
41
- autoUpdater.quitAndInstall(isSilent, isForceRunAfter);
42
- },
43
- on(event, listener) {
44
- if (event === 'download-progress') {
45
- autoUpdater.on('download-progress', listener);
46
- }
47
- else if (event === 'update-downloaded') {
48
- autoUpdater.on('update-downloaded', listener);
49
- }
50
- else {
51
- autoUpdater.on('error', listener);
52
- }
53
- },
54
- off(event, listener) {
55
- if (event === 'download-progress') {
56
- autoUpdater.off('download-progress', listener);
57
- }
58
- else if (event === 'update-downloaded') {
59
- autoUpdater.off('update-downloaded', listener);
60
- }
61
- else {
62
- autoUpdater.off('error', listener);
63
- }
64
- },
65
- };
66
- }
@@ -1,92 +0,0 @@
1
- /**
2
- * 桌面自动更新。产品经 `ProductConfig.updates.feedUrl` 注入 generic 通道;
3
- * 本模块只编排检查、下载和安装,具体传输交给 `AppUpdaterPort`
4
- * (生产环境是 electron-updater)。
5
- */
6
- export declare const APP_UPDATE_CHECK_INTERVAL_MS: number;
7
- export type AppUpdatePhase = 'disabled' | 'idle' | 'checking' | 'downloading' | 'ready' | 'installing' | 'error';
8
- export interface AppUpdateState {
9
- phase: AppUpdatePhase;
10
- version?: string;
11
- percent?: number;
12
- message?: string;
13
- }
14
- /** 渲染层看到的版本与更新状态。`version` 是当前安装版本。 */
15
- export interface AppReleaseSnapshot {
16
- version: string;
17
- enabled: boolean;
18
- phase: AppUpdatePhase;
19
- availableVersion?: string;
20
- percent?: number;
21
- message?: string;
22
- }
23
- export declare function toAppReleaseSnapshot(currentVersion: string, state: AppUpdateState): AppReleaseSnapshot;
24
- export interface AppUpdateCheckResult {
25
- updateInfo: {
26
- version: string;
27
- };
28
- isUpdateAvailable: boolean;
29
- }
30
- export interface AppUpdaterPort {
31
- autoDownload: boolean;
32
- autoInstallOnAppQuit: boolean;
33
- allowDowngrade: boolean;
34
- setFeedURL(options: {
35
- provider: 'generic';
36
- url: string;
37
- }): void;
38
- checkForUpdates(): Promise<AppUpdateCheckResult | null>;
39
- quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void;
40
- on(event: 'download-progress', listener: (progress: {
41
- percent: number;
42
- }) => void): void;
43
- on(event: 'update-downloaded', listener: (info: {
44
- version: string;
45
- }) => void): void;
46
- on(event: 'error', listener: (error: Error) => void): void;
47
- off(event: 'download-progress', listener: (progress: {
48
- percent: number;
49
- }) => void): void;
50
- off(event: 'update-downloaded', listener: (info: {
51
- version: string;
52
- }) => void): void;
53
- off(event: 'error', listener: (error: Error) => void): void;
54
- }
55
- export interface AppUpdateOptions {
56
- feedUrl?: string;
57
- packaged: boolean;
58
- currentVersion: string;
59
- env: NodeJS.ProcessEnv;
60
- updater: AppUpdaterPort;
61
- onState?: (state: AppUpdateState) => void;
62
- /** 测试注入。缺省写 `env.DEEPPATH_UPDATE_STATUS_FILE`。 */
63
- statusFile?: string;
64
- }
65
- export declare function appUpdateMenuAction(state: AppUpdateState): 'none' | 'check' | 'install';
66
- export declare function shouldNotifyUpdateReady(previous: AppUpdatePhase, next: AppUpdateState): boolean;
67
- export declare class AppUpdateController {
68
- private readonly options;
69
- private current;
70
- private enabled;
71
- private started;
72
- private disposed;
73
- private bound;
74
- private candidate;
75
- private downloadedVersion;
76
- private checkPromise;
77
- private cancelTimer;
78
- private readonly statusFile;
79
- constructor(options: AppUpdateOptions);
80
- get state(): AppUpdateState;
81
- start(): Promise<AppUpdateState>;
82
- checkNow(): Promise<AppUpdateState>;
83
- installNow(): AppUpdateState;
84
- dispose(): void;
85
- private bind;
86
- private readonly onProgress;
87
- private readonly onDownloaded;
88
- private readonly onError;
89
- private doCheck;
90
- private isNewer;
91
- private publish;
92
- }
@@ -1,213 +0,0 @@
1
- /**
2
- * 桌面自动更新。产品经 `ProductConfig.updates.feedUrl` 注入 generic 通道;
3
- * 本模块只编排检查、下载和安装,具体传输交给 `AppUpdaterPort`
4
- * (生产环境是 electron-updater)。
5
- */
6
- import { appendFileSync } from 'node:fs';
7
- import { gt, valid } from 'semver';
8
- export const APP_UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
9
- export function toAppReleaseSnapshot(currentVersion, state) {
10
- const snapshot = {
11
- version: currentVersion,
12
- enabled: state.phase !== 'disabled',
13
- phase: state.phase,
14
- };
15
- if (state.version)
16
- snapshot.availableVersion = state.version;
17
- if (state.percent !== undefined)
18
- snapshot.percent = state.percent;
19
- if (state.message)
20
- snapshot.message = state.message;
21
- return snapshot;
22
- }
23
- export function appUpdateMenuAction(state) {
24
- if (state.phase === 'disabled')
25
- return 'none';
26
- if (state.phase === 'ready' || state.phase === 'installing')
27
- return 'install';
28
- return 'check';
29
- }
30
- export function shouldNotifyUpdateReady(previous, next) {
31
- return previous !== 'ready' && next.phase === 'ready' && Boolean(next.version);
32
- }
33
- function resolveFeed(options) {
34
- const override = options.env.DEEPPATH_UPDATE_FEED_URL?.trim() ?? '';
35
- const feedUrl = override || options.feedUrl?.trim() || '';
36
- const forceUnpackaged = options.env.DEEPPATH_APP_UPDATE === '1';
37
- if (!feedUrl || (!options.packaged && !forceUnpackaged))
38
- return { enabled: false };
39
- return { enabled: true, feedUrl };
40
- }
41
- function clampPercent(percent) {
42
- if (!Number.isFinite(percent))
43
- return 0;
44
- return Math.min(100, Math.max(0, percent));
45
- }
46
- function defaultSchedule(fn, ms) {
47
- const timer = setInterval(fn, ms);
48
- timer.unref();
49
- return () => clearInterval(timer);
50
- }
51
- export class AppUpdateController {
52
- options;
53
- current = { phase: 'disabled' };
54
- enabled = false;
55
- started = false;
56
- disposed = false;
57
- bound = false;
58
- candidate;
59
- downloadedVersion;
60
- checkPromise;
61
- cancelTimer;
62
- statusFile;
63
- constructor(options) {
64
- this.options = options;
65
- this.statusFile = options.statusFile ?? options.env.DEEPPATH_UPDATE_STATUS_FILE;
66
- }
67
- get state() {
68
- return this.current;
69
- }
70
- async start() {
71
- if (this.started)
72
- return this.checkPromise ?? this.state;
73
- this.started = true;
74
- const target = resolveFeed(this.options);
75
- if (!target.enabled)
76
- return this.publish({ phase: 'disabled' });
77
- this.enabled = true;
78
- this.options.updater.autoDownload = true;
79
- this.options.updater.autoInstallOnAppQuit = true;
80
- this.options.updater.allowDowngrade = false;
81
- this.options.updater.setFeedURL({ provider: 'generic', url: target.feedUrl });
82
- this.bind();
83
- const first = this.checkNow();
84
- this.cancelTimer = defaultSchedule(() => {
85
- void this.checkNow();
86
- }, APP_UPDATE_CHECK_INTERVAL_MS);
87
- return first;
88
- }
89
- checkNow() {
90
- if (this.disposed || !this.enabled)
91
- return Promise.resolve(this.state);
92
- if (this.state.phase === 'downloading' || this.state.phase === 'ready' || this.state.phase === 'installing') {
93
- return Promise.resolve(this.state);
94
- }
95
- if (this.checkPromise)
96
- return this.checkPromise;
97
- this.checkPromise = this.doCheck().finally(() => {
98
- this.checkPromise = undefined;
99
- });
100
- return this.checkPromise;
101
- }
102
- installNow() {
103
- if (this.disposed || this.state.phase !== 'ready' || !this.downloadedVersion)
104
- return this.state;
105
- const version = this.downloadedVersion;
106
- this.publish({ phase: 'installing', version });
107
- try {
108
- this.options.updater.quitAndInstall(false, true);
109
- }
110
- catch (error) {
111
- return this.publish({
112
- phase: 'error',
113
- version,
114
- message: error instanceof Error ? error.message : String(error),
115
- });
116
- }
117
- return this.state;
118
- }
119
- dispose() {
120
- this.disposed = true;
121
- this.cancelTimer?.();
122
- this.cancelTimer = undefined;
123
- if (!this.bound)
124
- return;
125
- this.options.updater.off('download-progress', this.onProgress);
126
- this.options.updater.off('update-downloaded', this.onDownloaded);
127
- this.options.updater.off('error', this.onError);
128
- this.bound = false;
129
- }
130
- bind() {
131
- if (this.bound)
132
- return;
133
- this.bound = true;
134
- this.options.updater.on('download-progress', this.onProgress);
135
- this.options.updater.on('update-downloaded', this.onDownloaded);
136
- this.options.updater.on('error', this.onError);
137
- }
138
- onProgress = (progress) => {
139
- if (this.downloadedVersion)
140
- return;
141
- this.publish({
142
- phase: 'downloading',
143
- ...(this.candidate ? { version: this.candidate } : {}),
144
- percent: clampPercent(progress.percent),
145
- });
146
- };
147
- onDownloaded = (info) => {
148
- if (!this.isNewer(info.version))
149
- return;
150
- this.downloadedVersion = info.version;
151
- this.candidate = info.version;
152
- this.publish({ phase: 'ready', version: info.version });
153
- };
154
- onError = (error) => {
155
- const version = this.candidate ?? this.state.version;
156
- this.publish({
157
- phase: 'error',
158
- ...(version ? { version } : {}),
159
- message: error instanceof Error ? error.message : String(error),
160
- });
161
- };
162
- async doCheck() {
163
- this.downloadedVersion = undefined;
164
- this.candidate = undefined;
165
- this.publish({ phase: 'checking' });
166
- try {
167
- const result = await this.options.updater.checkForUpdates();
168
- if (this.disposed)
169
- return this.state;
170
- if (this.downloadedVersion && this.isNewer(this.downloadedVersion)) {
171
- if (this.state.phase === 'ready' && this.state.version === this.downloadedVersion)
172
- return this.state;
173
- return this.publish({ phase: 'ready', version: this.downloadedVersion });
174
- }
175
- if (!result)
176
- return this.publish({ phase: 'error', message: 'app update: no check result' });
177
- const version = result.updateInfo.version;
178
- if (!result.isUpdateAvailable)
179
- return this.publish({ phase: 'idle' });
180
- if (valid(version) === null) {
181
- return this.publish({ phase: 'error', message: 'app update: feed version is invalid' });
182
- }
183
- if (!this.isNewer(version))
184
- return this.publish({ phase: 'idle' });
185
- this.candidate = version;
186
- if (this.state.phase === 'downloading') {
187
- return this.publish({ phase: 'downloading', version, percent: this.state.percent ?? 0 });
188
- }
189
- return this.publish({ phase: 'downloading', version, percent: 0 });
190
- }
191
- catch (error) {
192
- if (this.disposed)
193
- return this.state;
194
- return this.publish({
195
- phase: 'error',
196
- ...(this.candidate ? { version: this.candidate } : {}),
197
- message: error instanceof Error ? error.message : String(error),
198
- });
199
- }
200
- }
201
- isNewer(version) {
202
- return valid(version) !== null && valid(this.options.currentVersion) !== null && gt(version, this.options.currentVersion);
203
- }
204
- publish(state) {
205
- if (this.disposed)
206
- return this.current;
207
- this.current = state;
208
- if (this.statusFile)
209
- appendFileSync(this.statusFile, `${JSON.stringify(state)}\n`);
210
- this.options.onState?.(state);
211
- return state;
212
- }
213
- }
@@ -1,11 +0,0 @@
1
- /**
2
- * 包 IPC 绑定器(0.3e):把包的 IpcContribution 注册到 Electron ipcMain。
3
- *
4
- * 命名空间规则:channel 必须以 `<packId>:` 开头——包只能占用自己的
5
- * 命名空间,注册期 fail fast。handler 统一收单个序列化载荷(renderer
6
- * 经 preload 透传),返回值经 IPC 结构化克隆回 renderer。
7
- */
8
- import type { IpcContribution } from '../scenario/pack.js';
9
- /** 命名空间校验(纯函数,单测直接覆盖)。 */
10
- export declare function assertPackChannel(packId: string, channel: string): void;
11
- export declare function registerPackIpc(packId: string, contributions: readonly IpcContribution[]): void;