@amd-gaia/agent-ui 0.25.3 → 0.25.4-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.
Files changed (57) hide show
  1. package/bin/gaia-ui.cjs +2 -23
  2. package/dist/assets/AdvancedSettings-DXf2pogI.js +1 -0
  3. package/dist/assets/ConnectorsPane-CzLYXGMl.css +1 -0
  4. package/dist/assets/ConnectorsPane-R1yvKMNT.js +1 -0
  5. package/dist/assets/DocumentLibrary-CM6o8j2T.css +1 -0
  6. package/dist/assets/DocumentLibrary-DSOiP7Ft.js +4 -0
  7. package/dist/assets/FileBrowser-BlVSPjD2.js +4 -0
  8. package/dist/assets/FileBrowser-Cna3BsT7.css +1 -0
  9. package/dist/assets/MemoryDashboard-D_ZVZbS_.js +1 -0
  10. package/dist/assets/MemoryDashboard-DwrWAXi6.css +1 -0
  11. package/dist/assets/MobileAccessModal-BkVDm3YG.css +1 -0
  12. package/dist/assets/MobileAccessModal-CPjDeIIk.js +2 -0
  13. package/dist/assets/ScheduleManager-CjHJ4NbD.css +1 -0
  14. package/dist/assets/ScheduleManager-D1DOYVQo.js +1 -0
  15. package/dist/assets/api-Co-B9E0t.js +4 -0
  16. package/dist/assets/index-CMVmIBZ8.js +78 -0
  17. package/dist/assets/index-Y93uvqe7.css +1 -0
  18. package/dist/assets/useT-tWXlG-cT.js +3 -0
  19. package/dist/assets/zap-D-WCZ6dX.js +1 -0
  20. package/dist/icons/apple-touch-icon.png +0 -0
  21. package/dist/icons/icon-192.png +0 -0
  22. package/dist/icons/icon-512.png +0 -0
  23. package/dist/icons/icon-maskable-192.png +0 -0
  24. package/dist/icons/icon-maskable-512.png +0 -0
  25. package/dist/index.html +8 -3
  26. package/dist/manifest.json +16 -0
  27. package/dist/sw.js +49 -0
  28. package/main-safety-net.cjs +106 -8
  29. package/main.cjs +145 -162
  30. package/package.json +5 -3
  31. package/preload.cjs +19 -9
  32. package/services/agent-seeder.cjs +22 -13
  33. package/services/auto-updater.cjs +780 -216
  34. package/services/backend-installer.cjs +523 -365
  35. package/services/engine-install.cjs +233 -0
  36. package/services/index-query.cjs +7 -1
  37. package/services/lock-holders.cjs +200 -0
  38. package/services/quit-guard.cjs +36 -3
  39. package/services/tray-manager.cjs +12 -7
  40. package/dist/assets/AdvancedSettings-DERzP9Jc.js +0 -1
  41. package/dist/assets/ConnectorsPane-Dzu2DRTc.css +0 -1
  42. package/dist/assets/ConnectorsPane-s0fFxmus.js +0 -1
  43. package/dist/assets/DocumentLibrary-CIKp6qld.css +0 -1
  44. package/dist/assets/DocumentLibrary-DqapN_Tr.js +0 -4
  45. package/dist/assets/FileBrowser-Dil1lZJ4.css +0 -1
  46. package/dist/assets/FileBrowser-vAJV41RT.js +0 -4
  47. package/dist/assets/MemoryDashboard-CP5wVWkY.js +0 -1
  48. package/dist/assets/MemoryDashboard-Mo721-BI.css +0 -1
  49. package/dist/assets/MobileAccessModal-B31mPs50.js +0 -2
  50. package/dist/assets/MobileAccessModal-DuVy9F1u.css +0 -1
  51. package/dist/assets/ScheduleManager-D5c4gVIt.css +0 -1
  52. package/dist/assets/ScheduleManager-b0gTAf2R.js +0 -2
  53. package/dist/assets/apiBase-CS6zLsJY.js +0 -1
  54. package/dist/assets/index-C-EM_5yl.css +0 -1
  55. package/dist/assets/index-Cae5Fje-.js +0 -78
  56. package/dist/assets/zap-BogJn9oN.js +0 -1
  57. package/services/backend-installer-progress-dialog.cjs +0 -510
@@ -24,7 +24,7 @@
24
24
  * - Auto-update is OFF by default (`autoUpdate` in ~/.gaia/update-config.json,
25
25
  * shared with the Terminal UI). Off, a found update is only offered: the
26
26
  * renderer's "Update now" calls downloadUpdate(). On, it downloads silently.
27
- * - On `update-downloaded`, show a native dialog: "Update ready — restart?"
27
+ * - On `update-downloaded`, the chip offers the restart; nothing interrupts
28
28
  * - Renderer integration via IPC channel `gaia:update:status`
29
29
  * - Disabled entirely via GAIA_DISABLE_UPDATE=1 env var (CI / dev / corp)
30
30
  * - Rollback: listReleases() → installVersion(tag) → pin persisted to
@@ -38,6 +38,7 @@
38
38
  * - listReleases() → fetch available releases from GitHub
39
39
  * - installVersion(tag) → downgrade/install a specific tagged release
40
40
  * - clearPin() → resume auto-updates (clear the version pin)
41
+ * - revertUpdate() → go back to the version before the last update
41
42
  * - setAutoUpdate(enabled) → persist the auto-update preference
42
43
  * - downloadUpdate() → download the offered update ("Update now")
43
44
  * - restartToInstall() → restart into a downloaded update
@@ -56,6 +57,8 @@
56
57
  const path = require("path");
57
58
  const fs = require("fs");
58
59
  const os = require("os");
60
+ const crypto = require("crypto");
61
+ const zlib = require("zlib");
59
62
 
60
63
  // ── Constants ────────────────────────────────────────────────────────────────
61
64
 
@@ -70,13 +73,6 @@ const UPDATE_CONFIG_PATH = path.join(os.homedir(), ".gaia", "update-config.json"
70
73
  // that split is the A6/A7 resolution (#1724).
71
74
  const DEFAULT_CHANNEL = "latest";
72
75
 
73
- // per_page=100 is the GitHub API max for a single page; we fetch broadly so
74
- // draft/prerelease/platform filtering still yields a full page of installable
75
- // releases before the display cap below. >100 releases would need pagination
76
- // (follow the Link rel="next" header). Not built yet.
77
- const GITHUB_API_RELEASES =
78
- "https://api.github.com/repos/amd/gaia/releases?per_page=100";
79
-
80
76
  // The picker shows only the N most-recent installable releases (rolling back
81
77
  // more than a few versions is rare). Older versions stay reachable via the
82
78
  // "browse all on GitHub" link in the picker, so this display cap never makes a
@@ -84,21 +80,47 @@ const GITHUB_API_RELEASES =
84
80
  const MAX_RELEASES_SHOWN = 10;
85
81
 
86
82
  const RELEASES_PAGE = "https://github.com/amd/gaia/releases";
83
+ const RELEASE_DOWNLOADS = `${RELEASES_PAGE}/download`;
84
+
85
+ // The atom feed and download URLs don't count against the REST API's 60
86
+ // anonymous calls an hour, which one busy network can use up for everyone.
87
+ const RELEASES_ATOM = `${RELEASES_PAGE}.atom`;
88
+ const PICKER_FEED_PAGES = 3;
89
+
90
+ // Where a restart notes the update it started: a silent install that fails
91
+ // shows nothing, so the next launch is what finds out.
92
+ const INSTALL_ATTEMPT_PATH = path.join(os.homedir(), ".gaia", "update-attempt.json");
93
+ const PREVIOUS_VERSION_KEY = "previousAppVersion";
94
+ const LAUNCHES_SINCE_UPDATE_KEY = "launchesSinceUpdate";
95
+ // Same cap as the TUI's sumsAtOnce, to go easy on proxies.
96
+ const RELEASE_CHECKS_AT_ONCE = 8;
97
+ // A backend that dies weeks after an update isn't the update's fault.
98
+ const START_FAILURE_REVERT_LAUNCHES = 3;
99
+
100
+ // The installer removes the old version before unpacking the new one.
101
+ const MIN_INSTALL_FREE_BYTES = 1024 * 1024 * 1024;
87
102
 
88
103
  // A black-holed connection must fail, not leave "Checking…" up forever.
89
104
  const GITHUB_TIMEOUT_MS = 20 * 1000;
90
105
 
91
- /**
92
- * GET the release list within GITHUB_TIMEOUT_MS. Resolves to the parsed JSON;
93
- * rejects with `.kind` set to "timeout", "network", "http" (with `.status`)
94
- * or "garbled", so each caller can word its own message.
95
- */
96
- async function _fetchReleases() {
97
- let resp;
106
+ /** The generic feed for one release's assets. */
107
+ function _releaseFeed(tag, extra = {}) {
108
+ return {
109
+ provider: "generic",
110
+ url: `${RELEASE_DOWNLOADS}/${tag}/`,
111
+ // GitHub answers multi-range requests 501, which loses the delta download.
112
+ useMultipleRangeRequest: false,
113
+ ...extra,
114
+ };
115
+ }
116
+
117
+ /** fetch() with a timeout; rejects with `.kind` "timeout" or "network". */
118
+ async function _githubFetch(url, options = {}) {
98
119
  try {
99
- resp = await fetch(GITHUB_API_RELEASES, {
100
- headers: { Accept: "application/vnd.github+json", "User-Agent": "GAIA-Agent-UI" },
120
+ return await fetch(url, {
121
+ headers: { "User-Agent": "GAIA-Agent-UI" },
101
122
  signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
123
+ ...options,
102
124
  });
103
125
  } catch (err) {
104
126
  const timedOut = err && (err.name === "TimeoutError" || err.name === "AbortError");
@@ -106,17 +128,147 @@ async function _fetchReleases() {
106
128
  kind: timedOut ? "timeout" : "network",
107
129
  });
108
130
  }
109
- if (!resp.ok) {
110
- throw Object.assign(new Error(`HTTP ${resp.status}`), { kind: "http", status: resp.status });
131
+ }
132
+
133
+ /**
134
+ * A delta update diffs the installer electron-updater cached at install time
135
+ * against the new one, using the running version's blockmap. It guesses that
136
+ * blockmap's URL by swapping version strings inside the new one, which misses
137
+ * every release candidate's tag ("v0.25.4-rc3" holds no "0.25.4-rc.3"), and a
138
+ * fresh install has no blockmap cached. Without one the update downloads in
139
+ * full, so fetch it from the running version's own release ahead of time.
140
+ */
141
+ async function _seedInstalledBlockmap() {
142
+ if (process.platform !== "win32" || typeof autoUpdaterRef.getOrCreateDownloadHelper !== "function") {
143
+ return;
144
+ }
145
+ const version = state.currentVersion;
146
+ const { cacheDir } = await autoUpdaterRef.getOrCreateDownloadHelper();
147
+ const installer = path.join(cacheDir, "installer.exe");
148
+ const blockmap = path.join(cacheDir, "current.blockmap");
149
+ const seededFor = path.join(cacheDir, "gaia-blockmap-version");
150
+ if (!version || !fs.existsSync(installer)) return;
151
+ const seeded = _readText(seededFor);
152
+ if (seeded === `${version}:not-this-release`) return;
153
+ if (fs.existsSync(blockmap) && seeded === version) return;
154
+
155
+ const base = `${RELEASE_DOWNLOADS}/${_tagForVersion(version)}`;
156
+ const manifest = await _githubFetch(`${base}/${_platformManifest()}`);
157
+ if (!manifest.ok) throw _httpError(manifest.status);
158
+ const yml = await manifest.text();
159
+ const file = /^path:\s*(\S+)/m.exec(yml);
160
+ const sha512 = /^sha512:\s*(\S+)/m.exec(yml);
161
+ if (!file || !sha512) throw new Error(`${_platformManifest()} for ${version} names no installer`);
162
+ // The cached installer must be this release's, or the diff comes out wrong.
163
+ if ((await _sha512File(installer)) !== sha512[1]) {
164
+ log("info", `The cached installer isn't GAIA ${version}'s release file: the next update downloads in full`);
165
+ fs.writeFileSync(seededFor, `${version}:not-this-release`);
166
+ return;
111
167
  }
168
+ const resp = await _githubFetch(`${base}/${file[1]}.blockmap`);
169
+ if (!resp.ok) throw _httpError(resp.status);
170
+ const data = Buffer.from(await resp.arrayBuffer());
171
+ JSON.parse(zlib.gunzipSync(data).toString());
172
+ fs.writeFileSync(blockmap, data);
173
+ fs.writeFileSync(seededFor, version);
174
+ log("info", `Cached GAIA ${version}'s blockmap, so the next update downloads only what changed`);
175
+ }
176
+
177
+ function _readText(file) {
112
178
  try {
113
- return await resp.json();
114
- } catch (err) {
115
- const timedOut = err && (err.name === "TimeoutError" || err.name === "AbortError");
116
- throw Object.assign(new Error((err && err.message) || String(err)), {
117
- kind: timedOut ? "timeout" : "garbled",
179
+ return fs.readFileSync(file, "utf8").trim();
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function _sha512File(file) {
186
+ return new Promise((resolve, reject) => {
187
+ const hash = crypto.createHash("sha512");
188
+ fs.createReadStream(file)
189
+ .on("error", reject)
190
+ .on("data", (chunk) => hash.update(chunk))
191
+ .on("end", () => resolve(hash.digest("base64")));
192
+ });
193
+ }
194
+
195
+ function _httpError(status) {
196
+ return Object.assign(new Error(`HTTP ${status}`), { kind: "http", status });
197
+ }
198
+
199
+ /** One page of releases.atom as [{tag_name, published_at, html_url, prerelease}]. */
200
+ async function _fetchFeedPage(after) {
201
+ const url = after ? `${RELEASES_ATOM}?after=${encodeURIComponent(after)}` : RELEASES_ATOM;
202
+ const resp = await _githubFetch(url);
203
+ if (!resp.ok) throw _httpError(resp.status);
204
+ const xml = await resp.text();
205
+ if (!xml.includes("<feed")) {
206
+ throw Object.assign(new Error("not an atom feed"), { kind: "garbled" });
207
+ }
208
+ const releases = [];
209
+ for (const [, entry] of xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)) {
210
+ const id = /<id>[^<]*\/([^/<]+)<\/id>/.exec(entry);
211
+ if (!id) continue;
212
+ const updated = /<updated>([^<]+)<\/updated>/.exec(entry);
213
+ releases.push({
214
+ tag_name: id[1],
215
+ published_at: updated ? updated[1] : null,
216
+ html_url: `${RELEASES_PAGE}/tag/${id[1]}`,
217
+ prerelease: id[1].includes("-"),
118
218
  });
119
219
  }
220
+ return releases;
221
+ }
222
+
223
+ /**
224
+ * The newest releases from the atom feed, plus the newest final release, which
225
+ * a run of candidates can push off the feed's first page. Rejects with `.kind`
226
+ * set to "timeout", "network", "http" (with `.status`) or "garbled", so each
227
+ * caller can word its own message.
228
+ */
229
+ async function _fetchReleases(pages = 1) {
230
+ const releases = [];
231
+ let after = null;
232
+ for (let page = 0; page < pages; page++) {
233
+ const batch = await _fetchFeedPage(after);
234
+ releases.push(...batch);
235
+ if (batch.length === 0) break;
236
+ after = batch[batch.length - 1].tag_name;
237
+ }
238
+ const latest = await _githubFetch(`${RELEASES_PAGE}/latest/download/${_platformManifest()}`);
239
+ if (latest.ok) {
240
+ const version = /^version:\s*(\S+)/m.exec(await latest.text());
241
+ const tag = version && `v${version[1].replace(/^v/, "")}`;
242
+ if (tag && !releases.some((r) => r.tag_name === tag)) {
243
+ releases.push({
244
+ tag_name: tag,
245
+ published_at: null,
246
+ html_url: `${RELEASES_PAGE}/tag/${tag}`,
247
+ prerelease: false,
248
+ });
249
+ }
250
+ } else if (latest.status !== 404) {
251
+ throw _httpError(latest.status);
252
+ }
253
+ return releases;
254
+ }
255
+
256
+ /**
257
+ * Whether `tag` carries this platform's manifest and the installer it names.
258
+ * A release is listed before its assets finish uploading, and macOS manifests
259
+ * can name a zip that was never attached, so the file itself is checked.
260
+ */
261
+ async function _releaseHasUpdate(tag) {
262
+ const base = `${RELEASE_DOWNLOADS}/${tag}`;
263
+ const manifest = await _githubFetch(`${base}/${_platformManifest()}`);
264
+ if (manifest.status === 404) return false;
265
+ if (!manifest.ok) throw _httpError(manifest.status);
266
+ const path = /^path:\s*(\S+)/m.exec(await manifest.text());
267
+ if (!path) return false;
268
+ const installer = await _githubFetch(`${base}/${path[1]}`, { method: "HEAD" });
269
+ if (installer.status === 404) return false;
270
+ if (!installer.ok) throw _httpError(installer.status);
271
+ return true;
120
272
  }
121
273
 
122
274
  /** The user-facing sentence for a _fetchReleases failure while `doing`. */
@@ -163,6 +315,8 @@ const state = {
163
315
  error: null,
164
316
  currentVersion: null,
165
317
  pinnedVersion: null,
318
+ previousVersion: null,
319
+ startFailed: false,
166
320
  autoUpdate: false,
167
321
  // Offer release candidates too; off by default. A release-candidate build
168
322
  // is offered newer candidates without it.
@@ -170,12 +324,43 @@ const state = {
170
324
  // update-config.json could not be read; kept apart from `error` so a later
171
325
  // check's status never hides it.
172
326
  configError: null,
327
+ // The last restart didn't install; stays until an install works.
328
+ installFailed: null,
329
+ // Why the last unasked check or download failed; Settings shows it quietly.
330
+ lastCheckError: null,
331
+ // The update has downloaded and its backend is being fetched before the restart.
332
+ preparing: false,
173
333
  };
174
334
 
175
335
  let mainWindowRef = null;
176
336
  /** Asks before an update restart would cut off running work (set by init). */
177
337
  let confirmRestartRef = null;
338
+ // Put the update's backend in the local cache before the restart (set by init).
339
+ let prefetchBackendRef = null;
340
+ let isBackendPrefetchedRef = null;
178
341
  let checkInProgress = false;
342
+ // True while a check nobody asked for runs, so its failure stays out of the UI.
343
+ let backgroundCheck = false;
344
+ // The last check was asked for, so the download it starts may report failure.
345
+ let askedCheck = false;
346
+ // A routine check is running with this version already downloaded.
347
+ let heldDownloadVersion = null;
348
+ // The downloaded update was given up on, so quitting must not install it.
349
+ let abandonedDownload = false;
350
+ // The silent install failed, so the next try shows the installer's window.
351
+ let wizardNextInstall = false;
352
+ // Picking a version asks for the restart too, so no second click is needed.
353
+ let restartWhenDownloaded = false;
354
+ // Settles once the restart a picked version asked for has run or been refused.
355
+ let pendingRestart = null;
356
+ let restartRefusal = null;
357
+ // Settles once the last update-downloaded is handled, prefetch and restart
358
+ // included. electron-updater doesn't wait for its listeners, so callers do.
359
+ let downloadHandled = null;
360
+ // Bumped when a download is given up on, so a prefetch that outlives it can tell.
361
+ let downloadEpoch = 0;
362
+ // A prefetch per version, so a repeat download waits on it instead of racing it.
363
+ const prefetching = new Map();
179
364
  // The user asked for this download, so it may install on quit with auto-update off.
180
365
  let installRequested = false;
181
366
  // The file was unreadable at launch, so a pin it holds was never loaded.
@@ -275,10 +460,7 @@ function _refreshPreferences() {
275
460
  log("info", `Settings file readable again — honouring its pin to ${pin}`);
276
461
  setState({ pinnedVersion: pin });
277
462
  if (autoUpdaterRef) {
278
- autoUpdaterRef.setFeedURL({
279
- provider: "generic",
280
- url: `https://github.com/amd/gaia/releases/download/${pin}/`,
281
- });
463
+ autoUpdaterRef.setFeedURL(_releaseFeed(pin));
282
464
  }
283
465
  }
284
466
  }
@@ -288,7 +470,8 @@ function _refreshPreferences() {
288
470
  function _applyInstallPolicy() {
289
471
  if (!autoUpdaterRef) return;
290
472
  autoUpdaterRef.autoDownload = state.autoUpdate && !state.pinnedVersion;
291
- autoUpdaterRef.autoInstallOnAppQuit = state.autoUpdate || installRequested;
473
+ autoUpdaterRef.autoInstallOnAppQuit =
474
+ (state.autoUpdate || installRequested) && !abandonedDownload && !wizardNextInstall && !state.preparing;
292
475
  autoUpdaterRef.allowPrerelease = _prereleasesAllowed();
293
476
  }
294
477
 
@@ -314,6 +497,42 @@ function _savePin(pinnedVersion) {
314
497
  _writeConfig({ pinnedVersion });
315
498
  }
316
499
 
500
+ /**
501
+ * An error the UI shows in the user's language: `code` names the message
502
+ * update.err.<code> and `params` fills it. The message is the English, for logs.
503
+ */
504
+ function _userError(code, params, message) {
505
+ return Object.assign(new Error(message), { code, params: params || {} });
506
+ }
507
+
508
+ // Marks an error message as a coded one, for the renderer to translate.
509
+ const USER_ERROR_MARK = "gaia-update-error:";
510
+
511
+ // Codes the UI has an update.err.<code> message for; any other code is a cause.
512
+ const USER_ERROR_CODES = new Set([
513
+ "checkBusy",
514
+ "configBusy",
515
+ "diskSpace",
516
+ "downloadFailed",
517
+ "failedWith",
518
+ "noEarlier",
519
+ "noInstaller",
520
+ "offline",
521
+ "pinFailed",
522
+ "saveFailed",
523
+ ]);
524
+
525
+ function _isUserCode(code) {
526
+ return USER_ERROR_CODES.has(code);
527
+ }
528
+
529
+ /** IPC keeps only an error's message, so the code travels inside it. */
530
+ function _forRenderer(err) {
531
+ if (!err || !_isUserCode(err.code)) return err;
532
+ const { code, params, note } = err;
533
+ return new Error(`${USER_ERROR_MARK}${JSON.stringify({ code, params, note, message: err.message })}`);
534
+ }
535
+
317
536
  /**
318
537
  * Merge `patch` into update-config.json. Propagates write errors so a failed
319
538
  * pin write can abort a rollback (AC2 must not be silently broken). Callers
@@ -414,7 +633,9 @@ function _withConfigLock(fn) {
414
633
  `${path.dirname(UPDATE_CONFIG_PATH)}.`
415
634
  );
416
635
  }
417
- throw new Error(
636
+ throw _userError(
637
+ "configBusy",
638
+ {},
418
639
  `${UPDATE_CONFIG_PATH} is busy (another GAIA app holds ${lock}) — try again in a moment.`
419
640
  );
420
641
  }
@@ -508,6 +729,19 @@ function _platformManifest() {
508
729
  return process.arch === "arm64" ? "latest-linux-arm64.yml" : "latest-linux.yml";
509
730
  }
510
731
 
732
+ /** GAIA releases on this install's channel, newest first. */
733
+ function _channelReleases(releases) {
734
+ const allowPrerelease = _prereleasesAllowed();
735
+ return releases
736
+ .filter(
737
+ (r) =>
738
+ TAG_RE.test(r.tag_name || "") &&
739
+ !Number.isNaN(_compareVersions(r.tag_name, "0.0.0")) &&
740
+ (allowPrerelease || !r.prerelease)
741
+ )
742
+ .sort((a, b) => _compareVersions(b.tag_name, a.tag_name));
743
+ }
744
+
511
745
  /**
512
746
  * Point the feed at the newest GitHub release on this install's channel that
513
747
  * carries this platform's installer and update manifest; returns its tag.
@@ -515,31 +749,19 @@ function _platformManifest() {
515
749
  * NO_CHANNEL state, when no release has an update for this platform.
516
750
  */
517
751
  async function _applyGithubFeed() {
518
- const allowPrerelease = _prereleasesAllowed();
519
- let releases;
752
+ const manifest = _platformManifest();
753
+ let tag = null;
520
754
  try {
521
- releases = await _fetchReleases();
755
+ for (const release of _channelReleases(await _fetchReleases())) {
756
+ if (await _releaseHasUpdate(release.tag_name)) {
757
+ tag = release.tag_name;
758
+ break;
759
+ }
760
+ }
522
761
  } catch (err) {
523
762
  throw new Error(_releasesErrorMessage(err, "check for updates"));
524
763
  }
525
- if (!Array.isArray(releases)) {
526
- throw new Error(
527
- `GitHub sent a response GAIA couldn't read when it tried to check for updates (not a list) — try again later, or see ${RELEASES_PAGE}.`
528
- );
529
- }
530
- const manifest = _platformManifest();
531
- const candidates = releases.filter(
532
- (r) =>
533
- r &&
534
- !r.draft &&
535
- TAG_RE.test(r.tag_name || "") &&
536
- !Number.isNaN(_compareVersions(r.tag_name, "0.0.0")) &&
537
- (allowPrerelease || !r.prerelease) &&
538
- _releaseHasPlatformAsset(r) &&
539
- Array.isArray(r.assets) &&
540
- r.assets.some((a) => a && a.name === manifest)
541
- );
542
- if (candidates.length === 0) {
764
+ if (!tag) {
543
765
  const error =
544
766
  `No GAIA release publishes a desktop update for this platform yet (it needs ${manifest} ` +
545
767
  `and its installer), so GAIA can't update itself here — download an installer from ${RELEASES_PAGE}.`;
@@ -547,13 +769,7 @@ async function _applyGithubFeed() {
547
769
  log("warn", error);
548
770
  return null;
549
771
  }
550
- candidates.sort((a, b) => _compareVersions(b.tag_name, a.tag_name));
551
- const tag = candidates[0].tag_name;
552
- autoUpdaterRef.setFeedURL({
553
- provider: "generic",
554
- url: `https://github.com/amd/gaia/releases/download/${tag}/`,
555
- channel: DEFAULT_CHANNEL,
556
- });
772
+ autoUpdaterRef.setFeedURL(_releaseFeed(tag, { channel: DEFAULT_CHANNEL }));
557
773
  log("info", `Update feed set from GitHub: ${tag}`);
558
774
  return tag;
559
775
  }
@@ -587,23 +803,6 @@ function isDisabled() {
587
803
  return process.env.GAIA_DISABLE_UPDATE === "1";
588
804
  }
589
805
 
590
- // ── Platform asset detection ─────────────────────────────────────────────────
591
-
592
- function _platformAssetPattern() {
593
- if (process.platform === "win32") return /setup\.exe$/i;
594
- if (process.platform === "darwin") return /\.zip$/i;
595
- // Linux: AppImage (deb is apt-managed, electron-updater can't downgrade it)
596
- return process.arch === "arm64" ? /(arm64|aarch64)\.AppImage$/i : /(x86_64|amd64)\.AppImage$/i;
597
- }
598
-
599
- function _releaseHasPlatformAsset(release) {
600
- const pattern = _platformAssetPattern();
601
- return (
602
- Array.isArray(release.assets) &&
603
- release.assets.some((a) => a && a.name && pattern.test(a.name))
604
- );
605
- }
606
-
607
806
  // ── listReleases ──────────────────────────────────────────────────────────────
608
807
 
609
808
  /**
@@ -637,42 +836,34 @@ async function listReleases() {
637
836
  }
638
837
  }
639
838
 
640
- let releases;
839
+ let installable;
641
840
  try {
642
- releases = await _fetchReleases();
841
+ // Twice the display cap, so releases with no update here still fill the list.
842
+ const candidates = _channelReleases(await _fetchReleases(PICKER_FEED_PAGES)).slice(
843
+ 0,
844
+ MAX_RELEASES_SHOWN * 2
845
+ );
846
+ const ready = await _mapLimited(candidates, RELEASE_CHECKS_AT_ONCE, (r) =>
847
+ _releaseHasUpdate(r.tag_name)
848
+ );
849
+ installable = candidates.filter((_, i) => ready[i]).slice(0, MAX_RELEASES_SHOWN);
643
850
  } catch (err) {
644
851
  return { error: _releasesErrorMessage(err, "list releases") };
645
852
  }
646
- if (!Array.isArray(releases)) {
853
+
854
+ return installable.map((r) => {
855
+ const tag = r.tag_name;
856
+ // Version is tag without leading 'v'
857
+ const version = tag.startsWith("v") ? tag.slice(1) : tag;
647
858
  return {
648
- error: `GitHub sent a response GAIA couldn't read when it tried to list releases (not a list) — try again later, or see ${RELEASES_PAGE}.`,
859
+ version,
860
+ tag,
861
+ date: r.published_at,
862
+ notesUrl: r.html_url,
863
+ isCurrent: currentVersion ? _compareVersions(version, currentVersion) === 0 : false,
864
+ isPinned: pinnedVersion ? tag === pinnedVersion : false,
649
865
  };
650
- }
651
-
652
- const result = releases
653
- .filter(
654
- (r) =>
655
- !r.draft &&
656
- (allowPrerelease || !r.prerelease) &&
657
- _releaseHasPlatformAsset(r)
658
- )
659
- .map((r) => {
660
- const tag = r.tag_name || "";
661
- // Version is tag without leading 'v'
662
- const version = tag.startsWith("v") ? tag.slice(1) : tag;
663
- return {
664
- version,
665
- tag,
666
- date: r.published_at || null,
667
- notesUrl: r.html_url || null,
668
- isCurrent: currentVersion ? version === currentVersion : false,
669
- isPinned: pinnedVersion ? tag === pinnedVersion : false,
670
- };
671
- });
672
-
673
- // Cap to the most-recent installable releases; older ones remain reachable
674
- // via the "browse all on GitHub" link in the picker.
675
- return result.slice(0, MAX_RELEASES_SHOWN);
866
+ });
676
867
  }
677
868
 
678
869
  // ── installVersion ────────────────────────────────────────────────────────────
@@ -686,7 +877,7 @@ async function listReleases() {
686
877
  * folder lets electron-updater treat it as an available update.
687
878
  *
688
879
  * Persists the version pin BEFORE starting the download so that even if the
689
- * user dismisses the restart dialog, the pin is already in place for AC2.
880
+ * user puts off the restart, the pin is already in place for AC2.
690
881
  *
691
882
  * @param {string} tag The git tag, e.g. "v0.20.0"
692
883
  */
@@ -702,7 +893,9 @@ async function installVersion(tag) {
702
893
  );
703
894
  }
704
895
  if (checkInProgress) {
705
- throw new Error(
896
+ throw _userError(
897
+ "checkBusy",
898
+ {},
706
899
  "An update check is already in progress — try again in a moment."
707
900
  );
708
901
  }
@@ -718,22 +911,23 @@ async function installVersion(tag) {
718
911
  _savePin(tag);
719
912
  } catch (err) {
720
913
  const msg = (err && err.message) || String(err);
721
- throw new Error(
914
+ if (err && err.code === "configBusy") throw err;
915
+ throw _userError(
916
+ "pinFailed",
917
+ { path: UPDATE_CONFIG_PATH, reason: msg },
722
918
  `Failed to persist version pin — rollback aborted: ${msg}`
723
919
  );
724
920
  }
725
- setState({ pinnedVersion: tag });
921
+ setState({ pinnedVersion: tag, previousVersion: null, startFailed: false });
726
922
  // A pin always stops background downloads; this one is downloaded below.
727
923
  installRequested = true;
924
+ restartWhenDownloaded = true;
728
925
  _applyInstallPolicy();
729
926
 
730
927
  checkInProgress = true;
731
928
  try {
732
929
  autoUpdaterRef.allowDowngrade = true;
733
- autoUpdaterRef.setFeedURL({
734
- provider: "generic",
735
- url: `https://github.com/amd/gaia/releases/download/${tag}/`,
736
- });
930
+ autoUpdaterRef.setFeedURL(_releaseFeed(tag));
737
931
 
738
932
  // Known limitation: the generic/tagged feed + allowDowngrade stay set
739
933
  // until clearPin(). While pinned, autoDownload=false (set at init) keeps
@@ -746,26 +940,42 @@ async function installVersion(tag) {
746
940
 
747
941
  // Targeted check → update-available. autoDownload is off (default, and
748
942
  // always while pinned), so download explicitly; update-downloaded then
749
- // prompts the restart.
943
+ // restarts into it.
750
944
  await autoUpdaterRef.checkForUpdates();
751
945
  if (state.status !== STATES.AVAILABLE) {
752
- throw new Error(
946
+ throw _userError(
947
+ "noInstaller",
948
+ { tag, url: RELEASES_PAGE },
753
949
  `GAIA ${tag} has no installer for this platform at its release feed` +
754
950
  (state.error ? `: ${state.error}` : ".") +
755
- " Download it from https://github.com/amd/gaia/releases instead."
951
+ ` Download it from ${RELEASES_PAGE} instead.`
756
952
  );
757
953
  }
758
954
  setState({ status: STATES.DOWNLOADING, progress: 0, error: null });
955
+ downloadHandled = null;
759
956
  await autoUpdaterRef.downloadUpdate();
760
957
  } catch (err) {
958
+ restartWhenDownloaded = false;
761
959
  // Don't leave the app pinned to a version that never installed.
762
960
  const pinNote = _restorePinAfterFailedInstall(previousPin, tag);
763
961
  const msg = (err && err.message) || String(err);
764
962
  setState({ status: STATES.ERROR, error: msg });
765
- throw new Error(`${msg} ${pinNote}`);
963
+ // A coded cause keeps its code; any other shows its own text.
964
+ const coded = err && _isUserCode(err.code);
965
+ throw Object.assign(new Error(`${msg} ${pinNote.message}`), {
966
+ code: coded ? err.code : "failedWith",
967
+ params: coded ? err.params : { reason: msg },
968
+ note: { code: pinNote.code, params: pinNote.params },
969
+ });
766
970
  } finally {
767
971
  checkInProgress = false;
768
972
  }
973
+ await downloadHandled;
974
+ if (restartRefusal) {
975
+ const refusal = restartRefusal;
976
+ restartRefusal = null;
977
+ throw refusal;
978
+ }
769
979
  }
770
980
 
771
981
  /** Undo the pin a failed install wrote; returns the sentence saying where things stand. */
@@ -775,21 +985,22 @@ function _restorePinAfterFailedInstall(previousPin, failedTag) {
775
985
  } catch (err) {
776
986
  // The file still pins failedTag; say so rather than claim otherwise.
777
987
  log("error", "Failed to restore the pin after a failed install:", err && err.message);
778
- return (
779
- `The pin to ${failedTag} could not be undone in ${UPDATE_CONFIG_PATH} ` +
780
- `(${(err && err.message) || err}) — fix that file, then use Unpin in Settings → General → Updates.`
781
- );
988
+ const reason = (err && err.message) || String(err);
989
+ return {
990
+ code: "pinStuck",
991
+ params: { tag: failedTag, path: UPDATE_CONFIG_PATH, reason },
992
+ message:
993
+ `The pin to ${failedTag} could not be undone in ${UPDATE_CONFIG_PATH} ` +
994
+ `(${reason}) — fix that file, then use Unpin in Settings → General → Updates.`,
995
+ };
782
996
  }
783
997
  if (!previousPin) {
784
998
  clearPin();
785
- return "Updates are not paused.";
999
+ return { code: "notPaused", params: {}, message: "Updates are not paused." };
786
1000
  }
787
1001
  setState({ pinnedVersion: previousPin });
788
- autoUpdaterRef.setFeedURL({
789
- provider: "generic",
790
- url: `https://github.com/amd/gaia/releases/download/${previousPin}/`,
791
- });
792
- return `Still pinned to ${previousPin}.`;
1002
+ autoUpdaterRef.setFeedURL(_releaseFeed(previousPin));
1003
+ return { code: "stillPinned", params: { tag: previousPin }, message: `Still pinned to ${previousPin}.` };
793
1004
  }
794
1005
 
795
1006
  // ── clearPin / resumeUpdates ──────────────────────────────────────────────────
@@ -810,8 +1021,9 @@ function clearPin() {
810
1021
  } catch (err) {
811
1022
  log("warn", "Failed to clear pin file:", err && err.message);
812
1023
  }
813
- setState({ pinnedVersion: null });
1024
+ setState({ pinnedVersion: null, previousVersion: previousVersion() });
814
1025
  installRequested = false;
1026
+ if (state.status === STATES.DOWNLOADED) _abandonDownload();
815
1027
 
816
1028
  if (autoUpdaterRef) {
817
1029
  _applyInstallPolicy();
@@ -837,8 +1049,12 @@ async function setAutoUpdate(enabled) {
837
1049
  try {
838
1050
  _writeConfig({ autoUpdate: enabled });
839
1051
  } catch (err) {
840
- throw new Error(
841
- `Couldn't save the auto-update setting to ${UPDATE_CONFIG_PATH}: ${(err && err.message) || err}`
1052
+ if (err && err.code === "configBusy") throw err;
1053
+ const reason = (err && err.message) || String(err);
1054
+ throw _userError(
1055
+ "saveFailed",
1056
+ { path: UPDATE_CONFIG_PATH, reason },
1057
+ `Couldn't save the auto-update setting to ${UPDATE_CONFIG_PATH}: ${reason}`
842
1058
  );
843
1059
  }
844
1060
  setState({ autoUpdate: enabled });
@@ -861,18 +1077,322 @@ async function setIncludePrereleases(enabled) {
861
1077
  try {
862
1078
  _writeConfig({ includePrereleases: enabled });
863
1079
  } catch (err) {
864
- throw new Error(
865
- `Couldn't save the release-candidate setting to ${UPDATE_CONFIG_PATH}: ${(err && err.message) || err}`
1080
+ if (err && err.code === "configBusy") throw err;
1081
+ const reason = (err && err.message) || String(err);
1082
+ throw _userError(
1083
+ "saveFailed",
1084
+ { path: UPDATE_CONFIG_PATH, reason },
1085
+ `Couldn't save the release-candidate setting to ${UPDATE_CONFIG_PATH}: ${reason}`
866
1086
  );
867
1087
  }
868
1088
  setState({ includePrereleases: enabled });
869
1089
  if (autoUpdaterRef) {
870
1090
  autoUpdaterRef.allowPrerelease = _prereleasesAllowed();
871
1091
  }
1092
+ const offered = state.status === STATES.DOWNLOADED || state.status === STATES.AVAILABLE;
1093
+ if (offered && _isPrereleaseVersion(state.version) && !_prereleasesAllowed()) {
1094
+ _abandonDownload();
1095
+ }
872
1096
  log("info", `Release-candidate updates ${enabled ? "enabled" : "disabled"}`);
873
1097
  }
874
1098
 
875
- /** "Update now": download the update a check found; the restart dialog follows. */
1099
+ /** Stop offering the current update, and don't let a quit install it. */
1100
+ function _abandonDownload() {
1101
+ log("info", `No longer offering GAIA ${state.version}`);
1102
+ abandonedDownload = true;
1103
+ downloadEpoch += 1;
1104
+ installRequested = false;
1105
+ restartWhenDownloaded = false;
1106
+ setState({ status: STATES.IDLE, version: null, progress: 0, releaseNotes: null });
1107
+ _applyInstallPolicy();
1108
+ }
1109
+
1110
+ /** Promise.all over items, with at most `limit` calls in flight. */
1111
+ async function _mapLimited(items, limit, fn) {
1112
+ const results = new Array(items.length);
1113
+ let next = 0;
1114
+ async function worker() {
1115
+ while (next < items.length) {
1116
+ const i = next++;
1117
+ results[i] = await fn(items[i]);
1118
+ }
1119
+ }
1120
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
1121
+ return results;
1122
+ }
1123
+
1124
+ function _readJsonFile(file) {
1125
+ try {
1126
+ return JSON.parse(fs.readFileSync(file, "utf8")) || {};
1127
+ } catch (err) {
1128
+ if (err && err.code !== "ENOENT") log("warn", `Couldn't read ${file}:`, err.message);
1129
+ return {};
1130
+ }
1131
+ }
1132
+
1133
+ function _runningVersion() {
1134
+ if (state.currentVersion) return state.currentVersion;
1135
+ // eslint-disable-next-line global-require
1136
+ return require("electron").app.getVersion();
1137
+ }
1138
+
1139
+ /**
1140
+ * The older version a bad update can go back to, or null. Works before init():
1141
+ * a backend that won't set up stops the launch before the updater starts.
1142
+ * A pin means the version was chosen, so there's nothing to undo.
1143
+ */
1144
+ function previousVersion() {
1145
+ if (isDisabled()) return null;
1146
+ const running = _runningVersion();
1147
+ const config = _readJsonFile(UPDATE_CONFIG_PATH);
1148
+ if (config.pinnedVersion) return null;
1149
+ const attempt = _readJsonFile(INSTALL_ATTEMPT_PATH);
1150
+ const earlier = attempt.from && attempt.from !== running ? attempt.from : config[PREVIOUS_VERSION_KEY];
1151
+ return earlier && _compareVersions(earlier, running) < 0 ? earlier : null;
1152
+ }
1153
+
1154
+ /** "0.25.4-rc.2" → "v0.25.4-rc2": candidate builds carry a dot their tags don't. */
1155
+ function _tagForVersion(version) {
1156
+ return `v${version.replace(/-rc\.(\d+)$/, "-rc$1")}`;
1157
+ }
1158
+
1159
+ /**
1160
+ * Go back to the version before the last update. Pins it, so the update that
1161
+ * went wrong isn't reinstalled, and resolves once the restart has run or been
1162
+ * refused (state then says why).
1163
+ */
1164
+ async function revertUpdate() {
1165
+ const version = previousVersion();
1166
+ if (!version) {
1167
+ throw _userError(
1168
+ "noEarlier",
1169
+ { url: RELEASES_PAGE },
1170
+ `There is no earlier GAIA version to go back to on this PC. Pick one in Settings → General → Updates, or download it from ${RELEASES_PAGE}.`
1171
+ );
1172
+ }
1173
+ log("info", `Going back to GAIA ${version}`);
1174
+ const tag = _tagForVersion(version);
1175
+ const { startFailed } = state;
1176
+ try {
1177
+ await installVersion(tag);
1178
+ } catch (err) {
1179
+ // A refused restart leaves the pin and the download: neither may outlive it.
1180
+ if (state.pinnedVersion === tag) clearPin();
1181
+ setState({ startFailed });
1182
+ throw err;
1183
+ }
1184
+ }
1185
+
1186
+ /** True for the first few launches after an update landed, the only time a failed start blames it. */
1187
+ function _updateIsRecent() {
1188
+ // An attempt from the running version is a failed install, not an update.
1189
+ const { from } = _readJsonFile(INSTALL_ATTEMPT_PATH);
1190
+ if (from && from !== _runningVersion()) return true;
1191
+ const launches = _readJsonFile(UPDATE_CONFIG_PATH)[LAUNCHES_SINCE_UPDATE_KEY];
1192
+ return Number.isInteger(launches) && launches <= START_FAILURE_REVERT_LAUNCHES;
1193
+ }
1194
+
1195
+ /** The app opened soon after an update but its backend didn't come up: offer the way back. */
1196
+ function reportStartFailure() {
1197
+ const previous = previousVersion();
1198
+ if (previous && _updateIsRecent()) setState({ startFailed: true, previousVersion: previous });
1199
+ }
1200
+
1201
+ /** Count this launch toward the window in which a failed start offers to go back. */
1202
+ function _countLaunchSinceUpdate() {
1203
+ const config = _readJsonFile(UPDATE_CONFIG_PATH);
1204
+ const launches = config[LAUNCHES_SINCE_UPDATE_KEY];
1205
+ if (!config[PREVIOUS_VERSION_KEY] || !Number.isInteger(launches)) return;
1206
+ if (launches > START_FAILURE_REVERT_LAUNCHES) return;
1207
+ try {
1208
+ _writeConfig({ [LAUNCHES_SINCE_UPDATE_KEY]: launches + 1 });
1209
+ } catch (err) {
1210
+ log("warn", "Couldn't count this launch since the update:", err && err.message);
1211
+ }
1212
+ }
1213
+
1214
+ /** A slow backend came up after all, so there's nothing to offer a way back from. */
1215
+ function clearStartFailure() {
1216
+ if (state.startFailed) setState({ startFailed: false });
1217
+ }
1218
+
1219
+ /** Note the install a quit is about to start, for the next launch to judge. */
1220
+ function _recordInstallAttempt() {
1221
+ try {
1222
+ fs.mkdirSync(path.dirname(INSTALL_ATTEMPT_PATH), { recursive: true });
1223
+ fs.writeFileSync(
1224
+ INSTALL_ATTEMPT_PATH,
1225
+ JSON.stringify({ from: state.currentVersion, target: state.version })
1226
+ );
1227
+ } catch (err) {
1228
+ log("warn", "Couldn't note the update attempt:", err && err.message);
1229
+ }
1230
+ }
1231
+
1232
+ /** Running the version an install was meant to replace means it failed. */
1233
+ function _readInstallOutcome() {
1234
+ let attempt;
1235
+ try {
1236
+ attempt = JSON.parse(fs.readFileSync(INSTALL_ATTEMPT_PATH, "utf8"));
1237
+ } catch (err) {
1238
+ if (err && err.code !== "ENOENT") {
1239
+ log("warn", "Couldn't read the last update attempt:", err.message);
1240
+ return;
1241
+ }
1242
+ _countLaunchSinceUpdate();
1243
+ return;
1244
+ }
1245
+ if (attempt.from !== state.currentVersion) {
1246
+ try {
1247
+ _writeConfig({ [PREVIOUS_VERSION_KEY]: attempt.from, [LAUNCHES_SINCE_UPDATE_KEY]: 1 });
1248
+ } catch (err) {
1249
+ // The attempt file stays, so previousVersion() still knows the way back.
1250
+ log("warn", "Couldn't save the previous version:", err && err.message);
1251
+ return;
1252
+ }
1253
+ fs.rmSync(INSTALL_ATTEMPT_PATH, { force: true });
1254
+ return;
1255
+ }
1256
+ wizardNextInstall = true;
1257
+ state.installFailed = { target: attempt.target, from: attempt.from };
1258
+ // Never stay pinned to a version that didn't land.
1259
+ if (state.pinnedVersion === _tagForVersion(attempt.target)) {
1260
+ try {
1261
+ _savePin(null);
1262
+ state.pinnedVersion = null;
1263
+ } catch (err) {
1264
+ log("warn", "Couldn't clear the pin to the failed install:", err && err.message);
1265
+ }
1266
+ }
1267
+ log("error", `GAIA ${attempt.target} didn't install; still on ${attempt.from}`);
1268
+ // Report the failure once: a stale attempt would keep install-on-quit off for good.
1269
+ try {
1270
+ fs.rmSync(INSTALL_ATTEMPT_PATH, { force: true });
1271
+ } catch (err) {
1272
+ log("warn", "Couldn't clear the failed update attempt:", err && err.message);
1273
+ }
1274
+ }
1275
+
1276
+ /** The quit path's check: a quit can't wait for the async one. */
1277
+ function _diskTooFullToInstall() {
1278
+ try {
1279
+ const stats = fs.statfsSync(os.homedir());
1280
+ return stats.bavail * stats.bsize < MIN_INSTALL_FREE_BYTES;
1281
+ } catch (err) {
1282
+ log("warn", "Couldn't read free disk space:", err && err.message);
1283
+ return false;
1284
+ }
1285
+ }
1286
+
1287
+ /** Refuse a restart the installer can't finish, while the app still works. */
1288
+ async function _requireInstallSpace() {
1289
+ let free;
1290
+ try {
1291
+ const stats = await fs.promises.statfs(os.homedir());
1292
+ free = stats.bavail * stats.bsize;
1293
+ } catch (err) {
1294
+ log("warn", "Couldn't read free disk space:", err && err.message);
1295
+ return;
1296
+ }
1297
+ if (free < MIN_INSTALL_FREE_BYTES) {
1298
+ const mb = Math.round(free / (1024 * 1024));
1299
+ throw _userError(
1300
+ "diskSpace",
1301
+ { need: "1 GB", free: `${mb} MB` },
1302
+ `GAIA needs about 1 GB free to install this update, and this disk has ` +
1303
+ `${mb} MB. Free some space, then restart from the update chip.`
1304
+ );
1305
+ }
1306
+ }
1307
+
1308
+ /**
1309
+ * Setup on the next launch needs PyPI unless the backend was prefetched, so
1310
+ * restarting offline would leave no working app until the connection returns.
1311
+ * Only a PC with no network at all counts: probing a host would block people
1312
+ * behind proxies.
1313
+ */
1314
+ function _isOffline() {
1315
+ return Boolean(electronApi && electronApi.net && !electronApi.net.isOnline());
1316
+ }
1317
+
1318
+ function _backendCached() {
1319
+ return Boolean(isBackendPrefetchedRef && state.version && isBackendPrefetchedRef(state.version));
1320
+ }
1321
+
1322
+ function _requireNetwork() {
1323
+ if (_isOffline() && !_backendCached()) {
1324
+ throw _userError(
1325
+ "offline",
1326
+ {},
1327
+ "GAIA needs the internet to finish this update. Connect, then restart from the update chip."
1328
+ );
1329
+ }
1330
+ }
1331
+
1332
+ /**
1333
+ * Fetch the update's backend while the app still runs. A failure only means the
1334
+ * first launch downloads it instead, as without a prefetch, so it is logged and
1335
+ * the restart keeps its internet check.
1336
+ */
1337
+ async function _prefetchBackend(version) {
1338
+ if (!prefetchBackendRef || !version) return;
1339
+ if (isBackendPrefetchedRef && isBackendPrefetchedRef(version)) return;
1340
+ if (!prefetching.has(version)) {
1341
+ prefetching.set(
1342
+ version,
1343
+ Promise.resolve()
1344
+ .then(() => prefetchBackendRef(version))
1345
+ .finally(() => prefetching.delete(version))
1346
+ );
1347
+ }
1348
+ try {
1349
+ await prefetching.get(version);
1350
+ } catch (err) {
1351
+ log("warn", `Couldn't fetch the backend for ${version} ahead of the restart:`, err && err.message);
1352
+ }
1353
+ }
1354
+
1355
+ /** An update finished downloading: fetch its backend, then offer the restart. */
1356
+ async function _onDownloaded(info) {
1357
+ abandonedDownload = false;
1358
+ const version = (info && info.version) || state.version;
1359
+ const epoch = downloadEpoch;
1360
+ if (prefetchBackendRef) {
1361
+ // electron-updater has armed install-on-quit; a quit now would skip the checks below.
1362
+ setState({ status: STATES.DOWNLOADING, version, progress: 100, preparing: true });
1363
+ _applyInstallPolicy();
1364
+ await _prefetchBackend(version);
1365
+ if (epoch !== downloadEpoch) {
1366
+ setState({ preparing: false });
1367
+ _applyInstallPolicy();
1368
+ log("info", `GAIA ${version} was given up on while its backend was fetched`);
1369
+ return;
1370
+ }
1371
+ }
1372
+ setState({
1373
+ status: STATES.DOWNLOADED,
1374
+ version,
1375
+ progress: 100,
1376
+ preparing: false,
1377
+ error: null,
1378
+ lastCheckError: null,
1379
+ });
1380
+ _applyInstallPolicy();
1381
+ log("info", `Update downloaded: ${version}`);
1382
+
1383
+ // The chip offers the restart. Picking a version already asked for one.
1384
+ if (restartWhenDownloaded) {
1385
+ restartWhenDownloaded = false;
1386
+ // The download stays on offer: the chip can restart once this is cleared up.
1387
+ pendingRestart = restartToInstall().catch((err) => {
1388
+ log("warn", "Restart refused:", err && err.message);
1389
+ restartRefusal = err;
1390
+ });
1391
+ await pendingRestart;
1392
+ }
1393
+ }
1394
+
1395
+ /** "Update now": download the update a check found; the chip then offers the restart. */
876
1396
  async function downloadUpdate() {
877
1397
  if (!autoUpdaterRef) {
878
1398
  throw new Error("downloadUpdate called before auto-updater was initialised");
@@ -893,7 +1413,11 @@ async function downloadUpdate() {
893
1413
  installRequested = false;
894
1414
  _applyInstallPolicy();
895
1415
  setState({ status: STATES.ERROR, error: message });
896
- throw new Error(`Downloading GAIA ${state.version || ""} failed: ${message}`);
1416
+ throw _userError(
1417
+ "downloadFailed",
1418
+ { version: state.version || "", reason: message },
1419
+ `Downloading GAIA ${state.version || ""} failed: ${message}`
1420
+ );
897
1421
  }
898
1422
  }
899
1423
 
@@ -902,17 +1426,26 @@ async function restartToInstall() {
902
1426
  if (!autoUpdaterRef || state.status !== STATES.DOWNLOADED) {
903
1427
  throw new Error(`No downloaded update to install (status: ${state.status}).`);
904
1428
  }
1429
+ await _requireInstallSpace();
1430
+ _requireNetwork();
905
1431
  if (!(await confirmRestartRef())) {
906
1432
  log("info", "Restart put off: work is still running — will install on next quit");
907
1433
  return;
908
1434
  }
909
- log("info", "User chose to restart from the update chip — calling quitAndInstall");
910
- autoUpdaterRef.quitAndInstall(false, true);
1435
+ log("info", "User chose to restart — calling quitAndInstall");
1436
+ _recordInstallAttempt();
1437
+ // Silent: the installer wizard would otherwise wait for clicks. Force-run
1438
+ // brings the app straight back.
1439
+ autoUpdaterRef.quitAndInstall(!wizardNextInstall, true);
911
1440
  }
912
1441
 
913
1442
  // ── Core check ───────────────────────────────────────────────────────────────
914
1443
 
915
- async function checkForUpdates() {
1444
+ /**
1445
+ * Check for an update. A `background` check that fails only logs and waits for
1446
+ * the next one: nobody asked for it, and nothing is wrong with the app.
1447
+ */
1448
+ async function checkForUpdates({ background = false } = {}) {
916
1449
  if (isDisabled()) {
917
1450
  setState({ status: STATES.DISABLED });
918
1451
  log("info", "Update check skipped — GAIA_DISABLE_UPDATE=1");
@@ -937,7 +1470,11 @@ async function checkForUpdates() {
937
1470
  return;
938
1471
  }
939
1472
  checkInProgress = true;
940
- setState({ status: STATES.CHECKING, error: null });
1473
+ backgroundCheck = background;
1474
+ askedCheck = !background;
1475
+ // A routine check may replace the restart the chip offers, never remove it.
1476
+ heldDownloadVersion = background && state.status === STATES.DOWNLOADED ? state.version : null;
1477
+ if (!heldDownloadVersion) setState({ status: STATES.CHECKING, error: null });
941
1478
  try {
942
1479
  // No configured feed: aim at the newest GitHub release on this channel,
943
1480
  // re-read every check so a new release is found.
@@ -947,14 +1484,36 @@ async function checkForUpdates() {
947
1484
  log("info", "Checking for updates...");
948
1485
  await autoUpdaterRef.checkForUpdates();
949
1486
  } catch (err) {
950
- setState({
951
- status: STATES.ERROR,
952
- error: (err && err.message) || String(err),
953
- });
954
- log("error", "Update check failed:", err && err.message);
1487
+ _recordFailure("Update check failed:", err);
955
1488
  } finally {
956
1489
  checkInProgress = false;
1490
+ backgroundCheck = false;
1491
+ heldDownloadVersion = null;
1492
+ }
1493
+ }
1494
+
1495
+ /** Show a failure the user asked to hear about; otherwise note it and go idle. */
1496
+ function _recordFailure(what, err) {
1497
+ const message = (err && err.message) || String(err);
1498
+ // electron-updater starts an automatic download without awaiting it, so the
1499
+ // failure can land before any progress and after the check has returned.
1500
+ const autoDownloading =
1501
+ !installRequested &&
1502
+ !askedCheck &&
1503
+ (state.status === STATES.DOWNLOADING ||
1504
+ (state.status === STATES.AVAILABLE && state.autoUpdate));
1505
+ if (heldDownloadVersion) {
1506
+ setState({ status: STATES.DOWNLOADED, version: heldDownloadVersion, lastCheckError: message });
1507
+ log("warn", `${what} ${message} — keeping the downloaded update`);
1508
+ return;
1509
+ }
1510
+ if (backgroundCheck || autoDownloading) {
1511
+ setState({ status: STATES.IDLE, progress: 0, error: null, lastCheckError: message, preparing: false });
1512
+ log("warn", `${what} ${message} — will try again at the next check`);
1513
+ return;
957
1514
  }
1515
+ setState({ status: STATES.ERROR, error: message });
1516
+ log("error", what, message);
958
1517
  }
959
1518
 
960
1519
  function scheduleNextCheck() {
@@ -964,7 +1523,7 @@ function scheduleNextCheck() {
964
1523
  }
965
1524
  scheduledTimeout = setTimeout(async () => {
966
1525
  try {
967
- await checkForUpdates();
1526
+ await checkForUpdates({ background: true });
968
1527
  } catch (err) {
969
1528
  log("error", "Scheduled check threw:", err && err.message);
970
1529
  }
@@ -976,22 +1535,30 @@ function scheduleNextCheck() {
976
1535
 
977
1536
  function wireAutoUpdaterEvents() {
978
1537
  autoUpdaterRef.on("checking-for-update", () => {
979
- setState({ status: STATES.CHECKING });
1538
+ if (!heldDownloadVersion) setState({ status: STATES.CHECKING });
980
1539
  });
981
1540
 
982
1541
  autoUpdaterRef.on("update-available", (info) => {
983
1542
  const releaseNotes =
984
1543
  typeof info.releaseNotes === "string" ? info.releaseNotes : null;
1544
+ if (heldDownloadVersion && info.version === heldDownloadVersion) {
1545
+ setState({ status: STATES.DOWNLOADED, lastCheckError: null });
1546
+ return;
1547
+ }
985
1548
  setState({
986
1549
  status: STATES.AVAILABLE,
987
1550
  version: info.version || null,
988
1551
  releaseNotes,
989
1552
  error: null,
1553
+ lastCheckError: null,
990
1554
  });
991
1555
  log("info", `Update available: ${info.version}`);
992
1556
  });
993
1557
 
994
1558
  autoUpdaterRef.on("update-not-available", (info) => {
1559
+ // A release withdrawn since it downloaded must not install on quit.
1560
+ if (heldDownloadVersion) _abandonDownload();
1561
+ downloadEpoch += 1;
995
1562
  // Reset to idle so the UI hides any stale "available" chip.
996
1563
  setState({
997
1564
  status: STATES.IDLE,
@@ -999,6 +1566,7 @@ function wireAutoUpdaterEvents() {
999
1566
  progress: 0,
1000
1567
  releaseNotes: null,
1001
1568
  error: null,
1569
+ lastCheckError: null,
1002
1570
  });
1003
1571
  log("info", `No update available (current ${info && info.version})`);
1004
1572
  });
@@ -1014,79 +1582,29 @@ function wireAutoUpdaterEvents() {
1014
1582
  });
1015
1583
  });
1016
1584
 
1017
- autoUpdaterRef.on("update-downloaded", async (info) => {
1018
- setState({
1019
- status: STATES.DOWNLOADED,
1020
- version: (info && info.version) || state.version,
1021
- progress: 100,
1022
- error: null,
1023
- });
1024
- log("info", `Update downloaded: ${info && info.version}`);
1025
-
1026
- if (!electronApi || !electronApi.dialog) {
1027
- log("warn", "No dialog available — skipping restart prompt");
1028
- return;
1029
- }
1030
- try {
1031
- const isRollback =
1032
- state.pinnedVersion !== null &&
1033
- info &&
1034
- info.version;
1035
- const title = isRollback ? "Roll back ready" : "Update ready";
1036
- const message = isRollback
1037
- ? `GAIA ${info.version} is ready to install.`
1038
- : `GAIA ${info && info.version ? info.version : ""} has been downloaded.`;
1039
- const detail = isRollback
1040
- ? `The app will restart into v${info.version}. Your chat history will be preserved.`
1041
- : "Restart the app to apply the update. Your chat history will be preserved.";
1042
-
1043
- const choice = await electronApi.dialog.showMessageBox(
1044
- mainWindowRef && !mainWindowRef.isDestroyed() ? mainWindowRef : null,
1045
- {
1046
- type: "info",
1047
- buttons: ["Restart now", "Later"],
1048
- defaultId: 0,
1049
- cancelId: 1,
1050
- title,
1051
- message,
1052
- detail,
1053
- }
1054
- );
1055
- if (choice && choice.response === 0) {
1056
- // quitAndInstall starts the installer at once, so ask about running
1057
- // work here; the quit that follows doesn't ask again.
1058
- if (!(await confirmRestartRef())) {
1059
- log("info", "Restart put off: work is still running — will install on next quit");
1060
- return;
1061
- }
1062
- log("info", "User chose to restart — calling quitAndInstall");
1063
- autoUpdaterRef.quitAndInstall(false, true);
1064
- } else {
1065
- log("info", "User deferred restart — will install on next quit");
1066
- }
1067
- } catch (err) {
1068
- const message = (err && err.message) || String(err);
1069
- log("error", "Failed to show restart dialog:", message);
1070
- electronApi.dialog.showErrorBox(
1071
- "GAIA update",
1072
- `GAIA couldn't finish asking about the update restart: ${message}\n\n` +
1073
- "The update installs the next time you quit GAIA."
1074
- );
1075
- }
1585
+ autoUpdaterRef.on("update-downloaded", (info) => {
1586
+ // Set before anything waits, so installVersion sees the restart it asked for.
1587
+ downloadHandled = _onDownloaded(info);
1588
+ return downloadHandled;
1076
1589
  });
1077
1590
 
1078
1591
  autoUpdaterRef.on("error", (err) => {
1079
- setState({
1080
- status: STATES.ERROR,
1081
- error: (err && err.message) || String(err),
1082
- });
1083
- log("error", "electron-updater error:", err && err.message);
1592
+ _recordFailure("electron-updater error:", err);
1084
1593
  });
1085
1594
  }
1086
1595
 
1087
1596
  function registerIpcHandlers() {
1088
1597
  if (ipcHandlersRegistered || !electronApi || !electronApi.ipcMain) return;
1089
- const { ipcMain } = electronApi;
1598
+ const ipcMain = {
1599
+ handle: (channel, fn) =>
1600
+ electronApi.ipcMain.handle(channel, async (...args) => {
1601
+ try {
1602
+ return await fn(...args);
1603
+ } catch (err) {
1604
+ throw _forRenderer(err);
1605
+ }
1606
+ }),
1607
+ };
1090
1608
 
1091
1609
  ipcMain.handle("gaia:update:get-status", () => getState());
1092
1610
  ipcMain.handle("gaia:update:check", async () => {
@@ -1100,16 +1618,22 @@ function registerIpcHandlers() {
1100
1618
  await installVersion(tag);
1101
1619
  return getState();
1102
1620
  });
1621
+ ipcMain.handle("gaia:update:revert", async () => {
1622
+ await revertUpdate();
1623
+ return getState();
1624
+ });
1103
1625
  ipcMain.handle("gaia:update:resume", async () => {
1104
1626
  clearPin();
1105
1627
  return getState();
1106
1628
  });
1107
1629
  ipcMain.handle("gaia:update:set-auto-update", async (_event, enabled) => {
1108
1630
  await setAutoUpdate(enabled);
1631
+ if (enabled) await checkForUpdates();
1109
1632
  return getState();
1110
1633
  });
1111
1634
  ipcMain.handle("gaia:update:set-include-prereleases", async (_event, enabled) => {
1112
1635
  await setIncludePrereleases(enabled);
1636
+ if (enabled) await checkForUpdates();
1113
1637
  return getState();
1114
1638
  });
1115
1639
  ipcMain.handle("gaia:update:download", async () => {
@@ -1132,6 +1656,7 @@ function unregisterIpcHandlers() {
1132
1656
  "gaia:update:check",
1133
1657
  "gaia:update:list-releases",
1134
1658
  "gaia:update:install-version",
1659
+ "gaia:update:revert",
1135
1660
  "gaia:update:resume",
1136
1661
  "gaia:update:set-auto-update",
1137
1662
  "gaia:update:set-include-prereleases",
@@ -1162,6 +1687,10 @@ function unregisterIpcHandlers() {
1162
1687
  * @param {() => Promise<boolean>} options.confirmRestart - Whether "Restart now"
1163
1688
  * may go ahead; asked before the installer starts. Required: without it an
1164
1689
  * update restart would silently cut off a running answer.
1690
+ * @param {(version: string) => Promise<void>} [options.prefetchBackend] - Fetch
1691
+ * the backend `version` needs before the restart, so its first launch is offline.
1692
+ * @param {(version: string) => boolean} [options.isBackendPrefetched] - Whether
1693
+ * that fetch finished, so a restart no longer needs the internet.
1165
1694
  */
1166
1695
  function init(mainWindow, options = {}) {
1167
1696
  if (typeof options.confirmRestart !== "function") {
@@ -1171,6 +1700,10 @@ function init(mainWindow, options = {}) {
1171
1700
  }
1172
1701
  mainWindowRef = mainWindow || null;
1173
1702
  confirmRestartRef = options.confirmRestart;
1703
+ if (options.prefetchBackend) {
1704
+ prefetchBackendRef = options.prefetchBackend;
1705
+ isBackendPrefetchedRef = options.isBackendPrefetched;
1706
+ }
1174
1707
 
1175
1708
  // Disabled path — short-circuit BEFORE touching any Electron APIs so
1176
1709
  // this works in plain Node tests where require('electron') returns a
@@ -1220,10 +1753,11 @@ function init(mainWindow, options = {}) {
1220
1753
  dialog: electron.dialog,
1221
1754
  ipcMain: electron.ipcMain,
1222
1755
  app: electron.app,
1756
+ net: electron.net,
1223
1757
  };
1224
1758
  autoUpdaterRef = electronUpdater.autoUpdater;
1225
1759
  // The Terminal UI may have turned auto-update off since the last check.
1226
- electron.app.on("before-quit", _refreshPreferences);
1760
+ electron.app.on("before-quit", _onBeforeQuit);
1227
1761
 
1228
1762
  // Record the running version for getState() / listReleases().
1229
1763
  try {
@@ -1231,6 +1765,8 @@ function init(mainWindow, options = {}) {
1231
1765
  } catch {
1232
1766
  // ignore
1233
1767
  }
1768
+ _readInstallOutcome();
1769
+ state.previousVersion = previousVersion();
1234
1770
  } catch (err) {
1235
1771
  log("error", "Failed to load electron-updater:", err && err.message);
1236
1772
  setState({
@@ -1272,10 +1808,11 @@ function init(mainWindow, options = {}) {
1272
1808
  }
1273
1809
 
1274
1810
  // Point the updater at the configured R2 generic feed (#1724), or at GitHub
1275
- // releases when none is configured. When a pin is active, installVersion()
1276
- // owns the feed (rollback path), so skip.
1811
+ // releases when none is configured. A pin stays on its own release's feed.
1277
1812
  let feedReady = true;
1278
- if (!savedPin) {
1813
+ if (state.pinnedVersion) {
1814
+ autoUpdaterRef.setFeedURL(_releaseFeed(state.pinnedVersion));
1815
+ } else {
1279
1816
  feedReady = _applyForwardFeed();
1280
1817
  }
1281
1818
 
@@ -1289,7 +1826,12 @@ function init(mainWindow, options = {}) {
1289
1826
  // First check after CHECK_DELAY_MS, then every CHECK_INTERVAL_MS.
1290
1827
  initialCheckTimeout = setTimeout(async () => {
1291
1828
  try {
1292
- await checkForUpdates();
1829
+ await _seedInstalledBlockmap();
1830
+ } catch (err) {
1831
+ log("warn", "Couldn't cache the installed version's blockmap; the next update downloads in full:", err && err.message);
1832
+ }
1833
+ try {
1834
+ await checkForUpdates({ background: true });
1293
1835
  } catch (err) {
1294
1836
  log("error", "Initial check threw:", err && err.message);
1295
1837
  }
@@ -1302,6 +1844,22 @@ function init(mainWindow, options = {}) {
1302
1844
  );
1303
1845
  }
1304
1846
 
1847
+ function _onBeforeQuit(event) {
1848
+ _refreshPreferences();
1849
+ if (state.status !== STATES.DOWNLOADED || !autoUpdaterRef || !autoUpdaterRef.autoInstallOnAppQuit) {
1850
+ return;
1851
+ }
1852
+ // The chip's checks, for an install nobody clicks.
1853
+ if ((_isOffline() && !_backendCached()) || _diskTooFullToInstall()) {
1854
+ log("info", "Can't finish an install now: leaving the update for a later quit");
1855
+ autoUpdaterRef.autoInstallOnAppQuit = false;
1856
+ return;
1857
+ }
1858
+ // The quit guard kept the app open, so nothing is installing.
1859
+ if (event && event.defaultPrevented) return;
1860
+ _recordInstallAttempt();
1861
+ }
1862
+
1305
1863
  /** Tear down timers and IPC handlers. Safe to call multiple times. */
1306
1864
  function destroy() {
1307
1865
  if (initialCheckTimeout) {
@@ -1313,7 +1871,7 @@ function destroy() {
1313
1871
  scheduledTimeout = null;
1314
1872
  }
1315
1873
  unregisterIpcHandlers();
1316
- if (electronApi) electronApi.app.removeListener("before-quit", _refreshPreferences);
1874
+ if (electronApi) electronApi.app.removeListener("before-quit", _onBeforeQuit);
1317
1875
  mainWindowRef = null;
1318
1876
  // Keep `initialized` true — calling init() again after destroy() is not a
1319
1877
  // supported lifecycle and we'd need to re-wire electron-updater events
@@ -1327,10 +1885,16 @@ module.exports = {
1327
1885
  getState,
1328
1886
  listReleases,
1329
1887
  installVersion,
1888
+ previousVersion,
1889
+ revertUpdate,
1890
+ reportStartFailure,
1891
+ clearStartFailure,
1330
1892
  clearPin,
1331
1893
  setAutoUpdate,
1332
1894
  setIncludePrereleases,
1333
1895
  downloadUpdate,
1334
1896
  restartToInstall,
1335
1897
  STATES,
1898
+ // For tests: init() runs it on a timer.
1899
+ _seedInstalledBlockmap,
1336
1900
  };