@amd-gaia/agent-ui 0.17.3 → 0.17.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/assets/index-CdffaS1a.js +438 -0
- package/dist/assets/index-CzObDlwu.css +1 -0
- package/dist/index.html +2 -2
- package/main-safety-net.cjs +172 -0
- package/main.cjs +284 -43
- package/package.json +2 -1
- package/services/backend-installer.cjs +325 -62
- package/services/index-query.cjs +49 -0
- package/services/port-manager.cjs +234 -0
- package/dist/assets/index-B4Qzv7Ys.js +0 -443
- package/dist/assets/index-eQemgF08.css +0 -1
package/main.cjs
CHANGED
|
@@ -13,26 +13,153 @@
|
|
|
13
13
|
// services/notification-service.js — Desktop notifications + permission prompts (T5)
|
|
14
14
|
// preload.cjs — contextBridge for IPC channels (T0/T1)
|
|
15
15
|
|
|
16
|
-
const { app, BrowserWindow, shell } = require("electron");
|
|
16
|
+
const { app, BrowserWindow, dialog, shell } = require("electron");
|
|
17
17
|
const path = require("path");
|
|
18
18
|
const fs = require("fs");
|
|
19
19
|
const os = require("os");
|
|
20
20
|
const { spawn } = require("child_process");
|
|
21
|
+
const { pathToFileURL } = require("url");
|
|
22
|
+
|
|
23
|
+
// ── Shared log path ───────────────────────────────────────────────────────────
|
|
24
|
+
// Single source of truth used by installSafetyNet AND installMainLogTee so
|
|
25
|
+
// both write to the same file without independent path computations that
|
|
26
|
+
// could drift apart.
|
|
27
|
+
const _GAIA_DIR = path.join(os.homedir(), ".gaia");
|
|
28
|
+
const _MAIN_LOG_PATH = path.join(_GAIA_DIR, "electron-main.log");
|
|
29
|
+
|
|
30
|
+
// ── Safety net (issue #934) ───────────────────────────────────────────────────
|
|
31
|
+
// Install top-level error handlers BEFORE any service module is required so
|
|
32
|
+
// that synchronous throws at module-load time are caught and shown as a
|
|
33
|
+
// GAIA-branded error box instead of Electron's bare JS-error dialog.
|
|
34
|
+
// Extracted into main-safety-net.cjs so tests can require it without
|
|
35
|
+
// triggering main.cjs side effects (Electron modules, service requires).
|
|
36
|
+
// Wrapped in try/catch: a corrupt ASAR or bad path would otherwise bypass the
|
|
37
|
+
// very handler we are trying to install, falling through to Electron's bare
|
|
38
|
+
// JS-error dialog.
|
|
39
|
+
let installSafetyNet, installLogTee, _fatalHandler;
|
|
40
|
+
try {
|
|
41
|
+
({ installSafetyNet, installLogTee } = require("./main-safety-net.cjs"));
|
|
42
|
+
({ fatal: _fatalHandler } = installSafetyNet({
|
|
43
|
+
logPath: _MAIN_LOG_PATH,
|
|
44
|
+
dialogModule: dialog,
|
|
45
|
+
appModule: app,
|
|
46
|
+
}));
|
|
47
|
+
} catch (err) {
|
|
48
|
+
try { process.stderr.write(`[main] safety-net load failed: ${err.message}\n`); } catch { }
|
|
49
|
+
try { dialog.showErrorBox("GAIA failed to start", String((err && err.stack) || err)); } catch { }
|
|
50
|
+
// Synchronous exit: service module requires below have no uncaughtException
|
|
51
|
+
// handler installed, so execution cannot safely continue.
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
21
54
|
|
|
22
55
|
// Services (loaded after app.whenReady)
|
|
23
56
|
const TrayManager = require("./services/tray-manager.cjs");
|
|
24
57
|
const AgentProcessManager = require("./services/agent-process-manager.cjs");
|
|
25
58
|
const NotificationService = require("./services/notification-service.cjs");
|
|
59
|
+
const PortManager = require("./services/port-manager.cjs");
|
|
60
|
+
const { buildIndexQuery } = require("./services/index-query.cjs");
|
|
26
61
|
const backendInstaller = require("./services/backend-installer.cjs");
|
|
27
62
|
const installerProgressDialog = require("./services/backend-installer-progress-dialog.cjs");
|
|
28
63
|
const autoUpdater = require("./services/auto-updater.cjs");
|
|
29
64
|
const agentSeeder = require("./services/agent-seeder.cjs");
|
|
30
65
|
|
|
66
|
+
// ── F7: Ozone hint (issue #782) ─────────────────────────────────────────────
|
|
67
|
+
// Electron-recommended switch for distro-agnostic Linux behaviour: picks
|
|
68
|
+
// Wayland on Wayland sessions, X11 elsewhere. Must be set before
|
|
69
|
+
// app.whenReady() fires.
|
|
70
|
+
app.commandLine.appendSwitch("ozone-platform-hint", "auto");
|
|
71
|
+
|
|
72
|
+
// ── F7: --no-sandbox on Linux (issue #782) ───────────────────────────────────
|
|
73
|
+
// chrome-sandbox is deleted from the packaged tree by after-pack.cjs so
|
|
74
|
+
// Chromium must use its unprivileged user-namespace sandbox on every launch
|
|
75
|
+
// path. The .desktop Exec= line already carries --no-sandbox via
|
|
76
|
+
// electron-builder.yml linux.executableArgs, but direct `./GAIA.AppImage`
|
|
77
|
+
// invocations bypass the .desktop entry. Appending the switch here makes
|
|
78
|
+
// all Linux launch paths behave identically.
|
|
79
|
+
if (process.platform === "linux") {
|
|
80
|
+
app.commandLine.appendSwitch("no-sandbox");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── F7: Log tee to ~/.gaia/electron-main.log (issue #782) ───────────────────
|
|
84
|
+
// Users often launch AppImages by double-click, not from a terminal, so
|
|
85
|
+
// console output vanishes. Mirror console.log/error to a file so the
|
|
86
|
+
// diagnostics bundler has something to attach.
|
|
87
|
+
(function installMainLogTee() {
|
|
88
|
+
try {
|
|
89
|
+
try { fs.mkdirSync(_GAIA_DIR, { recursive: true }); } catch { /* ignore */ }
|
|
90
|
+
const logPath = _MAIN_LOG_PATH;
|
|
91
|
+
|
|
92
|
+
// Rotate if > 5 MB — truncate to last ~5 MB on startup.
|
|
93
|
+
try {
|
|
94
|
+
const st = fs.statSync(logPath);
|
|
95
|
+
if (st.size > 5 * 1024 * 1024) {
|
|
96
|
+
const fd = fs.openSync(logPath, "r");
|
|
97
|
+
const keep = 5 * 1024 * 1024;
|
|
98
|
+
const buf = Buffer.alloc(keep);
|
|
99
|
+
// readSync can return fewer bytes than requested; only write
|
|
100
|
+
// what we actually read so we don't append zero-padding.
|
|
101
|
+
const bytesRead = fs.readSync(
|
|
102
|
+
fd,
|
|
103
|
+
buf,
|
|
104
|
+
0,
|
|
105
|
+
keep,
|
|
106
|
+
Math.max(0, st.size - keep),
|
|
107
|
+
);
|
|
108
|
+
fs.closeSync(fd);
|
|
109
|
+
fs.writeFileSync(logPath, buf.subarray(0, bytesRead));
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// ENOENT or permission — best-effort; just fall through.
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const stream = fs.createWriteStream(logPath, { flags: "a" });
|
|
116
|
+
// Root-cause fix for #934: stream.write() after end emits 'error'
|
|
117
|
+
// asynchronously — the try/catch in wrap() below doesn't catch it.
|
|
118
|
+
// This listener absorbs the event before it becomes uncaughtException.
|
|
119
|
+
installLogTee({ stream, logPath });
|
|
120
|
+
stream.write(
|
|
121
|
+
`\n──── electron-main opened (${new Date().toISOString()}) pid=${process.pid} ────\n`
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const flushAndEnd = () => {
|
|
125
|
+
try { stream.end(); } catch { /* ignore */ }
|
|
126
|
+
};
|
|
127
|
+
process.on("exit", flushAndEnd);
|
|
128
|
+
|
|
129
|
+
const wrap = (origFn, level) => (...args) => {
|
|
130
|
+
try {
|
|
131
|
+
const line = args
|
|
132
|
+
.map((a) =>
|
|
133
|
+
typeof a === "string"
|
|
134
|
+
? a
|
|
135
|
+
: a instanceof Error
|
|
136
|
+
? (a.stack || a.message || String(a))
|
|
137
|
+
: JSON.stringify(a)
|
|
138
|
+
)
|
|
139
|
+
.join(" ");
|
|
140
|
+
stream.write(`[${new Date().toISOString()}] ${level} ${line}\n`);
|
|
141
|
+
} catch {
|
|
142
|
+
// swallow — we must not recurse into console.error from here
|
|
143
|
+
}
|
|
144
|
+
return origFn.apply(console, args);
|
|
145
|
+
};
|
|
146
|
+
console.log = wrap(console.log.bind(console), "INFO");
|
|
147
|
+
console.warn = wrap(console.warn.bind(console), "WARN");
|
|
148
|
+
console.error = wrap(console.error.bind(console), "ERROR");
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// If this fails, we silently keep the original console — the app must
|
|
151
|
+
// not refuse to launch over a log-tee failure.
|
|
152
|
+
process.stderr.write(`[main] log tee failed: ${err.message}\n`);
|
|
153
|
+
}
|
|
154
|
+
})();
|
|
155
|
+
|
|
31
156
|
// ── Configuration ──────────────────────────────────────────────────────────
|
|
32
157
|
|
|
33
158
|
const APP_NAME = "GAIA";
|
|
34
|
-
|
|
35
|
-
|
|
159
|
+
// Default fallback only — at runtime we always allocate a free random port
|
|
160
|
+
// via PortManager.findFreePort() to avoid EADDRINUSE on zombie backends
|
|
161
|
+
// from prior aborted sessions (issue #782 / T5).
|
|
162
|
+
const DEFAULT_BACKEND_PORT = 4200;
|
|
36
163
|
const STARTUP_TIMEOUT = 30000;
|
|
37
164
|
|
|
38
165
|
// Parse CLI args (T11: --minimized flag for auto-start)
|
|
@@ -59,8 +186,17 @@ const windowConfig = appConfig.window || {
|
|
|
59
186
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
60
187
|
|
|
61
188
|
let backendProcess = null;
|
|
189
|
+
let backendPort = DEFAULT_BACKEND_PORT;
|
|
190
|
+
let healthCheckUrl = `http://localhost:${backendPort}/api/health`;
|
|
191
|
+
let backendStderrTail = [];
|
|
192
|
+
let isIntentionalKill = false;
|
|
62
193
|
let mainWindow = null;
|
|
63
194
|
|
|
195
|
+
// True until createWindow() runs. Guards window-all-closed from firing app.quit()
|
|
196
|
+
// while the backend-installer progress dialog is open (it's the only window during
|
|
197
|
+
// bootstrap, so destroying it would trigger a premature quit — issue #934).
|
|
198
|
+
let isBootstrapping = true;
|
|
199
|
+
|
|
64
200
|
/** @type {TrayManager | null} */
|
|
65
201
|
let trayManager = null;
|
|
66
202
|
|
|
@@ -70,6 +206,11 @@ let agentProcessManager = null;
|
|
|
70
206
|
/** @type {NotificationService | null} */
|
|
71
207
|
let notificationService = null;
|
|
72
208
|
|
|
209
|
+
/** @type {PortManager} */
|
|
210
|
+
const portManager = new PortManager({
|
|
211
|
+
logger: { log: console.log.bind(console), error: console.error.bind(console) },
|
|
212
|
+
});
|
|
213
|
+
|
|
73
214
|
/**
|
|
74
215
|
* Set to true when the user explicitly quits (via tray "Quit" or Cmd+Q).
|
|
75
216
|
* Prevents minimize-to-tray from intercepting the close event.
|
|
@@ -86,7 +227,7 @@ let isQuitting = false;
|
|
|
86
227
|
* Returns the ChildProcess, or null if the gaia binary cannot be found
|
|
87
228
|
* (shouldn't happen post-ensureBackend, but we guard just in case).
|
|
88
229
|
*/
|
|
89
|
-
function startBackend() {
|
|
230
|
+
async function startBackend() {
|
|
90
231
|
const gaiaCmd = backendInstaller.findGaiaBin();
|
|
91
232
|
|
|
92
233
|
if (!gaiaCmd) {
|
|
@@ -96,11 +237,27 @@ function startBackend() {
|
|
|
96
237
|
return null;
|
|
97
238
|
}
|
|
98
239
|
|
|
99
|
-
|
|
240
|
+
// F5: always spawn on a free random port. Never reuse/probe — the
|
|
241
|
+
// probe-and-reuse path is spoofable and leaves orphans (issue #782).
|
|
242
|
+
try {
|
|
243
|
+
backendPort = await portManager.findFreePort();
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.warn(
|
|
246
|
+
`[main] findFreePort failed (${err.message}); falling back to ${DEFAULT_BACKEND_PORT}`
|
|
247
|
+
);
|
|
248
|
+
backendPort = DEFAULT_BACKEND_PORT;
|
|
249
|
+
}
|
|
250
|
+
healthCheckUrl = `http://localhost:${backendPort}/api/health`;
|
|
251
|
+
|
|
252
|
+
console.log(`Starting backend: ${gaiaCmd} chat --ui --ui-port ${backendPort}`);
|
|
253
|
+
|
|
254
|
+
// Reset per-spawn state so a fresh crash dialog doesn't mix tails.
|
|
255
|
+
backendStderrTail = [];
|
|
256
|
+
isIntentionalKill = false;
|
|
100
257
|
|
|
101
258
|
const child = spawn(
|
|
102
259
|
gaiaCmd,
|
|
103
|
-
["chat", "--ui", "--ui-port", String(
|
|
260
|
+
["chat", "--ui", "--ui-port", String(backendPort)],
|
|
104
261
|
{
|
|
105
262
|
cwd: os.homedir(), // Electron's cwd is "/" on macOS when launched from Finder
|
|
106
263
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -116,24 +273,85 @@ function startBackend() {
|
|
|
116
273
|
});
|
|
117
274
|
|
|
118
275
|
child.stderr.on("data", (data) => {
|
|
119
|
-
const
|
|
120
|
-
|
|
276
|
+
const chunk = data.toString();
|
|
277
|
+
chunk.split(/\r?\n/).forEach((line) => {
|
|
278
|
+
if (!line) return;
|
|
279
|
+
console.log(`[backend] ${line}`);
|
|
280
|
+
// Cap per-line length so pathological no-newline backend output
|
|
281
|
+
// can't balloon the in-memory tail or the crash-dialog body.
|
|
282
|
+
const capped =
|
|
283
|
+
line.length > 2048 ? line.slice(0, 2048) + "…[truncated]" : line;
|
|
284
|
+
backendStderrTail.push(capped);
|
|
285
|
+
if (backendStderrTail.length > 20) backendStderrTail.shift();
|
|
286
|
+
});
|
|
121
287
|
});
|
|
122
288
|
|
|
123
289
|
child.on("error", (err) => {
|
|
124
290
|
console.error("Failed to start backend:", err.message);
|
|
125
291
|
});
|
|
126
292
|
|
|
127
|
-
child.on("exit", (code) => {
|
|
293
|
+
child.on("exit", (code, signal) => {
|
|
128
294
|
if (code !== 0 && code !== null) {
|
|
129
|
-
console.error(`Backend exited with code ${code}`);
|
|
295
|
+
console.error(`Backend exited with code ${code} (signal=${signal})`);
|
|
130
296
|
}
|
|
297
|
+
const crashed = !isIntentionalKill && code !== 0 && code !== null;
|
|
131
298
|
backendProcess = null;
|
|
299
|
+
|
|
300
|
+
if (crashed && !isQuitting) {
|
|
301
|
+
// Fire-and-forget — don't block the event loop.
|
|
302
|
+
void handleBackendCrash(code, signal);
|
|
303
|
+
}
|
|
132
304
|
});
|
|
133
305
|
|
|
134
306
|
return child;
|
|
135
307
|
}
|
|
136
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Show a user-facing crash dialog with the last ~20 stderr lines and
|
|
311
|
+
* offer to write a diagnostics bundle. Invoked from child.on("exit")
|
|
312
|
+
* when the kill was NOT initiated by us.
|
|
313
|
+
*/
|
|
314
|
+
async function handleBackendCrash(code, signal) {
|
|
315
|
+
const tail = backendStderrTail.slice(-20).join("\n") || "(no stderr captured)";
|
|
316
|
+
const detail =
|
|
317
|
+
`The GAIA backend exited unexpectedly (code=${code}, signal=${signal}).\n\n` +
|
|
318
|
+
`Recent log output:\n${tail}`;
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
const choice = await dialog.showMessageBox({
|
|
322
|
+
type: "error",
|
|
323
|
+
title: "GAIA backend crashed",
|
|
324
|
+
message: "GAIA backend crashed",
|
|
325
|
+
detail,
|
|
326
|
+
buttons: ["Copy diagnostics", "Quit"],
|
|
327
|
+
defaultId: 0,
|
|
328
|
+
cancelId: 1,
|
|
329
|
+
noLink: true,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (choice.response === 0) {
|
|
333
|
+
try {
|
|
334
|
+
const bundlePath = await portManager.writeDiagnosticsBundle();
|
|
335
|
+
await dialog.showMessageBox({
|
|
336
|
+
type: "info",
|
|
337
|
+
title: "Diagnostics saved",
|
|
338
|
+
message: "Diagnostics bundle written",
|
|
339
|
+
detail: `Attach this file to your bug report:\n${bundlePath}`,
|
|
340
|
+
buttons: ["OK"],
|
|
341
|
+
});
|
|
342
|
+
} catch (err) {
|
|
343
|
+
console.error("[main] Could not write diagnostics bundle:", err.message);
|
|
344
|
+
dialog.showErrorBox(
|
|
345
|
+
"Could not write diagnostics",
|
|
346
|
+
`Failed to write diagnostics bundle: ${err.message}`
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} catch (err) {
|
|
351
|
+
console.error("[main] handleBackendCrash failed:", err.message);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
137
355
|
async function waitForBackend(timeoutMs) {
|
|
138
356
|
const start = Date.now();
|
|
139
357
|
const http = require("http");
|
|
@@ -141,7 +359,7 @@ async function waitForBackend(timeoutMs) {
|
|
|
141
359
|
while (Date.now() - start < timeoutMs) {
|
|
142
360
|
try {
|
|
143
361
|
await new Promise((resolve, reject) => {
|
|
144
|
-
const req = http.get(
|
|
362
|
+
const req = http.get(healthCheckUrl, (res) => {
|
|
145
363
|
if (res.statusCode === 200) {
|
|
146
364
|
resolve();
|
|
147
365
|
} else {
|
|
@@ -221,10 +439,12 @@ function createWindow() {
|
|
|
221
439
|
|
|
222
440
|
// Show window when ready (unless --minimized or startMinimized config)
|
|
223
441
|
mainWindow.once("ready-to-show", () => {
|
|
442
|
+
console.log("[main] ready-to-show fired");
|
|
224
443
|
const shouldStartMinimized =
|
|
225
444
|
startMinimized || (trayManager && trayManager.startMinimized);
|
|
226
445
|
|
|
227
446
|
if (!shouldStartMinimized) {
|
|
447
|
+
console.log("[main] mainWindow.show() called");
|
|
228
448
|
mainWindow.show();
|
|
229
449
|
} else {
|
|
230
450
|
console.log("[main] Starting minimized to tray");
|
|
@@ -241,9 +461,20 @@ async function loadApp() {
|
|
|
241
461
|
// Always load the bundled frontend from the asar. The backend only
|
|
242
462
|
// serves the API (no frontend files in the pip package), so loading
|
|
243
463
|
// http://localhost:4200/ would show raw JSON instead of the UI.
|
|
464
|
+
//
|
|
465
|
+
// Pass the real backend base URL as a query parameter so the renderer
|
|
466
|
+
// can reach whatever random port port-manager picked (see #851). The
|
|
467
|
+
// renderer (apiBase.ts) validates this value against an allowlist
|
|
468
|
+
// before using it — keep buildIndexQuery in sync with TRUSTED_API_RE.
|
|
244
469
|
const indexPath = path.join(distPath, "index.html");
|
|
245
|
-
|
|
246
|
-
|
|
470
|
+
const indexQuery = buildIndexQuery(backendPort);
|
|
471
|
+
console.log("Loading app from:", indexPath, "api:", indexQuery.api);
|
|
472
|
+
// Use pathToFileURL so the file:// URL always has forward slashes on
|
|
473
|
+
// Windows — Chromium 130+ (Electron 40) rejects backslash file URLs
|
|
474
|
+
// that Node's url.format() (used by loadFile) produces on Windows.
|
|
475
|
+
const fileUrl = pathToFileURL(indexPath);
|
|
476
|
+
fileUrl.search = new URLSearchParams(indexQuery).toString();
|
|
477
|
+
await mainWindow.loadURL(fileUrl.href);
|
|
247
478
|
} else {
|
|
248
479
|
// Show a simple loading/error page
|
|
249
480
|
mainWindow.loadURL(
|
|
@@ -254,7 +485,7 @@ async function loadApp() {
|
|
|
254
485
|
<div style="text-align:center;">
|
|
255
486
|
<h1>${APP_NAME}</h1>
|
|
256
487
|
<p>Waiting for backend to start...</p>
|
|
257
|
-
<p style="color:#888; font-size:12px;">Backend: http://localhost:${
|
|
488
|
+
<p style="color:#888; font-size:12px;">Backend: http://localhost:${backendPort}</p>
|
|
258
489
|
</div>
|
|
259
490
|
</body>
|
|
260
491
|
</html>`
|
|
@@ -271,7 +502,7 @@ function initializeServices() {
|
|
|
271
502
|
agentProcessManager = new AgentProcessManager(mainWindow);
|
|
272
503
|
|
|
273
504
|
// T1: Tray Manager (system tray icon + context menu)
|
|
274
|
-
trayManager = new TrayManager(mainWindow, { backendPort
|
|
505
|
+
trayManager = new TrayManager(mainWindow, { backendPort });
|
|
275
506
|
trayManager.create();
|
|
276
507
|
|
|
277
508
|
// T5: Notification Service (routes agent notifications to OS + renderer)
|
|
@@ -369,6 +600,7 @@ async function bootstrapBackend() {
|
|
|
369
600
|
try {
|
|
370
601
|
await backendInstaller.ensureBackend({
|
|
371
602
|
onProgress: progress.onProgress,
|
|
603
|
+
isPackaged: app.isPackaged,
|
|
372
604
|
});
|
|
373
605
|
progress.close();
|
|
374
606
|
console.log("[main] Backend bootstrap complete");
|
|
@@ -484,10 +716,11 @@ app.whenReady().then(async () => {
|
|
|
484
716
|
}
|
|
485
717
|
|
|
486
718
|
// Start the Python backend
|
|
487
|
-
backendProcess = startBackend();
|
|
719
|
+
backendProcess = await startBackend();
|
|
488
720
|
|
|
489
721
|
// Create the window (hidden until ready-to-show)
|
|
490
722
|
createWindow();
|
|
723
|
+
isBootstrapping = false; // progress dialog is gone; window-all-closed may now quit
|
|
491
724
|
|
|
492
725
|
// Initialize services (tray, agent manager, notifications)
|
|
493
726
|
initializeServices();
|
|
@@ -520,7 +753,7 @@ app.whenReady().then(async () => {
|
|
|
520
753
|
console.log("Waiting for backend to start...");
|
|
521
754
|
const ready = await waitForBackend(STARTUP_TIMEOUT);
|
|
522
755
|
if (ready) {
|
|
523
|
-
console.log("Backend API is ready on port",
|
|
756
|
+
console.log("Backend API is ready on port", backendPort);
|
|
524
757
|
} else {
|
|
525
758
|
console.warn("Backend did not respond within timeout.");
|
|
526
759
|
}
|
|
@@ -551,11 +784,21 @@ app.whenReady().then(async () => {
|
|
|
551
784
|
mainWindow.show();
|
|
552
785
|
}
|
|
553
786
|
});
|
|
787
|
+
}).catch((err) => {
|
|
788
|
+
// Route explicit rejection through the safety-net so the user gets a
|
|
789
|
+
// GAIA-branded dialog and a stack trace in the log (issue #934).
|
|
790
|
+
_fatalHandler(err);
|
|
554
791
|
});
|
|
555
792
|
|
|
556
793
|
// ── Window-all-closed (C4 fix) ────────────────────────────────────────────
|
|
557
794
|
// Don't quit when window is hidden — tray keeps app alive
|
|
558
795
|
app.on("window-all-closed", () => {
|
|
796
|
+
// During bootstrap the progress dialog is the only open window. Destroying
|
|
797
|
+
// it (progress.close()) fires this event before the main window exists, which
|
|
798
|
+
// would trigger a premature app.quit() that races with the startup sequence
|
|
799
|
+
// and causes loadURL() to fail with ERR_FAILED (-2) — issue #934.
|
|
800
|
+
if (isBootstrapping) return;
|
|
801
|
+
|
|
559
802
|
// If minimize-to-tray is active, the window is just hidden, not closed.
|
|
560
803
|
// Only quit on macOS if the user explicitly quit (Cmd+Q).
|
|
561
804
|
const trayActive = trayManager && trayManager.minimizeToTray;
|
|
@@ -575,6 +818,9 @@ let cleanupDone = false;
|
|
|
575
818
|
|
|
576
819
|
app.on("before-quit", () => {
|
|
577
820
|
isQuitting = true;
|
|
821
|
+
// Mark any subsequent backend exit as intentional so we don't pop the
|
|
822
|
+
// crash dialog during normal shutdown.
|
|
823
|
+
isIntentionalKill = true;
|
|
578
824
|
});
|
|
579
825
|
|
|
580
826
|
app.on("will-quit", (event) => {
|
|
@@ -628,39 +874,34 @@ async function cleanup() {
|
|
|
628
874
|
trayManager = null;
|
|
629
875
|
}
|
|
630
876
|
|
|
631
|
-
// Stop the Python backend
|
|
877
|
+
// Stop the Python backend via the port-manager (SIGTERM → wait 3 s → SIGKILL).
|
|
878
|
+
// F5: previously we did this inline; extracted so main.cjs stays lean and
|
|
879
|
+
// to prevent orphan leaks across runs (issue #782).
|
|
632
880
|
if (backendProcess) {
|
|
633
881
|
console.log("Stopping backend process...");
|
|
634
|
-
const proc = backendProcess;
|
|
882
|
+
const proc = backendProcess;
|
|
635
883
|
backendProcess = null;
|
|
884
|
+
isIntentionalKill = true;
|
|
636
885
|
|
|
637
886
|
try {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
//
|
|
887
|
+
// Pass the ChildProcess handle (not a bare pid) so port-manager
|
|
888
|
+
// can short-circuit via `proc.exitCode` and close the PID-reuse
|
|
889
|
+
// TOCTOU window.
|
|
890
|
+
await portManager.killBackend(proc);
|
|
891
|
+
} catch (err) {
|
|
892
|
+
console.error("Error stopping backend:", err.message);
|
|
641
893
|
}
|
|
642
894
|
|
|
643
|
-
//
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
resolve
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
try {
|
|
653
|
-
proc.kill("SIGKILL");
|
|
654
|
-
} catch {
|
|
655
|
-
// Already dead
|
|
656
|
-
}
|
|
657
|
-
resolve();
|
|
658
|
-
}, 3000);
|
|
659
|
-
|
|
660
|
-
proc.once("exit", () => {
|
|
661
|
-
clearTimeout(forceKillTimer);
|
|
662
|
-
resolve();
|
|
895
|
+
// If the child object still reports alive (race), give its exit
|
|
896
|
+
// listener a brief moment to fire before we continue teardown.
|
|
897
|
+
if (proc.exitCode === null) {
|
|
898
|
+
await new Promise((resolve) => {
|
|
899
|
+
const timer = setTimeout(resolve, 500);
|
|
900
|
+
proc.once("exit", () => {
|
|
901
|
+
clearTimeout(timer);
|
|
902
|
+
resolve();
|
|
903
|
+
});
|
|
663
904
|
});
|
|
664
|
-
}
|
|
905
|
+
}
|
|
665
906
|
}
|
|
666
907
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amd-gaia/agent-ui",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"productName": "GAIA Agent UI",
|
|
6
6
|
"description": "Privacy-first agentic AI interface with document Q&A - runs 100% locally on AMD Ryzen AI",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"bin/",
|
|
36
36
|
"dist/",
|
|
37
37
|
"main.cjs",
|
|
38
|
+
"main-safety-net.cjs",
|
|
38
39
|
"preload.cjs",
|
|
39
40
|
"services/",
|
|
40
41
|
"assets/",
|