Skip to content

Commit

The desktop app is 0.3.2, and its runner updates are fast, seen and only ever g1t's. The app now trusts a g1t-runner release only when its manifest's Ed25519 signature, latest.json.sig, checks out against g1t's runner release key, which is built into the app (the public half of RUNNER_RELEASE_PUBLIC_KEY, kept in runner/native.rs, where development builds have it too; G1T_RUNNER_RELEASE_KEY overrides it for a g1t with its own key), as the runner already did with its own updates. A manifest with no signature, or one that doesn't check out, is refused and nothing in it is used: the runner keeps the version it has and says "Update refused: not signed by g1t", or, with no runner installed yet, "Not running: the runner's release isn't signed by g1t", each with why and what to do. It looks every hour rather than every six, on a thread of its own so the runner is looked after meanwhile, and what it finds waits, downloaded and checked, until each kind of job is idle, then goes in kind by kind, never cutting a job short: Linux jobs take the release's image (sandbox update) as soon as they have no job, while Windows jobs, whose runner is the program itself, are stopped after their job (sandbox down --only windows --drain), the program is replaced and they start again, the other kind running on throughout; a runner in Docker or on the computer stops between jobs and starts on the new one. The program there is moved aside rather than overwritten, so a status the app is asking at that moment can't stop it. Each kind of job's card in Settings shows its runner version, when the app last looked ("Checked 12 min ago") and a newer one waiting ("0.3.2 ready, installs after this job", "Installing 0.3.2"), and "Check for runner updates" beside the computer's name looks at once and installs what it finds, even with "Download updates by themselves" off, which otherwise stops the hourly look. The desktop report carries the same (snake_case: the diagnosis's update, with checked_at, ready and note, and each kind's update), so the Runners page's Desktops cards show it, "Checked" with how long ago, and this computer's row has the button too. All the words are diagnosis.rs's. The desktop guide says how updates work, what each card shows, the new troubleshooting rows and where the release key lives. Tests cover the signature with a key pair of their own and with release 0.3.0's manifest and signature as g1t.sh published them, kept byte for byte (.gitattributes), the wording, the cards and the report.

syntaqxcommitted Parent6cd2cc0Browse files
19 files+1229−1290/19 viewed
+2−0
11 * text=auto eol=lf
2+# Signed release files, kept byte for byte so their signatures check out.
3+apps/desktop/src-tauri/src/runner/testdata/** -text
+17−0
194194 flex: none;
195195 gap: 0.5rem;
196196 }
197+/* A row with buttons beside its words: in a narrow window, the buttons go
198+ under them rather than squeezing them into a column. */
199+.setting:has(> .buttons) {
200+ flex-wrap: wrap;
201+ row-gap: 0.5rem;
202+}
203+.setting:has(> .buttons) > span:first-child {
204+ flex: 1 1 14rem;
205+}
197206 button.primary {
198207 background: var(--accent);
199208 border-color: var(--accent);
544553 overflow-wrap: anywhere;
545554 user-select: text;
546555 }
556+/* A newer version waiting, over when g1t last looked. */
557+.kind-update {
558+ display: grid;
559+ gap: 0.0625rem;
560+}
561+.kind-update-ready {
562+ color: var(--accent);
563+}
547564 button.link {
548565 display: inline-flex;
549566 align-items: center;
+4−1
8383 </label>
8484 <div class="setting">
8585 <span><strong id="runner-machine">This computer</strong><span class="muted" id="runner-version">&nbsp;</span></span>
86− <span class="buttons"><button type="button" id="runner-view">View on g1t</button></span>
86+ <span class="buttons"
87+ ><button type="button" id="runner-check" hidden>Check for runner updates</button
88+ ><button type="button" id="runner-view">View on g1t</button></span
89+ >
8790 </div>
8891 <details class="log">
8992 <summary>Recent log</summary>
+28−2
307307
308308 // What changes the buttons and the words is drawn again only when it changes,
309309 // so a button keeps its focus while the status ticks.
310− var signature = JSON.stringify([kind, !!pending[kind.id], runner.link]);
310+ var update = runner.diagnosis.update || null;
311+ var signature = JSON.stringify([kind, !!pending[kind.id], runner.link, update && update.checked]);
311312 if (signature === card.signature) return;
312313 card.signature = signature;
313314
335336 }
336337 detail(details, "On g1t", kind.runner, named);
337338 detail(details, "Version", kind.version ? "g1t-runner " + kind.version : null);
339+ // A newer version waiting for this kind of job, and when g1t last looked.
340+ if (update && (kind.update || kind.version)) {
341+ var updates = element("span", "kind-update");
342+ if (kind.update) updates.appendChild(element("span", "kind-update-ready", kind.update));
343+ updates.appendChild(element("span", "muted", update.checked));
344+ detail(details, "Updates", true, updates);
345+ }
338346 detail(details, "Isolation", ISOLATION[kind.isolation] || kind.isolation);
339347 details.hidden = !details.firstChild;
340348
415423 workspaces.disabled = known.length === 0;
416424
417425 byId("runner-machine").textContent = "This computer: " + runner.machine;
418− byId("runner-version").textContent = runner.version ? "g1t-runner " + runner.version : " ";
426+ // The runner's version, when g1t last looked for a newer one, and, with
427+ // no cards to say it, what becomes of one waiting.
428+ var update = diagnosis.update;
429+ var about = [runner.version ? "g1t-runner " + runner.version : null, update ? update.checked : null, update && update.note].filter(Boolean);
430+ byId("runner-version").textContent = about.length ? about.join(" · ") : " ";
431+ var check = byId("runner-check");
432+ check.hidden = !update;
433+ if (update) {
434+ check.textContent = update.check.label;
435+ check.disabled = update.checking;
436+ }
419437 byId("runner-view").hidden = !runner.link;
420438 byId("runner-log").textContent = runner.log.length ? runner.log.join("\n") : "Nothing yet.";
421439 }
428446 byId("runner-open-log").addEventListener("click", function () {
429447 invoke("runner_control", { action: "view_log" }).catch(fail);
430448 });
449+ // Looks now; what it finds installs as each kind of job is idle, and the status that follows says so.
450+ byId("runner-check").addEventListener("click", function () {
451+ byId("runner-check").disabled = true;
452+ invoke("runner_control", { action: "check_updates" }).catch(function (error) {
453+ byId("runner-check").disabled = false;
454+ fail(error);
455+ });
456+ });
431457
432458 function save() {
433459 invoke("save_settings", { settings: settingsOf() }).then(render, function (error) {
+118−7
294294 ]
295295
296296 [[package]]
297+name = "block-buffer"
298+version = "0.12.1"
299+source = "registry+https://github.com/rust-lang/crates.io-index"
300+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
301+dependencies = [
302+ "hybrid-array",
303+]
304+
305+[[package]]
297306 name = "block2"
298307 version = "0.6.2"
299308 source = "registry+https://github.com/rust-lang/crates.io-index"
676685 ]
677686
678687 [[package]]
688+name = "crypto-common"
689+version = "0.2.2"
690+source = "registry+https://github.com/rust-lang/crates.io-index"
691+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
692+dependencies = [
693+ "hybrid-array",
694+]
695+
696+[[package]]
679697 name = "cssparser"
680698 version = "0.37.0"
681699 source = "registry+https://github.com/rust-lang/crates.io-index"
705723 checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
706724
707725 [[package]]
726+name = "curve25519-dalek"
727+version = "5.0.0"
728+source = "registry+https://github.com/rust-lang/crates.io-index"
729+checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
730+dependencies = [
731+ "cfg-if",
732+ "cpufeatures 0.3.1",
733+ "curve25519-dalek-derive",
734+ "digest 0.11.3",
735+ "fiat-crypto",
736+ "rustc_version",
737+ "subtle",
738+ "zeroize",
739+]
740+
741+[[package]]
742+name = "curve25519-dalek-derive"
743+version = "0.1.1"
744+source = "registry+https://github.com/rust-lang/crates.io-index"
745+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
746+dependencies = [
747+ "proc-macro2",
748+ "quote",
749+ "syn 2.0.119",
750+]
751+
752+[[package]]
708753 name = "darling"
709754 version = "0.24.1"
710755 source = "registry+https://github.com/rust-lang/crates.io-index"
827872 source = "registry+https://github.com/rust-lang/crates.io-index"
828873 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
829874 dependencies = [
830− "block-buffer",
831− "crypto-common",
875+ "block-buffer 0.10.4",
876+ "crypto-common 0.1.7",
832877 ]
833878
834879 [[package]]
880+name = "digest"
881+version = "0.11.3"
882+source = "registry+https://github.com/rust-lang/crates.io-index"
883+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
884+dependencies = [
885+ "block-buffer 0.12.1",
886+ "crypto-common 0.2.2",
887+]
888+
889+[[package]]
835890 name = "dirs"
836891 version = "6.0.0"
837892 source = "registry+https://github.com/rust-lang/crates.io-index"
9681023 checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
9691024
9701025 [[package]]
1026+name = "ed25519"
1027+version = "3.0.0"
1028+source = "registry+https://github.com/rust-lang/crates.io-index"
1029+checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
1030+dependencies = [
1031+ "signature",
1032+]
1033+
1034+[[package]]
1035+name = "ed25519-dalek"
1036+version = "3.0.0"
1037+source = "registry+https://github.com/rust-lang/crates.io-index"
1038+checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
1039+dependencies = [
1040+ "curve25519-dalek",
1041+ "ed25519",
1042+ "sha2 0.11.0",
1043+ "subtle",
1044+ "zeroize",
1045+]
1046+
1047+[[package]]
9711048 name = "embed-resource"
9721049 version = "3.0.12"
9731050 source = "registry+https://github.com/rust-lang/crates.io-index"
10771154 ]
10781155
10791156 [[package]]
1157+name = "fiat-crypto"
1158+version = "0.3.0"
1159+source = "registry+https://github.com/rust-lang/crates.io-index"
1160+checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
1161+
1162+[[package]]
10801163 name = "field-offset"
10811164 version = "0.3.6"
10821165 source = "registry+https://github.com/rust-lang/crates.io-index"
12471330
12481331 [[package]]
12491332 name = "g1t-desktop"
1250−version = "0.3.1"
1333+version = "0.3.2"
12511334 dependencies = [
1335+ "base64 0.22.1",
1336+ "ed25519-dalek",
12521337 "gtk",
12531338 "reqwest 0.12.28",
12541339 "serde",
12551340 "serde_json",
1256− "sha2",
1341+ "sha2 0.10.9",
12571342 "tauri",
12581343 "tauri-build",
12591344 "tauri-plugin-autostart",
16551740 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
16561741
16571742 [[package]]
1743+name = "hybrid-array"
1744+version = "0.4.15"
1745+source = "registry+https://github.com/rust-lang/crates.io-index"
1746+checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
1747+dependencies = [
1748+ "typenum",
1749+]
1750+
1751+[[package]]
16581752 name = "hyper"
16591753 version = "1.12.0"
16601754 source = "registry+https://github.com/rust-lang/crates.io-index"
37823876 dependencies = [
37833877 "cfg-if",
37843878 "cpufeatures 0.2.17",
3785− "digest",
3879+ "digest 0.10.7",
37863880 ]
37873881
37883882 [[package]]
3883+name = "sha2"
3884+version = "0.11.0"
3885+source = "registry+https://github.com/rust-lang/crates.io-index"
3886+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
3887+dependencies = [
3888+ "cfg-if",
3889+ "cpufeatures 0.3.1",
3890+ "digest 0.11.3",
3891+]
3892+
3893+[[package]]
37893894 name = "shlex"
37903895 version = "2.0.1"
37913896 source = "registry+https://github.com/rust-lang/crates.io-index"
38023907 ]
38033908
38043909 [[package]]
3910+name = "signature"
3911+version = "3.0.0"
3912+source = "registry+https://github.com/rust-lang/crates.io-index"
3913+checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
3914+
3915+[[package]]
38053916 name = "simd-adler32"
38063917 version = "0.3.10"
38073918 source = "registry+https://github.com/rust-lang/crates.io-index"
42084319 "semver",
42094320 "serde",
42104321 "serde_json",
4211− "sha2",
4322+ "sha2 0.10.9",
42124323 "syn 2.0.119",
42134324 "tauri-utils",
42144325 "thiserror 2.0.21",
55985709 "once_cell",
55995710 "percent-encoding",
56005711 "raw-window-handle",
5601− "sha2",
5712+ "sha2 0.10.9",
56025713 "soup3",
56035714 "tao-macros",
56045715 "thiserror 2.0.21",
+6−2
11 [package]
22 name = "g1t-desktop"
3−version = "0.3.1"
3+version = "0.3.2"
44 edition = "2024"
55 license = "MIT"
66 repository = "https://g1t.sh/flagon-io/g1t"
3939 # Whether the server answers, before the window goes there (the same client the updater brings).
4040 reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
4141 tokio = { version = "1", features = ["time"] }
42−# Checking a downloaded g1t-runner against its release's SHA-256 (src/runner/native.rs).
42+# Checking a downloaded g1t-runner against its release's SHA-256, and the
43+# release's manifest against g1t's release key (src/runner/native.rs), as
44+# the runner checks its own updates.
4345 sha2 = "0.10"
46+ed25519-dalek = "3"
47+base64 = "0.22"
4448
4549 # Keeping the menu bar hidden under g1t's own title bar (src/frame.rs); the
4650 # same GTK that Tauri brings.
+195−3
7272 SandboxTaken,
7373 Updating,
7474 UpdateFailed,
75+ /// The newest release's manifest isn't signed with g1t's release key.
76+ UpdateRefused,
7577 Offline,
7678 Crashed,
7779 }
112114 /// shows them shows no line of the whole runner's as well.
113115 #[serde(skip_serializing_if = "is_false")]
114116 pub in_capabilities: bool,
117+ /// How the runner's updates stand: when g1t last looked, and a newer
118+ /// one waiting. None for what is said once g1t is closed.
119+ #[serde(skip_serializing_if = "Option::is_none")]
120+ pub update: Option<UpdateLine>,
115121 }
116122
117123 fn is_false(value: &bool) -> bool {
118124 !*value
119125 }
120126
127+/// The runner's updates, as settings and the site's Desktops show them.
128+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
129+pub struct UpdateLine {
130+ /// When g1t last heard from the runner's releases, RFC 3339: the site,
131+ /// which shows a report for minutes, says how long ago itself.
132+ pub checked_at: Option<String>,
133+ /// `Checked 12 min ago`, `Checking for updates…`.
134+ pub checked: String,
135+ pub checking: bool,
136+ /// A newer version, downloaded and checked, waiting to be installed.
137+ pub ready: Option<String>,
138+ /// What becomes of it, for a runner with no kinds of job of its own
139+ /// (Docker, or on this computer): `0.3.2 ready, installs after this
140+ /// job`. A sandbox's cards each say it for their own kind.
141+ pub note: Option<String>,
142+ /// Looks now: "Check for runner updates".
143+ pub check: Action,
144+}
145+
121146 /// How one kind of job is, as its card's label says it.
122147 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
123148 #[serde(rename_all = "snake_case")]
174199 pub runner: Option<String>,
175200 /// Its runner's version.
176201 pub version: Option<String>,
202+ /// A newer version waiting for it: `0.3.2 ready, installs after this job`.
203+ pub update: Option<String>,
177204 }
178205
179206 /// The job a kind of job is running, with where to see it on g1t.
218245 pub download: Option<launcher::Download>,
219246 /// The person stopped the whole runner, or turned it off.
220247 pub held: bool,
248+ pub update: Update,
221249 }
222250
251+/// How the runner's updates stand.
252+#[derive(Clone, Debug, Default, PartialEq, Eq)]
253+pub struct Update {
254+ /// The g1t-runner here.
255+ pub installed: Option<String>,
256+ /// When g1t last heard from the releases, RFC 3339, and how long ago.
257+ pub checked_at: Option<String>,
258+ pub checked_ago: Option<u64>,
259+ pub checking: bool,
260+ /// A newer release, downloaded and checked, waiting to be put in place.
261+ pub ready: Option<String>,
262+ /// What is still to take it: `linux` (the sandbox's image), `windows`
263+ /// (the program its runner is), or `runner` for a runner with no kinds
264+ /// of job of its own.
265+ pub waiting: Vec<String>,
266+ /// Those taking it now.
267+ pub installing: Vec<String>,
268+}
269+
270+/// When g1t last looked for a newer runner: `Checked 12 min ago`.
271+pub fn checked(update: &Update) -> String {
272+ if update.checking {
273+ return "Checking for updates…".into();
274+ }
275+ let Some(ago) = update.checked_ago else { return "Not checked yet".into() };
276+ let plural = |n: u64, one: &str| if n == 1 { format!("Checked 1 {one} ago") } else { format!("Checked {n} {one}s ago") };
277+ match ago {
278+ 0..=59 => "Checked just now".into(),
279+ 60..=3599 => format!("Checked {} min ago", ago / 60),
280+ 3600..=86_399 => plural(ago / 3600, "hour"),
281+ _ => plural(ago / 86_400, "day"),
282+ }
283+}
284+
285+/// What becomes of a newer version waiting for `what` (a kind of job, or
286+/// `runner`), whose job is running when `busy`. None when nothing waits.
287+fn waiting(update: &Update, what: &str, busy: bool) -> Option<String> {
288+ let version = update.ready.as_deref()?;
289+ if update.installing.iter().any(|kind| kind == what) {
290+ return some(format!("Installing {version}"));
291+ }
292+ if !update.waiting.iter().any(|kind| kind == what) {
293+ return None;
294+ }
295+ some(if busy { format!("{version} ready, installs after this job") } else { format!("{version} ready, installs in a moment") })
296+}
297+
298+fn update_line(facts: &Facts, code: Code) -> UpdateLine {
299+ let update = &facts.update;
300+ UpdateLine {
301+ checked_at: update.checked_at.clone(),
302+ checked: checked(update),
303+ checking: update.checking,
304+ ready: update.ready.clone(),
305+ note: waiting(update, "runner", code == Code::Busy),
306+ check: CHECK_UPDATES,
307+ }
308+}
309+
223310 const START: Action = Action { id: "start", label: "Start" };
224311 const TURN_ON: Action = Action { id: "start", label: "Turn on" };
225312 const RETRY: Action = Action { id: "retry", label: "Retry" };
226313 const CHECK_NOW: Action = Action { id: "retry", label: "Check now" };
314+/// Looks for a newer runner now, and installs it as soon as its kinds of job are idle.
315+const CHECK_UPDATES: Action = Action { id: "check_updates", label: "Check for runner updates" };
316+const CHECK_AGAIN: Action = Action { id: "check_updates", label: "Check again" };
227317 const RESTART: Action = Action { id: "restart", label: "Restart" };
228318 const SIGN_IN: Action = Action { id: "sign_in", label: "Sign in" };
229319 const CHOOSE_WORKSPACE: Action = Action { id: "choose_workspace", label: "Choose workspace" };
462552 Tone::Warning,
463553 "Update failed".into(),
464554 detail,
465− some("The runner keeps running the version it has, and g1t tries the update again in a few hours. Retry tries now; if it keeps failing, report it."),
466− vec![RETRY, REPORT],
555+ some("The runner keeps running the version it has, and g1t tries the update again within the hour. Check again tries now; if it keeps failing, report it."),
556+ vec![CHECK_AGAIN, REPORT],
467557 ),
558+ Code::UpdateRefused => {
559+ let why = detail.unwrap_or_else(|| "its signature didn't check out".into());
560+ let tampered = format!(
561+ "If this keeps happening, something between this computer and {} is changing downloads, such as a proxy or antivirus: report it.",
562+ facts.server
563+ );
564+ match facts.update.installed.as_deref() {
565+ Some(version) => (
566+ Tone::Warning,
567+ "Update refused: not signed by g1t".into(),
568+ some(format!("The newest g1t-runner release from {} isn't signed with g1t's release key ({why}), so g1t didn't install it.", facts.server)),
569+ some(format!("The runner keeps running g1t-runner {version}, and g1t checks again within the hour. {tampered}")),
570+ vec![CHECK_AGAIN, REPORT],
571+ ),
572+ None => (
573+ Tone::Problem,
574+ "Not running: the runner's release isn't signed by g1t".into(),
575+ some(format!("The g1t-runner release from {} isn't signed with g1t's release key ({why}), so g1t didn't install it, and has no runner to start.", facts.server)),
576+ some(format!("g1t tries again by itself in a few minutes; Retry tries now. {tampered}")),
577+ vec![RETRY, REPORT],
578+ ),
579+ }
580+ }
468581 Code::Offline => (
469582 Tone::Warning,
470583 format!("Offline: {} unreachable", facts.server),
503616 | Code::SandboxFailed => cause.is_some() && capabilities.iter().any(|c| c.cause == cause),
504617 _ => false,
505618 };
506− Diagnosis { code, tone, title, cause, fix, actions, capabilities, in_capabilities }
619+ let update = Some(update_line(facts, code));
620+ Diagnosis { code, tone, title, cause, fix, actions, capabilities, in_capabilities, update }
507621 }
508622
509623 /// What the app calls a sandbox's problem with Linux jobs, from the
592706 isolation: capability.isolation.clone().filter(|_| capability.available),
593707 runner: capability.available.then(|| format!("{}-{id}", facts.machine)),
594708 version: capability.runner_version.clone(),
709+ update: waiting(&facts.update, id, capability.job.is_some()),
595710 };
596711 let said = |line: &mut CapabilityLine, state: KindState, tone: Tone, cause: Option<String>, fix: Option<String>, actions: Vec<Action>| {
597712 line.state = state;
729844 actions: vec![],
730845 capabilities: vec![],
731846 in_capabilities: false,
847+ update: None,
732848 }
733849 }
734850
768884 Code::SandboxFailed,
769885 Code::SandboxTaken,
770886 Code::UpdateFailed,
887+ Code::UpdateRefused,
771888 Code::Offline,
772889 Code::Crashed,
773890 ] {
9671084 }
9681085
9691086 #[test]
1087+ fn when_g1t_last_looked_for_a_runner_is_said_plainly() {
1088+ let ago = |seconds: u64| checked(&Update { checked_ago: Some(seconds), ..Update::default() });
1089+ assert_eq!(checked(&Update::default()), "Not checked yet");
1090+ assert_eq!(checked(&Update { checking: true, checked_ago: Some(30), ..Update::default() }), "Checking for updates…");
1091+ assert_eq!(ago(20), "Checked just now");
1092+ assert_eq!(ago(12 * 60 + 5), "Checked 12 min ago");
1093+ assert_eq!(ago(3600), "Checked 1 hour ago");
1094+ assert_eq!(ago(5 * 3600), "Checked 5 hours ago");
1095+ assert_eq!(ago(2 * 86_400), "Checked 2 days ago");
1096+ }
1097+
1098+ /// 0.3.2 is downloaded: Linux jobs take its image after the job they
1099+ /// are running, Windows jobs, idle, take its program now.
1100+ #[test]
1101+ fn a_waiting_update_is_said_on_each_kind_of_jobs_card() {
1102+ let status = status(
1103+ r#"{"schema":1,"state":"running","backend":"wsl2","image_version":"0.3.1","capabilities":[
1104+ {"id":"linux","available":true,"state":"running","runner_version":"0.3.1","turned_off":false,"job":{"kind":"workflow","id":"j","name":"build","repo":"acme/web"}},
1105+ {"id":"windows","available":true,"state":"running","runner_version":"0.3.1","turned_off":false}]}"#,
1106+ );
1107+ let update = Update {
1108+ installed: Some("0.3.1".into()),
1109+ checked_at: Some("2026-10-12T12:00:00Z".into()),
1110+ checked_ago: Some(12 * 60),
1111+ ready: Some("0.3.2".into()),
1112+ waiting: vec!["linux".into(), "windows".into()],
1113+ ..Update::default()
1114+ };
1115+ let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status.clone()), live: true, update: update.clone(), ..facts() });
1116+ let [linux, windows] = &busy.capabilities[..] else { panic!() };
1117+ assert_eq!(linux.version.as_deref(), Some("0.3.1"));
1118+ assert_eq!(linux.update.as_deref(), Some("0.3.2 ready, installs after this job"));
1119+ assert_eq!(windows.update.as_deref(), Some("0.3.2 ready, installs in a moment"));
1120+ let line = busy.update.unwrap();
1121+ assert_eq!((line.checked.as_str(), line.ready.as_deref(), line.checked_at.as_deref()), ("Checked 12 min ago", Some("0.3.2"), Some("2026-10-12T12:00:00Z")));
1122+ // The cards say it, each for its own kind: nothing is said for the whole runner.
1123+ assert_eq!(line.note, None);
1124+ assert_eq!((line.check.id, line.check.label), ("check_updates", "Check for runner updates"));
1125+ // Windows jobs stop while the program is replaced: installing, then nothing waits.
1126+ let installing = Update { installing: vec!["windows".into()], ..update.clone() };
1127+ let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status.clone()), live: true, update: installing, ..facts() });
1128+ assert_eq!(busy.capabilities[1].update.as_deref(), Some("Installing 0.3.2"));
1129+ let done = Update { waiting: vec!["linux".into()], ..update.clone() };
1130+ let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status), live: true, update: done, ..facts() });
1131+ assert_eq!(busy.capabilities[1].update, None);
1132+ // In Docker or on this computer there are no cards: the whole runner says it.
1133+ let docker = Update { waiting: vec!["runner".into()], ..update };
1134+ let busy = diagnose(Code::Busy, &Facts { backend: "in Docker", job: Some("build (acme/web)".into()), update: docker.clone(), ..facts() });
1135+ assert_eq!(busy.update.unwrap().note.as_deref(), Some("0.3.2 ready, installs after this job"));
1136+ let idle = diagnose(Code::Idle, &Facts { backend: "in Docker", update: docker, ..facts() });
1137+ assert_eq!(idle.update.unwrap().note.as_deref(), Some("0.3.2 ready, installs in a moment"));
1138+ // What is said once g1t is closed knows nothing of updates.
1139+ assert_eq!(closed(&facts()).update, None);
1140+ }
1141+
1142+ #[test]
1143+ fn a_release_not_signed_by_g1t_is_refused_and_said_so() {
1144+ let detail = Some("the release's signature isn't g1t's".to_owned());
1145+ let running = diagnose(Code::UpdateRefused, &Facts { detail: detail.clone(), update: Update { installed: Some("0.3.1".into()), ..Update::default() }, ..facts() });
1146+ assert_eq!(running.title, "Update refused: not signed by g1t");
1147+ assert_eq!(
1148+ running.cause.as_deref(),
1149+ Some("The newest g1t-runner release from g1t.sh isn't signed with g1t's release key (the release's signature isn't g1t's), so g1t didn't install it.")
1150+ );
1151+ assert!(running.fix.as_deref().unwrap().starts_with("The runner keeps running g1t-runner 0.3.1, and g1t checks again within the hour."));
1152+ assert_eq!(running.actions, [CHECK_AGAIN, REPORT]);
1153+ assert_eq!(running.tone, Tone::Warning);
1154+ // With no runner yet, nothing runs until a signed one downloads.
1155+ let nothing = diagnose(Code::UpdateRefused, &Facts { detail, ..facts() });
1156+ assert_eq!(nothing.title, "Not running: the runner's release isn't signed by g1t");
1157+ assert_eq!((nothing.tone, nothing.actions[0].id), (Tone::Problem, "retry"));
1158+ assert!(!nothing.in_capabilities);
1159+ }
1160+
1161+ #[test]
9701162 fn a_diagnosis_without_the_sandbox_lists_no_capabilities() {
9711163 let json = serde_json::to_value(diagnose(Code::Idle, &Facts { backend: "in Docker", ..facts() })).unwrap();
9721164 assert!(json.get("capabilities").is_none());
+10−0
344344 args
345345 }
346346
347+/// `down --only <kind> --drain`: as [`down_only_args`], after the job
348+/// that kind is running, if any. An update stops a kind this way while
349+/// its program is replaced, and starts it again after.
350+pub fn drain_only_args(dir: &Path, kind: &str) -> Vec<OsString> {
351+ let mut args = down_only_args(dir, kind);
352+ args.push("--drain".into());
353+ args
354+}
355+
347356 pub fn status_args(dir: &Path) -> Vec<OsString> {
348357 args("status", dir)
349358 }
676685 // Never the token: it goes in the environment, out of the process list.
677686 assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop", "--only", "linux,windows"]);
678687 assert_eq!(text(down_only_args(&dir, "windows"))[5..], ["--only", "windows"]);
688+ assert_eq!(text(drain_only_args(&dir, "windows"))[5..], ["--only", "windows", "--drain"]);
679689 assert!(!up.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
680690 let mut command = command(Path::new("g1t-runner"), up_args(&dir, &first));
681691 with_token(&mut command, first.token);
+444−80
3232 //! (registration.json and the runner's own folder; the sandbox keeps its
3333 //! own, and sandbox-registration.json says whose it is) and used on
3434 //! every later start.
35−//! - **Up to date.** Soon after it starts and every six hours, while the
36−//! runner is idle, the app looks for a newer release and puts it in
37−//! place, and has the sandbox take the newest image (`sandbox update`):
38−//! never in the middle of a job.
35+//! - **Up to date.** Soon after it starts and every hour, and when the
36+//! person asks ("Check for runner updates"), the app looks for a newer
37+//! release on a thread of its own: its manifest only when g1t's release
38+//! key signed it (native.rs), its program only when its SHA-256 is the
39+//! manifest's. What it finds waits, downloaded, until each kind of job
40+//! is idle, and goes in kind by kind, never in the middle of a job: the
41+//! sandbox's Linux jobs take its image (`sandbox update`, which drains
42+//! them itself) once they have no job; its Windows jobs, whose runner is
43+//! the program itself, are drained (`down --only windows --drain`), the
44+//! program is replaced, and they start again; a runner in Docker or on
45+//! this computer is stopped between jobs and started on the new one.
46+//! "Download updates by themselves" off, it looks only when asked.
3947 //! - **Seen.** What it is doing, and when it is not running why and how to
4048 //! fix it (diagnosis.rs), is in the tray, in settings, and on the site's
4149 //! Runners page (`g1t-runner-status`), read from what its log says
5866 use std::process::{Child, Stdio};
5967 use std::sync::Mutex;
6068 use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
61−use std::time::{Duration, Instant};
69+use std::time::{Duration, Instant, SystemTime};
6270
6371 use serde::{Deserialize, Serialize};
6472 use tauri::{AppHandle, Emitter, EventTarget, Manager};
6876 use backend::{Engine, Missing};
6977 use diagnosis::{Code, Diagnosis, Facts};
7078 use launcher::{Down, State, Status};
79+use native::Fault;
7180
7281 use crate::{config, frame, settings, tray};
7382
8291 const TICK: Duration = Duration::from_secs(5);
8392 /// How long after starting the first look for an update is, then how often.
8493 const FIRST_UPDATE: Duration = Duration::from_secs(90);
85−const UPDATE_EVERY: Duration = Duration::from_secs(6 * 60 * 60);
94+const UPDATE_EVERY: Duration = Duration::from_secs(60 * 60);
8695 /// How often a backend that cannot start is looked at again.
8796 const LOOK_AGAIN: Duration = Duration::from_secs(30);
8897 /// How long the app waits for the site to hand over a token before asking again.
126135 download: Option<launcher::Download>,
127136 }
128137
138+/// How the runner's updates stand, as the thread last said.
139+#[derive(Clone, Debug, Default, PartialEq)]
140+struct Updates {
141+ /// When the releases last answered.
142+ checked_at: Option<SystemTime>,
143+ checking: bool,
144+ /// A newer version, downloaded and checked, waiting.
145+ ready: Option<String>,
146+ /// Its program is still to be put in place.
147+ program: bool,
148+ /// The sandbox's image is still to take it.
149+ image: bool,
150+ /// The kinds of job taking it now.
151+ installing: Vec<String>,
152+}
153+
154+/// What a look at the releases found.
155+struct Found {
156+ version: String,
157+ /// Newer than the program here.
158+ newer: bool,
159+ /// Its program, downloaded and checked: None when it isn't newer, or is
160+ /// already waiting.
161+ program: Option<Vec<u8>>,
162+ /// The release has the sandbox's images.
163+ images: bool,
164+}
165+
129166 /// What the site last said about the person.
130167 #[derive(Clone, Debug, Default, PartialEq)]
131168 struct Site {
146183 paused: Mutex<bool>,
147184 tail: Mutex<VecDeque<String>>,
148185 tx: Mutex<Option<Sender<Ask>>>,
186+ updates: Mutex<Updates>,
149187 }
150188
151189 /// What settings, the tray and the site are shown.
180218 /// A kind of job was started: start what should run now, stopping nothing.
181219 Soon,
182220 Retry,
221+ /// "Check for runner updates": look now, and install what is found
222+ /// even with "Download updates by themselves" off.
223+ Check,
224+ /// What a look at the releases found.
225+ Checked(Result<Found, Fault>),
183226 Token { workspace: String, token: String },
184227 Refused { workspace: String, allowed: bool, why: String },
185228 /// A line of the runner's output, or the launcher's; true from its standard output.
295338 starting: now.starting.clone(),
296339 download: now.download.clone(),
297340 held: !settings.runner || *app.state::<Shared>().paused.lock().unwrap(),
341+ update: update_facts(app, now, backend),
298342 }
299343 }
300344
345+/// How the runner's updates stand, for the words: what is still to take a
346+/// waiting version. The sandbox's Windows jobs (any kind but Linux) take
347+/// its program while they run; a runner with no kinds of job takes it
348+/// itself.
349+fn update_facts(app: &AppHandle, now: &Now, backend: Backend) -> diagnosis::Update {
350+ let updates = app.state::<Shared>().updates.lock().unwrap().clone();
351+ let sandbox = now.sandbox.as_ref().filter(|_| backend == Backend::Sandboxed);
352+ let mut waiting = Vec::new();
353+ if updates.image {
354+ waiting.push("linux".to_owned());
355+ }
356+ if updates.program {
357+ match sandbox {
358+ Some(status) => waiting.extend(native_kinds(status).into_iter().map(|c| c.id.clone())),
359+ None => waiting.push("runner".to_owned()),
360+ }
361+ }
362+ diagnosis::Update {
363+ installed: native::installed(&folder(app)),
364+ checked_at: updates.checked_at.map(rfc3339),
365+ checked_ago: updates.checked_at.and_then(|at| at.elapsed().ok()).map(|ago| ago.as_secs()),
366+ checking: updates.checking,
367+ ready: updates.ready,
368+ waiting,
369+ installing: updates.installing,
370+ }
371+}
372+
373+/// The kinds of job running on the program itself, which replacing it
374+/// stops: the sandbox's Windows jobs (not Linux jobs, which run in its VM).
375+fn native_kinds(status: &Status) -> Vec<&launcher::Capability> {
376+ status.capabilities.iter().filter(|c| c.id != "linux" && !c.off() && matches!(c.state, State::Running | State::Starting)).collect()
377+}
378+
379+/// A time as RFC 3339, in UTC to the second: `2026-10-12T12:04:05Z`.
380+fn rfc3339(at: SystemTime) -> String {
381+ let seconds = at.duration_since(SystemTime::UNIX_EPOCH).map_or(0, |since| since.as_secs());
382+ let (days, rest) = (seconds / 86_400, seconds % 86_400);
383+ // Days since 1970 to a date in the proleptic Gregorian calendar (Howard Hinnant's civil_from_days).
384+ let z = days as i64 + 719_468;
385+ let era = z.div_euclid(146_097);
386+ let day_of_era = z.rem_euclid(146_097);
387+ let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
388+ let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
389+ let month_index = (5 * day_of_year + 2) / 153;
390+ let day = day_of_year - (153 * month_index + 2) / 5 + 1;
391+ let month = if month_index < 10 { month_index + 3 } else { month_index - 9 };
392+ let year = year_of_era + era * 400 + i64::from(month <= 2);
393+ format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", rest / 3600, rest % 3600 / 60, rest % 60)
394+}
395+
301396 pub fn view(app: &AppHandle) -> View {
302397 let shared = app.state::<Shared>();
303398 let now = shared.now.lock().unwrap().clone();
443538 }
444539 "stop" => set_paused(app, true),
445540 "retry" | "restart" => retry(app),
541+ "check_updates" => ask(app, Ask::Check),
446542 "sign_in" => crate::links::go(app, "/login"),
447543 "new_workspace" => crate::links::go(app, "/workspaces/new"),
448544 "view_runners" => match workspace(app) {
556652 Start(String),
557653 /// `down --only <kind>`: one kind of job, stopped, the rest running on.
558654 Stop(String),
655+ /// `down --only <kind> --drain`: one kind of job, stopped after its
656+ /// job, so the program its runner is can be replaced.
657+ Drain(String),
559658 }
560659
561660 impl Step {
563662 match self {
564663 Step::Up | Step::Start(_) => "up",
565664 Step::Update => "update",
566− Step::Stop(_) => "down",
665+ Step::Stop(_) | Step::Drain(_) => "down",
567666 }
568667 }
569668 }
633732 failures: u32,
634733 next_update: Instant,
635734 log: Option<File>,
636− /// The runner was updated: the sandbox takes its image before it starts again.
637− image_pending: bool,
638− /// Why the last update failed, said until the next one works.
639− update_failed: Option<String>,
735+ /// Why the last update failed (`UpdateFailed`, or `UpdateRefused` for
736+ /// a release g1t didn't sign), said while the runner is idle, until
737+ /// the next one works.
738+ update_failed: Option<(Code, String)>,
739+ /// A newer program, downloaded and checked, waiting for the kinds of
740+ /// job that run on it to be idle: its version and its bytes.
741+ program: Option<(String, Vec<u8>)>,
742+ /// The person asked for this update: it installs with "Download
743+ /// updates by themselves" off.
744+ asked: bool,
745+ /// When g1t last looked, as settings last said it.
746+ said_checked: String,
640747 /// A kind of job that should run and doesn't, while another runs:
641748 /// when it is tried again, and how many times it has been.
642749 retry: Vec<(String, Instant, u32)>,
668775 failures: 0,
669776 next_update: now + FIRST_UPDATE,
670777 log: None,
671− image_pending: false,
672778 update_failed: None,
779+ program: None,
780+ asked: false,
781+ said_checked: String::new(),
673782 retry: Vec::new(),
674783 }
675784 }
692801 }
693802 }
694803 self.look();
804+ self.said_when();
695805 }
696806 }
697807
808+ /// Settings say when g1t last looked for a newer runner, in minutes
809+ /// and hours: said again as that changes.
810+ fn said_when(&mut self) {
811+ let (checked_at, checking) = self.with_updates(|updates| (updates.checked_at, updates.checking));
812+ let checked_ago = checked_at.and_then(|at| at.elapsed().ok()).map(|ago| ago.as_secs());
813+ let checked = diagnosis::checked(&diagnosis::Update { checked_ago, checking, ..diagnosis::Update::default() });
814+ if checked != self.said_checked {
815+ self.said_checked = checked;
816+ publish(&self.app);
817+ }
818+ }
819+
820+ fn with_updates<R>(&self, change: impl FnOnce(&mut Updates) -> R) -> R {
821+ change(&mut self.app.state::<Shared>().updates.lock().unwrap())
822+ }
823+
824+ /// What an idle runner shows: why its last update failed, until one works.
825+ fn idle(&self, code: Code) -> (Code, Extra) {
826+ match (&self.update_failed, code) {
827+ (Some((failed, why)), Code::Idle) => (*failed, detail(why.clone())),
828+ _ => (code, Extra::default()),
829+ }
830+ }
831+
698832 fn set(&mut self, code: Code, extra: Extra) {
699833 let changed = {
700834 let shared = self.app.state::<Shared>();
775909 }
776910 match self.code() {
777911 // The runner is fine: only the update is tried again.
778− Code::UpdateFailed => {
779− self.next_update = Instant::now();
780− self.update_failed = None;
781− }
912+ Code::UpdateFailed | Code::UpdateRefused if self.run.is_some() => self.check(true),
782913 Code::Offline => self.halt(),
783914 _ if self.run.as_ref().is_some_and(|run| !run.listening) => self.halt(),
784915 _ => {}
785916 }
786917 }
918+ Ask::Check => self.check(true),
919+ Ask::Checked(found) => self.checked(found),
787920 Ask::Token { workspace, token } => {
788921 let shared = self.app.state::<Shared>();
789922 let mut wanted = shared.wanted.lock().unwrap();
8791012 }
8801013 self.note(job, version);
8811014 if let Some(code) = became {
882− self.set(code, Extra::default());
1015+ let (code, extra) = self.idle(code);
1016+ self.set(code, extra);
8831017 }
8841018 }
8851019
10271161 self.set(if paused { Code::Stopped } else { Code::Off }, Extra::default());
10281162 return;
10291163 }
1164+ // Looking needs a runner here to compare with; the first comes with the first start.
1165+ if Instant::now() >= self.next_update && native::installed(&folder(&self.app)).is_some() {
1166+ self.check(false);
1167+ }
10301168 let workspace = workspace(&self.app);
10311169 if let Some(run) = &self.run {
10321170 let moved = run.backend != settings.runner_backend || Some(&run.workspace) != workspace.as_ref();
10331171 let back = run.offline_at.is_some_and(|at| at.elapsed() >= BACK_ONLINE);
1034− let (busy, listening, stepping) = (run.busy, run.listening, run.step().is_some());
1172+ let (busy, stepping) = (run.busy, run.step().is_some());
10351173 if back {
10361174 if let Some(run) = self.run.as_mut() {
10371175 run.offline_at = None;
10421180 if moved && !busy && !stepping {
10431181 self.halt_as(Down::Drain);
10441182 self.not_before = Instant::now();
1045− } else if !busy && listening && !stepping && Instant::now() >= self.next_update {
1046− self.update();
1047− } else if !moved && !stepping {
1183+ } else if !moved && !stepping && !self.install() {
10481184 self.kinds(workspace.as_deref());
10491185 }
10501186 self.poll();
10531189 if Instant::now() < self.not_before {
10541190 return;
10551191 }
1192+ // Nothing runs: a waiting program goes in before anything starts.
1193+ if self.program.is_some()
1194+ && self.allowed()
1195+ && let Some((version, bytes)) = self.program.take()
1196+ {
1197+ self.put(version, &bytes);
1198+ }
10561199 self.start(settings.runner_backend, workspace);
10571200 }
10581201
11011244 let envs = environment(&server, backend, token.as_deref());
11021245 let generation = self.generation + 1;
11031246 self.generation = generation;
1104− let started = self.ensure_binary(&server).and_then(|binary| {
1105− let child = native::spawn(&binary, &native_dir(&app, backend), &envs).map_err(|e| format!("g1t-runner could not start: {e}"))?;
1247+ let binary = match self.ensure_binary(&server) {
1248+ Ok(binary) => binary,
1249+ Err(fault) => return self.not_installed(workspace, token, fault),
1250+ };
1251+ let started = native::spawn(&binary, &native_dir(&app, backend), &envs).map_err(|e| format!("g1t-runner could not start: {e}")).inspect(|child| {
11061252 let _ = std::fs::write(folder(&app).join("pid"), child.id().to_string());
1107− Ok(child)
11081253 });
11091254 match started {
11101255 Ok(mut child) => {
11641309 }
11651310 }
11661311
1167− /// `g1t-runner` installed, from the newest release when it is not.
1168− fn ensure_binary(&mut self, server: &Url) -> Result<PathBuf, String> {
1312+ /// `g1t-runner` installed, from the newest release when it is not:
1313+ /// one g1t signed, as every update is.
1314+ fn ensure_binary(&mut self, server: &Url) -> Result<PathBuf, Fault> {
11691315 let folder = folder(&self.app);
11701316 let binary = native::binary(&folder);
11711317 if native::installed(&folder).is_some() {
11731319 }
11741320 self.set(Code::Updating, detail("Downloading g1t-runner."));
11751321 let manifest = native::latest(server)?;
1176− let bytes = native::download(server, &manifest)?;
1177− native::put(&folder, &bytes, &manifest.version)?;
1322+ let bytes = native::download(server, &manifest).map_err(Fault::Failed)?;
1323+ native::put(&folder, &bytes, &manifest.version).map_err(Fault::Failed)?;
11781324 self.note(None, Some(manifest.version));
1325+ self.with_updates(|updates| updates.checked_at = Some(SystemTime::now()));
11791326 Ok(binary)
11801327 }
11811328
1182− /// A newer release or image, put in place while the runner is idle.
1183− /// The new one is downloaded and checked before the runner stops, so
1184− /// one that fails leaves it running as it was.
1185− fn update(&mut self) {
1329+ /// There is no `g1t-runner` here, and none could be had: a release g1t
1330+ /// didn't sign is refused, and asked for again in a while.
1331+ fn not_installed(&mut self, workspace: String, token: Option<String>, fault: Fault) {
1332+ match fault {
1333+ Fault::Failed(why) => self.not_started(workspace, token, why),
1334+ Fault::Unsigned(why) => {
1335+ if let Some(given) = token {
1336+ self.token = Some((workspace, given));
1337+ }
1338+ self.write_log(&format!("g1t: g1t-runner wasn't installed: {why}"));
1339+ self.not_before = Instant::now() + MOST_BACKOFF;
1340+ self.set(Code::UpdateRefused, detail(why));
1341+ }
1342+ }
1343+ }
1344+
1345+ // --- Updates ------------------------------------------------------------------
1346+
1347+ /// Looks for a newer release on a thread of its own, so the runner is
1348+ /// looked after meanwhile: its signed manifest, and its program when it
1349+ /// is newer than the one here. `asked` is the person asking, which
1350+ /// works with "Download updates by themselves" off.
1351+ fn check(&mut self, asked: bool) {
11861352 self.next_update = Instant::now() + UPDATE_EVERY;
1187− if !config::settings(&self.app).auto_update {
1353+ if !asked && !config::settings(&self.app).auto_update {
1354+ return;
1355+ }
1356+ if self.with_updates(|updates| std::mem::replace(&mut updates.checking, true)) {
11881357 return;
11891358 }
1190− let Some(engine) = self.run.as_ref().map(|run| run.engine) else { return };
1191− let server = config::server(&self.app);
1192− let folder = folder(&self.app);
1193− let release = match self.newer_release(&server) {
1194− Ok(release) => release,
1195− Err(why) => {
1196− let why = format!("g1t-runner couldn't be updated: {why}");
1197− self.write_log(&format!("g1t: {why}"));
1198− self.update_failed = Some(why.clone());
1199− return self.set(Code::UpdateFailed, detail(why));
1359+ self.asked |= asked;
1360+ publish(&self.app);
1361+ let (server, folder) = (config::server(&self.app), folder(&self.app));
1362+ let waiting = self.program.as_ref().map(|(version, _)| version.clone());
1363+ let tx = self.tx.clone();
1364+ let spawned = std::thread::Builder::new().name("g1t-runner-releases".into()).spawn(move || {
1365+ let _ = tx.send(Ask::Checked(look_for_release(&server, &folder, waiting.as_deref())));
1366+ });
1367+ if spawned.is_err() {
1368+ self.with_updates(|updates| updates.checking = false);
1369+ }
1370+ }
1371+
1372+ /// What the look found: a newer program waits for its kinds of job to
1373+ /// be idle, and so does the sandbox's image when it is older than the
1374+ /// release's. A release g1t didn't sign, or one that didn't download,
1375+ /// leaves the runner as it is, and is said while it is idle.
1376+ fn checked(&mut self, found: Result<Found, Fault>) {
1377+ self.with_updates(|updates| updates.checking = false);
1378+ let found = match found {
1379+ Ok(found) => found,
1380+ Err(fault) => {
1381+ let failed = match fault {
1382+ Fault::Unsigned(why) => (Code::UpdateRefused, why),
1383+ Fault::Failed(why) => (Code::UpdateFailed, format!("g1t-runner couldn't be updated: {why}")),
1384+ };
1385+ self.write_log(&format!("g1t: {}", failed.1));
1386+ self.update_failed = Some(failed);
1387+ self.asked = false;
1388+ if matches!(self.code(), Code::Idle | Code::UpdateFailed | Code::UpdateRefused) {
1389+ let (code, extra) = self.idle(Code::Idle);
1390+ self.set(code, extra);
1391+ }
1392+ return publish(&self.app);
12001393 }
12011394 };
1202− let Some((version, bytes)) = release else {
1203− // The program is the newest; the sandbox's image may not be
1204− // (`update` does nothing when it is).
1205− if engine == Engine::Launcher {
1206− self.sandbox_step(Step::Update);
1395+ if let Some(bytes) = found.program {
1396+ self.program = Some((found.version.clone(), bytes));
1397+ }
1398+ if !found.newer {
1399+ self.program = None;
1400+ }
1401+ // The sandbox's image is the release's version; one older takes the newest.
1402+ let sandbox = self.app.state::<Shared>().now.lock().unwrap().sandbox.clone();
1403+ let launcher = self.run.as_ref().is_some_and(|run| run.engine == Engine::Launcher);
1404+ let image = launcher
1405+ && found.images
1406+ && sandbox.is_some_and(|status| status.linux().state != State::Absent && status.image_version.as_deref().is_some_and(|have| native::newer(&found.version, have)));
1407+ let program = self.program.is_some();
1408+ self.with_updates(|updates| {
1409+ updates.checked_at = Some(SystemTime::now());
1410+ updates.program = program;
1411+ updates.image = image;
1412+ updates.ready = (program || image).then(|| found.version.clone());
1413+ });
1414+ if program || image {
1415+ self.write_log(&format!("g1t: g1t-runner {} is ready, and installs as each kind of job is idle.", found.version));
1416+ } else {
1417+ self.asked = false;
1418+ }
1419+ // It worked: what the last failure said goes.
1420+ if self.update_failed.take().is_some() && matches!(self.code(), Code::UpdateFailed | Code::UpdateRefused) {
1421+ self.set(Code::Idle, Extra::default());
1422+ }
1423+ publish(&self.app);
1424+ }
1425+
1426+ /// Whether a waiting update may go in: "Download updates by
1427+ /// themselves" is on, or the person asked for it. Turned off since it
1428+ /// downloaded, it no longer waits.
1429+ fn allowed(&mut self) -> bool {
1430+ if self.asked || config::settings(&self.app).auto_update {
1431+ return true;
1432+ }
1433+ self.program = None;
1434+ self.with_updates(|updates| {
1435+ updates.program = false;
1436+ updates.image = false;
1437+ updates.ready = None;
1438+ });
1439+ false
1440+ }
1441+
1442+ /// Puts a waiting update in, kind by kind, as each kind of job is idle:
1443+ /// true when it started something, which is waited for. Never in the
1444+ /// middle of a job.
1445+ fn install(&mut self) -> bool {
1446+ let image = self.with_updates(|updates| updates.image);
1447+ if (self.program.is_none() && !image) || !self.allowed() {
1448+ return false;
1449+ }
1450+ let Some((engine, busy, listening)) = self.run.as_ref().map(|run| (run.engine, run.busy, run.listening)) else { return false };
1451+ if engine != Engine::Launcher {
1452+ // A runner in Docker or on this computer is the program: stopped between jobs, started on the new one.
1453+ if busy || !listening {
1454+ return false;
12071455 }
1208− return;
1209− };
1210− self.set(Code::Updating, detail(format!("Updating g1t-runner to {version}.")));
1211− // The sandbox's runner for Windows jobs is this program too: it
1212− // stops, after its job, before the program is replaced.
1213− self.halt_as(Down::Drain);
1214− match native::put(&folder, &bytes, &version) {
1456+ let Some((version, bytes)) = self.program.take() else { return false };
1457+ self.set(Code::Updating, detail(format!("Updating g1t-runner to {version}.")));
1458+ self.halt();
1459+ self.put(version, &bytes);
1460+ self.not_before = Instant::now();
1461+ return true;
1462+ }
1463+ let Some(status) = self.app.state::<Shared>().now.lock().unwrap().sandbox.clone() else { return false };
1464+ if self.program.is_some() {
1465+ let running = native_kinds(&status);
1466+ if running.is_empty() {
1467+ // Nothing runs on the program: it is replaced now, and
1468+ // kinds() starts what was drained on the new one first,
1469+ // before the image, which takes minutes, is updated.
1470+ let (version, bytes) = self.program.take().expect("checked above");
1471+ self.put(version, &bytes);
1472+ return false;
1473+ } else if !status.per_kind() {
1474+ // A launcher from before kinds of job stopped on their own: the whole sandbox stops between jobs.
1475+ if busy {
1476+ return false;
1477+ }
1478+ let (version, bytes) = self.program.take().expect("checked above");
1479+ self.set(Code::Updating, detail(format!("Updating g1t-runner to {version}.")));
1480+ self.halt_as(Down::Drain);
1481+ self.put(version, &bytes);
1482+ self.with_updates(|updates| updates.image = true);
1483+ self.not_before = Instant::now();
1484+ return true;
1485+ } else if let Some(idle) = running.iter().find(|kind| kind.job.is_none()) {
1486+ // Stopped after any job it takes meanwhile; replaced once it has.
1487+ let kind = idle.id.clone();
1488+ self.with_updates(|updates| updates.installing = vec![kind.clone()]);
1489+ self.kind_step(Step::Drain(kind), None);
1490+ return true;
1491+ }
1492+ }
1493+ // Linux jobs take the image once they have no job (`update` drains them itself too).
1494+ let image = self.with_updates(|updates| updates.image);
1495+ if image && status.linux().job.is_none() {
1496+ self.with_updates(|updates| updates.installing.push("linux".into()));
1497+ self.sandbox_step(Step::Update);
1498+ return true;
1499+ }
1500+ false
1501+ }
1502+
1503+ /// Puts a downloaded program in place, with nothing running on it.
1504+ fn put(&mut self, version: String, bytes: &[u8]) {
1505+ match native::put(&folder(&self.app), bytes, &version) {
12151506 Ok(()) => {
1216− self.note(None, Some(version));
1217− self.update_failed = None;
1218− // The sandbox's image comes with each release.
1219− self.image_pending = engine == Engine::Launcher;
1507+ self.write_log(&format!("g1t: g1t-runner {version} is in place."));
1508+ if self.run.as_ref().is_none_or(|run| run.engine != Engine::Launcher) {
1509+ self.note(None, Some(version));
1510+ }
12201511 }
1221− Err(why) => self.write_log(&format!("g1t: g1t-runner {version} couldn't be put in place: {why}")),
1512+ Err(why) => {
1513+ let why = format!("g1t-runner {version} couldn't be put in place: {why}");
1514+ self.write_log(&format!("g1t: {why}"));
1515+ self.update_failed = Some((Code::UpdateFailed, why));
1516+ }
12221517 }
1223− self.not_before = Instant::now();
1518+ self.done_with(|updates| {
1519+ updates.program = false;
1520+ updates.installing.retain(|kind| kind == "linux");
1521+ });
12241522 }
12251523
1226− /// A release newer than the `g1t-runner` here, downloaded and checked
1227− /// against its SHA-256: its version and program. None when this one is
1228− /// the newest, or the releases can't be asked now.
1229− fn newer_release(&self, server: &Url) -> Result<Option<(String, Vec<u8>)>, String> {
1230− let Ok(manifest) = native::latest(server) else { return Ok(None) };
1231− if !native::installed(&folder(&self.app)).is_none_or(|have| native::newer(&manifest.version, &have)) {
1232− return Ok(None);
1524+ /// Part of an update is done: what waits is said again.
1525+ fn done_with(&mut self, change: impl FnOnce(&mut Updates)) {
1526+ let left = self.with_updates(|updates| {
1527+ change(updates);
1528+ if !updates.program && !updates.image {
1529+ updates.ready = None;
1530+ }
1531+ updates.program || updates.image
1532+ });
1533+ if !left {
1534+ self.asked = false;
12331535 }
1234− let bytes = native::download(server, &manifest).map_err(|why| format!("{} didn't download: {why}", manifest.version))?;
1235− Ok(Some((manifest.version, bytes)))
1536+ publish(&self.app);
12361537 }
12371538
12381539 // --- The sandbox (launcher.rs) ------------------------------------------------
12441545 let server = config::server(&app);
12451546 let binary = match self.ensure_binary(&server) {
12461547 Ok(binary) => binary,
1247− Err(why) => return self.not_started(workspace, None, why),
1548+ Err(fault) => return self.not_installed(workspace, None, fault),
12481549 };
12491550 let dir = native_dir(&app, Backend::Sandboxed);
12501551 let mut status = match launcher::status(&binary, &dir) {
13171618 self.generation = generation;
13181619 self.run = Some(Run::new(generation, Engine::Launcher, Backend::Sandboxed, workspace.clone(), Process::Sandbox(None), token.is_some()));
13191620 // A new release's image first, then the sandbox starts again.
1320− let step = if self.image_pending && status.linux().state != State::Absent { Step::Update } else { Step::Up };
1321− self.image_pending = false;
1621+ let image = self.with_updates(|updates| std::mem::take(&mut updates.image));
1622+ let step = if image && status.linux().state != State::Absent { Step::Update } else { Step::Up };
1623+ if step == Step::Update {
1624+ self.with_updates(|updates| updates.installing.push("linux".into()));
1625+ } else if image {
1626+ self.done_with(|_| {});
1627+ }
13221628 let name = backend::machine_name();
13231629 let url = server.as_str().trim_end_matches('/').to_owned();
13241630 let up = launcher::Up { url: &url, name: &name, labels: "desktop", token: token.as_deref(), only: wanted.iter().map(String::as_str).collect() };
13621668 return;
13631669 }
13641670 let on = config::settings(&self.app).runner_capabilities;
1671+ // A kind of job drained for a new program starts on the new one, once it is in.
1672+ let replacing = self.program.is_some();
13651673 for capability in &status.capabilities {
13661674 if !on.on(&capability.id) && !capability.off() {
13671675 return self.kind_step(Step::Stop(capability.id.clone()), None);
13681676 }
1369− if on.on(&capability.id) && capability.off() && capability.available {
1677+ if on.on(&capability.id) && capability.off() && capability.available && !(replacing && capability.id != "linux") {
13701678 return self.start_kind(capability, workspace);
13711679 }
13721680 }
14301738 (launcher::up_args(&dir, &up), vec![kind.clone()], format!("starting {}", diagnosis::kind_label(kind)))
14311739 }
14321740 Step::Stop(kind) => (launcher::down_only_args(&dir, kind), vec![], format!("stopping {}", diagnosis::kind_label(kind))),
1741+ Step::Drain(kind) => (launcher::drain_only_args(&dir, kind), vec![], format!("stopping {} after their job, to update g1t-runner", diagnosis::kind_label(kind))),
14331742 Step::Up | Step::Update => return,
14341743 };
14351744 self.write_log(&format!("g1t: {said}."));
14451754 self.write_log(&format!("g1t: the sandbox's status: {why}"));
14461755 self.failures += 1;
14471756 if why.contains("older than its sandbox launcher") {
1448− let server = config::server(&self.app);
1449− if let Ok(Some((version, bytes))) = self.newer_release(&server)
1450− && native::put(&folder(&self.app), &bytes, &version).is_ok()
1757+ let (server, folder) = (config::server(&self.app), folder(&self.app));
1758+ if let Ok(Found { version, program: Some(bytes), .. }) = look_for_release(&server, &folder, None)
1759+ && native::put(&folder, &bytes, &version).is_ok()
14511760 {
14521761 self.note(None, Some(version));
14531762 self.not_before = Instant::now();
15701879 return self.sandbox_stopped(&status, code, extra);
15711880 }
15721881 match (code, update_failed) {
1573− (Code::Idle, Some(why)) => self.set(Code::UpdateFailed, detail(why)),
1882+ (Code::Idle, Some((failed, why))) => self.set(failed, detail(why)),
15741883 (code, _) => self.set(code, extra),
15751884 }
15761885 }
16451954 if let Some(error) = &error {
16461955 self.write_log(&format!("g1t: sandbox {}: {error}", step.name()));
16471956 }
1957+ // Drained for a new program, which goes in on the next look; one
1958+ // that didn't stop leaves the update for the next one, rather than
1959+ // being asked again and again.
1960+ if let (Step::Drain(_), Some(error)) = (&step, &error) {
1961+ self.program = None;
1962+ self.update_failed = Some((Code::UpdateFailed, format!("g1t-runner couldn't be updated: {error}")));
1963+ self.done_with(|updates| {
1964+ updates.program = false;
1965+ updates.installing.retain(|kind| kind == "linux");
1966+ });
1967+ }
16481968 // One kind of job started or stopped: how the sandbox is now says
16491969 // the rest, and one that didn't start is tried again in a while.
1650− if matches!(step, Step::Start(_) | Step::Stop(_)) {
1970+ if matches!(step, Step::Start(_) | Step::Stop(_) | Step::Drain(_)) {
16511971 if let Some(status) = status {
16521972 self.sandbox_now(status);
16531973 }
16631983 registration.save(&app);
16641984 }
16651985 }
1986+ // The image is taken, or failed to be: the next look says whether another waits.
1987+ if step == Step::Update {
1988+ self.done_with(|updates| {
1989+ updates.image = false;
1990+ updates.installing.retain(|kind| kind != "linux");
1991+ });
1992+ }
16661993 if let Some(error) = error.clone() {
16671994 if self.registration_trouble(&error) {
16681995 return;
16691996 }
16701997 if step == Step::Update {
1671− self.update_failed = Some(format!("The sandbox's image couldn't be updated: {error}"));
1998+ self.update_failed = Some((Code::UpdateFailed, format!("The sandbox's image couldn't be updated: {error}")));
16721999 } else if !running {
16732000 // Nothing runs: tidied away, and tried again in a while;
16742001 // a release without the sandbox's images every few minutes.
17072034 /// What the tray says while the sandbox starts.
17082035 const STARTING_SANDBOX: &str = "Starting the sandbox. The first time, it downloads and checks the sandbox's image, which takes a few minutes.";
17092036
2037+/// The newest release, signed, and its program when it is newer than the
2038+/// one in `folder` and isn't already `waiting`: downloaded and checked
2039+/// against its SHA-256. On the update thread, never the runner's.
2040+fn look_for_release(server: &Url, folder: &std::path::Path, waiting: Option<&str>) -> Result<Found, Fault> {
2041+ let manifest = native::latest(server)?;
2042+ let newer = native::installed(folder).is_none_or(|have| native::newer(&manifest.version, &have));
2043+ let program = if newer && waiting != Some(manifest.version.as_str()) {
2044+ Some(native::download(server, &manifest).map_err(|why| Fault::Failed(format!("{} didn't download: {why}", manifest.version)))?)
2045+ } else {
2046+ None
2047+ };
2048+ Ok(Found { images: manifest.has_images(), version: manifest.version, newer, program })
2049+}
2050+
17102051 fn some_text(text: &str) -> Option<String> {
17112052 Some(text.to_owned())
17122053 }
18462187 }
18472188
18482189 #[test]
2190+ fn when_is_said_as_the_site_reads_it() {
2191+ let at = |seconds: u64| rfc3339(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds));
2192+ assert_eq!(at(0), "1970-01-01T00:00:00Z");
2193+ assert_eq!(at(1_791_806_645), "2026-10-12T12:04:05Z");
2194+ // The day after February 29th of a leap year.
2195+ assert_eq!(at(1_709_251_199 + 1), "2024-03-01T00:00:00Z");
2196+ assert_eq!(at(951_782_400), "2000-02-29T00:00:00Z");
2197+ }
2198+
2199+ /// What is running on the program itself, which an update drains first:
2200+ /// Windows jobs running or starting, not Linux jobs, nor ones stopped.
2201+ #[test]
2202+ fn an_update_drains_only_what_runs_on_the_program() {
2203+ let kinds = |json: &str| native_kinds(&status(json)).iter().map(|c| c.id.clone()).collect::<Vec<_>>();
2204+ assert_eq!(
2205+ kinds(r#"{"schema":1,"state":"running","capabilities":[{"id":"linux","available":true,"state":"running","turned_off":false},{"id":"windows","available":true,"state":"running","turned_off":false}]}"#),
2206+ ["windows"]
2207+ );
2208+ assert!(kinds(r#"{"schema":1,"state":"running","capabilities":[{"id":"linux","available":true,"state":"running","turned_off":false},{"id":"windows","available":true,"state":"stopped","turned_off":true}]}"#).is_empty());
2209+ assert!(kinds(r#"{"schema":1,"state":"running","capabilities":[{"id":"linux","available":true,"state":"running"},{"id":"windows","available":false,"state":"absent"}]}"#).is_empty());
2210+ }
2211+
2212+ #[test]
18492213 fn the_site_names_workspaces_in_its_own_words() {
18502214 let offered: Vec<Offered> = serde_json::from_str(r#"[{"slug":"acme","name":"Acme"},{"slug":"solo"}]"#).unwrap();
18512215 assert_eq!(offered[1], Offered { slug: "solo".into(), name: None });
+162−11
44 //!
55 //! The app installs and updates it from the server's releases: the
66 //! newest one's manifest is `<server>/downloads/runner/latest.json`, which
7−//! names each platform's file and its SHA-256, and a download whose
8−//! SHA-256 is not the manifest's is thrown away. The runner's own
9−//! updating is turned off (`G1T_RUNNER_NO_AUTO_UPDATE`), so only the app
10−//! replaces it, and only while it runs nothing.
7+//! names each platform's file and its SHA-256, and `latest.json.sig` is
8+//! its Ed25519 signature, made with g1t's release key
9+//! (scripts/runner-release.mjs). The app trusts a manifest only when that
10+//! signature checks out against the key built into it, as the runner does
11+//! with its own updates (crates/runner/src/selfhosted/update.rs), and
12+//! throws away a download whose SHA-256 is not the manifest's. The
13+//! runner's own updating is turned off (`G1T_RUNNER_NO_AUTO_UPDATE`), so
14+//! only the app replaces it, and only while it runs nothing.
1115
1216 use std::collections::BTreeMap;
1317 use std::path::{Path, PathBuf};
1418 use std::process::{Child, Stdio};
1519 use std::time::Duration;
1620
21+use base64::Engine;
22+use base64::engine::general_purpose::STANDARD;
1723 use serde::Deserialize;
1824 use sha2::{Digest, Sha256};
1925 use url::Url;
2026
2127 use super::backend::quiet;
2228
29+/// g1t's release key, as base64 of its 32 bytes: the public half of the
30+/// key that signs every runner release (the repository variable
31+/// RUNNER_RELEASE_PUBLIC_KEY, which the runner's own builds take as
32+/// G1T_RUNNER_RELEASE_KEY). It is public, so it is kept here, where every
33+/// build has it, development builds included; someone who builds the app
34+/// for a g1t of their own, with their own release key, sets
35+/// G1T_RUNNER_RELEASE_KEY when they build it.
36+const RELEASE_KEY: &str = match option_env!("G1T_RUNNER_RELEASE_KEY") {
37+ Some(key) => key,
38+ None => "C8TxhitVxZpx+VLr3gtu0nq7I46q2awBsPZISwDzIgI=",
39+};
40+
41+/// Why the releases couldn't be had.
42+#[derive(Clone, Debug, PartialEq, Eq)]
43+pub enum Fault {
44+ /// The manifest isn't signed with g1t's release key: nothing in it is used.
45+ Unsigned(String),
46+ /// It couldn't be fetched or read, or a download didn't check out.
47+ Failed(String),
48+}
49+
50+impl std::fmt::Display for Fault {
51+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52+ match self {
53+ Fault::Unsigned(why) | Fault::Failed(why) => f.write_str(why),
54+ }
55+ }
56+}
57+
2358 #[derive(Deserialize)]
2459 pub struct File {
2560 pub name: String,
3065 pub struct Manifest {
3166 pub version: String,
3267 pub files: BTreeMap<String, File>,
68+ /// The sandbox's images, which `sandbox update` takes: none in
69+ /// releases from before them, or published without them.
70+ #[serde(default)]
71+ pub sandbox: Option<SandboxRelease>,
72+}
73+
74+#[derive(Default, Deserialize)]
75+pub struct SandboxRelease {
76+ #[serde(default)]
77+ pub files: BTreeMap<String, serde::de::IgnoredAny>,
78+}
79+
80+impl Manifest {
81+ /// Whether the release has the sandbox's images, so `sandbox update` has something to take.
82+ pub fn has_images(&self) -> bool {
83+ self.sandbox.as_ref().is_some_and(|sandbox| !sandbox.files.is_empty())
84+ }
3385 }
3486
3587 /// This computer's platform, as releases name it.
78130 })
79131 }
80132
81−/// The newest release's manifest.
82−pub fn latest(server: &Url) -> Result<Manifest, String> {
83− let bytes = get(&format!("{}/latest.json", downloads(server)))?;
84− serde_json::from_slice(&bytes).map_err(|e| format!("the runner's release manifest does not read: {e}"))
133+/// Whether `signature` (base64) is the release key's over `message`.
134+pub fn verify(message: &[u8], signature: &str, key: &str) -> Result<(), Fault> {
135+ let unsigned = |why: &str| Fault::Unsigned(why.to_owned());
136+ let key: [u8; 32] = STANDARD
137+ .decode(key.trim())
138+ .ok()
139+ .and_then(|bytes| bytes.try_into().ok())
140+ .ok_or_else(|| unsigned("the release key built into g1t doesn't read"))?;
141+ let signature: [u8; 64] = STANDARD
142+ .decode(signature.trim())
143+ .ok()
144+ .and_then(|bytes| bytes.try_into().ok())
145+ .ok_or_else(|| unsigned("the release's signature doesn't read"))?;
146+ let key = ed25519_dalek::VerifyingKey::from_bytes(&key).map_err(|_| unsigned("the release key built into g1t isn't a key"))?;
147+ key.verify_strict(message, &ed25519_dalek::Signature::from_bytes(&signature))
148+ .map_err(|_| unsigned("the release's signature isn't g1t's"))
149+}
150+
151+/// A manifest, once its signature checks out against `key`.
152+fn signed(manifest: &[u8], signature: &str, key: &str) -> Result<Manifest, Fault> {
153+ verify(manifest, signature, key)?;
154+ serde_json::from_slice(manifest).map_err(|e| Fault::Failed(format!("the runner's release manifest doesn't read: {e}")))
85155 }
86156
157+/// The newest release's manifest, signed by g1t. One without a
158+/// signature, or whose signature doesn't check out, is refused.
159+pub fn latest(server: &Url) -> Result<Manifest, Fault> {
160+ let base = downloads(server);
161+ let manifest = get(&format!("{base}/latest.json")).map_err(Fault::Failed)?;
162+ let signature = get(&format!("{base}/latest.json.sig")).map_err(|why| if why.contains("404") { Fault::Unsigned("the release has no signature".into()) } else { Fault::Failed(why) })?;
163+ signed(&manifest, &String::from_utf8_lossy(&signature), RELEASE_KEY)
164+}
165+
87166 /// Downloads a release's program for this platform and checks it against
88167 /// the manifest's SHA-256.
89168 pub fn download(server: &Url, manifest: &Manifest) -> Result<Vec<u8>, String> {
95174 Ok(bytes)
96175 }
97176
98−/// Puts a checked program in place. The runner must not be running:
99−/// Windows will not replace a program that is.
177+/// Puts a checked program in place. The runner it replaces must not be
178+/// running, but a short launcher command may be (a `sandbox status` the
179+/// app is asking at that moment): the program there is moved aside first,
180+/// under a name of its own, since Windows won't overwrite a program that
181+/// is running but lets it be renamed. Those left from earlier updates are
182+/// removed here, once nothing runs them.
100183 pub fn put(folder: &Path, bytes: &[u8], version: &str) -> Result<(), String> {
101184 let path = binary(folder);
102185 let dir = path.parent().unwrap_or(folder);
103186 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
187+ for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
188+ if entry.file_name().to_string_lossy().ends_with(".old") {
189+ let _ = std::fs::remove_file(entry.path());
190+ }
191+ }
104192 let new = path.with_extension("new");
105193 std::fs::write(&new, bytes).map_err(|e| e.to_string())?;
106194 #[cfg(unix)]
108196 use std::os::unix::fs::PermissionsExt;
109197 std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?;
110198 }
111− std::fs::rename(&new, &path).map_err(|e| format!("could not put g1t-runner in place: {e}"))?;
199+ let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |since| since.as_nanos());
200+ let old = dir.join(format!("g1t-runner-{stamp}.old"));
201+ let moved = path.exists() && std::fs::rename(&path, &old).is_ok();
202+ if let Err(error) = std::fs::rename(&new, &path) {
203+ if moved {
204+ let _ = std::fs::rename(&old, &path);
205+ }
206+ return Err(format!("could not put g1t-runner in place: {error}"));
207+ }
112208 std::fs::write(dir.join("version"), version).map_err(|e| e.to_string())
113209 }
114210
214310 assert_eq!(downloads(&Url::parse("https://g1t.sh/").unwrap()), "https://g1t.sh/downloads/runner");
215311 }
216312
313+ /// A key pair of the tests' own: what it signs checks out, and nothing else does.
314+ #[test]
315+ fn only_a_manifest_signed_with_the_release_key_is_trusted() {
316+ use ed25519_dalek::Signer;
317+ let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
318+ let key = STANDARD.encode(signing.verifying_key().to_bytes());
319+ let manifest = br#"{"version":"0.3.2","files":{"windows-x64":{"name":"g1t-runner-windows-x64.exe","sha256":"ab"}}}"#;
320+ let signature = STANDARD.encode(signing.sign(manifest).to_bytes());
321+ let trusted = signed(manifest, &signature, &key).unwrap();
322+ assert_eq!(trusted.version, "0.3.2");
323+ assert!(!trusted.has_images());
324+ // Changed after it was signed: a newer version slipped in.
325+ let changed = br#"{"version":"9.9.9","files":{"windows-x64":{"name":"g1t-runner-windows-x64.exe","sha256":"ab"}}}"#;
326+ assert_eq!(signed(changed, &signature, &key).err(), Some(Fault::Unsigned("the release's signature isn't g1t's".into())));
327+ // Signed with another key.
328+ let other = STANDARD.encode(ed25519_dalek::SigningKey::from_bytes(&[8u8; 32]).verifying_key().to_bytes());
329+ assert!(matches!(signed(manifest, &signature, &other), Err(Fault::Unsigned(_))));
330+ // No signature, or not one.
331+ assert!(matches!(signed(manifest, "", &key), Err(Fault::Unsigned(_))));
332+ assert!(matches!(signed(manifest, "not base64!", &key), Err(Fault::Unsigned(_))));
333+ // Signed, yet not a manifest: that is a failure, not a refusal.
334+ let junk = b"not json";
335+ assert!(matches!(signed(junk, &STANDARD.encode(signing.sign(junk).to_bytes()), &key), Err(Fault::Failed(_))));
336+ }
337+
338+ /// Release 0.3.0's manifest and signature, as g1t.sh published them:
339+ /// the key built in is the one g1t's releases are signed with.
340+ #[test]
341+ fn g1ts_published_release_checks_out_against_the_key_built_in() {
342+ let manifest = include_bytes!("testdata/latest-0.3.0.json");
343+ let signature = include_str!("testdata/latest-0.3.0.json.sig");
344+ let release = signed(manifest, signature, RELEASE_KEY).unwrap();
345+ assert_eq!(release.version, "0.3.0");
346+ assert!(release.has_images());
347+ let mut changed = manifest.to_vec();
348+ let at = changed.windows(5).position(|five| five == b"0.3.0").unwrap();
349+ changed[at + 4] = b'9';
350+ assert!(matches!(signed(&changed, signature, RELEASE_KEY), Err(Fault::Unsigned(_))));
351+ }
352+
353+ #[test]
354+ fn a_new_program_takes_the_old_ones_place_and_its_version() {
355+ let dir = std::env::temp_dir().join(format!("g1t-desktop-put-{}", std::process::id()));
356+ let _ = std::fs::remove_dir_all(&dir);
357+ put(&dir, b"one", "0.3.1").unwrap();
358+ put(&dir, b"two", "0.3.2").unwrap();
359+ assert_eq!(std::fs::read(binary(&dir)).unwrap(), b"two");
360+ assert_eq!(installed(&dir).as_deref(), Some("0.3.2"));
361+ // The one moved aside goes with the next update, once nothing runs it.
362+ put(&dir, b"three", "0.3.3").unwrap();
363+ let old = std::fs::read_dir(dir.join("bin")).unwrap().flatten().filter(|e| e.file_name().to_string_lossy().ends_with(".old")).count();
364+ assert_eq!(old, 1);
365+ let _ = std::fs::remove_dir_all(&dir);
366+ }
367+
217368 #[test]
218369 fn nothing_is_installed_in_an_empty_folder() {
219370 let dir = std::env::temp_dir().join(format!("g1t-desktop-runner-{}", std::process::id()));
+44−0
1+{
2+ "version": "0.3.0",
3+ "published_at": "2026-10-12T11:12:40.264Z",
4+ "agent_image": null,
5+ "files": {
6+ "linux-x64": {
7+ "name": "g1t-runner-linux-x64",
8+ "sha256": "2730bf0e71e29af6ecfe815dbcb241104012fb30cc8b38c4dc762b81bbaddc4d"
9+ },
10+ "linux-arm64": {
11+ "name": "g1t-runner-linux-arm64",
12+ "sha256": "036db83173480e93e05502d004a594ba6f61ec694742d37471b0e9c038eaecaa"
13+ },
14+ "windows-x64": {
15+ "name": "g1t-runner-windows-x64.exe",
16+ "sha256": "642982aa3d01de8828c09b4807c511d3b4802814155ddf2e10192b71d75db692"
17+ }
18+ },
19+ "sandbox": {
20+ "runner_image": "g1t.sh/flagon-io/g1t-runner@sha256:5469b31c423e0c164699e1d06e1574e6742eb4d1eec08e934ae5e0d02e118df1",
21+ "files": {
22+ "wsl-x64": {
23+ "name": "g1t-sandbox-wsl-x64.tar.gz",
24+ "sha256": "09a12394566a53660ded11ef03761e58d8c27c63daed87a875fa8cd74b7139f1",
25+ "size": 130488511
26+ },
27+ "vm-x64-kernel": {
28+ "name": "g1t-sandbox-vm-x64-kernel",
29+ "sha256": "26cb804f0a0a8878e5ab560391962aee89c344f5b8faebe0329f65c507a03483",
30+ "size": 14157760
31+ },
32+ "vm-x64-initrd": {
33+ "name": "g1t-sandbox-vm-x64-initrd",
34+ "sha256": "b7fd7b22e2fa045f95cc8cad004b91d6b055b24f0672164a4678b0880acb74c2",
35+ "size": 15483764
36+ },
37+ "vm-x64-disk": {
38+ "name": "g1t-sandbox-vm-x64-disk.raw.gz",
39+ "sha256": "11f10907828a947cdb8ee81ae9d687b47863e935946e6897e040995a4e4ec7a2",
40+ "size": 195642640
41+ }
42+ }
43+ }
44+}
+1−0
1+Dg1HkgLm7YdhES6DgrqXmVyX+9j7KGqDcfKF/iyL3Ze7svr9ejIfQa+Wi/qLsy6VXiHXhKv6n9Np41M1RRNLCg==
+36−12
136136 | **Show a badge on the icon** | On | The count, or the dot on Windows. Off, the icon stays clean; the tray's hover text still counts. |
137137 | **Use the system's title bar** | Off | The system's title bar in place of [g1t's own](#the-title-bar), with the menu bar under it on Windows and Linux. Takes effect when g1t restarts. |
138138 | **Zoom** | 100% | How large g1t is drawn, from 67% to 200%. The View menu changes it too. |
139−| **Download updates by themselves** | On | Off, the app never checks on its own; **Check for updates** still does. The runner's updates follow it too. |
139+| **Download updates by themselves** | On | Off, the app never checks on its own; **Check for updates** still does. The runner's updates follow it too: off, only **Check for runner updates** looks for a newer runner. |
140140 | **Run jobs on this computer while g1t is open** | On | Starts [the runner](#the-runner) a few seconds after the window. Off, nothing runs. |
141141 | **How jobs run** | Sandboxed | Sandboxed, in Docker, or directly on this computer. See [how jobs run](#how-jobs-run). |
142142 | **Linux jobs**, **Windows jobs** (sandboxed) | Both on | Each kind of job's card has its own **Stop** and **Start**, which leave the other kind as it is. See [the runner](#the-runner). |
213213 | When you press **Start** on one kind of job's card | `g1t-runner sandbox up --only windows` (or `linux`), leaving the other as it is. A kind never registered is registered first, with a token as above. |
214214 | While one kind of job runs and the other should and doesn't | `g1t-runner sandbox up --only` it again, by itself: every five minutes while the newest release has no sandbox images, else after a wait that grows each time, up to ten minutes. **Retry** or **Check now** tries at once. |
215215 | When you choose another way of running jobs | `g1t-runner sandbox down --drain`, once the job that is running ends. The sandbox keeps its registration, so choosing Sandboxed again needs no new one. |
216−| With a new release, while it has no job | `g1t-runner sandbox update`: the newest signed image, the same registration. |
216+| With a new release, once Linux jobs have no job | `g1t-runner sandbox update`: the newest signed image, the same registration. Windows jobs run on meanwhile. |
217+| With a new release, once Windows jobs have no job | `g1t-runner sandbox down --only windows --drain`, the new `g1t-runner` put in place, then `g1t-runner sandbox up --only windows`. Their runner is `g1t-runner` itself, so they stop while it is replaced, a few seconds; Linux jobs run on meanwhile. |
217218
218219 The other two choices run `g1t-runner` from the same data folder, with
219220 each one's registration beside it.
244245
245246 ### Keeping it up to date
246247
247−The app keeps the runner up to date: soon after it starts and every six
248−hours, while the runner has no job, it looks for a newer `g1t-runner` in
249−`g1t.sh/downloads/runner/latest.json`, downloads it, checks it against
250−the release's SHA-256, and puts it in place; the sandbox then takes the
251−release's image, whose signature and SHA-256 it checks itself. A job is
252−never cut short for an update, and one that fails leaves the runner as it
253−was. Turning off **Download updates by themselves** turns these off too.
248+The app keeps the runner up to date. Soon after it starts, then every
249+hour, it looks for a newer `g1t-runner` in
250+`g1t.sh/downloads/runner/latest.json`, while the runner carries on:
251+
252+1. **It checks the release is g1t's.** The manifest's Ed25519 signature,
253+ `latest.json.sig`, must check out against g1t's release key, which is
254+ built into the app. A manifest without one, or whose signature
255+ doesn't check out, is refused, and nothing in it is used: the app says
256+ *Update refused: not signed by g1t* and keeps the runner it has.
257+2. **It downloads the new `g1t-runner`** and checks it against the
258+ SHA-256 the signed manifest names, throwing away one that differs.
259+3. **It puts it in, one kind of job at a time, as each is idle.** A job
260+ is never cut short for an update. Sandboxed, Linux jobs take the
261+ release's image (`g1t-runner sandbox update`, which checks its
262+ signature and SHA-256 itself) as soon as they have no job, and
263+ Windows jobs, whose runner is `g1t-runner` itself, stop after their
264+ job, take the new program and start again, while the other kind runs
265+ on. In Docker or directly on this computer, the runner stops between
266+ jobs and starts on the new one.
267+
268+Meanwhile each card says what is waiting: *0.3.2 ready, installs after
269+this job*. **Check for runner updates**, beside your computer's name in
270+the settings, looks at once, and installs what it finds the same way. An
271+update that fails leaves the runner as it was, and the next hour's look
272+tries again. Turning off **Download updates by themselves** stops the
273+hourly look; **Check for runner updates** still looks, and installs.
254274
255275 ### Seeing it
256276
257277 | Where | What it shows |
258278 | --- | --- |
259279 | The tray | A row with what the runner is doing, *Runner: Waiting for jobs*, which opens the settings; **Stop running jobs** or **Start running jobs** under it. The icon's hover text says it too. |
260−| The settings window | Sandboxed, a card for each kind of job this computer can have, side by side: its state (*Running a job*, *Waiting for jobs*, *Starting*, *Downloading image* with how far, *Updating*, *Waiting for image*, *Stopped*, *Not available*, *Problem*), what it is, the job it is running with a link to it, and when it isn't running why and the fix, with the fix's buttons and its own **Stop** or **Start**; below, its runner's name on g1t, version and isolation. Above the cards, only what is about the whole runner: signed out, no workspace, turned off, stopped, the sandbox down. Then how jobs run; the workspace; your computer's name and the runner's version; **View on g1t**; and the runner's recent log, with **Open the whole log**. In Docker or directly on this computer, one line says what it is doing, or why it isn't running and the fix. |
261−| The workspace's [Runners page](/guides/self-hosted-runners/#the-runners-page) | **Desktops**: every computer with the app in the workspace, with what each is doing, or why it isn't running and the fix, in the same words; a sandboxed one's kinds of job as cards of their own, as in the settings, with a link to the run each is running. Inside the app, this computer comes first, live, with its buttons, each kind's **Stop** or **Start** among them. A computer whose g1t is closed says *g1t is closed on* its name. |
280+| The settings window | Sandboxed, a card for each kind of job this computer can have, side by side: its state (*Running a job*, *Waiting for jobs*, *Starting*, *Downloading image* with how far, *Updating*, *Waiting for image*, *Stopped*, *Not available*, *Problem*), what it is, the job it is running with a link to it, and when it isn't running why and the fix, with the fix's buttons and its own **Stop** or **Start**; below, its runner's name on g1t, version, updates (when the app last looked, *Checked 12 min ago*, and a newer one waiting for it, *0.3.2 ready, installs after this job*) and isolation. Above the cards, only what is about the whole runner: signed out, no workspace, turned off, stopped, the sandbox down. Then how jobs run; the workspace; your computer's name, the runner's version and when the app last looked for a newer one, with **Check for runner updates** and **View on g1t**; and the runner's recent log, with **Open the whole log**. In Docker or directly on this computer, one line says what it is doing, or why it isn't running and the fix. |
281+| The workspace's [Runners page](/guides/self-hosted-runners/#the-runners-page) | **Desktops**: every computer with the app in the workspace, with what each is doing, or why it isn't running and the fix, in the same words; a sandboxed one's kinds of job as cards of their own, as in the settings, with a link to the run each is running, its version, when the app last looked for updates and a newer one waiting. Inside the app, this computer comes first, live, with its buttons, each kind's **Stop** or **Start** and **Check for runner updates** among them. A computer whose g1t is closed says *g1t is closed on* its name. |
262282
263283 ### When it isn't running
264284
295315 | *Windows jobs: Problem*: *Its runner didn't start* or *Its runner stopped* | The runner for Windows jobs didn't start, with why, or stopped by itself, while Linux jobs run. | It is started again by itself in a few minutes; **Retry** now, or **View log**. |
296316 | *macOS jobs: Not available*: *Not released yet* | Sandboxed macOS jobs aren't released. | Nothing: they come with a `g1t-runner` update, which the app downloads by itself. |
297317 | *Offline:* the server *unreachable* | Your computer can't reach g1t. | Check the connection; it carries on by itself. **Retry** tries now. |
298−| *Update failed* | A new version couldn't be downloaded, or its SHA-256 wasn't the release's. | It keeps running the version it has, and tries again in a few hours. **Retry** now; **Report** it if it keeps failing. |
318+| *0.3.2 ready, installs after this job*, on a card | A newer `g1t-runner` is downloaded and checked, and that kind of job is running a job. | Nothing: it goes in as soon as the job ends, and the other kind runs on meanwhile. |
319+| *Update failed* | A new version couldn't be downloaded, its SHA-256 wasn't the release's, or the sandbox's image couldn't be updated. | It keeps running the version it has, and tries again within the hour. **Check again** now; **Report** it if it keeps failing. |
320+| *Update refused: not signed by g1t* | The newest release's manifest has no signature, or one that isn't g1t's release key's, so nothing in it was used. | Nothing to fix on your side: it keeps running the version it has, and looks again within the hour (**Check again** looks now). If it keeps happening, something between your computer and g1t, such as a proxy or antivirus, is changing downloads: **Report** it. |
321+| *Not running: the runner's release isn't signed by g1t* | As above, before any `g1t-runner` was installed, so there is none to run. | It tries again by itself in a few minutes; **Retry** now, or **Report** it if it keeps happening. |
299322 | *Runner stopped (exit code* N*)* | The runner ended by itself, with the reason from its log. | It starts again by itself, waiting longer each time. **Restart** now, or **View log**. |
300323 | *g1t is closed on* a computer | Seen on the Runners page: g1t hasn't been open on that computer for ten minutes. | Open g1t there. |
301324
383406
384407 | What | Where |
385408 | --- | --- |
409+| The runner's release key | The app trusts a `g1t-runner` release only when its manifest is signed with g1t's runner release key, whose public half (the repository variable `RUNNER_RELEASE_PUBLIC_KEY`) is built into the app, in `src-tauri/src/runner/native.rs`. To build the app for a g1t of your own, whose runner releases you sign with your own key, set `G1T_RUNNER_RELEASE_KEY` to its public key, base64, when you build it. |
386410 | The updater's signing key | `node scripts/desktop-release.mjs keygen`, once. The private key is the repository secret `DESKTOP_SIGNING_KEY`; the public key is `plugins.updater.pubkey` in `tauri.conf.json`. Losing the private key means every installed app stops updating: keep it. |
387411 | Linux builds | g1t's own Linux machines, in every release. |
388412 | Windows builds | Either a Windows machine registered as a [self-hosted runner](/guides/self-hosted-runners/) with the labels `self-hosted` and `windows` (Rust, Node and the Visual Studio build tools on it) and the repository variable `DESKTOP_WINDOWS_RUNNER` set to `true`; or, with no Windows machine, `DESKTOP_WINDOWS_FROM_LINUX` set to `true`, which cross-builds on Linux with `cargo-xwin` and NSIS and needs `aka.ms` and `download.visualstudio.microsoft.com` among the project's [workflow-only domains](/guides/guardrails/#workflow-only-domains) for `desktop-release.yml` in `production`, where the MSVC runtime is fetched from. |
+39−3
22 import { type ReactNode, useState } from "react";
33 import { Link } from "react-router";
44
5−import type { DesktopBackend, DesktopDiagnosis, DesktopRunner, Runner } from "@g1t/contracts";
5+import type { DesktopBackend, DesktopDiagnosis, DesktopRunner, DesktopUpdate, Runner } from "@g1t/contracts";
66
77 import { BACKEND_LABEL, type DesktopRunnerAction, type DesktopRunnerCapability, desktopRunner, jobHref, useDesktopRunner } from "../lib/desktop-runner";
88 import { EmptyState, TimeAgo } from "./ui";
5757 inCapabilities: boolean;
5858 here: boolean;
5959 reportedAt: string | null;
60+ /** How its runner's updates stand, while g1t is open there. */
61+ update: DesktopUpdate | null;
62+ /** Here only: the app's button that looks for a newer runner now. */
63+ check: DesktopRunnerAction | null;
6064 };
6165
6266 /** The desktops to list: this computer, live, first; then the others, as their apps last said, or closed. */
7579 inCapabilities: !!here.diagnosis.in_capabilities && capabilities.length > 0,
7680 here: true,
7781 reportedAt: null,
82+ update: here.diagnosis.update ?? null,
83+ check: here.diagnosis.update?.checking ? null : (here.diagnosis.update?.check ?? null),
7884 });
7985 }
8086 for (const desktop of desktops) {
9197 inCapabilities: desktop.open && !!desktop.diagnosis.in_capabilities && capabilities.length > 0,
9298 here: false,
9399 reportedAt: desktop.reportedAt,
100+ update: desktop.open ? (desktop.diagnosis.update ?? null) : null,
101+ check: null,
94102 });
95103 }
96104 return rows;
143151 );
144152 }
145153
154+/** When the app last looked for a newer runner: the app sends when, and this says how long ago, as "Heard from" does. */
155+function Checked({ update }: { update: DesktopUpdate | null }) {
156+ if (!update?.checked_at) return null;
157+ return (
158+ <span className="block">
159+ Checked <TimeAgo at={update.checked_at} />
160+ </span>
161+ );
162+}
163+
146164 /** One kind of job, as a card of its own: its state, what it runs, why it doesn't and the fix, and here its own Start or Stop. */
147−function KindCard({ capability, here, runners }: { capability: DesktopRunnerCapability; here: boolean; runners: Runner[] }) {
165+function KindCard({ capability, here, runners, update }: { capability: DesktopRunnerCapability; here: boolean; runners: Runner[]; update: DesktopUpdate | null }) {
148166 const href = jobHref(capability, runners);
149167 const isolation = capability.isolation ? (ISOLATION[capability.isolation] ?? { label: capability.isolation, hint: null }) : null;
150168 const edge = capability.tone === "problem" ? "border-danger/40" : capability.tone === "warning" ? "border-warn/35" : "";
196214 <dl className="space-y-0.5 border-t border-line pt-2 text-xs">
197215 {capability.runner && <Detail term="On g1t">{capability.runner}</Detail>}
198216 {capability.version && <Detail term="Version">g1t-runner {capability.version}</Detail>}
217+ {(capability.update || (capability.version && update?.checked_at)) && (
218+ <Detail term="Updates">
219+ {capability.update && <span className="block text-accent">{capability.update}</span>}
220+ <Checked update={update} />
221+ </Detail>
222+ )}
199223 {isolation && (
200224 <Detail term="Isolation">
201225 {isolation.hint ? (
261285 </div>
262286 <div className="shrink-0 text-right text-xs text-faint">
263287 {row.version && <div>g1t-runner {row.version}</div>}
288+ {/* With no cards to say it, what becomes of a newer runner, and when the app last looked. */}
289+ {row.capabilities.length === 0 && row.update?.note && <div className="mt-0.5 text-accent">{row.update.note}</div>}
290+ {row.capabilities.length === 0 && row.update?.checked_at && (
291+ <div className="mt-0.5">
292+ <Checked update={row.update} />
293+ </div>
294+ )}
264295 {row.reportedAt && (
265296 <div className="mt-0.5">
266297 Heard from <TimeAgo at={row.reportedAt} />
267298 </div>
268299 )}
300+ {row.check && (
301+ <div className="mt-2">
302+ <Actions actions={[row.check]} primary={false} />
303+ </div>
304+ )}
269305 </div>
270306 </div>
271307 {!row.inCapabilities && (
286322 {row.capabilities.length > 0 && (
287323 <div className={`grid gap-3 ${row.capabilities.length > 1 ? "sm:grid-cols-2" : ""}`}>
288324 {row.capabilities.map((capability) => (
289− <KindCard key={capability.id} capability={capability} here={row.here} runners={runners} />
325+ <KindCard key={capability.id} capability={capability} here={row.here} runners={runners} update={row.update} />
290326 ))}
291327 </div>
292328 )}
+30−0
165165 assert.equal(backendOf("host"), "host");
166166 assert.equal(backendOf("vm"), null);
167167 });
168+
169+test("a report says how the runner's updates stand, without the app's own words and button", () => {
170+ const update = {
171+ checked_at: "2026-10-12T12:00:00Z",
172+ checked: "Checked 12 min ago",
173+ checking: false,
174+ ready: "0.3.2",
175+ note: null,
176+ check: { id: "check_updates", label: "Check for runner updates" },
177+ };
178+ const linux = { id: "linux", label: "Linux jobs", tone: "working" as const, title: "Running a job", cause: null, fix: null, actions: [], version: "0.3.1", update: "0.3.2 ready, installs after this job" };
179+ const sandboxed = view({ backend: "sandboxed", diagnosis: { code: "busy", tone: "working", title: "Running build (acme/web)", cause: null, fix: null, actions: [], capabilities: [linux], in_capabilities: true, update } });
180+ const report = reportOf(sandboxed)!;
181+ assert.deepEqual(report.diagnosis.update, { checked_at: "2026-10-12T12:00:00Z", ready: "0.3.2", note: null });
182+ assert.equal(report.diagnosis.capabilities![0].update, "0.3.2 ready, installs after this job");
183+ // A look that found something, or a newer one put in, is reported at once.
184+ const last = { key: reportKey(report), at: 0 };
185+ const looked = reportOf(view({ ...sandboxed, diagnosis: { ...sandboxed.diagnosis, update: { ...update, checked_at: "2026-10-12T13:00:00Z" } } }))!;
186+ assert.equal(reportDue(last, looked, 1000), true);
187+ const installed = reportOf(view({ ...sandboxed, diagnosis: { ...sandboxed.diagnosis, capabilities: [{ ...linux, version: "0.3.2", update: undefined }] } }))!;
188+ assert.equal(reportDue(last, installed, 1000), true);
189+ // As the server reads it back: a time that isn't one is dropped, and an update that says nothing is none.
190+ const kept = diagnosisOf(JSON.parse(JSON.stringify(report.diagnosis)))!;
191+ assert.deepEqual(kept.update, report.diagnosis.update);
192+ assert.equal(kept.capabilities![0].update, "0.3.2 ready, installs after this job");
193+ assert.deepEqual(diagnosisOf({ ...report.diagnosis, update: { checked_at: "soon", ready: "0.3.2" } })!.update, { checked_at: null, ready: "0.3.2", note: null });
194+ assert.equal(diagnosisOf({ ...report.diagnosis, update: { checked_at: 5 } })!.update, undefined);
195+ // An app from before updates were reported says nothing of them.
196+ assert.equal(reportOf(view())!.diagnosis.update, undefined);
197+});
+45−7
1818 */
1919 import { useEffect, useState } from "react";
2020
21−import type { DesktopBackend, DesktopCapability, DesktopCapabilityState, DesktopDiagnosis, DesktopJob, DesktopReport, Runner } from "@g1t/contracts";
21+import type { DesktopBackend, DesktopCapability, DesktopCapabilityState, DesktopDiagnosis, DesktopJob, DesktopReport, DesktopUpdate, Runner } from "@g1t/contracts";
2222
2323 import { agentRunHref, workflowRunHref } from "./runners.ts";
2424
3939 job?: (DesktopJob & { link?: string | null }) | null;
4040 };
4141
42+/**
43+ * How the runner's updates stand on this computer (`diagnosis::UpdateLine`
44+ * in the app): besides what is reported, when it last looked in the app's
45+ * words, and its button that looks now.
46+ */
47+export type DesktopRunnerUpdate = DesktopUpdate & {
48+ /** `Checked 12 min ago`. */
49+ checked: string;
50+ checking: boolean;
51+ /** "Check for runner updates". */
52+ check: DesktopRunnerAction;
53+};
54+
4255 /** What the app says of its runner (`runner::View` in the app). */
4356 export type DesktopRunnerView = {
44− diagnosis: Omit<DesktopDiagnosis, "capabilities"> & { actions: DesktopRunnerAction[]; capabilities?: DesktopRunnerCapability[] };
57+ diagnosis: Omit<DesktopDiagnosis, "capabilities" | "update"> & { actions: DesktopRunnerAction[]; capabilities?: DesktopRunnerCapability[]; update?: DesktopRunnerUpdate | null };
4558 closed: DesktopDiagnosis & { actions: DesktopRunnerAction[] };
4659 running: boolean;
4760 version: string | null;
103116
104117 /** A kind of job as the server keeps it: what it says, without what only works or changes on that computer. */
105118 function plainCapability(capability: DesktopCapability): DesktopCapability {
106− const { id, label, tone, title, cause, fix, state, headline, about, enabled, job, runner, isolation, version } = capability;
119+ const { id, label, tone, title, cause, fix, state, headline, about, enabled, job, runner, isolation, version, update } = capability;
107120 const said: DesktopCapability = { id, label, tone, title, cause: cause ?? null, fix: fix ?? null };
108121 if (state) said.state = state;
109122 if (headline) said.headline = headline;
113126 if (runner) said.runner = runner;
114127 if (isolation) said.isolation = isolation;
115128 if (version) said.version = version;
129+ if (update) said.update = update;
116130 return said;
117131 }
118132
133+/** How the updates stand, as the server keeps it: without what only this computer's settings show. */
134+function plainUpdate(update: DesktopUpdate | null | undefined): DesktopUpdate | null {
135+ if (!update) return null;
136+ return { checked_at: update.checked_at ?? null, ready: update.ready ?? null, note: update.note ?? null };
137+}
138+
119139 /** What goes to the server, without the buttons, which only work on that computer. */
120140 export function reportOf(view: DesktopRunnerView): DesktopReport | null {
121141 if (!view.workspace) return null;
122− const plain = ({ code, tone, title, cause, fix, capabilities, in_capabilities }: DesktopDiagnosis): DesktopDiagnosis => {
142+ const plain = ({ code, tone, title, cause, fix, capabilities, in_capabilities, update }: DesktopDiagnosis): DesktopDiagnosis => {
123143 const said: DesktopDiagnosis = { code, tone, title, cause, fix };
124144 if (capabilities?.length) {
125145 said.capabilities = capabilities.map(plainCapability);
126146 if (in_capabilities) said.in_capabilities = true;
127147 }
148+ const updates = plainUpdate(update);
149+ if (updates) said.update = updates;
128150 return said;
129151 };
130152 return { workspace: view.workspace, machine: view.machine, backend: view.backend, version: view.version, diagnosis: plain(view.diagnosis), closed: plain(view.closed) };
138160
139161 export function reportKey(report: DesktopReport): string {
140162 const { diagnosis } = report;
141− const capabilities = (diagnosis.capabilities ?? []).map((c) => [c.id, c.tone, c.title, c.cause, c.fix, c.state, c.enabled, c.headline, c.job?.name, c.job?.repo, c.version].join("\u0001"));
142− return [report.workspace, report.machine, report.backend, report.version, diagnosis.code, diagnosis.title, diagnosis.cause, diagnosis.fix, diagnosis.in_capabilities, ...capabilities].join("\u0000");
163+ const capabilities = (diagnosis.capabilities ?? []).map((c) => [c.id, c.tone, c.title, c.cause, c.fix, c.state, c.enabled, c.headline, c.job?.name, c.job?.repo, c.version, c.update].join("\u0001"));
164+ const update = diagnosis.update ? [diagnosis.update.checked_at, diagnosis.update.ready, diagnosis.update.note].join("\u0001") : "";
165+ return [report.workspace, report.machine, report.backend, report.version, diagnosis.code, diagnosis.title, diagnosis.cause, diagnosis.fix, diagnosis.in_capabilities, update, ...capabilities].join("\u0000");
143166 }
144167
145168 /** A string with something in it, or null. */
169192 said.capabilities = capabilities;
170193 if (given.in_capabilities === true) said.in_capabilities = true;
171194 }
195+ const update = updateOf(given.update);
196+ if (update) said.update = update;
172197 return said;
173198 }
174199
200+/** How the updates stand, as the app sent it, or null when it says nothing. */
201+function updateOf(value: unknown): DesktopUpdate | null {
202+ if (!value || typeof value !== "object") return null;
203+ const given = value as Record<string, unknown>;
204+ const checkedAt = text(given.checked_at);
205+ const update: DesktopUpdate = {
206+ checked_at: checkedAt && !Number.isNaN(Date.parse(checkedAt)) ? checkedAt : null,
207+ ready: text(given.ready),
208+ note: text(given.note),
209+ };
210+ return update.checked_at || update.ready || update.note ? update : null;
211+}
212+
175213 /** The most kinds of job a desktop reports (the server keeps as many). */
176214 const MAX_CAPABILITIES = 4;
177215
187225 const said: DesktopCapability = { id, label, tone, title, cause: text(given.cause), fix: text(given.fix) };
188226 const state = STATES.find((s) => s === given.state);
189227 if (state) said.state = state;
190− for (const key of ["headline", "about", "runner", "isolation", "version"] as const) {
228+ for (const key of ["headline", "about", "runner", "isolation", "version", "update"] as const) {
191229 const value = text(given[key]);
192230 if (value) said[key] = value;
193231 }
+23−0
Loading the diff…
+18−0
Loading the diff…
+7−1
Loading the diff…