@amd-gaia/agent-ui 0.21.2 → 0.23.0
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/bin/gaia-ui.cjs +1 -1
- package/dist/assets/{browser-B1LxEoD3.js → browser-C-_jUAU-.js} +3 -3
- package/dist/assets/index-C4_mR67p.js +69 -0
- package/dist/assets/index-ChKtY1lF.css +1 -0
- package/dist/index.html +2 -2
- package/main-safety-net.cjs +13 -3
- package/main.cjs +201 -2
- package/package.json +5 -5
- package/preload.cjs +10 -1
- package/services/agent-process-manager.cjs +287 -6
- package/services/agent-seeder.cjs +256 -14
- package/services/auto-updater.cjs +132 -17
- package/services/backend-installer-progress-dialog.cjs +1 -1
- package/services/backend-installer.cjs +151 -69
- package/services/deep-link.cjs +230 -0
- package/services/system-metrics.cjs +154 -0
- package/dist/assets/index-BaBSHxtq.js +0 -72
- package/dist/assets/index-CGT9a3El.css +0 -1
|
@@ -16,20 +16,40 @@
|
|
|
16
16
|
* - macOS: <Bundle>.app/Contents/Resources/agents/<id>/
|
|
17
17
|
* - Linux: /opt/<AppName>/resources/agents/<id>/
|
|
18
18
|
* - Target: path.join(os.homedir(), ".gaia", "agents", "<id>")
|
|
19
|
-
* - Sentinel: <target>/.seeded (
|
|
19
|
+
* - Sentinel: <target>/.seeded (this copy completed)
|
|
20
|
+
* - Marker: ~/.gaia/seeder/<id>.seeded ("this machine seeded <id> once";
|
|
21
|
+
* EXISTENCE is the signal — content is informational, never parsed for
|
|
22
|
+
* decisions. Lives outside ~/.gaia/agents/ so deleting an agent, or the
|
|
23
|
+
* whole agents dir, never resurrects it.)
|
|
20
24
|
*
|
|
21
25
|
* Write protocol (atomic-ish, crash-safe):
|
|
22
26
|
* 1. Remove any stale `<id>.partial/` sibling from a prior failed run.
|
|
23
27
|
* 2. Copy source → `<id>.partial/`.
|
|
24
28
|
* 3. `fs.renameSync(<id>.partial, <id>)` — atomic on the same filesystem.
|
|
25
|
-
* 4. Write `<id>/.seeded
|
|
29
|
+
* 4. Write `<id>/.seeded`, so a partial seed never looks complete.
|
|
30
|
+
* 5. Write the marker LAST — a crash between 4 and 5 self-heals next
|
|
31
|
+
* launch via the marker back-fill (rule 2 below).
|
|
32
|
+
*
|
|
33
|
+
* Per-agent decision order (markers read fresh on every call, no caching):
|
|
34
|
+
* 1. Marker exists → skip, full stop. A deleted agent is never re-seeded;
|
|
35
|
+
* a dir the user re-created under the same id is never touched.
|
|
36
|
+
* 2. No marker, target has `.seeded` → back-fill the marker, skip.
|
|
37
|
+
* 3. No marker, target exists WITHOUT `.seeded` → user-owned data,
|
|
38
|
+
* log a warning, skip, write no marker.
|
|
39
|
+
* 4. No marker, no target → seed via the write protocol above.
|
|
40
|
+
*
|
|
41
|
+
* Recovery: to get a deleted bundled agent back, remove
|
|
42
|
+
* `~/.gaia/seeder/<id>.seeded` and relaunch.
|
|
43
|
+
*
|
|
44
|
+
* Legacy cleanup: the retired `zoo-agent` demo is removed from
|
|
45
|
+
* ~/.gaia/agents/ when it is provably an unmodified seeded copy — sentinel
|
|
46
|
+
* present, no file newer than its seededAt (+5s slack), never through a
|
|
47
|
+
* symlink. Removed ids are reported via the additive `cleaned` array.
|
|
26
48
|
*
|
|
27
49
|
* Behaviour:
|
|
28
|
-
* -
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* - `process.resourcesPath` unset (dev / Jest) or source dir missing →
|
|
32
|
-
* empty result, no error.
|
|
50
|
+
* - `process.resourcesPath` unset (dev / Jest) → empty result, no error,
|
|
51
|
+
* no cleanup (never touch the real HOME from dev/test contexts).
|
|
52
|
+
* - Source dir missing → legacy cleanup still runs, then empty result.
|
|
33
53
|
* - Per-agent failures are isolated: they go into `errors[]` but do not
|
|
34
54
|
* stop the next agent from being seeded.
|
|
35
55
|
*
|
|
@@ -53,6 +73,10 @@ function agentsTargetRoot() {
|
|
|
53
73
|
return path.join(gaiaHome(), "agents");
|
|
54
74
|
}
|
|
55
75
|
|
|
76
|
+
function seederMarkersDir() {
|
|
77
|
+
return path.join(gaiaHome(), "seeder");
|
|
78
|
+
}
|
|
79
|
+
|
|
56
80
|
function logsDir() {
|
|
57
81
|
return path.join(gaiaHome(), "logs");
|
|
58
82
|
}
|
|
@@ -124,6 +148,48 @@ function isDirectory(p) {
|
|
|
124
148
|
}
|
|
125
149
|
}
|
|
126
150
|
|
|
151
|
+
// ── Marker helpers ───────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Defense-in-depth: an agent id must be a single plain path segment before
|
|
155
|
+
* it is used in any path.join or marker filename.
|
|
156
|
+
*/
|
|
157
|
+
function isSinglePathSegment(id) {
|
|
158
|
+
return (
|
|
159
|
+
typeof id === "string" &&
|
|
160
|
+
id.length > 0 &&
|
|
161
|
+
id !== "." &&
|
|
162
|
+
id !== ".." &&
|
|
163
|
+
!id.includes("/") &&
|
|
164
|
+
!id.includes("\\") &&
|
|
165
|
+
!id.includes(path.sep)
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function markerPath(id) {
|
|
170
|
+
if (!isSinglePathSegment(id)) {
|
|
171
|
+
throw new Error(`Invalid agent id for marker path: ${JSON.stringify(id)}`);
|
|
172
|
+
}
|
|
173
|
+
return path.join(seederMarkersDir(), `${id}.seeded`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function hasMarker(id) {
|
|
177
|
+
return fs.existsSync(markerPath(id));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function writeMarker(id, source) {
|
|
181
|
+
fs.mkdirSync(seederMarkersDir(), { recursive: true });
|
|
182
|
+
fs.writeFileSync(
|
|
183
|
+
markerPath(id),
|
|
184
|
+
JSON.stringify(
|
|
185
|
+
{ seededAt: new Date().toISOString(), source },
|
|
186
|
+
null,
|
|
187
|
+
2
|
|
188
|
+
),
|
|
189
|
+
{ encoding: "utf8" }
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
127
193
|
// ── Seeding core ─────────────────────────────────────────────────────────
|
|
128
194
|
|
|
129
195
|
/**
|
|
@@ -135,18 +201,42 @@ function isDirectory(p) {
|
|
|
135
201
|
* Throws only on programmer error. All IO errors are caught and logged.
|
|
136
202
|
*/
|
|
137
203
|
function seedOneAgent(sourceDir, targetRoot, id) {
|
|
204
|
+
if (!isSinglePathSegment(id)) {
|
|
205
|
+
log("WARN", `Skipping invalid agent id ${JSON.stringify(id)}`);
|
|
206
|
+
return { status: "skipped" };
|
|
207
|
+
}
|
|
208
|
+
|
|
138
209
|
const src = path.join(sourceDir, id);
|
|
139
210
|
const target = path.join(targetRoot, id);
|
|
140
211
|
const partial = path.join(targetRoot, `${id}.partial`);
|
|
141
212
|
const sentinel = path.join(target, ".seeded");
|
|
142
213
|
|
|
143
|
-
//
|
|
214
|
+
// Rule 1: marker wins, full stop — honors deletion and leaves any
|
|
215
|
+
// user-recreated dir under the same id untouched.
|
|
216
|
+
if (hasMarker(id)) {
|
|
217
|
+
log("INFO", `Skipping "${id}" — already seeded once on this machine (marker present)`);
|
|
218
|
+
return { status: "skipped" };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Rule 2: seeded before markers existed → back-fill the marker.
|
|
222
|
+
// Runs every launch, so it also heals a crash between sentinel and marker.
|
|
144
223
|
if (fs.existsSync(sentinel)) {
|
|
145
|
-
|
|
224
|
+
try {
|
|
225
|
+
writeMarker(id, src);
|
|
226
|
+
log("INFO", `Skipping "${id}" — already seeded (sentinel present); marker back-filled`);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
log(
|
|
229
|
+
"ERROR",
|
|
230
|
+
`Failed to back-fill marker for "${id}": ${
|
|
231
|
+
err && err.message ? err.message : err
|
|
232
|
+
} — will retry next launch`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
146
235
|
return { status: "skipped" };
|
|
147
236
|
}
|
|
148
237
|
|
|
149
|
-
//
|
|
238
|
+
// Rule 3: target exists but no sentinel → user-owned data. Do not touch,
|
|
239
|
+
// write no marker.
|
|
150
240
|
if (fs.existsSync(target)) {
|
|
151
241
|
log(
|
|
152
242
|
"WARN",
|
|
@@ -176,7 +266,7 @@ function seedOneAgent(sourceDir, targetRoot, id) {
|
|
|
176
266
|
copyDirRecursive(src, partial);
|
|
177
267
|
fs.renameSync(partial, target);
|
|
178
268
|
|
|
179
|
-
// Write sentinel
|
|
269
|
+
// Write sentinel — its presence means "copy completed".
|
|
180
270
|
fs.writeFileSync(
|
|
181
271
|
sentinel,
|
|
182
272
|
JSON.stringify(
|
|
@@ -190,6 +280,9 @@ function seedOneAgent(sourceDir, targetRoot, id) {
|
|
|
190
280
|
{ encoding: "utf8" }
|
|
191
281
|
);
|
|
192
282
|
|
|
283
|
+
// Marker LAST — a crash here self-heals via the rule-2 back-fill.
|
|
284
|
+
writeMarker(id, src);
|
|
285
|
+
|
|
193
286
|
log("INFO", `Seeded "${id}" from ${src} to ${target}`);
|
|
194
287
|
return { status: "seeded" };
|
|
195
288
|
} catch (err) {
|
|
@@ -214,15 +307,156 @@ function seedOneAgent(sourceDir, targetRoot, id) {
|
|
|
214
307
|
}
|
|
215
308
|
}
|
|
216
309
|
|
|
310
|
+
// ── Legacy cleanup ───────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
// Retired bundled ids removed from user machines when provably unmodified.
|
|
313
|
+
const LEGACY_CLEANUP_IDS = ["zoo-agent"];
|
|
314
|
+
|
|
315
|
+
// Filesystem mtime granularity + copy latency headroom for the
|
|
316
|
+
// modified-since-seed check.
|
|
317
|
+
const MODIFIED_SLACK_MS = 5000;
|
|
318
|
+
|
|
319
|
+
function listFilesRecursive(dir) {
|
|
320
|
+
const out = [];
|
|
321
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
322
|
+
// __pycache__/*.pyc is regenerated by the registry on every load — it
|
|
323
|
+
// postdates seededAt but is not a user modification.
|
|
324
|
+
if (entry.isDirectory() && entry.name === "__pycache__") continue;
|
|
325
|
+
if (!entry.isDirectory() && entry.name.endsWith(".pyc")) continue;
|
|
326
|
+
const p = path.join(dir, entry.name);
|
|
327
|
+
if (entry.isDirectory()) {
|
|
328
|
+
out.push(...listFilesRecursive(p));
|
|
329
|
+
} else {
|
|
330
|
+
out.push(p);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Remove retired seeded demo agents (currently `zoo-agent`) from
|
|
338
|
+
* ~/.gaia/agents/. Deletes ONLY when the dir is provably an unmodified
|
|
339
|
+
* seeded copy; anything ambiguous is left in place. Failures are logged
|
|
340
|
+
* and retried on the next launch (the sentinel keys the retry — no marker
|
|
341
|
+
* is involved, since a retired id can never be re-seeded anyway).
|
|
342
|
+
*
|
|
343
|
+
* @returns {string[]} ids actually removed (verified gone from disk).
|
|
344
|
+
*/
|
|
345
|
+
function cleanupLegacyAgents() {
|
|
346
|
+
const cleaned = [];
|
|
347
|
+
|
|
348
|
+
for (const id of LEGACY_CLEANUP_IDS) {
|
|
349
|
+
const target = path.join(agentsTargetRoot(), id);
|
|
350
|
+
|
|
351
|
+
let st;
|
|
352
|
+
try {
|
|
353
|
+
st = fs.lstatSync(target);
|
|
354
|
+
} catch {
|
|
355
|
+
continue; // not present — nothing to clean
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Never rm through a symlink — it would delete the link target.
|
|
359
|
+
if (st.isSymbolicLink()) {
|
|
360
|
+
log(
|
|
361
|
+
"WARN",
|
|
362
|
+
`Legacy "${id}" at ${target} is a symlink — leaving it untouched`
|
|
363
|
+
);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!st.isDirectory()) {
|
|
368
|
+
log("WARN", `Legacy "${id}" at ${target} is not a directory — leaving it untouched`);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// No sentinel → user-authored, not ours to remove.
|
|
373
|
+
const sentinel = path.join(target, ".seeded");
|
|
374
|
+
if (!fs.existsSync(sentinel)) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let seededAtMs;
|
|
379
|
+
try {
|
|
380
|
+
seededAtMs = Date.parse(
|
|
381
|
+
JSON.parse(fs.readFileSync(sentinel, "utf8")).seededAt
|
|
382
|
+
);
|
|
383
|
+
if (!Number.isFinite(seededAtMs)) {
|
|
384
|
+
throw new Error("seededAt missing or not a valid timestamp");
|
|
385
|
+
}
|
|
386
|
+
} catch (err) {
|
|
387
|
+
log(
|
|
388
|
+
"WARN",
|
|
389
|
+
`Cannot determine seededAt for legacy "${id}" (${
|
|
390
|
+
err && err.message ? err.message : err
|
|
391
|
+
}) — treating as user-modified, leaving it untouched`
|
|
392
|
+
);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Any file newer than the seed time means the user customized it —
|
|
397
|
+
// it is their work now, never delete it.
|
|
398
|
+
let modified = false;
|
|
399
|
+
try {
|
|
400
|
+
for (const file of listFilesRecursive(target)) {
|
|
401
|
+
if (fs.lstatSync(file).mtimeMs > seededAtMs + MODIFIED_SLACK_MS) {
|
|
402
|
+
log(
|
|
403
|
+
"WARN",
|
|
404
|
+
`Legacy "${id}" was modified after seeding (${file}) — leaving it untouched`
|
|
405
|
+
);
|
|
406
|
+
modified = true;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} catch (err) {
|
|
411
|
+
log(
|
|
412
|
+
"WARN",
|
|
413
|
+
`Cannot inspect legacy "${id}" for modifications (${
|
|
414
|
+
err && err.message ? err.message : err
|
|
415
|
+
}) — leaving it untouched`
|
|
416
|
+
);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (modified) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
425
|
+
} catch (err) {
|
|
426
|
+
log(
|
|
427
|
+
"ERROR",
|
|
428
|
+
`Failed to remove legacy "${id}" at ${target}: ${
|
|
429
|
+
err && err.message ? err.message : err
|
|
430
|
+
} — will retry next launch`
|
|
431
|
+
);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Report success only after verifying it is actually gone.
|
|
436
|
+
if (fs.existsSync(target)) {
|
|
437
|
+
log(
|
|
438
|
+
"ERROR",
|
|
439
|
+
`Legacy "${id}" still present after removal attempt: ${target} — will retry next launch`
|
|
440
|
+
);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
log("INFO", `Removed legacy seeded agent "${id}" from ${target}`);
|
|
445
|
+
cleaned.push(id);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return cleaned;
|
|
449
|
+
}
|
|
450
|
+
|
|
217
451
|
/**
|
|
218
452
|
* Seed all bundled agents found under `<resourcesPath>/agents/`.
|
|
219
453
|
*
|
|
220
454
|
* Idempotent — safe to call on every app launch.
|
|
221
455
|
*
|
|
222
|
-
* @returns {Promise<{seeded: string[], skipped: string[], errors: {id: string, error: Error}[]}>}
|
|
456
|
+
* @returns {Promise<{seeded: string[], skipped: string[], errors: {id: string, error: Error}[], cleaned: string[]}>}
|
|
223
457
|
*/
|
|
224
458
|
async function seedBundledAgents() {
|
|
225
|
-
const result = { seeded: [], skipped: [], errors: [] };
|
|
459
|
+
const result = { seeded: [], skipped: [], errors: [], cleaned: [] };
|
|
226
460
|
|
|
227
461
|
// Guard against dev / test environments where resourcesPath is unset.
|
|
228
462
|
if (!process.resourcesPath) {
|
|
@@ -233,6 +467,11 @@ async function seedBundledAgents() {
|
|
|
233
467
|
return result;
|
|
234
468
|
}
|
|
235
469
|
|
|
470
|
+
// Legacy cleanup runs before the missing-source guard: an upgraded
|
|
471
|
+
// install must shed the retired zoo-agent even if this build ships no
|
|
472
|
+
// bundled agents.
|
|
473
|
+
result.cleaned = cleanupLegacyAgents();
|
|
474
|
+
|
|
236
475
|
const sourceDir = path.join(process.resourcesPath, "agents");
|
|
237
476
|
|
|
238
477
|
if (!fs.existsSync(sourceDir) || !isDirectory(sourceDir)) {
|
|
@@ -284,7 +523,8 @@ async function seedBundledAgents() {
|
|
|
284
523
|
log(
|
|
285
524
|
"INFO",
|
|
286
525
|
`Seeding complete — seeded=${result.seeded.length} ` +
|
|
287
|
-
`skipped=${result.skipped.length} errors=${result.errors.length}`
|
|
526
|
+
`skipped=${result.skipped.length} errors=${result.errors.length} ` +
|
|
527
|
+
`cleaned=${result.cleaned.length}`
|
|
288
528
|
);
|
|
289
529
|
|
|
290
530
|
return result;
|
|
@@ -295,7 +535,9 @@ module.exports = {
|
|
|
295
535
|
// Exposed for tests — do not rely on these from production code.
|
|
296
536
|
_internals: {
|
|
297
537
|
seedOneAgent,
|
|
538
|
+
cleanupLegacyAgents,
|
|
298
539
|
agentsTargetRoot,
|
|
540
|
+
seederMarkersDir,
|
|
299
541
|
logFilePath,
|
|
300
542
|
},
|
|
301
543
|
};
|
|
@@ -4,9 +4,19 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* auto-updater.cjs — GAIA Agent UI auto-update service.
|
|
6
6
|
*
|
|
7
|
-
* Wraps `electron-updater`
|
|
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.
|
|
15
|
+
*
|
|
8
16
|
* Implements §4 Layer 3 + §7 Phase F of docs/plans/desktop-installer.mdx.
|
|
9
|
-
* Issue #1336:
|
|
17
|
+
* Issue #1336: in-app rollback to a specific previous release still lists and
|
|
18
|
+
* installs from GitHub Releases (that path is independent of the forward feed
|
|
19
|
+
* and migrates to R2 with #1719).
|
|
10
20
|
*
|
|
11
21
|
* Behavior:
|
|
12
22
|
* - First check 10 seconds after `init()` is called (typically from
|
|
@@ -51,6 +61,12 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // Subsequent checks every 4h
|
|
|
51
61
|
const LOG_PATH = path.join(os.homedir(), ".gaia", "electron-updater.log");
|
|
52
62
|
const UPDATE_CONFIG_PATH = path.join(os.homedir(), ".gaia", "update-config.json");
|
|
53
63
|
|
|
64
|
+
// Mutable channel pointer name. electron-updater's generic provider fetches
|
|
65
|
+
// `${feedUrl}/${DEFAULT_CHANNEL}.yml` (+ per-platform variants). The Hub Worker
|
|
66
|
+
// re-points this file each release while versioned artifacts stay immutable —
|
|
67
|
+
// that split is the A6/A7 resolution (#1724).
|
|
68
|
+
const DEFAULT_CHANNEL = "latest";
|
|
69
|
+
|
|
54
70
|
// per_page=100 is the GitHub API max for a single page; we fetch broadly so
|
|
55
71
|
// draft/prerelease/platform filtering still yields a full page of installable
|
|
56
72
|
// releases before the display cap below. >100 releases would need pagination
|
|
@@ -72,6 +88,8 @@ const STATES = Object.freeze({
|
|
|
72
88
|
DOWNLOADED: "downloaded",
|
|
73
89
|
ERROR: "error",
|
|
74
90
|
DISABLED: "disabled",
|
|
91
|
+
// No forward-update feed configured — loud, actionable, never a silent no-op.
|
|
92
|
+
NO_CHANNEL: "no-channel",
|
|
75
93
|
});
|
|
76
94
|
|
|
77
95
|
// ── Module state ─────────────────────────────────────────────────────────────
|
|
@@ -93,6 +111,8 @@ let scheduledTimeout = null;
|
|
|
93
111
|
let initialCheckTimeout = null;
|
|
94
112
|
let ipcHandlersRegistered = false;
|
|
95
113
|
let initialized = false;
|
|
114
|
+
// The resolved forward-update feed URL (generic/R2), or null when unconfigured.
|
|
115
|
+
let forwardFeedUrl = null;
|
|
96
116
|
|
|
97
117
|
// Lazy-loaded Electron references (populated inside init()).
|
|
98
118
|
let electronApi = null; // { dialog, ipcMain, app }
|
|
@@ -147,13 +167,86 @@ function _loadPin() {
|
|
|
147
167
|
*/
|
|
148
168
|
function _savePin(pinnedVersion) {
|
|
149
169
|
fs.mkdirSync(path.dirname(UPDATE_CONFIG_PATH), { recursive: true });
|
|
170
|
+
// Merge into any existing config so we don't clobber a persisted feedUrl.
|
|
171
|
+
let existing = {};
|
|
172
|
+
try {
|
|
173
|
+
if (fs.existsSync(UPDATE_CONFIG_PATH)) {
|
|
174
|
+
existing = JSON.parse(fs.readFileSync(UPDATE_CONFIG_PATH, "utf8")) || {};
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
existing = {};
|
|
178
|
+
}
|
|
150
179
|
fs.writeFileSync(
|
|
151
180
|
UPDATE_CONFIG_PATH,
|
|
152
|
-
JSON.stringify({ pinnedVersion }, null, 2),
|
|
181
|
+
JSON.stringify({ ...existing, pinnedVersion }, null, 2),
|
|
153
182
|
"utf8"
|
|
154
183
|
);
|
|
155
184
|
}
|
|
156
185
|
|
|
186
|
+
// ── Forward-update feed resolution (issue #1724) ────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolve the generic forward-update feed URL, or null if none is configured.
|
|
190
|
+
* Precedence: GAIA_UPDATE_FEED_URL env > feedUrl in update-config.json.
|
|
191
|
+
*/
|
|
192
|
+
function _resolveFeedUrl() {
|
|
193
|
+
const envUrl = process.env.GAIA_UPDATE_FEED_URL;
|
|
194
|
+
if (typeof envUrl === "string" && envUrl.trim()) return envUrl.trim();
|
|
195
|
+
try {
|
|
196
|
+
if (fs.existsSync(UPDATE_CONFIG_PATH)) {
|
|
197
|
+
const cfg = JSON.parse(fs.readFileSync(UPDATE_CONFIG_PATH, "utf8"));
|
|
198
|
+
if (cfg && typeof cfg.feedUrl === "string" && cfg.feedUrl.trim()) {
|
|
199
|
+
return cfg.feedUrl.trim();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch (err) {
|
|
203
|
+
log("warn", "Failed to read feedUrl from update-config.json:", err && err.message);
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Point electron-updater at the configured generic (R2) feed + mutable channel.
|
|
210
|
+
* When nothing is configured, enter the loud NO_CHANNEL state and set no feed —
|
|
211
|
+
* an unconfigured updater must never silently pretend it's up to date.
|
|
212
|
+
*
|
|
213
|
+
* @returns {boolean} true when a feed was applied, false when NO_CHANNEL.
|
|
214
|
+
*/
|
|
215
|
+
function _applyForwardFeed() {
|
|
216
|
+
if (!autoUpdaterRef) return false;
|
|
217
|
+
const url = _resolveFeedUrl();
|
|
218
|
+
if (!url) {
|
|
219
|
+
forwardFeedUrl = null;
|
|
220
|
+
setState({
|
|
221
|
+
status: STATES.NO_CHANNEL,
|
|
222
|
+
error:
|
|
223
|
+
"No update channel configured — set GAIA_UPDATE_FEED_URL (or feedUrl " +
|
|
224
|
+
"in ~/.gaia/update-config.json) to your R2 channel base URL. " +
|
|
225
|
+
"Auto-update is paused until a feed is configured.",
|
|
226
|
+
});
|
|
227
|
+
log("warn", "No update feed configured — auto-update paused (NO_CHANNEL)");
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
autoUpdaterRef.setFeedURL({
|
|
232
|
+
provider: "generic",
|
|
233
|
+
url,
|
|
234
|
+
channel: DEFAULT_CHANNEL,
|
|
235
|
+
});
|
|
236
|
+
forwardFeedUrl = url;
|
|
237
|
+
log("info", `Update feed set (generic): ${url} [channel=${DEFAULT_CHANNEL}]`);
|
|
238
|
+
return true;
|
|
239
|
+
} catch (err) {
|
|
240
|
+
forwardFeedUrl = null;
|
|
241
|
+
setState({
|
|
242
|
+
status: STATES.ERROR,
|
|
243
|
+
error: `Failed to set update feed URL: ${(err && err.message) || String(err)}`,
|
|
244
|
+
});
|
|
245
|
+
log("error", "Failed to set feed URL:", err && err.message);
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
157
250
|
// ── State management ─────────────────────────────────────────────────────────
|
|
158
251
|
|
|
159
252
|
function broadcastState() {
|
|
@@ -359,8 +452,10 @@ async function installVersion(tag) {
|
|
|
359
452
|
/**
|
|
360
453
|
* Clear the version pin and resume normal auto-updates.
|
|
361
454
|
*
|
|
362
|
-
* Restores the
|
|
363
|
-
* allowDowngrade so the next check is the normal forward-only flow.
|
|
455
|
+
* Restores the configured forward (R2 generic) feed, sets autoDownload=true,
|
|
456
|
+
* and clears allowDowngrade so the next check is the normal forward-only flow.
|
|
457
|
+
* If no feed is configured, resume lands in the loud NO_CHANNEL state rather
|
|
458
|
+
* than silently reverting to a stale rollback feed.
|
|
364
459
|
*/
|
|
365
460
|
function clearPin() {
|
|
366
461
|
// A failed clear is non-fatal — worst case the stale pin re-pauses updates
|
|
@@ -375,17 +470,9 @@ function clearPin() {
|
|
|
375
470
|
if (autoUpdaterRef) {
|
|
376
471
|
autoUpdaterRef.autoDownload = true;
|
|
377
472
|
autoUpdaterRef.allowDowngrade = false;
|
|
378
|
-
// Restore the
|
|
379
|
-
// installVersion).
|
|
380
|
-
|
|
381
|
-
autoUpdaterRef.setFeedURL({
|
|
382
|
-
provider: "github",
|
|
383
|
-
owner: "amd",
|
|
384
|
-
repo: "gaia",
|
|
385
|
-
});
|
|
386
|
-
} catch (err) {
|
|
387
|
-
log("warn", "Failed to restore GitHub feed on resume:", err && err.message);
|
|
388
|
-
}
|
|
473
|
+
// Restore the forward channel (overrides any generic/tagged rollback feed
|
|
474
|
+
// set by installVersion). Loud NO_CHANNEL if unconfigured.
|
|
475
|
+
_applyForwardFeed();
|
|
389
476
|
}
|
|
390
477
|
|
|
391
478
|
log("info", "Version pin cleared — auto-updates resumed");
|
|
@@ -403,6 +490,16 @@ async function checkForUpdates() {
|
|
|
403
490
|
log("warn", "checkForUpdates called before init — ignoring");
|
|
404
491
|
return;
|
|
405
492
|
}
|
|
493
|
+
// No forward feed and not pinned → surface the loud NO_CHANNEL state instead
|
|
494
|
+
// of asking electron-updater to check a feed that doesn't exist. Re-resolve
|
|
495
|
+
// first so a feed written to config after init() is picked up here.
|
|
496
|
+
if (!forwardFeedUrl && !state.pinnedVersion) {
|
|
497
|
+
if (!_applyForwardFeed()) return;
|
|
498
|
+
// Feed just became available after a no-feed (NO_CHANNEL) start — init()
|
|
499
|
+
// skipped arming the periodic scheduler, so arm it now or background checks
|
|
500
|
+
// never resume once the feed goes live.
|
|
501
|
+
if (!scheduledTimeout) scheduleNextCheck();
|
|
502
|
+
}
|
|
406
503
|
if (checkInProgress) {
|
|
407
504
|
log("info", "Skipping check — another check is already in progress");
|
|
408
505
|
return;
|
|
@@ -691,6 +788,25 @@ function init(mainWindow) {
|
|
|
691
788
|
log("warn", "Failed to register IPC handlers:", err && err.message);
|
|
692
789
|
}
|
|
693
790
|
|
|
791
|
+
// Point the updater at the configured R2 generic feed (#1724). When a pin is
|
|
792
|
+
// active, installVersion() owns the feed (rollback path), so skip. With no
|
|
793
|
+
// pin and no feed, _applyForwardFeed enters the loud NO_CHANNEL state and we
|
|
794
|
+
// don't schedule checks against a nonexistent feed.
|
|
795
|
+
let feedReady = true;
|
|
796
|
+
if (!savedPin) {
|
|
797
|
+
feedReady = _applyForwardFeed();
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
initialized = true;
|
|
801
|
+
|
|
802
|
+
if (!feedReady && !savedPin) {
|
|
803
|
+
log(
|
|
804
|
+
"info",
|
|
805
|
+
"Auto-updater initialized without a channel — checks paused until a feed is configured"
|
|
806
|
+
);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
694
810
|
// First check after CHECK_DELAY_MS, then every CHECK_INTERVAL_MS.
|
|
695
811
|
initialCheckTimeout = setTimeout(async () => {
|
|
696
812
|
try {
|
|
@@ -701,7 +817,6 @@ function init(mainWindow) {
|
|
|
701
817
|
scheduleNextCheck();
|
|
702
818
|
}, CHECK_DELAY_MS);
|
|
703
819
|
|
|
704
|
-
initialized = true;
|
|
705
820
|
log(
|
|
706
821
|
"info",
|
|
707
822
|
`Auto-updater initialized; first check in ${CHECK_DELAY_MS}ms`
|
|
@@ -399,7 +399,7 @@ async function showFailureDialog(parentWindow, errorInfo = {}) {
|
|
|
399
399
|
return "retry";
|
|
400
400
|
case 2: {
|
|
401
401
|
try {
|
|
402
|
-
await shell.openExternal("https://amd-gaia.ai/quickstart#cli-install");
|
|
402
|
+
await shell.openExternal("https://amd-gaia.ai/docs/quickstart#cli-install");
|
|
403
403
|
} catch {
|
|
404
404
|
// ignore
|
|
405
405
|
}
|