@jixoai/gh-runner 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/icon-1024.png +0 -0
- package/dist/assets/icon.icns +0 -0
- package/dist/assets/icon.ico +0 -0
- package/dist/assets/linux/128x128/app-icon.png +0 -0
- package/dist/assets/linux/16x16/app-icon.png +0 -0
- package/dist/assets/linux/256x256/app-icon.png +0 -0
- package/dist/assets/linux/32x32/app-icon.png +0 -0
- package/dist/assets/linux/48x48/app-icon.png +0 -0
- package/dist/assets/linux/512x512/app-icon.png +0 -0
- package/dist/assets/linux/64x64/app-icon.png +0 -0
- package/dist/assets/manifest.json +84 -0
- package/dist/assets/tray-color.png +0 -0
- package/dist/assets/tray-template.png +0 -0
- package/dist/main.mjs +217 -128
- package/dist/ui/index.html +65 -596
- package/package.json +27 -4
- package/dist/ui/demo.js +0 -194
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"appIcon": [
|
|
4
|
+
{
|
|
5
|
+
"platform": "darwin",
|
|
6
|
+
"format": "icns",
|
|
7
|
+
"source": {
|
|
8
|
+
"type": "file",
|
|
9
|
+
"path": "icon.icns"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"platform": "windows",
|
|
14
|
+
"format": "ico",
|
|
15
|
+
"source": {
|
|
16
|
+
"type": "file",
|
|
17
|
+
"path": "icon.ico"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"platform": "linux",
|
|
22
|
+
"format": "png",
|
|
23
|
+
"size": 16,
|
|
24
|
+
"source": {
|
|
25
|
+
"type": "file",
|
|
26
|
+
"path": "linux/16x16/app-icon.png"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"platform": "linux",
|
|
31
|
+
"format": "png",
|
|
32
|
+
"size": 32,
|
|
33
|
+
"source": {
|
|
34
|
+
"type": "file",
|
|
35
|
+
"path": "linux/32x32/app-icon.png"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"platform": "linux",
|
|
40
|
+
"format": "png",
|
|
41
|
+
"size": 48,
|
|
42
|
+
"source": {
|
|
43
|
+
"type": "file",
|
|
44
|
+
"path": "linux/48x48/app-icon.png"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"platform": "linux",
|
|
49
|
+
"format": "png",
|
|
50
|
+
"size": 64,
|
|
51
|
+
"source": {
|
|
52
|
+
"type": "file",
|
|
53
|
+
"path": "linux/64x64/app-icon.png"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"platform": "linux",
|
|
58
|
+
"format": "png",
|
|
59
|
+
"size": 128,
|
|
60
|
+
"source": {
|
|
61
|
+
"type": "file",
|
|
62
|
+
"path": "linux/128x128/app-icon.png"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"platform": "linux",
|
|
67
|
+
"format": "png",
|
|
68
|
+
"size": 256,
|
|
69
|
+
"source": {
|
|
70
|
+
"type": "file",
|
|
71
|
+
"path": "linux/256x256/app-icon.png"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"platform": "linux",
|
|
76
|
+
"format": "png",
|
|
77
|
+
"size": 512,
|
|
78
|
+
"source": {
|
|
79
|
+
"type": "file",
|
|
80
|
+
"path": "linux/512x512/app-icon.png"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
}
|
|
Binary file
|
|
Binary file
|
package/dist/main.mjs
CHANGED
|
@@ -1,17 +1,143 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { arch, homedir, platform } from "node:os";
|
|
3
2
|
import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
3
|
import { createTray } from "opentray";
|
|
5
4
|
import { attachDialog } from "@opentray/ext-dialog";
|
|
6
5
|
import { attachOpener } from "@opentray/ext-opener";
|
|
6
|
+
import { arch, homedir, platform } from "node:os";
|
|
7
7
|
import { dirname, isAbsolute, join } from "node:path";
|
|
8
|
-
import { createWriteStream, existsSync } from "node:fs";
|
|
8
|
+
import { createWriteStream, existsSync, readFileSync } from "node:fs";
|
|
9
9
|
import { execFile, spawn } from "node:child_process";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
11
|
import { Readable } from "node:stream";
|
|
12
12
|
import { pipeline } from "node:stream/promises";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { WebviewExt } from "@opentray/ext-webview";
|
|
15
|
+
// src/platform.ts
|
|
16
|
+
const IS_WIN = platform() === "win32";
|
|
17
|
+
const PLATFORM = platform();
|
|
18
|
+
const ARCH = arch();
|
|
19
|
+
/** actions/runner release 资产的平台段:darwin→osx, win32→win, linux→linux。 */
|
|
20
|
+
function osSegment(p = PLATFORM) {
|
|
21
|
+
if (p === "darwin") return "osx";
|
|
22
|
+
if (p === "win32") return "win";
|
|
23
|
+
return "linux";
|
|
24
|
+
}
|
|
25
|
+
/** arch 段:ia32→x86, arm64→arm64, 其余→x64。 */
|
|
26
|
+
function archSegment(a = ARCH) {
|
|
27
|
+
if (a === "ia32") return "x86";
|
|
28
|
+
if (a === "arm64") return "arm64";
|
|
29
|
+
return "x64";
|
|
30
|
+
}
|
|
31
|
+
/** 官方 release 资产名,如 actions-runner-osx-arm64-2.338.1.tar.gz / actions-runner-win-x64-2.338.1.zip。 */
|
|
32
|
+
function assetNameFor(version, p = PLATFORM, a = ARCH) {
|
|
33
|
+
const ext = osSegment(p) === "win" ? "zip" : "tar.gz";
|
|
34
|
+
return `actions-runner-${osSegment(p)}-${archSegment(a)}-${version}.${ext}`;
|
|
35
|
+
}
|
|
36
|
+
/** 包文件是否与当前平台匹配(供 UI 把不兼容包置灰)。 */
|
|
37
|
+
function packageCompatible(fileName) {
|
|
38
|
+
const seg = `${osSegment()}-${archSegment()}`;
|
|
39
|
+
return fileName.includes(`-${seg}-`);
|
|
40
|
+
}
|
|
41
|
+
/** runner 启动入口:darwin/linux 用 run.sh;win32 直接跑 Listener.exe(run.cmd 只是它的薄包装)。 */
|
|
42
|
+
function runnerSpawn(dir) {
|
|
43
|
+
if (IS_WIN) return {
|
|
44
|
+
file: join(dir, "bin", "Runner.Listener.exe"),
|
|
45
|
+
args: ["run"]
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
file: join(dir, "run.sh"),
|
|
49
|
+
args: []
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** 注册命令:darwin/linux 走 ./config.sh;win32 用 Listener 的 configure 动词(避免 cmd.exe 转义坑)。 */
|
|
53
|
+
function registerSpawn(dir) {
|
|
54
|
+
if (IS_WIN) return {
|
|
55
|
+
file: join(dir, "bin", "Runner.Listener.exe"),
|
|
56
|
+
prefixArgs: ["configure"]
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
file: "./config.sh",
|
|
60
|
+
prefixArgs: []
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** labels 默认值(UI 预填)。 */
|
|
64
|
+
function defaultLabels() {
|
|
65
|
+
if (PLATFORM === "darwin") return "self-hosted,macOS,ARM64";
|
|
66
|
+
if (IS_WIN) return `self-hosted,Windows,${archSegment().toUpperCase()}`;
|
|
67
|
+
return `self-hosted,Linux,${archSegment().toUpperCase()}`;
|
|
68
|
+
}
|
|
69
|
+
/** 实例目录的默认父目录(UI placeholder 与初始 searchDirs)。 */
|
|
70
|
+
function defaultSearchDirs() {
|
|
71
|
+
if (PLATFORM === "darwin") return ["/Volumes/bnqkl/Users/kzf"];
|
|
72
|
+
if (IS_WIN) return ["C:\\actions-runner", homedir()];
|
|
73
|
+
return [homedir()];
|
|
74
|
+
}
|
|
75
|
+
/** 平台路径分段(win32 反斜杠)。 */
|
|
76
|
+
function basenameOf(dir) {
|
|
77
|
+
const parts = dir.split(/[\\/]+/).filter(Boolean);
|
|
78
|
+
return parts[parts.length - 1] ?? dir;
|
|
79
|
+
}
|
|
80
|
+
/** 平台无关的绝对路径判定。 */
|
|
81
|
+
function isAbsolutePath(p) {
|
|
82
|
+
if (IS_WIN) return /^[A-Za-z]:[\\/]/.test(p) || isAbsolute(p);
|
|
83
|
+
return isAbsolute(p);
|
|
84
|
+
}
|
|
85
|
+
// src/config.ts
|
|
86
|
+
const DEFAULT_SEARCH_DIRS = defaultSearchDirs();
|
|
87
|
+
function configDir() {
|
|
88
|
+
if (process.platform === "win32") {
|
|
89
|
+
const appData = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming");
|
|
90
|
+
return join(appData, "gh-runner");
|
|
91
|
+
}
|
|
92
|
+
return join(homedir(), "Library", "Application Support", "gh-runner");
|
|
93
|
+
}
|
|
94
|
+
function configPath() {
|
|
95
|
+
return join(configDir(), "config.json");
|
|
96
|
+
}
|
|
97
|
+
/** 形状校验:损坏的实例项直接丢弃(手编配置/撕裂写的容错),不让 boot 崩掉。
|
|
98
|
+
* 路径合法性平台无关(POSIX 绝对 / win32 盘符 / UNC 均可)——config 是本机产物,
|
|
99
|
+
* 但 Windows 盘符路径曾被 startsWith("/") 全量误杀并回写空清单(v0.2.2 实证事故)。 */
|
|
100
|
+
function isAbsoluteish(p) {
|
|
101
|
+
return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("\\\\");
|
|
102
|
+
}
|
|
103
|
+
function validInstance(item) {
|
|
104
|
+
return typeof item === "object" && item !== null && typeof item.dir === "string" && isAbsoluteish(item.dir) && typeof item.id === "string";
|
|
105
|
+
}
|
|
106
|
+
async function loadConfig() {
|
|
107
|
+
try {
|
|
108
|
+
const raw = (await readFile(configPath(), "utf8")).replace(/^\uFEFF/, "");
|
|
109
|
+
const parsed = JSON.parse(raw);
|
|
110
|
+
const searchDirs = Array.isArray(parsed.searchDirs) ? parsed.searchDirs.filter((d) => typeof d === "string" && isAbsolutePath(d)) : [];
|
|
111
|
+
const instances = Array.isArray(parsed.instances) ? parsed.instances.filter((i) => validInstance(i)) : [];
|
|
112
|
+
const importedPackages = Array.isArray(parsed.importedPackages) ? parsed.importedPackages.filter((p) => typeof p === "string") : [];
|
|
113
|
+
return {
|
|
114
|
+
searchDirs: searchDirs.length > 0 ? searchDirs : DEFAULT_SEARCH_DIRS,
|
|
115
|
+
instances,
|
|
116
|
+
importedPackages
|
|
117
|
+
};
|
|
118
|
+
} catch {
|
|
119
|
+
return {
|
|
120
|
+
searchDirs: DEFAULT_SEARCH_DIRS,
|
|
121
|
+
instances: [],
|
|
122
|
+
importedPackages: []
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** 原子写:tmp + rename,杜绝撕裂写(损坏的 config 会 brick 启动路径)。 */
|
|
127
|
+
async function saveConfig(config) {
|
|
128
|
+
await mkdir(configDir(), { recursive: true });
|
|
129
|
+
const path = configPath();
|
|
130
|
+
const tmp = `${path}.tmp`;
|
|
131
|
+
await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
132
|
+
await rename(tmp, path);
|
|
133
|
+
}
|
|
134
|
+
function upsertInstance(config, instance) {
|
|
135
|
+
const rest = config.instances.filter((i) => i.id !== instance.id && i.dir !== instance.dir);
|
|
136
|
+
return {
|
|
137
|
+
...config,
|
|
138
|
+
instances: [...rest, instance]
|
|
139
|
+
};
|
|
140
|
+
}
|
|
15
141
|
// src/tailer.ts
|
|
16
142
|
/**
|
|
17
143
|
* 跟随一个目录中最新的、名字匹配 `prefix*.log` 的文件。
|
|
@@ -164,76 +290,6 @@ function parseConsoleLine(line) {
|
|
|
164
290
|
};
|
|
165
291
|
return null;
|
|
166
292
|
}
|
|
167
|
-
// src/platform.ts
|
|
168
|
-
const IS_WIN = platform() === "win32";
|
|
169
|
-
const PLATFORM = platform();
|
|
170
|
-
const ARCH = arch();
|
|
171
|
-
/** actions/runner release 资产的平台段:darwin→osx, win32→win, linux→linux。 */
|
|
172
|
-
function osSegment(p = PLATFORM) {
|
|
173
|
-
if (p === "darwin") return "osx";
|
|
174
|
-
if (p === "win32") return "win";
|
|
175
|
-
return "linux";
|
|
176
|
-
}
|
|
177
|
-
/** arch 段:ia32→x86, arm64→arm64, 其余→x64。 */
|
|
178
|
-
function archSegment(a = ARCH) {
|
|
179
|
-
if (a === "ia32") return "x86";
|
|
180
|
-
if (a === "arm64") return "arm64";
|
|
181
|
-
return "x64";
|
|
182
|
-
}
|
|
183
|
-
/** 官方 release 资产名,如 actions-runner-osx-arm64-2.338.1.tar.gz / actions-runner-win-x64-2.338.1.zip。 */
|
|
184
|
-
function assetNameFor(version, p = PLATFORM, a = ARCH) {
|
|
185
|
-
const ext = osSegment(p) === "win" ? "zip" : "tar.gz";
|
|
186
|
-
return `actions-runner-${osSegment(p)}-${archSegment(a)}-${version}.${ext}`;
|
|
187
|
-
}
|
|
188
|
-
/** 包文件是否与当前平台匹配(供 UI 把不兼容包置灰)。 */
|
|
189
|
-
function packageCompatible(fileName) {
|
|
190
|
-
const seg = `${osSegment()}-${archSegment()}`;
|
|
191
|
-
return fileName.includes(`-${seg}-`);
|
|
192
|
-
}
|
|
193
|
-
/** runner 启动入口:darwin/linux 用 run.sh;win32 直接跑 Listener.exe(run.cmd 只是它的薄包装)。 */
|
|
194
|
-
function runnerSpawn(dir) {
|
|
195
|
-
if (IS_WIN) return {
|
|
196
|
-
file: join(dir, "bin", "Runner.Listener.exe"),
|
|
197
|
-
args: ["run"]
|
|
198
|
-
};
|
|
199
|
-
return {
|
|
200
|
-
file: join(dir, "run.sh"),
|
|
201
|
-
args: []
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
/** 注册命令:darwin/linux 走 ./config.sh;win32 用 Listener 的 configure 动词(避免 cmd.exe 转义坑)。 */
|
|
205
|
-
function registerSpawn(dir) {
|
|
206
|
-
if (IS_WIN) return {
|
|
207
|
-
file: join(dir, "bin", "Runner.Listener.exe"),
|
|
208
|
-
prefixArgs: ["configure"]
|
|
209
|
-
};
|
|
210
|
-
return {
|
|
211
|
-
file: "./config.sh",
|
|
212
|
-
prefixArgs: []
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
/** labels 默认值(UI 预填)。 */
|
|
216
|
-
function defaultLabels() {
|
|
217
|
-
if (PLATFORM === "darwin") return "self-hosted,macOS,ARM64";
|
|
218
|
-
if (IS_WIN) return `self-hosted,Windows,${archSegment().toUpperCase()}`;
|
|
219
|
-
return `self-hosted,Linux,${archSegment().toUpperCase()}`;
|
|
220
|
-
}
|
|
221
|
-
/** 实例目录的默认父目录(UI placeholder 与初始 searchDirs)。 */
|
|
222
|
-
function defaultSearchDirs() {
|
|
223
|
-
if (PLATFORM === "darwin") return ["/Volumes/bnqkl/Users/kzf"];
|
|
224
|
-
if (IS_WIN) return ["C:\\actions-runner", homedir()];
|
|
225
|
-
return [homedir()];
|
|
226
|
-
}
|
|
227
|
-
/** 平台路径分段(win32 反斜杠)。 */
|
|
228
|
-
function basenameOf(dir) {
|
|
229
|
-
const parts = dir.split(/[\\/]+/).filter(Boolean);
|
|
230
|
-
return parts[parts.length - 1] ?? dir;
|
|
231
|
-
}
|
|
232
|
-
/** 平台无关的绝对路径判定。 */
|
|
233
|
-
function isAbsolutePath(p) {
|
|
234
|
-
if (IS_WIN) return /^[A-Za-z]:[\\/]/.test(p) || isAbsolute(p);
|
|
235
|
-
return isAbsolute(p);
|
|
236
|
-
}
|
|
237
293
|
// src/discover.ts
|
|
238
294
|
const exec = promisify(execFile);
|
|
239
295
|
/** 读取 <dir>/.runner;缺失或损坏时返回未配置态。注意 GitHub 写入的文件带 UTF-8 BOM。 */
|
|
@@ -444,53 +500,6 @@ async function stopProcessGroup(pid, graceMs = 8e3) {
|
|
|
444
500
|
await new Promise((r) => setTimeout(r, 500));
|
|
445
501
|
return pidExists(pid) === "dead";
|
|
446
502
|
}
|
|
447
|
-
// src/config.ts
|
|
448
|
-
const DEFAULT_SEARCH_DIRS = defaultSearchDirs();
|
|
449
|
-
function configDir() {
|
|
450
|
-
return join(homedir(), "Library", "Application Support", "gh-runner");
|
|
451
|
-
}
|
|
452
|
-
function configPath() {
|
|
453
|
-
return join(configDir(), "config.json");
|
|
454
|
-
}
|
|
455
|
-
/** 形状校验:损坏的实例项直接丢弃(手编配置/撕裂写的容错),不让 boot 崩掉。 */
|
|
456
|
-
function validInstance(item) {
|
|
457
|
-
return typeof item === "object" && item !== null && typeof item.dir === "string" && item.dir.startsWith("/") && typeof item.id === "string";
|
|
458
|
-
}
|
|
459
|
-
async function loadConfig() {
|
|
460
|
-
try {
|
|
461
|
-
const raw = (await readFile(configPath(), "utf8")).replace(/^\uFEFF/, "");
|
|
462
|
-
const parsed = JSON.parse(raw);
|
|
463
|
-
const searchDirs = Array.isArray(parsed.searchDirs) ? parsed.searchDirs.filter((d) => typeof d === "string" && isAbsolutePath(d)) : [];
|
|
464
|
-
const instances = Array.isArray(parsed.instances) ? parsed.instances.filter((i) => validInstance(i)) : [];
|
|
465
|
-
const importedPackages = Array.isArray(parsed.importedPackages) ? parsed.importedPackages.filter((p) => typeof p === "string") : [];
|
|
466
|
-
return {
|
|
467
|
-
searchDirs: searchDirs.length > 0 ? searchDirs : DEFAULT_SEARCH_DIRS,
|
|
468
|
-
instances,
|
|
469
|
-
importedPackages
|
|
470
|
-
};
|
|
471
|
-
} catch {
|
|
472
|
-
return {
|
|
473
|
-
searchDirs: DEFAULT_SEARCH_DIRS,
|
|
474
|
-
instances: [],
|
|
475
|
-
importedPackages: []
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
/** 原子写:tmp + rename,杜绝撕裂写(损坏的 config 会 brick 启动路径)。 */
|
|
480
|
-
async function saveConfig(config) {
|
|
481
|
-
await mkdir(configDir(), { recursive: true });
|
|
482
|
-
const path = configPath();
|
|
483
|
-
const tmp = `${path}.tmp`;
|
|
484
|
-
await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
485
|
-
await rename(tmp, path);
|
|
486
|
-
}
|
|
487
|
-
function upsertInstance(config, instance) {
|
|
488
|
-
const rest = config.instances.filter((i) => i.id !== instance.id && i.dir !== instance.dir);
|
|
489
|
-
return {
|
|
490
|
-
...config,
|
|
491
|
-
instances: [...rest, instance]
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
503
|
// src/register.ts
|
|
495
504
|
async function runStep(title, file, args, cwd, onProgress, timeoutMs = 3e5) {
|
|
496
505
|
onProgress(`$ ${title}`);
|
|
@@ -544,13 +553,24 @@ async function registerRunner(req, searchDirs, onProgress) {
|
|
|
544
553
|
const dir = req.dir;
|
|
545
554
|
try {
|
|
546
555
|
if (!dir || !isAbsolutePath(dir)) throw new Error("实例目录必须是绝对路径");
|
|
556
|
+
if (existsSync(join(dir, ".runner"))) {
|
|
557
|
+
onProgress(`检测到已注册实例(${dir})→ 接入管理清单`);
|
|
558
|
+
const meta = await readRunnerMeta(dir);
|
|
559
|
+
if (!meta.configured) throw new Error(".runner 存在但无法解析(损坏?)");
|
|
560
|
+
onProgress(`✔ 接入成功:${meta.agentName}(agentId=${meta.agentId}),如需重启请点「启动」`);
|
|
561
|
+
return {
|
|
562
|
+
ok: true,
|
|
563
|
+
error: null,
|
|
564
|
+
runnerDir: dir
|
|
565
|
+
};
|
|
566
|
+
}
|
|
547
567
|
if (!req.name.trim()) throw new Error("缺少实例名称");
|
|
548
568
|
if (!req.token.trim()) throw new Error("缺少注册 Token(org 设置页每次生成、一次性)");
|
|
549
569
|
if (!req.workFolder.trim()) throw new Error("缺少 Work 目录(多实例必须各自独立)");
|
|
550
570
|
let entries = [];
|
|
551
571
|
if (existsSync(dir)) {
|
|
552
572
|
entries = await readdir(dir);
|
|
553
|
-
if (entries.some((e) => e !== ".DS_Store")) throw new Error(
|
|
573
|
+
if (entries.some((e) => e !== ".DS_Store")) throw new Error(`目录非空且无 .runner(${entries.length} 项):无法安全解包。若是此前注册的实例,请确认目录下有 .runner 文件;否则请换一个空目录。`);
|
|
554
574
|
} else {
|
|
555
575
|
onProgress(`创建目录 ${dir}`);
|
|
556
576
|
await mkdir(dir, { recursive: true });
|
|
@@ -1123,6 +1143,26 @@ var Fleet = class {
|
|
|
1123
1143
|
this.#debounceTimer.unref?.();
|
|
1124
1144
|
}
|
|
1125
1145
|
};
|
|
1146
|
+
// src/branding.ts
|
|
1147
|
+
const assetsDir = join(dirname(fileURLToPath(import.meta.url)), "assets");
|
|
1148
|
+
/** 内核 manifest → 运行时 AppIcon(manifest 内相对路径 → 绝对)。 */
|
|
1149
|
+
function appIcon() {
|
|
1150
|
+
return JSON.parse(readFileSync(join(assetsDir, "manifest.json"), "utf8")).appIcon.map((asset) => ({
|
|
1151
|
+
...asset,
|
|
1152
|
+
source: {
|
|
1153
|
+
type: "file",
|
|
1154
|
+
path: join(assetsDir, asset.source.path)
|
|
1155
|
+
}
|
|
1156
|
+
}));
|
|
1157
|
+
}
|
|
1158
|
+
/** 菜单栏模板图(darwin:纯黑剪影 isTemplate 系统着色)。 */
|
|
1159
|
+
function trayTemplatePath() {
|
|
1160
|
+
return join(assetsDir, "tray-template.png");
|
|
1161
|
+
}
|
|
1162
|
+
/** 菜单栏彩色图(win32)。 */
|
|
1163
|
+
function trayColorPath() {
|
|
1164
|
+
return join(assetsDir, "tray-color.png");
|
|
1165
|
+
}
|
|
1126
1166
|
// src/tray.ts
|
|
1127
1167
|
const ITEM_OPEN = 1;
|
|
1128
1168
|
const ITEM_QUIT = 2;
|
|
@@ -1138,11 +1178,15 @@ function stateLabel(snapshot, index) {
|
|
|
1138
1178
|
default: return i.processState;
|
|
1139
1179
|
}
|
|
1140
1180
|
}
|
|
1141
|
-
/** 快照 →
|
|
1181
|
+
/** 快照 → 托盘投影器。原生托盘接口按内容去重——内容未变绝不重复调用。 */
|
|
1142
1182
|
var TrayProjection = class {
|
|
1143
1183
|
tray;
|
|
1144
1184
|
actions;
|
|
1145
1185
|
#instanceIds = [];
|
|
1186
|
+
#lastIcon = null;
|
|
1187
|
+
#platform = process.platform;
|
|
1188
|
+
#lastTooltip = null;
|
|
1189
|
+
#lastMenu = null;
|
|
1146
1190
|
constructor(tray, actions) {
|
|
1147
1191
|
this.tray = tray;
|
|
1148
1192
|
this.actions = actions;
|
|
@@ -1151,12 +1195,39 @@ var TrayProjection = class {
|
|
|
1151
1195
|
this.#instanceIds = snapshot.instances.map((i) => i.id);
|
|
1152
1196
|
const running = snapshot.instances.filter((i) => i.processState === "running" || i.processState === "starting");
|
|
1153
1197
|
const busy = running.filter((i) => i.currentJob !== null).length;
|
|
1154
|
-
|
|
1155
|
-
|
|
1198
|
+
const iconText = busy > 0 ? `▶${running.length}↻${busy}` : `▶${running.length}`;
|
|
1199
|
+
if (iconText !== this.#lastIcon) {
|
|
1200
|
+
this.#lastIcon = iconText;
|
|
1201
|
+
const icon = this.#iconOf(iconText);
|
|
1202
|
+
if (icon) await this.tray.setIcon(icon);
|
|
1203
|
+
}
|
|
1204
|
+
const tooltip = JSON.stringify({
|
|
1156
1205
|
title: "GH Runner",
|
|
1157
1206
|
description: snapshot.instances.map((_, index) => `${snapshot.instances[index].name}: ${stateLabel(snapshot, index)}`).join("\n") || "尚无实例"
|
|
1158
1207
|
});
|
|
1159
|
-
|
|
1208
|
+
if (tooltip !== this.#lastTooltip) {
|
|
1209
|
+
this.#lastTooltip = tooltip;
|
|
1210
|
+
await this.tray.setTooltip(JSON.parse(tooltip));
|
|
1211
|
+
}
|
|
1212
|
+
const menu = JSON.stringify(this.#menuOf(snapshot));
|
|
1213
|
+
if (menu !== this.#lastMenu) {
|
|
1214
|
+
this.#lastMenu = menu;
|
|
1215
|
+
await this.tray.setMenu(JSON.parse(menu));
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
#iconOf(text) {
|
|
1219
|
+
if (this.#platform === "darwin") return { "darwin-icon-text": {
|
|
1220
|
+
type: "file",
|
|
1221
|
+
path: trayTemplatePath(),
|
|
1222
|
+
isTemplate: true,
|
|
1223
|
+
text
|
|
1224
|
+
} };
|
|
1225
|
+
if (this.#platform === "win32") return { "win32-icon-text": {
|
|
1226
|
+
type: "file",
|
|
1227
|
+
path: trayColorPath(),
|
|
1228
|
+
text
|
|
1229
|
+
} };
|
|
1230
|
+
return { "text-only": text };
|
|
1160
1231
|
}
|
|
1161
1232
|
/** onMenuClick 事件入口;返回是否已消费。 */
|
|
1162
1233
|
handleMenuClick(itemId) {
|
|
@@ -1224,6 +1295,7 @@ var UiHost = class {
|
|
|
1224
1295
|
/** 单飞守卫:hello 全量重推与去抖推送并发交错会让同一批日志行重复送达页面。 */
|
|
1225
1296
|
#pushInFlight = false;
|
|
1226
1297
|
#pushPending = false;
|
|
1298
|
+
#visible = true;
|
|
1227
1299
|
constructor(fleet, webviewTray, onOpenDiag, pickPackageFile, openExternal) {
|
|
1228
1300
|
this.fleet = fleet;
|
|
1229
1301
|
this.webviewTray = webviewTray;
|
|
@@ -1235,6 +1307,9 @@ var UiHost = class {
|
|
|
1235
1307
|
if (this.#window) {
|
|
1236
1308
|
await this.#window.show();
|
|
1237
1309
|
await this.#window.focus();
|
|
1310
|
+
this.#visible = true;
|
|
1311
|
+
this.#logOffsets.clear();
|
|
1312
|
+
this.#doPush();
|
|
1238
1313
|
return;
|
|
1239
1314
|
}
|
|
1240
1315
|
const htmlPath = join(dirname(fileURLToPath(import.meta.url)), "ui", "index.html");
|
|
@@ -1249,12 +1324,25 @@ var UiHost = class {
|
|
|
1249
1324
|
style: {
|
|
1250
1325
|
appMode: true,
|
|
1251
1326
|
autoHide: false,
|
|
1252
|
-
frameless: false
|
|
1327
|
+
frameless: false,
|
|
1328
|
+
background: {
|
|
1329
|
+
kind: "semantic",
|
|
1330
|
+
token: "blur",
|
|
1331
|
+
state: "active"
|
|
1332
|
+
}
|
|
1253
1333
|
}
|
|
1254
1334
|
});
|
|
1255
1335
|
this.#window = win;
|
|
1256
1336
|
await win.show();
|
|
1257
1337
|
await win.setMinimumSize(720, 520);
|
|
1338
|
+
win.listen("visibleChange", (event) => {
|
|
1339
|
+
const visible = event.payload.visible ?? true;
|
|
1340
|
+
this.#visible = visible;
|
|
1341
|
+
if (visible) {
|
|
1342
|
+
this.#logOffsets.clear();
|
|
1343
|
+
this.#doPush();
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1258
1346
|
this.#drainTimer = setInterval(() => void this.#drain(), DRAIN_INTERVAL_MS);
|
|
1259
1347
|
this.#drainTimer.unref?.();
|
|
1260
1348
|
this.#flush();
|
|
@@ -1298,7 +1386,7 @@ var UiHost = class {
|
|
|
1298
1386
|
async #doPush() {
|
|
1299
1387
|
const win = this.#window;
|
|
1300
1388
|
const snap = this.#lastSnapshot;
|
|
1301
|
-
if (!win || !snap || win.connectionDead) return;
|
|
1389
|
+
if (!win || !snap || win.connectionDead || !this.#visible) return;
|
|
1302
1390
|
if (this.#pushInFlight) {
|
|
1303
1391
|
this.#pushPending = true;
|
|
1304
1392
|
return;
|
|
@@ -1603,7 +1691,8 @@ async function main() {
|
|
|
1603
1691
|
}] }
|
|
1604
1692
|
}, {
|
|
1605
1693
|
appId: APP_ID,
|
|
1606
|
-
appName: "GH Runner"
|
|
1694
|
+
appName: "GH Runner",
|
|
1695
|
+
appIcon: appIcon()
|
|
1607
1696
|
});
|
|
1608
1697
|
const projection = new TrayProjection(tray, {
|
|
1609
1698
|
openWindow: () => void uiHost.open(),
|
|
@@ -1638,7 +1727,7 @@ async function main() {
|
|
|
1638
1727
|
};
|
|
1639
1728
|
process.on("SIGINT", () => void shutdown());
|
|
1640
1729
|
process.on("SIGTERM", () => void shutdown());
|
|
1641
|
-
console.log(`[gh-runner] 已启动(配置目录 ${
|
|
1730
|
+
console.log(`[gh-runner] 已启动(配置目录 ${configDir()})`);
|
|
1642
1731
|
}
|
|
1643
1732
|
main().catch((err) => {
|
|
1644
1733
|
console.error("[gh-runner] 启动失败:", err);
|