@amd-gaia/agent-ui 0.24.0 → 0.25.0-rc.2
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 +4 -3
- package/bin/gaia-ui.cjs +30 -12
- package/dist/assets/AdvancedSettings-Ce6EvcAe.css +1 -0
- package/dist/assets/AdvancedSettings-DabrR6sf.js +1 -0
- package/dist/assets/ConnectorsPane-CG7atLb2.css +1 -0
- package/dist/assets/ConnectorsPane-KLjT1y3F.js +1 -0
- package/dist/assets/DocumentLibrary-CElkmNvc.js +1 -0
- package/dist/assets/DocumentLibrary-DrIP_032.css +1 -0
- package/dist/assets/FileBrowser-Bj6aB6Ro.css +1 -0
- package/dist/assets/FileBrowser-ROrpBhVm.js +4 -0
- package/dist/assets/MemoryDashboard-B-YH4b1Z.css +1 -0
- package/dist/assets/MemoryDashboard-DG9P8Bqf.js +1 -0
- package/dist/assets/MobileAccessModal-C_t8DG0n.css +1 -0
- package/dist/assets/MobileAccessModal-Jub4U6L2.js +2 -0
- package/dist/assets/ScheduleManager-BStuwsjn.js +2 -0
- package/dist/assets/ScheduleManager-CIescbX6.css +1 -0
- package/dist/assets/{browser-D6fty1nl.js → browser-CCrYwriZ.js} +2 -2
- package/dist/assets/index-B8E7IBmc.css +1 -0
- package/dist/assets/index-CXbVMk3u.js +58 -0
- package/dist/assets/jsx-runtime-DVeaULmT.js +1 -0
- package/dist/assets/message-square-Ccmbk96j.js +1 -0
- package/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/index.html +7 -2
- package/main.cjs +133 -42
- package/package.json +9 -9
- package/services/backend-installer-progress-dialog.cjs +34 -15
- package/services/backend-installer.cjs +29 -13
- package/services/deep-link-queue.cjs +77 -0
- package/services/link-policy.cjs +90 -0
- package/services/notification-service.cjs +36 -14
- package/services/tray-manager.cjs +163 -23
- package/dist/assets/index-C0wmkHcD.js +0 -69
- package/dist/assets/index-D1S2dWYe.css +0 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* deep-link-queue.cjs — hold gaia:// links until the backend can verify them.
|
|
6
|
+
*
|
|
7
|
+
* A cold-start "Open in GAIA" link reaches the app long before the backend
|
|
8
|
+
* listens, and the install gate resolves the agent through the backend's
|
|
9
|
+
* catalog API (failing closed). This queue holds every link that arrives
|
|
10
|
+
* before startup has waited for the backend, then dispatches each exactly
|
|
11
|
+
* once. Electron-free so the ordering can be unit-tested.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} deps
|
|
18
|
+
* @param {(rawUrl: string) => void} deps.dispatch Act on one validated link.
|
|
19
|
+
* @param {(rawUrls: string[]) => void} deps.reportNotReady Tell the user the
|
|
20
|
+
* queued links could not be opened because the backend never came up.
|
|
21
|
+
* @param {{log: Function}} [deps.logger]
|
|
22
|
+
*/
|
|
23
|
+
function createStartupDeepLinkQueue({ dispatch, reportNotReady, logger = console }) {
|
|
24
|
+
if (typeof dispatch !== "function" || typeof reportNotReady !== "function") {
|
|
25
|
+
throw new Error("createStartupDeepLinkQueue requires dispatch and reportNotReady functions");
|
|
26
|
+
}
|
|
27
|
+
const pending = [];
|
|
28
|
+
let drained = false;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
/**
|
|
32
|
+
* Accept a validated link: queue it until drain(), dispatch it after.
|
|
33
|
+
* @returns {"queued" | "duplicate" | "dispatched"}
|
|
34
|
+
*/
|
|
35
|
+
accept(rawUrl) {
|
|
36
|
+
if (drained) {
|
|
37
|
+
dispatch(rawUrl);
|
|
38
|
+
return "dispatched";
|
|
39
|
+
}
|
|
40
|
+
if (pending.includes(rawUrl)) return "duplicate";
|
|
41
|
+
pending.push(rawUrl);
|
|
42
|
+
logger.log(`[deep-link] Queued until the backend is ready: ${rawUrl}`);
|
|
43
|
+
return "queued";
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Release the queue once, after startup has waited for the backend.
|
|
48
|
+
* Later calls are no-ops, so nothing is dispatched twice.
|
|
49
|
+
*
|
|
50
|
+
* @param {{backendReady: boolean, argvUrl?: string|null}} opts
|
|
51
|
+
* @returns {string[]} The links dispatched by this call.
|
|
52
|
+
*/
|
|
53
|
+
drain({ backendReady, argvUrl = null }) {
|
|
54
|
+
if (drained) return [];
|
|
55
|
+
drained = true;
|
|
56
|
+
const urls = pending.splice(0);
|
|
57
|
+
if (argvUrl && !urls.includes(argvUrl)) urls.push(argvUrl);
|
|
58
|
+
if (urls.length === 0) return [];
|
|
59
|
+
if (!backendReady) {
|
|
60
|
+
reportNotReady(urls);
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
for (const url of urls) dispatch(url);
|
|
64
|
+
return urls;
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
get isDrained() {
|
|
68
|
+
return drained;
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
get pendingCount() {
|
|
72
|
+
return pending.length;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { createStartupDeepLinkQueue };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* link-policy.cjs — which URLs the shell is allowed to hand to the OS.
|
|
6
|
+
*
|
|
7
|
+
* The renderer is loaded from `file://`, so a scheme-less link in
|
|
8
|
+
* LLM-rendered markdown (`/Windows/System32/calc.exe`, `//host/share/x.exe`)
|
|
9
|
+
* RESOLVES to `file:///C:/…` / `file://host/…` before Electron hands it to
|
|
10
|
+
* `setWindowOpenHandler`. Passing that to `shell.openExternal` is a
|
|
11
|
+
* ShellExecute on a local binary or an SMB fetch. The check therefore runs on
|
|
12
|
+
* the resolved URL and allows only web/mail schemes.
|
|
13
|
+
*
|
|
14
|
+
* Electron-free so it can be unit-tested without a running app; main.cjs owns
|
|
15
|
+
* the window handlers that call it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
"use strict";
|
|
19
|
+
|
|
20
|
+
/** The only schemes `shell.openExternal` may ever receive. */
|
|
21
|
+
const ALLOWED_EXTERNAL_SCHEMES = new Set(["http:", "https:", "mailto:"]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} rawUrl A URL already resolved by Electron/Chromium.
|
|
25
|
+
* @returns {boolean} True when it is safe to hand to `shell.openExternal`.
|
|
26
|
+
*/
|
|
27
|
+
function isAllowedExternalUrl(rawUrl) {
|
|
28
|
+
if (typeof rawUrl !== "string" || rawUrl === "") return false;
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = new URL(rawUrl);
|
|
32
|
+
} catch {
|
|
33
|
+
return false; // Unresolvable/relative — never openExternal it.
|
|
34
|
+
}
|
|
35
|
+
return ALLOWED_EXTERNAL_SCHEMES.has(parsed.protocol.toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when `targetUrl` is the document already loaded, differing only by
|
|
40
|
+
* fragment or query — i.e. an in-app anchor, not a navigation away.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} targetUrl
|
|
43
|
+
* @param {string} currentUrl
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
function isInAppNavigation(targetUrl, currentUrl) {
|
|
47
|
+
if (typeof targetUrl !== "string" || typeof currentUrl !== "string") return false;
|
|
48
|
+
let target;
|
|
49
|
+
let current;
|
|
50
|
+
try {
|
|
51
|
+
target = new URL(targetUrl);
|
|
52
|
+
current = new URL(currentUrl);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return (
|
|
57
|
+
target.protocol === current.protocol &&
|
|
58
|
+
target.host === current.host &&
|
|
59
|
+
target.pathname === current.pathname
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A one-line, user-facing reason a URL was refused. Never silently dropped —
|
|
65
|
+
* the caller logs this and shows it, so a blocked click is explainable.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} rawUrl
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function describeBlockedUrl(rawUrl) {
|
|
71
|
+
const shown = typeof rawUrl === "string" && rawUrl ? rawUrl : String(rawUrl);
|
|
72
|
+
let scheme = "no scheme";
|
|
73
|
+
try {
|
|
74
|
+
scheme = new URL(shown).protocol.replace(/:$/, "");
|
|
75
|
+
} catch {
|
|
76
|
+
/* keep "no scheme" */
|
|
77
|
+
}
|
|
78
|
+
return (
|
|
79
|
+
`GAIA refused to open "${shown.slice(0, 300)}" (${scheme}). ` +
|
|
80
|
+
"Only http, https and mailto links open outside the app — a link that " +
|
|
81
|
+
"resolves to a local file or network share is never launched."
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
ALLOWED_EXTERNAL_SCHEMES,
|
|
87
|
+
isAllowedExternalUrl,
|
|
88
|
+
isInAppNavigation,
|
|
89
|
+
describeBlockedUrl,
|
|
90
|
+
};
|
|
@@ -330,36 +330,58 @@ class NotificationService extends EventEmitter {
|
|
|
330
330
|
// Listen for agent notifications via the EventEmitter
|
|
331
331
|
this.agentProcessManager.on(
|
|
332
332
|
"agent-notification",
|
|
333
|
-
(agentId, params) => {
|
|
333
|
+
this._guardListener("agent-notification", (agentId, params) => {
|
|
334
334
|
this.handleAgentNotification(agentId, params);
|
|
335
|
-
}
|
|
335
|
+
})
|
|
336
336
|
);
|
|
337
337
|
|
|
338
338
|
// Agent crash → generate error notification
|
|
339
|
-
this.agentProcessManager.on(
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
339
|
+
this.agentProcessManager.on(
|
|
340
|
+
"status-change",
|
|
341
|
+
this._guardListener("status-change", (payload) => {
|
|
342
|
+
if (payload.status === "stopped" && payload.detail) {
|
|
343
|
+
// Only notify on unexpected stops (crashes)
|
|
344
|
+
this.handleAgentNotification(payload.agentId, {
|
|
345
|
+
type: "error",
|
|
346
|
+
title: "Agent Crashed",
|
|
347
|
+
message: payload.detail || `Agent ${payload.agentId} stopped unexpectedly`,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
);
|
|
349
352
|
|
|
350
353
|
// Crash limit reached → generate error notification
|
|
351
354
|
this.agentProcessManager.on(
|
|
352
355
|
"agent-crash-limit",
|
|
353
|
-
(agentId, crashCount) => {
|
|
356
|
+
this._guardListener("agent-crash-limit", (agentId, crashCount) => {
|
|
354
357
|
this.handleAgentNotification(agentId, {
|
|
355
358
|
type: "error",
|
|
356
359
|
title: "Agent Crash Limit Reached",
|
|
357
360
|
message: `Agent ${agentId} crashed ${crashCount} times — automatic restart disabled`,
|
|
358
361
|
});
|
|
359
|
-
}
|
|
362
|
+
})
|
|
360
363
|
);
|
|
361
364
|
}
|
|
362
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Wrap an EventEmitter listener so a throw inside it is logged instead of
|
|
368
|
+
* escaping as an uncaughtException — these fire from a child-process `exit`
|
|
369
|
+
* handler, where an unhandled throw takes the whole app down with the
|
|
370
|
+
* backend and sidecars still running.
|
|
371
|
+
*/
|
|
372
|
+
_guardListener(eventName, fn) {
|
|
373
|
+
return (...args) => {
|
|
374
|
+
try {
|
|
375
|
+
fn(...args);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
console.error(
|
|
378
|
+
`[notif] Listener for "${eventName}" threw and was contained: ` +
|
|
379
|
+
`${err && err.stack ? err.stack : err}`
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
363
385
|
// ── Private: Persistence ─────────────────────────────────────────────
|
|
364
386
|
|
|
365
387
|
_loadNotifications() {
|
|
@@ -33,8 +33,47 @@ const DEFAULT_CONFIG = {
|
|
|
33
33
|
startMinimized: false,
|
|
34
34
|
startOnLogin: false,
|
|
35
35
|
},
|
|
36
|
+
agents: {},
|
|
36
37
|
};
|
|
37
38
|
|
|
39
|
+
const AGENT_LOG_LEVELS = ["debug", "info", "warn", "error"];
|
|
40
|
+
|
|
41
|
+
function isPlainObject(value) {
|
|
42
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Validate an `agents` section (id → AgentConfig), dropping unknown keys. */
|
|
46
|
+
function validateAgents(agents, where) {
|
|
47
|
+
if (!isPlainObject(agents)) {
|
|
48
|
+
throw new TypeError(`${where}: agents must be an object`);
|
|
49
|
+
}
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const [id, cfg] of Object.entries(agents)) {
|
|
52
|
+
if (id.trim() === "") {
|
|
53
|
+
throw new TypeError(`${where}: agent id must be a non-empty string`);
|
|
54
|
+
}
|
|
55
|
+
if (!isPlainObject(cfg)) {
|
|
56
|
+
throw new TypeError(`${where}: agents.${id} must be an object`);
|
|
57
|
+
}
|
|
58
|
+
for (const key of ["autoStart", "restartOnCrash"]) {
|
|
59
|
+
if (typeof cfg[key] !== "boolean") {
|
|
60
|
+
throw new TypeError(`${where}: agents.${id}.${key} must be a boolean`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!AGENT_LOG_LEVELS.includes(cfg.logLevel)) {
|
|
64
|
+
throw new TypeError(
|
|
65
|
+
`${where}: agents.${id}.logLevel must be one of ${AGENT_LOG_LEVELS.join(", ")}`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
out[id] = {
|
|
69
|
+
autoStart: cfg.autoStart,
|
|
70
|
+
restartOnCrash: cfg.restartOnCrash,
|
|
71
|
+
logLevel: cfg.logLevel,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
38
77
|
// ── TrayManager ──────────────────────────────────────────────────────────
|
|
39
78
|
|
|
40
79
|
class TrayManager {
|
|
@@ -53,6 +92,9 @@ class TrayManager {
|
|
|
53
92
|
/** @type {Electron.Tray | null} */
|
|
54
93
|
this.tray = null;
|
|
55
94
|
|
|
95
|
+
/** @type {number} Unread notifications reflected in the tooltip/badge. */
|
|
96
|
+
this._notificationCount = 0;
|
|
97
|
+
|
|
56
98
|
/** @type {object} */
|
|
57
99
|
this.config = this._loadConfig();
|
|
58
100
|
|
|
@@ -69,7 +111,8 @@ class TrayManager {
|
|
|
69
111
|
if (this.tray) return;
|
|
70
112
|
|
|
71
113
|
this.tray = new Tray(this._icon);
|
|
72
|
-
|
|
114
|
+
// Re-apply any count that arrived before the tray existed.
|
|
115
|
+
this.setNotificationCount(this._notificationCount);
|
|
73
116
|
|
|
74
117
|
// Single-click: show/focus window
|
|
75
118
|
this.tray.on("click", () => this._showWindow());
|
|
@@ -95,6 +138,45 @@ class TrayManager {
|
|
|
95
138
|
this._rebuildContextMenu();
|
|
96
139
|
}
|
|
97
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Reflect the unread-notification count in the tray tooltip (and the dock /
|
|
143
|
+
* launcher badge where the platform has one).
|
|
144
|
+
*
|
|
145
|
+
* @param {number} count Unread notifications; negative/NaN is treated as 0.
|
|
146
|
+
*/
|
|
147
|
+
setNotificationCount(count) {
|
|
148
|
+
const unread =
|
|
149
|
+
typeof count === "number" && Number.isFinite(count) && count > 0
|
|
150
|
+
? Math.floor(count)
|
|
151
|
+
: 0;
|
|
152
|
+
if (unread !== this._notificationCount) {
|
|
153
|
+
console.log(`[tray] Unread notifications: ${unread}`);
|
|
154
|
+
}
|
|
155
|
+
this._notificationCount = unread;
|
|
156
|
+
|
|
157
|
+
const trayAlive =
|
|
158
|
+
this.tray &&
|
|
159
|
+
!(typeof this.tray.isDestroyed === "function" && this.tray.isDestroyed());
|
|
160
|
+
if (trayAlive) {
|
|
161
|
+
this.tray.setToolTip(
|
|
162
|
+
unread > 0
|
|
163
|
+
? `GAIA — ${unread} unread notification${unread === 1 ? "" : "s"}`
|
|
164
|
+
: "GAIA"
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Dock (macOS) / Unity launcher (Linux) badge. Windows has no
|
|
169
|
+
// app.setBadgeCount equivalent — the tooltip above is the badge there.
|
|
170
|
+
if (process.platform !== "win32" && typeof app.setBadgeCount === "function") {
|
|
171
|
+
app.setBadgeCount(unread);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** @returns {number} The unread count last passed to setNotificationCount. */
|
|
176
|
+
get notificationCount() {
|
|
177
|
+
return this._notificationCount || 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
98
180
|
/** @returns {boolean} Whether minimize-to-tray is enabled. */
|
|
99
181
|
get minimizeToTray() {
|
|
100
182
|
return this.config.tray.minimizeToTray;
|
|
@@ -184,32 +266,63 @@ class TrayManager {
|
|
|
184
266
|
// ── Private: Config persistence ──────────────────────────────────────
|
|
185
267
|
|
|
186
268
|
_loadConfig() {
|
|
269
|
+
let loaded = null;
|
|
187
270
|
try {
|
|
188
271
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
189
|
-
|
|
190
|
-
const loaded = JSON.parse(raw);
|
|
191
|
-
return {
|
|
192
|
-
...DEFAULT_CONFIG,
|
|
193
|
-
...loaded,
|
|
194
|
-
tray: { ...DEFAULT_CONFIG.tray, ...(loaded.tray || {}) },
|
|
195
|
-
};
|
|
272
|
+
loaded = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
196
273
|
}
|
|
197
274
|
} catch (err) {
|
|
198
275
|
console.warn("[tray] Could not load tray config:", err.message);
|
|
199
276
|
}
|
|
200
|
-
|
|
201
|
-
|
|
277
|
+
if (!isPlainObject(loaded)) {
|
|
278
|
+
return { ...DEFAULT_CONFIG, tray: { ...DEFAULT_CONFIG.tray }, agents: {} };
|
|
279
|
+
}
|
|
202
280
|
|
|
203
|
-
|
|
281
|
+
// Scoped to the agents section: one bad entry must not cost the user their
|
|
282
|
+
// tray settings, which the next save would then overwrite with defaults.
|
|
283
|
+
let agents = {};
|
|
204
284
|
try {
|
|
205
|
-
|
|
206
|
-
|
|
285
|
+
agents = validateAgents(loaded.agents ?? {}, CONFIG_PATH);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
console.error(
|
|
288
|
+
`[tray] Ignoring invalid agents section in ${CONFIG_PATH}: ${err.message}. ` +
|
|
289
|
+
"Agent settings fall back to defaults; tray settings are kept."
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Repair on read: the pre-validation handler could store wrong-typed
|
|
294
|
+
// values, and set-config re-reads stored keys the payload omits.
|
|
295
|
+
const stored = isPlainObject(loaded.tray) ? loaded.tray : {};
|
|
296
|
+
const tray = { ...DEFAULT_CONFIG.tray };
|
|
297
|
+
for (const key of Object.keys(DEFAULT_CONFIG.tray)) {
|
|
298
|
+
if (typeof stored[key] === "boolean") {
|
|
299
|
+
tray[key] = stored[key];
|
|
300
|
+
} else if (key in stored) {
|
|
301
|
+
console.warn(
|
|
302
|
+
`[tray] Ignoring non-boolean ${key} in ${CONFIG_PATH}; using default`
|
|
303
|
+
);
|
|
207
304
|
}
|
|
208
|
-
|
|
209
|
-
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
...DEFAULT_CONFIG,
|
|
309
|
+
...loaded,
|
|
310
|
+
tray,
|
|
311
|
+
agents,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
_saveConfig(config) {
|
|
316
|
+
try {
|
|
317
|
+
fs.mkdirSync(GAIA_DIR, { recursive: true });
|
|
318
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
|
|
210
319
|
} catch (err) {
|
|
211
|
-
|
|
320
|
+
throw new Error(
|
|
321
|
+
`Could not save settings to ${CONFIG_PATH}: ${err.message}. ` +
|
|
322
|
+
"Check that the file and its folder are writable."
|
|
323
|
+
);
|
|
212
324
|
}
|
|
325
|
+
console.log("[tray] Config saved to", CONFIG_PATH);
|
|
213
326
|
}
|
|
214
327
|
|
|
215
328
|
// ── Private: IPC handlers ────────────────────────────────────────────
|
|
@@ -220,15 +333,42 @@ class TrayManager {
|
|
|
220
333
|
});
|
|
221
334
|
|
|
222
335
|
ipcMain.handle("tray:set-config", (_event, cfg) => {
|
|
223
|
-
if (cfg
|
|
224
|
-
|
|
336
|
+
if (!isPlainObject(cfg)) {
|
|
337
|
+
throw new TypeError("tray:set-config expects an object payload");
|
|
338
|
+
}
|
|
339
|
+
if (cfg.tray !== undefined && !isPlainObject(cfg.tray)) {
|
|
340
|
+
throw new TypeError("tray:set-config: tray must be an object");
|
|
341
|
+
}
|
|
342
|
+
const agents =
|
|
343
|
+
cfg.agents === undefined
|
|
344
|
+
? null
|
|
345
|
+
: validateAgents(cfg.agents, "tray:set-config");
|
|
346
|
+
|
|
347
|
+
let tray = null;
|
|
348
|
+
if (cfg.tray !== undefined) {
|
|
349
|
+
tray = {};
|
|
350
|
+
for (const key of Object.keys(DEFAULT_CONFIG.tray)) {
|
|
351
|
+
const value = key in cfg.tray ? cfg.tray[key] : this.config.tray[key];
|
|
352
|
+
if (typeof value !== "boolean") {
|
|
353
|
+
throw new TypeError(`tray:set-config: tray.${key} must be a boolean`);
|
|
354
|
+
}
|
|
355
|
+
tray[key] = value;
|
|
356
|
+
}
|
|
225
357
|
}
|
|
226
358
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
this.
|
|
359
|
+
if (tray === null && agents === null) {
|
|
360
|
+
return this.config;
|
|
361
|
+
}
|
|
362
|
+
const next = {
|
|
363
|
+
...this.config,
|
|
364
|
+
tray: tray ?? this.config.tray,
|
|
365
|
+
agents: agents ? { ...this.config.agents, ...agents } : this.config.agents,
|
|
366
|
+
};
|
|
367
|
+
this._saveConfig(next);
|
|
368
|
+
this.config = next;
|
|
369
|
+
|
|
370
|
+
if (tray !== null && "startOnLogin" in cfg.tray) {
|
|
371
|
+
this._applyLoginItemSetting(tray.startOnLogin);
|
|
232
372
|
}
|
|
233
373
|
|
|
234
374
|
return this.config;
|