@amd-gaia/agent-ui 0.25.4-rc.9 → 0.25.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.
- package/dist/assets/AdvancedSettings-Dkjf4LIp.js +1 -0
- package/dist/assets/ConnectorsPane-CIV89VVI.js +1 -0
- package/dist/assets/{DocumentLibrary-D3FbBLgb.js → DocumentLibrary-2IMvlsjP.js} +2 -2
- package/dist/assets/{FileBrowser-DoZvZBAZ.js → FileBrowser-DMnjs5do.js} +4 -4
- package/dist/assets/MemoryDashboard-BAqXtg9o.js +1 -0
- package/dist/assets/MobileAccessModal-CrtIKCP3.js +2 -0
- package/dist/assets/ScheduleManager-D_KcT6nO.js +1 -0
- package/dist/assets/api-D5mTAXjB.js +4 -0
- package/dist/assets/index-B7tI0eKR.css +1 -0
- package/dist/assets/index-CsgZhHsw.js +80 -0
- package/dist/assets/useT-CtECS-vp.js +3 -0
- package/dist/assets/{zap-nquO2dVq.js → zap-FkXztfRB.js} +1 -1
- package/dist/index.html +4 -4
- package/dist/sw.js +1 -1
- package/main.cjs +126 -20
- package/package.json +1 -1
- package/preload.cjs +8 -0
- package/services/auto-updater.cjs +26 -3
- package/services/backend-supervisor.cjs +150 -0
- package/services/port-manager.cjs +3 -0
- package/services/quit-guard.cjs +9 -0
- package/services/tray-host.cjs +84 -0
- package/services/tray-manager.cjs +33 -7
- package/dist/assets/AdvancedSettings-Pil1Flfe.js +0 -1
- package/dist/assets/ConnectorsPane-D3tAB0fM.js +0 -1
- package/dist/assets/MemoryDashboard-BVs69AmP.js +0 -1
- package/dist/assets/MobileAccessModal-bBzOCo4o.js +0 -2
- package/dist/assets/ScheduleManager-BN8h4kt3.js +0 -1
- package/dist/assets/api-CiSwuQUq.js +0 -4
- package/dist/assets/index-B2qlJogW.css +0 -1
- package/dist/assets/index-DL8_h-hw.js +0 -78
- package/dist/assets/useT-BTgbCEQm.js +0 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{q as e}from"./useT-CtECS-vp.js";var t={name:`message-square`,size:24,node:[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]]};t.node;var n=e(t),r={name:`zap`,size:24,node:[[`path`,{d:`M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z`,key:`1v7up4`}]]};r.node;var i=e(r);export{n,i as t};
|
package/dist/index.html
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
13
13
|
<meta name="apple-mobile-web-app-title" content="GAIA" />
|
|
14
14
|
<title>GAIA Agent UI</title>
|
|
15
|
-
<script type="module" crossorigin src="./assets/index-
|
|
15
|
+
<script type="module" crossorigin src="./assets/index-CsgZhHsw.js"></script>
|
|
16
16
|
<link rel="modulepreload" crossorigin href="./assets/rolldown-runtime-XKyaHtl9.js">
|
|
17
|
-
<link rel="modulepreload" crossorigin href="./assets/useT-
|
|
18
|
-
<link rel="modulepreload" crossorigin href="./assets/api-
|
|
19
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
17
|
+
<link rel="modulepreload" crossorigin href="./assets/useT-CtECS-vp.js">
|
|
18
|
+
<link rel="modulepreload" crossorigin href="./assets/api-D5mTAXjB.js">
|
|
19
|
+
<link rel="stylesheet" crossorigin href="./assets/index-B7tI0eKR.css">
|
|
20
20
|
</head>
|
|
21
21
|
<body>
|
|
22
22
|
<div id="root"></div>
|
package/dist/sw.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// never sees a stale UI or stale data.
|
|
10
10
|
|
|
11
11
|
const CACHE_PREFIX = 'gaia-assets-';
|
|
12
|
-
const CACHE_NAME = CACHE_PREFIX + '0.25.
|
|
12
|
+
const CACHE_NAME = CACHE_PREFIX + '0.25.4-f9c04029eab871f5';
|
|
13
13
|
const ASSETS_PATH = new URL('assets/', self.registration.scope).pathname;
|
|
14
14
|
|
|
15
15
|
self.addEventListener('install', () => {
|
package/main.cjs
CHANGED
|
@@ -85,6 +85,7 @@ const _inSafeMode = _crashCount >= SAFE_MODE_THRESHOLD;
|
|
|
85
85
|
|
|
86
86
|
// Services (loaded after app.whenReady)
|
|
87
87
|
const TrayManager = require("./services/tray-manager.cjs");
|
|
88
|
+
const { probeTrayHost } = require("./services/tray-host.cjs");
|
|
88
89
|
const { appIconPath, appUserModelId } = require("./services/app-icon.cjs");
|
|
89
90
|
const {
|
|
90
91
|
attachWindowGuards,
|
|
@@ -101,6 +102,7 @@ const KeepAwake = require("./services/keep-awake.cjs");
|
|
|
101
102
|
const PortManager = require("./services/port-manager.cjs");
|
|
102
103
|
const { isGaiaBackendProcess } = require("./services/backend-orphan.cjs");
|
|
103
104
|
const { showBackendCrashDialog } = require("./services/backend-crash-dialog.cjs");
|
|
105
|
+
const { createBackendSupervisor } = require("./services/backend-supervisor.cjs");
|
|
104
106
|
const { buildIndexQuery, INSTALL_SCREEN_QUERY } = require("./services/index-query.cjs");
|
|
105
107
|
const backendInstaller = require("./services/backend-installer.cjs");
|
|
106
108
|
const { createEngineInstall } = require("./services/engine-install.cjs");
|
|
@@ -213,6 +215,8 @@ const APP_NAME = "GAIA";
|
|
|
213
215
|
// from prior aborted sessions (issue #782 / T5).
|
|
214
216
|
const DEFAULT_BACKEND_PORT = 4200;
|
|
215
217
|
const STARTUP_TIMEOUT = 30000;
|
|
218
|
+
// A restart must answer within this, or it counts as a failed try.
|
|
219
|
+
const RESTART_TIMEOUT = 90000;
|
|
216
220
|
|
|
217
221
|
// Parse CLI args (T11: --minimized flag for auto-start)
|
|
218
222
|
const startMinimized = process.argv.includes("--minimized");
|
|
@@ -243,6 +247,8 @@ let backendProcess = null;
|
|
|
243
247
|
let backendStarting = null;
|
|
244
248
|
// Set as cleanup() begins; startup stops at its next step once it is.
|
|
245
249
|
let shutdownStarted = false;
|
|
250
|
+
// Set once an update install starts quitting the app.
|
|
251
|
+
let updateQuitStarted = false;
|
|
246
252
|
let backendPort = DEFAULT_BACKEND_PORT;
|
|
247
253
|
let healthCheckUrl = `http://localhost:${backendPort}/api/health`;
|
|
248
254
|
let backendStderrTail = [];
|
|
@@ -257,6 +263,8 @@ let engineInstall = null;
|
|
|
257
263
|
|
|
258
264
|
/** @type {TrayManager | null} */
|
|
259
265
|
let trayManager = null;
|
|
266
|
+
/** Settles once the tray knows whether this desktop shows tray icons. */
|
|
267
|
+
let trayHostReady = null;
|
|
260
268
|
|
|
261
269
|
/** @type {AgentProcessManager | null} */
|
|
262
270
|
let agentProcessManager = null;
|
|
@@ -278,6 +286,11 @@ const portManager = new PortManager({
|
|
|
278
286
|
*/
|
|
279
287
|
let isQuitting = false;
|
|
280
288
|
|
|
289
|
+
/** Any quit is under way, an update install's included: start no backend. */
|
|
290
|
+
function quitStarted() {
|
|
291
|
+
return shutdownStarted || isQuitting || updateQuitStarted;
|
|
292
|
+
}
|
|
293
|
+
|
|
281
294
|
// ── Backend Process ────────────────────────────────────────────────────────
|
|
282
295
|
|
|
283
296
|
/**
|
|
@@ -386,6 +399,8 @@ async function startBackend() {
|
|
|
386
399
|
env: {
|
|
387
400
|
...process.env,
|
|
388
401
|
GAIA_APP_PID: String(process.pid),
|
|
402
|
+
// Piped stdout is block-buffered: a dying backend's last lines would never arrive.
|
|
403
|
+
PYTHONUNBUFFERED: "1",
|
|
389
404
|
...(app.isPackaged && {
|
|
390
405
|
GAIA_BUNDLED_LEMONADE_DIR: path.join(process.resourcesPath, "vendor", "lemonade"),
|
|
391
406
|
}),
|
|
@@ -431,17 +446,16 @@ async function startBackend() {
|
|
|
431
446
|
}
|
|
432
447
|
|
|
433
448
|
child.on("exit", (code, signal) => {
|
|
434
|
-
|
|
435
|
-
|
|
449
|
+
// Any exit we didn't ask for leaves the window without a backend, even code 0.
|
|
450
|
+
const crashed = !isIntentionalKill && !isQuitting;
|
|
451
|
+
if (crashed) {
|
|
452
|
+
console.error(`Backend exited with code ${code} (signal=${signal}, pid=${child.pid})`);
|
|
436
453
|
}
|
|
437
|
-
|
|
438
|
-
backendProcess = null;
|
|
454
|
+
if (backendProcess === child) backendProcess = null;
|
|
439
455
|
if (trayManager && !isQuitting) trayManager.setBackendAlive(false);
|
|
440
456
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
void handleBackendCrash(code, signal);
|
|
444
|
-
}
|
|
457
|
+
// Once restarts run out, the supervisor's onStopped shows the crash dialog.
|
|
458
|
+
if (crashed) backendSupervisor.crashed({ code, signal });
|
|
445
459
|
|
|
446
460
|
// Remove pidfile on exit to avoid leaving stale PID behind.
|
|
447
461
|
try {
|
|
@@ -458,12 +472,82 @@ async function startBackend() {
|
|
|
458
472
|
}
|
|
459
473
|
});
|
|
460
474
|
|
|
475
|
+
// "close" waits for stdio to drain, so the tail holds the backend's last words.
|
|
476
|
+
child.once("close", (code, signal) => {
|
|
477
|
+
if (isIntentionalKill || isQuitting) return;
|
|
478
|
+
const tail = backendStderrTail.length ? backendStderrTail.join("\n") : "(nothing on stderr)";
|
|
479
|
+
console.error(`[main] Backend pid ${child.pid} closed (code=${code}, signal=${signal}). Last stderr lines:\n${tail}`);
|
|
480
|
+
});
|
|
481
|
+
|
|
461
482
|
return child;
|
|
462
483
|
}
|
|
463
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Start a new backend for the supervisor and resolve once it answers.
|
|
487
|
+
* Rejects when it exits first or never answers, so the supervisor retries.
|
|
488
|
+
*/
|
|
489
|
+
async function restartBackend() {
|
|
490
|
+
backendStarting = startBackend();
|
|
491
|
+
const child = await backendStarting;
|
|
492
|
+
if (!child) {
|
|
493
|
+
throw new Error("GAIA's backend program is missing, so it can't restart. Reinstall GAIA.");
|
|
494
|
+
}
|
|
495
|
+
if (quitStarted()) {
|
|
496
|
+
isIntentionalKill = true;
|
|
497
|
+
await portManager.killBackend(child);
|
|
498
|
+
throw new Error("GAIA is quitting, so the restarted backend was stopped.");
|
|
499
|
+
}
|
|
500
|
+
backendProcess = child;
|
|
501
|
+
const alive = () => child.exitCode === null && child.signalCode === null;
|
|
502
|
+
const ready = await waitForBackend(RESTART_TIMEOUT, alive);
|
|
503
|
+
if (!ready) {
|
|
504
|
+
if (!alive()) {
|
|
505
|
+
throw new Error(`The backend exited (code ${child.exitCode}, signal ${child.signalCode}) before it answered.`);
|
|
506
|
+
}
|
|
507
|
+
isIntentionalKill = true;
|
|
508
|
+
await portManager.killBackend(child);
|
|
509
|
+
throw new Error(`The backend did not answer within ${RESTART_TIMEOUT / 1000} s.`);
|
|
510
|
+
}
|
|
511
|
+
console.log(`[main] Backend restarted on port ${backendPort} (pid=${child.pid})`);
|
|
512
|
+
if (trayManager) {
|
|
513
|
+
trayManager.setBackendPort(backendPort);
|
|
514
|
+
trayManager.setBackendAlive(true);
|
|
515
|
+
}
|
|
516
|
+
return { port: backendPort };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** What the renderer needs: the supervisor's state plus the API base to use. */
|
|
520
|
+
function backendStateForRenderer(state) {
|
|
521
|
+
return { ...state, apiBase: state.port ? buildIndexQuery(state.port).api : null };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const backendSupervisor = createBackendSupervisor({
|
|
525
|
+
start: restartBackend,
|
|
526
|
+
onStopped: (state) => {
|
|
527
|
+
const { code = null, signal = null } = state.lastExit || {};
|
|
528
|
+
void handleBackendCrash(code, signal);
|
|
529
|
+
},
|
|
530
|
+
onState: (state) => {
|
|
531
|
+
console.log(
|
|
532
|
+
`[main] Backend ${state.status}` +
|
|
533
|
+
(state.status === "restarting" ? ` (try ${state.attempt} of ${state.maxRestarts})` : "") +
|
|
534
|
+
(state.lastError ? `: ${state.lastError}` : "")
|
|
535
|
+
);
|
|
536
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
537
|
+
mainWindow.webContents.send("backend:state", backendStateForRenderer(state));
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
ipcMain.handle("backend:get-state", () => backendStateForRenderer(backendSupervisor.getState()));
|
|
543
|
+
ipcMain.handle("backend:restart", () => {
|
|
544
|
+
backendSupervisor.restartNow();
|
|
545
|
+
return backendStateForRenderer(backendSupervisor.getState());
|
|
546
|
+
});
|
|
547
|
+
|
|
464
548
|
/**
|
|
465
549
|
* Tell the user the backend stopped and offer Restart / Save diagnostics /
|
|
466
|
-
* Quit. Invoked
|
|
550
|
+
* Quit. Invoked by the supervisor's onStopped once automatic restarts run out.
|
|
467
551
|
*/
|
|
468
552
|
async function handleBackendCrash(code, signal) {
|
|
469
553
|
autoUpdater.reportStartFailure();
|
|
@@ -485,11 +569,11 @@ async function handleBackendCrash(code, signal) {
|
|
|
485
569
|
}
|
|
486
570
|
}
|
|
487
571
|
|
|
488
|
-
async function waitForBackend(timeoutMs) {
|
|
572
|
+
async function waitForBackend(timeoutMs, isAlive = () => true) {
|
|
489
573
|
const start = Date.now();
|
|
490
574
|
const http = require("http");
|
|
491
575
|
|
|
492
|
-
while (Date.now() - start < timeoutMs) {
|
|
576
|
+
while (Date.now() - start < timeoutMs && isAlive()) {
|
|
493
577
|
try {
|
|
494
578
|
await new Promise((resolve, reject) => {
|
|
495
579
|
const req = http.get(healthCheckUrl, (res) => {
|
|
@@ -666,15 +750,20 @@ function createWindow() {
|
|
|
666
750
|
// Show window when ready (unless --minimized or startMinimized config)
|
|
667
751
|
mainWindow.once("ready-to-show", () => {
|
|
668
752
|
console.log("[main] ready-to-show fired");
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
753
|
+
Promise.resolve(trayHostReady).then(() => {
|
|
754
|
+
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
755
|
+
// Never start hidden where no tray icon would bring the window back.
|
|
756
|
+
const canHide = Boolean(trayManager && trayManager.trayHost.available);
|
|
757
|
+
const shouldStartMinimized =
|
|
758
|
+
canHide && (startMinimized || trayManager.startMinimized);
|
|
759
|
+
|
|
760
|
+
if (!shouldStartMinimized) {
|
|
761
|
+
console.log("[main] mainWindow.show() called");
|
|
762
|
+
mainWindow.show();
|
|
763
|
+
} else {
|
|
764
|
+
console.log("[main] Starting minimized to tray");
|
|
765
|
+
}
|
|
766
|
+
});
|
|
678
767
|
});
|
|
679
768
|
|
|
680
769
|
return mainWindow;
|
|
@@ -782,6 +871,14 @@ function initializeServices() {
|
|
|
782
871
|
reopenWindow: () => reopenWindow(),
|
|
783
872
|
});
|
|
784
873
|
trayManager.create();
|
|
874
|
+
trayHostReady = probeTrayHost()
|
|
875
|
+
.then((host) => {
|
|
876
|
+
if (trayManager) trayManager.setHost(host);
|
|
877
|
+
})
|
|
878
|
+
.catch((err) => {
|
|
879
|
+
// The host stays unknown, so closing quits instead of hiding.
|
|
880
|
+
console.error(`[main] Could not record the tray host: ${err.message}`);
|
|
881
|
+
});
|
|
785
882
|
try {
|
|
786
883
|
trayManager.syncLoginItem();
|
|
787
884
|
} catch (err) {
|
|
@@ -1197,6 +1294,7 @@ app.whenReady().then(async () => {
|
|
|
1197
1294
|
if (shutdownStarted) return;
|
|
1198
1295
|
backendStarting = startBackend();
|
|
1199
1296
|
backendProcess = await backendStarting;
|
|
1297
|
+
if (backendProcess) backendSupervisor.running(backendPort);
|
|
1200
1298
|
if (shutdownStarted) {
|
|
1201
1299
|
console.log("[main] Quit during startup — not opening the window");
|
|
1202
1300
|
return;
|
|
@@ -1328,6 +1426,7 @@ const quitGuard = installQuitGuard({
|
|
|
1328
1426
|
// Mark any subsequent backend exit as intentional so we don't pop the
|
|
1329
1427
|
// crash dialog during normal shutdown.
|
|
1330
1428
|
isIntentionalKill = true;
|
|
1429
|
+
backendSupervisor.stop();
|
|
1331
1430
|
},
|
|
1332
1431
|
onError: (err) => {
|
|
1333
1432
|
console.error("[main] Quit check failed:", err);
|
|
@@ -1339,12 +1438,19 @@ const quitGuard = installQuitGuard({
|
|
|
1339
1438
|
},
|
|
1340
1439
|
});
|
|
1341
1440
|
|
|
1441
|
+
// The installer is already running: a restart now would hold its files open.
|
|
1442
|
+
electronAutoUpdater.on("before-quit-for-update", () => {
|
|
1443
|
+
updateQuitStarted = true;
|
|
1444
|
+
backendSupervisor.stop();
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1342
1447
|
quitOnTermSignal({ proc: process, app, guard: quitGuard });
|
|
1343
1448
|
|
|
1344
1449
|
installQuitCleanup({ app, cleanup });
|
|
1345
1450
|
|
|
1346
1451
|
async function cleanup() {
|
|
1347
1452
|
shutdownStarted = true;
|
|
1453
|
+
backendSupervisor.stop();
|
|
1348
1454
|
const installStopped = engineInstall ? engineInstall.stop() : null;
|
|
1349
1455
|
|
|
1350
1456
|
// Phase F: tear down auto-updater timers and IPC handlers.
|
package/package.json
CHANGED
package/preload.cjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* system:* — System metrics for the observability dashboard (#2007)
|
|
17
17
|
* power:* — Keep the PC awake while a chat turn runs (#5226)
|
|
18
18
|
* dialog:* — The OS folder picker
|
|
19
|
+
* backend:* — Backend restarts after a crash (services/backend-supervisor.cjs)
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
const { contextBridge, ipcRenderer } = require("electron");
|
|
@@ -90,6 +91,13 @@ contextBridge.exposeInMainWorld("gaiaAPI", {
|
|
|
90
91
|
dialog: {
|
|
91
92
|
pickFolder: (defaultPath) => ipcRenderer.invoke("dialog:pick-folder", defaultPath),
|
|
92
93
|
},
|
|
94
|
+
|
|
95
|
+
// ── GAIA's backend process: restarts after a crash, and its new port ──
|
|
96
|
+
backend: {
|
|
97
|
+
getState: () => ipcRenderer.invoke("backend:get-state"),
|
|
98
|
+
restart: () => ipcRenderer.invoke("backend:restart"),
|
|
99
|
+
onState: (cb) => onEvent("backend:state", cb),
|
|
100
|
+
},
|
|
93
101
|
});
|
|
94
102
|
|
|
95
103
|
// ── Window frame ─────────────────────────────────────────────────────────
|
|
@@ -1705,10 +1705,10 @@ function init(mainWindow, options = {}) {
|
|
|
1705
1705
|
isBackendPrefetchedRef = options.isBackendPrefetched;
|
|
1706
1706
|
}
|
|
1707
1707
|
|
|
1708
|
-
// Disabled path — short-circuit
|
|
1709
|
-
//
|
|
1710
|
-
// string and the electron-updater singleton throws on access.
|
|
1708
|
+
// Disabled path — short-circuit before electron-updater, whose singleton
|
|
1709
|
+
// throws on access in plain Node tests.
|
|
1711
1710
|
if (isDisabled()) {
|
|
1711
|
+
_initDisabled();
|
|
1712
1712
|
setState({ status: STATES.DISABLED });
|
|
1713
1713
|
log("info", "Auto-updater disabled via GAIA_DISABLE_UPDATE=1");
|
|
1714
1714
|
return;
|
|
@@ -1844,6 +1844,29 @@ function init(mainWindow, options = {}) {
|
|
|
1844
1844
|
);
|
|
1845
1845
|
}
|
|
1846
1846
|
|
|
1847
|
+
/**
|
|
1848
|
+
* Settings still needs the running version and the disabled status, so record
|
|
1849
|
+
* the one and answer the status request. Plain Node's require("electron") is a
|
|
1850
|
+
* path string, so a missing app or ipcMain leaves the updater as it is.
|
|
1851
|
+
*/
|
|
1852
|
+
function _initDisabled() {
|
|
1853
|
+
let electron;
|
|
1854
|
+
try {
|
|
1855
|
+
// eslint-disable-next-line global-require
|
|
1856
|
+
electron = require("electron");
|
|
1857
|
+
} catch (err) {
|
|
1858
|
+
log("warn", "Electron unavailable — Settings can't show the running version:", err && err.message);
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
if (!electron || !electron.app || !electron.ipcMain) return;
|
|
1862
|
+
electronApi = { app: electron.app, ipcMain: electron.ipcMain, dialog: electron.dialog, net: electron.net };
|
|
1863
|
+
state.currentVersion = electron.app.getVersion() || null;
|
|
1864
|
+
if (!ipcHandlersRegistered) {
|
|
1865
|
+
electron.ipcMain.handle("gaia:update:get-status", () => getState());
|
|
1866
|
+
ipcHandlersRegistered = true;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1847
1870
|
function _onBeforeQuit(event) {
|
|
1848
1871
|
_refreshPreferences();
|
|
1849
1872
|
if (state.status !== STATES.DOWNLOADED || !autoUpdaterRef || !autoUpdaterRef.autoInstallOnAppQuit) {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Restarts GAIA's backend when it exits on its own: after a growing wait,
|
|
6
|
+
* at most `maxRestarts` times in `windowMs`, then it stops and waits for the
|
|
7
|
+
* user's Restart GAIA (restartNow). No Electron imports, so it unit-tests.
|
|
8
|
+
*
|
|
9
|
+
* State, as sent to the renderer:
|
|
10
|
+
* status "running" | "restarting" | "stopped"
|
|
11
|
+
* port the backend's port once running
|
|
12
|
+
* since when this outage began (ms epoch), for the banner's timer
|
|
13
|
+
* attempt which try this outage is on
|
|
14
|
+
* nextAttemptAt when the next try starts, or null while one is starting
|
|
15
|
+
* lastExit { code, signal } of the last exit
|
|
16
|
+
* lastError why the last start failed, if it did
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
"use strict";
|
|
20
|
+
|
|
21
|
+
const DEFAULTS = Object.freeze({
|
|
22
|
+
maxRestarts: 3,
|
|
23
|
+
windowMs: 10 * 60_000,
|
|
24
|
+
backoffMs: [1000, 5000, 15000],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} deps
|
|
29
|
+
* @param {() => Promise<{port: number}>} deps.start spawns the backend and resolves once it answers
|
|
30
|
+
* @param {(state: object) => void} deps.onState called with every new state
|
|
31
|
+
* @param {(state: object) => void} [deps.onStopped] called once each time restarts run out
|
|
32
|
+
*/
|
|
33
|
+
function createBackendSupervisor({
|
|
34
|
+
start,
|
|
35
|
+
onState,
|
|
36
|
+
onStopped = () => {},
|
|
37
|
+
maxRestarts = DEFAULTS.maxRestarts,
|
|
38
|
+
windowMs = DEFAULTS.windowMs,
|
|
39
|
+
backoffMs = DEFAULTS.backoffMs,
|
|
40
|
+
now = Date.now,
|
|
41
|
+
setTimer = setTimeout,
|
|
42
|
+
clearTimer = clearTimeout,
|
|
43
|
+
}) {
|
|
44
|
+
if (!Number.isInteger(maxRestarts) || maxRestarts < 1) {
|
|
45
|
+
throw new Error(`backend supervisor: maxRestarts must be a positive integer, got ${maxRestarts}`);
|
|
46
|
+
}
|
|
47
|
+
if (!Array.isArray(backoffMs) || backoffMs.length === 0) {
|
|
48
|
+
throw new Error("backend supervisor: backoffMs must list at least one delay");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Automatic restarts in the window; a user's Restart GAIA isn't counted.
|
|
52
|
+
let history = [];
|
|
53
|
+
let timer = null;
|
|
54
|
+
let starting = false;
|
|
55
|
+
let stopped = false;
|
|
56
|
+
let state = { status: "running", port: null, since: null, attempt: 0, nextAttemptAt: null, lastExit: null, lastError: null };
|
|
57
|
+
|
|
58
|
+
function emit(next) {
|
|
59
|
+
state = { ...state, ...next, maxRestarts };
|
|
60
|
+
onState({ ...state });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function attemptStart() {
|
|
64
|
+
timer = null;
|
|
65
|
+
starting = true;
|
|
66
|
+
emit({ nextAttemptAt: null });
|
|
67
|
+
let started;
|
|
68
|
+
try {
|
|
69
|
+
started = Promise.resolve(start());
|
|
70
|
+
} catch (err) {
|
|
71
|
+
started = Promise.reject(err);
|
|
72
|
+
}
|
|
73
|
+
started.then(
|
|
74
|
+
({ port }) => {
|
|
75
|
+
starting = false;
|
|
76
|
+
if (stopped) return;
|
|
77
|
+
emit({ status: "running", port, since: null, attempt: 0, lastError: null });
|
|
78
|
+
},
|
|
79
|
+
(err) => {
|
|
80
|
+
starting = false;
|
|
81
|
+
if (stopped) return;
|
|
82
|
+
schedule({ lastError: err && err.message ? err.message : String(err) });
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function schedule(extra) {
|
|
88
|
+
const t = now();
|
|
89
|
+
history = history.filter((at) => t - at < windowMs);
|
|
90
|
+
const outage = state.status === "running" ? { since: t, attempt: 0 } : {};
|
|
91
|
+
if (history.length >= maxRestarts) {
|
|
92
|
+
const wasStopped = state.status === "stopped";
|
|
93
|
+
emit({ status: "stopped", nextAttemptAt: null, ...outage, ...extra });
|
|
94
|
+
if (!wasStopped) onStopped({ ...state });
|
|
95
|
+
return "stopped";
|
|
96
|
+
}
|
|
97
|
+
const delay = backoffMs[Math.min(history.length, backoffMs.length - 1)];
|
|
98
|
+
history.push(t + delay);
|
|
99
|
+
const attempt = (outage.attempt ?? state.attempt) + 1;
|
|
100
|
+
emit({ ...outage, status: "restarting", attempt, nextAttemptAt: t + delay, ...extra });
|
|
101
|
+
timer = setTimer(attemptStart, delay);
|
|
102
|
+
return "restarting";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
/** The backend is up on `port` (the first start, outside the supervisor). */
|
|
107
|
+
running(port) {
|
|
108
|
+
emit({ status: "running", port, since: null, attempt: 0, nextAttemptAt: null, lastError: null });
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/** The backend exited on its own. Returns what happens next. */
|
|
112
|
+
crashed({ code = null, signal = null } = {}) {
|
|
113
|
+
if (stopped) return "stopped";
|
|
114
|
+
// An exit during a start is that start's failure, reported by start() itself;
|
|
115
|
+
// keep its exit so the crash dialog names the latest one.
|
|
116
|
+
if (starting) {
|
|
117
|
+
state = { ...state, lastExit: { code, signal } };
|
|
118
|
+
return state.status;
|
|
119
|
+
}
|
|
120
|
+
if (timer) return state.status;
|
|
121
|
+
return schedule({ lastExit: { code, signal } });
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/** The user's Restart GAIA: start now, with a fresh budget. */
|
|
125
|
+
restartNow() {
|
|
126
|
+
if (stopped || starting) return;
|
|
127
|
+
if (timer) {
|
|
128
|
+
clearTimer(timer);
|
|
129
|
+
timer = null;
|
|
130
|
+
}
|
|
131
|
+
history = [];
|
|
132
|
+
if (state.status === "running") return;
|
|
133
|
+
emit({ status: "restarting", attempt: 1 });
|
|
134
|
+
attemptStart();
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/** GAIA is quitting: no more restarts. */
|
|
138
|
+
stop() {
|
|
139
|
+
stopped = true;
|
|
140
|
+
if (timer) clearTimer(timer);
|
|
141
|
+
timer = null;
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
getState() {
|
|
145
|
+
return { ...state, maxRestarts };
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { createBackendSupervisor, DEFAULTS };
|
|
@@ -111,6 +111,9 @@ async function writeDiagnosticsBundle(destPath, logger) {
|
|
|
111
111
|
"gaia.log",
|
|
112
112
|
"electron-main.log",
|
|
113
113
|
"electron-install-state.json",
|
|
114
|
+
// Fatal-crash stacks from the backend (src/gaia/ui/fault_log.py), and its rotated copy.
|
|
115
|
+
"logs/backend-faults.log",
|
|
116
|
+
"logs/backend-faults.log.1",
|
|
114
117
|
];
|
|
115
118
|
|
|
116
119
|
const present = candidates.filter((rel) =>
|
package/services/quit-guard.cjs
CHANGED
|
@@ -242,6 +242,7 @@ function attachWindowGuards(win, { app, guard, getTray, isQuitting, platform, re
|
|
|
242
242
|
*
|
|
243
243
|
* @param {{proc: NodeJS.Process, app: Electron.App,
|
|
244
244
|
* guard: ReturnType<typeof installQuitGuard>, logger?: {log: Function}}} deps
|
|
245
|
+
* @returns {Promise<void>} Settles once the handler is back on top of Electron's.
|
|
245
246
|
*/
|
|
246
247
|
function quitOnTermSignal({ proc, app, guard, logger = console }) {
|
|
247
248
|
proc.on("SIGTERM", () => {
|
|
@@ -249,6 +250,14 @@ function quitOnTermSignal({ proc, app, guard, logger = console }) {
|
|
|
249
250
|
guard.allow();
|
|
250
251
|
app.quit();
|
|
251
252
|
});
|
|
253
|
+
// Electron installs its own SIGTERM handler after the main script runs, and
|
|
254
|
+
// it replaces Node's, so the listener above never fires. Re-adding every
|
|
255
|
+
// listener once ready makes Node install its handler again, over Electron's.
|
|
256
|
+
return app.whenReady().then(() => {
|
|
257
|
+
const listeners = proc.listeners("SIGTERM");
|
|
258
|
+
proc.removeAllListeners("SIGTERM");
|
|
259
|
+
for (const listener of listeners) proc.on("SIGTERM", listener);
|
|
260
|
+
});
|
|
252
261
|
}
|
|
253
262
|
|
|
254
263
|
module.exports = {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether this desktop can show GAIA's tray icon at all.
|
|
6
|
+
*
|
|
7
|
+
* Windows and macOS always can. On Linux, Electron's tray is a
|
|
8
|
+
* StatusNotifierItem, which shows only when a host owns the
|
|
9
|
+
* `org.kde.StatusNotifierWatcher` name on the session bus. Stock GNOME has
|
|
10
|
+
* none (its AppIndicator extension adds one), so hiding the window there
|
|
11
|
+
* leaves GAIA running with no way back to it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { execFile } = require("child_process");
|
|
15
|
+
|
|
16
|
+
const WATCHER = "org.kde.StatusNotifierWatcher";
|
|
17
|
+
const PROBE_TIMEOUT_MS = 2000;
|
|
18
|
+
|
|
19
|
+
/** The two bus clients a Linux desktop ships, and how each reports NameHasOwner. */
|
|
20
|
+
const PROBES = [
|
|
21
|
+
{
|
|
22
|
+
cmd: "gdbus",
|
|
23
|
+
args: [
|
|
24
|
+
"call", "--session",
|
|
25
|
+
"--dest", "org.freedesktop.DBus",
|
|
26
|
+
"--object-path", "/org/freedesktop/DBus",
|
|
27
|
+
"--method", "org.freedesktop.DBus.NameHasOwner", WATCHER,
|
|
28
|
+
],
|
|
29
|
+
parse: (out) => /\(\s*true\s*,?\s*\)/.test(out) ? true : /\(\s*false\s*,?\s*\)/.test(out) ? false : null,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
cmd: "dbus-send",
|
|
33
|
+
args: [
|
|
34
|
+
"--session", "--print-reply", "--dest=org.freedesktop.DBus",
|
|
35
|
+
"/org/freedesktop/DBus", "org.freedesktop.DBus.NameHasOwner", `string:${WATCHER}`,
|
|
36
|
+
],
|
|
37
|
+
parse: (out) => /boolean\s+true/.test(out) ? true : /boolean\s+false/.test(out) ? false : null,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function runCommand(cmd, args) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
execFile(cmd, args, { timeout: PROBE_TIMEOUT_MS, encoding: "utf8" }, (err, stdout, stderr) => {
|
|
44
|
+
if (err) {
|
|
45
|
+
err.stderr = stderr;
|
|
46
|
+
reject(err);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
resolve(stdout);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {{available: boolean, reason: null | "no-host" | "check-failed", detail?: string}} TrayHost
|
|
56
|
+
* `no-host`: the bus answered and nothing hosts tray icons.
|
|
57
|
+
* `check-failed`: the bus couldn't be asked; treated as no tray, so closing never strands the app.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {{platform?: string, run?: (cmd: string, args: string[]) => Promise<string>}} [deps]
|
|
62
|
+
* @returns {Promise<TrayHost>}
|
|
63
|
+
*/
|
|
64
|
+
async function probeTrayHost({ platform = process.platform, run = runCommand } = {}) {
|
|
65
|
+
if (platform !== "linux") return { available: true, reason: null };
|
|
66
|
+
const failures = [];
|
|
67
|
+
for (const probe of PROBES) {
|
|
68
|
+
let out;
|
|
69
|
+
try {
|
|
70
|
+
out = await run(probe.cmd, probe.args);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const why = err.code === "ENOENT" ? "not installed" : (err.stderr || err.message || String(err)).trim();
|
|
73
|
+
failures.push(`${probe.cmd}: ${why}`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const owned = probe.parse(out);
|
|
77
|
+
if (owned === true) return { available: true, reason: null };
|
|
78
|
+
if (owned === false) return { available: false, reason: "no-host", detail: `nothing owns ${WATCHER}` };
|
|
79
|
+
failures.push(`${probe.cmd}: unexpected reply ${JSON.stringify(out.trim())}`);
|
|
80
|
+
}
|
|
81
|
+
return { available: false, reason: "check-failed", detail: failures.join("; ") };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { probeTrayHost, WATCHER, PROBE_TIMEOUT_MS };
|