@amd-gaia/agent-ui 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/app.config.json +37 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/assets/tray-icon-active.png +0 -0
- package/assets/tray-icon-active@2x.png +0 -0
- package/assets/tray-icon.ico +0 -0
- package/assets/tray-icon.png +0 -0
- package/assets/tray-icon@2x.png +0 -0
- package/assets/tray-iconTemplate.png +0 -0
- package/assets/tray-iconTemplate@2x.png +0 -0
- package/bin/gaia-ui.mjs +572 -0
- package/dist/assets/browser-CTB2jwNe.js +8 -0
- package/dist/assets/dm-sans-latin-Xz1IZZA0.woff2 +0 -0
- package/dist/assets/gaia-robot-NKaQnEIp.png +0 -0
- package/dist/assets/index-C7oO2M6Q.js +432 -0
- package/dist/assets/index-TyWv9Ej0.css +1 -0
- package/dist/assets/jetbrains-mono-latin-6fWv1k7M.woff2 +0 -0
- package/dist/assets/space-mono-400-Co7bH5Hm.woff2 +0 -0
- package/dist/favicon.png +0 -0
- package/dist/index.html +14 -0
- package/main.cjs +511 -0
- package/package.json +83 -0
- package/preload.cjs +61 -0
- package/services/agent-process-manager.cjs +818 -0
- package/services/notification-service.cjs +419 -0
- package/services/tray-manager.cjs +239 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GAIA Agent UI — Notification Service (T5-service)
|
|
6
|
+
*
|
|
7
|
+
* Routes notifications from agents to OS native toasts and the renderer process.
|
|
8
|
+
*
|
|
9
|
+
* Design decisions (from spec):
|
|
10
|
+
* - OS native toasts are click-to-focus only (S5 fix) — no action buttons
|
|
11
|
+
* - All interactive prompts (Approve/Deny) happen in-app via PermissionPrompt modal
|
|
12
|
+
* - Permission responses are sent back to agents via JSON-RPC
|
|
13
|
+
* - Notification persistence in ~/.gaia/notifications.json (optional, last 200)
|
|
14
|
+
*
|
|
15
|
+
* Notification types:
|
|
16
|
+
* permission_request — Modal dialog (blocks action) + OS click-to-focus toast
|
|
17
|
+
* security_alert — In-app toast + OS click-to-focus toast
|
|
18
|
+
* status_change — In-app toast (auto-dismiss 5s)
|
|
19
|
+
* info — Notification center only
|
|
20
|
+
* error — In-app toast (persistent) + OS click-to-focus toast
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { Notification, ipcMain } = require("electron");
|
|
24
|
+
const { EventEmitter } = require("events");
|
|
25
|
+
const path = require("path");
|
|
26
|
+
const fs = require("fs");
|
|
27
|
+
const os = require("os");
|
|
28
|
+
|
|
29
|
+
// ── Constants ────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const GAIA_DIR = path.join(os.homedir(), ".gaia");
|
|
32
|
+
const NOTIFICATIONS_PATH = path.join(GAIA_DIR, "notifications.json");
|
|
33
|
+
|
|
34
|
+
/** Max persisted notifications */
|
|
35
|
+
const MAX_PERSISTED = 200;
|
|
36
|
+
|
|
37
|
+
/** Notification types that trigger OS native toasts */
|
|
38
|
+
const OS_TOAST_TYPES = new Set([
|
|
39
|
+
"permission_request",
|
|
40
|
+
"security_alert",
|
|
41
|
+
"error",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
// ── NotificationService ──────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
class NotificationService extends EventEmitter {
|
|
47
|
+
/**
|
|
48
|
+
* @param {Electron.BrowserWindow} mainWindow
|
|
49
|
+
* @param {import('./agent-process-manager')} agentProcessManager
|
|
50
|
+
* @param {import('./tray-manager')} trayManager
|
|
51
|
+
*/
|
|
52
|
+
constructor(mainWindow, agentProcessManager, trayManager) {
|
|
53
|
+
super();
|
|
54
|
+
|
|
55
|
+
/** @type {Electron.BrowserWindow} */
|
|
56
|
+
this.mainWindow = mainWindow;
|
|
57
|
+
|
|
58
|
+
/** @type {import('./agent-process-manager')} */
|
|
59
|
+
this.agentProcessManager = agentProcessManager;
|
|
60
|
+
|
|
61
|
+
/** @type {import('./tray-manager')} */
|
|
62
|
+
this.trayManager = trayManager;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* All notifications (in-memory, most recent last).
|
|
66
|
+
* @type {Array<{
|
|
67
|
+
* id: string,
|
|
68
|
+
* type: string,
|
|
69
|
+
* agentId: string,
|
|
70
|
+
* title: string,
|
|
71
|
+
* message: string,
|
|
72
|
+
* tool?: string,
|
|
73
|
+
* toolArgs?: object,
|
|
74
|
+
* actions?: string[],
|
|
75
|
+
* timeoutSeconds?: number,
|
|
76
|
+
* timestamp: number,
|
|
77
|
+
* read: boolean,
|
|
78
|
+
* responded: boolean,
|
|
79
|
+
* response?: { action: string, remember: boolean },
|
|
80
|
+
* }>}
|
|
81
|
+
*/
|
|
82
|
+
this.notifications = this._loadNotifications();
|
|
83
|
+
|
|
84
|
+
/** Counter for generating notification IDs (timestamp-based to avoid collisions across restarts) */
|
|
85
|
+
this._idCounter = Date.now();
|
|
86
|
+
|
|
87
|
+
/** Pending permission request timers (auto-deny on timeout) */
|
|
88
|
+
this._permissionTimers = {};
|
|
89
|
+
|
|
90
|
+
this._registerIpcHandlers();
|
|
91
|
+
this._listenToAgentEvents();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Handle an incoming notification from an agent.
|
|
98
|
+
* Called by AgentProcessManager when it receives a "notification/send" JSON-RPC message.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} agentId
|
|
101
|
+
* @param {object} params — from the JSON-RPC notification/send message
|
|
102
|
+
*/
|
|
103
|
+
handleAgentNotification(agentId, params) {
|
|
104
|
+
const notif = {
|
|
105
|
+
id: `notif-${this._idCounter++}`,
|
|
106
|
+
type: params.type || "info",
|
|
107
|
+
agentId,
|
|
108
|
+
title: params.title || "Agent Notification",
|
|
109
|
+
message: params.message || "",
|
|
110
|
+
tool: params.tool,
|
|
111
|
+
toolArgs: params.tool_args,
|
|
112
|
+
actions: params.actions,
|
|
113
|
+
timeoutSeconds: params.timeout_seconds,
|
|
114
|
+
timestamp: Date.now(),
|
|
115
|
+
read: false,
|
|
116
|
+
responded: false,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Add to in-memory list
|
|
120
|
+
this.notifications.push(notif);
|
|
121
|
+
if (this.notifications.length > MAX_PERSISTED * 2) {
|
|
122
|
+
this.notifications = this.notifications.slice(-MAX_PERSISTED);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log(
|
|
126
|
+
`[notif] ${notif.type} from ${agentId}: ${notif.title} — ${notif.message}`
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Route based on type
|
|
130
|
+
switch (notif.type) {
|
|
131
|
+
case "permission_request":
|
|
132
|
+
this._handlePermissionRequest(notif);
|
|
133
|
+
break;
|
|
134
|
+
case "security_alert":
|
|
135
|
+
case "error":
|
|
136
|
+
this._sendToRenderer("notification:new", notif);
|
|
137
|
+
this._showOsToast(notif);
|
|
138
|
+
break;
|
|
139
|
+
case "status_change":
|
|
140
|
+
this._sendToRenderer("notification:new", notif);
|
|
141
|
+
break;
|
|
142
|
+
case "info":
|
|
143
|
+
default:
|
|
144
|
+
this._sendToRenderer("notification:new", notif);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Update tray badge
|
|
149
|
+
this._updateTrayBadge();
|
|
150
|
+
|
|
151
|
+
// Persist
|
|
152
|
+
this._saveNotifications();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get the current unread notification count.
|
|
157
|
+
* @returns {number}
|
|
158
|
+
*/
|
|
159
|
+
getUnreadCount() {
|
|
160
|
+
return this.notifications.filter((n) => !n.read).length;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Mark all notifications as read.
|
|
165
|
+
*/
|
|
166
|
+
markAllRead() {
|
|
167
|
+
for (const notif of this.notifications) {
|
|
168
|
+
notif.read = true;
|
|
169
|
+
}
|
|
170
|
+
this._updateTrayBadge();
|
|
171
|
+
this._saveNotifications();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Clear all notifications.
|
|
176
|
+
*/
|
|
177
|
+
clearAll() {
|
|
178
|
+
this.notifications = [];
|
|
179
|
+
this._updateTrayBadge();
|
|
180
|
+
this._saveNotifications();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Clean up all pending timers. Call during shutdown to prevent leaked timers.
|
|
185
|
+
*/
|
|
186
|
+
destroy() {
|
|
187
|
+
for (const [id, timer] of Object.entries(this._permissionTimers)) {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
}
|
|
190
|
+
this._permissionTimers = {};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Private: Permission requests ─────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
_handlePermissionRequest(notif) {
|
|
196
|
+
// Send to renderer as a permission prompt
|
|
197
|
+
this._sendToRenderer("notification:permission-request", notif);
|
|
198
|
+
|
|
199
|
+
// Show OS toast (click-to-focus only)
|
|
200
|
+
this._showOsToast(notif);
|
|
201
|
+
|
|
202
|
+
// Set up auto-deny timeout if specified
|
|
203
|
+
if (notif.timeoutSeconds && notif.timeoutSeconds > 0) {
|
|
204
|
+
this._permissionTimers[notif.id] = setTimeout(() => {
|
|
205
|
+
if (!notif.responded) {
|
|
206
|
+
console.log(
|
|
207
|
+
`[notif] Permission request ${notif.id} timed out — auto-denying`
|
|
208
|
+
);
|
|
209
|
+
this._respondToPermission(notif.id, "deny", false);
|
|
210
|
+
}
|
|
211
|
+
}, notif.timeoutSeconds * 1000);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Respond to a permission request.
|
|
217
|
+
* @param {string} notifId
|
|
218
|
+
* @param {string} action — "allow" or "deny"
|
|
219
|
+
* @param {boolean} remember — whether to remember this choice
|
|
220
|
+
*/
|
|
221
|
+
_respondToPermission(notifId, action, remember) {
|
|
222
|
+
const notif = this.notifications.find((n) => n.id === notifId);
|
|
223
|
+
if (!notif) {
|
|
224
|
+
console.warn(`[notif] Permission response for unknown notification: ${notifId}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (notif.responded) {
|
|
229
|
+
console.warn(`[notif] Permission ${notifId} already responded`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
notif.responded = true;
|
|
234
|
+
notif.response = { action, remember };
|
|
235
|
+
|
|
236
|
+
// Clear timeout timer if exists
|
|
237
|
+
if (this._permissionTimers[notifId]) {
|
|
238
|
+
clearTimeout(this._permissionTimers[notifId]);
|
|
239
|
+
delete this._permissionTimers[notifId];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Send response back to the agent via JSON-RPC notification (no id, no response expected).
|
|
243
|
+
// We use _sendJsonRpcRaw (not sendJsonRpc) because this is a notification TO the agent,
|
|
244
|
+
// not a request — the agent doesn't reply, so we must not wait for one.
|
|
245
|
+
if (this.agentProcessManager) {
|
|
246
|
+
try {
|
|
247
|
+
this.agentProcessManager._sendJsonRpcRaw(
|
|
248
|
+
notif.agentId,
|
|
249
|
+
"notification/response",
|
|
250
|
+
{
|
|
251
|
+
notification_id: notifId,
|
|
252
|
+
action,
|
|
253
|
+
remember,
|
|
254
|
+
}
|
|
255
|
+
);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.error(
|
|
258
|
+
`[notif] Failed to send permission response to ${notif.agentId}:`,
|
|
259
|
+
err.message
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
console.log(
|
|
265
|
+
`[notif] Permission ${notifId}: ${action} (remember=${remember})`
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
this._saveNotifications();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Private: OS native toasts ────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
_showOsToast(notif) {
|
|
274
|
+
if (!OS_TOAST_TYPES.has(notif.type)) return;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
// Check if Notification is supported
|
|
278
|
+
if (!Notification.isSupported()) {
|
|
279
|
+
console.warn("[notif] OS notifications not supported on this platform");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const osNotif = new Notification({
|
|
284
|
+
title: notif.title,
|
|
285
|
+
body: notif.message,
|
|
286
|
+
icon: path.join(__dirname, "..", "assets", "icon.png"),
|
|
287
|
+
urgency: notif.type === "security_alert" ? "critical" : "normal",
|
|
288
|
+
// No action buttons — click-to-focus only (S5 fix)
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Click → show and focus the main window
|
|
292
|
+
osNotif.on("click", () => {
|
|
293
|
+
this._showAndFocusWindow(notif);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
osNotif.show();
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.warn("[notif] Failed to show OS notification:", err.message);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
_showAndFocusWindow(notif) {
|
|
303
|
+
if (!this.mainWindow || this.mainWindow.isDestroyed()) return;
|
|
304
|
+
|
|
305
|
+
if (this.mainWindow.isMinimized()) {
|
|
306
|
+
this.mainWindow.restore();
|
|
307
|
+
}
|
|
308
|
+
this.mainWindow.show();
|
|
309
|
+
this.mainWindow.focus();
|
|
310
|
+
|
|
311
|
+
// Tell the renderer which notification to focus on.
|
|
312
|
+
// Note: for permission_request, the notification was already sent to the renderer
|
|
313
|
+
// via _handlePermissionRequest — we just navigate to it here, don't re-send.
|
|
314
|
+
this._sendToRenderer("tray:navigate", `notification:${notif.id}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── Private: Tray badge ──────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
_updateTrayBadge() {
|
|
320
|
+
if (this.trayManager) {
|
|
321
|
+
this.trayManager.setNotificationCount(this.getUnreadCount());
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Private: Event listeners ─────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
_listenToAgentEvents() {
|
|
328
|
+
if (!this.agentProcessManager) return;
|
|
329
|
+
|
|
330
|
+
// Listen for agent notifications via the EventEmitter
|
|
331
|
+
this.agentProcessManager.on(
|
|
332
|
+
"agent-notification",
|
|
333
|
+
(agentId, params) => {
|
|
334
|
+
this.handleAgentNotification(agentId, params);
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// Agent crash → generate error notification
|
|
339
|
+
this.agentProcessManager.on("status-change", (payload) => {
|
|
340
|
+
if (payload.status === "stopped" && payload.detail) {
|
|
341
|
+
// Only notify on unexpected stops (crashes)
|
|
342
|
+
this.handleAgentNotification(payload.agentId, {
|
|
343
|
+
type: "error",
|
|
344
|
+
title: "Agent Crashed",
|
|
345
|
+
message: payload.detail || `Agent ${payload.agentId} stopped unexpectedly`,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Crash limit reached → generate error notification
|
|
351
|
+
this.agentProcessManager.on(
|
|
352
|
+
"agent-crash-limit",
|
|
353
|
+
(agentId, crashCount) => {
|
|
354
|
+
this.handleAgentNotification(agentId, {
|
|
355
|
+
type: "error",
|
|
356
|
+
title: "Agent Crash Limit Reached",
|
|
357
|
+
message: `Agent ${agentId} crashed ${crashCount} times — automatic restart disabled`,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Private: Persistence ─────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
_loadNotifications() {
|
|
366
|
+
try {
|
|
367
|
+
if (fs.existsSync(NOTIFICATIONS_PATH)) {
|
|
368
|
+
const raw = fs.readFileSync(NOTIFICATIONS_PATH, "utf8");
|
|
369
|
+
return JSON.parse(raw);
|
|
370
|
+
}
|
|
371
|
+
} catch (err) {
|
|
372
|
+
console.warn("[notif] Could not load notifications:", err.message);
|
|
373
|
+
}
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
_saveNotifications() {
|
|
378
|
+
try {
|
|
379
|
+
if (!fs.existsSync(GAIA_DIR)) {
|
|
380
|
+
fs.mkdirSync(GAIA_DIR, { recursive: true });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Only persist the last MAX_PERSISTED entries
|
|
384
|
+
const toSave = this.notifications.slice(-MAX_PERSISTED);
|
|
385
|
+
fs.writeFileSync(
|
|
386
|
+
NOTIFICATIONS_PATH,
|
|
387
|
+
JSON.stringify(toSave, null, 2),
|
|
388
|
+
"utf8"
|
|
389
|
+
);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.warn("[notif] Could not save notifications:", err.message);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Private: IPC handlers ────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
_registerIpcHandlers() {
|
|
398
|
+
ipcMain.handle(
|
|
399
|
+
"notification:respond",
|
|
400
|
+
(_event, notifId, action, remember) => {
|
|
401
|
+
this._respondToPermission(notifId, action, remember);
|
|
402
|
+
}
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── Private: Helpers ─────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
_sendToRenderer(channel, data) {
|
|
409
|
+
try {
|
|
410
|
+
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
|
411
|
+
this.mainWindow.webContents.send(channel, data);
|
|
412
|
+
}
|
|
413
|
+
} catch (err) {
|
|
414
|
+
console.warn("[notif] Could not send to renderer:", err.message);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
module.exports = NotificationService;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GAIA Agent UI — Tray Manager (T1)
|
|
6
|
+
*
|
|
7
|
+
* Manages the Electron system tray icon, context menu, and minimize-to-tray
|
|
8
|
+
* behaviour. Co-located alongside main.cjs per the T0 co-location decision.
|
|
9
|
+
*
|
|
10
|
+
* Responsibilities:
|
|
11
|
+
* - Create Tray instance with GAIA icon on app startup
|
|
12
|
+
* - Build context menu (Show Window, Open in Browser, Quit)
|
|
13
|
+
* - Handle "minimize to tray" on window close (configurable)
|
|
14
|
+
* - Handle "show window" on tray click / double-click
|
|
15
|
+
* - Expose IPC handlers for renderer to query/update tray config
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { Tray, Menu, nativeImage, ipcMain, app, shell } = require("electron");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const fs = require("fs");
|
|
21
|
+
const os = require("os");
|
|
22
|
+
|
|
23
|
+
// ── Constants ────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const GAIA_DIR = path.join(os.homedir(), ".gaia");
|
|
26
|
+
const CONFIG_PATH = path.join(GAIA_DIR, "tray-config.json");
|
|
27
|
+
|
|
28
|
+
// ── Default config ───────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const DEFAULT_CONFIG = {
|
|
31
|
+
tray: {
|
|
32
|
+
minimizeToTray: true,
|
|
33
|
+
startMinimized: false,
|
|
34
|
+
startOnLogin: false,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ── TrayManager ──────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
class TrayManager {
|
|
41
|
+
/**
|
|
42
|
+
* @param {Electron.BrowserWindow} mainWindow
|
|
43
|
+
* @param {object} [options]
|
|
44
|
+
* @param {number} [options.backendPort=4200] - Backend port for "Open in Browser"
|
|
45
|
+
*/
|
|
46
|
+
constructor(mainWindow, options = {}) {
|
|
47
|
+
/** @type {Electron.BrowserWindow} */
|
|
48
|
+
this.mainWindow = mainWindow;
|
|
49
|
+
|
|
50
|
+
/** @type {number} */
|
|
51
|
+
this._backendPort = options.backendPort || 4200;
|
|
52
|
+
|
|
53
|
+
/** @type {Electron.Tray | null} */
|
|
54
|
+
this.tray = null;
|
|
55
|
+
|
|
56
|
+
/** @type {object} */
|
|
57
|
+
this.config = this._loadConfig();
|
|
58
|
+
|
|
59
|
+
/** @type {Electron.NativeImage} */
|
|
60
|
+
const trayIconFile = process.platform === "win32" ? "icon.ico" : "icon.png";
|
|
61
|
+
this._icon = this._loadIcon(trayIconFile);
|
|
62
|
+
|
|
63
|
+
this._registerIpcHandlers();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** Create the tray icon and wire up events. Call once after app.whenReady(). */
|
|
69
|
+
create() {
|
|
70
|
+
if (this.tray) return;
|
|
71
|
+
|
|
72
|
+
this.tray = new Tray(this._icon);
|
|
73
|
+
this.tray.setToolTip("GAIA Agent UI");
|
|
74
|
+
|
|
75
|
+
// Single-click: show/focus window
|
|
76
|
+
this.tray.on("click", () => this._showWindow());
|
|
77
|
+
|
|
78
|
+
// Double-click (Windows): show/focus window
|
|
79
|
+
this.tray.on("double-click", () => this._showWindow());
|
|
80
|
+
|
|
81
|
+
this._rebuildContextMenu();
|
|
82
|
+
console.log("[tray] System tray icon created");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Destroy the tray icon. Call before app.quit(). */
|
|
86
|
+
destroy() {
|
|
87
|
+
if (this.tray) {
|
|
88
|
+
this.tray.destroy();
|
|
89
|
+
this.tray = null;
|
|
90
|
+
}
|
|
91
|
+
console.log("[tray] System tray icon destroyed");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Update the context menu. */
|
|
95
|
+
refresh() {
|
|
96
|
+
this._rebuildContextMenu();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @returns {boolean} Whether minimize-to-tray is enabled. */
|
|
100
|
+
get minimizeToTray() {
|
|
101
|
+
return this.config.tray.minimizeToTray;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @returns {boolean} Whether app should start minimized. */
|
|
105
|
+
get startMinimized() {
|
|
106
|
+
return this.config.tray.startMinimized;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @returns {boolean} Whether app should start on login. */
|
|
110
|
+
get startOnLogin() {
|
|
111
|
+
return this.config.tray.startOnLogin;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Private: Context Menu ────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
_rebuildContextMenu() {
|
|
117
|
+
if (!this.tray) return;
|
|
118
|
+
|
|
119
|
+
const contextMenu = Menu.buildFromTemplate([
|
|
120
|
+
{
|
|
121
|
+
label: "Show Window",
|
|
122
|
+
click: () => this._showWindow(),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
label: "Open in Browser",
|
|
126
|
+
click: () => shell.openExternal(`http://localhost:${this._backendPort}`),
|
|
127
|
+
},
|
|
128
|
+
{ type: "separator" },
|
|
129
|
+
{
|
|
130
|
+
label: "Quit",
|
|
131
|
+
click: () => this._quit(),
|
|
132
|
+
},
|
|
133
|
+
]);
|
|
134
|
+
this.tray.setContextMenu(contextMenu);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Private: Window management ───────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
_showWindow() {
|
|
140
|
+
if (!this.mainWindow || this.mainWindow.isDestroyed()) return;
|
|
141
|
+
|
|
142
|
+
if (this.mainWindow.isMinimized()) {
|
|
143
|
+
this.mainWindow.restore();
|
|
144
|
+
}
|
|
145
|
+
this.mainWindow.show();
|
|
146
|
+
this.mainWindow.focus();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async _quit() {
|
|
150
|
+
console.log("[tray] Quit requested");
|
|
151
|
+
app.quit();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Private: Icon loading ────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
_loadIcon(filename) {
|
|
157
|
+
// __dirname is services/, assets/ is one level up alongside main.cjs
|
|
158
|
+
const iconPath = path.join(__dirname, "..", "assets", filename);
|
|
159
|
+
try {
|
|
160
|
+
if (fs.existsSync(iconPath)) {
|
|
161
|
+
return nativeImage.createFromPath(iconPath);
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.warn(`[tray] Could not load icon ${filename}:`, err.message);
|
|
165
|
+
}
|
|
166
|
+
// Return empty image as fallback (Electron will show a default)
|
|
167
|
+
return nativeImage.createEmpty();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Private: Config persistence ──────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
_loadConfig() {
|
|
173
|
+
try {
|
|
174
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
175
|
+
const raw = fs.readFileSync(CONFIG_PATH, "utf8");
|
|
176
|
+
const loaded = JSON.parse(raw);
|
|
177
|
+
return {
|
|
178
|
+
...DEFAULT_CONFIG,
|
|
179
|
+
...loaded,
|
|
180
|
+
tray: { ...DEFAULT_CONFIG.tray, ...(loaded.tray || {}) },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
console.warn("[tray] Could not load tray config:", err.message);
|
|
185
|
+
}
|
|
186
|
+
return { ...DEFAULT_CONFIG };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_saveConfig() {
|
|
190
|
+
try {
|
|
191
|
+
if (!fs.existsSync(GAIA_DIR)) {
|
|
192
|
+
fs.mkdirSync(GAIA_DIR, { recursive: true });
|
|
193
|
+
}
|
|
194
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(this.config, null, 2), "utf8");
|
|
195
|
+
console.log("[tray] Config saved to", CONFIG_PATH);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error("[tray] Could not save tray config:", err.message);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Private: IPC handlers ────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
_registerIpcHandlers() {
|
|
204
|
+
ipcMain.handle("tray:get-config", () => {
|
|
205
|
+
return this.config;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
ipcMain.handle("tray:set-config", (_event, cfg) => {
|
|
209
|
+
if (cfg.tray) {
|
|
210
|
+
this.config.tray = { ...this.config.tray, ...cfg.tray };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
this._saveConfig();
|
|
214
|
+
|
|
215
|
+
// Apply login-item setting if changed
|
|
216
|
+
if (cfg.tray && "startOnLogin" in cfg.tray) {
|
|
217
|
+
this._applyLoginItemSetting(cfg.tray.startOnLogin);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return this.config;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Register/unregister the app from OS login startup. */
|
|
225
|
+
_applyLoginItemSetting(enabled) {
|
|
226
|
+
try {
|
|
227
|
+
app.setLoginItemSettings({
|
|
228
|
+
openAtLogin: enabled,
|
|
229
|
+
path: app.getPath("exe"),
|
|
230
|
+
args: enabled ? ["--minimized"] : [],
|
|
231
|
+
});
|
|
232
|
+
console.log(`[tray] Login item ${enabled ? "enabled" : "disabled"}`);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.warn("[tray] Could not set login item:", err.message);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = TrayManager;
|