@amd-gaia/agent-ui 0.17.0 → 0.17.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/preload.cjs CHANGED
@@ -11,6 +11,8 @@
11
11
  * agent:* — Agent process management (T2)
12
12
  * tray:* — Tray icon/config (T1)
13
13
  * notification:* — Desktop notifications & permission prompts (T5)
14
+ * install:* — First-run backend install progress (Phase A)
15
+ * gaia:update:* — Auto-update status & manual check (Phase F)
14
16
  */
15
17
 
16
18
  const { contextBridge, ipcRenderer } = require("electron");
@@ -59,3 +61,43 @@ contextBridge.exposeInMainWorld("gaiaAPI", {
59
61
  onNotification: (cb) => onEvent("notification:new", cb),
60
62
  },
61
63
  });
64
+
65
+ // ── Install progress (Phase A) ──────────────────────────────────────────
66
+ // Exposed as a separate global so the progress window can use it without
67
+ // pulling in the full gaiaAPI surface (and so it keeps working if an
68
+ // install dialog runs before the main window is ready).
69
+ contextBridge.exposeInMainWorld("gaiaInstall", {
70
+ // Subscribe to progress updates. Returns an unsubscribe function.
71
+ onProgress: (cb) => onEvent("install:progress", cb),
72
+
73
+ // Query the current install state (state machine + log/state paths).
74
+ status: () => ipcRenderer.invoke("install:status"),
75
+
76
+ // Copy the log file path to the clipboard.
77
+ copyLogPath: () => ipcRenderer.invoke("install:copy-log-path"),
78
+
79
+ // Open the log file in the OS's default viewer.
80
+ openLogFile: () => ipcRenderer.invoke("install:open-log-file"),
81
+ });
82
+
83
+ // ── Auto-update (Phase F) ───────────────────────────────────────────────
84
+ // Exposed as a separate global so the renderer can show a small "update
85
+ // available" chip without pulling in the full gaiaAPI surface. Mirrors
86
+ // the naming convention (gaiaInstall, gaiaUpdater, gaiaAPI).
87
+ contextBridge.exposeInMainWorld("gaiaUpdater", {
88
+ /** Get the current update state (status, version, progress, error). */
89
+ getStatus: () => ipcRenderer.invoke("gaia:update:get-status"),
90
+
91
+ /** Manually trigger a check. Resolves with the post-check state. */
92
+ check: () => ipcRenderer.invoke("gaia:update:check"),
93
+
94
+ /**
95
+ * Subscribe to status changes. The callback is invoked with the full
96
+ * state object on every state transition. Returns an unsubscribe fn.
97
+ */
98
+ onStatusChange: (callback) => {
99
+ const handler = (_event, state) => callback(state);
100
+ ipcRenderer.on("gaia:update:status", handler);
101
+ return () => ipcRenderer.removeListener("gaia:update:status", handler);
102
+ },
103
+ });
@@ -0,0 +1,437 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * auto-updater.cjs — GAIA Agent UI auto-update service.
6
+ *
7
+ * Wraps `electron-updater` for the GitHub Releases auto-update flow.
8
+ * Implements §4 Layer 3 + §7 Phase F of docs/plans/desktop-installer.mdx.
9
+ *
10
+ * Behavior:
11
+ * - First check 10 seconds after `init()` is called (typically from
12
+ * `app.whenReady`)
13
+ * - Subsequent checks every 4 hours via re-scheduling setTimeout
14
+ * - Concurrent-check guard so checks never overlap
15
+ * - Download silently in the background
16
+ * - On `update-downloaded`, show a native dialog: "Update ready — restart?"
17
+ * - Renderer integration via IPC channel `gaia:update:status`
18
+ * - Disabled entirely via GAIA_DISABLE_UPDATE=1 env var (CI / dev / corp)
19
+ *
20
+ * Exports:
21
+ * - init(mainWindow) → set up handlers, schedule checks
22
+ * - destroy() → tear down timers and IPC handlers
23
+ * - checkForUpdates() → manually trigger a check
24
+ * - getState() → returns a copy of the current state
25
+ * - STATES → string constants for valid states
26
+ *
27
+ * Design note: `electron` and `electron-updater` are lazy-required inside
28
+ * `init()`. Accessing `electronUpdater.autoUpdater` outside of an Electron
29
+ * runtime throws synchronously (it reads `app.getVersion()` eagerly), so
30
+ * we keep the module load pure. This also makes `GAIA_DISABLE_UPDATE=1`
31
+ * safely short-circuit before touching any Electron APIs — useful in tests
32
+ * and in environments where the Electron app isn't wired up.
33
+ */
34
+
35
+ "use strict";
36
+
37
+ const path = require("path");
38
+ const fs = require("fs");
39
+ const os = require("os");
40
+
41
+ // ── Constants ────────────────────────────────────────────────────────────────
42
+
43
+ const CHECK_DELAY_MS = 10 * 1000; // First check 10s after init
44
+ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // Subsequent checks every 4h
45
+ const LOG_PATH = path.join(os.homedir(), ".gaia", "electron-updater.log");
46
+
47
+ const STATES = Object.freeze({
48
+ IDLE: "idle",
49
+ CHECKING: "checking",
50
+ AVAILABLE: "available",
51
+ DOWNLOADING: "downloading",
52
+ DOWNLOADED: "downloaded",
53
+ ERROR: "error",
54
+ DISABLED: "disabled",
55
+ });
56
+
57
+ // ── Module state ─────────────────────────────────────────────────────────────
58
+
59
+ /** Shape broadcast to the renderer via `gaia:update:status`. */
60
+ const state = {
61
+ status: STATES.IDLE,
62
+ version: null,
63
+ progress: 0,
64
+ releaseNotes: null,
65
+ error: null,
66
+ };
67
+
68
+ let mainWindowRef = null;
69
+ let checkInProgress = false;
70
+ let scheduledTimeout = null;
71
+ let initialCheckTimeout = null;
72
+ let ipcHandlersRegistered = false;
73
+ let initialized = false;
74
+
75
+ // Lazy-loaded Electron references (populated inside init()).
76
+ let electronApi = null; // { dialog, ipcMain }
77
+ let autoUpdaterRef = null; // electron-updater's singleton
78
+
79
+ // ── Logging ──────────────────────────────────────────────────────────────────
80
+
81
+ function log(level, message, ...args) {
82
+ const ts = new Date().toISOString();
83
+ const extra = args.length ? " " + safeStringify(args) : "";
84
+ const line = `[${ts}] [${level}] ${message}${extra}\n`;
85
+ try {
86
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
87
+ fs.appendFileSync(LOG_PATH, line);
88
+ } catch {
89
+ // Non-fatal — logging must never crash the app.
90
+ }
91
+ // Also mirror to stdout so devs see it in `npm start` output.
92
+ try {
93
+ // eslint-disable-next-line no-console
94
+ console.log(`[auto-updater] ${level} ${message}`, ...args);
95
+ } catch {
96
+ // ignore
97
+ }
98
+ }
99
+
100
+ function safeStringify(value) {
101
+ try {
102
+ return JSON.stringify(value);
103
+ } catch {
104
+ return String(value);
105
+ }
106
+ }
107
+
108
+ // ── State management ─────────────────────────────────────────────────────────
109
+
110
+ function broadcastState() {
111
+ if (!mainWindowRef) return;
112
+ try {
113
+ if (mainWindowRef.isDestroyed && mainWindowRef.isDestroyed()) return;
114
+ if (mainWindowRef.webContents && !mainWindowRef.webContents.isDestroyed()) {
115
+ mainWindowRef.webContents.send("gaia:update:status", { ...state });
116
+ }
117
+ } catch (err) {
118
+ log("warn", "Failed to broadcast state:", err && err.message);
119
+ }
120
+ }
121
+
122
+ function setState(patch) {
123
+ Object.assign(state, patch);
124
+ broadcastState();
125
+ }
126
+
127
+ function getState() {
128
+ return { ...state };
129
+ }
130
+
131
+ // ── Env gate ─────────────────────────────────────────────────────────────────
132
+
133
+ function isDisabled() {
134
+ return process.env.GAIA_DISABLE_UPDATE === "1";
135
+ }
136
+
137
+ // ── Core check ───────────────────────────────────────────────────────────────
138
+
139
+ async function checkForUpdates() {
140
+ if (isDisabled()) {
141
+ setState({ status: STATES.DISABLED });
142
+ log("info", "Update check skipped — GAIA_DISABLE_UPDATE=1");
143
+ return;
144
+ }
145
+ if (!autoUpdaterRef) {
146
+ log("warn", "checkForUpdates called before init — ignoring");
147
+ return;
148
+ }
149
+ if (checkInProgress) {
150
+ log("info", "Skipping check — another check is already in progress");
151
+ return;
152
+ }
153
+ checkInProgress = true;
154
+ setState({ status: STATES.CHECKING, error: null });
155
+ try {
156
+ log("info", "Checking for updates...");
157
+ await autoUpdaterRef.checkForUpdates();
158
+ } catch (err) {
159
+ setState({
160
+ status: STATES.ERROR,
161
+ error: (err && err.message) || String(err),
162
+ });
163
+ log("error", "Update check failed:", err && err.message);
164
+ } finally {
165
+ checkInProgress = false;
166
+ }
167
+ }
168
+
169
+ function scheduleNextCheck() {
170
+ if (scheduledTimeout) {
171
+ clearTimeout(scheduledTimeout);
172
+ scheduledTimeout = null;
173
+ }
174
+ scheduledTimeout = setTimeout(async () => {
175
+ try {
176
+ await checkForUpdates();
177
+ } catch (err) {
178
+ log("error", "Scheduled check threw:", err && err.message);
179
+ }
180
+ scheduleNextCheck();
181
+ }, CHECK_INTERVAL_MS);
182
+ }
183
+
184
+ // ── Event wiring ─────────────────────────────────────────────────────────────
185
+
186
+ function wireAutoUpdaterEvents() {
187
+ autoUpdaterRef.on("checking-for-update", () => {
188
+ setState({ status: STATES.CHECKING });
189
+ });
190
+
191
+ autoUpdaterRef.on("update-available", (info) => {
192
+ const releaseNotes =
193
+ typeof info.releaseNotes === "string" ? info.releaseNotes : null;
194
+ setState({
195
+ status: STATES.AVAILABLE,
196
+ version: info.version || null,
197
+ releaseNotes,
198
+ error: null,
199
+ });
200
+ log("info", `Update available: ${info.version}`);
201
+ });
202
+
203
+ autoUpdaterRef.on("update-not-available", (info) => {
204
+ // Reset to idle so the UI hides any stale "available" chip.
205
+ setState({
206
+ status: STATES.IDLE,
207
+ version: null,
208
+ progress: 0,
209
+ releaseNotes: null,
210
+ error: null,
211
+ });
212
+ log("info", `No update available (current ${info && info.version})`);
213
+ });
214
+
215
+ autoUpdaterRef.on("download-progress", (progress) => {
216
+ const percent =
217
+ progress && typeof progress.percent === "number"
218
+ ? Math.max(0, Math.min(100, Math.round(progress.percent)))
219
+ : 0;
220
+ setState({
221
+ status: STATES.DOWNLOADING,
222
+ progress: percent,
223
+ });
224
+ });
225
+
226
+ autoUpdaterRef.on("update-downloaded", async (info) => {
227
+ setState({
228
+ status: STATES.DOWNLOADED,
229
+ version: (info && info.version) || state.version,
230
+ progress: 100,
231
+ error: null,
232
+ });
233
+ log("info", `Update downloaded: ${info && info.version}`);
234
+
235
+ if (!electronApi || !electronApi.dialog) {
236
+ log("warn", "No dialog available — skipping restart prompt");
237
+ return;
238
+ }
239
+ try {
240
+ const choice = await electronApi.dialog.showMessageBox(
241
+ mainWindowRef && !mainWindowRef.isDestroyed() ? mainWindowRef : null,
242
+ {
243
+ type: "info",
244
+ buttons: ["Restart now", "Later"],
245
+ defaultId: 0,
246
+ cancelId: 1,
247
+ title: "Update ready",
248
+ message: `GAIA ${info && info.version ? info.version : ""} has been downloaded.`,
249
+ detail:
250
+ "Restart the app to apply the update. Your chat history will be preserved.",
251
+ }
252
+ );
253
+ if (choice && choice.response === 0) {
254
+ log("info", "User chose to restart — calling quitAndInstall");
255
+ // (isSilent=false, isForceRunAfter=true) — run the installer UI on
256
+ // Windows and relaunch after the update is applied.
257
+ autoUpdaterRef.quitAndInstall(false, true);
258
+ } else {
259
+ log("info", "User deferred restart — will install on next quit");
260
+ }
261
+ } catch (err) {
262
+ log("error", "Failed to show restart dialog:", err && err.message);
263
+ }
264
+ });
265
+
266
+ autoUpdaterRef.on("error", (err) => {
267
+ setState({
268
+ status: STATES.ERROR,
269
+ error: (err && err.message) || String(err),
270
+ });
271
+ log("error", "electron-updater error:", err && err.message);
272
+ });
273
+ }
274
+
275
+ function registerIpcHandlers() {
276
+ if (ipcHandlersRegistered || !electronApi || !electronApi.ipcMain) return;
277
+ const { ipcMain } = electronApi;
278
+
279
+ ipcMain.handle("gaia:update:get-status", () => getState());
280
+ ipcMain.handle("gaia:update:check", async () => {
281
+ await checkForUpdates();
282
+ return getState();
283
+ });
284
+ ipcHandlersRegistered = true;
285
+ }
286
+
287
+ function unregisterIpcHandlers() {
288
+ if (!ipcHandlersRegistered || !electronApi || !electronApi.ipcMain) return;
289
+ const { ipcMain } = electronApi;
290
+ try {
291
+ ipcMain.removeHandler("gaia:update:get-status");
292
+ } catch {
293
+ // ignore
294
+ }
295
+ try {
296
+ ipcMain.removeHandler("gaia:update:check");
297
+ } catch {
298
+ // ignore
299
+ }
300
+ ipcHandlersRegistered = false;
301
+ }
302
+
303
+ // ── Public API ───────────────────────────────────────────────────────────────
304
+
305
+ /**
306
+ * Initialize the auto-updater. Safe to call multiple times — subsequent
307
+ * calls update the window reference only.
308
+ *
309
+ * Must NOT block app launch: if anything goes wrong the caller catches and
310
+ * continues, and this function itself never throws.
311
+ *
312
+ * @param {Electron.BrowserWindow | null} mainWindow
313
+ */
314
+ function init(mainWindow) {
315
+ mainWindowRef = mainWindow || null;
316
+
317
+ // Disabled path — short-circuit BEFORE touching any Electron APIs so
318
+ // this works in plain Node tests where require('electron') returns a
319
+ // string and the electron-updater singleton throws on access.
320
+ if (isDisabled()) {
321
+ setState({ status: STATES.DISABLED });
322
+ log("info", "Auto-updater disabled via GAIA_DISABLE_UPDATE=1");
323
+ return;
324
+ }
325
+
326
+ if (initialized) {
327
+ // Just refresh the window reference and push the current state down.
328
+ broadcastState();
329
+ return;
330
+ }
331
+
332
+ // Lazy-load Electron and electron-updater. Any failure here is logged
333
+ // and the updater stays in `idle` — we never crash the app.
334
+ try {
335
+ // eslint-disable-next-line global-require
336
+ const electron = require("electron");
337
+ // eslint-disable-next-line global-require
338
+ const electronUpdater = require("electron-updater");
339
+
340
+ if (!electron || !electron.app || !electron.ipcMain || !electron.dialog) {
341
+ log(
342
+ "warn",
343
+ "Electron APIs unavailable — auto-updater will not be active"
344
+ );
345
+ return;
346
+ }
347
+
348
+ electronApi = {
349
+ dialog: electron.dialog,
350
+ ipcMain: electron.ipcMain,
351
+ };
352
+ autoUpdaterRef = electronUpdater.autoUpdater;
353
+ } catch (err) {
354
+ log("error", "Failed to load electron-updater:", err && err.message);
355
+ setState({
356
+ status: STATES.ERROR,
357
+ error: (err && err.message) || "Failed to load electron-updater",
358
+ });
359
+ return;
360
+ }
361
+
362
+ // Configure electron-updater. Provider config comes from
363
+ // electron-builder.yml (`publish:` block) at build time; we only set
364
+ // behavioral flags here.
365
+ try {
366
+ autoUpdaterRef.autoDownload = true;
367
+ autoUpdaterRef.autoInstallOnAppQuit = true;
368
+ autoUpdaterRef.disableWebInstaller = true;
369
+ autoUpdaterRef.allowDowngrade = false;
370
+ // Allow pre-releases only if explicitly opted in (for beta channels later).
371
+ autoUpdaterRef.allowPrerelease =
372
+ process.env.GAIA_UPDATE_PRERELEASE === "1";
373
+
374
+ autoUpdaterRef.logger = {
375
+ info: (m) => log("info", String(m)),
376
+ warn: (m) => log("warn", String(m)),
377
+ error: (m) => log("error", String(m)),
378
+ debug: (m) => log("debug", String(m)),
379
+ };
380
+ } catch (err) {
381
+ log("warn", "Failed to configure autoUpdater flags:", err && err.message);
382
+ }
383
+
384
+ try {
385
+ wireAutoUpdaterEvents();
386
+ } catch (err) {
387
+ log("error", "Failed to wire autoUpdater events:", err && err.message);
388
+ return;
389
+ }
390
+
391
+ try {
392
+ registerIpcHandlers();
393
+ } catch (err) {
394
+ log("warn", "Failed to register IPC handlers:", err && err.message);
395
+ }
396
+
397
+ // First check after CHECK_DELAY_MS, then every CHECK_INTERVAL_MS.
398
+ initialCheckTimeout = setTimeout(async () => {
399
+ try {
400
+ await checkForUpdates();
401
+ } catch (err) {
402
+ log("error", "Initial check threw:", err && err.message);
403
+ }
404
+ scheduleNextCheck();
405
+ }, CHECK_DELAY_MS);
406
+
407
+ initialized = true;
408
+ log(
409
+ "info",
410
+ `Auto-updater initialized; first check in ${CHECK_DELAY_MS}ms`
411
+ );
412
+ }
413
+
414
+ /** Tear down timers and IPC handlers. Safe to call multiple times. */
415
+ function destroy() {
416
+ if (initialCheckTimeout) {
417
+ clearTimeout(initialCheckTimeout);
418
+ initialCheckTimeout = null;
419
+ }
420
+ if (scheduledTimeout) {
421
+ clearTimeout(scheduledTimeout);
422
+ scheduledTimeout = null;
423
+ }
424
+ unregisterIpcHandlers();
425
+ mainWindowRef = null;
426
+ // Keep `initialized` true — calling init() again after destroy() is not a
427
+ // supported lifecycle and we'd need to re-wire electron-updater events
428
+ // which cannot be reliably cleaned up via its public API.
429
+ }
430
+
431
+ module.exports = {
432
+ init,
433
+ destroy,
434
+ checkForUpdates,
435
+ getState,
436
+ STATES,
437
+ };