cotutor 0.3.0 → 0.4.0
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/LICENSE +9 -7
- package/NOTICE +3 -3
- package/README.md +36 -13
- package/avatars/chinese.webp +0 -0
- package/avatars/english.webp +0 -0
- package/avatars/math.webp +0 -0
- package/bin/cotutor.js +13 -3
- package/cards/photo/card.css +10 -3
- package/cards/photo/card.md +2 -2
- package/cards/tutor/card.css +13 -0
- package/cards/tutor/card.md +2 -1
- package/cards//346/235/277/344/271/246/346/200/216/344/271/210/345/206/231.md +1 -1
- package/dist/app/activity-eval.js +3 -2
- package/dist/app/cli/activities.d.ts +8 -1
- package/dist/app/cli/activities.js +42 -4
- package/dist/app/cli/cert.d.ts +5 -0
- package/dist/app/cli/cert.js +20 -7
- package/dist/app/cli/chalk/browser.d.ts +2 -2
- package/dist/app/cli/chalk/browser.js +22 -7
- package/dist/app/cli/chalk/main.js +3 -2
- package/dist/app/cli/doctor.d.ts +6 -5
- package/dist/app/cli/doctor.js +107 -171
- package/dist/app/cli/firewall.d.ts +24 -0
- package/dist/app/cli/firewall.js +79 -0
- package/dist/app/cli/init.js +8 -5
- package/dist/app/cli/main.js +347 -24
- package/dist/app/cli/migrate.d.ts +2 -11
- package/dist/app/cli/migrate.js +1 -59
- package/dist/app/cli/port-holder.d.ts +16 -0
- package/dist/app/cli/port-holder.js +34 -0
- package/dist/app/cli/port.d.ts +37 -0
- package/dist/app/cli/port.js +116 -0
- package/dist/app/cli/sandbox.d.ts +25 -0
- package/dist/app/cli/sandbox.js +166 -0
- package/dist/app/cli/serve-log.d.ts +13 -0
- package/dist/app/cli/serve-log.js +90 -0
- package/dist/app/cli/serve.d.ts +18 -4
- package/dist/app/cli/serve.js +78 -8
- package/dist/app/cli/service-windows.d.ts +50 -0
- package/dist/app/cli/service-windows.js +117 -0
- package/dist/app/cli/service.d.ts +138 -0
- package/dist/app/cli/service.js +429 -0
- package/dist/app/cli/skeleton.d.ts +1 -1
- package/dist/app/cli/skeleton.js +13 -13
- package/dist/app/cli/skills.d.ts +5 -1
- package/dist/app/cli/skills.js +14 -4
- package/dist/app/cli/tray-icons.d.ts +11 -0
- package/dist/app/cli/tray-icons.js +87 -0
- package/dist/app/cli/tray-ids.d.ts +6 -0
- package/dist/app/cli/tray-ids.js +6 -0
- package/dist/app/cli/tray-mac.d.ts +8 -0
- package/dist/app/cli/tray-mac.js +140 -0
- package/dist/app/cli/tray-run.d.ts +1 -0
- package/dist/app/cli/tray-run.js +33 -0
- package/dist/app/cli/tray-windows.d.ts +5 -0
- package/dist/app/cli/tray-windows.js +166 -0
- package/dist/app/cli/tray.d.ts +205 -0
- package/dist/app/cli/tray.js +618 -0
- package/dist/app/cli/usage.d.ts +1 -1
- package/dist/app/cli/usage.js +11 -6
- package/dist/app/dictation/route.js +3 -2
- package/dist/app/http/app.d.ts +5 -10
- package/dist/app/http/app.js +45 -98
- package/dist/app/http/avatars.d.ts +17 -0
- package/dist/app/http/avatars.js +35 -0
- package/dist/app/http/dev-tutor-files.js +1 -1
- package/dist/app/http/parent-home.d.ts +14 -0
- package/dist/app/http/parent-home.js +69 -6
- package/dist/app/http/parent-look.d.ts +2 -0
- package/dist/app/http/parent-look.js +151 -0
- package/dist/app/http/raw-view.js +3 -3
- package/dist/app/http/voice-preview.d.ts +4 -0
- package/dist/app/http/voice-preview.js +34 -0
- package/dist/app/machine/bailian.d.ts +0 -2
- package/dist/app/machine/bailian.js +5 -6
- package/dist/app/mock/mock-live.js +1 -1
- package/dist/app/mock/mock.d.ts +4 -2
- package/dist/app/mock/mock.js +20 -12
- package/dist/app/replay.js +4 -4
- package/dist/app/skills/home-doc.js +1 -1
- package/dist/app/workspace.d.ts +9 -4
- package/dist/app/workspace.js +33 -10
- package/dist/app/ws/route.d.ts +8 -2
- package/dist/app/ws/route.js +7 -3
- package/dist/board/parse.d.ts +0 -2
- package/dist/board/parse.js +0 -6
- package/dist/board/presentation.d.ts +9 -15
- package/dist/board/presentation.js +8 -10
- package/dist/brain/api-tools.js +2 -1
- package/dist/brain/api.d.ts +12 -17
- package/dist/brain/api.js +16 -33
- package/dist/brain/index.d.ts +23 -71
- package/dist/brain/index.js +6 -69
- package/dist/brain/log.d.ts +5 -0
- package/dist/brain/log.js +22 -0
- package/dist/brain/runtime.d.ts +30 -0
- package/dist/brain/runtime.js +23 -0
- package/dist/brain/{cli/stream-json.d.ts → stream-json.d.ts} +2 -4
- package/dist/brain/{cli/stream-json.js → stream-json.js} +0 -4
- package/dist/brain/stream.d.ts +5 -5
- package/dist/brain/stream.js +7 -7
- package/dist/brain/transcript.d.ts +3 -3
- package/dist/brain/transcript.js +6 -6
- package/dist/brain/{cli/types.d.ts → types.d.ts} +3 -48
- package/dist/brain/types.js +14 -0
- package/dist/chalk/glyphs/skeleton.d.ts +3 -3
- package/dist/chalk/glyphs/skeleton.js +1 -1
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/index.js +1 -1
- package/dist/memory/diary.js +3 -3
- package/dist/realtime/bailian.d.ts +1 -1
- package/dist/realtime/bailian.js +3 -2
- package/dist/realtime/preview.js +1 -1
- package/dist/realtime/voices.d.ts +2 -0
- package/dist/realtime/voices.js +57 -57
- package/dist/runtime/activities.d.ts +13 -1
- package/dist/runtime/activities.js +28 -2
- package/dist/runtime/activity-state.js +2 -2
- package/dist/runtime/agent-file.d.ts +3 -3
- package/dist/runtime/agent-file.js +3 -3
- package/dist/runtime/context/tutor-rules.d.ts +2 -2
- package/dist/runtime/context/tutor-rules.js +3 -3
- package/dist/runtime/home/home.d.ts +12 -1
- package/dist/runtime/home/home.js +8 -1
- package/dist/runtime/home/store.d.ts +1 -1
- package/dist/runtime/home/store.js +36 -21
- package/dist/runtime/journal/mix.d.ts +15 -6
- package/dist/runtime/journal/mix.js +60 -19
- package/dist/runtime/journal/recorder.d.ts +58 -13
- package/dist/runtime/journal/recorder.js +106 -16
- package/dist/runtime/journal/store.d.ts +4 -4
- package/dist/runtime/journal/store.js +6 -6
- package/dist/runtime/kid-input.js +2 -3
- package/dist/runtime/live/call.d.ts +12 -0
- package/dist/runtime/live/call.js +51 -18
- package/dist/runtime/live/turns.js +1 -1
- package/dist/runtime/runner.d.ts +9 -55
- package/dist/runtime/runner.js +52 -173
- package/dist/scenes/paint/painter.js +4 -3
- package/dist/schema/config.d.ts +43 -22
- package/dist/schema/config.js +45 -47
- package/dist/schema/conversation.d.ts +6 -0
- package/dist/schema/home.d.ts +11 -1
- package/dist/schema/home.js +7 -2
- package/dist/schema/workspace.d.ts +27 -0
- package/dist/schema/workspace.js +28 -0
- package/dist/shared/default-port.d.ts +2 -0
- package/dist/shared/default-port.js +2 -0
- package/dist/shared/qianwen.d.ts +14 -10
- package/dist/shared/qianwen.js +26 -22
- package/dist/shared/site-url.d.ts +2 -0
- package/dist/shared/site-url.js +5 -0
- package/dist/ui/dev/dev-page.js +25 -252
- package/dist/ui/dev/dev-theme.d.ts +1 -1
- package/dist/ui/dev/dev-theme.js +1 -1
- package/dist/ui/dev/voice-gallery-page.js +1 -1
- package/dist/ui/kid/board-mark.d.ts +2 -2
- package/dist/ui/kid/board-mark.js +2 -2
- package/dist/ui/kid/home-tiles.d.ts +14 -0
- package/dist/ui/kid/home-tiles.js +44 -0
- package/dist/ui/kid/kid-page.js +68 -103
- package/dist/ui/kid/voice-ui.d.ts +1 -1
- package/dist/ui/kid/voice-ui.js +1 -1
- package/dist/ui/kid/word-emoji.js +5 -3
- package/dist/ui/parent/parent-home-page.js +315 -7
- package/dist/ui/parent/parent-page.js +6 -2
- package/dist/ui/parent/parent-settings-page.d.ts +1 -0
- package/dist/ui/parent/parent-settings-page.js +133 -0
- package/dist/ui/{dev → parent}/qr-page.d.ts +2 -2
- package/dist/ui/{dev → parent}/qr-page.js +19 -14
- package/dist/ui/shared/brand.d.ts +3 -1
- package/dist/ui/shared/brand.js +3 -1
- package/grades/grade-2/README.md +1 -1
- package/grades/grade-2/VALIDATION.md +1 -1
- package/grades/grade-2/agents/chinese-tutor/activities//345/244/215/344/271/240.md +1 -1
- package/grades/grade-2/agents/chinese-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
- package/grades/grade-2/agents/chinese-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
- package/grades/grade-2/agents/chinese-tutor/activities//351/242/204/344/271/240.md +1 -1
- package/grades/grade-2/agents/english-tutor/activities//345/244/215/344/271/240.md +1 -1
- package/grades/grade-2/agents/english-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
- package/grades/grade-2/agents/english-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
- package/grades/grade-2/agents/english-tutor/activities//351/242/204/344/271/240.md +1 -1
- package/grades/grade-2/agents/math-tutor/activities//345/244/215/344/271/240.md +1 -1
- package/grades/grade-2/agents/math-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
- package/grades/grade-2/agents/math-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
- package/grades/grade-2/agents/math-tutor/activities//351/242/204/344/271/240.md +1 -1
- package/package.json +2 -1
- package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +2 -2
- package/skills/cotutor-board/references/photo.md +2 -2
- package/skills/cotutor-home/SKILL.md +1 -0
- package/skills/cotutor-home/references/tutor.md +2 -1
- package/skills/cotutor-home/references//351/246/226/351/241/265/350/257/255/346/263/225.md +2 -1
- package/skills/cotutor-tune/references//345/255/227/346/256/265.md +6 -3
- package/skills/cotutor-tutor/SKILL.md +5 -13
- package/dist/brain/cli/adapters.d.ts +0 -60
- package/dist/brain/cli/adapters.js +0 -91
- package/dist/brain/cli/qwen.d.ts +0 -11
- package/dist/brain/cli/qwen.js +0 -141
- package/dist/brain/cli/run-plan.d.ts +0 -59
- package/dist/brain/cli/run-plan.js +0 -71
- package/dist/brain/cli/run.d.ts +0 -35
- package/dist/brain/cli/run.js +0 -256
- package/dist/brain/cli/types.js +0 -3
- package/dist/brain/cli/warm.d.ts +0 -53
- package/dist/brain/cli/warm.js +0 -103
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 托盘图标的 PNG:现画,不往仓里塞图片文件(同 favicon.ts);几何都来自 logo.ts 的双 C,16、18 px 这种小尺寸用小号那档。
|
|
3
|
+
* 正常时只有 logo;有事在右边缺口放状态点,橙 = 要处理,红 = 没在跑(同时 logo 变淡,不只靠颜色区分)。
|
|
4
|
+
* macOS:logo 一张模板图(系统按菜单栏深浅着色),状态点一张模板图(叠在上面单独着色),18 pt @2x
|
|
5
|
+
* Windows:没有底色,white 给深色任务栏、ink 给浅色任务栏,每种状态各画几档尺寸,按系统要的尺寸挑;变淡后对比度要过 3:1
|
|
6
|
+
* 菜单第一行的小圆点两边共用。
|
|
7
|
+
*/
|
|
8
|
+
import { LOGO_INK, LOGO_SMALL, logoHit } from "../../ui/shared/logo.js";
|
|
9
|
+
import { encodePng } from "../../ui/shared/png.js";
|
|
10
|
+
const rgb = (hex) => {
|
|
11
|
+
const v = parseInt(hex.slice(1), 16);
|
|
12
|
+
return [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff];
|
|
13
|
+
};
|
|
14
|
+
export const TRAY_DOT = { r: 14, cx: 85.5 };
|
|
15
|
+
/** 没在跑时 logo 的不透明度(#F3F3F3 上墨色对比度 3.3,#202020 上白色 5.8) */
|
|
16
|
+
export const TRAY_DIM = 0.55;
|
|
17
|
+
export const TRAY_WIN_SIZES = [16, 20, 24, 32, 40, 48];
|
|
18
|
+
const SS = 8;
|
|
19
|
+
/** at 返回这一点的颜色与不透明度,null = 透明 */
|
|
20
|
+
function draw(n, at) {
|
|
21
|
+
const px = Buffer.alloc(n * n * 4);
|
|
22
|
+
const unit = 100 / n;
|
|
23
|
+
for (let y = 0; y < n; y++) {
|
|
24
|
+
for (let x = 0; x < n; x++) {
|
|
25
|
+
let r = 0;
|
|
26
|
+
let g = 0;
|
|
27
|
+
let b = 0;
|
|
28
|
+
let a = 0;
|
|
29
|
+
for (let sy = 0; sy < SS; sy++) {
|
|
30
|
+
for (let sx = 0; sx < SS; sx++) {
|
|
31
|
+
const p = at((x + (sx + 0.5) / SS) * unit, (y + (sy + 0.5) / SS) * unit);
|
|
32
|
+
if (!p)
|
|
33
|
+
continue;
|
|
34
|
+
r += p.c[0] * p.a;
|
|
35
|
+
g += p.c[1] * p.a;
|
|
36
|
+
b += p.c[2] * p.a;
|
|
37
|
+
a += p.a;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (a <= 0)
|
|
41
|
+
continue;
|
|
42
|
+
const off = (y * n + x) * 4;
|
|
43
|
+
px[off] = Math.round(r / a);
|
|
44
|
+
px[off + 1] = Math.round(g / a);
|
|
45
|
+
px[off + 2] = Math.round(b / a);
|
|
46
|
+
px[off + 3] = Math.round((255 * a) / (SS * SS));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return encodePng(n, px, 4);
|
|
50
|
+
}
|
|
51
|
+
const inDot = (u, v) => Math.hypot(u - TRAY_DOT.cx, v - 50) <= TRAY_DOT.r;
|
|
52
|
+
const BLACK = [0, 0, 0];
|
|
53
|
+
/** 菜单里的小圆点(10 pt @2x) */
|
|
54
|
+
const MENU_DOT = { ok: '#1F9D55', warn: '#E8830C', bad: '#D92D20' };
|
|
55
|
+
const menuDot = (hex) => draw(20, (u, v) => (Math.hypot(u - 50, v - 50) <= 42 ? { c: rgb(hex), a: 1 } : null));
|
|
56
|
+
/** 文件名 → 内容:macOS 那几张 */
|
|
57
|
+
export function macTrayIcons() {
|
|
58
|
+
const out = {
|
|
59
|
+
'logo.png': draw(36, (u, v) => (logoHit(u, v, LOGO_SMALL) ? { c: BLACK, a: 1 } : null)),
|
|
60
|
+
'dotmark.png': draw(36, (u, v) => (inDot(u, v) ? { c: BLACK, a: 1 } : null)),
|
|
61
|
+
};
|
|
62
|
+
for (const [s, c] of Object.entries(MENU_DOT))
|
|
63
|
+
out[`dot-${s}.png`] = menuDot(c);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
const WIN_THEMES = {
|
|
67
|
+
white: { fg: [255, 255, 255], warn: rgb('#FFA938'), bad: rgb('#FF5A4F') },
|
|
68
|
+
ink: { fg: rgb(LOGO_INK), warn: rgb('#C96A00'), bad: rgb('#D92D20') },
|
|
69
|
+
};
|
|
70
|
+
/** 文件名 → 内容:Windows 那几张(<white|ink>-<ok|warn|bad>-<尺寸>.png、dot-<状态>.png) */
|
|
71
|
+
export function windowsTrayIcons() {
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const [name, t] of Object.entries(WIN_THEMES)) {
|
|
74
|
+
for (const s of ['ok', 'warn', 'bad']) {
|
|
75
|
+
for (const n of TRAY_WIN_SIZES) {
|
|
76
|
+
out[`${name}-${s}-${n}.png`] = draw(n, (u, v) => {
|
|
77
|
+
if (s !== 'ok' && inDot(u, v))
|
|
78
|
+
return { c: t[s], a: 1 };
|
|
79
|
+
return logoHit(u, v, LOGO_SMALL) ? { c: t.fg, a: s === 'bad' ? TRAY_DIM : 1 } : null;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const [s, c] of Object.entries(MENU_DOT))
|
|
85
|
+
out[`dot-${s}.png`] = menuDot(c);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** 托盘的几个名字。不和后台服务撞:服务的 Label 是 com.tarslab.cotutor.<名>、Run 项是 cotutor-<名>,workspace 叫 tray 也不会认错 */
|
|
2
|
+
export declare const TRAY_LABEL = "com.tarslab.cotutor-tray";
|
|
3
|
+
export declare const TRAY_RUN_VALUE = "cotutorTray";
|
|
4
|
+
export declare const TRAY_AUMID = "cotutor.tray";
|
|
5
|
+
/** 显示脚本发现没有桌面(ssh 进来的会话)时的退出码:node 不再起它 */
|
|
6
|
+
export declare const HELPER_NO_SESSION = 3;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** 托盘的几个名字。不和后台服务撞:服务的 Label 是 com.tarslab.cotutor.<名>、Run 项是 cotutor-<名>,workspace 叫 tray 也不会认错 */
|
|
2
|
+
export const TRAY_LABEL = 'com.tarslab.cotutor-tray';
|
|
3
|
+
export const TRAY_RUN_VALUE = 'cotutorTray';
|
|
4
|
+
export const TRAY_AUMID = 'cotutor.tray';
|
|
5
|
+
/** 显示脚本发现没有桌面(ssh 进来的会话)时的退出码:node 不再起它 */
|
|
6
|
+
export const HELPER_NO_SESSION = 3;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS 菜单栏图标的显示脚本:osascript -l JavaScript 跑,系统自带,不用签名、不用装东西(tray.ts 每次起来写到托盘目录再跑)。
|
|
3
|
+
* 这里只管画和回报,不判断状态:从 stdin 收一行一条 JSON(show / notify / confirm / inspect / press / quit),从 stdout 回一行一条 JSON(ready / click / confirm / state)。
|
|
4
|
+
* stdin 断了就退:看护它的 node 没了,图标也不该留着。
|
|
5
|
+
* JXA 的坑(2026-11-06 在 macOS 27.2 上试的):$.NSAttributedString.alloc.initWithStringAttributes 不存在,要 NSMutableAttributedString.alloc.init 再填;枚举常量取不到值,写数字(NSImageScaleProportionallyUpOrDown = 3);返回整数的方法给的是字符串,比较前要 Number()。
|
|
6
|
+
* 状态点是叠在按钮上的一层,系统的模板图才能继续跟着菜单栏深浅变;试过不挡点击。
|
|
7
|
+
*/
|
|
8
|
+
export declare const MAC_TRAY_SCRIPT: string;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS 菜单栏图标的显示脚本:osascript -l JavaScript 跑,系统自带,不用签名、不用装东西(tray.ts 每次起来写到托盘目录再跑)。
|
|
3
|
+
* 这里只管画和回报,不判断状态:从 stdin 收一行一条 JSON(show / notify / confirm / inspect / press / quit),从 stdout 回一行一条 JSON(ready / click / confirm / state)。
|
|
4
|
+
* stdin 断了就退:看护它的 node 没了,图标也不该留着。
|
|
5
|
+
* JXA 的坑(2026-11-06 在 macOS 27.2 上试的):$.NSAttributedString.alloc.initWithStringAttributes 不存在,要 NSMutableAttributedString.alloc.init 再填;枚举常量取不到值,写数字(NSImageScaleProportionallyUpOrDown = 3);返回整数的方法给的是字符串,比较前要 Number()。
|
|
6
|
+
* 状态点是叠在按钮上的一层,系统的模板图才能继续跟着菜单栏深浅变;试过不挡点击。
|
|
7
|
+
*/
|
|
8
|
+
export const MAC_TRAY_SCRIPT = String.raw `ObjC.import('Cocoa');
|
|
9
|
+
const app = Application.currentApplication();
|
|
10
|
+
app.includeStandardAdditions = true;
|
|
11
|
+
const dir = $.NSProcessInfo.processInfo.environment.objectForKey('COTUTOR_TRAY_DIR').js;
|
|
12
|
+
const stdout = $.NSFileHandle.fileHandleWithStandardOutput;
|
|
13
|
+
const stdin = $.NSFileHandle.fileHandleWithStandardInput;
|
|
14
|
+
const emit = (o) => stdout.writeData($(JSON.stringify(o) + '\n').dataUsingEncoding($.NSUTF8StringEncoding));
|
|
15
|
+
let item, menu, tray, dotView, current = 'ok', pending = $.NSMutableData.alloc.init;
|
|
16
|
+
let ids = [];
|
|
17
|
+
|
|
18
|
+
const image = (name, size, template) => {
|
|
19
|
+
const i = $.NSImage.alloc.initWithContentsOfFile($(dir + '/icons/' + name));
|
|
20
|
+
i.setSize($.NSMakeSize(size, size));
|
|
21
|
+
if (template) i.setTemplate(true);
|
|
22
|
+
return i;
|
|
23
|
+
};
|
|
24
|
+
const styled = (text, font, color) => {
|
|
25
|
+
const t = $.NSMutableAttributedString.alloc.init;
|
|
26
|
+
t.replaceCharactersInRangeWithString($.NSMakeRange(0, 0), $(text));
|
|
27
|
+
t.setAttributesRange($.NSDictionary.dictionaryWithObjectsForKeys($([font, color]), $([$.NSFontAttributeName, $.NSForegroundColorAttributeName])), $.NSMakeRange(0, text.length));
|
|
28
|
+
return t;
|
|
29
|
+
};
|
|
30
|
+
function center() {
|
|
31
|
+
const b = item.button.bounds;
|
|
32
|
+
dotView.frame = $.NSMakeRect((b.size.width - 18) / 2, (b.size.height - 18) / 2, 18, 18);
|
|
33
|
+
}
|
|
34
|
+
function fill(entries) {
|
|
35
|
+
menu.removeAllItems;
|
|
36
|
+
ids = [];
|
|
37
|
+
for (const e of entries) {
|
|
38
|
+
if (e.kind === 'sep') { menu.addItem($.NSMenuItem.separatorItem); continue; }
|
|
39
|
+
const m = $.NSMenuItem.alloc.initWithTitleActionKeyEquivalent($(e.title), e.kind === 'action' ? 'clicked:' : null, $(''));
|
|
40
|
+
if (e.kind === 'action') {
|
|
41
|
+
m.target = tray;
|
|
42
|
+
m.enabled = true;
|
|
43
|
+
// 标号从 1 起:别的行缺省是 0,按标号找会找到它们
|
|
44
|
+
ids.push(e.id);
|
|
45
|
+
m.tag = ids.length;
|
|
46
|
+
if (e.primary) m.attributedTitle = styled(e.title, $.NSFont.boldSystemFontOfSize(13), $.NSColor.labelColor);
|
|
47
|
+
} else if (e.kind === 'info') {
|
|
48
|
+
m.enabled = false;
|
|
49
|
+
m.attributedTitle = styled(e.title, $.NSFont.boldSystemFontOfSize(13), $.NSColor.labelColor);
|
|
50
|
+
if (e.level) m.image = image('dot-' + e.level + '.png', 10, false);
|
|
51
|
+
} else {
|
|
52
|
+
m.enabled = false;
|
|
53
|
+
m.attributedTitle = styled(e.title, $.NSFont.systemFontOfSize(11), $.NSColor.secondaryLabelColor);
|
|
54
|
+
}
|
|
55
|
+
menu.addItem(m);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function show(m) {
|
|
59
|
+
current = m.icon;
|
|
60
|
+
dotView.setHidden(m.icon === 'ok');
|
|
61
|
+
dotView.contentTintColor = m.icon === 'warn' ? $.NSColor.systemOrangeColor : $.NSColor.systemRedColor;
|
|
62
|
+
item.button.appearsDisabled = m.icon === 'bad';
|
|
63
|
+
item.button.toolTip = $(m.tip);
|
|
64
|
+
fill(m.menu);
|
|
65
|
+
center();
|
|
66
|
+
}
|
|
67
|
+
function quit() {
|
|
68
|
+
$.NSStatusBar.systemStatusBar.removeStatusItem(item);
|
|
69
|
+
$.NSApp.terminate(null);
|
|
70
|
+
}
|
|
71
|
+
function confirm(m) {
|
|
72
|
+
const a = $.NSAlert.alloc.init;
|
|
73
|
+
a.messageText = $(m.title);
|
|
74
|
+
a.informativeText = $(m.body);
|
|
75
|
+
// 第一个按钮是回车默认的那个:默认不退出
|
|
76
|
+
a.addButtonWithTitle($(m.no));
|
|
77
|
+
a.addButtonWithTitle($(m.yes));
|
|
78
|
+
$.NSApp.activateIgnoringOtherApps(true);
|
|
79
|
+
// 桥接把整数返回成字符串("1001"),不转就永远不等
|
|
80
|
+
emit({ type: 'confirm', id: m.id, yes: Number(a.runModal) === 1001 });
|
|
81
|
+
}
|
|
82
|
+
function handle(m) {
|
|
83
|
+
if (m.type === 'show') show(m);
|
|
84
|
+
else if (m.type === 'notify') { try { app.displayNotification(m.body, { withTitle: m.title }); } catch (e) { emit({ type: 'error', message: String(e) }); } }
|
|
85
|
+
else if (m.type === 'confirm') confirm(m);
|
|
86
|
+
else if (m.type === 'quit') quit();
|
|
87
|
+
else if (m.type === 'inspect') {
|
|
88
|
+
const titles = [];
|
|
89
|
+
for (let i = 0; i < menu.numberOfItems; i++) titles.push(menu.itemAtIndex(i).title.js);
|
|
90
|
+
emit({ type: 'state', icon: current, dot: !dotView.isHidden, dim: item.button.appearsDisabled, visible: (item.button.window.occlusionState & 2) === 2, appearance: item.button.effectiveAppearance.name.js, menu: titles });
|
|
91
|
+
} else if (m.type === 'press') {
|
|
92
|
+
const n = ids.indexOf(m.id);
|
|
93
|
+
if (n >= 0) { const at = menu.indexOfItemWithTag(n + 1); if (at >= 0) menu.performActionForItemAtIndex(at); }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function received() {
|
|
97
|
+
const data = stdin.availableData;
|
|
98
|
+
if (Number(data.length) === 0) { quit(); return; }
|
|
99
|
+
pending.appendData(data);
|
|
100
|
+
const text = $.NSString.alloc.initWithDataEncoding(pending, $.NSUTF8StringEncoding);
|
|
101
|
+
// 一个汉字被切在两块之间时解不出来:等下一块
|
|
102
|
+
if (!text.isNil()) {
|
|
103
|
+
const lines = text.js.split('\n');
|
|
104
|
+
const rest = lines.pop();
|
|
105
|
+
pending = $.NSMutableData.alloc.init;
|
|
106
|
+
if (rest) pending.appendData($(rest).dataUsingEncoding($.NSUTF8StringEncoding));
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
if (!line) continue;
|
|
109
|
+
try { handle(JSON.parse(line)); } catch (e) { emit({ type: 'error', message: String(e) }); }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
stdin.waitForDataInBackgroundAndNotify;
|
|
113
|
+
}
|
|
114
|
+
ObjC.registerSubclass({
|
|
115
|
+
name: 'CotutorTray',
|
|
116
|
+
methods: {
|
|
117
|
+
'clicked:': { types: ['void', ['id']], implementation: function (sender) { emit({ type: 'click', id: ids[Number(sender.tag) - 1] }); } },
|
|
118
|
+
'received:': { types: ['void', ['id']], implementation: function () { received(); } },
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const nsapp = $.NSApplication.sharedApplication;
|
|
122
|
+
nsapp.setActivationPolicy($.NSApplicationActivationPolicyAccessory);
|
|
123
|
+
tray = $.CotutorTray.alloc.init;
|
|
124
|
+
item = $.NSStatusBar.systemStatusBar.statusItemWithLength(28);
|
|
125
|
+
item.button.image = image('logo.png', 18, true);
|
|
126
|
+
item.button.setAccessibilityLabel($('cotutor'));
|
|
127
|
+
dotView = $.NSImageView.alloc.initWithFrame($.NSMakeRect(5, 2, 18, 18));
|
|
128
|
+
dotView.image = image('dotmark.png', 18, true);
|
|
129
|
+
dotView.imageScaling = 3;
|
|
130
|
+
dotView.setHidden(true);
|
|
131
|
+
item.button.addSubview(dotView);
|
|
132
|
+
menu = $.NSMenu.alloc.init;
|
|
133
|
+
menu.autoenablesItems = false;
|
|
134
|
+
item.menu = menu;
|
|
135
|
+
center();
|
|
136
|
+
$.NSNotificationCenter.defaultCenter.addObserverSelectorNameObject(tray, 'received:', $.NSFileHandleDataAvailableNotification, stdin);
|
|
137
|
+
stdin.waitForDataInBackgroundAndNotify;
|
|
138
|
+
emit({ type: 'ready' });
|
|
139
|
+
nsapp.run;
|
|
140
|
+
`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(argv: string[]): Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cotutor tray run 的轻入口:bin/cotutor.js 直接到这里,不经 main.ts。
|
|
3
|
+
* 托盘进程一直开着,main.ts 把所有命令都加载进来要多占近一百 MB(2026-11-06 量的,源码直跑 140 MB 对 63 MB);这里和 tray.ts 引的东西都不带 zod。
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { realHost } from "./service.js";
|
|
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;
|
|
10
|
+
export async function main(argv) {
|
|
11
|
+
const host = realHost();
|
|
12
|
+
if (!traySupported(host.platform)) {
|
|
13
|
+
process.stderr.write(`${host.platform} 上没做托盘图标\n`);
|
|
14
|
+
process.exitCode = 0;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const at = argv.indexOf('--dir');
|
|
18
|
+
const dir = at >= 0 && argv[at + 1] ? argv[at + 1] : trayDir(host);
|
|
19
|
+
mkdirSync(dir, { recursive: true });
|
|
20
|
+
const note = trayNote(dir);
|
|
21
|
+
// 没接住的错误:记一笔再以非 0 退(macOS 上 launchd 隔 10 秒再起)
|
|
22
|
+
const fatal = (what) => (err) => {
|
|
23
|
+
note(`${what}:${err instanceof Error ? err.stack : String(err)}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
};
|
|
26
|
+
process.on('uncaughtException', fatal('没接住的错误'));
|
|
27
|
+
process.on('unhandledRejection', fatal('没接住的 Promise 错误'));
|
|
28
|
+
const file = await writeTrayFiles(host.platform, dir);
|
|
29
|
+
note(`起来了,node ${process.versions.node},pid ${process.pid}`);
|
|
30
|
+
const code = await runTray(realTrayDeps(host, dir, file, version()));
|
|
31
|
+
note(`退出 ${code}`);
|
|
32
|
+
process.exit(code);
|
|
33
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const WINDOWS_TRAY_SCRIPT: string;
|
|
2
|
+
/** 找这台电脑上这个用户的托盘进程(命令行里带 tray run 和这个用户自己的托盘目录) */
|
|
3
|
+
export declare function findTrayScript(dir: string): string;
|
|
4
|
+
/** 启动器:wscript 跑它,藏着窗口起 node(和后台服务同一个办法,见 service-windows.ts) */
|
|
5
|
+
export declare function trayLauncher(commandLine: string): string;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows 托盘图标的显示脚本:Windows PowerShell + WinForms 的 NotifyIcon,系统自带,不用装东西(tray.ts 每次起来写到托盘目录再跑,存 UTF-8 带 BOM,不然中文按系统代码页读)。
|
|
3
|
+
* 这里只管画和回报,不判断状态:从 stdin 收一行一条 JSON(show / notify / confirm / inspect / press / quit),从 stdout 回一行一条 JSON(ready / click / confirm / state)。
|
|
4
|
+
* stdin 断了就退:看护它的 node 没了,图标也不该留着。没有桌面的会话(ssh 进来)里图标没人看得见,以 3 退,node 也不再起。
|
|
5
|
+
* 深浅自己看:每两秒读一次 SystemUsesLightTheme(任务栏跟它走,不是 AppsUseLightTheme;在深色任务栏上用白色 logo,浅色用墨色)。
|
|
6
|
+
* 通知走 WinRT 的 ToastNotifier + 注册的应用 ID,来源才写 cotutor;直接用 NotifyIcon 的气泡,来源是 Windows PowerShell(2026-11-06 build 26300 上都试过)。不行就退回气泡。
|
|
7
|
+
* 点通知没有动作:PowerShell 5.1 接不了 WinRT 的事件(Register-ObjectEvent 报 INVALID_REGISTRATION,脚本块直接接又在别的线程上没有 runspace),所以文案同 macOS,请家长点图标。
|
|
8
|
+
*/
|
|
9
|
+
import { TRAY_AUMID } from "./tray-ids.js";
|
|
10
|
+
export const WINDOWS_TRAY_SCRIPT = String.raw `Add-Type -AssemblyName System.Windows.Forms, System.Drawing
|
|
11
|
+
$dir = $env:COTUTOR_TRAY_DIR
|
|
12
|
+
$aumid = '${TRAY_AUMID}'
|
|
13
|
+
if ((Get-Process -Id $PID).SessionId -eq 0) { exit 3 }
|
|
14
|
+
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
|
15
|
+
# stderr 进 tray.log:不设的话是系统代码页,中文 Windows 上读出来是乱码
|
|
16
|
+
try { [Console]::OutputEncoding = $utf8 } catch { }
|
|
17
|
+
$stdin = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), $utf8)
|
|
18
|
+
$stdout = [Console]::OpenStandardOutput()
|
|
19
|
+
function Emit($o) {
|
|
20
|
+
$b = $utf8.GetBytes((ConvertTo-Json -Compress -InputObject $o) + [char]10)
|
|
21
|
+
$stdout.Write($b, 0, $b.Length)
|
|
22
|
+
$stdout.Flush()
|
|
23
|
+
}
|
|
24
|
+
$want = [System.Windows.Forms.SystemInformation]::SmallIconSize.Width
|
|
25
|
+
$size = (@(16, 20, 24, 32, 40, 48) | Where-Object { $_ -ge $want } | Select-Object -First 1)
|
|
26
|
+
if (-not $size) { $size = 48 }
|
|
27
|
+
$icons = @{}; $dots = @{}
|
|
28
|
+
foreach ($t in 'white', 'ink') { foreach ($s in 'ok', 'warn', 'bad') { $icons["$t-$s"] = [System.Drawing.Icon]::FromHandle(([System.Drawing.Bitmap]::new("$dir\icons\$t-$s-$size.png")).GetHicon()) } }
|
|
29
|
+
foreach ($s in 'ok', 'warn', 'bad') { $dots[$s] = [System.Drawing.Bitmap]::new("$dir\icons\dot-$s.png") }
|
|
30
|
+
$themeKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
|
|
31
|
+
function ThemeNow { try { if ((Get-ItemProperty $themeKey).SystemUsesLightTheme -eq 1) { 'ink' } else { 'white' } } catch { 'white' } }
|
|
32
|
+
$amKey = "HKCU:\Software\Classes\AppUserModelId\$aumid"
|
|
33
|
+
$script:toastReady = $false
|
|
34
|
+
$script:theme = ThemeNow
|
|
35
|
+
function RegisterToast {
|
|
36
|
+
New-Item $amKey -Force | Out-Null
|
|
37
|
+
Set-ItemProperty $amKey -Name DisplayName -Value 'cotutor'
|
|
38
|
+
Set-ItemProperty $amKey -Name IconUri -Value "$dir\icons\$($script:theme)-ok-48.png"
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
RegisterToast
|
|
42
|
+
[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
|
|
43
|
+
[void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
|
|
44
|
+
$script:toastReady = $true
|
|
45
|
+
} catch { Emit @{ type = 'error'; message = "toast: $_" } }
|
|
46
|
+
$ni = New-Object System.Windows.Forms.NotifyIcon
|
|
47
|
+
$script:level = 'ok'
|
|
48
|
+
$ni.Icon = $icons["$($script:theme)-ok"]; $ni.Text = 'cotutor'; $ni.Visible = $true
|
|
49
|
+
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
50
|
+
$menu.ShowImageMargin = $false
|
|
51
|
+
$menu.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 9)
|
|
52
|
+
$bold = New-Object System.Drawing.Font('Microsoft YaHei UI', 9, [System.Drawing.FontStyle]::Bold)
|
|
53
|
+
$tiny = New-Object System.Drawing.Font('Microsoft YaHei UI', 8)
|
|
54
|
+
$grey = [System.Drawing.Color]::FromArgb(90, 90, 90)
|
|
55
|
+
$ni.ContextMenuStrip = $menu
|
|
56
|
+
$showMenu = [System.Windows.Forms.NotifyIcon].GetMethod('ShowContextMenu', [Reflection.BindingFlags]'Instance,NonPublic')
|
|
57
|
+
$ni.add_MouseUp({ param($s, $e) if ($e.Button -eq 'Left') { try { $showMenu.Invoke($ni, $null) | Out-Null } catch { } } })
|
|
58
|
+
function Quit {
|
|
59
|
+
$ni.Visible = $false
|
|
60
|
+
$ni.Dispose()
|
|
61
|
+
[System.Windows.Forms.Application]::Exit()
|
|
62
|
+
}
|
|
63
|
+
function Fill($entries) {
|
|
64
|
+
while ($menu.Items.Count) { $x = $menu.Items[0]; $menu.Items.RemoveAt(0); $x.Dispose() }
|
|
65
|
+
foreach ($e in $entries) {
|
|
66
|
+
if ($e.kind -eq 'sep') { [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)); continue }
|
|
67
|
+
if ($e.kind -eq 'action') {
|
|
68
|
+
$i = New-Object System.Windows.Forms.ToolStripMenuItem $e.title
|
|
69
|
+
$i.Tag = $e.id
|
|
70
|
+
if ($e.primary) { $i.Font = $bold }
|
|
71
|
+
$i.add_Click({ param($s, $a) Emit @{ type = 'click'; id = $s.Tag } })
|
|
72
|
+
} elseif ($e.kind -eq 'info') {
|
|
73
|
+
$i = New-Object System.Windows.Forms.ToolStripLabel $e.title
|
|
74
|
+
$i.Font = $bold
|
|
75
|
+
if ($e.level) { $i.Image = $dots[$e.level]; $i.ImageAlign = 'MiddleLeft' }
|
|
76
|
+
$i.TextAlign = 'MiddleLeft'
|
|
77
|
+
} else {
|
|
78
|
+
$i = New-Object System.Windows.Forms.ToolStripLabel $e.title
|
|
79
|
+
$i.Font = $tiny
|
|
80
|
+
$i.ForeColor = $grey
|
|
81
|
+
$i.TextAlign = 'MiddleLeft'
|
|
82
|
+
}
|
|
83
|
+
[void]$menu.Items.Add($i)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function Show($m) {
|
|
87
|
+
$script:level = $m.icon
|
|
88
|
+
$ni.Icon = $icons["$($script:theme)-$($m.icon)"]
|
|
89
|
+
$t = [string]$m.tip
|
|
90
|
+
if ($t.Length -gt 63) { $t = $t.Substring(0, 63) }
|
|
91
|
+
$ni.Text = $t
|
|
92
|
+
Fill $m.menu
|
|
93
|
+
}
|
|
94
|
+
function Notify($title, $body) {
|
|
95
|
+
if ($script:toastReady) {
|
|
96
|
+
try {
|
|
97
|
+
$esc = { param($v) [System.Security.SecurityElement]::Escape([string]$v) }
|
|
98
|
+
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
99
|
+
$xml.LoadXml("<toast><visual><binding template='ToastGeneric'><text>$(& $esc $title)</text><text>$(& $esc $body)</text></binding></visual></toast>")
|
|
100
|
+
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
|
101
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($aumid).Show($toast)
|
|
102
|
+
return
|
|
103
|
+
} catch { Emit @{ type = 'error'; message = "toast: $_" } }
|
|
104
|
+
}
|
|
105
|
+
$ni.ShowBalloonTip(8000, $title, $body, [System.Windows.Forms.ToolTipIcon]::None)
|
|
106
|
+
}
|
|
107
|
+
function Confirm($m) {
|
|
108
|
+
$owner = New-Object System.Windows.Forms.Form
|
|
109
|
+
$owner.TopMost = $true
|
|
110
|
+
$answer = [System.Windows.Forms.MessageBox]::Show($owner, $m.body, $m.title, 'YesNo', 'Warning', 'Button2')
|
|
111
|
+
$owner.Dispose()
|
|
112
|
+
Emit @{ type = 'confirm'; id = $m.id; yes = ($answer -eq 'Yes') }
|
|
113
|
+
}
|
|
114
|
+
function Handle($m) {
|
|
115
|
+
switch ($m.type) {
|
|
116
|
+
'show' { Show $m }
|
|
117
|
+
'notify' { Notify $m.title $m.body }
|
|
118
|
+
'confirm' { Confirm $m }
|
|
119
|
+
'quit' { Quit }
|
|
120
|
+
'inspect' {
|
|
121
|
+
$titles = @(); foreach ($i in $menu.Items) { $titles += [string]$i.Text }
|
|
122
|
+
$p = Get-Process -Id $PID
|
|
123
|
+
Emit @{ type = 'state'; icon = $script:level; theme = $script:theme; size = $size; privateMb = [int]($p.PrivateMemorySize64 / 1MB); workingMb = [int]($p.WorkingSet64 / 1MB); menu = $titles }
|
|
124
|
+
}
|
|
125
|
+
'press' { foreach ($i in $menu.Items) { if ($i.Tag -eq $m.id) { $i.PerformClick(); break } } }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
$script:reading = $stdin.ReadLineAsync()
|
|
129
|
+
$script:themeAt = Get-Date
|
|
130
|
+
$timer = New-Object System.Windows.Forms.Timer
|
|
131
|
+
$timer.Interval = 200
|
|
132
|
+
$timer.add_Tick({
|
|
133
|
+
while ($script:reading.IsCompleted) {
|
|
134
|
+
$line = $script:reading.Result
|
|
135
|
+
if ($null -eq $line) { $timer.Stop(); Quit; return }
|
|
136
|
+
$script:reading = $stdin.ReadLineAsync()
|
|
137
|
+
if ($line) { try { Handle (ConvertFrom-Json $line) } catch { Emit @{ type = 'error'; message = [string]$_ } } }
|
|
138
|
+
}
|
|
139
|
+
if (((Get-Date) - $script:themeAt).TotalSeconds -ge 2) {
|
|
140
|
+
$script:themeAt = Get-Date
|
|
141
|
+
$t = ThemeNow
|
|
142
|
+
if ($t -ne $script:theme) {
|
|
143
|
+
$script:theme = $t
|
|
144
|
+
$ni.Icon = $icons["$t-$($script:level)"]
|
|
145
|
+
if ($script:toastReady) { try { RegisterToast } catch { } }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
$timer.Start()
|
|
150
|
+
Emit @{ type = 'ready' }
|
|
151
|
+
[System.Windows.Forms.Application]::Run()
|
|
152
|
+
`;
|
|
153
|
+
/** 找这台电脑上这个用户的托盘进程(命令行里带 tray run 和这个用户自己的托盘目录) */
|
|
154
|
+
export function findTrayScript(dir) {
|
|
155
|
+
return [
|
|
156
|
+
`$dir = '${dir.replaceAll("'", "''")}'`,
|
|
157
|
+
"$found = @(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $c = $_.CommandLine; $c -and $c.Contains($dir) -and $c.Contains(' tray run ') })",
|
|
158
|
+
'ConvertTo-Json -Compress -InputObject @($found | ForEach-Object { @{ pid = $_.ProcessId; keeper = $true } })',
|
|
159
|
+
].join('\n');
|
|
160
|
+
}
|
|
161
|
+
/** 启动器:wscript 跑它,藏着窗口起 node(和后台服务同一个办法,见 service-windows.ts) */
|
|
162
|
+
export function trayLauncher(commandLine) {
|
|
163
|
+
return `// cotutor tray on 写的;改了会被下次盖掉。关掉:cotutor tray off
|
|
164
|
+
new ActiveXObject("WScript.Shell").Run(${JSON.stringify(commandLine)}, 0, false);
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { type Health, type InstalledService, type ServiceHost } from './service.ts';
|
|
2
|
+
import { HELPER_NO_SESSION, TRAY_AUMID, TRAY_LABEL, TRAY_RUN_VALUE } from './tray-ids.ts';
|
|
3
|
+
export { HELPER_NO_SESSION, TRAY_AUMID, TRAY_LABEL, TRAY_RUN_VALUE };
|
|
4
|
+
/** 多久问一次 */
|
|
5
|
+
export declare const TRAY_POLL_MS = 5000;
|
|
6
|
+
/** 连着几次问不到才算没在跑:重起一下、网络抖一下不报 */
|
|
7
|
+
export declare const TRAY_STRIKES = 2;
|
|
8
|
+
/** 图标刚起来时服务可能还没起来:这么久里问不到算「正在启动」(Windows 登录后约一分钟才跑 Run 项) */
|
|
9
|
+
export declare const TRAY_START_GRACE_MS = 90000;
|
|
10
|
+
/** 点了启动、重启以后等多久 */
|
|
11
|
+
export declare const TRAY_RESTART_GRACE_MS = 30000;
|
|
12
|
+
/** 两次问之间隔太久 = 刚睡醒:网络还没回来,先别报 */
|
|
13
|
+
export declare const TRAY_WAKE_GAP_MS = 30000;
|
|
14
|
+
export declare const TRAY_WAKE_GRACE_MS = 30000;
|
|
15
|
+
export type TrayLevel = 'ok' | 'warn' | 'bad';
|
|
16
|
+
export type RowLevel = TrayLevel | 'starting';
|
|
17
|
+
export type TrayFix = {
|
|
18
|
+
kind: 'open';
|
|
19
|
+
target: string;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'restart';
|
|
22
|
+
};
|
|
23
|
+
/** 一个服务这一轮问到的 */
|
|
24
|
+
export interface TrayProbe {
|
|
25
|
+
name: string;
|
|
26
|
+
kid: string;
|
|
27
|
+
root: string;
|
|
28
|
+
logDir: string | null;
|
|
29
|
+
port: number;
|
|
30
|
+
health: Health | null;
|
|
31
|
+
/** 问不到时端口被谁占着(「nginx」);不知道 → null */
|
|
32
|
+
holder: string | null;
|
|
33
|
+
}
|
|
34
|
+
/** 一个服务现在的样子 */
|
|
35
|
+
export interface TrayRow {
|
|
36
|
+
name: string;
|
|
37
|
+
kid: string;
|
|
38
|
+
logDir: string | null;
|
|
39
|
+
level: RowLevel;
|
|
40
|
+
/** 家长端地址;服务没答应 → null */
|
|
41
|
+
url: string | null;
|
|
42
|
+
/** 第二行小字 */
|
|
43
|
+
detail: string;
|
|
44
|
+
/** 有几件事要处理 */
|
|
45
|
+
issues: number;
|
|
46
|
+
fix: TrayFix | null;
|
|
47
|
+
calls: number;
|
|
48
|
+
}
|
|
49
|
+
export type TrayEntry = {
|
|
50
|
+
kind: 'info';
|
|
51
|
+
title: string;
|
|
52
|
+
level?: TrayLevel;
|
|
53
|
+
} | {
|
|
54
|
+
kind: 'sub';
|
|
55
|
+
title: string;
|
|
56
|
+
} | {
|
|
57
|
+
kind: 'sep';
|
|
58
|
+
} | {
|
|
59
|
+
kind: 'action';
|
|
60
|
+
id: string;
|
|
61
|
+
title: string;
|
|
62
|
+
primary?: boolean;
|
|
63
|
+
};
|
|
64
|
+
export interface TrayShow {
|
|
65
|
+
type: 'show';
|
|
66
|
+
icon: TrayLevel;
|
|
67
|
+
tip: string;
|
|
68
|
+
menu: TrayEntry[];
|
|
69
|
+
}
|
|
70
|
+
export interface TrayNote {
|
|
71
|
+
type: 'notify';
|
|
72
|
+
title: string;
|
|
73
|
+
body: string;
|
|
74
|
+
}
|
|
75
|
+
/** 跑了多久,给家长看 */
|
|
76
|
+
export declare function uptimeText(startedMs: number, now: number): string;
|
|
77
|
+
/** 服务在跑,但有事要家长处理:先后就是轻重,菜单上只说第一件 */
|
|
78
|
+
export declare function issuesOf(p: TrayProbe, o: {
|
|
79
|
+
platform: NodeJS.Platform;
|
|
80
|
+
version: string;
|
|
81
|
+
}): {
|
|
82
|
+
text: string;
|
|
83
|
+
fix: TrayFix;
|
|
84
|
+
}[];
|
|
85
|
+
/** 记着每个服务上一轮的样子,算这一轮的样子和要不要通知;不碰任何系统,测试直接喂 */
|
|
86
|
+
export declare class TrayModel {
|
|
87
|
+
private readonly mem;
|
|
88
|
+
private last;
|
|
89
|
+
private readonly o;
|
|
90
|
+
constructor(o: {
|
|
91
|
+
platform: NodeJS.Platform;
|
|
92
|
+
version: string;
|
|
93
|
+
});
|
|
94
|
+
/** 刚点了启动、重启:这一阵问不到是正常的 */
|
|
95
|
+
expect(name: string, now: number): void;
|
|
96
|
+
update(probes: TrayProbe[], now: number): {
|
|
97
|
+
rows: TrayRow[];
|
|
98
|
+
notes: TrayNote[];
|
|
99
|
+
};
|
|
100
|
+
private healthy;
|
|
101
|
+
private placeholder;
|
|
102
|
+
}
|
|
103
|
+
/** 这一轮显示什么 */
|
|
104
|
+
export declare function trayShow(rows: TrayRow[]): TrayShow;
|
|
105
|
+
export interface HelperMessage {
|
|
106
|
+
type: string;
|
|
107
|
+
id?: string;
|
|
108
|
+
yes?: boolean;
|
|
109
|
+
message?: string;
|
|
110
|
+
}
|
|
111
|
+
export interface TrayHelper {
|
|
112
|
+
send(m: unknown): void;
|
|
113
|
+
/** 关 stdin:显示脚本读到头自己退 */
|
|
114
|
+
close(): void;
|
|
115
|
+
}
|
|
116
|
+
export interface TrayDeps {
|
|
117
|
+
platform: NodeJS.Platform;
|
|
118
|
+
version: string;
|
|
119
|
+
now(): number;
|
|
120
|
+
list(): Promise<InstalledService[]>;
|
|
121
|
+
/** 读 cotutor.json:孩子叫什么、哪个端口 */
|
|
122
|
+
inspect(s: InstalledService): Promise<{
|
|
123
|
+
kid: string;
|
|
124
|
+
port: number;
|
|
125
|
+
}>;
|
|
126
|
+
probe(port: number): Promise<Health | null>;
|
|
127
|
+
holder(port: number): Promise<string | null>;
|
|
128
|
+
control(root: string, what: 'start' | 'stop' | 'restart'): Promise<unknown>;
|
|
129
|
+
open(target: string): void;
|
|
130
|
+
spawnHelper(on: {
|
|
131
|
+
message(m: HelperMessage): void;
|
|
132
|
+
exit(code: number | null): void;
|
|
133
|
+
}): TrayHelper;
|
|
134
|
+
note(text: string): void;
|
|
135
|
+
pollMs?: number;
|
|
136
|
+
helperRestartMs?: number;
|
|
137
|
+
}
|
|
138
|
+
export declare const QUIT_CONFIRM: {
|
|
139
|
+
id: string;
|
|
140
|
+
title: string;
|
|
141
|
+
body: string;
|
|
142
|
+
yes: string;
|
|
143
|
+
no: string;
|
|
144
|
+
};
|
|
145
|
+
/** 主循环:问状态、发给显示脚本、接点击;都退了才返回退出码。0 = 不用再起(点了退出、没有服务了、没有桌面) */
|
|
146
|
+
export declare function runTray(d: TrayDeps): Promise<number>;
|
|
147
|
+
export declare const traySupported: (platform: NodeJS.Platform) => boolean;
|
|
148
|
+
type HostPaths = Pick<ServiceHost, 'platform' | 'home' | 'env'>;
|
|
149
|
+
/** 显示脚本、图标、日志放哪 */
|
|
150
|
+
export declare function trayDir(host: HostPaths): string;
|
|
151
|
+
/** 写显示脚本(每次起来都写,升级后马上生效)和图标(版本变了才画,画一次要几百毫秒) */
|
|
152
|
+
export declare function writeTrayFiles(platform: NodeJS.Platform, dir: string): Promise<string>;
|
|
153
|
+
/** 起显示脚本 */
|
|
154
|
+
export declare function spawnTrayHelper(platform: NodeJS.Platform, file: string, dir: string, note: (t: string) => void, on: {
|
|
155
|
+
message(m: HelperMessage): void;
|
|
156
|
+
exit(code: number | null): void;
|
|
157
|
+
}): TrayHelper;
|
|
158
|
+
/** 打开地址或目录(Windows 不弹黑窗口) */
|
|
159
|
+
export declare function openTarget(platform: NodeJS.Platform, target: string): void;
|
|
160
|
+
/** 日志:一个文件,过 1 MB 换一次,只留上一份 */
|
|
161
|
+
export declare function trayNote(dir: string): (text: string) => void;
|
|
162
|
+
/** 孩子叫什么、哪个端口:只读这两样,不加载 zod(托盘进程常驻,要轻);读不了就用服务名和缺省端口 */
|
|
163
|
+
export declare function inspectService(s: InstalledService): Promise<{
|
|
164
|
+
kid: string;
|
|
165
|
+
port: number;
|
|
166
|
+
}>;
|
|
167
|
+
/** 真的依赖,给 runTray */
|
|
168
|
+
export declare function realTrayDeps(host: ServiceHost, dir: string, file: string, version: string): TrayDeps;
|
|
169
|
+
/** 登录项跑的命令 */
|
|
170
|
+
export declare const trayArgs: (host: HostPaths, o?: {
|
|
171
|
+
node?: string;
|
|
172
|
+
bin?: string;
|
|
173
|
+
}) => string[];
|
|
174
|
+
/** macOS 的 LaunchAgent:登录就起,崩了拉起,以 0 退(点了退出)不拉 */
|
|
175
|
+
export declare function trayPlist(args: string[], dir: string, path: string): string;
|
|
176
|
+
/** 装上并起来;已经装过就照现在的 node、bin 重写一遍再起(升级后新代码才生效)。started = false:登录项写好了,这个会话里起不来,下次登录出现 */
|
|
177
|
+
export declare function installTray(host: ServiceHost, o?: {
|
|
178
|
+
node?: string;
|
|
179
|
+
bin?: string;
|
|
180
|
+
}): Promise<{
|
|
181
|
+
file: string;
|
|
182
|
+
started: boolean;
|
|
183
|
+
}>;
|
|
184
|
+
/** 卸掉:停下、删登录项。remember = 家长说不要(以后 service install 也不再装) */
|
|
185
|
+
export declare function removeTray(host: ServiceHost, o?: {
|
|
186
|
+
remember?: boolean;
|
|
187
|
+
}): Promise<boolean>;
|
|
188
|
+
export interface TrayStatus {
|
|
189
|
+
supported: boolean;
|
|
190
|
+
installed: boolean;
|
|
191
|
+
running: boolean;
|
|
192
|
+
pid: number | null;
|
|
193
|
+
/** 家长关掉过(cotutor tray off) */
|
|
194
|
+
off: boolean;
|
|
195
|
+
/** Windows:下次登录还会不会起来;别的系统 null */
|
|
196
|
+
autostart: boolean | null;
|
|
197
|
+
}
|
|
198
|
+
export declare function trayStatus(host: ServiceHost): Promise<TrayStatus>;
|
|
199
|
+
/** service install 时跟着装:没做的系统、家长关掉过都跳过;返回给人看的结果 */
|
|
200
|
+
export declare function ensureTray(host: ServiceHost, o?: {
|
|
201
|
+
node?: string;
|
|
202
|
+
bin?: string;
|
|
203
|
+
}): Promise<'unsupported' | 'off' | 'started' | 'later'>;
|
|
204
|
+
/** 服务卸完了:图标也撤(不算家长说不要,以后再装服务还会带上) */
|
|
205
|
+
export declare function dropTrayIfNoServices(host: ServiceHost): Promise<boolean>;
|