@amd-gaia/agent-ui 0.25.2-rc.2 → 0.25.2-rc.4

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 (32) hide show
  1. package/assets/notification-icon.png +0 -0
  2. package/dist/assets/AdvancedSettings-DxzL09lt.js +1 -0
  3. package/dist/assets/ConnectorsPane-BOaVt5wd.js +1 -0
  4. package/dist/assets/DocumentLibrary-DjgHNHF0.js +4 -0
  5. package/dist/assets/FileBrowser-Dil1lZJ4.css +1 -0
  6. package/dist/assets/FileBrowser-mE75Zm-P.js +4 -0
  7. package/dist/assets/MemoryDashboard-AN4yi3u6.js +1 -0
  8. package/dist/assets/MobileAccessModal-SPNutZLD.js +2 -0
  9. package/dist/assets/ScheduleManager-DUwRXGd2.js +2 -0
  10. package/dist/assets/{apiBase-BmPG2Rib.js → apiBase-CS6zLsJY.js} +1 -1
  11. package/dist/assets/index-B73021Op.js +79 -0
  12. package/dist/assets/index-DJViDewM.css +1 -0
  13. package/dist/assets/{message-square-DuVoNniw.js → message-square-CLnmengs.js} +1 -1
  14. package/dist/index.html +3 -3
  15. package/package.json +1 -1
  16. package/preload.cjs +13 -0
  17. package/services/app-icon.cjs +12 -1
  18. package/services/auto-updater.cjs +409 -79
  19. package/services/backend-installer.cjs +43 -4
  20. package/services/notification-service.cjs +3 -2
  21. package/dist/assets/AdvancedSettings-D5VKpkPY.css +0 -1
  22. package/dist/assets/AdvancedSettings-DlNYuBwr.js +0 -1
  23. package/dist/assets/ConnectorsPane-BMuJwbQS.js +0 -1
  24. package/dist/assets/DocumentLibrary-BBkaVGFa.js +0 -4
  25. package/dist/assets/FileBrowser-B3rdG7Th.js +0 -4
  26. package/dist/assets/FileBrowser-D04I8Pvv.css +0 -1
  27. package/dist/assets/MemoryDashboard-CI0XsMok.js +0 -1
  28. package/dist/assets/MobileAccessModal-Bt5XREFP.js +0 -2
  29. package/dist/assets/ScheduleManager-ia8OmdHV.js +0 -2
  30. package/dist/assets/eye-CwjwlxR5.js +0 -1
  31. package/dist/assets/index-CfiDLYYM.js +0 -79
  32. package/dist/assets/index-CtsaRdvF.css +0 -1
@@ -4,14 +4,12 @@
4
4
  /**
5
5
  * auto-updater.cjs — GAIA Agent UI auto-update service.
6
6
  *
7
- * Wraps `electron-updater` against a CONFIGURABLE generic feed (R2-primary —
8
- * issue #1724). The forward-update channel URL is resolved at runtime from
9
- * `GAIA_UPDATE_FEED_URL` or `feedUrl` in ~/.gaia/update-config.json, and the
10
- * app fetches the mutable `latest*.yml` channel pointer from it. When no feed
11
- * is configured the updater enters a loud `no-channel` state (no silent
12
- * no-op). The actual R2 publish feed + mutable channel pointer is milestone-52
13
- * work (#1719); until it lands, point the env var at any generic feed (a local
14
- * mock works) to exercise the path.
7
+ * Wraps `electron-updater` against a generic feed. A feed set in
8
+ * `GAIA_UPDATE_FEED_URL` or `feedUrl` in ~/.gaia/update-config.json (the R2
9
+ * channel of #1724) wins. Otherwise each check reads the GitHub releases and
10
+ * points the feed at the newest one on this install's channel that publishes
11
+ * this platform's `latest*.yml`; when none does, the updater enters the
12
+ * `no-channel` state and says so (no silent "up to date").
15
13
  *
16
14
  * Implements §4 Layer 3 + §7 Phase F of docs/plans/desktop-installer.mdx.
17
15
  * Issue #1336: in-app rollback to a specific previous release still lists and
@@ -23,7 +21,9 @@
23
21
  * `app.whenReady`)
24
22
  * - Subsequent checks every 4 hours via re-scheduling setTimeout
25
23
  * - Concurrent-check guard so checks never overlap
26
- * - Download silently in the background
24
+ * - Auto-update is OFF by default (`autoUpdate` in ~/.gaia/update-config.json,
25
+ * shared with the Terminal UI). Off, a found update is only offered: the
26
+ * renderer's "Update now" calls downloadUpdate(). On, it downloads silently.
27
27
  * - On `update-downloaded`, show a native dialog: "Update ready — restart?"
28
28
  * - Renderer integration via IPC channel `gaia:update:status`
29
29
  * - Disabled entirely via GAIA_DISABLE_UPDATE=1 env var (CI / dev / corp)
@@ -38,6 +38,9 @@
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
+ * - setAutoUpdate(enabled) → persist the auto-update preference
42
+ * - downloadUpdate() → download the offered update ("Update now")
43
+ * - restartToInstall() → restart into a downloaded update
41
44
  * - STATES → string constants for valid states
42
45
  *
43
46
  * Design note: `electron` and `electron-updater` are lazy-required inside
@@ -80,6 +83,62 @@ const GITHUB_API_RELEASES =
80
83
  // release unreachable.
81
84
  const MAX_RELEASES_SHOWN = 10;
82
85
 
86
+ const RELEASES_PAGE = "https://github.com/amd/gaia/releases";
87
+
88
+ // A black-holed connection must fail, not leave "Checking…" up forever.
89
+ const GITHUB_TIMEOUT_MS = 20 * 1000;
90
+
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;
98
+ try {
99
+ resp = await fetch(GITHUB_API_RELEASES, {
100
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "GAIA-Agent-UI" },
101
+ signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
102
+ });
103
+ } catch (err) {
104
+ const timedOut = err && (err.name === "TimeoutError" || err.name === "AbortError");
105
+ throw Object.assign(new Error((err && err.message) || String(err)), {
106
+ kind: timedOut ? "timeout" : "network",
107
+ });
108
+ }
109
+ if (!resp.ok) {
110
+ throw Object.assign(new Error(`HTTP ${resp.status}`), { kind: "http", status: resp.status });
111
+ }
112
+ 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",
118
+ });
119
+ }
120
+ }
121
+
122
+ /** The user-facing sentence for a _fetchReleases failure while `doing`. */
123
+ function _releasesErrorMessage(err, doing) {
124
+ switch (err && err.kind) {
125
+ case "timeout":
126
+ return `GitHub didn't answer within ${GITHUB_TIMEOUT_MS / 1000} seconds when GAIA tried to ${doing} — try again later, or download an installer from ${RELEASES_PAGE}.`;
127
+ case "http": {
128
+ const limited = err.status === 403 || err.status === 429;
129
+ return (
130
+ `GitHub answered HTTP ${err.status} when GAIA tried to ${doing}` +
131
+ (limited ? " — its hourly limit for this network may be used up; try again later" : " — try again later") +
132
+ `, or download an installer from ${RELEASES_PAGE}.`
133
+ );
134
+ }
135
+ case "garbled":
136
+ return `GitHub sent a response GAIA couldn't read when it tried to ${doing} (${err.message}) — try again later, or download an installer from ${RELEASES_PAGE}.`;
137
+ default:
138
+ return `Couldn't reach GitHub to ${doing} (${(err && err.message) || err}) — check your connection, or download an installer from ${RELEASES_PAGE}.`;
139
+ }
140
+ }
141
+
83
142
  const STATES = Object.freeze({
84
143
  IDLE: "idle",
85
144
  CHECKING: "checking",
@@ -88,7 +147,8 @@ const STATES = Object.freeze({
88
147
  DOWNLOADED: "downloaded",
89
148
  ERROR: "error",
90
149
  DISABLED: "disabled",
91
- // No forward-update feed configured — loud, actionable, never a silent no-op.
150
+ // No release publishes a desktop update for this platform — said in
151
+ // Settings, never shown as "up to date".
92
152
  NO_CHANNEL: "no-channel",
93
153
  });
94
154
 
@@ -103,6 +163,13 @@ const state = {
103
163
  error: null,
104
164
  currentVersion: null,
105
165
  pinnedVersion: null,
166
+ autoUpdate: false,
167
+ // Offer release candidates too; off by default. A release-candidate build
168
+ // is offered newer candidates without it.
169
+ includePrereleases: false,
170
+ // update-config.json could not be read; kept apart from `error` so a later
171
+ // check's status never hides it.
172
+ configError: null,
106
173
  };
107
174
 
108
175
  let mainWindowRef = null;
@@ -163,26 +230,82 @@ function _loadPin() {
163
230
  }
164
231
 
165
232
  /**
166
- * Persist the version pin. Propagates write errors to the caller so that a
167
- * failed pin write can abort a rollback (AC2 must not be silently broken).
168
- * Callers that can tolerate a failed write (e.g. clearPin) should catch.
233
+ * Off unless the user turned it on: the app still changes fast. An unreadable
234
+ * file is reported (Settings shows it), and auto-update stays off until fixed.
235
+ */
236
+ function _loadAutoUpdate() {
237
+ if (!fs.existsSync(UPDATE_CONFIG_PATH)) {
238
+ return { autoUpdate: false, includePrereleases: false, error: null };
239
+ }
240
+ try {
241
+ const cfg = JSON.parse(fs.readFileSync(UPDATE_CONFIG_PATH, "utf8"));
242
+ return {
243
+ autoUpdate: Boolean(cfg && cfg.autoUpdate === true),
244
+ includePrereleases: Boolean(cfg && cfg.includePrereleases === true),
245
+ error: null,
246
+ };
247
+ } catch (err) {
248
+ const error =
249
+ `${UPDATE_CONFIG_PATH} cannot be read (${(err && err.message) || err}), so auto-update is off — ` +
250
+ "fix the file by hand or delete it.";
251
+ log("error", error);
252
+ return { autoUpdate: false, includePrereleases: false, error };
253
+ }
254
+ }
255
+
256
+ /** "0.25.2-rc.3" → true. A candidate build keeps being offered candidates. */
257
+ function _isPrereleaseVersion(version) {
258
+ return typeof version === "string" && /^\d+\.\d+\.\d+-/.test(version);
259
+ }
260
+
261
+ /**
262
+ * Whether release candidates are offered: the user opted in, this build is
263
+ * itself a candidate, or GAIA_UPDATE_PRERELEASE=1. A channel, not an install
264
+ * trigger: with auto-update off an offered candidate still waits to be asked.
169
265
  */
266
+ function _prereleasesAllowed() {
267
+ return (
268
+ process.env.GAIA_UPDATE_PRERELEASE === "1" ||
269
+ state.includePrereleases ||
270
+ _isPrereleaseVersion(state.currentVersion)
271
+ );
272
+ }
273
+
170
274
  function _savePin(pinnedVersion) {
275
+ _writeConfig({ pinnedVersion });
276
+ }
277
+
278
+ /**
279
+ * Merge `patch` into update-config.json. Propagates write errors so a failed
280
+ * pin write can abort a rollback (AC2 must not be silently broken). Callers
281
+ * that can tolerate a failed write (e.g. clearPin) should catch.
282
+ *
283
+ * A file that can't be read or parsed is refused, never overwritten: it holds
284
+ * keys this app doesn't own (feedUrl, the Terminal UI's pin). The write goes
285
+ * to a temp file and is renamed over, so a crash never leaves half a file.
286
+ */
287
+ function _writeConfig(patch) {
171
288
  fs.mkdirSync(path.dirname(UPDATE_CONFIG_PATH), { recursive: true });
172
- // Merge into any existing config so we don't clobber a persisted feedUrl.
173
289
  let existing = {};
174
- try {
175
- if (fs.existsSync(UPDATE_CONFIG_PATH)) {
176
- existing = JSON.parse(fs.readFileSync(UPDATE_CONFIG_PATH, "utf8")) || {};
290
+ if (fs.existsSync(UPDATE_CONFIG_PATH)) {
291
+ try {
292
+ existing = JSON.parse(fs.readFileSync(UPDATE_CONFIG_PATH, "utf8"));
293
+ } catch (err) {
294
+ throw new Error(
295
+ `${UPDATE_CONFIG_PATH} cannot be read (${(err && err.message) || err}), so the setting was not saved — ` +
296
+ "fix the file by hand or delete it."
297
+ );
298
+ }
299
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
300
+ throw new Error(
301
+ `${UPDATE_CONFIG_PATH} does not hold a settings object, so the setting was not saved — ` +
302
+ "fix the file by hand or delete it."
303
+ );
177
304
  }
178
- } catch {
179
- existing = {};
180
305
  }
181
- fs.writeFileSync(
182
- UPDATE_CONFIG_PATH,
183
- JSON.stringify({ ...existing, pinnedVersion }, null, 2),
184
- "utf8"
185
- );
306
+ const tmp = `${UPDATE_CONFIG_PATH}.tmp`;
307
+ fs.writeFileSync(tmp, JSON.stringify({ ...existing, ...patch }, null, 2), "utf8");
308
+ fs.renameSync(tmp, UPDATE_CONFIG_PATH);
186
309
  }
187
310
 
188
311
  // ── Forward-update feed resolution (issue #1724) ────────────────────────────────
@@ -209,25 +332,19 @@ function _resolveFeedUrl() {
209
332
 
210
333
  /**
211
334
  * Point electron-updater at the configured generic (R2) feed + mutable channel.
212
- * When nothing is configured, enter the loud NO_CHANNEL state and set no feed —
213
- * an unconfigured updater must never silently pretend it's up to date.
335
+ * With nothing configured, each check instead reads the newest GitHub release
336
+ * on this install's channel (_applyGithubFeed) — where releases publish their
337
+ * latest*.yml today.
214
338
  *
215
- * @returns {boolean} true when a feed was applied, false when NO_CHANNEL.
339
+ * @returns {boolean} true when a feed is ready, false when setting it failed.
216
340
  */
217
341
  function _applyForwardFeed() {
218
342
  if (!autoUpdaterRef) return false;
219
343
  const url = _resolveFeedUrl();
220
344
  if (!url) {
221
345
  forwardFeedUrl = null;
222
- setState({
223
- status: STATES.NO_CHANNEL,
224
- error:
225
- "No update channel configured — set GAIA_UPDATE_FEED_URL (or feedUrl " +
226
- "in ~/.gaia/update-config.json) to your R2 channel base URL. " +
227
- "Auto-update is paused until a feed is configured.",
228
- });
229
- log("warn", "No update feed configured — auto-update paused (NO_CHANNEL)");
230
- return false;
346
+ log("info", "No update feed configured — checks read GitHub releases");
347
+ return true;
231
348
  }
232
349
  try {
233
350
  autoUpdaterRef.setFeedURL({
@@ -249,6 +366,79 @@ function _applyForwardFeed() {
249
366
  }
250
367
  }
251
368
 
369
+ /** -1, 0 or 1 comparing "0.25.2-rc.3"-style versions; candidates sort before their final. */
370
+ function _compareVersions(a, b) {
371
+ const parse = (v) => {
372
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.?(\d+))?$/.exec(String(v || ""));
373
+ if (!m) return null;
374
+ return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? Infinity : Number(m[4])];
375
+ };
376
+ const x = parse(a);
377
+ const y = parse(b);
378
+ if (!x || !y) return NaN;
379
+ for (let i = 0; i < 4; i++) {
380
+ if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
381
+ }
382
+ return 0;
383
+ }
384
+
385
+ /** The update manifest electron-updater reads on this platform. */
386
+ function _platformManifest() {
387
+ if (process.platform === "win32") return "latest.yml";
388
+ if (process.platform === "darwin") return "latest-mac.yml";
389
+ return "latest-linux.yml";
390
+ }
391
+
392
+ /**
393
+ * Point the feed at the newest GitHub release on this install's channel that
394
+ * carries this platform's installer and update manifest; returns its tag.
395
+ * Throws an actionable error when GitHub can't be read. Returns null, in the
396
+ * NO_CHANNEL state, when no release has an update for this platform.
397
+ */
398
+ async function _applyGithubFeed() {
399
+ const allowPrerelease = _prereleasesAllowed();
400
+ let releases;
401
+ try {
402
+ releases = await _fetchReleases();
403
+ } catch (err) {
404
+ throw new Error(_releasesErrorMessage(err, "check for updates"));
405
+ }
406
+ if (!Array.isArray(releases)) {
407
+ throw new Error(
408
+ `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}.`
409
+ );
410
+ }
411
+ const manifest = _platformManifest();
412
+ const candidates = releases.filter(
413
+ (r) =>
414
+ r &&
415
+ !r.draft &&
416
+ TAG_RE.test(r.tag_name || "") &&
417
+ !Number.isNaN(_compareVersions(r.tag_name, "0.0.0")) &&
418
+ (allowPrerelease || !r.prerelease) &&
419
+ _releaseHasPlatformAsset(r) &&
420
+ Array.isArray(r.assets) &&
421
+ r.assets.some((a) => a && a.name === manifest)
422
+ );
423
+ if (candidates.length === 0) {
424
+ const error =
425
+ `No GAIA release publishes a desktop update for this platform yet (it needs ${manifest} ` +
426
+ `and its installer), so GAIA can't update itself here — download an installer from ${RELEASES_PAGE}.`;
427
+ setState({ status: STATES.NO_CHANNEL, error });
428
+ log("warn", error);
429
+ return null;
430
+ }
431
+ candidates.sort((a, b) => _compareVersions(b.tag_name, a.tag_name));
432
+ const tag = candidates[0].tag_name;
433
+ autoUpdaterRef.setFeedURL({
434
+ provider: "generic",
435
+ url: `https://github.com/amd/gaia/releases/download/${tag}/`,
436
+ channel: DEFAULT_CHANNEL,
437
+ });
438
+ log("info", `Update feed set from GitHub: ${tag}`);
439
+ return tag;
440
+ }
441
+
252
442
  // ── State management ─────────────────────────────────────────────────────────
253
443
 
254
444
  function broadcastState() {
@@ -307,7 +497,7 @@ function _releaseHasPlatformAsset(release) {
307
497
  * @returns {Promise<Array<ReleaseInfo> | { error: string }>}
308
498
  */
309
499
  async function listReleases() {
310
- const allowPrerelease = process.env.GAIA_UPDATE_PRERELEASE === "1";
500
+ const allowPrerelease = _prereleasesAllowed();
311
501
  const pinnedVersion = _loadPin();
312
502
 
313
503
  // Get the running version — use the cached electronApi if available, else
@@ -330,23 +520,13 @@ async function listReleases() {
330
520
 
331
521
  let releases;
332
522
  try {
333
- const resp = await fetch(GITHUB_API_RELEASES, {
334
- headers: {
335
- Accept: "application/vnd.github+json",
336
- "User-Agent": "GAIA-Agent-UI",
337
- },
338
- });
339
- if (!resp.ok) {
340
- return {
341
- error: `Couldn't reach GitHub to list releases (HTTP ${resp.status}) — check your connection; you can still download installers from the Releases page.`,
342
- };
343
- }
344
- releases = await resp.json();
523
+ releases = await _fetchReleases();
345
524
  } catch (err) {
525
+ return { error: _releasesErrorMessage(err, "list releases") };
526
+ }
527
+ if (!Array.isArray(releases)) {
346
528
  return {
347
- error: `Couldn't reach GitHub to list releases — check your connection; you can still download installers from the Releases page. (${
348
- err && err.message
349
- })`,
529
+ 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}.`,
350
530
  };
351
531
  }
352
532
 
@@ -414,6 +594,7 @@ async function installVersion(tag) {
414
594
  // the download completes but the user dismisses the restart prompt. If the
415
595
  // pin can't be persisted we ABORT — proceeding would let the next launch
416
596
  // silently auto-upgrade past the rollback (no silent fallbacks).
597
+ const previousPin = state.pinnedVersion;
417
598
  try {
418
599
  _savePin(tag);
419
600
  } catch (err) {
@@ -423,6 +604,8 @@ async function installVersion(tag) {
423
604
  );
424
605
  }
425
606
  setState({ pinnedVersion: tag });
607
+ // A pin always stops background downloads; this one is downloaded below.
608
+ autoUpdaterRef.autoDownload = false;
426
609
 
427
610
  checkInProgress = true;
428
611
  try {
@@ -441,14 +624,54 @@ async function installVersion(tag) {
441
624
  // in-flight rollback download (which outlives this checkInProgress window),
442
625
  // so it's intentionally deferred.
443
626
 
444
- // Kick off the targeted check — will fire update-available → downloads →
445
- // update-downloaded → our existing dialog prompts restart.
627
+ // Targeted check → update-available. autoDownload is off (default, and
628
+ // always while pinned), so download explicitly; update-downloaded then
629
+ // prompts the restart.
446
630
  await autoUpdaterRef.checkForUpdates();
631
+ if (state.status !== STATES.AVAILABLE) {
632
+ throw new Error(
633
+ `GAIA ${tag} has no installer for this platform at its release feed` +
634
+ (state.error ? `: ${state.error}` : ".") +
635
+ " Download it from https://github.com/amd/gaia/releases instead."
636
+ );
637
+ }
638
+ setState({ status: STATES.DOWNLOADING, progress: 0, error: null });
639
+ await autoUpdaterRef.downloadUpdate();
640
+ } catch (err) {
641
+ // Don't leave the app pinned to a version that never installed.
642
+ const pinNote = _restorePinAfterFailedInstall(previousPin, tag);
643
+ const msg = (err && err.message) || String(err);
644
+ setState({ status: STATES.ERROR, error: msg });
645
+ throw new Error(`${msg} ${pinNote}`);
447
646
  } finally {
448
647
  checkInProgress = false;
449
648
  }
450
649
  }
451
650
 
651
+ /** Undo the pin a failed install wrote; returns the sentence saying where things stand. */
652
+ function _restorePinAfterFailedInstall(previousPin, failedTag) {
653
+ try {
654
+ _savePin(previousPin || null);
655
+ } catch (err) {
656
+ // The file still pins failedTag; say so rather than claim otherwise.
657
+ log("error", "Failed to restore the pin after a failed install:", err && err.message);
658
+ return (
659
+ `The pin to ${failedTag} could not be undone in ${UPDATE_CONFIG_PATH} ` +
660
+ `(${(err && err.message) || err}) — fix that file, then use Unpin in Settings → General → Updates.`
661
+ );
662
+ }
663
+ if (!previousPin) {
664
+ clearPin();
665
+ return "Updates are not paused.";
666
+ }
667
+ setState({ pinnedVersion: previousPin });
668
+ autoUpdaterRef.setFeedURL({
669
+ provider: "generic",
670
+ url: `https://github.com/amd/gaia/releases/download/${previousPin}/`,
671
+ });
672
+ return `Still pinned to ${previousPin}.`;
673
+ }
674
+
452
675
  // ── clearPin / resumeUpdates ──────────────────────────────────────────────────
453
676
 
454
677
  /**
@@ -456,8 +679,8 @@ async function installVersion(tag) {
456
679
  *
457
680
  * Restores the configured forward (R2 generic) feed, sets autoDownload=true,
458
681
  * and clears allowDowngrade so the next check is the normal forward-only flow.
459
- * If no feed is configured, resume lands in the loud NO_CHANNEL state rather
460
- * than silently reverting to a stale rollback feed.
682
+ * With no feed configured, the next check reads GitHub releases again rather
683
+ * than staying on the rollback feed.
461
684
  */
462
685
  function clearPin() {
463
686
  // A failed clear is non-fatal — worst case the stale pin re-pauses updates
@@ -470,16 +693,101 @@ function clearPin() {
470
693
  setState({ pinnedVersion: null });
471
694
 
472
695
  if (autoUpdaterRef) {
473
- autoUpdaterRef.autoDownload = true;
696
+ autoUpdaterRef.autoDownload = state.autoUpdate;
474
697
  autoUpdaterRef.allowDowngrade = false;
475
698
  // Restore the forward channel (overrides any generic/tagged rollback feed
476
- // set by installVersion). Loud NO_CHANNEL if unconfigured.
699
+ // set by installVersion); unconfigured means GitHub releases.
477
700
  _applyForwardFeed();
478
701
  }
479
702
 
480
703
  log("info", "Version pin cleared — auto-updates resumed");
481
704
  }
482
705
 
706
+ // ── Auto-update preference / user-driven install ─────────────────────────────
707
+
708
+ /**
709
+ * Persist the auto-update preference. Throws when it can't be saved, so the
710
+ * Settings toggle never shows a value the next launch won't honour.
711
+ */
712
+ async function setAutoUpdate(enabled) {
713
+ if (typeof enabled !== "boolean") {
714
+ throw new TypeError(`setAutoUpdate expects true or false, got ${typeof enabled}`);
715
+ }
716
+ try {
717
+ _writeConfig({ autoUpdate: enabled });
718
+ } catch (err) {
719
+ throw new Error(
720
+ `Couldn't save the auto-update setting to ${UPDATE_CONFIG_PATH}: ${(err && err.message) || err}`
721
+ );
722
+ }
723
+ setState({ autoUpdate: enabled });
724
+ if (autoUpdaterRef) {
725
+ autoUpdaterRef.autoDownload = enabled && !state.pinnedVersion;
726
+ autoUpdaterRef.allowPrerelease = _prereleasesAllowed();
727
+ }
728
+ log("info", `Auto-update ${enabled ? "enabled" : "disabled"}`);
729
+ // Turning it on with an update already offered means "install it".
730
+ if (enabled && !state.pinnedVersion && state.status === STATES.AVAILABLE) {
731
+ await downloadUpdate();
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Persist whether release candidates are offered as updates. Independent of
737
+ * auto-update: it changes what is offered, not whether it installs unasked.
738
+ */
739
+ async function setIncludePrereleases(enabled) {
740
+ if (typeof enabled !== "boolean") {
741
+ throw new TypeError(`setIncludePrereleases expects true or false, got ${typeof enabled}`);
742
+ }
743
+ try {
744
+ _writeConfig({ includePrereleases: enabled });
745
+ } catch (err) {
746
+ throw new Error(
747
+ `Couldn't save the release-candidate setting to ${UPDATE_CONFIG_PATH}: ${(err && err.message) || err}`
748
+ );
749
+ }
750
+ setState({ includePrereleases: enabled });
751
+ if (autoUpdaterRef) {
752
+ autoUpdaterRef.allowPrerelease = _prereleasesAllowed();
753
+ }
754
+ log("info", `Release-candidate updates ${enabled ? "enabled" : "disabled"}`);
755
+ }
756
+
757
+ /** "Update now": download the update a check found; the restart dialog follows. */
758
+ async function downloadUpdate() {
759
+ if (!autoUpdaterRef) {
760
+ throw new Error("downloadUpdate called before auto-updater was initialised");
761
+ }
762
+ if (state.status !== STATES.AVAILABLE) {
763
+ throw new Error(
764
+ `No update is waiting to download (status: ${state.status}) — check for updates first.`
765
+ );
766
+ }
767
+ log("info", `User asked to install ${state.version}`);
768
+ setState({ status: STATES.DOWNLOADING, progress: 0, error: null });
769
+ try {
770
+ await autoUpdaterRef.downloadUpdate();
771
+ } catch (err) {
772
+ const message = (err && err.message) || String(err);
773
+ setState({ status: STATES.ERROR, error: message });
774
+ throw new Error(`Downloading GAIA ${state.version || ""} failed: ${message}`);
775
+ }
776
+ }
777
+
778
+ /** Restart into a downloaded update, asking first if work would be cut off. */
779
+ async function restartToInstall() {
780
+ if (!autoUpdaterRef || state.status !== STATES.DOWNLOADED) {
781
+ throw new Error(`No downloaded update to install (status: ${state.status}).`);
782
+ }
783
+ if (!(await confirmRestartRef())) {
784
+ log("info", "Restart put off: work is still running — will install on next quit");
785
+ return;
786
+ }
787
+ log("info", "User chose to restart from the update chip — calling quitAndInstall");
788
+ autoUpdaterRef.quitAndInstall(false, true);
789
+ }
790
+
483
791
  // ── Core check ───────────────────────────────────────────────────────────────
484
792
 
485
793
  async function checkForUpdates() {
@@ -492,15 +800,9 @@ async function checkForUpdates() {
492
800
  log("warn", "checkForUpdates called before init — ignoring");
493
801
  return;
494
802
  }
495
- // No forward feed and not pinned → surface the loud NO_CHANNEL state instead
496
- // of asking electron-updater to check a feed that doesn't exist. Re-resolve
497
- // first so a feed written to config after init() is picked up here.
803
+ // Re-resolve so a feed written to config after init() is picked up here.
498
804
  if (!forwardFeedUrl && !state.pinnedVersion) {
499
805
  if (!_applyForwardFeed()) return;
500
- // Feed just became available after a no-feed (NO_CHANNEL) start — init()
501
- // skipped arming the periodic scheduler, so arm it now or background checks
502
- // never resume once the feed goes live.
503
- if (!scheduledTimeout) scheduleNextCheck();
504
806
  }
505
807
  if (checkInProgress) {
506
808
  log("info", "Skipping check — another check is already in progress");
@@ -509,6 +811,11 @@ async function checkForUpdates() {
509
811
  checkInProgress = true;
510
812
  setState({ status: STATES.CHECKING, error: null });
511
813
  try {
814
+ // No configured feed: aim at the newest GitHub release on this channel,
815
+ // re-read every check so a new release is found.
816
+ if (!forwardFeedUrl && !state.pinnedVersion && !(await _applyGithubFeed())) {
817
+ return;
818
+ }
512
819
  log("info", "Checking for updates...");
513
820
  await autoUpdaterRef.checkForUpdates();
514
821
  } catch (err) {
@@ -669,6 +976,22 @@ function registerIpcHandlers() {
669
976
  clearPin();
670
977
  return getState();
671
978
  });
979
+ ipcMain.handle("gaia:update:set-auto-update", async (_event, enabled) => {
980
+ await setAutoUpdate(enabled);
981
+ return getState();
982
+ });
983
+ ipcMain.handle("gaia:update:set-include-prereleases", async (_event, enabled) => {
984
+ await setIncludePrereleases(enabled);
985
+ return getState();
986
+ });
987
+ ipcMain.handle("gaia:update:download", async () => {
988
+ await downloadUpdate();
989
+ return getState();
990
+ });
991
+ ipcMain.handle("gaia:update:restart", async () => {
992
+ await restartToInstall();
993
+ return getState();
994
+ });
672
995
 
673
996
  ipcHandlersRegistered = true;
674
997
  }
@@ -682,6 +1005,10 @@ function unregisterIpcHandlers() {
682
1005
  "gaia:update:list-releases",
683
1006
  "gaia:update:install-version",
684
1007
  "gaia:update:resume",
1008
+ "gaia:update:set-auto-update",
1009
+ "gaia:update:set-include-prereleases",
1010
+ "gaia:update:download",
1011
+ "gaia:update:restart",
685
1012
  ];
686
1013
  for (const ch of channels) {
687
1014
  try {
@@ -739,6 +1066,10 @@ function init(mainWindow, options = {}) {
739
1066
  state.pinnedVersion = savedPin;
740
1067
  log("info", `Found persisted version pin: ${savedPin} — autoDownload will be paused`);
741
1068
  }
1069
+ const loaded = _loadAutoUpdate();
1070
+ state.autoUpdate = loaded.autoUpdate;
1071
+ state.includePrereleases = loaded.includePrereleases;
1072
+ state.configError = loaded.error;
742
1073
 
743
1074
  // Lazy-load Electron and electron-updater. Any failure here is logged
744
1075
  // and the updater stays in `idle` — we never crash the app.
@@ -780,14 +1111,13 @@ function init(mainWindow, options = {}) {
780
1111
 
781
1112
  // Configure electron-updater.
782
1113
  try {
783
- // When pinned, suppress autoDownload so the scheduled forward-only check
784
- // cannot silently download/install a newer version (AC2).
785
- autoUpdaterRef.autoDownload = savedPin ? false : true;
1114
+ // Off by default; a pin always suppresses it so the scheduled forward-only
1115
+ // check cannot silently download/install a newer version (AC2).
1116
+ autoUpdaterRef.autoDownload = !savedPin && state.autoUpdate;
786
1117
  autoUpdaterRef.autoInstallOnAppQuit = true;
787
1118
  autoUpdaterRef.disableWebInstaller = true;
788
1119
  autoUpdaterRef.allowDowngrade = false;
789
- autoUpdaterRef.allowPrerelease =
790
- process.env.GAIA_UPDATE_PRERELEASE === "1";
1120
+ autoUpdaterRef.allowPrerelease = _prereleasesAllowed();
791
1121
 
792
1122
  autoUpdaterRef.logger = {
793
1123
  info: (m) => log("info", String(m)),
@@ -812,10 +1142,9 @@ function init(mainWindow, options = {}) {
812
1142
  log("warn", "Failed to register IPC handlers:", err && err.message);
813
1143
  }
814
1144
 
815
- // Point the updater at the configured R2 generic feed (#1724). When a pin is
816
- // active, installVersion() owns the feed (rollback path), so skip. With no
817
- // pin and no feed, _applyForwardFeed enters the loud NO_CHANNEL state and we
818
- // don't schedule checks against a nonexistent feed.
1145
+ // Point the updater at the configured R2 generic feed (#1724), or at GitHub
1146
+ // releases when none is configured. When a pin is active, installVersion()
1147
+ // owns the feed (rollback path), so skip.
819
1148
  let feedReady = true;
820
1149
  if (!savedPin) {
821
1150
  feedReady = _applyForwardFeed();
@@ -823,11 +1152,8 @@ function init(mainWindow, options = {}) {
823
1152
 
824
1153
  initialized = true;
825
1154
 
826
- if (!feedReady && !savedPin) {
827
- log(
828
- "info",
829
- "Auto-updater initialized without a channel — checks paused until a feed is configured"
830
- );
1155
+ if (!feedReady) {
1156
+ log("error", "Auto-updater initialized but its feed could not be set — see the error above");
831
1157
  return;
832
1158
  }
833
1159
 
@@ -872,5 +1198,9 @@ module.exports = {
872
1198
  listReleases,
873
1199
  installVersion,
874
1200
  clearPin,
1201
+ setAutoUpdate,
1202
+ setIncludePrereleases,
1203
+ downloadUpdate,
1204
+ restartToInstall,
875
1205
  STATES,
876
1206
  };