@jixoai/gh-runner 0.0.0 → 0.2.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/dist/main.mjs ADDED
@@ -0,0 +1,1656 @@
1
+ #!/usr/bin/env node
2
+ import { arch, homedir, platform } from "node:os";
3
+ import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { execFile, spawn } from "node:child_process";
5
+ import { createTray } from "opentray";
6
+ import { attachDialog } from "@opentray/ext-dialog";
7
+ import { dirname, isAbsolute, join } from "node:path";
8
+ import { createWriteStream, existsSync } from "node:fs";
9
+ import { promisify } from "node:util";
10
+ import { Readable } from "node:stream";
11
+ import { pipeline } from "node:stream/promises";
12
+ import { fileURLToPath } from "node:url";
13
+ import { WebviewExt } from "@opentray/ext-webview";
14
+ // src/tailer.ts
15
+ /**
16
+ * 跟随一个目录中最新的、名字匹配 `prefix*.log` 的文件。
17
+ * - 文件被轮转(出现更新者)时切换并从尾部起步;
18
+ * - 文件被截断时重置 offset;
19
+ * - start() 首次把既有尾部内容以回调交付(用于恢复状态),此后持续增量。
20
+ */
21
+ var DirTail = class {
22
+ #dir;
23
+ #prefix;
24
+ #intervalMs;
25
+ #initialBackreadBytes;
26
+ #timer = null;
27
+ #activeFile = null;
28
+ #offset = 0;
29
+ #partial = "";
30
+ /** 首附/轮转从文件中部(64KB 窗口)起步时,首个片段是残行,须丢弃到首个换行。 */
31
+ #skipFirstPartial = false;
32
+ #polling = false;
33
+ constructor(dir, prefix, options = {}) {
34
+ this.#dir = dir;
35
+ this.#prefix = prefix;
36
+ this.#intervalMs = options.intervalMs ?? 1e3;
37
+ this.#initialBackreadBytes = options.initialBackreadBytes ?? 65536;
38
+ }
39
+ start(onLines) {
40
+ if (this.#timer) return;
41
+ this.#poll(onLines);
42
+ this.#timer = setInterval(() => void this.#poll(onLines), this.#intervalMs);
43
+ this.#timer.unref?.();
44
+ }
45
+ stop() {
46
+ if (this.#timer) clearInterval(this.#timer);
47
+ this.#timer = null;
48
+ this.#activeFile = null;
49
+ this.#offset = 0;
50
+ this.#partial = "";
51
+ }
52
+ async #listCandidates() {
53
+ let names;
54
+ try {
55
+ names = await readdir(this.#dir);
56
+ } catch {
57
+ return [];
58
+ }
59
+ const matched = names.filter((n) => n.startsWith(this.#prefix) && n.endsWith(".log"));
60
+ const out = [];
61
+ for (const name of matched) try {
62
+ const st = await stat(join(this.#dir, name));
63
+ if (st.isFile()) out.push({
64
+ file: name,
65
+ mtimeMs: st.mtimeMs
66
+ });
67
+ } catch {}
68
+ out.sort((a, b) => a.mtimeMs === b.mtimeMs ? a.file < b.file ? 1 : -1 : b.mtimeMs - a.mtimeMs);
69
+ return out;
70
+ }
71
+ async #poll(onLines) {
72
+ if (this.#polling) return;
73
+ this.#polling = true;
74
+ try {
75
+ const latest = (await this.#listCandidates())[0];
76
+ if (!latest) {
77
+ this.#activeFile = null;
78
+ return;
79
+ }
80
+ const path = join(this.#dir, latest.file);
81
+ if (this.#activeFile !== path) {
82
+ const st = await stat(path);
83
+ const start = Math.max(0, st.size - this.#initialBackreadBytes);
84
+ this.#activeFile = path;
85
+ this.#offset = start;
86
+ this.#partial = "";
87
+ this.#skipFirstPartial = start > 0;
88
+ }
89
+ const st = await stat(path).catch(() => null);
90
+ if (!st) return;
91
+ if (st.size < this.#offset) {
92
+ this.#offset = 0;
93
+ this.#partial = "";
94
+ }
95
+ if (st.size === this.#offset) return;
96
+ const handle = await open(path, "r");
97
+ try {
98
+ const length = st.size - this.#offset;
99
+ const buffer = Buffer.alloc(length);
100
+ await handle.read(buffer, 0, length, this.#offset);
101
+ this.#offset = st.size;
102
+ let text = this.#partial + buffer.toString("utf8");
103
+ if (this.#skipFirstPartial) {
104
+ const firstNl = text.indexOf("\n");
105
+ text = firstNl >= 0 ? text.slice(firstNl + 1) : "";
106
+ this.#skipFirstPartial = false;
107
+ }
108
+ const lines = text.split("\n");
109
+ this.#partial = lines.pop() ?? "";
110
+ const complete = lines.filter((l) => l.trim().length > 0);
111
+ if (complete.length > 0) onLines(complete);
112
+ } finally {
113
+ await handle.close();
114
+ }
115
+ } catch {} finally {
116
+ this.#polling = false;
117
+ }
118
+ }
119
+ };
120
+ // src/logparse.ts
121
+ /** 日志行前缀(diag 文件形态):`[2026-10-24 10:16:04Z INFO Terminal] WRITE LINE: `。 */
122
+ const DIAG_PREFIX = "] WRITE LINE: ";
123
+ const ANSI$1 = /\x1b\[[0-9;]*m/g;
124
+ const TS = "([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2})";
125
+ const RE_RUNNING = new RegExp(`^${TS}Z: Running job: (.+)$`);
126
+ const RE_COMPLETED = new RegExp(`^${TS}Z: Job (.+) completed with result: (.+)$`);
127
+ const RE_LISTENING = new RegExp(`^${TS}Z: Listening for Jobs$`);
128
+ /** `2026-10-24 10:16:04` → `2026-10-24T10:16:04Z`(runner 控制台时间戳恒为 UTC)。 */
129
+ function toIsoUtc(raw) {
130
+ return `${raw.replace(" ", "T")}Z`;
131
+ }
132
+ /** 剥离 diag 包裹前缀(`[..INFO Terminal] WRITE LINE: `),非包裹行原样返回。 */
133
+ function stripDiagPrefix(line) {
134
+ const marker = line.indexOf(DIAG_PREFIX);
135
+ return marker >= 0 ? line.slice(marker + 14) : line;
136
+ }
137
+ /** 剥离 diag 包裹与 ANSI 颜色,还原纯控制台行;非控制台行返回 null。 */
138
+ function normalizeLine(line) {
139
+ const stripped = stripDiagPrefix(line).replaceAll(ANSI$1, "").trimEnd();
140
+ return RE_LISTENING.test(stripped) || RE_RUNNING.test(stripped) || RE_COMPLETED.test(stripped) ? stripped : null;
141
+ }
142
+ /** 解析一行控制台输出;不是作业事件行时返回 null。 */
143
+ function parseConsoleLine(line) {
144
+ const content = normalizeLine(line);
145
+ if (content === null) return null;
146
+ const completed = RE_COMPLETED.exec(content);
147
+ if (completed) return {
148
+ at: toIsoUtc(completed[1]),
149
+ kind: "job-end",
150
+ job: completed[2],
151
+ outcome: completed[3].trim()
152
+ };
153
+ const running = RE_RUNNING.exec(content);
154
+ if (running) return {
155
+ at: toIsoUtc(running[1]),
156
+ kind: "job-start",
157
+ job: running[2].trim()
158
+ };
159
+ const listening = RE_LISTENING.exec(content);
160
+ if (listening) return {
161
+ at: toIsoUtc(listening[1]),
162
+ kind: "idle"
163
+ };
164
+ return null;
165
+ }
166
+ // src/platform.ts
167
+ const IS_WIN = platform() === "win32";
168
+ const PLATFORM = platform();
169
+ const ARCH = arch();
170
+ /** actions/runner release 资产的平台段:darwin→osx, win32→win, linux→linux。 */
171
+ function osSegment(p = PLATFORM) {
172
+ if (p === "darwin") return "osx";
173
+ if (p === "win32") return "win";
174
+ return "linux";
175
+ }
176
+ /** arch 段:ia32→x86, arm64→arm64, 其余→x64。 */
177
+ function archSegment(a = ARCH) {
178
+ if (a === "ia32") return "x86";
179
+ if (a === "arm64") return "arm64";
180
+ return "x64";
181
+ }
182
+ /** 官方 release 资产名,如 actions-runner-osx-arm64-2.338.1.tar.gz / actions-runner-win-x64-2.338.1.zip。 */
183
+ function assetNameFor(version, p = PLATFORM, a = ARCH) {
184
+ const ext = osSegment(p) === "win" ? "zip" : "tar.gz";
185
+ return `actions-runner-${osSegment(p)}-${archSegment(a)}-${version}.${ext}`;
186
+ }
187
+ /** 包文件是否与当前平台匹配(供 UI 把不兼容包置灰)。 */
188
+ function packageCompatible(fileName) {
189
+ const seg = `${osSegment()}-${archSegment()}`;
190
+ return fileName.includes(`-${seg}-`);
191
+ }
192
+ /** runner 启动入口:darwin/linux 用 run.sh;win32 直接跑 Listener.exe(run.cmd 只是它的薄包装)。 */
193
+ function runnerSpawn(dir) {
194
+ if (IS_WIN) return {
195
+ file: join(dir, "bin", "Runner.Listener.exe"),
196
+ args: ["run"]
197
+ };
198
+ return {
199
+ file: join(dir, "run.sh"),
200
+ args: []
201
+ };
202
+ }
203
+ /** 注册命令:darwin/linux 走 ./config.sh;win32 用 Listener 的 configure 动词(避免 cmd.exe 转义坑)。 */
204
+ function registerSpawn(dir) {
205
+ if (IS_WIN) return {
206
+ file: join(dir, "bin", "Runner.Listener.exe"),
207
+ prefixArgs: ["configure"]
208
+ };
209
+ return {
210
+ file: "./config.sh",
211
+ prefixArgs: []
212
+ };
213
+ }
214
+ /** labels 默认值(UI 预填)。 */
215
+ function defaultLabels() {
216
+ if (PLATFORM === "darwin") return "self-hosted,macOS,ARM64";
217
+ if (IS_WIN) return `self-hosted,Windows,${archSegment().toUpperCase()}`;
218
+ return `self-hosted,Linux,${archSegment().toUpperCase()}`;
219
+ }
220
+ /** 实例目录的默认父目录(UI placeholder 与初始 searchDirs)。 */
221
+ function defaultSearchDirs() {
222
+ if (PLATFORM === "darwin") return ["/Volumes/bnqkl/Users/kzf"];
223
+ if (IS_WIN) return ["C:\\actions-runner", homedir()];
224
+ return [homedir()];
225
+ }
226
+ /** 在系统文件管理器中揭示目录/文件。 */
227
+ function revealInFileManager(path) {
228
+ if (PLATFORM === "darwin") return {
229
+ file: "open",
230
+ args: ["-R", path]
231
+ };
232
+ if (IS_WIN) return {
233
+ file: "explorer",
234
+ args: [`/select,${path}`]
235
+ };
236
+ return {
237
+ file: "xdg-open",
238
+ args: [path]
239
+ };
240
+ }
241
+ /** 平台路径分段(win32 反斜杠)。 */
242
+ function basenameOf(dir) {
243
+ const parts = dir.split(/[\\/]+/).filter(Boolean);
244
+ return parts[parts.length - 1] ?? dir;
245
+ }
246
+ /** 平台无关的绝对路径判定。 */
247
+ function isAbsolutePath(p) {
248
+ if (IS_WIN) return /^[A-Za-z]:[\\/]/.test(p) || isAbsolute(p);
249
+ return isAbsolute(p);
250
+ }
251
+ // src/discover.ts
252
+ const exec = promisify(execFile);
253
+ /** 读取 <dir>/.runner;缺失或损坏时返回未配置态。注意 GitHub 写入的文件带 UTF-8 BOM。 */
254
+ async function readRunnerMeta(dir) {
255
+ try {
256
+ const raw = (await readFile(join(dir, ".runner"), "utf8")).replace(/^\uFEFF/, "");
257
+ const json = JSON.parse(raw);
258
+ return {
259
+ agentId: typeof json.agentId === "number" ? json.agentId : null,
260
+ agentName: json.agentName ?? null,
261
+ gitHubUrl: json.gitHubUrl ?? null,
262
+ workFolder: json.workFolder ?? null,
263
+ configured: true
264
+ };
265
+ } catch {
266
+ return {
267
+ agentId: null,
268
+ agentName: null,
269
+ gitHubUrl: null,
270
+ workFolder: null,
271
+ configured: false
272
+ };
273
+ }
274
+ }
275
+ /** 扫描父目录集合,返回含 .runner 的一层子目录(绝对路径)。 */
276
+ async function discoverRunnerDirs(searchDirs) {
277
+ const found = [];
278
+ for (const parent of searchDirs) {
279
+ let names;
280
+ try {
281
+ names = (await readdir(parent, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
282
+ } catch {
283
+ continue;
284
+ }
285
+ for (const name of names) {
286
+ const dir = join(parent, name);
287
+ try {
288
+ await readFile(join(dir, ".runner"), "utf8");
289
+ found.push(dir);
290
+ } catch {}
291
+ }
292
+ }
293
+ return found.sort();
294
+ }
295
+ /**
296
+ * 全部 Runner.Listener 进程 → pid 身份(darwin/linux = cwd;win32 = 由 ExecutablePath 推导的
297
+ * runner 目录)。身份与实例目录对账。
298
+ */
299
+ async function findListenerProcesses() {
300
+ if (IS_WIN) {
301
+ const result = /* @__PURE__ */ new Map();
302
+ try {
303
+ const { stdout } = await exec("powershell", [
304
+ "-NoProfile",
305
+ "-Command",
306
+ `Get-CimInstance Win32_Process -Filter "Name='Runner.Listener.exe'" | ForEach-Object { "$($_.ProcessId)|$($_.ExecutablePath)" }`
307
+ ], { timeout: 15e3 });
308
+ for (const line of stdout.split(/\r?\n/)) {
309
+ const m = /^(\d+)\|(.+)$/.exec(line.trim());
310
+ if (!m) continue;
311
+ result.set(Number(m[1]), dirname(dirname(m[2])));
312
+ }
313
+ } catch {}
314
+ return result;
315
+ }
316
+ const result = /* @__PURE__ */ new Map();
317
+ try {
318
+ const { stdout } = await exec("pgrep", ["-f", "Runner.Listener run"]);
319
+ const pids = stdout.split("\n").map((s) => Number.parseInt(s.trim(), 10)).filter((n) => Number.isInteger(n) && n > 0);
320
+ await Promise.all(pids.map(async (pid) => {
321
+ try {
322
+ const { stdout: out } = await exec("lsof", [
323
+ "-a",
324
+ "-p",
325
+ String(pid),
326
+ "-d",
327
+ "cwd",
328
+ "-Fn"
329
+ ]);
330
+ for (const line of out.split("\n")) if (line.startsWith("n/")) {
331
+ const raw = line.slice(1);
332
+ result.set(pid, await realpath(raw).catch(() => raw));
333
+ }
334
+ } catch {}
335
+ }));
336
+ } catch {}
337
+ return result;
338
+ }
339
+ /** 实例默认 id:目录名。 */
340
+ function instanceIdOf(dir, _cfg) {
341
+ const parts = dir.split(/[\\/]+/).filter(Boolean);
342
+ return parts[parts.length - 1] ?? dir;
343
+ }
344
+ /** 单个 pid 的身份(同 findListenerProcesses 口径;读不到返回 null)。供停止前的 pid 复用核验。 */
345
+ async function pidIdentity(pid) {
346
+ return (await findListenerProcesses()).get(pid) ?? null;
347
+ }
348
+ // src/supervisor.ts
349
+ const ANSI = /\x1b\[[0-9;]*m/g;
350
+ function stripAnsi(line) {
351
+ return line.replaceAll(ANSI, "").trimEnd();
352
+ }
353
+ function splitLines(chunk) {
354
+ return chunk.split(/\r?\n/).filter((l) => l.trim().length > 0);
355
+ }
356
+ /**
357
+ * 拉起 <dir>/run.sh(detached:随工具退出继续存活,与"终端里跑"等价)。
358
+ * 返回 null 表示 run.sh 不存在或无法 spawn(权限/解释器缺失——错误细节经 onLine(stderr) 回流)。
359
+ */
360
+ function startRunner(dir, onLine, onError) {
361
+ const entry = runnerSpawn(dir);
362
+ if (!(IS_WIN ? existsSync(entry.file) : existsSync(join(dir, "run.sh")))) return null;
363
+ const child = IS_WIN ? spawn(entry.file, entry.args, {
364
+ cwd: dir,
365
+ detached: true,
366
+ windowsHide: true,
367
+ stdio: [
368
+ "ignore",
369
+ "pipe",
370
+ "pipe"
371
+ ],
372
+ env: { ...process.env }
373
+ }) : spawn(entry.file, entry.args, {
374
+ cwd: dir,
375
+ detached: true,
376
+ stdio: [
377
+ "ignore",
378
+ "pipe",
379
+ "pipe"
380
+ ],
381
+ env: {
382
+ ...process.env,
383
+ RUNNER_MANUALLY_TRAP_SIG: "1"
384
+ }
385
+ });
386
+ child.on("error", (err) => {
387
+ onError?.(err);
388
+ onLine("stderr", `[spawn error] ${err.message}`);
389
+ });
390
+ if (child.pid == null) {
391
+ onLine("stderr", `[spawn error] 无法启动 run.sh(检查执行权限/解释器)`);
392
+ return null;
393
+ }
394
+ for (const stream of ["stdout", "stderr"]) {
395
+ child[stream].setEncoding("utf8");
396
+ child[stream].on("data", (chunk) => {
397
+ for (const line of splitLines(chunk)) onLine(stream, stripAnsi(line));
398
+ });
399
+ }
400
+ child.unref();
401
+ return {
402
+ pid: child.pid,
403
+ child
404
+ };
405
+ }
406
+ function pidExists(pid) {
407
+ try {
408
+ process.kill(pid, 0);
409
+ return "alive";
410
+ } catch (err) {
411
+ return err.code === "EPERM" ? "no-perm" : "dead";
412
+ }
413
+ }
414
+ /**
415
+ * 终止:darwin/linux 对进程组 SIGTERM → 限时 SIGKILL;win32 用 taskkill /T(树终止),
416
+ * 先宽限尝试再强制。返回 false 表示终止失败(进程未响应或无权限——EPERM 不算成功)。
417
+ */
418
+ async function stopProcessGroup(pid, graceMs = 8e3) {
419
+ if (pidExists(pid) === "dead") return true;
420
+ if (IS_WIN) {
421
+ const tk = spawn("taskkill", [
422
+ "/pid",
423
+ String(pid),
424
+ "/T",
425
+ "/F"
426
+ ], {
427
+ stdio: "ignore",
428
+ windowsHide: true
429
+ });
430
+ const ok = await new Promise((resolve) => {
431
+ tk.on("error", () => resolve(false));
432
+ tk.on("close", (code) => resolve(code === 0 || pidExists(pid) === "dead"));
433
+ });
434
+ const deadline = Date.now() + graceMs;
435
+ while (ok && Date.now() < deadline && pidExists(pid) !== "dead") await new Promise((r) => setTimeout(r, 250));
436
+ return pidExists(pid) === "dead";
437
+ }
438
+ const signal = (sig) => {
439
+ try {
440
+ process.kill(-pid, sig);
441
+ return true;
442
+ } catch {
443
+ try {
444
+ process.kill(pid, sig);
445
+ return true;
446
+ } catch {
447
+ return false;
448
+ }
449
+ }
450
+ };
451
+ if (!signal("SIGTERM")) return false;
452
+ const deadline = Date.now() + graceMs;
453
+ while (Date.now() < deadline) {
454
+ await new Promise((r) => setTimeout(r, 250));
455
+ if (pidExists(pid) === "dead") return true;
456
+ }
457
+ if (!signal("SIGKILL")) return false;
458
+ await new Promise((r) => setTimeout(r, 500));
459
+ return pidExists(pid) === "dead";
460
+ }
461
+ // src/config.ts
462
+ const DEFAULT_SEARCH_DIRS = defaultSearchDirs();
463
+ function configDir() {
464
+ return join(homedir(), "Library", "Application Support", "gh-runner");
465
+ }
466
+ function configPath() {
467
+ return join(configDir(), "config.json");
468
+ }
469
+ /** 形状校验:损坏的实例项直接丢弃(手编配置/撕裂写的容错),不让 boot 崩掉。 */
470
+ function validInstance(item) {
471
+ return typeof item === "object" && item !== null && typeof item.dir === "string" && item.dir.startsWith("/") && typeof item.id === "string";
472
+ }
473
+ async function loadConfig() {
474
+ try {
475
+ const raw = (await readFile(configPath(), "utf8")).replace(/^\uFEFF/, "");
476
+ const parsed = JSON.parse(raw);
477
+ const searchDirs = Array.isArray(parsed.searchDirs) ? parsed.searchDirs.filter((d) => typeof d === "string" && isAbsolutePath(d)) : [];
478
+ const instances = Array.isArray(parsed.instances) ? parsed.instances.filter((i) => validInstance(i)) : [];
479
+ const importedPackages = Array.isArray(parsed.importedPackages) ? parsed.importedPackages.filter((p) => typeof p === "string") : [];
480
+ return {
481
+ searchDirs: searchDirs.length > 0 ? searchDirs : DEFAULT_SEARCH_DIRS,
482
+ instances,
483
+ importedPackages
484
+ };
485
+ } catch {
486
+ return {
487
+ searchDirs: DEFAULT_SEARCH_DIRS,
488
+ instances: [],
489
+ importedPackages: []
490
+ };
491
+ }
492
+ }
493
+ /** 原子写:tmp + rename,杜绝撕裂写(损坏的 config 会 brick 启动路径)。 */
494
+ async function saveConfig(config) {
495
+ await mkdir(configDir(), { recursive: true });
496
+ const path = configPath();
497
+ const tmp = `${path}.tmp`;
498
+ await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, "utf8");
499
+ await rename(tmp, path);
500
+ }
501
+ function upsertInstance(config, instance) {
502
+ const rest = config.instances.filter((i) => i.id !== instance.id && i.dir !== instance.dir);
503
+ return {
504
+ ...config,
505
+ instances: [...rest, instance]
506
+ };
507
+ }
508
+ // src/register.ts
509
+ async function runStep(title, file, args, cwd, onProgress, timeoutMs = 3e5) {
510
+ onProgress(`$ ${title}`);
511
+ await new Promise((resolve, reject) => {
512
+ const child = spawn(file, args, {
513
+ cwd,
514
+ stdio: [
515
+ "ignore",
516
+ "pipe",
517
+ "pipe"
518
+ ]
519
+ });
520
+ const timer = setTimeout(() => {
521
+ child.kill("SIGKILL");
522
+ reject(/* @__PURE__ */ new Error(`${title} 超时(${timeoutMs / 1e3}s)`));
523
+ }, timeoutMs);
524
+ child.stdout.setEncoding("utf8");
525
+ child.stderr.setEncoding("utf8");
526
+ for (const stream of [child.stdout, child.stderr]) {
527
+ let partial = "";
528
+ stream.on("data", (chunk) => {
529
+ partial += chunk;
530
+ const lines = partial.split(/\r?\n/);
531
+ partial = lines.pop() ?? "";
532
+ for (const l of lines) if (l.trim()) onProgress(` ${l.trim()}`);
533
+ });
534
+ }
535
+ child.on("error", (err) => {
536
+ clearTimeout(timer);
537
+ reject(/* @__PURE__ */ new Error(`${title} 启动失败: ${err.message}`));
538
+ });
539
+ child.on("close", (code) => {
540
+ clearTimeout(timer);
541
+ if (code === 0) resolve();
542
+ else reject(/* @__PURE__ */ new Error(`${title} 退出码 ${code}`));
543
+ });
544
+ });
545
+ }
546
+ /** 旧版回退:在 searchDirs 的约定位置(actions-runner/)探测既有分发包。 */
547
+ async function findTarball(searchDirs) {
548
+ for (const parent of searchDirs) for (const sub of ["actions-runner", "."]) {
549
+ const dir = join(parent, sub);
550
+ try {
551
+ const hit = (await readdir(dir)).filter((n) => /^actions-runner-(osx|win|linux)-(x86|x64|arm64)-.*\.(tar\.gz|zip)$/.test(n)).sort().at(-1);
552
+ if (hit) return join(dir, hit);
553
+ } catch {}
554
+ }
555
+ return null;
556
+ }
557
+ async function registerRunner(req, searchDirs, onProgress) {
558
+ const dir = req.dir;
559
+ try {
560
+ if (!dir || !isAbsolutePath(dir)) throw new Error("实例目录必须是绝对路径");
561
+ if (!req.name.trim()) throw new Error("缺少实例名称");
562
+ if (!req.token.trim()) throw new Error("缺少注册 Token(org 设置页每次生成、一次性)");
563
+ if (!req.workFolder.trim()) throw new Error("缺少 Work 目录(多实例必须各自独立)");
564
+ let entries = [];
565
+ if (existsSync(dir)) {
566
+ entries = await readdir(dir);
567
+ if (entries.some((e) => e !== ".DS_Store")) throw new Error(`目录非空(${entries.length} 项):注册必须是空目录;管理已有实例请直接 attach`);
568
+ } else {
569
+ onProgress(`创建目录 ${dir}`);
570
+ await mkdir(dir, { recursive: true });
571
+ }
572
+ const pkg = req.packagePath?.trim() ? req.packagePath.trim() : await findTarball(searchDirs);
573
+ if (!pkg || !existsSync(pkg)) throw new Error("未选定分发包:请下载最新版或导入本地包");
574
+ onProgress(`使用分发包 ${basenameOf(pkg)}`);
575
+ await runStep("解包分发包", "tar", ["-xf", pkg], dir, onProgress);
576
+ if (PLATFORM === "darwin") await runStep("移除 quarantine 隔离标记", "xattr", [
577
+ "-rd",
578
+ "com.apple.quarantine",
579
+ "."
580
+ ], dir, onProgress);
581
+ const reg = registerSpawn(dir);
582
+ if (!existsSync(join(dir, reg.file.replace("./", ""))) && !IS_WIN) throw new Error("解包结果缺少 config.sh:包内容与预期不符");
583
+ await runStep(IS_WIN ? "Listener configure 注册" : "config.sh 注册", reg.file, [
584
+ ...reg.prefixArgs,
585
+ "--url",
586
+ req.orgUrl.trim() || "https://github.com/jixoai",
587
+ "--token",
588
+ req.token.trim(),
589
+ "--name",
590
+ req.name.trim(),
591
+ "--labels",
592
+ req.labels.trim() || defaultLabels(),
593
+ "--work",
594
+ req.workFolder.trim(),
595
+ "--unattended"
596
+ ], dir, onProgress);
597
+ const meta = await readRunnerMeta(dir);
598
+ if (!meta.configured) throw new Error("注册后未找到 .runner,注册疑似失败,请查看上方输出");
599
+ onProgress(`✔ 注册成功:${meta.agentName}(agentId=${meta.agentId})`);
600
+ return {
601
+ ok: true,
602
+ error: null,
603
+ runnerDir: dir
604
+ };
605
+ } catch (err) {
606
+ const message = err instanceof Error ? err.message : String(err);
607
+ onProgress(`✘ 注册失败:${message}`);
608
+ return {
609
+ ok: false,
610
+ error: message,
611
+ runnerDir: dir
612
+ };
613
+ }
614
+ }
615
+ // src/packages.ts
616
+ /** 下载缓存目录(下载的包与导入清单都归工具所有)。 */
617
+ function packagesDir() {
618
+ return join(configDir(), "packages");
619
+ }
620
+ const PKG_RE = /^actions-runner-(osx|win|linux)-(x86|x64|arm64)-\d+\.\d+\.\d+\.(tar\.gz|zip)$/;
621
+ function toInfo(path, st) {
622
+ const name = path.split(/[\\/]+/).pop() ?? path;
623
+ return {
624
+ path,
625
+ name,
626
+ sizeBytes: st.size,
627
+ mtimeMs: st.mtimeMs,
628
+ compatible: packageCompatible(name)
629
+ };
630
+ }
631
+ /** 包清单:下载缓存目录里的包 + config 导入的外部路径(去重、新者在前)。 */
632
+ async function listPackages(importedPaths) {
633
+ const out = /* @__PURE__ */ new Map();
634
+ const dir = packagesDir();
635
+ if (existsSync(dir)) for (const name of await readdir(dir)) {
636
+ const path = join(dir, name);
637
+ if (!PKG_RE.test(name)) continue;
638
+ try {
639
+ const st = await stat(path);
640
+ if (st.isFile()) out.set(path, toInfo(path, st));
641
+ } catch {}
642
+ }
643
+ for (const path of importedPaths) try {
644
+ const st = await stat(path);
645
+ if (st.isFile() && !out.has(path)) out.set(path, toInfo(path, st));
646
+ } catch {}
647
+ return [...out.values()].sort((a, b) => b.mtimeMs - a.mtimeMs);
648
+ }
649
+ /** 查询 actions/runner 最新 release 中匹配当前平台的资产。 */
650
+ async function fetchLatestReleaseInfo() {
651
+ const res = await fetch("https://api.github.com/repos/actions/runner/releases/latest", { headers: {
652
+ "User-Agent": "jixoai-gh-runner",
653
+ Accept: "application/vnd.github+json"
654
+ } });
655
+ if (!res.ok) throw new Error(`GitHub API ${res.status}(查询最新 release 失败)`);
656
+ const body = await res.json();
657
+ const version = (body.tag_name ?? "").replace(/^v/, "");
658
+ if (!version || !Array.isArray(body.assets)) throw new Error("release 响应缺少 tag_name/assets");
659
+ const assetName = assetNameFor(version);
660
+ const asset = body.assets.find((a) => a.name === assetName);
661
+ if (!asset) throw new Error(`最新 release ${body.tag_name} 没有当前平台的资产(期望 ${assetName})`);
662
+ return {
663
+ tag: body.tag_name,
664
+ version,
665
+ assetName,
666
+ url: asset.browser_download_url,
667
+ sizeBytes: asset.size
668
+ };
669
+ }
670
+ /** 流式下载到包缓存目录(tmp→rename 原子替换);onProgress 以 0-100 汇报。 */
671
+ async function downloadPackage(release, onProgress) {
672
+ await mkdir(packagesDir(), { recursive: true });
673
+ const dest = join(packagesDir(), release.assetName);
674
+ const tmp = `${dest}.part`;
675
+ await rm(tmp, { force: true });
676
+ const res = await fetch(release.url, {
677
+ headers: { "User-Agent": "jixoai-gh-runner" },
678
+ redirect: "follow"
679
+ });
680
+ if (!res.ok || !res.body) throw new Error(`下载失败:HTTP ${res.status}`);
681
+ const total = Number(res.headers.get("content-length") ?? release.sizeBytes) || release.sizeBytes;
682
+ let received = 0;
683
+ let lastPct = -1;
684
+ const source = Readable.fromWeb(res.body);
685
+ source.on("data", (chunk) => {
686
+ received += chunk.length;
687
+ const pct = total > 0 ? Math.min(100, Math.floor(received / total * 100)) : 0;
688
+ if (pct !== lastPct) {
689
+ lastPct = pct;
690
+ onProgress(pct, received, total);
691
+ }
692
+ });
693
+ await pipeline(source, createWriteStream(tmp));
694
+ if (total > 0 && received !== total) {
695
+ await rm(tmp, { force: true });
696
+ throw new Error(`下载不完整:${received}/${total} 字节`);
697
+ }
698
+ await rm(dest, { force: true });
699
+ await rename(tmp, dest);
700
+ return dest;
701
+ }
702
+ // src/fleet.ts
703
+ const LOG_RING_MAX = 400;
704
+ const DEDUP_MAX = 2400;
705
+ const TICK_MS = 2e3;
706
+ const DEBOUNCE_MS = 200;
707
+ var Fleet = class {
708
+ #rts = /* @__PURE__ */ new Map();
709
+ #config = null;
710
+ #listeners = /* @__PURE__ */ new Set();
711
+ #seq = 0;
712
+ #dirty = false;
713
+ #debounceTimer = null;
714
+ #tickTimer = null;
715
+ #task = {
716
+ kind: "none",
717
+ status: "ok",
718
+ message: null
719
+ };
720
+ #ticking = false;
721
+ #persist = true;
722
+ #download = null;
723
+ #downloading = false;
724
+ async boot(options = {}) {
725
+ this.#persist = options.persist ?? true;
726
+ this.#config = await loadConfig();
727
+ await this.#syncInstances();
728
+ await this.#tick();
729
+ this.#tickTimer = setInterval(() => void this.#tick(), TICK_MS);
730
+ this.#tickTimer.unref?.();
731
+ if (options.autoStart ?? true) {
732
+ for (const rt of this.#rts.values()) if (rt.autoStart && rt.meta?.configured && rt.processState === "stopped") this.start(rt.id);
733
+ }
734
+ }
735
+ onSnapshot(listener) {
736
+ this.#listeners.add(listener);
737
+ listener(this.snapshot());
738
+ return () => this.#listeners.delete(listener);
739
+ }
740
+ snapshot() {
741
+ this.#seq += 1;
742
+ return {
743
+ seq: this.#seq,
744
+ now: (/* @__PURE__ */ new Date()).toISOString(),
745
+ platform: PLATFORM,
746
+ arch: ARCH,
747
+ instances: [...this.#rts.values()].map((rt) => this.#view(rt)).sort((a, b) => a.name.localeCompare(b.name)),
748
+ task: this.#task,
749
+ packages: this.#packages,
750
+ download: this.#download
751
+ };
752
+ }
753
+ #packages = [];
754
+ /** 刷新包清单(下载目录 ∪ 导入清单),变更时推送。 */
755
+ async #refreshPackages() {
756
+ try {
757
+ const before = JSON.stringify(this.#packages);
758
+ const stale = [];
759
+ this.#packages = await listPackages(this.#config?.importedPackages ?? []);
760
+ const paths = new Set(this.#packages.map((p) => p.path));
761
+ for (const imported of this.#config?.importedPackages ?? []) if (!paths.has(imported)) stale.push(imported);
762
+ if (stale.length > 0 && this.#config) {
763
+ this.#config.importedPackages = (this.#config.importedPackages ?? []).filter((p) => !stale.includes(p));
764
+ if (this.#persist) await saveConfig(this.#config);
765
+ }
766
+ if (JSON.stringify(this.#packages) !== before) this.#emitSoon();
767
+ } catch {}
768
+ }
769
+ /** 下载 actions/runner 最新平台的包(进度写入 snapshot.download,完成后刷新清单)。 */
770
+ async downloadLatest() {
771
+ if (this.#downloading) return;
772
+ this.#downloading = true;
773
+ this.#download = {
774
+ status: "running",
775
+ pct: 0,
776
+ message: "查询最新 release…"
777
+ };
778
+ this.#emitSoon();
779
+ try {
780
+ const release = await fetchLatestReleaseInfo();
781
+ this.#download = {
782
+ status: "running",
783
+ pct: 0,
784
+ message: `下载 ${release.assetName}`
785
+ };
786
+ this.#emitSoon();
787
+ const dest = await downloadPackage(release, (pct) => {
788
+ this.#download = {
789
+ status: "running",
790
+ pct,
791
+ message: `下载 ${release.assetName}`
792
+ };
793
+ this.#emitSoon();
794
+ });
795
+ this.#download = {
796
+ status: "ok",
797
+ pct: 100,
798
+ message: `已下载 ${dest}`
799
+ };
800
+ await this.#refreshPackages();
801
+ } catch (err) {
802
+ const message = err instanceof Error ? err.message : String(err);
803
+ this.#download = {
804
+ status: "error",
805
+ pct: 0,
806
+ message
807
+ };
808
+ } finally {
809
+ this.#downloading = false;
810
+ this.#emitSoon();
811
+ setTimeout(() => {
812
+ if (this.#download && this.#download.status !== "running") {
813
+ this.#download = null;
814
+ this.#emitSoon();
815
+ }
816
+ }, 8e3).unref?.();
817
+ }
818
+ }
819
+ /** 通过系统文件选择对话框导入本地包路径(登记进 config,不复制)。 */
820
+ async importPackage(path) {
821
+ if (!this.#config || !isAbsolutePath(path)) return;
822
+ if (!this.#config.importedPackages.includes(path)) {
823
+ this.#config.importedPackages = [...this.#config.importedPackages, path];
824
+ if (this.#persist) await saveConfig(this.#config);
825
+ }
826
+ await this.#refreshPackages();
827
+ }
828
+ /** 页面加载完成后的主动重推(对齐 hello)。 */
829
+ ping() {
830
+ this.#emit();
831
+ }
832
+ view(id) {
833
+ const rt = this.#rts.get(id);
834
+ return rt ? this.#view(rt) : null;
835
+ }
836
+ async start(id) {
837
+ const rt = this.#rts.get(id);
838
+ if (!rt) return;
839
+ if (rt.processState === "running" || rt.processState === "starting") return;
840
+ if (!rt.meta?.configured) {
841
+ rt.lastError = "实例尚未注册(目录缺 .runner),先走注册表单";
842
+ this.#emit();
843
+ return;
844
+ }
845
+ rt.lastError = null;
846
+ let spawnError = null;
847
+ const spawned = startRunner(rt.dir, (stream, line) => this.#onManagedLine(rt, stream, line), (err) => {
848
+ spawnError = err;
849
+ });
850
+ if (!spawned) {
851
+ rt.lastError = "无法启动 runner(检查 run.sh / Runner.Listener.exe 是否存在且有执行权限)";
852
+ this.#emit();
853
+ return;
854
+ }
855
+ rt.child = spawned;
856
+ rt.pid = spawned.pid;
857
+ rt.external = false;
858
+ rt.processState = "starting";
859
+ spawned.child.on("close", (code, signal) => {
860
+ if (rt.child?.pid !== spawned.pid) return;
861
+ rt.child = null;
862
+ rt.pid = null;
863
+ rt.processState = "stopped";
864
+ rt.currentJob = null;
865
+ rt.lastError = spawnError ? `启动失败:${spawnError.message}` : signal === "SIGTERM" || code === 0 ? null : `进程退出 code=${code} signal=${signal ?? "none"}(详见日志)`;
866
+ this.#emitSoon();
867
+ });
868
+ this.#emitSoon();
869
+ }
870
+ async stop(id) {
871
+ const rt = this.#rts.get(id);
872
+ if (!rt) return;
873
+ if (rt.processState !== "running" && rt.processState !== "starting") return;
874
+ if (rt.pid == null) return;
875
+ rt.processState = "stopping";
876
+ rt.stoppingUntilMs = Date.now() + 15e3;
877
+ this.#emit();
878
+ const pid = rt.pid;
879
+ const wasExternal = rt.external;
880
+ if (wasExternal) {
881
+ const identity = await pidIdentity(pid);
882
+ const rtDirReal = await realpath(rt.dir).catch(() => rt.dir);
883
+ const same = (a, b) => {
884
+ if (a === null) return false;
885
+ if (PLATFORM === "win32") return a.replace(/\\/g, "/").toLowerCase() === b.replace(/\\/g, "/").toLowerCase();
886
+ return a === b || a === rtDirReal;
887
+ };
888
+ if (identity === null) {
889
+ if (pidExists(pid) !== "dead") {} else {
890
+ rt.pid = null;
891
+ rt.external = false;
892
+ rt.processState = "stopped";
893
+ rt.currentJob = null;
894
+ this.#emit();
895
+ return;
896
+ }
897
+ } else if (!same(identity, rt.dir)) {
898
+ rt.lastError = "停止已取消:该 pid 已不属于本实例(等待重新对账)";
899
+ rt.pid = null;
900
+ rt.external = false;
901
+ rt.processState = "stopped";
902
+ this.#emit();
903
+ return;
904
+ }
905
+ }
906
+ const ok = await stopProcessGroup(pid, wasExternal ? 6e3 : 8e3);
907
+ if (wasExternal) {
908
+ rt.external = false;
909
+ rt.pid = null;
910
+ rt.processState = ok ? "stopped" : "running";
911
+ if (!ok) rt.lastError = "停止失败:进程无响应或无权限(可能由 sudo 启动)";
912
+ rt.currentJob = null;
913
+ }
914
+ this.#emit();
915
+ }
916
+ async setAutoStart(id, value) {
917
+ const rt = this.#rts.get(id);
918
+ if (!rt || !this.#config) return;
919
+ rt.autoStart = value;
920
+ this.#config = upsertInstance(this.#config, {
921
+ id: rt.id,
922
+ dir: rt.dir,
923
+ autoStart: value
924
+ });
925
+ await saveConfig(this.#config);
926
+ this.#emitSoon();
927
+ }
928
+ async register(req) {
929
+ if (this.#task.kind === "register" && this.#task.status === "running") return;
930
+ this.#task = {
931
+ kind: "register",
932
+ status: "running",
933
+ message: null
934
+ };
935
+ this.#emit();
936
+ const existing = this.#rts.get(instanceIdOf(req.dir));
937
+ const result = await registerRunner(req, this.#config?.searchDirs ?? [], (line) => {
938
+ this.#task = {
939
+ kind: "register",
940
+ status: "running",
941
+ message: line
942
+ };
943
+ if (existing) this.#pushLog(existing, line);
944
+ this.#emitSoon();
945
+ });
946
+ if (result.ok) {
947
+ const id = instanceIdOf(result.runnerDir);
948
+ const rt = this.#ensureRt(result.runnerDir, true);
949
+ rt.meta = await readRunnerMeta(result.runnerDir);
950
+ if (this.#config) {
951
+ this.#config = upsertInstance(this.#config, {
952
+ id,
953
+ dir: result.runnerDir,
954
+ autoStart: false
955
+ });
956
+ await saveConfig(this.#config);
957
+ }
958
+ this.#task = {
959
+ kind: "register",
960
+ status: "ok",
961
+ message: `${rt.meta.agentName ?? id} 注册成功`
962
+ };
963
+ await this.start(id);
964
+ } else this.#task = {
965
+ kind: "register",
966
+ status: "error",
967
+ message: result.error
968
+ };
969
+ this.#emit();
970
+ }
971
+ async destroy() {
972
+ if (this.#tickTimer) clearInterval(this.#tickTimer);
973
+ this.#tickTimer = null;
974
+ for (const rt of this.#rts.values()) rt.tail?.stop();
975
+ }
976
+ #ensureRt(dir, create) {
977
+ const id = instanceIdOf(dir);
978
+ let rt = this.#rts.get(id);
979
+ if (!rt && create) {
980
+ rt = {
981
+ id,
982
+ dir,
983
+ meta: null,
984
+ processState: "stopped",
985
+ external: false,
986
+ autoStart: false,
987
+ pid: null,
988
+ child: null,
989
+ currentJob: null,
990
+ lastResult: null,
991
+ lastError: null,
992
+ log: [],
993
+ logTotal: 0,
994
+ seen: /* @__PURE__ */ new Set(),
995
+ tail: null,
996
+ stoppingUntilMs: 0
997
+ };
998
+ this.#rts.set(id, rt);
999
+ }
1000
+ return rt;
1001
+ }
1002
+ async #syncInstances() {
1003
+ const config = this.#config;
1004
+ if (!config) return;
1005
+ const discovered = await discoverRunnerDirs(config.searchDirs);
1006
+ const dirs = /* @__PURE__ */ new Set([...discovered, ...config.instances.map((i) => i.dir)]);
1007
+ let changed = false;
1008
+ for (const dir of dirs) try {
1009
+ const rt = this.#ensureRt(dir, true);
1010
+ rt.meta = await readRunnerMeta(dir);
1011
+ const cfgAuto = config.instances.find((i) => i.dir === dir || i.id === rt.id);
1012
+ rt.autoStart = cfgAuto?.autoStart ?? false;
1013
+ if (!cfgAuto && this.#persist) {
1014
+ config.instances = upsertInstance(config, {
1015
+ id: rt.id,
1016
+ dir,
1017
+ autoStart: false
1018
+ }).instances;
1019
+ changed = true;
1020
+ }
1021
+ if (!rt.tail) {
1022
+ rt.tail = new DirTail(join(dir, "_diag"), "Runner_");
1023
+ rt.tail.start((lines) => this.#onDiagLines(rt, lines));
1024
+ }
1025
+ } catch {}
1026
+ if (changed && this.#persist) await saveConfig(config);
1027
+ }
1028
+ #onDiagLines(rt, lines) {
1029
+ for (const line of lines) {
1030
+ const event = parseConsoleLine(line);
1031
+ if (event) this.#applyJobEvent(rt, event);
1032
+ this.#pushLog(rt, normalizeLine(line) ?? stripDiagPrefix(line));
1033
+ }
1034
+ this.#emitSoon();
1035
+ }
1036
+ #onManagedLine(rt, stream, line) {
1037
+ if (!parseConsoleLine(line)) {
1038
+ this.#pushLog(rt, (stream === "stderr" ? "[stderr] " : "") + line);
1039
+ this.#emitSoon();
1040
+ }
1041
+ if (rt.processState === "starting") {
1042
+ rt.processState = "running";
1043
+ this.#emitSoon();
1044
+ }
1045
+ }
1046
+ #applyJobEvent(rt, event) {
1047
+ if (!event) return;
1048
+ if (event.kind === "job-start") rt.currentJob = {
1049
+ job: event.job,
1050
+ startedAt: event.at
1051
+ };
1052
+ else if (event.kind === "job-end") {
1053
+ const startedAtMs = rt.currentJob ? Date.parse(rt.currentJob.startedAt) : NaN;
1054
+ rt.lastResult = {
1055
+ job: event.job,
1056
+ outcome: event.outcome,
1057
+ at: event.at,
1058
+ durationMs: Number.isFinite(startedAtMs) ? Math.max(0, Date.parse(event.at) - startedAtMs) : 0
1059
+ };
1060
+ rt.currentJob = null;
1061
+ } else rt.currentJob = null;
1062
+ }
1063
+ #pushLog(rt, line) {
1064
+ if (rt.seen.has(line)) return;
1065
+ rt.seen.add(line);
1066
+ if (rt.seen.size > DEDUP_MAX) rt.seen = new Set([...rt.seen].slice(-1200));
1067
+ rt.log.push(line);
1068
+ rt.logTotal += 1;
1069
+ if (rt.log.length > LOG_RING_MAX) rt.log.splice(0, rt.log.length - LOG_RING_MAX);
1070
+ }
1071
+ async #tick() {
1072
+ try {
1073
+ await this.#syncInstances();
1074
+ await this.#reconcileExternal();
1075
+ await this.#refreshPackages();
1076
+ } catch {}
1077
+ }
1078
+ async #reconcileExternal() {
1079
+ const cwdByPid = await findListenerProcesses();
1080
+ const dirsRunning = new Set(cwdByPid.values());
1081
+ for (const rt of this.#rts.values()) {
1082
+ if (rt.stoppingUntilMs > Date.now()) continue;
1083
+ if (rt.child) continue;
1084
+ const rtDir = await realpath(rt.dir).catch(() => rt.dir);
1085
+ const externalPid = [...cwdByPid.entries()].find(([, cwd]) => cwd === rtDir)?.[0] ?? null;
1086
+ const isRunning = dirsRunning.has(rtDir);
1087
+ if (externalPid !== null) {
1088
+ if (rt.processState !== "running" || rt.external !== true) {
1089
+ rt.processState = "running";
1090
+ rt.external = true;
1091
+ rt.pid = externalPid;
1092
+ rt.lastError = null;
1093
+ this.#emitSoon();
1094
+ }
1095
+ } else if (rt.external && rt.processState === "running" && !isRunning) {
1096
+ rt.processState = "stopped";
1097
+ rt.external = false;
1098
+ rt.pid = null;
1099
+ rt.currentJob = null;
1100
+ this.#emitSoon();
1101
+ }
1102
+ }
1103
+ }
1104
+ #view(rt) {
1105
+ return {
1106
+ id: rt.id,
1107
+ dir: rt.dir,
1108
+ name: rt.meta?.agentName ?? rt.id,
1109
+ agentId: rt.meta?.agentId ?? null,
1110
+ orgUrl: rt.meta?.gitHubUrl ?? null,
1111
+ workFolder: rt.meta?.workFolder ?? null,
1112
+ configured: rt.meta?.configured ?? false,
1113
+ processState: rt.processState,
1114
+ external: rt.external,
1115
+ autoStart: rt.autoStart,
1116
+ pid: rt.pid,
1117
+ currentJob: rt.currentJob,
1118
+ lastResult: rt.lastResult,
1119
+ lastError: rt.lastError,
1120
+ log: rt.log,
1121
+ logTotal: rt.logTotal
1122
+ };
1123
+ }
1124
+ #emit() {
1125
+ const snap = this.snapshot();
1126
+ for (const listener of this.#listeners) listener(snap);
1127
+ }
1128
+ #emitSoon() {
1129
+ this.#dirty = true;
1130
+ if (this.#debounceTimer) return;
1131
+ this.#debounceTimer = setTimeout(() => {
1132
+ this.#debounceTimer = null;
1133
+ if (!this.#dirty) return;
1134
+ this.#dirty = false;
1135
+ this.#emit();
1136
+ }, DEBOUNCE_MS);
1137
+ this.#debounceTimer.unref?.();
1138
+ }
1139
+ };
1140
+ // src/tray.ts
1141
+ const ITEM_OPEN = 1;
1142
+ const ITEM_QUIT = 2;
1143
+ const ITEM_INSTANCE_BASE = 100;
1144
+ function stateLabel(snapshot, index) {
1145
+ const i = snapshot.instances[index];
1146
+ if (i.currentJob) return `↻ ${i.currentJob.job}`;
1147
+ switch (i.processState) {
1148
+ case "running": return "空闲";
1149
+ case "stopped": return "已停止";
1150
+ case "starting": return "启动中…";
1151
+ case "stopping": return "停止中…";
1152
+ default: return i.processState;
1153
+ }
1154
+ }
1155
+ /** 快照 → 托盘投影器。每次 update() 全量重画图标/tooltip/菜单。 */
1156
+ var TrayProjection = class {
1157
+ tray;
1158
+ actions;
1159
+ #instanceIds = [];
1160
+ constructor(tray, actions) {
1161
+ this.tray = tray;
1162
+ this.actions = actions;
1163
+ }
1164
+ async update(snapshot) {
1165
+ this.#instanceIds = snapshot.instances.map((i) => i.id);
1166
+ const running = snapshot.instances.filter((i) => i.processState === "running" || i.processState === "starting");
1167
+ const busy = running.filter((i) => i.currentJob !== null).length;
1168
+ await this.tray.setIcon({ "text-only": busy > 0 ? `▶${running.length}↻${busy}` : `▶${running.length}` });
1169
+ await this.tray.setTooltip({
1170
+ title: "GH Runner",
1171
+ description: snapshot.instances.map((_, index) => `${snapshot.instances[index].name}: ${stateLabel(snapshot, index)}`).join("\n") || "尚无实例"
1172
+ });
1173
+ await this.tray.setMenu(this.#menuOf(snapshot));
1174
+ }
1175
+ /** onMenuClick 事件入口;返回是否已消费。 */
1176
+ handleMenuClick(itemId) {
1177
+ if (itemId === ITEM_OPEN) {
1178
+ this.actions.openWindow();
1179
+ return true;
1180
+ }
1181
+ if (itemId === ITEM_QUIT) {
1182
+ this.actions.quit();
1183
+ return true;
1184
+ }
1185
+ const index = itemId - ITEM_INSTANCE_BASE;
1186
+ const id = this.#instanceIds[index];
1187
+ if (id) {
1188
+ this.actions.openInstance(id);
1189
+ return true;
1190
+ }
1191
+ return false;
1192
+ }
1193
+ #menuOf(snapshot) {
1194
+ const items = [{
1195
+ type: "item",
1196
+ id: ITEM_OPEN,
1197
+ title: "打开控制面板",
1198
+ primaryEvent: true
1199
+ }];
1200
+ if (snapshot.instances.length > 0) items.push({ type: "separator" });
1201
+ snapshot.instances.forEach((instance, index) => {
1202
+ items.push({
1203
+ type: "item",
1204
+ id: ITEM_INSTANCE_BASE + index,
1205
+ title: `${instance.name} — ${stateLabel(snapshot, index)}`
1206
+ });
1207
+ });
1208
+ items.push({ type: "separator" });
1209
+ items.push({
1210
+ type: "item",
1211
+ id: ITEM_QUIT,
1212
+ title: "退出 GH Runner"
1213
+ });
1214
+ return { items };
1215
+ }
1216
+ };
1217
+ // src/window.ts
1218
+ const DRAIN_INTERVAL_MS = 150;
1219
+ const PUSH_DEBOUNCE_MS = 120;
1220
+ /** U+2028/2029 会切断 JS 对象字面量,序列化后必须转义(不可见字符法则)。 */
1221
+ function jsLiteral(value) {
1222
+ return value.replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
1223
+ }
1224
+ var UiHost = class {
1225
+ fleet;
1226
+ webviewTray;
1227
+ onOpenDiag;
1228
+ pickPackageFile;
1229
+ #window = null;
1230
+ #lastSnapshot = null;
1231
+ #ackedSeq = 0;
1232
+ #drainTimer = null;
1233
+ #pushTimer = null;
1234
+ /** 每实例已推送的日志绝对计数(logTotal),0=从头全量。 */
1235
+ #logOffsets = /* @__PURE__ */ new Map();
1236
+ #seq = 0;
1237
+ /** 单飞守卫:hello 全量重推与去抖推送并发交错会让同一批日志行重复送达页面。 */
1238
+ #pushInFlight = false;
1239
+ #pushPending = false;
1240
+ constructor(fleet, webviewTray, onOpenDiag, pickPackageFile) {
1241
+ this.fleet = fleet;
1242
+ this.webviewTray = webviewTray;
1243
+ this.onOpenDiag = onOpenDiag;
1244
+ this.pickPackageFile = pickPackageFile;
1245
+ }
1246
+ async open() {
1247
+ if (this.#window) {
1248
+ await this.#window.show();
1249
+ await this.#window.focus();
1250
+ return;
1251
+ }
1252
+ const htmlPath = join(dirname(fileURLToPath(import.meta.url)), "ui", "index.html");
1253
+ const html = await readFile(htmlPath, "utf8");
1254
+ const win = this.webviewTray.createWebviewWindow({
1255
+ html,
1256
+ width: 1020,
1257
+ height: 700,
1258
+ title: "GH Runner",
1259
+ nativeWindowApi: true,
1260
+ devtools: true,
1261
+ style: {
1262
+ appMode: true,
1263
+ autoHide: false,
1264
+ frameless: false
1265
+ }
1266
+ });
1267
+ this.#window = win;
1268
+ await win.show();
1269
+ await win.setMinimumSize(720, 520);
1270
+ this.#drainTimer = setInterval(() => void this.#drain(), DRAIN_INTERVAL_MS);
1271
+ this.#drainTimer.unref?.();
1272
+ this.#flush();
1273
+ }
1274
+ push(snapshot) {
1275
+ this.#lastSnapshot = snapshot;
1276
+ this.#flush();
1277
+ }
1278
+ /** --smoke 自走查用:页面最近一次 ack 的消息 seq(>0 即真实链路 host→page→host 闭环)。 */
1279
+ get ackedSeq() {
1280
+ return this.#ackedSeq;
1281
+ }
1282
+ /** --smoke 自走查用:最近一次收到的快照。 */
1283
+ get lastSnapshot() {
1284
+ return this.#lastSnapshot;
1285
+ }
1286
+ /** --smoke 对照实验:绕过快照管道,直接 evaluate 一个最小 summary。 */
1287
+ async debugPushMinimal() {
1288
+ const win = this.#window;
1289
+ if (!win) return;
1290
+ try {
1291
+ await win.evaluate(`window.__rfSink({kind:"summary",seq:99999,now:new Date().toISOString(),task:{kind:"none",status:"ok",message:null},instances:[]})`);
1292
+ } catch (err) {
1293
+ if (process.env.RF_DEBUG) console.error("[ui] minimal evaluate 失败:", err);
1294
+ }
1295
+ }
1296
+ async destroy() {
1297
+ if (this.#drainTimer) clearInterval(this.#drainTimer);
1298
+ if (this.#pushTimer) clearTimeout(this.#pushTimer);
1299
+ this.#drainTimer = null;
1300
+ this.#pushTimer = null;
1301
+ }
1302
+ #flush() {
1303
+ if (this.#pushTimer || !this.#window) return;
1304
+ this.#pushTimer = setTimeout(() => {
1305
+ this.#pushTimer = null;
1306
+ this.#doPush();
1307
+ }, PUSH_DEBOUNCE_MS);
1308
+ this.#pushTimer.unref?.();
1309
+ }
1310
+ async #doPush() {
1311
+ const win = this.#window;
1312
+ const snap = this.#lastSnapshot;
1313
+ if (!win || !snap || win.connectionDead) return;
1314
+ if (this.#pushInFlight) {
1315
+ this.#pushPending = true;
1316
+ return;
1317
+ }
1318
+ this.#pushInFlight = true;
1319
+ try {
1320
+ const summary = {
1321
+ kind: "summary",
1322
+ seq: ++this.#seq,
1323
+ now: snap.now,
1324
+ platform: snap.platform,
1325
+ arch: snap.arch,
1326
+ task: snap.task,
1327
+ instances: snap.instances.map((inst) => {
1328
+ const { log: _log, ...rest } = inst;
1329
+ return rest;
1330
+ }),
1331
+ packages: snap.packages,
1332
+ download: snap.download
1333
+ };
1334
+ await this.#evaluate(JSON.stringify(summary));
1335
+ for (const inst of snap.instances) await this.#pushLogDelta(win, inst.id);
1336
+ } finally {
1337
+ this.#pushInFlight = false;
1338
+ if (this.#pushPending && this.#window) {
1339
+ this.#pushPending = false;
1340
+ this.#flush();
1341
+ }
1342
+ }
1343
+ }
1344
+ async #pushLogDelta(win, id) {
1345
+ const view = this.fleet.view(id);
1346
+ if (!view) return;
1347
+ const pushed = this.#logOffsets.get(id) ?? 0;
1348
+ const total = view.logTotal;
1349
+ if (total === pushed) return;
1350
+ const wanted = total - pushed;
1351
+ const available = Math.min(wanted, view.log.length);
1352
+ const allLines = view.log.slice(view.log.length - available);
1353
+ const truncated = available < wanted;
1354
+ const chunks = [];
1355
+ let cur = [];
1356
+ let bytes = 0;
1357
+ for (const line of allLines) {
1358
+ const b = Buffer.byteLength(line, "utf8") + 1;
1359
+ if (bytes + b > 24e3 && cur.length > 0) {
1360
+ chunks.push(cur);
1361
+ cur = [];
1362
+ bytes = 0;
1363
+ }
1364
+ cur.push(line);
1365
+ bytes += b;
1366
+ }
1367
+ if (cur.length > 0) chunks.push(cur);
1368
+ let offset = total - allLines.length;
1369
+ for (const lines of chunks) {
1370
+ const msg = {
1371
+ kind: "log",
1372
+ seq: ++this.#seq,
1373
+ id,
1374
+ from: truncated && offset === total - allLines.length ? 0 : offset,
1375
+ truncated: truncated && offset === total - allLines.length,
1376
+ lines
1377
+ };
1378
+ if (!await this.#evaluate(JSON.stringify(msg))) return;
1379
+ offset += lines.length;
1380
+ this.#logOffsets.set(id, offset);
1381
+ }
1382
+ }
1383
+ async #evaluate(json) {
1384
+ const win = this.#window;
1385
+ if (!win || win.connectionDead) return false;
1386
+ try {
1387
+ const js = `window.__rfSink(${jsLiteral(json)})`;
1388
+ if (process.env.RF_DEBUG) console.error(`[ui] evaluate ${js.length} bytes: ${json.slice(0, 60)}`);
1389
+ await win.evaluate(js);
1390
+ return true;
1391
+ } catch (err) {
1392
+ if (process.env.RF_DEBUG) console.error("[ui] evaluate 失败:", err);
1393
+ return false;
1394
+ }
1395
+ }
1396
+ async #drain() {
1397
+ const win = this.#window;
1398
+ if (!win || win.connectionDead) return;
1399
+ let messages;
1400
+ try {
1401
+ messages = await win.drainIpcMessages();
1402
+ } catch (err) {
1403
+ if (process.env.RF_DEBUG) console.error("[ui] drain 失败:", err);
1404
+ return;
1405
+ }
1406
+ for (const message of messages) {
1407
+ if (process.env.RF_DEBUG) console.error("[ui] ipc <-", JSON.stringify(message.payload).slice(0, 120));
1408
+ const cmd = message.payload;
1409
+ if (!cmd || typeof cmd !== "object") continue;
1410
+ await this.#handle(cmd);
1411
+ }
1412
+ }
1413
+ async #handle(cmd) {
1414
+ switch (cmd.type) {
1415
+ case "hello":
1416
+ this.#logOffsets.clear();
1417
+ this.#flush();
1418
+ break;
1419
+ case "ack":
1420
+ if (cmd.seq > this.#ackedSeq) this.#ackedSeq = cmd.seq;
1421
+ break;
1422
+ case "start":
1423
+ await this.fleet.start(cmd.id);
1424
+ break;
1425
+ case "stop":
1426
+ await this.fleet.stop(cmd.id);
1427
+ break;
1428
+ case "set-auto-start":
1429
+ await this.fleet.setAutoStart(cmd.id, cmd.value);
1430
+ break;
1431
+ case "open-diag":
1432
+ this.onOpenDiag(cmd.id);
1433
+ break;
1434
+ case "register":
1435
+ await this.fleet.register({
1436
+ dir: cmd.dir,
1437
+ name: cmd.name,
1438
+ token: cmd.token,
1439
+ labels: cmd.labels,
1440
+ orgUrl: cmd.orgUrl,
1441
+ workFolder: cmd.workFolder,
1442
+ packagePath: cmd.packagePath
1443
+ });
1444
+ break;
1445
+ case "download-latest":
1446
+ this.fleet.downloadLatest();
1447
+ break;
1448
+ case "import-package": {
1449
+ const picked = await this.pickPackageFile();
1450
+ if (picked) await this.fleet.importPackage(picked);
1451
+ break;
1452
+ }
1453
+ }
1454
+ }
1455
+ };
1456
+ /** 从托盘句柄构造 Webview 能力的便利封装(保持 main.ts 干净)。 */
1457
+ function extendWebview(tray) {
1458
+ return tray.extend(WebviewExt);
1459
+ }
1460
+ // src/main.ts
1461
+ const APP_ID = "com.jixoai.gh-runner";
1462
+ async function revealPath(path) {
1463
+ const { file, args } = revealInFileManager(path);
1464
+ await new Promise((resolve) => {
1465
+ execFile(file, args, () => resolve());
1466
+ });
1467
+ }
1468
+ /** 导入本地分发包:系统文件选择框(ext-dialog;darwin NSOpenPanel / win32 对话框)。 */
1469
+ async function pickPackageFile(tray) {
1470
+ try {
1471
+ const picked = await attachDialog(tray).pickFile({ filters: [{
1472
+ name: "runner 包",
1473
+ extensions: ["gz", "zip"]
1474
+ }] });
1475
+ return typeof picked === "string" ? picked : null;
1476
+ } catch (err) {
1477
+ console.error("[gh-runner] 包选择失败:", err);
1478
+ return null;
1479
+ }
1480
+ }
1481
+ /** --probe:探明真实窗口里注入的页面桥形态。三通道取证:页面 ipc 上行 / 宿主 evaluate 内省 +
1482
+ * titlechange 回传 / loadstate 时机观测。 */
1483
+ async function runProbe() {
1484
+ const tray = await createTray({
1485
+ id: APP_ID,
1486
+ icon: { "text-only": "probe" }
1487
+ }, {
1488
+ appId: APP_ID,
1489
+ appName: "GH Runner Probe"
1490
+ });
1491
+ const win = extendWebview(tray).createWebviewWindow({
1492
+ html: `<script>
1493
+ window.__probeState = { started: true };
1494
+ try {
1495
+ navigator.opentray.ipc.postMessage({ t: "probe-ipc", ok: true,
1496
+ opentray: Object.keys(navigator.opentray) });
1497
+ } catch (e) { window.__probeState.ipcError = String(e); }
1498
+ <\/script>`,
1499
+ width: 500,
1500
+ height: 200,
1501
+ nativeWindowApi: true,
1502
+ titleSync: { documentToWindow: true }
1503
+ });
1504
+ const report = {
1505
+ ipc: "none",
1506
+ loadstate: [],
1507
+ titlechange: "none"
1508
+ };
1509
+ const finish = async () => {
1510
+ await writeFile("/tmp/gh-runner-probe.json", JSON.stringify(report, null, 2), "utf8");
1511
+ await win.destroy();
1512
+ await tray.destroy();
1513
+ process.exit(0);
1514
+ };
1515
+ win.listen("loadstate", (event) => {
1516
+ const phase = event.payload?.phase ?? "unknown";
1517
+ report.loadstate = [...report.loadstate, phase];
1518
+ if (phase === "loaded") win.evaluate(`document.title = "PROBE:" + JSON.stringify({
1519
+ opentray: (typeof navigator.opentray !== "undefined") ? Object.keys(navigator.opentray) : null,
1520
+ ipc: !!navigator.opentray?.ipc,
1521
+ opentrayWindow: (typeof navigator.opentrayWindow !== "undefined") ? Object.keys(navigator.opentrayWindow) : null
1522
+ })`).catch((err) => {
1523
+ report.evaluateError = String(err);
1524
+ });
1525
+ });
1526
+ win.listen("titlechange", (event) => {
1527
+ if (event.payload.title.startsWith("PROBE:")) {
1528
+ report.titlechange = event.payload.title.slice(6);
1529
+ finish();
1530
+ }
1531
+ });
1532
+ await win.show();
1533
+ setInterval(() => {
1534
+ win.drainIpcMessages().then((msgs) => {
1535
+ for (const m of msgs) if (m.payload?.t === "probe-ipc") {
1536
+ report.ipc = m.payload;
1537
+ finish();
1538
+ }
1539
+ }).catch(() => {});
1540
+ }, 200).unref?.();
1541
+ const deadline = Date.now() + 2e4;
1542
+ while (Date.now() < deadline) await new Promise((r) => setTimeout(r, 300));
1543
+ await finish();
1544
+ }
1545
+ async function runSmoke(fleet, uiHost) {
1546
+ await uiHost.open();
1547
+ const deadline = Date.now() + 3e4;
1548
+ while (Date.now() < deadline && uiHost.ackedSeq === 0) await new Promise((r) => setTimeout(r, 250));
1549
+ if (uiHost.ackedSeq === 0) {
1550
+ console.error("[smoke] 全量快照未获 ack,尝试最小快照对照");
1551
+ await uiHost.debugPushMinimal();
1552
+ const t = Date.now();
1553
+ while (Date.now() - t < 5e3 && uiHost.ackedSeq === 0) await new Promise((r) => setTimeout(r, 250));
1554
+ console.error(`[smoke] 最小快照 ackedSeq=${uiHost.ackedSeq}`);
1555
+ }
1556
+ const snap = uiHost.lastSnapshot;
1557
+ await writeFile("/tmp/gh-runner-smoke.json", JSON.stringify({
1558
+ ok: uiHost.ackedSeq > 0,
1559
+ ackedSeq: uiHost.ackedSeq,
1560
+ pushedSeq: snap?.seq ?? 0,
1561
+ instances: snap?.instances.map((i) => ({
1562
+ id: i.id,
1563
+ name: i.name,
1564
+ processState: i.processState,
1565
+ external: i.external,
1566
+ currentJob: i.currentJob,
1567
+ lastResult: i.lastResult,
1568
+ logLines: i.log.length
1569
+ })) ?? []
1570
+ }, null, 2), "utf8");
1571
+ const holdMs = Number(process.env.RF_SMOKE_HOLD ?? "0");
1572
+ if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs));
1573
+ await fleet.destroy();
1574
+ process.exit(0);
1575
+ }
1576
+ async function main() {
1577
+ const mode = process.argv[2] ?? "";
1578
+ if (mode === "--probe") {
1579
+ await runProbe();
1580
+ return;
1581
+ }
1582
+ const fleet = new Fleet();
1583
+ await fleet.boot(mode === "--dry-run" || mode === "--smoke" ? {
1584
+ autoStart: false,
1585
+ persist: false
1586
+ } : {});
1587
+ if (mode === "--dry-run") {
1588
+ const iterations = Number(process.argv[3] ?? "3");
1589
+ await new Promise((r) => setTimeout(r, 3e3));
1590
+ for (let i = 0; i < iterations; i++) {
1591
+ const snap = fleet.snapshot();
1592
+ console.log(JSON.stringify(snap.instances.map((inst) => ({
1593
+ id: inst.id,
1594
+ name: inst.name,
1595
+ state: inst.processState,
1596
+ external: inst.external,
1597
+ job: inst.currentJob?.job ?? null,
1598
+ last: inst.lastResult ? `${inst.lastResult.job}=${inst.lastResult.outcome}` : null,
1599
+ logLines: inst.log.length
1600
+ }))));
1601
+ if (i < iterations - 1) await new Promise((r) => setTimeout(r, 3e3));
1602
+ }
1603
+ await fleet.destroy();
1604
+ return;
1605
+ }
1606
+ const tray = await createTray({
1607
+ id: APP_ID,
1608
+ icon: { "text-only": "▶…" },
1609
+ menu: { items: [{
1610
+ type: "item",
1611
+ id: 1,
1612
+ title: "打开控制面板",
1613
+ primaryEvent: true
1614
+ }] }
1615
+ }, {
1616
+ appId: APP_ID,
1617
+ appName: "GH Runner"
1618
+ });
1619
+ const projection = new TrayProjection(tray, {
1620
+ openWindow: () => void uiHost.open(),
1621
+ openInstance: (id) => {
1622
+ uiHost.open();
1623
+ Promise.resolve(id);
1624
+ },
1625
+ quit: () => void shutdown()
1626
+ });
1627
+ const uiHost = new UiHost(fleet, extendWebview(tray), (id) => {
1628
+ const view = fleet.view(id);
1629
+ if (view) revealPath(`${view.dir}/_diag`);
1630
+ }, () => pickPackageFile(tray));
1631
+ fleet.onSnapshot((snap) => {
1632
+ projection.update(snap);
1633
+ uiHost.push(snap);
1634
+ });
1635
+ tray.onMenuClick(({ itemId }) => {
1636
+ if (!projection.handleMenuClick(itemId)) console.warn(`[tray] 未路由的菜单项 ${itemId}`);
1637
+ });
1638
+ if (mode === "--smoke") {
1639
+ runSmoke(fleet, uiHost);
1640
+ return;
1641
+ }
1642
+ const shutdown = async () => {
1643
+ await uiHost.destroy();
1644
+ await tray.destroy();
1645
+ await fleet.destroy();
1646
+ process.exit(0);
1647
+ };
1648
+ process.on("SIGINT", () => void shutdown());
1649
+ process.on("SIGTERM", () => void shutdown());
1650
+ console.log(`[gh-runner] 已启动(配置目录 ${homedir()}/Library/Application Support/gh-runner)`);
1651
+ }
1652
+ main().catch((err) => {
1653
+ console.error("[gh-runner] 启动失败:", err);
1654
+ process.exit(1);
1655
+ });
1656
+ export {};