@jixoai/gh-runner 0.2.2 → 0.2.4

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.
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,62 +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
- if (process.platform === "win32") {
451
- const appData = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming");
452
- return join(appData, "gh-runner");
453
- }
454
- return join(homedir(), "Library", "Application Support", "gh-runner");
455
- }
456
- function configPath() {
457
- return join(configDir(), "config.json");
458
- }
459
- /** 形状校验:损坏的实例项直接丢弃(手编配置/撕裂写的容错),不让 boot 崩掉。
460
- * 路径合法性平台无关(POSIX 绝对 / win32 盘符 / UNC 均可)——config 是本机产物,
461
- * 但 Windows 盘符路径曾被 startsWith("/") 全量误杀并回写空清单(v0.2.2 实证事故)。 */
462
- function isAbsoluteish(p) {
463
- return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("\\\\");
464
- }
465
- function validInstance(item) {
466
- return typeof item === "object" && item !== null && typeof item.dir === "string" && isAbsoluteish(item.dir) && typeof item.id === "string";
467
- }
468
- async function loadConfig() {
469
- try {
470
- const raw = (await readFile(configPath(), "utf8")).replace(/^\uFEFF/, "");
471
- const parsed = JSON.parse(raw);
472
- const searchDirs = Array.isArray(parsed.searchDirs) ? parsed.searchDirs.filter((d) => typeof d === "string" && isAbsolutePath(d)) : [];
473
- const instances = Array.isArray(parsed.instances) ? parsed.instances.filter((i) => validInstance(i)) : [];
474
- const importedPackages = Array.isArray(parsed.importedPackages) ? parsed.importedPackages.filter((p) => typeof p === "string") : [];
475
- return {
476
- searchDirs: searchDirs.length > 0 ? searchDirs : DEFAULT_SEARCH_DIRS,
477
- instances,
478
- importedPackages
479
- };
480
- } catch {
481
- return {
482
- searchDirs: DEFAULT_SEARCH_DIRS,
483
- instances: [],
484
- importedPackages: []
485
- };
486
- }
487
- }
488
- /** 原子写:tmp + rename,杜绝撕裂写(损坏的 config 会 brick 启动路径)。 */
489
- async function saveConfig(config) {
490
- await mkdir(configDir(), { recursive: true });
491
- const path = configPath();
492
- const tmp = `${path}.tmp`;
493
- await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, "utf8");
494
- await rename(tmp, path);
495
- }
496
- function upsertInstance(config, instance) {
497
- const rest = config.instances.filter((i) => i.id !== instance.id && i.dir !== instance.dir);
498
- return {
499
- ...config,
500
- instances: [...rest, instance]
501
- };
502
- }
503
503
  // src/register.ts
504
504
  async function runStep(title, file, args, cwd, onProgress, timeoutMs = 3e5) {
505
505
  onProgress(`$ ${title}`);
@@ -553,13 +553,24 @@ async function registerRunner(req, searchDirs, onProgress) {
553
553
  const dir = req.dir;
554
554
  try {
555
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})` + (meta.workFolder ? `,work 目录已恢复:${meta.workFolder}` : ""));
561
+ return {
562
+ ok: true,
563
+ error: null,
564
+ runnerDir: dir
565
+ };
566
+ }
556
567
  if (!req.name.trim()) throw new Error("缺少实例名称");
557
568
  if (!req.token.trim()) throw new Error("缺少注册 Token(org 设置页每次生成、一次性)");
558
569
  if (!req.workFolder.trim()) throw new Error("缺少 Work 目录(多实例必须各自独立)");
559
570
  let entries = [];
560
571
  if (existsSync(dir)) {
561
572
  entries = await readdir(dir);
562
- if (entries.some((e) => e !== ".DS_Store")) throw new Error(`目录非空(${entries.length} 项):注册必须是空目录;管理已有实例请直接 attach`);
573
+ if (entries.some((e) => e !== ".DS_Store")) throw new Error(`目录非空且无 .runner(${entries.length} 项):无法安全解包。若是此前注册的实例,请确认目录下有 .runner 文件;否则请换一个空目录。`);
563
574
  } else {
564
575
  onProgress(`创建目录 ${dir}`);
565
576
  await mkdir(dir, { recursive: true });
@@ -1132,6 +1143,26 @@ var Fleet = class {
1132
1143
  this.#debounceTimer.unref?.();
1133
1144
  }
1134
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
+ }
1135
1166
  // src/tray.ts
1136
1167
  const ITEM_OPEN = 1;
1137
1168
  const ITEM_QUIT = 2;
@@ -1147,11 +1178,15 @@ function stateLabel(snapshot, index) {
1147
1178
  default: return i.processState;
1148
1179
  }
1149
1180
  }
1150
- /** 快照 → 托盘投影器。每次 update() 全量重画图标/tooltip/菜单。 */
1181
+ /** 快照 → 托盘投影器。原生托盘接口按内容去重——内容未变绝不重复调用。 */
1151
1182
  var TrayProjection = class {
1152
1183
  tray;
1153
1184
  actions;
1154
1185
  #instanceIds = [];
1186
+ #lastIcon = null;
1187
+ #platform = process.platform;
1188
+ #lastTooltip = null;
1189
+ #lastMenu = null;
1155
1190
  constructor(tray, actions) {
1156
1191
  this.tray = tray;
1157
1192
  this.actions = actions;
@@ -1160,12 +1195,39 @@ var TrayProjection = class {
1160
1195
  this.#instanceIds = snapshot.instances.map((i) => i.id);
1161
1196
  const running = snapshot.instances.filter((i) => i.processState === "running" || i.processState === "starting");
1162
1197
  const busy = running.filter((i) => i.currentJob !== null).length;
1163
- await this.tray.setIcon({ "text-only": busy > 0 ? `▶${running.length}↻${busy}` : `▶${running.length}` });
1164
- await this.tray.setTooltip({
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({
1165
1205
  title: "GH Runner",
1166
1206
  description: snapshot.instances.map((_, index) => `${snapshot.instances[index].name}: ${stateLabel(snapshot, index)}`).join("\n") || "尚无实例"
1167
1207
  });
1168
- await this.tray.setMenu(this.#menuOf(snapshot));
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 };
1169
1231
  }
1170
1232
  /** onMenuClick 事件入口;返回是否已消费。 */
1171
1233
  handleMenuClick(itemId) {
@@ -1221,6 +1283,7 @@ var UiHost = class {
1221
1283
  webviewTray;
1222
1284
  onOpenDiag;
1223
1285
  pickPackageFile;
1286
+ pickRunnerDir;
1224
1287
  openExternal;
1225
1288
  #window = null;
1226
1289
  #lastSnapshot = null;
@@ -1233,17 +1296,22 @@ var UiHost = class {
1233
1296
  /** 单飞守卫:hello 全量重推与去抖推送并发交错会让同一批日志行重复送达页面。 */
1234
1297
  #pushInFlight = false;
1235
1298
  #pushPending = false;
1236
- constructor(fleet, webviewTray, onOpenDiag, pickPackageFile, openExternal) {
1299
+ #visible = true;
1300
+ constructor(fleet, webviewTray, onOpenDiag, pickPackageFile, pickRunnerDir, openExternal) {
1237
1301
  this.fleet = fleet;
1238
1302
  this.webviewTray = webviewTray;
1239
1303
  this.onOpenDiag = onOpenDiag;
1240
1304
  this.pickPackageFile = pickPackageFile;
1305
+ this.pickRunnerDir = pickRunnerDir;
1241
1306
  this.openExternal = openExternal;
1242
1307
  }
1243
1308
  async open() {
1244
1309
  if (this.#window) {
1245
1310
  await this.#window.show();
1246
1311
  await this.#window.focus();
1312
+ this.#visible = true;
1313
+ this.#logOffsets.clear();
1314
+ this.#doPush();
1247
1315
  return;
1248
1316
  }
1249
1317
  const htmlPath = join(dirname(fileURLToPath(import.meta.url)), "ui", "index.html");
@@ -1258,12 +1326,25 @@ var UiHost = class {
1258
1326
  style: {
1259
1327
  appMode: true,
1260
1328
  autoHide: false,
1261
- frameless: false
1329
+ frameless: false,
1330
+ background: {
1331
+ kind: "semantic",
1332
+ token: "blur",
1333
+ state: "active"
1334
+ }
1262
1335
  }
1263
1336
  });
1264
1337
  this.#window = win;
1265
1338
  await win.show();
1266
1339
  await win.setMinimumSize(720, 520);
1340
+ win.listen("visibleChange", (event) => {
1341
+ const visible = event.payload.visible ?? true;
1342
+ this.#visible = visible;
1343
+ if (visible) {
1344
+ this.#logOffsets.clear();
1345
+ this.#doPush();
1346
+ }
1347
+ });
1267
1348
  this.#drainTimer = setInterval(() => void this.#drain(), DRAIN_INTERVAL_MS);
1268
1349
  this.#drainTimer.unref?.();
1269
1350
  this.#flush();
@@ -1307,7 +1388,7 @@ var UiHost = class {
1307
1388
  async #doPush() {
1308
1389
  const win = this.#window;
1309
1390
  const snap = this.#lastSnapshot;
1310
- if (!win || !snap || win.connectionDead) return;
1391
+ if (!win || !snap || win.connectionDead || !this.#visible) return;
1311
1392
  if (this.#pushInFlight) {
1312
1393
  this.#pushPending = true;
1313
1394
  return;
@@ -1612,7 +1693,8 @@ async function main() {
1612
1693
  }] }
1613
1694
  }, {
1614
1695
  appId: APP_ID,
1615
- appName: "GH Runner"
1696
+ appName: "GH Runner",
1697
+ appIcon: appIcon()
1616
1698
  });
1617
1699
  const projection = new TrayProjection(tray, {
1618
1700
  openWindow: () => void uiHost.open(),
@@ -1627,7 +1709,14 @@ async function main() {
1627
1709
  const uiHost = new UiHost(fleet, webviewTray, (id) => {
1628
1710
  const view = fleet.view(id);
1629
1711
  if (view) opener.revealInFolder(`${view.dir}/_diag`).catch((err) => console.error("[gh-runner] 揭示目录失败:", err));
1630
- }, () => pickPackageFile(tray), (url) => opener.open(url));
1712
+ }, () => pickPackageFile(tray), async () => {
1713
+ try {
1714
+ return await attachDialog(tray).pickDirectory();
1715
+ } catch (err) {
1716
+ console.error("[gh-runner] 目录选择失败:", err);
1717
+ return null;
1718
+ }
1719
+ }, (url) => opener.open(url));
1631
1720
  fleet.onSnapshot((snap) => {
1632
1721
  projection.update(snap);
1633
1722
  uiHost.push(snap);
@@ -1647,7 +1736,7 @@ async function main() {
1647
1736
  };
1648
1737
  process.on("SIGINT", () => void shutdown());
1649
1738
  process.on("SIGTERM", () => void shutdown());
1650
- console.log(`[gh-runner] 已启动(配置目录 ${homedir()}/Library/Application Support/gh-runner)`);
1739
+ console.log(`[gh-runner] 已启动(配置目录 ${configDir()})`);
1651
1740
  }
1652
1741
  main().catch((err) => {
1653
1742
  console.error("[gh-runner] 启动失败:", err);