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.
- package/README.md +39 -8
- package/dist/app/cli/cert.d.ts +6 -0
- package/dist/app/cli/cert.js +16 -2
- package/dist/app/cli/doctor.d.ts +5 -0
- package/dist/app/cli/doctor.js +65 -17
- package/dist/app/cli/init.js +1 -1
- package/dist/app/cli/main.js +58 -17
- package/dist/app/cli/serve.d.ts +0 -1
- package/dist/app/cli/serve.js +5 -0
- package/dist/app/cli/service.d.ts +40 -0
- package/dist/app/cli/service.js +50 -5
- package/dist/app/cli/tray-run.js +11 -2
- package/dist/app/cli/tray.d.ts +35 -4
- package/dist/app/cli/tray.js +67 -13
- package/dist/app/cli/update-cmd.d.ts +38 -0
- package/dist/app/cli/update-cmd.js +174 -0
- package/dist/app/cli/usage.d.ts +1 -1
- package/dist/app/cli/usage.js +4 -3
- package/dist/app/http/app.d.ts +2 -0
- package/dist/app/http/app.js +19 -2
- package/dist/app/machine/bailian.d.ts +26 -0
- package/dist/app/machine/bailian.js +91 -4
- package/dist/app/machine/update.d.ts +38 -0
- package/dist/app/machine/update.js +109 -0
- package/dist/brain/api.js +6 -3
- package/dist/realtime/preview.d.ts +16 -0
- package/dist/realtime/preview.js +43 -1
- package/dist/runtime/live/call.d.ts +2 -0
- package/dist/runtime/live/call.js +6 -1
- package/dist/shared/upgrade.d.ts +9 -0
- package/dist/shared/upgrade.js +23 -0
- package/dist/ui/kid/kid-page.js +8 -2
- package/dist/ui/parent/parent-home-page.js +47 -2
- package/package.json +11 -2
- package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +1 -1
- package/skills/cotutor-tune/references//345/255/227/346/256/265.md +1 -1
package/dist/app/cli/serve.js
CHANGED
|
@@ -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';
|
package/dist/app/cli/service.js
CHANGED
|
@@ -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
|
|
318
|
+
await launchdUnload(host, `gui/${host.uid}/${label}`);
|
|
274
319
|
await writeFile(file, launchdPlist(spec));
|
|
275
|
-
const r = await host
|
|
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
|
|
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
|
|
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;
|
package/dist/app/cli/tray-run.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/app/cli/tray.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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>;
|
package/dist/app/cli/tray.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
/**
|
|
450
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|