@amd-gaia/agent-ui 0.17.4 → 0.17.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/main.cjs CHANGED
@@ -17,13 +17,47 @@ const { app, BrowserWindow, dialog, shell } = require("electron");
17
17
  const path = require("path");
18
18
  const fs = require("fs");
19
19
  const os = require("os");
20
- const { spawn } = require("child_process");
20
+ const { spawn, spawnSync } = require("child_process");
21
+ const { pathToFileURL } = require("url");
22
+
23
+ // ── Shared log path ───────────────────────────────────────────────────────────
24
+ // Single source of truth used by installSafetyNet AND installMainLogTee so
25
+ // both write to the same file without independent path computations that
26
+ // could drift apart.
27
+ const _GAIA_DIR = path.join(os.homedir(), ".gaia");
28
+ const _MAIN_LOG_PATH = path.join(_GAIA_DIR, "electron-main.log");
29
+
30
+ // ── Safety net (issue #934) ───────────────────────────────────────────────────
31
+ // Install top-level error handlers BEFORE any service module is required so
32
+ // that synchronous throws at module-load time are caught and shown as a
33
+ // GAIA-branded error box instead of Electron's bare JS-error dialog.
34
+ // Extracted into main-safety-net.cjs so tests can require it without
35
+ // triggering main.cjs side effects (Electron modules, service requires).
36
+ // Wrapped in try/catch: a corrupt ASAR or bad path would otherwise bypass the
37
+ // very handler we are trying to install, falling through to Electron's bare
38
+ // JS-error dialog.
39
+ let installSafetyNet, installLogTee, _fatalHandler;
40
+ try {
41
+ ({ installSafetyNet, installLogTee } = require("./main-safety-net.cjs"));
42
+ ({ fatal: _fatalHandler } = installSafetyNet({
43
+ logPath: _MAIN_LOG_PATH,
44
+ dialogModule: dialog,
45
+ appModule: app,
46
+ }));
47
+ } catch (err) {
48
+ try { process.stderr.write(`[main] safety-net load failed: ${err.message}\n`); } catch { }
49
+ try { dialog.showErrorBox("GAIA failed to start", String((err && err.stack) || err)); } catch { }
50
+ // Synchronous exit: service module requires below have no uncaughtException
51
+ // handler installed, so execution cannot safely continue.
52
+ process.exit(1);
53
+ }
21
54
 
22
55
  // Services (loaded after app.whenReady)
23
56
  const TrayManager = require("./services/tray-manager.cjs");
24
57
  const AgentProcessManager = require("./services/agent-process-manager.cjs");
25
58
  const NotificationService = require("./services/notification-service.cjs");
26
59
  const PortManager = require("./services/port-manager.cjs");
60
+ const { buildIndexQuery } = require("./services/index-query.cjs");
27
61
  const backendInstaller = require("./services/backend-installer.cjs");
28
62
  const installerProgressDialog = require("./services/backend-installer-progress-dialog.cjs");
29
63
  const autoUpdater = require("./services/auto-updater.cjs");
@@ -52,9 +86,8 @@ if (process.platform === "linux") {
52
86
  // diagnostics bundler has something to attach.
53
87
  (function installMainLogTee() {
54
88
  try {
55
- const gaiaDir = path.join(os.homedir(), ".gaia");
56
- try { fs.mkdirSync(gaiaDir, { recursive: true }); } catch { /* ignore */ }
57
- const logPath = path.join(gaiaDir, "electron-main.log");
89
+ try { fs.mkdirSync(_GAIA_DIR, { recursive: true }); } catch { /* ignore */ }
90
+ const logPath = _MAIN_LOG_PATH;
58
91
 
59
92
  // Rotate if > 5 MB — truncate to last ~5 MB on startup.
60
93
  try {
@@ -80,6 +113,10 @@ if (process.platform === "linux") {
80
113
  }
81
114
 
82
115
  const stream = fs.createWriteStream(logPath, { flags: "a" });
116
+ // Root-cause fix for #934: stream.write() after end emits 'error'
117
+ // asynchronously — the try/catch in wrap() below doesn't catch it.
118
+ // This listener absorbs the event before it becomes uncaughtException.
119
+ installLogTee({ stream, logPath });
83
120
  stream.write(
84
121
  `\n──── electron-main opened (${new Date().toISOString()}) pid=${process.pid} ────\n`
85
122
  );
@@ -155,6 +192,11 @@ let backendStderrTail = [];
155
192
  let isIntentionalKill = false;
156
193
  let mainWindow = null;
157
194
 
195
+ // True until createWindow() runs. Guards window-all-closed from firing app.quit()
196
+ // while the backend-installer progress dialog is open (it's the only window during
197
+ // bootstrap, so destroying it would trigger a premature quit — issue #934).
198
+ let isBootstrapping = true;
199
+
158
200
  /** @type {TrayManager | null} */
159
201
  let trayManager = null;
160
202
 
@@ -206,6 +248,70 @@ async function startBackend() {
206
248
  backendPort = DEFAULT_BACKEND_PORT;
207
249
  }
208
250
  healthCheckUrl = `http://localhost:${backendPort}/api/health`;
251
+ // Clean up any stale backend PID left from previous runs. The AppImage
252
+ // may be double-clicked multiple times or crash, leaving orphaned
253
+ // backend processes. We store a PID file under ~/.gaia/backend.pid and
254
+ // attempt to SIGTERM any still-running PID before starting a new backend.
255
+ try {
256
+ const pidFile = path.join(_GAIA_DIR, "backend.pid");
257
+ if (fs.existsSync(pidFile)) {
258
+ try {
259
+ const existingPid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
260
+ if (!Number.isNaN(existingPid)) {
261
+ console.log(`[main] Found existing backend pidfile (${existingPid}) — verifying process identity`);
262
+
263
+ // Verify the PID belongs to a GAIA backend before signalling it.
264
+ // Prefer a conservative match on the process command to avoid
265
+ // accidentally killing unrelated user processes (TOCTOU mitigation).
266
+ let isBackend = false;
267
+ try {
268
+ if (process.platform === "linux") {
269
+ const procCmd = `/proc/${existingPid}/cmdline`;
270
+ if (fs.existsSync(procCmd)) {
271
+ const raw = fs.readFileSync(procCmd, "utf8");
272
+ // /proc/<pid>/cmdline is NUL-separated; use a stricter match to
273
+ // avoid killing unrelated Python processes (Jupyter, LSP, etc.).
274
+ // Legitimate backend uses: `gaia chat --ui --ui-port <port>`
275
+ const looksLikeGaiaBackend = raw.includes("gaia") && (raw.includes("chat") || raw.includes("--ui-port"));
276
+ if (looksLikeGaiaBackend) {
277
+ isBackend = true;
278
+ }
279
+ }
280
+ } else if (process.platform === "darwin") {
281
+ try {
282
+ const out = spawnSync("ps", ["-p", String(existingPid), "-o", "command="], { encoding: "utf8" });
283
+ const cmd = (out && out.stdout) ? out.stdout : "";
284
+ const looksLikeGaiaBackend = cmd.includes("gaia") && (cmd.includes("chat") || cmd.includes("--ui-port"));
285
+ if (looksLikeGaiaBackend) {
286
+ isBackend = true;
287
+ }
288
+ } catch {
289
+ // fallthrough
290
+ }
291
+ }
292
+ } catch (err) {
293
+ console.warn(`[main] Could not verify pid ${existingPid}: ${err.message}`);
294
+ }
295
+
296
+ if (!isBackend) {
297
+ console.log(`[main] PID ${existingPid} does not appear to be a GAIA backend; skipping kill`);
298
+ } else {
299
+ try {
300
+ await portManager.killBackend(existingPid);
301
+ console.log(`[main] Cleaned up previous backend pid ${existingPid}`);
302
+ } catch (err) {
303
+ console.warn(`[main] Could not clean previous backend pid ${existingPid}: ${err.message}`);
304
+ }
305
+ }
306
+ }
307
+ } catch (err) {
308
+ console.warn(`[main] Failed reading backend pidfile: ${err.message}`);
309
+ }
310
+ try { fs.unlinkSync(pidFile); } catch { /* ignore */ }
311
+ }
312
+ } catch (err) {
313
+ console.warn(`[main] PID cleanup check failed: ${err.message}`);
314
+ }
209
315
 
210
316
  console.log(`Starting backend: ${gaiaCmd} chat --ui --ui-port ${backendPort}`);
211
317
 
@@ -248,6 +354,18 @@ async function startBackend() {
248
354
  console.error("Failed to start backend:", err.message);
249
355
  });
250
356
 
357
+ // Write a PID file so subsequent AppImage launches can detect and
358
+ // cleanup this backend if it becomes orphaned. The pidfile is removed
359
+ // when the child exits.
360
+ try {
361
+ try { fs.mkdirSync(_GAIA_DIR, { recursive: true }); } catch { /* ignore */ }
362
+ const pidFile = path.join(_GAIA_DIR, "backend.pid");
363
+ fs.writeFileSync(pidFile, String(child.pid), { mode: 0o600 });
364
+ console.log(`[main] Wrote backend pidfile ${pidFile} (pid=${child.pid})`);
365
+ } catch (err) {
366
+ console.warn(`[main] Could not write backend pidfile: ${err.message}`);
367
+ }
368
+
251
369
  child.on("exit", (code, signal) => {
252
370
  if (code !== 0 && code !== null) {
253
371
  console.error(`Backend exited with code ${code} (signal=${signal})`);
@@ -259,6 +377,20 @@ async function startBackend() {
259
377
  // Fire-and-forget — don't block the event loop.
260
378
  void handleBackendCrash(code, signal);
261
379
  }
380
+
381
+ // Remove pidfile on exit to avoid leaving stale PID behind.
382
+ try {
383
+ const pidFile = path.join(_GAIA_DIR, "backend.pid");
384
+ if (fs.existsSync(pidFile)) {
385
+ const p = fs.readFileSync(pidFile, "utf8").trim();
386
+ if (p && parseInt(p, 10) === child.pid) {
387
+ try { fs.unlinkSync(pidFile); } catch { /* ignore */ }
388
+ }
389
+ }
390
+ } catch (err) {
391
+ // Non-fatal — just log and continue.
392
+ console.warn(`[main] Failed to remove backend pidfile: ${err.message}`);
393
+ }
262
394
  });
263
395
 
264
396
  return child;
@@ -421,13 +553,18 @@ async function loadApp() {
421
553
  // http://localhost:4200/ would show raw JSON instead of the UI.
422
554
  //
423
555
  // Pass the real backend base URL as a query parameter so the renderer
424
- // can reach whatever random port port-manager picked (see #851).
425
- // Without this, the compiled bundle falls back to its hardcoded
426
- // `http://localhost:4200/api` and every API call 404s.
556
+ // can reach whatever random port port-manager picked (see #851). The
557
+ // renderer (apiBase.ts) validates this value against an allowlist
558
+ // before using it keep buildIndexQuery in sync with TRUSTED_API_RE.
427
559
  const indexPath = path.join(distPath, "index.html");
428
- const apiBase = `http://127.0.0.1:${backendPort}/api`;
429
- console.log("Loading app from:", indexPath, "api:", apiBase);
430
- await mainWindow.loadFile(indexPath, { query: { api: apiBase } });
560
+ const indexQuery = buildIndexQuery(backendPort);
561
+ console.log("Loading app from:", indexPath, "api:", indexQuery.api);
562
+ // Use pathToFileURL so the file:// URL always has forward slashes on
563
+ // Windows — Chromium 130+ (Electron 40) rejects backslash file URLs
564
+ // that Node's url.format() (used by loadFile) produces on Windows.
565
+ const fileUrl = pathToFileURL(indexPath);
566
+ fileUrl.search = new URLSearchParams(indexQuery).toString();
567
+ await mainWindow.loadURL(fileUrl.href);
431
568
  } else {
432
569
  // Show a simple loading/error page
433
570
  mainWindow.loadURL(
@@ -673,6 +810,7 @@ app.whenReady().then(async () => {
673
810
 
674
811
  // Create the window (hidden until ready-to-show)
675
812
  createWindow();
813
+ isBootstrapping = false; // progress dialog is gone; window-all-closed may now quit
676
814
 
677
815
  // Initialize services (tray, agent manager, notifications)
678
816
  initializeServices();
@@ -736,11 +874,21 @@ app.whenReady().then(async () => {
736
874
  mainWindow.show();
737
875
  }
738
876
  });
877
+ }).catch((err) => {
878
+ // Route explicit rejection through the safety-net so the user gets a
879
+ // GAIA-branded dialog and a stack trace in the log (issue #934).
880
+ _fatalHandler(err);
739
881
  });
740
882
 
741
883
  // ── Window-all-closed (C4 fix) ────────────────────────────────────────────
742
884
  // Don't quit when window is hidden — tray keeps app alive
743
885
  app.on("window-all-closed", () => {
886
+ // During bootstrap the progress dialog is the only open window. Destroying
887
+ // it (progress.close()) fires this event before the main window exists, which
888
+ // would trigger a premature app.quit() that races with the startup sequence
889
+ // and causes loadURL() to fail with ERR_FAILED (-2) — issue #934.
890
+ if (isBootstrapping) return;
891
+
744
892
  // If minimize-to-tray is active, the window is just hidden, not closed.
745
893
  // Only quit on macOS if the user explicitly quit (Cmd+Q).
746
894
  const trayActive = trayManager && trayManager.minimizeToTray;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amd-gaia/agent-ui",
3
- "version": "0.17.4",
3
+ "version": "0.17.6",
4
4
  "type": "module",
5
5
  "productName": "GAIA Agent UI",
6
6
  "description": "Privacy-first agentic AI interface with document Q&A - runs 100% locally on AMD Ryzen AI",
@@ -35,6 +35,7 @@
35
35
  "bin/",
36
36
  "dist/",
37
37
  "main.cjs",
38
+ "main-safety-net.cjs",
38
39
  "preload.cjs",
39
40
  "services/",
40
41
  "assets/",
@@ -345,55 +345,89 @@ async function showFailureDialog(parentWindow, errorInfo = {}) {
345
345
  .filter(Boolean)
346
346
  .join("\n");
347
347
 
348
- const result = await dialog.showMessageBox(parentWindow || null, {
349
- type: "error",
350
- title: "GAIA install failed",
351
- message,
352
- detail,
353
- buttons: [
354
- "Retry",
355
- "Manual install instructions",
356
- "Copy log path",
357
- "Open log file",
358
- "Quit",
359
- ],
360
- defaultId: 0,
361
- cancelId: 4,
362
- noLink: true,
363
- });
364
-
365
- switch (result.response) {
366
- case 0:
367
- return "retry";
368
- case 1: {
369
- try {
370
- await shell.openExternal("https://amd-gaia.ai/quickstart#cli-install");
371
- } catch {
372
- // ignore
348
+ // Loop so that certain actions (Copy/Open log) return control to the
349
+ // same dialog rather than exiting the flow. The dialog includes a
350
+ // one-click "Install uv" action which attempts the packaged rescue
351
+ // installer and then returns 'retry' on success so the caller can
352
+ // re-run the full backend install.
353
+ const buttons = [
354
+ "Install uv (auto)",
355
+ "Retry",
356
+ "Manual install instructions",
357
+ "Copy log path",
358
+ "Open log file",
359
+ "Quit",
360
+ ];
361
+
362
+ // Helper to show the dialog and handle the response.
363
+ const show = async () => {
364
+ const result = await dialog.showMessageBox(parentWindow || null, {
365
+ type: "error",
366
+ title: "GAIA install failed",
367
+ message,
368
+ detail,
369
+ buttons,
370
+ defaultId: 0,
371
+ cancelId: buttons.length - 1,
372
+ noLink: true,
373
+ });
374
+
375
+ switch (result.response) {
376
+ case 0: {
377
+ // Run the full packaged install flow (ensureBackend) so the user
378
+ // gets a single-click recovery that attempts the entire backend
379
+ // install rather than only uv provisioning. Show progress UI
380
+ // while the operation runs. On success, tell the caller to retry
381
+ // (which will detect READY and proceed); on failure, re-show the
382
+ // dialog with augmented details.
383
+ const { window, onProgress, close } = createProgressWindow();
384
+ try {
385
+ await installer.ensureBackend({ onProgress, isPackaged: true });
386
+ try { close(); } catch {}
387
+ return "retry";
388
+ } catch (err) {
389
+ try { close(); } catch {}
390
+ const nextInfo = Object.assign({}, errorInfo, {
391
+ message: err.message || String(err),
392
+ stage: err.stage || "ensure-backend",
393
+ suggestion: err.suggestion || errorInfo.suggestion,
394
+ });
395
+ return showFailureDialog(parentWindow, nextInfo);
396
+ }
373
397
  }
374
- return "manual";
375
- }
376
- case 2: {
377
- try {
378
- clipboard.writeText(logPath);
379
- } catch {
380
- // ignore
398
+ case 1:
399
+ return "retry";
400
+ case 2: {
401
+ try {
402
+ await shell.openExternal("https://amd-gaia.ai/quickstart#cli-install");
403
+ } catch {
404
+ // ignore
405
+ }
406
+ return "manual";
381
407
  }
382
- // Keep the user in the loop — re-show the dialog so they can pick an action.
383
- return showFailureDialog(parentWindow, errorInfo);
384
- }
385
- case 3: {
386
- try {
387
- await shell.openPath(logPath);
388
- } catch {
389
- // ignore
408
+ case 3: {
409
+ try {
410
+ clipboard.writeText(logPath);
411
+ } catch {
412
+ // ignore
413
+ }
414
+ return showFailureDialog(parentWindow, errorInfo);
415
+ }
416
+ case 4: {
417
+ try {
418
+ await shell.openPath(logPath);
419
+ } catch {
420
+ // ignore
421
+ }
422
+ return showFailureDialog(parentWindow, errorInfo);
390
423
  }
391
- return showFailureDialog(parentWindow, errorInfo);
424
+ case 5:
425
+ default:
426
+ return "quit";
392
427
  }
393
- case 4:
394
- default:
395
- return "quit";
396
- }
428
+ };
429
+
430
+ return show();
397
431
  }
398
432
 
399
433
  // ── Pre-check failure dialogs ───────────────────────────────────────────────
@@ -77,10 +77,28 @@ const NETWORK_CHECK_TIMEOUT_MS = 5000;
77
77
  // archive against upstream's published .sha256, then extracts the `uv` binary
78
78
  // which is what `ensureUv()` hashes at runtime.
79
79
  //
80
- // Currently pinned: uv v0.5.14 linux-x64.
80
+ // Currently pinned: uv v0.5.14 linux-x64, mac-arm64.
81
+ // (win-x64 deferred to a follow-up issue — its SHA must ship together with an
82
+ // NSIS structural-smoke verifier, not on its own; see the #849 lesson.)
83
+ //
84
+ // IMPORTANT: per-platform SHA origin differs:
85
+ // - linux-x64: raw extracted-from-tarball digest (no post-build modification).
86
+ // - mac-arm64: POST-CODESIGN digest. electron-builder code-signs the bundled
87
+ // uv during packaging, so this hash matches what ensureUv() sees
88
+ // at runtime, NOT the upstream tarball. Bumping this pin means
89
+ // running the CI build, then copying the SHA from the
90
+ // dmg-structural-smoke failure message — never from `shasum`
91
+ // against the freshly downloaded tarball.
81
92
  const BUNDLED_UV_VERSION = "0.5.14";
82
93
  const BUNDLED_UV_SHA256 = {
83
94
  "linux-x64": "0e05d828b5708e8a927724124db3746396afddad6273c47283d7c562dc795bd6",
95
+ // The Windows extracted uv.exe SHA is populated by CI during the
96
+ // build step. The placeholder MUST be replaced in CI before packaging
97
+ // so runtime verification remains strict.
98
+ "win-x64": "055d55eec85a91cfb5e9c8bc7f6463f9883866796c5bcb205fbcdfed9c088c88",
99
+ // mac-arm64: POST-codesign digest. CI should populate this value when
100
+ // packaging the macOS DMG and running the dmg-structural-smoke job.
101
+ "mac-arm64": "6099aa8cd701f0c81227ee30c304777ce151e4d47c53a75ce53cd2243448d8c8",
84
102
  };
85
103
 
86
104
  const MANAGED_UV_DIR = path.join(GAIA_HOME, "bin");
@@ -697,9 +715,12 @@ function findBundledUvResource() {
697
715
  */
698
716
  async function installBundledUv(sourcePath, platformKey) {
699
717
  const expected = BUNDLED_UV_SHA256[platformKey];
700
- if (!expected) {
718
+ if (!expected || expected.startsWith("<")) {
719
+ // Enforce strict verification: builds MUST populate the expected SHA
720
+ // for packaged binaries. Failing fast prevents shipping an unverified
721
+ // uv binary which would be a supply-chain regression.
701
722
  throw new InstallError(
702
- `No bundled uv checksum registered for platform ${platformKey}.`,
723
+ `No bundled uv checksum registered for platform ${platformKey}. Build must populate BUNDLED_UV_SHA256.${platformKey}`,
703
724
  { stage: STAGES.ENSURE_UV }
704
725
  );
705
726
  }
@@ -741,16 +762,20 @@ async function installBundledUv(sourcePath, platformKey) {
741
762
  );
742
763
  }
743
764
 
744
- if (actual !== expected) {
745
- try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
746
- throw new InstallError(
747
- `Bundled uv SHA256 mismatch (expected ${expected}, got ${actual}).`,
748
- {
749
- stage: STAGES.ENSURE_UV,
750
- suggestion:
751
- "The AppImage/installer may be corrupt. Re-download from https://amd-gaia.ai and try again.",
752
- }
753
- );
765
+ if (expected) {
766
+ if (actual !== expected) {
767
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
768
+ throw new InstallError(
769
+ `Bundled uv SHA256 mismatch (expected ${expected}, got ${actual}).`,
770
+ {
771
+ stage: STAGES.ENSURE_UV,
772
+ suggestion:
773
+ "The AppImage/installer may be corrupt. Re-download from https://amd-gaia.ai and try again.",
774
+ }
775
+ );
776
+ }
777
+ } else {
778
+ log("No expected SHA registered for bundled uv; installed binary will not be verified locally.");
754
779
  }
755
780
 
756
781
  try {
@@ -836,6 +861,10 @@ async function ensureUv({ onProgress, isPackaged } = {}) {
836
861
 
837
862
  // Verify the source resource matches the manifest before copying —
838
863
  // catches AppImage corruption before we touch the user's home.
864
+ // Enforce that the packaged build provides an expected SHA for the
865
+ // bundled resource. CI replaces the placeholder with the extracted
866
+ // binary's SHA during the build; missing/placeholder values are a
867
+ // build-time error and are rejected at runtime here.
839
868
  const srcHash = await sha256File(bundled);
840
869
  if (srcHash !== expectedSha) {
841
870
  throw new InstallError(
@@ -925,6 +954,52 @@ async function ensureUv({ onProgress, isPackaged } = {}) {
925
954
  return;
926
955
  }
927
956
 
957
+ // Packaged Windows rescue: try the Astral PowerShell installer even when
958
+ // running a packaged build. This provides an automated recovery path for
959
+ // end users on clean machines that don't have uv and where the installer
960
+ // build unexpectedly omitted a bundled binary. It is a last-resort and
961
+ // non-fatal attempt; on failure we fall through to the generic error.
962
+ if (IS_WINDOWS && !isDev) {
963
+ log("Packaged Windows: attempting automated uv installer (rescue)");
964
+ try {
965
+ const rescue = await runCommand(
966
+ "powershell",
967
+ [
968
+ "-ExecutionPolicy",
969
+ "Bypass",
970
+ "-Command",
971
+ "irm https://astral.sh/uv/install.ps1 | iex",
972
+ ],
973
+ { stageLabel: "uv-install-packaged-rescue" }
974
+ );
975
+ if (rescue.code === 0) {
976
+ // Ensure common install locations are present on PATH for this
977
+ // process in case the installer placed uv in a user-local bin.
978
+ const candidates = [
979
+ path.join(os.homedir(), ".local", "bin"),
980
+ path.join(os.homedir(), ".cargo", "bin"),
981
+ ];
982
+ for (const uvDir of candidates) {
983
+ if (process.env.PATH && !process.env.PATH.includes(uvDir)) {
984
+ process.env.PATH = `${uvDir}${path.delimiter}${process.env.PATH}`;
985
+ log(`Added ${uvDir} to PATH for this process`);
986
+ }
987
+ }
988
+ if (commandExists("uv")) {
989
+ log("Packaged Windows: uv installed and found on PATH (rescue succeeded)");
990
+ addManagedBinToPath();
991
+ report(STAGES.ENSURE_UV, 100, "uv ready (system, unverified)");
992
+ return;
993
+ }
994
+ log("Packaged Windows: uv installer ran but uv not found on PATH");
995
+ } else {
996
+ log(`Packaged Windows: automated uv installer exited ${rescue.code}`);
997
+ }
998
+ } catch (rescueErr) {
999
+ log(`Packaged Windows: rescue installer threw: ${rescueErr.message}`);
1000
+ }
1001
+ }
1002
+
928
1003
  // Packaged build, but we somehow don't have a bundled binary for this
929
1004
  // platform AND no system uv. Last-ditch: accept an unverified system uv
930
1005
  // if present; otherwise fail with a clear message.
@@ -937,11 +1012,11 @@ async function ensureUv({ onProgress, isPackaged } = {}) {
937
1012
  }
938
1013
 
939
1014
  throw new InstallError(
940
- `No bundled uv available for ${process.platform}-${process.arch} and no system uv found.`,
1015
+ `GAIA could not find or install its Python helper (uv) required to provision the backend.`,
941
1016
  {
942
1017
  stage: STAGES.ENSURE_UV,
943
1018
  suggestion:
944
- "Install uv manually from https://astral.sh/uv and re-launch GAIA.",
1019
+ "GAIA attempted automatic recovery but could not install uv. Please either: (a) click 'Install uv' in the dialog to let GAIA try again, or (b) install uv from https://astral.sh/uv and re-launch GAIA.",
945
1020
  }
946
1021
  );
947
1022
  }
@@ -1135,6 +1210,46 @@ async function installBackend(opts = {}) {
1135
1210
  log(`Verified gaia binary: ${verifiedBin} (version=${installedVersion || "unknown"})`);
1136
1211
  report(STAGES.VERIFY, 100, "Install verified");
1137
1212
 
1213
+ // Ensure a user-accessible shim is created so users who installed via
1214
+ // AppImage can run `gaia` from a terminal without manually adding the
1215
+ // venv bin directory to their PATH. Do not overwrite an existing system
1216
+ // `gaia` or an existing shim the user may have created.
1217
+ try {
1218
+ // Only create shims on POSIX-like systems (AppImage target).
1219
+ if (process.platform !== "win32") {
1220
+ const userBin = process.env.XDG_BIN_HOME || path.join(os.homedir(), ".local", "bin");
1221
+ const shimPath = path.join(userBin, "gaia");
1222
+
1223
+ // Only create a shim if there's no `gaia` already on PATH and no shim
1224
+ // at the target location. This avoids clobbering system packages.
1225
+ if (!commandExists("gaia") && !fs.existsSync(shimPath)) {
1226
+ try {
1227
+ // Basic sanity-check on the target path to avoid writing a
1228
+ // wrapper that could execute an arbitrary command. The
1229
+ // verifiedBin is produced by our installer and is expected to be
1230
+ // a normal filesystem path (alphanum, dash, dot, slash, underscore).
1231
+ if (!/^[\w\-./]+$/.test(verifiedBin)) {
1232
+ log(`Refusing to create shim: verified bin path looks suspicious: ${verifiedBin}`);
1233
+ } else {
1234
+ fs.mkdirSync(userBin, { recursive: true });
1235
+ const wrapper = `#!/bin/sh\nexec \"${verifiedBin}\" \"$@\"\n`;
1236
+ fs.writeFileSync(shimPath, wrapper, { mode: 0o755 });
1237
+ log(`Created user shim at ${shimPath} pointing to ${verifiedBin}`);
1238
+ }
1239
+ } catch (err) {
1240
+ log(`Could not create user shim at ${shimPath}: ${err.message}`);
1241
+ }
1242
+ } else if (fs.existsSync(shimPath)) {
1243
+ log(`User shim already exists at ${shimPath}; leaving it intact`);
1244
+ } else {
1245
+ log("A system 'gaia' binary was found on PATH; skipping shim creation");
1246
+ }
1247
+ }
1248
+ } catch (err) {
1249
+ // Non-fatal: proceed even if shim creation fails.
1250
+ log(`Shim creation check failed: ${err.message}`);
1251
+ }
1252
+
1138
1253
  setState(STATES.READY, { stage: null, version, installedVersion });
1139
1254
  log("Backend install complete");
1140
1255
  }
@@ -0,0 +1,49 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * index-query.cjs — Build the `?api=` query object that main.cjs hands
6
+ * to BrowserWindow.loadFile() so the renderer can reach the random
7
+ * backend port that PortManager picked.
8
+ *
9
+ * Extracted from main.cjs (issue #851) so it can be unit-tested without
10
+ * an Electron runtime. Pure CommonJS, no Node built-ins required, no
11
+ * Electron imports.
12
+ *
13
+ * Contract: this module must stay in sync with the TRUSTED_API_RE
14
+ * allowlist in src/gaia/apps/webui/src/utils/apiBase.ts. The renderer
15
+ * rejects any value that does not match that regex, so the URL produced
16
+ * here MUST satisfy `/^https?:\/\/(127\.0\.0\.1|localhost):\d+\/api$/`.
17
+ *
18
+ * The cross-file invariant is enforced by tests/electron/test_loadapp_query.mjs.
19
+ */
20
+
21
+ "use strict";
22
+
23
+ /** Path the backend serves its API under. Shared so a future change is one edit. */
24
+ const API_PATH = "/api";
25
+
26
+ /**
27
+ * Build the loadFile query object for a given backend port.
28
+ *
29
+ * @param {number} port - Integer in (0, 65535]. Typically chosen by
30
+ * PortManager.findFreePort(); falls back to DEFAULT_BACKEND_PORT (4200)
31
+ * in main.cjs if findFreePort fails.
32
+ * @returns {{api: string}} - Object passed as `loadFile(..., { query })`.
33
+ * Electron URL-encodes the values; the renderer reads them back via
34
+ * `URLSearchParams(window.location.search).get('api')`.
35
+ * @throws {Error} - If port is not a valid integer in (0, 65535].
36
+ */
37
+ function buildIndexQuery(port) {
38
+ if (
39
+ typeof port !== "number" ||
40
+ !Number.isInteger(port) ||
41
+ port <= 0 ||
42
+ port > 65535
43
+ ) {
44
+ throw new Error(`buildIndexQuery: invalid port ${port}`);
45
+ }
46
+ return { api: `http://127.0.0.1:${port}${API_PATH}` };
47
+ }
48
+
49
+ module.exports = { buildIndexQuery, API_PATH };