@amd-gaia/agent-ui 0.25.0 → 0.25.1-rc.10
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/assets/tray-icon.png +0 -0
- package/assets/tray-icon@2x.png +0 -0
- package/dist/assets/AdvancedSettings-CtYKNNWc.js +1 -0
- package/dist/assets/AdvancedSettings-D5VKpkPY.css +1 -0
- package/dist/assets/ConnectorsPane-Ctfp9d5s.js +1 -0
- package/dist/assets/ConnectorsPane-Dzu2DRTc.css +1 -0
- package/dist/assets/DocumentLibrary-CIKp6qld.css +1 -0
- package/dist/assets/DocumentLibrary-Fp70O1-8.js +4 -0
- package/dist/assets/FileBrowser-3H3DoJIm.js +4 -0
- package/dist/assets/FileBrowser-D04I8Pvv.css +1 -0
- package/dist/assets/MemoryDashboard-D5HzmtDi.js +1 -0
- package/dist/assets/MemoryDashboard-Mo721-BI.css +1 -0
- package/dist/assets/MobileAccessModal-B9peVPY4.js +2 -0
- package/dist/assets/MobileAccessModal-DuVy9F1u.css +1 -0
- package/dist/assets/ScheduleManager-DotJl5JW.css +1 -0
- package/dist/assets/ScheduleManager-HcGB9P6m.js +2 -0
- package/dist/assets/{jsx-runtime-DVeaULmT.js → apiBase-y00JgRBP.js} +1 -1
- package/dist/assets/eye-B6FejHNG.js +1 -0
- package/dist/assets/fraunces-latin-BwwriW9Y.woff2 +0 -0
- package/dist/assets/index-D2UMLtwY.js +74 -0
- package/dist/assets/index-DF5qku9L.css +1 -0
- package/dist/assets/inter-latin-C2S99t-D.woff2 +0 -0
- package/dist/assets/inter-latin-ext-CFHvXkgd.woff2 +0 -0
- package/dist/assets/message-square-Dwm7y3C0.js +1 -0
- package/dist/fonts-OFL.txt +101 -0
- package/dist/index.html +6 -6
- package/main-safety-net.cjs +54 -7
- package/main.cjs +276 -120
- package/package.json +3 -1
- package/preload.cjs +24 -0
- package/services/agent-process-manager.cjs +116 -13
- package/services/app-icon.cjs +29 -0
- package/services/auto-updater.cjs +26 -2
- package/services/backend-crash-dialog.cjs +102 -0
- package/services/backend-installer-progress-dialog.cjs +71 -43
- package/services/backend-installer.cjs +233 -25
- package/services/chromium-switches.cjs +29 -0
- package/services/keep-awake.cjs +52 -0
- package/services/notification-service.cjs +62 -0
- package/services/port-manager.cjs +19 -70
- package/services/process-tree.cjs +328 -0
- package/services/quit-cleanup.cjs +86 -0
- package/services/quit-guard.cjs +242 -0
- package/services/tray-icons.cjs +174 -0
- package/services/tray-manager.cjs +582 -83
- package/services/tray-state.cjs +231 -0
- package/services/window-keys.cjs +43 -0
- package/assets/tray-icon.ico +0 -0
- package/dist/assets/AdvancedSettings-5cX0pGZc.js +0 -1
- package/dist/assets/AdvancedSettings-Ce6EvcAe.css +0 -1
- package/dist/assets/ConnectorsPane-CG7atLb2.css +0 -1
- package/dist/assets/ConnectorsPane-CuWxzcBY.js +0 -1
- package/dist/assets/DocumentLibrary-BdAKDe8H.js +0 -1
- package/dist/assets/DocumentLibrary-DrIP_032.css +0 -1
- package/dist/assets/FileBrowser-B8s6hKC5.js +0 -4
- package/dist/assets/FileBrowser-Bj6aB6Ro.css +0 -1
- package/dist/assets/MemoryDashboard-DQxtAW90.css +0 -1
- package/dist/assets/MemoryDashboard-DtolA-HZ.js +0 -1
- package/dist/assets/MobileAccessModal-C3nWkpWA.js +0 -2
- package/dist/assets/MobileAccessModal-C_t8DG0n.css +0 -1
- package/dist/assets/ScheduleManager-CIescbX6.css +0 -1
- package/dist/assets/ScheduleManager-HHdD6sko.js +0 -2
- package/dist/assets/dm-sans-latin-Xz1IZZA0.woff2 +0 -0
- package/dist/assets/index-CFVpPQal.css +0 -1
- package/dist/assets/index-DEPbenxC.js +0 -60
- package/dist/assets/message-square-Ccmbk96j.js +0 -1
- package/dist/assets/space-mono-400-Co7bH5Hm.woff2 +0 -0
- package/dist/assets/useEscapeToClose-B8fw5mn-.js +0 -1
package/main.cjs
CHANGED
|
@@ -13,7 +13,17 @@
|
|
|
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 {
|
|
16
|
+
const {
|
|
17
|
+
app,
|
|
18
|
+
autoUpdater: electronAutoUpdater,
|
|
19
|
+
BrowserWindow,
|
|
20
|
+
Menu,
|
|
21
|
+
dialog,
|
|
22
|
+
ipcMain,
|
|
23
|
+
powerMonitor,
|
|
24
|
+
session,
|
|
25
|
+
shell,
|
|
26
|
+
} = require("electron");
|
|
17
27
|
const path = require("path");
|
|
18
28
|
const fs = require("fs");
|
|
19
29
|
const os = require("os");
|
|
@@ -37,12 +47,18 @@ const _MAIN_LOG_PATH = path.join(_GAIA_DIR, "electron-main.log");
|
|
|
37
47
|
// very handler we are trying to install, falling through to Electron's bare
|
|
38
48
|
// JS-error dialog.
|
|
39
49
|
let installSafetyNet, installLogTee, _fatalHandler;
|
|
50
|
+
// Set when a fatal error quits the app: the crash dialog was the question,
|
|
51
|
+
// so the quit guard must not ask again.
|
|
52
|
+
let fatalQuitting = false;
|
|
40
53
|
try {
|
|
41
54
|
({ installSafetyNet, installLogTee } = require("./main-safety-net.cjs"));
|
|
42
55
|
({ fatal: _fatalHandler } = installSafetyNet({
|
|
43
56
|
logPath: _MAIN_LOG_PATH,
|
|
44
57
|
dialogModule: dialog,
|
|
45
58
|
appModule: app,
|
|
59
|
+
beforeQuit: () => {
|
|
60
|
+
fatalQuitting = true;
|
|
61
|
+
},
|
|
46
62
|
}));
|
|
47
63
|
} catch (err) {
|
|
48
64
|
try { process.stderr.write(`[main] safety-net load failed: ${err.message}\n`); } catch { }
|
|
@@ -54,10 +70,21 @@ try {
|
|
|
54
70
|
|
|
55
71
|
// Services (loaded after app.whenReady)
|
|
56
72
|
const TrayManager = require("./services/tray-manager.cjs");
|
|
73
|
+
const { appIconPath, appUserModelId } = require("./services/app-icon.cjs");
|
|
74
|
+
const {
|
|
75
|
+
attachWindowGuards,
|
|
76
|
+
confirmQuit,
|
|
77
|
+
fetchWorkInProgress,
|
|
78
|
+
installQuitGuard,
|
|
79
|
+
QUIT,
|
|
80
|
+
RESTART_FOR_UPDATE,
|
|
81
|
+
} = require("./services/quit-guard.cjs");
|
|
57
82
|
const AgentProcessManager = require("./services/agent-process-manager.cjs");
|
|
58
83
|
const NotificationService = require("./services/notification-service.cjs");
|
|
84
|
+
const KeepAwake = require("./services/keep-awake.cjs");
|
|
59
85
|
const PortManager = require("./services/port-manager.cjs");
|
|
60
86
|
const { isGaiaBackendProcess } = require("./services/backend-orphan.cjs");
|
|
87
|
+
const { showBackendCrashDialog } = require("./services/backend-crash-dialog.cjs");
|
|
61
88
|
const { buildIndexQuery } = require("./services/index-query.cjs");
|
|
62
89
|
const backendInstaller = require("./services/backend-installer.cjs");
|
|
63
90
|
const installerProgressDialog = require("./services/backend-installer-progress-dialog.cjs");
|
|
@@ -76,27 +103,14 @@ const {
|
|
|
76
103
|
describeBlockedUrl,
|
|
77
104
|
} = require("./services/link-policy.cjs");
|
|
78
105
|
const { createStartupDeepLinkQueue } = require("./services/deep-link-queue.cjs");
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// chrome-sandbox is deleted from the packaged tree by after-pack.cjs so
|
|
88
|
-
// Chromium must use its unprivileged user-namespace sandbox on every launch
|
|
89
|
-
// path. The .desktop Exec= line already carries --no-sandbox via
|
|
90
|
-
// electron-builder.yml linux.executableArgs, but direct `./GAIA.AppImage`
|
|
91
|
-
// invocations bypass the .desktop entry. Appending the switch here makes
|
|
92
|
-
// all Linux launch paths behave identically.
|
|
93
|
-
if (process.platform === "linux") {
|
|
94
|
-
app.commandLine.appendSwitch("no-sandbox");
|
|
95
|
-
|
|
96
|
-
// Workaround: disable Chromium's Wayland color management which crashes on
|
|
97
|
-
// some Wayland compositors that implement the wp_color_manager protocol
|
|
98
|
-
// partially. See issue reports about wayland_wp_color_manager.cc SIGTRAP.
|
|
99
|
-
app.commandLine.appendSwitch("disable-features", "WaylandColorManagement");
|
|
106
|
+
const { installWindowKeys } = require("./services/window-keys.cjs");
|
|
107
|
+
const { installQuitCleanup } = require("./services/quit-cleanup.cjs");
|
|
108
|
+
const { chromiumSwitches } = require("./services/chromium-switches.cjs");
|
|
109
|
+
|
|
110
|
+
// No ozone switch: Electron 38+ already picks Wayland or X11, and users override with --ozone-platform=x11.
|
|
111
|
+
for (const [name, value] of chromiumSwitches({ platform: process.platform, env: process.env })) {
|
|
112
|
+
if (value === undefined) app.commandLine.appendSwitch(name);
|
|
113
|
+
else app.commandLine.appendSwitch(name, value);
|
|
100
114
|
}
|
|
101
115
|
|
|
102
116
|
// ── F7: Log tee to ~/.gaia/electron-main.log (issue #782) ───────────────────
|
|
@@ -205,6 +219,11 @@ const windowConfig = appConfig.window || {
|
|
|
205
219
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
206
220
|
|
|
207
221
|
let backendProcess = null;
|
|
222
|
+
// The in-flight startBackend(), so a quit during startup can wait for the
|
|
223
|
+
// spawn and stop it instead of leaving it behind.
|
|
224
|
+
let backendStarting = null;
|
|
225
|
+
// Set as cleanup() begins; startup stops at its next step once it is.
|
|
226
|
+
let shutdownStarted = false;
|
|
208
227
|
let backendPort = DEFAULT_BACKEND_PORT;
|
|
209
228
|
let healthCheckUrl = `http://localhost:${backendPort}/api/health`;
|
|
210
229
|
let backendStderrTail = [];
|
|
@@ -225,6 +244,9 @@ let agentProcessManager = null;
|
|
|
225
244
|
/** @type {NotificationService | null} */
|
|
226
245
|
let notificationService = null;
|
|
227
246
|
|
|
247
|
+
/** @type {KeepAwake | null} */
|
|
248
|
+
let keepAwake = null;
|
|
249
|
+
|
|
228
250
|
/** @type {PortManager} */
|
|
229
251
|
const portManager = new PortManager({
|
|
230
252
|
logger: { log: console.log.bind(console), error: console.error.bind(console) },
|
|
@@ -386,6 +408,7 @@ async function startBackend() {
|
|
|
386
408
|
}
|
|
387
409
|
const crashed = !isIntentionalKill && code !== 0 && code !== null;
|
|
388
410
|
backendProcess = null;
|
|
411
|
+
if (trayManager && !isQuitting) trayManager.setBackendAlive(false);
|
|
389
412
|
|
|
390
413
|
if (crashed && !isQuitting) {
|
|
391
414
|
// Fire-and-forget — don't block the event loop.
|
|
@@ -411,46 +434,23 @@ async function startBackend() {
|
|
|
411
434
|
}
|
|
412
435
|
|
|
413
436
|
/**
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
* when the kill was NOT initiated by us.
|
|
437
|
+
* Tell the user the backend stopped and offer Restart / Save diagnostics /
|
|
438
|
+
* Quit. Invoked from child.on("exit") when the kill was NOT initiated by us.
|
|
417
439
|
*/
|
|
418
440
|
async function handleBackendCrash(code, signal) {
|
|
419
|
-
const tail = backendStderrTail.slice(-20).join("\n") || "(no stderr captured)";
|
|
420
|
-
const detail =
|
|
421
|
-
`The GAIA backend exited unexpectedly (code=${code}, signal=${signal}).\n\n` +
|
|
422
|
-
`Recent log output:\n${tail}`;
|
|
423
|
-
|
|
424
441
|
try {
|
|
425
|
-
const choice = await
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
442
|
+
const choice = await showBackendCrashDialog({
|
|
443
|
+
dialog,
|
|
444
|
+
app,
|
|
445
|
+
code,
|
|
446
|
+
signal,
|
|
447
|
+
tailLines: backendStderrTail.slice(-8),
|
|
448
|
+
writeDiagnostics: () => portManager.writeDiagnosticsBundle(),
|
|
449
|
+
// The backend is gone, so there is no running work to ask about.
|
|
450
|
+
beforeQuit: () => { fatalQuitting = true; },
|
|
451
|
+
parent: mainWindow,
|
|
434
452
|
});
|
|
435
|
-
|
|
436
|
-
if (choice.response === 0) {
|
|
437
|
-
try {
|
|
438
|
-
const bundlePath = await portManager.writeDiagnosticsBundle();
|
|
439
|
-
await dialog.showMessageBox({
|
|
440
|
-
type: "info",
|
|
441
|
-
title: "Diagnostics saved",
|
|
442
|
-
message: "Diagnostics bundle written",
|
|
443
|
-
detail: `Attach this file to your bug report:\n${bundlePath}`,
|
|
444
|
-
buttons: ["OK"],
|
|
445
|
-
});
|
|
446
|
-
} catch (err) {
|
|
447
|
-
console.error("[main] Could not write diagnostics bundle:", err.message);
|
|
448
|
-
dialog.showErrorBox(
|
|
449
|
-
"Could not write diagnostics",
|
|
450
|
-
`Failed to write diagnostics bundle: ${err.message}`
|
|
451
|
-
);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
453
|
+
console.log(`[main] Backend crash: user chose ${choice}`);
|
|
454
454
|
} catch (err) {
|
|
455
455
|
console.error("[main] handleBackendCrash failed:", err.message);
|
|
456
456
|
}
|
|
@@ -523,14 +523,57 @@ function reportBlockedLink(url) {
|
|
|
523
523
|
);
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
+
// No system title bar: the app's own top row is the window's top edge, and
|
|
527
|
+
// the native window controls are drawn into it (traffic lights on macOS).
|
|
528
|
+
// Keep the height in sync with --titlebar-h in src/styles/index.css. The
|
|
529
|
+
// renderer sends its canvas and text colors once it paints; until then the
|
|
530
|
+
// controls use the dark canvas, like the window background.
|
|
531
|
+
const TITLE_BAR_HEIGHT = 48;
|
|
532
|
+
const TITLE_BAR_INITIAL = { color: "#1A1917", symbolColor: "#EFECE6" };
|
|
533
|
+
const IS_MAC = process.platform === "darwin";
|
|
534
|
+
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
535
|
+
|
|
536
|
+
function titleBarOptions() {
|
|
537
|
+
return IS_MAC
|
|
538
|
+
? { titleBarStyle: "hidden", trafficLightPosition: { x: 16, y: 16 } }
|
|
539
|
+
: {
|
|
540
|
+
titleBarStyle: "hidden",
|
|
541
|
+
titleBarOverlay: { ...TITLE_BAR_INITIAL, height: TITLE_BAR_HEIGHT },
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// The renderer sends its canvas and text colors so the controls match the theme.
|
|
546
|
+
ipcMain.handle("window:set-titlebar-colors", (_event, colors) => {
|
|
547
|
+
const { color, symbolColor } = colors || {};
|
|
548
|
+
if (!HEX_COLOR.test(color) || !HEX_COLOR.test(symbolColor)) {
|
|
549
|
+
throw new Error(`Title bar colors must be #rrggbb, got ${JSON.stringify(colors)}`);
|
|
550
|
+
}
|
|
551
|
+
if (!IS_MAC && mainWindow && !mainWindow.isDestroyed()) {
|
|
552
|
+
mainWindow.setTitleBarOverlay({ color, symbolColor });
|
|
553
|
+
}
|
|
554
|
+
return true;
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// Registered once: createWindow runs again on every macOS reopen, and the session is shared.
|
|
558
|
+
function titleDownloadSaveDialogs() {
|
|
559
|
+
// An export is a blob: download, and Chromium titles its Save dialog with the blob URL.
|
|
560
|
+
session.defaultSession.on("will-download", (_event, item) => {
|
|
561
|
+
item.setSaveDialogOptions({
|
|
562
|
+
title: "Save from GAIA",
|
|
563
|
+
defaultPath: path.join(app.getPath("downloads"), item.getFilename()),
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
526
568
|
function createWindow() {
|
|
527
569
|
mainWindow = new BrowserWindow({
|
|
570
|
+
...titleBarOptions(),
|
|
528
571
|
width: windowConfig.width,
|
|
529
572
|
height: windowConfig.height,
|
|
530
573
|
minWidth: windowConfig.minWidth,
|
|
531
574
|
minHeight: windowConfig.minHeight,
|
|
532
575
|
title: APP_NAME,
|
|
533
|
-
icon:
|
|
576
|
+
icon: appIconPath(),
|
|
534
577
|
show: false, // Don't show until ready (prevents flash)
|
|
535
578
|
webPreferences: {
|
|
536
579
|
nodeIntegration: false,
|
|
@@ -539,7 +582,12 @@ function createWindow() {
|
|
|
539
582
|
},
|
|
540
583
|
});
|
|
541
584
|
|
|
542
|
-
//
|
|
585
|
+
// No File/Edit/View menu: hiding the bar alone still lets Alt bring it back.
|
|
586
|
+
// macOS keeps its menu, which carries copy, paste and quit.
|
|
587
|
+
if (process.platform !== "darwin") {
|
|
588
|
+
Menu.setApplicationMenu(null);
|
|
589
|
+
installWindowKeys(mainWindow.webContents, { devTools: !app.isPackaged });
|
|
590
|
+
}
|
|
543
591
|
mainWindow.setMenuBarVisibility(false);
|
|
544
592
|
|
|
545
593
|
// Open external links in the default browser. The URL here is already
|
|
@@ -567,18 +615,27 @@ function createWindow() {
|
|
|
567
615
|
reportBlockedLink(url);
|
|
568
616
|
});
|
|
569
617
|
|
|
570
|
-
// ── Minimize-to-tray on close (C4 fix)
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
618
|
+
// ── Minimize-to-tray on close (C4 fix), first-close notice, quit guard ──
|
|
619
|
+
attachWindowGuards(mainWindow, {
|
|
620
|
+
app,
|
|
621
|
+
guard: quitGuard,
|
|
622
|
+
getTray: () => trayManager,
|
|
623
|
+
isQuitting: () => isQuitting,
|
|
624
|
+
platform: process.platform,
|
|
625
|
+
reportNoticeError: (err) => {
|
|
626
|
+
console.error("[main] First-close notice failed:", err.message);
|
|
627
|
+
const where = process.platform === "darwin" ? "menu bar" : "tray";
|
|
628
|
+
dialog.showErrorBox(
|
|
629
|
+
"GAIA is still running",
|
|
630
|
+
`GAIA is still running in the ${where}. Quit from the ${where} menu to stop it.\n\n` +
|
|
631
|
+
`(The usual notice failed: ${err.message})`
|
|
632
|
+
);
|
|
633
|
+
},
|
|
578
634
|
});
|
|
579
635
|
|
|
580
636
|
mainWindow.on("closed", () => {
|
|
581
637
|
mainWindow = null;
|
|
638
|
+
if (trayManager) trayManager.windowClosed();
|
|
582
639
|
});
|
|
583
640
|
|
|
584
641
|
// Show window when ready (unless --minimized or startMinimized config)
|
|
@@ -631,9 +688,9 @@ async function loadApp() {
|
|
|
631
688
|
* src/styles/index.css, so the dark theme's role values are
|
|
632
689
|
* mirrored here. Keep them in step with that file. */
|
|
633
690
|
:root {
|
|
634
|
-
--bg-primary: #
|
|
635
|
-
--text-primary: #
|
|
636
|
-
--text-secondary: #
|
|
691
|
+
--bg-primary: #1A1917;
|
|
692
|
+
--text-primary: #EFECE6;
|
|
693
|
+
--text-secondary: #B3ADA5;
|
|
637
694
|
}
|
|
638
695
|
body {
|
|
639
696
|
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
|
@@ -664,6 +721,25 @@ async function loadApp() {
|
|
|
664
721
|
|
|
665
722
|
// ── Services Setup ─────────────────────────────────────────────────────────
|
|
666
723
|
|
|
724
|
+
/**
|
|
725
|
+
* Recreate the window after it was closed (macOS keeps the app running).
|
|
726
|
+
* Services are re-pointed at it, not re-created: their IPC handlers stay.
|
|
727
|
+
* @returns {Electron.BrowserWindow}
|
|
728
|
+
*/
|
|
729
|
+
function reopenWindow() {
|
|
730
|
+
createWindow();
|
|
731
|
+
// Asked for from the tray or the dock: show it even when starting minimized.
|
|
732
|
+
mainWindow.once("ready-to-show", () => mainWindow && mainWindow.show());
|
|
733
|
+
if (agentProcessManager) agentProcessManager.mainWindow = mainWindow;
|
|
734
|
+
if (trayManager) trayManager.mainWindow = mainWindow;
|
|
735
|
+
if (notificationService) notificationService.mainWindow = mainWindow;
|
|
736
|
+
loadApp().catch((err) => {
|
|
737
|
+
console.error("[main] Failed to load the reopened window:", err.message);
|
|
738
|
+
dialog.showErrorBox("GAIA", `GAIA couldn't reopen its window: ${err.message}`);
|
|
739
|
+
});
|
|
740
|
+
return mainWindow;
|
|
741
|
+
}
|
|
742
|
+
|
|
667
743
|
function initializeServices() {
|
|
668
744
|
console.log("[main] Initializing services...");
|
|
669
745
|
|
|
@@ -676,8 +752,16 @@ function initializeServices() {
|
|
|
676
752
|
});
|
|
677
753
|
|
|
678
754
|
// T1: Tray Manager (system tray icon + context menu)
|
|
679
|
-
trayManager = new TrayManager(mainWindow, {
|
|
755
|
+
trayManager = new TrayManager(mainWindow, {
|
|
756
|
+
backendPort,
|
|
757
|
+
reopenWindow: () => reopenWindow(),
|
|
758
|
+
});
|
|
680
759
|
trayManager.create();
|
|
760
|
+
try {
|
|
761
|
+
trayManager.syncLoginItem();
|
|
762
|
+
} catch (err) {
|
|
763
|
+
console.error(`[main] Could not read the sign-in start setting: ${err.message}`);
|
|
764
|
+
}
|
|
681
765
|
|
|
682
766
|
// T5: Notification Service (routes agent notifications to OS + renderer)
|
|
683
767
|
notificationService = new NotificationService(
|
|
@@ -686,6 +770,9 @@ function initializeServices() {
|
|
|
686
770
|
trayManager
|
|
687
771
|
);
|
|
688
772
|
|
|
773
|
+
// #5226: hold off idle sleep while a chat turn runs
|
|
774
|
+
keepAwake = new KeepAwake();
|
|
775
|
+
|
|
689
776
|
// #2007: System metrics for the observability dashboard
|
|
690
777
|
systemMetrics.registerIpcHandlers();
|
|
691
778
|
|
|
@@ -732,8 +819,19 @@ function setupJumpList() {
|
|
|
732
819
|
// ── Backend Bootstrap (Phase A) ───────────────────────────────────────────
|
|
733
820
|
|
|
734
821
|
/** First install found a Lemonade already running: use it, or install GAIA's own? */
|
|
735
|
-
async function chooseLemonadeInDialog(found) {
|
|
736
|
-
|
|
822
|
+
async function chooseLemonadeInDialog(found, parent) {
|
|
823
|
+
// Modal to the progress window and in front: otherwise it can open behind
|
|
824
|
+
// other windows while the install sits at 70% waiting for it.
|
|
825
|
+
const owner = parent && !parent.isDestroyed() ? parent : null;
|
|
826
|
+
if (owner) {
|
|
827
|
+
if (owner.isMinimized()) owner.restore();
|
|
828
|
+
owner.show();
|
|
829
|
+
owner.moveTop();
|
|
830
|
+
owner.focus();
|
|
831
|
+
// Windows can refuse focus to a background app; the taskbar flash is the hint.
|
|
832
|
+
if (!owner.isFocused()) owner.flashFrame(true);
|
|
833
|
+
}
|
|
834
|
+
const box = {
|
|
737
835
|
type: "question",
|
|
738
836
|
title: "Lemonade Server found",
|
|
739
837
|
message: `A Lemonade Server (v${found.version}) is already running at ${found.url}. Use it instead of installing GAIA's own?`,
|
|
@@ -742,7 +840,11 @@ async function chooseLemonadeInDialog(found) {
|
|
|
742
840
|
defaultId: 0,
|
|
743
841
|
cancelId: 1,
|
|
744
842
|
noLink: true,
|
|
745
|
-
}
|
|
843
|
+
};
|
|
844
|
+
const { response } = owner
|
|
845
|
+
? await dialog.showMessageBox(owner, box)
|
|
846
|
+
: await dialog.showMessageBox(box);
|
|
847
|
+
if (owner && !owner.isDestroyed()) owner.flashFrame(false);
|
|
746
848
|
return response === 0 ? "external" : "gaia";
|
|
747
849
|
}
|
|
748
850
|
|
|
@@ -793,7 +895,7 @@ async function bootstrapBackend() {
|
|
|
793
895
|
await backendInstaller.ensureBackend({
|
|
794
896
|
onProgress: progress.onProgress,
|
|
795
897
|
isPackaged: app.isPackaged,
|
|
796
|
-
chooseLemonade: chooseLemonadeInDialog,
|
|
898
|
+
chooseLemonade: (found) => chooseLemonadeInDialog(found, progress.window),
|
|
797
899
|
});
|
|
798
900
|
progress.close();
|
|
799
901
|
console.log("[main] Backend bootstrap complete");
|
|
@@ -1054,6 +1156,11 @@ if (!gotTheSingleInstanceLock) {
|
|
|
1054
1156
|
process.exit(0);
|
|
1055
1157
|
}
|
|
1056
1158
|
|
|
1159
|
+
// Before any window: the taskbar reads the ID when a window first appears.
|
|
1160
|
+
if (process.platform === "win32") {
|
|
1161
|
+
app.setAppUserModelId(appUserModelId(app.isPackaged));
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1057
1164
|
app.on("second-instance", (_event, argv, _cwd) => {
|
|
1058
1165
|
// A second launch happened while we were running. Surface our window
|
|
1059
1166
|
// (the user almost certainly wanted to see it). mainWindow may be null
|
|
@@ -1111,9 +1218,16 @@ app.whenReady().then(async () => {
|
|
|
1111
1218
|
}
|
|
1112
1219
|
|
|
1113
1220
|
// Start the Python backend
|
|
1114
|
-
|
|
1221
|
+
if (shutdownStarted) return;
|
|
1222
|
+
backendStarting = startBackend();
|
|
1223
|
+
backendProcess = await backendStarting;
|
|
1224
|
+
if (shutdownStarted) {
|
|
1225
|
+
console.log("[main] Quit during startup — not opening the window");
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1115
1228
|
|
|
1116
1229
|
// Create the window (hidden until ready-to-show)
|
|
1230
|
+
titleDownloadSaveDialogs();
|
|
1117
1231
|
createWindow();
|
|
1118
1232
|
isBootstrapping = false; // progress dialog is gone; window-all-closed may now quit
|
|
1119
1233
|
|
|
@@ -1125,7 +1239,7 @@ app.whenReady().then(async () => {
|
|
|
1125
1239
|
// Any failure here is logged and swallowed — the app continues to run
|
|
1126
1240
|
// even if auto-update is unavailable.
|
|
1127
1241
|
try {
|
|
1128
|
-
autoUpdater.init(mainWindow);
|
|
1242
|
+
autoUpdater.init(mainWindow, { confirmRestart: confirmUpdateRestart });
|
|
1129
1243
|
} catch (err) {
|
|
1130
1244
|
console.error(
|
|
1131
1245
|
"[main] Failed to init auto-updater:",
|
|
@@ -1169,18 +1283,12 @@ app.whenReady().then(async () => {
|
|
|
1169
1283
|
}
|
|
1170
1284
|
}
|
|
1171
1285
|
|
|
1172
|
-
|
|
1286
|
+
// macOS and Linux: the shutdown is the OS's, so don't ask.
|
|
1287
|
+
powerMonitor.on("shutdown", () => quitGuard.allow());
|
|
1288
|
+
|
|
1289
|
+
app.on("activate", () => {
|
|
1173
1290
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
1174
|
-
|
|
1175
|
-
// Re-wire existing services to the new window (don't re-create — IPC handlers are already registered)
|
|
1176
|
-
if (agentProcessManager) agentProcessManager.mainWindow = mainWindow;
|
|
1177
|
-
if (trayManager) trayManager.mainWindow = mainWindow;
|
|
1178
|
-
if (notificationService) notificationService.mainWindow = mainWindow;
|
|
1179
|
-
try {
|
|
1180
|
-
await loadApp();
|
|
1181
|
-
} catch (err) {
|
|
1182
|
-
console.error("[main] Failed to load app on activate:", err.message);
|
|
1183
|
-
}
|
|
1291
|
+
reopenWindow();
|
|
1184
1292
|
} else if (mainWindow) {
|
|
1185
1293
|
mainWindow.show();
|
|
1186
1294
|
}
|
|
@@ -1215,33 +1323,57 @@ app.on("window-all-closed", () => {
|
|
|
1215
1323
|
// Electron's before-quit does NOT await async handlers.
|
|
1216
1324
|
// We use will-quit + event.preventDefault() to perform async cleanup, then re-quit.
|
|
1217
1325
|
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
// Mark any subsequent backend exit as intentional so we don't pop the
|
|
1223
|
-
// crash dialog during normal shutdown.
|
|
1224
|
-
isIntentionalKill = true;
|
|
1225
|
-
});
|
|
1326
|
+
/** Only while the GAIA service runs: without it, no answer is running. */
|
|
1327
|
+
function workCanBeLost() {
|
|
1328
|
+
return Boolean(trayManager && backendProcess);
|
|
1329
|
+
}
|
|
1226
1330
|
|
|
1227
|
-
|
|
1228
|
-
|
|
1331
|
+
/** Ask about losing work in front of the window, even when it was hidden in the tray. */
|
|
1332
|
+
function askAboutRunningWork(action) {
|
|
1333
|
+
return confirmQuit({
|
|
1334
|
+
action,
|
|
1335
|
+
getWork: () => fetchWorkInProgress(backendPort),
|
|
1336
|
+
showMessageBox: (opts) => {
|
|
1337
|
+
if (!mainWindow || mainWindow.isDestroyed()) return dialog.showMessageBox(opts);
|
|
1338
|
+
if (!mainWindow.isVisible()) mainWindow.show();
|
|
1339
|
+
return dialog.showMessageBox(mainWindow, opts);
|
|
1340
|
+
},
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1229
1343
|
|
|
1230
|
-
|
|
1231
|
-
|
|
1344
|
+
/** The update dialog's "Restart now": the installer starts at once, so ask first. */
|
|
1345
|
+
function confirmUpdateRestart() {
|
|
1346
|
+
return workCanBeLost() ? askAboutRunningWork(RESTART_FOR_UPDATE) : Promise.resolve(true);
|
|
1347
|
+
}
|
|
1232
1348
|
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1349
|
+
// Every quit asks first while an answer or index job would be lost; an
|
|
1350
|
+
// update restart (asked in confirmUpdateRestart) and an OS shutdown don't.
|
|
1351
|
+
const quitGuard = installQuitGuard({
|
|
1352
|
+
app,
|
|
1353
|
+
updater: electronAutoUpdater,
|
|
1354
|
+
shouldAsk: () => !fatalQuitting && workCanBeLost(),
|
|
1355
|
+
confirm: () => askAboutRunningWork(QUIT),
|
|
1356
|
+
onQuitting: () => {
|
|
1357
|
+
isQuitting = true;
|
|
1358
|
+
// Mark any subsequent backend exit as intentional so we don't pop the
|
|
1359
|
+
// crash dialog during normal shutdown.
|
|
1360
|
+
isIntentionalKill = true;
|
|
1361
|
+
},
|
|
1362
|
+
onError: (err) => {
|
|
1363
|
+
console.error("[main] Quit check failed:", err);
|
|
1364
|
+
dialog.showErrorBox(
|
|
1365
|
+
"GAIA",
|
|
1366
|
+
`GAIA couldn't ask whether to stop the running work: ${err.message}\n\n` +
|
|
1367
|
+
"Quit again within a minute to quit anyway; any answer still being written will be lost."
|
|
1368
|
+
);
|
|
1369
|
+
},
|
|
1242
1370
|
});
|
|
1243
1371
|
|
|
1372
|
+
installQuitCleanup({ app, cleanup });
|
|
1373
|
+
|
|
1244
1374
|
async function cleanup() {
|
|
1375
|
+
shutdownStarted = true;
|
|
1376
|
+
|
|
1245
1377
|
// Phase F: tear down auto-updater timers and IPC handlers.
|
|
1246
1378
|
try {
|
|
1247
1379
|
autoUpdater.destroy();
|
|
@@ -1258,24 +1390,48 @@ async function cleanup() {
|
|
|
1258
1390
|
notificationService = null;
|
|
1259
1391
|
}
|
|
1260
1392
|
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
try {
|
|
1265
|
-
await agentProcessManager.stopAll();
|
|
1266
|
-
} catch (err) {
|
|
1267
|
-
console.error("Error stopping agents:", err.message);
|
|
1268
|
-
}
|
|
1269
|
-
agentProcessManager = null;
|
|
1393
|
+
if (keepAwake) {
|
|
1394
|
+
keepAwake.destroy();
|
|
1395
|
+
keepAwake = null;
|
|
1270
1396
|
}
|
|
1271
1397
|
|
|
1398
|
+
// The agents and the backend stop side by side: each waits out its own
|
|
1399
|
+
// grace period, and in series they could overrun the quit's time limit.
|
|
1400
|
+
await Promise.all([stopAgentsForQuit(), stopBackendForQuit()]);
|
|
1401
|
+
|
|
1272
1402
|
// Destroy tray icon
|
|
1273
1403
|
if (trayManager) {
|
|
1274
1404
|
trayManager.destroy();
|
|
1275
1405
|
trayManager = null;
|
|
1276
1406
|
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
async function stopAgentsForQuit() {
|
|
1410
|
+
if (!agentProcessManager) return;
|
|
1411
|
+
console.log("Stopping all managed agents...");
|
|
1412
|
+
try {
|
|
1413
|
+
await agentProcessManager.stopAll();
|
|
1414
|
+
} catch (err) {
|
|
1415
|
+
console.error("Error stopping agents:", err.message);
|
|
1416
|
+
}
|
|
1417
|
+
agentProcessManager = null;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
async function stopBackendForQuit() {
|
|
1421
|
+
// A quit during startup: wait for the spawn so it can be stopped below.
|
|
1422
|
+
if (backendStarting) {
|
|
1423
|
+
try {
|
|
1424
|
+
const started = await backendStarting;
|
|
1425
|
+
if (started && !backendProcess && started.exitCode === null && started.signalCode === null) {
|
|
1426
|
+
backendProcess = started;
|
|
1427
|
+
}
|
|
1428
|
+
} catch (err) {
|
|
1429
|
+
console.error("[main] Backend start failed during quit:", err.message);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1277
1432
|
|
|
1278
|
-
// Stop the Python backend via the port-manager
|
|
1433
|
+
// Stop the Python backend and everything it started via the port-manager
|
|
1434
|
+
// (SIGTERM → wait 3 s → SIGKILL).
|
|
1279
1435
|
// F5: previously we did this inline; extracted so main.cjs stays lean and
|
|
1280
1436
|
// to prevent orphan leaks across runs (issue #782).
|
|
1281
1437
|
if (backendProcess) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amd-gaia/agent-ui",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.1-rc.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"productName": "GAIA Agent UI",
|
|
6
6
|
"description": "Chat with the GAIA agent - local and private by default on AMD Ryzen AI, cloud models when you choose them",
|
|
@@ -59,12 +59,14 @@
|
|
|
59
59
|
"prepublishOnly": "npm run build",
|
|
60
60
|
"test": "vitest run",
|
|
61
61
|
"test:watch": "vitest",
|
|
62
|
+
"test:visual": "vite build && playwright test -c visual/playwright.config.ts",
|
|
62
63
|
"gen:api-types": "node scripts/gen-api-types.mjs"
|
|
63
64
|
},
|
|
64
65
|
"dependencies": {
|
|
65
66
|
"electron-updater": "^6.8.3"
|
|
66
67
|
},
|
|
67
68
|
"devDependencies": {
|
|
69
|
+
"@playwright/test": "1.63.0",
|
|
68
70
|
"@testing-library/jest-dom": "^7.0.0",
|
|
69
71
|
"@testing-library/react": "^16.1.0",
|
|
70
72
|
"@testing-library/user-event": "^14.5.2",
|
package/preload.cjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* install:* — First-run backend install progress (Phase A)
|
|
15
15
|
* gaia:update:* — Auto-update status & manual check (Phase F)
|
|
16
16
|
* system:* — System metrics for the observability dashboard (#2007)
|
|
17
|
+
* power:* — Keep the PC awake while a chat turn runs (#5226)
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
const { contextBridge, ipcRenderer } = require("electron");
|
|
@@ -53,6 +54,10 @@ contextBridge.exposeInMainWorld("gaiaAPI", {
|
|
|
53
54
|
tray: {
|
|
54
55
|
getConfig: () => ipcRenderer.invoke("tray:get-config"),
|
|
55
56
|
setConfig: (cfg) => ipcRenderer.invoke("tray:set-config", cfg),
|
|
57
|
+
// What GAIA is doing, for the tray icon, tooltip and menu.
|
|
58
|
+
setActivity: (activity) => ipcRenderer.invoke("tray:set-activity", activity),
|
|
59
|
+
// Tray menu actions: open-chat, new-chat, open-settings, refresh-status.
|
|
60
|
+
onCommand: (cb) => onEvent("tray:command", cb),
|
|
56
61
|
onNavigate: (cb) => onEvent("tray:navigate", cb),
|
|
57
62
|
},
|
|
58
63
|
|
|
@@ -63,6 +68,16 @@ contextBridge.exposeInMainWorld("gaiaAPI", {
|
|
|
63
68
|
respondPermission: (id, action, remember) =>
|
|
64
69
|
ipcRenderer.invoke("notification:respond", id, action, remember),
|
|
65
70
|
onNotification: (cb) => onEvent("notification:new", cb),
|
|
71
|
+
// Chat alerts (#5226)
|
|
72
|
+
show: (payload) => ipcRenderer.invoke("notification:show", payload),
|
|
73
|
+
flash: () => ipcRenderer.invoke("notification:flash"),
|
|
74
|
+
setAttentionCount: (count) =>
|
|
75
|
+
ipcRenderer.invoke("notification:attention", count),
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
// ── Keep the PC awake while GAIA works (#5226) ────────────────────────
|
|
79
|
+
power: {
|
|
80
|
+
setKeepAwake: (on) => ipcRenderer.invoke("power:keep-awake", on),
|
|
66
81
|
},
|
|
67
82
|
|
|
68
83
|
// ── System metrics for the observability dashboard (#2007) ────────────
|
|
@@ -71,6 +86,15 @@ contextBridge.exposeInMainWorld("gaiaAPI", {
|
|
|
71
86
|
},
|
|
72
87
|
});
|
|
73
88
|
|
|
89
|
+
// ── Window frame ─────────────────────────────────────────────────────────
|
|
90
|
+
// The app draws its own top row; this says how the native controls sit in it.
|
|
91
|
+
contextBridge.exposeInMainWorld("gaiaWindow", {
|
|
92
|
+
/** "mac": traffic lights at the top left; "overlay": controls at the top right. */
|
|
93
|
+
titleBar: process.platform === "darwin" ? "mac" : "overlay",
|
|
94
|
+
/** Match the native window controls to the app: { color, symbolColor } as #rrggbb. */
|
|
95
|
+
setColors: (colors) => ipcRenderer.invoke("window:set-titlebar-colors", colors),
|
|
96
|
+
});
|
|
97
|
+
|
|
74
98
|
// ── Install progress (Phase A) ──────────────────────────────────────────
|
|
75
99
|
// Exposed as a separate global so the progress window can use it without
|
|
76
100
|
// pulling in the full gaiaAPI surface (and so it keeps working if an
|