@amd-gaia/agent-ui 0.21.0 → 0.21.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.
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * Wraps `electron-updater` for the GitHub Releases auto-update flow.
8
8
  * Implements §4 Layer 3 + §7 Phase F of docs/plans/desktop-installer.mdx.
9
+ * Issue #1336: adds in-app rollback to a specific previous release.
9
10
  *
10
11
  * Behavior:
11
12
  * - First check 10 seconds after `init()` is called (typically from
@@ -16,12 +17,17 @@
16
17
  * - On `update-downloaded`, show a native dialog: "Update ready — restart?"
17
18
  * - Renderer integration via IPC channel `gaia:update:status`
18
19
  * - Disabled entirely via GAIA_DISABLE_UPDATE=1 env var (CI / dev / corp)
20
+ * - Rollback: listReleases() → installVersion(tag) → pin persisted to
21
+ * ~/.gaia/update-config.json so auto-update stays paused until resumeUpdates()
19
22
  *
20
23
  * Exports:
21
24
  * - init(mainWindow) → set up handlers, schedule checks
22
25
  * - destroy() → tear down timers and IPC handlers
23
26
  * - checkForUpdates() → manually trigger a check
24
27
  * - getState() → returns a copy of the current state
28
+ * - listReleases() → fetch available releases from GitHub
29
+ * - installVersion(tag) → downgrade/install a specific tagged release
30
+ * - clearPin() → resume auto-updates (clear the version pin)
25
31
  * - STATES → string constants for valid states
26
32
  *
27
33
  * Design note: `electron` and `electron-updater` are lazy-required inside
@@ -43,6 +49,20 @@ const os = require("os");
43
49
  const CHECK_DELAY_MS = 10 * 1000; // First check 10s after init
44
50
  const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // Subsequent checks every 4h
45
51
  const LOG_PATH = path.join(os.homedir(), ".gaia", "electron-updater.log");
52
+ const UPDATE_CONFIG_PATH = path.join(os.homedir(), ".gaia", "update-config.json");
53
+
54
+ // per_page=100 is the GitHub API max for a single page; we fetch broadly so
55
+ // draft/prerelease/platform filtering still yields a full page of installable
56
+ // releases before the display cap below. >100 releases would need pagination
57
+ // (follow the Link rel="next" header). Not built yet.
58
+ const GITHUB_API_RELEASES =
59
+ "https://api.github.com/repos/amd/gaia/releases?per_page=100";
60
+
61
+ // The picker shows only the N most-recent installable releases (rolling back
62
+ // more than a few versions is rare). Older versions stay reachable via the
63
+ // "browse all on GitHub" link in the picker, so this display cap never makes a
64
+ // release unreachable.
65
+ const MAX_RELEASES_SHOWN = 10;
46
66
 
47
67
  const STATES = Object.freeze({
48
68
  IDLE: "idle",
@@ -63,6 +83,8 @@ const state = {
63
83
  progress: 0,
64
84
  releaseNotes: null,
65
85
  error: null,
86
+ currentVersion: null,
87
+ pinnedVersion: null,
66
88
  };
67
89
 
68
90
  let mainWindowRef = null;
@@ -73,7 +95,7 @@ let ipcHandlersRegistered = false;
73
95
  let initialized = false;
74
96
 
75
97
  // Lazy-loaded Electron references (populated inside init()).
76
- let electronApi = null; // { dialog, ipcMain }
98
+ let electronApi = null; // { dialog, ipcMain, app }
77
99
  let autoUpdaterRef = null; // electron-updater's singleton
78
100
 
79
101
  // ── Logging ──────────────────────────────────────────────────────────────────
@@ -88,7 +110,6 @@ function log(level, message, ...args) {
88
110
  } catch {
89
111
  // Non-fatal — logging must never crash the app.
90
112
  }
91
- // Also mirror to stdout so devs see it in `npm start` output.
92
113
  try {
93
114
  // eslint-disable-next-line no-console
94
115
  console.log(`[auto-updater] ${level} ${message}`, ...args);
@@ -105,6 +126,34 @@ function safeStringify(value) {
105
126
  }
106
127
  }
107
128
 
129
+ // ── Pin persistence ───────────────────────────────────────────────────────────
130
+
131
+ function _loadPin() {
132
+ try {
133
+ if (!fs.existsSync(UPDATE_CONFIG_PATH)) return null;
134
+ const raw = fs.readFileSync(UPDATE_CONFIG_PATH, "utf8");
135
+ const cfg = JSON.parse(raw);
136
+ return cfg.pinnedVersion || null;
137
+ } catch (err) {
138
+ log("warn", "Failed to read version pin — treating as unpinned:", err && err.message);
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Persist the version pin. Propagates write errors to the caller so that a
145
+ * failed pin write can abort a rollback (AC2 must not be silently broken).
146
+ * Callers that can tolerate a failed write (e.g. clearPin) should catch.
147
+ */
148
+ function _savePin(pinnedVersion) {
149
+ fs.mkdirSync(path.dirname(UPDATE_CONFIG_PATH), { recursive: true });
150
+ fs.writeFileSync(
151
+ UPDATE_CONFIG_PATH,
152
+ JSON.stringify({ pinnedVersion }, null, 2),
153
+ "utf8"
154
+ );
155
+ }
156
+
108
157
  // ── State management ─────────────────────────────────────────────────────────
109
158
 
110
159
  function broadcastState() {
@@ -134,6 +183,214 @@ function isDisabled() {
134
183
  return process.env.GAIA_DISABLE_UPDATE === "1";
135
184
  }
136
185
 
186
+ // ── Platform asset detection ─────────────────────────────────────────────────
187
+
188
+ function _platformAssetPattern() {
189
+ if (process.platform === "win32") return /setup\.exe$/i;
190
+ if (process.platform === "darwin") return /\.zip$/i;
191
+ // Linux: AppImage (deb is apt-managed, electron-updater can't downgrade it)
192
+ return /\.AppImage$/i;
193
+ }
194
+
195
+ function _releaseHasPlatformAsset(release) {
196
+ const pattern = _platformAssetPattern();
197
+ return (
198
+ Array.isArray(release.assets) &&
199
+ release.assets.some((a) => a && a.name && pattern.test(a.name))
200
+ );
201
+ }
202
+
203
+ // ── listReleases ──────────────────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Fetch published GitHub releases filtered for this platform.
207
+ *
208
+ * Returns an array of ReleaseInfo objects newest-first, or a plain object
209
+ * { error: string } if the network call fails — never an empty array that
210
+ * would silently mask failures.
211
+ *
212
+ * @returns {Promise<Array<ReleaseInfo> | { error: string }>}
213
+ */
214
+ async function listReleases() {
215
+ const allowPrerelease = process.env.GAIA_UPDATE_PRERELEASE === "1";
216
+ const pinnedVersion = _loadPin();
217
+
218
+ // Get the running version — use the cached electronApi if available, else
219
+ // attempt a direct require (works when called after init(), and from tests
220
+ // where electron is mocked via moduleNameMapper).
221
+ let currentVersion = null;
222
+ if (electronApi && electronApi.app) {
223
+ currentVersion = electronApi.app.getVersion() || null;
224
+ } else {
225
+ try {
226
+ // eslint-disable-next-line global-require
227
+ const electron = require("electron");
228
+ if (electron && electron.app && electron.app.getVersion) {
229
+ currentVersion = electron.app.getVersion() || null;
230
+ }
231
+ } catch {
232
+ // ignore — currentVersion stays null
233
+ }
234
+ }
235
+
236
+ let releases;
237
+ try {
238
+ const resp = await fetch(GITHUB_API_RELEASES, {
239
+ headers: {
240
+ Accept: "application/vnd.github+json",
241
+ "User-Agent": "GAIA-Agent-UI",
242
+ },
243
+ });
244
+ if (!resp.ok) {
245
+ return {
246
+ error: `Couldn't reach GitHub to list releases (HTTP ${resp.status}) — check your connection; you can still download installers from the Releases page.`,
247
+ };
248
+ }
249
+ releases = await resp.json();
250
+ } catch (err) {
251
+ return {
252
+ error: `Couldn't reach GitHub to list releases — check your connection; you can still download installers from the Releases page. (${
253
+ err && err.message
254
+ })`,
255
+ };
256
+ }
257
+
258
+ const result = releases
259
+ .filter(
260
+ (r) =>
261
+ !r.draft &&
262
+ (allowPrerelease || !r.prerelease) &&
263
+ _releaseHasPlatformAsset(r)
264
+ )
265
+ .map((r) => {
266
+ const tag = r.tag_name || "";
267
+ // Version is tag without leading 'v'
268
+ const version = tag.startsWith("v") ? tag.slice(1) : tag;
269
+ return {
270
+ version,
271
+ tag,
272
+ date: r.published_at || null,
273
+ notesUrl: r.html_url || null,
274
+ isCurrent: currentVersion ? version === currentVersion : false,
275
+ isPinned: pinnedVersion ? tag === pinnedVersion : false,
276
+ };
277
+ });
278
+
279
+ // Cap to the most-recent installable releases; older ones remain reachable
280
+ // via the "browse all on GitHub" link in the picker.
281
+ return result.slice(0, MAX_RELEASES_SHOWN);
282
+ }
283
+
284
+ // ── installVersion ────────────────────────────────────────────────────────────
285
+
286
+ /**
287
+ * Download and install a specific tagged release, then prompt restart.
288
+ *
289
+ * Uses the "generic provider + per-release channel file" pattern:
290
+ * each GitHub release hosts its own latest*.yml which describes that exact
291
+ * version. Setting allowDowngrade=true + pointing setFeedURL at the release
292
+ * folder lets electron-updater treat it as an available update.
293
+ *
294
+ * Persists the version pin BEFORE starting the download so that even if the
295
+ * user dismisses the restart dialog, the pin is already in place for AC2.
296
+ *
297
+ * @param {string} tag The git tag, e.g. "v0.20.0"
298
+ */
299
+ const TAG_RE = /^v\d+\.\d+\.\d+(-[A-Za-z0-9.]+)?$/;
300
+
301
+ async function installVersion(tag) {
302
+ if (!autoUpdaterRef) {
303
+ throw new Error("installVersion called before auto-updater was initialised");
304
+ }
305
+ if (typeof tag !== "string" || !TAG_RE.test(tag)) {
306
+ throw new Error(
307
+ `Invalid release tag "${tag}" — expected the form "vMAJOR.MINOR.PATCH" (e.g. "v0.20.0")`
308
+ );
309
+ }
310
+ if (checkInProgress) {
311
+ throw new Error(
312
+ "An update check is already in progress — try again in a moment."
313
+ );
314
+ }
315
+
316
+ log("info", `Installing version ${tag} via targeted rollback`);
317
+
318
+ // Persist pin before touching the feed/allowDowngrade so AC2 holds even if
319
+ // the download completes but the user dismisses the restart prompt. If the
320
+ // pin can't be persisted we ABORT — proceeding would let the next launch
321
+ // silently auto-upgrade past the rollback (no silent fallbacks).
322
+ try {
323
+ _savePin(tag);
324
+ } catch (err) {
325
+ const msg = (err && err.message) || String(err);
326
+ throw new Error(
327
+ `Failed to persist version pin — rollback aborted: ${msg}`
328
+ );
329
+ }
330
+ setState({ pinnedVersion: tag });
331
+
332
+ checkInProgress = true;
333
+ try {
334
+ autoUpdaterRef.allowDowngrade = true;
335
+ autoUpdaterRef.setFeedURL({
336
+ provider: "generic",
337
+ url: `https://github.com/amd/gaia/releases/download/${tag}/`,
338
+ });
339
+
340
+ // Known limitation: the generic/tagged feed + allowDowngrade stay set
341
+ // until clearPin(). While pinned, autoDownload=false (set at init) keeps
342
+ // the scheduled check from installing anything, but that check queries the
343
+ // pinned release's own feed rather than GitHub-latest — so the "newer
344
+ // version available, you're pinned" chip won't fire until the user resumes.
345
+ // Restoring the github feed in the scheduled path risks racing the still-
346
+ // in-flight rollback download (which outlives this checkInProgress window),
347
+ // so it's intentionally deferred.
348
+
349
+ // Kick off the targeted check — will fire update-available → downloads →
350
+ // update-downloaded → our existing dialog prompts restart.
351
+ await autoUpdaterRef.checkForUpdates();
352
+ } finally {
353
+ checkInProgress = false;
354
+ }
355
+ }
356
+
357
+ // ── clearPin / resumeUpdates ──────────────────────────────────────────────────
358
+
359
+ /**
360
+ * Clear the version pin and resume normal auto-updates.
361
+ *
362
+ * Restores the GitHub provider feed, sets autoDownload=true, and clears
363
+ * allowDowngrade so the next check is the normal forward-only flow.
364
+ */
365
+ function clearPin() {
366
+ // A failed clear is non-fatal — worst case the stale pin re-pauses updates
367
+ // on next launch and the user can retry Resume. Don't block resume on it.
368
+ try {
369
+ _savePin(null);
370
+ } catch (err) {
371
+ log("warn", "Failed to clear pin file:", err && err.message);
372
+ }
373
+ setState({ pinnedVersion: null });
374
+
375
+ if (autoUpdaterRef) {
376
+ autoUpdaterRef.autoDownload = true;
377
+ autoUpdaterRef.allowDowngrade = false;
378
+ // Restore the GitHub provider (overrides any generic/tagged feed set by
379
+ // installVersion). Owner/repo must match electron-builder.yml publish block.
380
+ try {
381
+ autoUpdaterRef.setFeedURL({
382
+ provider: "github",
383
+ owner: "amd",
384
+ repo: "gaia",
385
+ });
386
+ } catch (err) {
387
+ log("warn", "Failed to restore GitHub feed on resume:", err && err.message);
388
+ }
389
+ }
390
+
391
+ log("info", "Version pin cleared — auto-updates resumed");
392
+ }
393
+
137
394
  // ── Core check ───────────────────────────────────────────────────────────────
138
395
 
139
396
  async function checkForUpdates() {
@@ -237,6 +494,18 @@ function wireAutoUpdaterEvents() {
237
494
  return;
238
495
  }
239
496
  try {
497
+ const isRollback =
498
+ state.pinnedVersion !== null &&
499
+ info &&
500
+ info.version;
501
+ const title = isRollback ? "Roll back ready" : "Update ready";
502
+ const message = isRollback
503
+ ? `GAIA ${info.version} is ready to install.`
504
+ : `GAIA ${info && info.version ? info.version : ""} has been downloaded.`;
505
+ const detail = isRollback
506
+ ? `The app will restart into v${info.version}. Your chat history will be preserved.`
507
+ : "Restart the app to apply the update. Your chat history will be preserved.";
508
+
240
509
  const choice = await electronApi.dialog.showMessageBox(
241
510
  mainWindowRef && !mainWindowRef.isDestroyed() ? mainWindowRef : null,
242
511
  {
@@ -244,16 +513,13 @@ function wireAutoUpdaterEvents() {
244
513
  buttons: ["Restart now", "Later"],
245
514
  defaultId: 0,
246
515
  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.",
516
+ title,
517
+ message,
518
+ detail,
251
519
  }
252
520
  );
253
521
  if (choice && choice.response === 0) {
254
522
  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
523
  autoUpdaterRef.quitAndInstall(false, true);
258
524
  } else {
259
525
  log("info", "User deferred restart — will install on next quit");
@@ -281,21 +547,37 @@ function registerIpcHandlers() {
281
547
  await checkForUpdates();
282
548
  return getState();
283
549
  });
550
+ ipcMain.handle("gaia:update:list-releases", async () => {
551
+ return listReleases();
552
+ });
553
+ ipcMain.handle("gaia:update:install-version", async (_event, tag) => {
554
+ await installVersion(tag);
555
+ return getState();
556
+ });
557
+ ipcMain.handle("gaia:update:resume", async () => {
558
+ clearPin();
559
+ return getState();
560
+ });
561
+
284
562
  ipcHandlersRegistered = true;
285
563
  }
286
564
 
287
565
  function unregisterIpcHandlers() {
288
566
  if (!ipcHandlersRegistered || !electronApi || !electronApi.ipcMain) return;
289
567
  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
568
+ const channels = [
569
+ "gaia:update:get-status",
570
+ "gaia:update:check",
571
+ "gaia:update:list-releases",
572
+ "gaia:update:install-version",
573
+ "gaia:update:resume",
574
+ ];
575
+ for (const ch of channels) {
576
+ try {
577
+ ipcMain.removeHandler(ch);
578
+ } catch {
579
+ // ignore
580
+ }
299
581
  }
300
582
  ipcHandlersRegistered = false;
301
583
  }
@@ -329,6 +611,14 @@ function init(mainWindow) {
329
611
  return;
330
612
  }
331
613
 
614
+ // Read the persisted pin before touching electron-updater so pin gating
615
+ // is applied to the very first autoDownload flag set below.
616
+ const savedPin = _loadPin();
617
+ if (savedPin) {
618
+ state.pinnedVersion = savedPin;
619
+ log("info", `Found persisted version pin: ${savedPin} — autoDownload will be paused`);
620
+ }
621
+
332
622
  // Lazy-load Electron and electron-updater. Any failure here is logged
333
623
  // and the updater stays in `idle` — we never crash the app.
334
624
  try {
@@ -348,8 +638,16 @@ function init(mainWindow) {
348
638
  electronApi = {
349
639
  dialog: electron.dialog,
350
640
  ipcMain: electron.ipcMain,
641
+ app: electron.app,
351
642
  };
352
643
  autoUpdaterRef = electronUpdater.autoUpdater;
644
+
645
+ // Record the running version for getState() / listReleases().
646
+ try {
647
+ state.currentVersion = electron.app.getVersion() || null;
648
+ } catch {
649
+ // ignore
650
+ }
353
651
  } catch (err) {
354
652
  log("error", "Failed to load electron-updater:", err && err.message);
355
653
  setState({
@@ -359,15 +657,14 @@ function init(mainWindow) {
359
657
  return;
360
658
  }
361
659
 
362
- // Configure electron-updater. Provider config comes from
363
- // electron-builder.yml (`publish:` block) at build time; we only set
364
- // behavioral flags here.
660
+ // Configure electron-updater.
365
661
  try {
366
- autoUpdaterRef.autoDownload = true;
662
+ // When pinned, suppress autoDownload so the scheduled forward-only check
663
+ // cannot silently download/install a newer version (AC2).
664
+ autoUpdaterRef.autoDownload = savedPin ? false : true;
367
665
  autoUpdaterRef.autoInstallOnAppQuit = true;
368
666
  autoUpdaterRef.disableWebInstaller = true;
369
667
  autoUpdaterRef.allowDowngrade = false;
370
- // Allow pre-releases only if explicitly opted in (for beta channels later).
371
668
  autoUpdaterRef.allowPrerelease =
372
669
  process.env.GAIA_UPDATE_PRERELEASE === "1";
373
670
 
@@ -433,5 +730,8 @@ module.exports = {
433
730
  destroy,
434
731
  checkForUpdates,
435
732
  getState,
733
+ listReleases,
734
+ installVersion,
735
+ clearPin,
436
736
  STATES,
437
737
  };