cotutor 0.4.0 → 0.4.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.
Files changed (36) hide show
  1. package/README.md +39 -8
  2. package/dist/app/cli/cert.d.ts +6 -0
  3. package/dist/app/cli/cert.js +16 -2
  4. package/dist/app/cli/doctor.d.ts +5 -0
  5. package/dist/app/cli/doctor.js +65 -17
  6. package/dist/app/cli/init.js +1 -1
  7. package/dist/app/cli/main.js +58 -17
  8. package/dist/app/cli/serve.d.ts +0 -1
  9. package/dist/app/cli/serve.js +5 -0
  10. package/dist/app/cli/service.d.ts +40 -0
  11. package/dist/app/cli/service.js +50 -5
  12. package/dist/app/cli/tray-run.js +11 -2
  13. package/dist/app/cli/tray.d.ts +35 -4
  14. package/dist/app/cli/tray.js +67 -13
  15. package/dist/app/cli/update-cmd.d.ts +38 -0
  16. package/dist/app/cli/update-cmd.js +174 -0
  17. package/dist/app/cli/usage.d.ts +1 -1
  18. package/dist/app/cli/usage.js +4 -3
  19. package/dist/app/http/app.d.ts +2 -0
  20. package/dist/app/http/app.js +19 -2
  21. package/dist/app/machine/bailian.d.ts +26 -0
  22. package/dist/app/machine/bailian.js +91 -4
  23. package/dist/app/machine/update.d.ts +38 -0
  24. package/dist/app/machine/update.js +109 -0
  25. package/dist/brain/api.js +6 -3
  26. package/dist/realtime/preview.d.ts +16 -0
  27. package/dist/realtime/preview.js +43 -1
  28. package/dist/runtime/live/call.d.ts +2 -0
  29. package/dist/runtime/live/call.js +6 -1
  30. package/dist/shared/upgrade.d.ts +9 -0
  31. package/dist/shared/upgrade.js +23 -0
  32. package/dist/ui/kid/kid-page.js +8 -2
  33. package/dist/ui/parent/parent-home-page.js +47 -2
  34. package/package.json +11 -2
  35. package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +1 -1
  36. package/skills/cotutor-tune/references//345/255/227/346/256/265.md +1 -1
@@ -22,6 +22,7 @@ import { AUTO_BOOKKEEP_EVERY_MS, autoBookkeep } from "../../runtime/auto-bookkee
22
22
  import { USER_CERT_DIR, expandPath, loadWorkspace } from "../workspace.js";
23
23
  export { siteUrl };
24
24
  /** 证书路径:cotutor.json 的 server.https 优先(相对 workspace 根),否则机器级 ~/.config/cotutor/certs/;都没有 → null */
25
+ const BAILIAN_RECHECK_MS = 120_000;
25
26
  export function httpsFiles(ws) {
26
27
  const h = ws.config.server.https;
27
28
  if (h)
@@ -126,6 +127,10 @@ export async function serveWorkspace(opts = {}) {
126
127
  }, AUTO_BOOKKEEP_EVERY_MS);
127
128
  timer.unref();
128
129
  server.once('close', () => clearInterval(timer));
130
+ // 嘴连不上时隔一阵再试:充上钱、网回来了,没人点也会恢复,孩子端和图标跟着好
131
+ const recheck = setInterval(() => { void ctx.bailian.recheck().catch(() => undefined); }, BAILIAN_RECHECK_MS);
132
+ recheck.unref();
133
+ server.once('close', () => clearInterval(recheck));
129
134
  if (opts.awake !== false) {
130
135
  const stop = holdAwake(() => liveUpgrade.openCalls() > 0 || Object.keys(ctx.ws.config.tutors).some((t) => ctx.runner.running(t) !== null));
131
136
  server.once('close', stop);
@@ -28,6 +28,34 @@ export declare function earlyLogHint(platform: ServicePlatform, s: {
28
28
  }): string;
29
29
  /** 看护进程的日志(Windows):serve 退了几次、起来之前的输出 */
30
30
  export declare const KEEPER_LOG = "keeper.log";
31
+ /**
32
+ * launchctl bootout 发完信号就回,进程还没退;这时 bootstrap 报 5: Input/output error,服务就停在那儿了(2026-11-07 实测重装 12 次失败 10 次)。
33
+ * 等它真的卸下来再往下走。launchd 最多等进程 20 秒(ExitTimeOut 缺省),再不走它自己会杀。
34
+ */
35
+ export declare function launchdUnload(host: Pick<ServiceHost, 'run'>, target: string, waitMs?: number): Promise<void>;
36
+ /** 装上;刚卸下的那一下还可能报错,隔一秒再试几次,真的不行才算不行 */
37
+ export declare function launchdLoad(host: Pick<ServiceHost, 'run' | 'uid'>, file: string, tries?: number): Promise<{
38
+ code: number;
39
+ stdout: string;
40
+ stderr: string;
41
+ }>;
42
+ /**
43
+ * 写进服务文件、登录项的 node。Homebrew 的 process.execPath 是带版本号的路径(实测 /opt/homebrew/Cellar/node/26.12.1_1/bin/node),
44
+ * brew upgrade 清掉旧版后,服务和图标下次登录都起不来,连报警的都没了;opt/<formula> 这个链接 brew 一直维护。别的管理器(nvm、asdf)不会自己删旧版,不动。
45
+ */
46
+ export declare function stableNode(execPath: string, exists?: (p: string) => boolean): string;
47
+ /**
48
+ * service install 之后开不开家长端:只有这个孩子第一次装时开,下一步就是填百炼、扫码。
49
+ * 别的启动都不开:登录、崩溃重起、升级、重启都不是人要看页面;放进 serve 的话,一直崩就隔 10 秒开一个标签页。
50
+ * ssh 进来的不开:没人看,macOS 的 open 还会开到那台电脑的屏幕上。Linux 要有图形会话。--open 是人明说的,重装也开;--no-open 最大
51
+ */
52
+ export declare function opensAfterInstall(o: {
53
+ firstTime: boolean;
54
+ open: boolean;
55
+ noOpen: boolean;
56
+ platform: NodeJS.Platform;
57
+ env: NodeJS.ProcessEnv;
58
+ }): boolean;
31
59
  /**
32
60
  * 看护进程(Windows;cotutor service run):起 serve,非 0 隔 10 秒再起,0 不起(端口被占着);同 launchd 的 KeepAlive、systemd 的 Restart=on-failure。
33
61
  * 给 serve 带上 COTUTOR_SERVICE(mode 算后台服务、端口被占以 0 退);返回退出码
@@ -108,6 +136,18 @@ export interface Health {
108
136
  calls?: number;
109
137
  /** 百炼配了没有(托盘:没配算有事要处理) */
110
138
  bailian?: boolean;
139
+ /** 跑起来以后百炼出的问题(欠费、key 失效、连不上);没有 → null(machine/bailian.ts) */
140
+ bailianTrouble?: {
141
+ kind: string;
142
+ message: string;
143
+ fix: string;
144
+ why?: string;
145
+ blocked?: boolean;
146
+ } | null;
147
+ /** npm 上比在跑的这份新的版本;没有 → null(machine/update.ts) */
148
+ latest?: string | null;
149
+ /** 那个版本写的更新说明 */
150
+ latestNotes?: string[];
111
151
  configError?: string | null;
112
152
  /** 问到的是 https 还是 http(这边加的,不是服务回的) */
113
153
  scheme?: 'https' | 'http';
@@ -88,6 +88,51 @@ export function earlyLogHint(platform, s) {
88
88
  export const KEEPER_LOG = 'keeper.log';
89
89
  const KEEPER_LOG_MAX = 5 * 1024 * 1024;
90
90
  const KEEPER_RESTART_MS = 10_000;
91
+ const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
92
+ /**
93
+ * launchctl bootout 发完信号就回,进程还没退;这时 bootstrap 报 5: Input/output error,服务就停在那儿了(2026-11-07 实测重装 12 次失败 10 次)。
94
+ * 等它真的卸下来再往下走。launchd 最多等进程 20 秒(ExitTimeOut 缺省),再不走它自己会杀。
95
+ */
96
+ export async function launchdUnload(host, target, waitMs = 25_000) {
97
+ await host.run('launchctl', ['bootout', target]);
98
+ const until = Date.now() + waitMs;
99
+ while ((await host.run('launchctl', ['print', target])).code === 0 && Date.now() < until)
100
+ await sleep(200);
101
+ }
102
+ /** 装上;刚卸下的那一下还可能报错,隔一秒再试几次,真的不行才算不行 */
103
+ export async function launchdLoad(host, file, tries = 5) {
104
+ let r = await host.run('launchctl', ['bootstrap', `gui/${host.uid}`, file]);
105
+ for (let i = 1; i < tries && r.code !== 0; i++) {
106
+ await sleep(1000);
107
+ r = await host.run('launchctl', ['bootstrap', `gui/${host.uid}`, file]);
108
+ }
109
+ return r;
110
+ }
111
+ /**
112
+ * 写进服务文件、登录项的 node。Homebrew 的 process.execPath 是带版本号的路径(实测 /opt/homebrew/Cellar/node/26.12.1_1/bin/node),
113
+ * brew upgrade 清掉旧版后,服务和图标下次登录都起不来,连报警的都没了;opt/<formula> 这个链接 brew 一直维护。别的管理器(nvm、asdf)不会自己删旧版,不动。
114
+ */
115
+ export function stableNode(execPath, exists = existsSync) {
116
+ const m = /^(.*)\/Cellar\/(node(?:@\d+)?)\/[^/]+\/bin\/node$/.exec(execPath);
117
+ if (!m)
118
+ return execPath;
119
+ const opt = `${m[1]}/opt/${m[2]}/bin/node`;
120
+ return exists(opt) ? opt : execPath;
121
+ }
122
+ /**
123
+ * service install 之后开不开家长端:只有这个孩子第一次装时开,下一步就是填百炼、扫码。
124
+ * 别的启动都不开:登录、崩溃重起、升级、重启都不是人要看页面;放进 serve 的话,一直崩就隔 10 秒开一个标签页。
125
+ * ssh 进来的不开:没人看,macOS 的 open 还会开到那台电脑的屏幕上。Linux 要有图形会话。--open 是人明说的,重装也开;--no-open 最大
126
+ */
127
+ export function opensAfterInstall(o) {
128
+ if (o.noOpen)
129
+ return false;
130
+ if (o.open)
131
+ return true;
132
+ if (!o.firstTime || o.env.SSH_CONNECTION || o.env.SSH_TTY)
133
+ return false;
134
+ return o.platform !== 'linux' || Boolean(o.env.DISPLAY || o.env.WAYLAND_DISPLAY);
135
+ }
91
136
  /**
92
137
  * 看护进程(Windows;cotutor service run):起 serve,非 0 隔 10 秒再起,0 不起(端口被占着);同 launchd 的 KeepAlive、systemd 的 Restart=on-failure。
93
138
  * 给 serve 带上 COTUTOR_SERVICE(mode 算后台服务、端口被占以 0 退);返回退出码
@@ -253,7 +298,7 @@ export async function installService(host, root, opts = {}) {
253
298
  const logDir = serviceLogDir(host, name);
254
299
  // Windows 上跑的是看护进程,它再起 serve(那边没有 launchd / systemd 替你拉起)
255
300
  const run = platform === 'win32' ? ['service', 'run'] : ['serve'];
256
- const spec = { name, label, root, logDir, path: host.env.PATH ?? '/usr/bin:/bin', args: [opts.node ?? process.execPath, opts.bin ?? binPath(), ...run, '--workspace', root, '--log-dir', logDir] };
301
+ const spec = { name, label, root, logDir, path: host.env.PATH ?? '/usr/bin:/bin', args: [opts.node ?? stableNode(process.execPath), opts.bin ?? binPath(), ...run, '--workspace', root, '--log-dir', logDir] };
257
302
  const file = fileOf(host, name);
258
303
  await mkdir(serviceDir(host), { recursive: true });
259
304
  await mkdir(logDir, { recursive: true });
@@ -270,9 +315,9 @@ export async function installService(host, root, opts = {}) {
270
315
  }
271
316
  else if (platform === 'darwin') {
272
317
  // 先卸下旧的(没装过会报错,不管),再装上;bootstrap 会按 RunAtLoad 马上起
273
- await host.run('launchctl', ['bootout', `gui/${host.uid}/${label}`]);
318
+ await launchdUnload(host, `gui/${host.uid}/${label}`);
274
319
  await writeFile(file, launchdPlist(spec));
275
- const r = await host.run('launchctl', ['bootstrap', `gui/${host.uid}`, file]);
320
+ const r = await launchdLoad(host, file);
276
321
  if (r.code !== 0)
277
322
  throw new UsageError(`launchctl bootstrap 没成:${(r.stderr || r.stdout).trim()}`);
278
323
  }
@@ -332,10 +377,10 @@ export async function controlService(host, root, what) {
332
377
  const loaded = (await host.run('launchctl', ['print', target])).code === 0;
333
378
  if (what === 'stop') {
334
379
  if (loaded)
335
- await host.run('launchctl', ['bootout', target]);
380
+ await launchdUnload(host, target);
336
381
  return s;
337
382
  }
338
- const r = loaded ? await host.run('launchctl', what === 'restart' ? ['kickstart', '-k', target] : ['kickstart', target]) : await host.run('launchctl', ['bootstrap', `gui/${host.uid}`, s.file]);
383
+ const r = loaded ? await host.run('launchctl', what === 'restart' ? ['kickstart', '-k', target] : ['kickstart', target]) : await launchdLoad(host, s.file);
339
384
  if (r.code !== 0)
340
385
  throw new UsageError(`launchctl 没成:${(r.stderr || r.stdout).trim()}`);
341
386
  return s;
@@ -6,7 +6,16 @@ import { mkdirSync, readFileSync } from 'node:fs';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { realHost } from "./service.js";
8
8
  import { realTrayDeps, runTray, trayDir, trayNote, traySupported, writeTrayFiles } from "./tray.js";
9
- const version = () => JSON.parse(readFileSync(fileURLToPath(new URL('../../../package.json', import.meta.url)), 'utf8')).version;
9
+ const readVersion = () => JSON.parse(readFileSync(fileURLToPath(new URL('../../../package.json', import.meta.url)), 'utf8')).version;
10
+ let installed = readVersion();
11
+ /** 每轮重读:npm 升级时这个进程一直开着,只记起来那一刻的就会误报。装到一半读不到就用上次的 */
12
+ const version = () => {
13
+ try {
14
+ installed = readVersion();
15
+ }
16
+ catch { /* 下一轮再读 */ }
17
+ return installed;
18
+ };
10
19
  export async function main(argv) {
11
20
  const host = realHost();
12
21
  if (!traySupported(host.platform)) {
@@ -27,7 +36,7 @@ export async function main(argv) {
27
36
  process.on('unhandledRejection', fatal('没接住的 Promise 错误'));
28
37
  const file = await writeTrayFiles(host.platform, dir);
29
38
  note(`起来了,node ${process.versions.node},pid ${process.pid}`);
30
- const code = await runTray(realTrayDeps(host, dir, file, version()));
39
+ const code = await runTray(realTrayDeps(host, dir, file, version));
31
40
  note(`退出 ${code}`);
32
41
  process.exit(code);
33
42
  }
@@ -1,3 +1,15 @@
1
+ /**
2
+ * 托盘图标(macOS 菜单栏、Windows 任务栏;Linux 没做,GNOME 缺省不显示托盘):家长不开终端也看得到服务好不好、点得开家长端。
3
+ * 三层:
4
+ * node 进程(cotutor tray run,tray-run.ts 是它的轻入口):隔几秒问每个后台服务的 /api/health,判断状态,决定显示什么、要不要通知;这里全是能测的 TS。
5
+ * 显示脚本(tray-mac.ts 的 JXA、tray-windows.ts 的 PowerShell,都是系统自带、不用签名):只管画和回报点击,不判断。
6
+ * 登录项:macOS 一个 LaunchAgent,Windows 一条 Run 项 + wscript 隐藏启动器;service install 时一起装(--no-tray 不装,cotutor tray off 关掉并记住)。
7
+ * 为什么不放在 serve 里:服务挂了图标也没了,就说不出「没在跑」;多个孩子时一台电脑也只该有一个图标。
8
+ * 为什么不用 npm 的托盘库、Electron:见 docs/wip;systray2 只有 x86 的 Mac 二进制且没签名,Electron 要多下一百多 MB。
9
+ * 状态:正常只有 logo(和菜单栏别的图标一样单色);有事才出状态点,橙 = 要处理,红 = 没在跑(logo 同时变淡)。正常 → 有事、→ 没在跑时通知一次,恢复不通知。
10
+ * 「退出 cotutor」= 停掉所有后台服务并退出图标,下次登录自己回来;正开着电话时先问一声。
11
+ */
12
+ import { type SpawnOptions } from 'node:child_process';
1
13
  import { type Health, type InstalledService, type ServiceHost } from './service.ts';
2
14
  import { HELPER_NO_SESSION, TRAY_AUMID, TRAY_LABEL, TRAY_RUN_VALUE } from './tray-ids.ts';
3
15
  export { HELPER_NO_SESSION, TRAY_AUMID, TRAY_LABEL, TRAY_RUN_VALUE };
@@ -45,6 +57,8 @@ export interface TrayRow {
45
57
  issues: number;
46
58
  fix: TrayFix | null;
47
59
  calls: number;
60
+ /** npm 上有比装的新的版本 → 版本号;不算有事,不出状态点 */
61
+ update: string | null;
48
62
  }
49
63
  export type TrayEntry = {
50
64
  kind: 'info';
@@ -89,7 +103,7 @@ export declare class TrayModel {
89
103
  private readonly o;
90
104
  constructor(o: {
91
105
  platform: NodeJS.Platform;
92
- version: string;
106
+ version: () => string;
93
107
  });
94
108
  /** 刚点了启动、重启:这一阵问不到是正常的 */
95
109
  expect(name: string, now: number): void;
@@ -100,6 +114,8 @@ export declare class TrayModel {
100
114
  private healthy;
101
115
  private placeholder;
102
116
  }
117
+ /** 有新版本的通知:每个版本最多一次,记在托盘目录,重起、重新登录不再弹 */
118
+ export declare function updateNote(version: string, platform: NodeJS.Platform): TrayNote;
103
119
  /** 这一轮显示什么 */
104
120
  export declare function trayShow(rows: TrayRow[]): TrayShow;
105
121
  export interface HelperMessage {
@@ -115,7 +131,11 @@ export interface TrayHelper {
115
131
  }
116
132
  export interface TrayDeps {
117
133
  platform: NodeJS.Platform;
118
- version: string;
134
+ /** 现在装的是哪个版本:每轮问,程序可能刚被 npm 换了 */
135
+ version(): string;
136
+ /** 已经通知过的新版本 */
137
+ updateSeen(): string | null;
138
+ setUpdateSeen(version: string): void;
119
139
  now(): number;
120
140
  list(): Promise<InstalledService[]>;
121
141
  /** 读 cotutor.json:孩子叫什么、哪个端口 */
@@ -155,7 +175,16 @@ export declare function spawnTrayHelper(platform: NodeJS.Platform, file: string,
155
175
  message(m: HelperMessage): void;
156
176
  exit(code: number | null): void;
157
177
  }): TrayHelper;
158
- /** 打开地址或目录(Windows 不弹黑窗口) */
178
+ /**
179
+ * 打开地址或目录用什么命令。windowsHide 不弹黑窗口。
180
+ * detached 不能省:Windows 上非 detached 的子进程在「父进程退出就杀」的 job 里(libuv),cotutor open、service install 起完马上退,cmd 还没把地址交给浏览器就被一起杀了。
181
+ * 退出码是 0、地址也打印了,就是没打开,也不报错(2026-11-07 真机);托盘进程一直开着,所以光点图标看不出来。
182
+ */
183
+ export declare function openCommand(platform: NodeJS.Platform, target: string): {
184
+ cmd: string;
185
+ args: string[];
186
+ options: SpawnOptions;
187
+ };
159
188
  export declare function openTarget(platform: NodeJS.Platform, target: string): void;
160
189
  /** 日志:一个文件,过 1 MB 换一次,只留上一份 */
161
190
  export declare function trayNote(dir: string): (text: string) => void;
@@ -165,7 +194,7 @@ export declare function inspectService(s: InstalledService): Promise<{
165
194
  port: number;
166
195
  }>;
167
196
  /** 真的依赖,给 runTray */
168
- export declare function realTrayDeps(host: ServiceHost, dir: string, file: string, version: string): TrayDeps;
197
+ export declare function realTrayDeps(host: ServiceHost, dir: string, file: string, version: () => string): TrayDeps;
169
198
  /** 登录项跑的命令 */
170
199
  export declare const trayArgs: (host: HostPaths, o?: {
171
200
  node?: string;
@@ -201,5 +230,7 @@ export declare function ensureTray(host: ServiceHost, o?: {
201
230
  node?: string;
202
231
  bin?: string;
203
232
  }): Promise<'unsupported' | 'off' | 'started' | 'later'>;
233
+ /** service restart 时:图标进程也换上新代码(升级后跑的就是这条)。没开图标的不动,不能把 --no-tray 的装回来 */
234
+ export declare function refreshTray(host: ServiceHost): Promise<boolean>;
204
235
  /** 服务卸完了:图标也撤(不算家长说不要,以后再装服务还会带上) */
205
236
  export declare function dropTrayIfNoServices(host: ServiceHost): Promise<boolean>;
@@ -10,16 +10,17 @@
10
10
  * 「退出 cotutor」= 停掉所有后台服务并退出图标,下次登录自己回来;正开着电话时先问一声。
11
11
  */
12
12
  import { spawn } from 'node:child_process';
13
- import { appendFileSync, existsSync, renameSync, statSync } from 'node:fs';
13
+ import { appendFileSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
15
15
  import { join } from 'node:path';
16
16
  import { createInterface } from 'node:readline';
17
17
  import { DEFAULT_PORT } from "../../shared/default-port.js";
18
18
  import { UsageError } from "../../shared/errors.js";
19
19
  import { siteUrl } from "../../shared/site-url.js";
20
+ import { isNewer } from "../../shared/upgrade.js";
20
21
  import { portHolder } from "./port-holder.js";
21
22
  import { RUN_KEY, STARTUP_APPROVED_KEY, encodeLauncher, parseProcs, psArgs, runValue, startupDisabled, winCommandLine } from "./service-windows.js";
22
- import { binPath, controlService, listServices, parseLaunchctlPrint, probeHealth, wscriptPath, xml } from "./service.js";
23
+ import { binPath, controlService, listServices, parseLaunchctlPrint, probeHealth, wscriptPath, xml, launchdLoad, launchdUnload, stableNode } from "./service.js";
23
24
  import { macTrayIcons, windowsTrayIcons } from "./tray-icons.js";
24
25
  import { HELPER_NO_SESSION, TRAY_AUMID, TRAY_LABEL, TRAY_RUN_VALUE } from "./tray-ids.js";
25
26
  import { MAC_TRAY_SCRIPT } from "./tray-mac.js";
@@ -64,6 +65,8 @@ export function issuesOf(p, o) {
64
65
  out.push({ text: 'vault 不在了:看看 cotutor.json 的 paths.vault', fix: { kind: 'open', target: p.root } });
65
66
  if (h.bailian === false)
66
67
  out.push({ text: '还没填百炼:老师们到不了', fix: { kind: 'open', target: url } });
68
+ else if (h.bailianTrouble)
69
+ out.push({ text: h.bailianTrouble.message, fix: { kind: 'open', target: url } });
67
70
  if (h.version && h.version !== o.version)
68
71
  out.push({ text: `在跑的是 ${h.version},装的是 ${o.version}:重启一下`, fix: { kind: 'restart' } });
69
72
  return out;
@@ -73,6 +76,7 @@ const rank = (l) => (l === 'bad' ? 2 : l === 'warn' ? 1 : 0);
73
76
  export class TrayModel {
74
77
  mem = new Map();
75
78
  last = 0;
79
+ // 版本每轮问:npm 升级时这个进程一直开着,记死了就把「都对了」报成「要重启」,把真要重启的放过去
76
80
  o;
77
81
  constructor(o) { this.o = o; }
78
82
  /** 刚点了启动、重启:这一阵问不到是正常的 */
@@ -128,7 +132,8 @@ export class TrayModel {
128
132
  }
129
133
  healthy(p, now) {
130
134
  const h = p.health;
131
- const issues = issuesOf(p, this.o);
135
+ const installed = this.o.version();
136
+ const issues = issuesOf(p, { platform: this.o.platform, version: installed });
132
137
  const started = Date.parse(h.started);
133
138
  const running = h.mode === 'foreground' ? '在终端里跑,关了终端就停' : `已运行 ${uptimeText(Number.isNaN(started) ? now : started, now)}`;
134
139
  return {
@@ -141,10 +146,12 @@ export class TrayModel {
141
146
  issues: issues.length,
142
147
  fix: issues[0]?.fix ?? null,
143
148
  calls: h.calls ?? 0,
149
+ // 和装的比,不只和在跑的比:程序已经换上新版、只差重启时,再说「有新版本」就错了
150
+ update: isNewer(h.latest, installed) ? h.latest : null,
144
151
  };
145
152
  }
146
153
  placeholder(p, level, detail) {
147
- return { name: p.name, kid: p.kid, logDir: p.logDir, level, url: null, detail, issues: 0, fix: null, calls: 0 };
154
+ return { name: p.name, kid: p.kid, logDir: p.logDir, level, url: null, detail, issues: 0, fix: null, calls: 0, update: null };
148
155
  }
149
156
  }
150
157
  const titleOf = (r, multi) => {
@@ -152,9 +159,14 @@ const titleOf = (r, multi) => {
152
159
  return r.level === 'ok' ? 'cotutor 正常' : r.level === 'warn' ? `有 ${r.issues} 件事要处理` : r.level === 'bad' ? '后台服务没在跑' : 'cotutor 正在启动';
153
160
  return `${r.kid} · ${r.level === 'ok' ? '正常' : r.level === 'warn' ? `有 ${r.issues} 件事要处理` : r.level === 'bad' ? '没在跑' : '正在启动'}`;
154
161
  };
162
+ // 点通知回不到这里(Mac 的来源是系统的脚本程序;Windows 的 PowerShell 5.1 接不了 WinRT 的事件),只能请家长去点图标
163
+ const iconHint = (platform) => (platform === 'darwin' ? '点菜单栏的 cotutor 图标看看' : '点任务栏的 cotutor 图标看看');
164
+ /** 有新版本的通知:每个版本最多一次,记在托盘目录,重起、重新登录不再弹 */
165
+ export function updateNote(version, platform) {
166
+ return { type: 'notify', title: `cotutor 有新版本 ${version}`, body: iconHint(platform) };
167
+ }
155
168
  function noteFor(r, multi, platform) {
156
- // 点通知回不到这里(Mac 的来源是系统的脚本程序;Windows 的 PowerShell 5.1 接不了 WinRT 的事件),只能请家长去点图标
157
- const hint = platform === 'darwin' ? '点菜单栏的 cotutor 图标看看' : '点任务栏的 cotutor 图标看看';
169
+ const hint = iconHint(platform);
158
170
  const who = multi ? r.kid : 'cotutor ';
159
171
  if (r.level === 'bad')
160
172
  return { type: 'notify', title: `${who}${multi ? '的' : ''}后台服务没在跑`, body: `孩子的平板现在打不开,${hint}` };
@@ -190,6 +202,12 @@ export function trayShow(rows) {
190
202
  act('logs', '看日志');
191
203
  menu.push({ kind: 'sep' });
192
204
  }
205
+ // 版本是这台电脑的事,几个孩子也只说一行;点了去家长端,升级的命令写在那里
206
+ const newer = rows.find((r) => r.update);
207
+ if (newer) {
208
+ menu.push({ kind: 'action', id: `update:${newer.name}`, title: `有新版本 ${newer.update},看怎么升级` });
209
+ menu.push({ kind: 'sep' });
210
+ }
193
211
  menu.push({ kind: 'action', id: 'quit', title: '退出 cotutor' });
194
212
  return { type: 'show', icon: level === 'starting' ? 'ok' : level, tip: worst ? titleOf(worst, multi) : 'cotutor', menu };
195
213
  }
@@ -269,7 +287,7 @@ export function runTray(d) {
269
287
  const r = rows.find((x) => x.name === id.slice(at + 1));
270
288
  if (!r)
271
289
  return;
272
- if (what === 'open' && r.url)
290
+ if ((what === 'open' || what === 'update') && r.url)
273
291
  d.open(r.url);
274
292
  else if (what === 'logs' && r.logDir)
275
293
  d.open(r.logDir);
@@ -368,6 +386,13 @@ export function runTray(d) {
368
386
  helper?.send(n);
369
387
  d.note(`通知:${n.title}`);
370
388
  }
389
+ const newer = rows.find((x) => x.update)?.update;
390
+ if (newer && helper && newer !== d.updateSeen()) {
391
+ const n = updateNote(newer, d.platform);
392
+ helper.send(n);
393
+ d.setUpdateSeen(newer);
394
+ d.note(`通知:${n.title}`);
395
+ }
371
396
  }
372
397
  catch (err) {
373
398
  d.note(`这一轮出错:${String(err)}`);
@@ -446,10 +471,18 @@ export function spawnTrayHelper(platform, file, dir, note, on) {
446
471
  close() { child.stdin?.end(); },
447
472
  };
448
473
  }
449
- /** 打开地址或目录(Windows 不弹黑窗口) */
450
- export function openTarget(platform, target) {
474
+ /**
475
+ * 打开地址或目录用什么命令。windowsHide 不弹黑窗口。
476
+ * detached 不能省:Windows 上非 detached 的子进程在「父进程退出就杀」的 job 里(libuv),cotutor open、service install 起完马上退,cmd 还没把地址交给浏览器就被一起杀了。
477
+ * 退出码是 0、地址也打印了,就是没打开,也不报错(2026-11-07 真机);托盘进程一直开着,所以光点图标看不出来。
478
+ */
479
+ export function openCommand(platform, target) {
451
480
  const [cmd, args] = platform === 'darwin' ? ['/usr/bin/open', [target]] : platform === 'win32' ? ['cmd', ['/c', 'start', '', target]] : ['xdg-open', [target]];
452
- const child = spawn(cmd, args, { stdio: 'ignore', windowsHide: true });
481
+ return { cmd: cmd, args: args, options: { stdio: 'ignore', windowsHide: true, detached: true } };
482
+ }
483
+ export function openTarget(platform, target) {
484
+ const { cmd, args, options } = openCommand(platform, target);
485
+ const child = spawn(cmd, args, options);
453
486
  child.on('error', () => undefined);
454
487
  child.unref();
455
488
  }
@@ -482,9 +515,21 @@ export async function inspectService(s) {
482
515
  /** 真的依赖,给 runTray */
483
516
  export function realTrayDeps(host, dir, file, version) {
484
517
  const note = trayNote(dir);
518
+ const seenFile = join(dir, 'update-seen');
485
519
  return {
486
520
  platform: host.platform,
487
521
  version,
522
+ updateSeen: () => { try {
523
+ return readFileSync(seenFile, 'utf8').trim() || null;
524
+ }
525
+ catch {
526
+ return null;
527
+ } },
528
+ // 写不了就当没记住:最坏多弹一次
529
+ setUpdateSeen: (v) => { try {
530
+ writeFileSync(seenFile, `${v}\n`);
531
+ }
532
+ catch { /* 见上 */ } },
488
533
  note,
489
534
  now: () => Date.now(),
490
535
  list: () => listServices(host),
@@ -497,7 +542,7 @@ export function realTrayDeps(host, dir, file, version) {
497
542
  };
498
543
  }
499
544
  /** 登录项跑的命令 */
500
- export const trayArgs = (host, o = {}) => [o.node ?? process.execPath, o.bin ?? binPath(), 'tray', 'run', '--dir', trayDir(host)];
545
+ export const trayArgs = (host, o = {}) => [o.node ?? stableNode(process.execPath), o.bin ?? binPath(), 'tray', 'run', '--dir', trayDir(host)];
501
546
  /** macOS 的 LaunchAgent:登录就起,崩了拉起,以 0 退(点了退出)不拉 */
502
547
  export function trayPlist(args, dir, path) {
503
548
  const str = (v) => `<string>${xml(v)}</string>`;
@@ -555,9 +600,9 @@ export async function installTray(host, o = {}) {
555
600
  }
556
601
  const file = plistFile(host);
557
602
  await mkdir(join(host.home, 'Library', 'LaunchAgents'), { recursive: true });
558
- await host.run('launchctl', ['bootout', `gui/${host.uid}/${TRAY_LABEL}`]);
603
+ await launchdUnload(host, `gui/${host.uid}/${TRAY_LABEL}`);
559
604
  await writeFile(file, trayPlist(args, dir, host.env.PATH ?? '/usr/bin:/bin'));
560
- const r = await host.run('launchctl', ['bootstrap', `gui/${host.uid}`, file]);
605
+ const r = await launchdLoad(host, file);
561
606
  return { file, started: r.code === 0 };
562
607
  }
563
608
  /** 卸掉:停下、删登录项。remember = 家长说不要(以后 service install 也不再装) */
@@ -608,6 +653,15 @@ export async function ensureTray(host, o = {}) {
608
653
  return 'off';
609
654
  return (await installTray(host, o)).started ? 'started' : 'later';
610
655
  }
656
+ /** service restart 时:图标进程也换上新代码(升级后跑的就是这条)。没开图标的不动,不能把 --no-tray 的装回来 */
657
+ export async function refreshTray(host) {
658
+ if (!traySupported(host.platform))
659
+ return false;
660
+ if (!(await trayStatus(host)).installed)
661
+ return false;
662
+ await installTray(host);
663
+ return true;
664
+ }
611
665
  /** 服务卸完了:图标也撤(不算家长说不要,以后再装服务还会带上) */
612
666
  export async function dropTrayIfNoServices(host) {
613
667
  if (!traySupported(host.platform))
@@ -0,0 +1,38 @@
1
+ import { type Release } from '../machine/update.ts';
2
+ import { type Health } from './service.ts';
3
+ export interface UpdateService {
4
+ name: string;
5
+ kid: string;
6
+ root: string;
7
+ port: number;
8
+ node: string;
9
+ health: Health | null;
10
+ }
11
+ export interface UpdateDeps {
12
+ current: string;
13
+ /** 从仓库跑的:版本跟着 git 走,npm 不管 */
14
+ fromRepo: boolean;
15
+ nodeVersion: string;
16
+ release(): Promise<Release | null>;
17
+ services(): Promise<UpdateService[]>;
18
+ health(port: number): Promise<Health | null>;
19
+ npm(spec: string, registry: string | null): Promise<{
20
+ code: number;
21
+ out: string;
22
+ }>;
23
+ /** 现在装着的版本:读文件,npm 刚换过 */
24
+ installed(): string | null;
25
+ /** 跑新的 bin */
26
+ cli(args: string[]): Promise<{
27
+ code: number;
28
+ out: string;
29
+ }>;
30
+ /** 没有后台服务时,在终端里跑 serve 的那个孩子 */
31
+ workspace(): string | null;
32
+ stableNode(path: string): string;
33
+ print(line: string): void;
34
+ }
35
+ export declare function runUpdate(d: UpdateDeps, o?: {
36
+ anyway?: boolean;
37
+ }): Promise<number>;
38
+ export declare function realUpdateDeps(workspace: () => string | null): UpdateDeps;