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.
19 files+1229−1290/19 viewed
| 1 | 1 | * 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 |
| 194 | 194 | flex: none; | |
| 195 | 195 | gap: 0.5rem; | |
| 196 | 196 | } | |
| 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 | + | } | |
| 197 | 206 | button.primary { | |
| 198 | 207 | background: var(--accent); | |
| 199 | 208 | border-color: var(--accent); | |
| ⋯ | |||
| 544 | 553 | overflow-wrap: anywhere; | |
| 545 | 554 | user-select: text; | |
| 546 | 555 | } | |
| 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 | + | } | |
| 547 | 564 | button.link { | |
| 548 | 565 | display: inline-flex; | |
| 549 | 566 | align-items: center; | |
| 83 | 83 | </label> | |
| 84 | 84 | <div class="setting"> | |
| 85 | 85 | <span><strong id="runner-machine">This computer</strong><span class="muted" id="runner-version"> </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 | + | > | |
| 87 | 90 | </div> | |
| 88 | 91 | <details class="log"> | |
| 89 | 92 | <summary>Recent log</summary> |
| 307 | 307 | ||
| 308 | 308 | // What changes the buttons and the words is drawn again only when it changes, | |
| 309 | 309 | // 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]); | |
| 311 | 312 | if (signature === card.signature) return; | |
| 312 | 313 | card.signature = signature; | |
| 313 | 314 | ||
| ⋯ | |||
| 335 | 336 | } | |
| 336 | 337 | detail(details, "On g1t", kind.runner, named); | |
| 337 | 338 | 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 | + | } | |
| 338 | 346 | detail(details, "Isolation", ISOLATION[kind.isolation] || kind.isolation); | |
| 339 | 347 | details.hidden = !details.firstChild; | |
| 340 | 348 | ||
| ⋯ | |||
| 415 | 423 | workspaces.disabled = known.length === 0; | |
| 416 | 424 | ||
| 417 | 425 | 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 | + | } | |
| 419 | 437 | byId("runner-view").hidden = !runner.link; | |
| 420 | 438 | byId("runner-log").textContent = runner.log.length ? runner.log.join("\n") : "Nothing yet."; | |
| 421 | 439 | } | |
| ⋯ | |||
| 428 | 446 | byId("runner-open-log").addEventListener("click", function () { | |
| 429 | 447 | invoke("runner_control", { action: "view_log" }).catch(fail); | |
| 430 | 448 | }); | |
| 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 | + | }); | |
| 431 | 457 | ||
| 432 | 458 | function save() { | |
| 433 | 459 | invoke("save_settings", { settings: settingsOf() }).then(render, function (error) { | |
| 294 | 294 | ] | |
| 295 | 295 | ||
| 296 | 296 | [[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]] | |
| 297 | 306 | name = "block2" | |
| 298 | 307 | version = "0.6.2" | |
| 299 | 308 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 676 | 685 | ] | |
| 677 | 686 | ||
| 678 | 687 | [[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]] | |
| 679 | 697 | name = "cssparser" | |
| 680 | 698 | version = "0.37.0" | |
| 681 | 699 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 705 | 723 | checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" | |
| 706 | 724 | ||
| 707 | 725 | [[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]] | |
| 708 | 753 | name = "darling" | |
| 709 | 754 | version = "0.24.1" | |
| 710 | 755 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 827 | 872 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 828 | 873 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |
| 829 | 874 | dependencies = [ | |
| 830 | − | "block-buffer", | |
| 831 | − | "crypto-common", | |
| 875 | + | "block-buffer 0.10.4", | |
| 876 | + | "crypto-common 0.1.7", | |
| 832 | 877 | ] | |
| 833 | 878 | ||
| 834 | 879 | [[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]] | |
| 835 | 890 | name = "dirs" | |
| 836 | 891 | version = "6.0.0" | |
| 837 | 892 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 968 | 1023 | checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" | |
| 969 | 1024 | ||
| 970 | 1025 | [[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]] | |
| 971 | 1048 | name = "embed-resource" | |
| 972 | 1049 | version = "3.0.12" | |
| 973 | 1050 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 1077 | 1154 | ] | |
| 1078 | 1155 | ||
| 1079 | 1156 | [[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]] | |
| 1080 | 1163 | name = "field-offset" | |
| 1081 | 1164 | version = "0.3.6" | |
| 1082 | 1165 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 1247 | 1330 | ||
| 1248 | 1331 | [[package]] | |
| 1249 | 1332 | name = "g1t-desktop" | |
| 1250 | − | version = "0.3.1" | |
| 1333 | + | version = "0.3.2" | |
| 1251 | 1334 | dependencies = [ | |
| 1335 | + | "base64 0.22.1", | |
| 1336 | + | "ed25519-dalek", | |
| 1252 | 1337 | "gtk", | |
| 1253 | 1338 | "reqwest 0.12.28", | |
| 1254 | 1339 | "serde", | |
| 1255 | 1340 | "serde_json", | |
| 1256 | − | "sha2", | |
| 1341 | + | "sha2 0.10.9", | |
| 1257 | 1342 | "tauri", | |
| 1258 | 1343 | "tauri-build", | |
| 1259 | 1344 | "tauri-plugin-autostart", | |
| ⋯ | |||
| 1655 | 1740 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" | |
| 1656 | 1741 | ||
| 1657 | 1742 | [[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]] | |
| 1658 | 1752 | name = "hyper" | |
| 1659 | 1753 | version = "1.12.0" | |
| 1660 | 1754 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 3782 | 3876 | dependencies = [ | |
| 3783 | 3877 | "cfg-if", | |
| 3784 | 3878 | "cpufeatures 0.2.17", | |
| 3785 | − | "digest", | |
| 3879 | + | "digest 0.10.7", | |
| 3786 | 3880 | ] | |
| 3787 | 3881 | ||
| 3788 | 3882 | [[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]] | |
| 3789 | 3894 | name = "shlex" | |
| 3790 | 3895 | version = "2.0.1" | |
| 3791 | 3896 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 3802 | 3907 | ] | |
| 3803 | 3908 | ||
| 3804 | 3909 | [[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]] | |
| 3805 | 3916 | name = "simd-adler32" | |
| 3806 | 3917 | version = "0.3.10" | |
| 3807 | 3918 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 4208 | 4319 | "semver", | |
| 4209 | 4320 | "serde", | |
| 4210 | 4321 | "serde_json", | |
| 4211 | − | "sha2", | |
| 4322 | + | "sha2 0.10.9", | |
| 4212 | 4323 | "syn 2.0.119", | |
| 4213 | 4324 | "tauri-utils", | |
| 4214 | 4325 | "thiserror 2.0.21", | |
| ⋯ | |||
| 5598 | 5709 | "once_cell", | |
| 5599 | 5710 | "percent-encoding", | |
| 5600 | 5711 | "raw-window-handle", | |
| 5601 | − | "sha2", | |
| 5712 | + | "sha2 0.10.9", | |
| 5602 | 5713 | "soup3", | |
| 5603 | 5714 | "tao-macros", | |
| 5604 | 5715 | "thiserror 2.0.21", | |
| 1 | 1 | [package] | |
| 2 | 2 | name = "g1t-desktop" | |
| 3 | − | version = "0.3.1" | |
| 3 | + | version = "0.3.2" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license = "MIT" | |
| 6 | 6 | repository = "https://g1t.sh/flagon-io/g1t" | |
| ⋯ | |||
| 39 | 39 | # Whether the server answers, before the window goes there (the same client the updater brings). | |
| 40 | 40 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } | |
| 41 | 41 | 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. | |
| 43 | 45 | sha2 = "0.10" | |
| 46 | + | ed25519-dalek = "3" | |
| 47 | + | base64 = "0.22" | |
| 44 | 48 | ||
| 45 | 49 | # Keeping the menu bar hidden under g1t's own title bar (src/frame.rs); the | |
| 46 | 50 | # same GTK that Tauri brings. | |
| 72 | 72 | SandboxTaken, | |
| 73 | 73 | Updating, | |
| 74 | 74 | UpdateFailed, | |
| 75 | + | /// The newest release's manifest isn't signed with g1t's release key. | |
| 76 | + | UpdateRefused, | |
| 75 | 77 | Offline, | |
| 76 | 78 | Crashed, | |
| 77 | 79 | } | |
| ⋯ | |||
| 112 | 114 | /// shows them shows no line of the whole runner's as well. | |
| 113 | 115 | #[serde(skip_serializing_if = "is_false")] | |
| 114 | 116 | 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>, | |
| 115 | 121 | } | |
| 116 | 122 | ||
| 117 | 123 | fn is_false(value: &bool) -> bool { | |
| 118 | 124 | !*value | |
| 119 | 125 | } | |
| 120 | 126 | ||
| 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 | + | ||
| 121 | 146 | /// How one kind of job is, as its card's label says it. | |
| 122 | 147 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] | |
| 123 | 148 | #[serde(rename_all = "snake_case")] | |
| ⋯ | |||
| 174 | 199 | pub runner: Option<String>, | |
| 175 | 200 | /// Its runner's version. | |
| 176 | 201 | pub version: Option<String>, | |
| 202 | + | /// A newer version waiting for it: `0.3.2 ready, installs after this job`. | |
| 203 | + | pub update: Option<String>, | |
| 177 | 204 | } | |
| 178 | 205 | ||
| 179 | 206 | /// The job a kind of job is running, with where to see it on g1t. | |
| ⋯ | |||
| 218 | 245 | pub download: Option<launcher::Download>, | |
| 219 | 246 | /// The person stopped the whole runner, or turned it off. | |
| 220 | 247 | pub held: bool, | |
| 248 | + | pub update: Update, | |
| 221 | 249 | } | |
| 222 | 250 | ||
| 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 | + | ||
| 223 | 310 | const START: Action = Action { id: "start", label: "Start" }; | |
| 224 | 311 | const TURN_ON: Action = Action { id: "start", label: "Turn on" }; | |
| 225 | 312 | const RETRY: Action = Action { id: "retry", label: "Retry" }; | |
| 226 | 313 | 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" }; | |
| 227 | 317 | const RESTART: Action = Action { id: "restart", label: "Restart" }; | |
| 228 | 318 | const SIGN_IN: Action = Action { id: "sign_in", label: "Sign in" }; | |
| 229 | 319 | const CHOOSE_WORKSPACE: Action = Action { id: "choose_workspace", label: "Choose workspace" }; | |
| ⋯ | |||
| 462 | 552 | Tone::Warning, | |
| 463 | 553 | "Update failed".into(), | |
| 464 | 554 | 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], | |
| 467 | 557 | ), | |
| 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 | + | } | |
| 468 | 581 | Code::Offline => ( | |
| 469 | 582 | Tone::Warning, | |
| 470 | 583 | format!("Offline: {} unreachable", facts.server), | |
| ⋯ | |||
| 503 | 616 | | Code::SandboxFailed => cause.is_some() && capabilities.iter().any(|c| c.cause == cause), | |
| 504 | 617 | _ => false, | |
| 505 | 618 | }; | |
| 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 } | |
| 507 | 621 | } | |
| 508 | 622 | ||
| 509 | 623 | /// What the app calls a sandbox's problem with Linux jobs, from the | |
| ⋯ | |||
| 592 | 706 | isolation: capability.isolation.clone().filter(|_| capability.available), | |
| 593 | 707 | runner: capability.available.then(|| format!("{}-{id}", facts.machine)), | |
| 594 | 708 | version: capability.runner_version.clone(), | |
| 709 | + | update: waiting(&facts.update, id, capability.job.is_some()), | |
| 595 | 710 | }; | |
| 596 | 711 | let said = |line: &mut CapabilityLine, state: KindState, tone: Tone, cause: Option<String>, fix: Option<String>, actions: Vec<Action>| { | |
| 597 | 712 | line.state = state; | |
| ⋯ | |||
| 729 | 844 | actions: vec![], | |
| 730 | 845 | capabilities: vec![], | |
| 731 | 846 | in_capabilities: false, | |
| 847 | + | update: None, | |
| 732 | 848 | } | |
| 733 | 849 | } | |
| 734 | 850 | ||
| ⋯ | |||
| 768 | 884 | Code::SandboxFailed, | |
| 769 | 885 | Code::SandboxTaken, | |
| 770 | 886 | Code::UpdateFailed, | |
| 887 | + | Code::UpdateRefused, | |
| 771 | 888 | Code::Offline, | |
| 772 | 889 | Code::Crashed, | |
| 773 | 890 | ] { | |
| ⋯ | |||
| 967 | 1084 | } | |
| 968 | 1085 | ||
| 969 | 1086 | #[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] | |
| 970 | 1162 | fn a_diagnosis_without_the_sandbox_lists_no_capabilities() { | |
| 971 | 1163 | let json = serde_json::to_value(diagnose(Code::Idle, &Facts { backend: "in Docker", ..facts() })).unwrap(); | |
| 972 | 1164 | assert!(json.get("capabilities").is_none()); | |
| 344 | 344 | args | |
| 345 | 345 | } | |
| 346 | 346 | ||
| 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 | + | ||
| 347 | 356 | pub fn status_args(dir: &Path) -> Vec<OsString> { | |
| 348 | 357 | args("status", dir) | |
| 349 | 358 | } | |
| ⋯ | |||
| 676 | 685 | // Never the token: it goes in the environment, out of the process list. | |
| 677 | 686 | assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop", "--only", "linux,windows"]); | |
| 678 | 687 | assert_eq!(text(down_only_args(&dir, "windows"))[5..], ["--only", "windows"]); | |
| 688 | + | assert_eq!(text(drain_only_args(&dir, "windows"))[5..], ["--only", "windows", "--drain"]); | |
| 679 | 689 | assert!(!up.iter().any(|a| a == "--token" || a.starts_with("g1trt_"))); | |
| 680 | 690 | let mut command = command(Path::new("g1t-runner"), up_args(&dir, &first)); | |
| 681 | 691 | with_token(&mut command, first.token); | |
| 32 | 32 | //! (registration.json and the runner's own folder; the sandbox keeps its | |
| 33 | 33 | //! own, and sandbox-registration.json says whose it is) and used on | |
| 34 | 34 | //! 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. | |
| 39 | 47 | //! - **Seen.** What it is doing, and when it is not running why and how to | |
| 40 | 48 | //! fix it (diagnosis.rs), is in the tray, in settings, and on the site's | |
| 41 | 49 | //! Runners page (`g1t-runner-status`), read from what its log says | |
| ⋯ | |||
| 58 | 66 | use std::process::{Child, Stdio}; | |
| 59 | 67 | use std::sync::Mutex; | |
| 60 | 68 | use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; | |
| 61 | − | use std::time::{Duration, Instant}; | |
| 69 | + | use std::time::{Duration, Instant, SystemTime}; | |
| 62 | 70 | ||
| 63 | 71 | use serde::{Deserialize, Serialize}; | |
| 64 | 72 | use tauri::{AppHandle, Emitter, EventTarget, Manager}; | |
| ⋯ | |||
| 68 | 76 | use backend::{Engine, Missing}; | |
| 69 | 77 | use diagnosis::{Code, Diagnosis, Facts}; | |
| 70 | 78 | use launcher::{Down, State, Status}; | |
| 79 | + | use native::Fault; | |
| 71 | 80 | ||
| 72 | 81 | use crate::{config, frame, settings, tray}; | |
| 73 | 82 | ||
| ⋯ | |||
| 82 | 91 | const TICK: Duration = Duration::from_secs(5); | |
| 83 | 92 | /// How long after starting the first look for an update is, then how often. | |
| 84 | 93 | 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); | |
| 86 | 95 | /// How often a backend that cannot start is looked at again. | |
| 87 | 96 | const LOOK_AGAIN: Duration = Duration::from_secs(30); | |
| 88 | 97 | /// How long the app waits for the site to hand over a token before asking again. | |
| ⋯ | |||
| 126 | 135 | download: Option<launcher::Download>, | |
| 127 | 136 | } | |
| 128 | 137 | ||
| 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 | + | ||
| 129 | 166 | /// What the site last said about the person. | |
| 130 | 167 | #[derive(Clone, Debug, Default, PartialEq)] | |
| 131 | 168 | struct Site { | |
| ⋯ | |||
| 146 | 183 | paused: Mutex<bool>, | |
| 147 | 184 | tail: Mutex<VecDeque<String>>, | |
| 148 | 185 | tx: Mutex<Option<Sender<Ask>>>, | |
| 186 | + | updates: Mutex<Updates>, | |
| 149 | 187 | } | |
| 150 | 188 | ||
| 151 | 189 | /// What settings, the tray and the site are shown. | |
| ⋯ | |||
| 180 | 218 | /// A kind of job was started: start what should run now, stopping nothing. | |
| 181 | 219 | Soon, | |
| 182 | 220 | 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>), | |
| 183 | 226 | Token { workspace: String, token: String }, | |
| 184 | 227 | Refused { workspace: String, allowed: bool, why: String }, | |
| 185 | 228 | /// A line of the runner's output, or the launcher's; true from its standard output. | |
| ⋯ | |||
| 295 | 338 | starting: now.starting.clone(), | |
| 296 | 339 | download: now.download.clone(), | |
| 297 | 340 | held: !settings.runner || *app.state::<Shared>().paused.lock().unwrap(), | |
| 341 | + | update: update_facts(app, now, backend), | |
| 298 | 342 | } | |
| 299 | 343 | } | |
| 300 | 344 | ||
| 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 | + | ||
| 301 | 396 | pub fn view(app: &AppHandle) -> View { | |
| 302 | 397 | let shared = app.state::<Shared>(); | |
| 303 | 398 | let now = shared.now.lock().unwrap().clone(); | |
| ⋯ | |||
| 443 | 538 | } | |
| 444 | 539 | "stop" => set_paused(app, true), | |
| 445 | 540 | "retry" | "restart" => retry(app), | |
| 541 | + | "check_updates" => ask(app, Ask::Check), | |
| 446 | 542 | "sign_in" => crate::links::go(app, "/login"), | |
| 447 | 543 | "new_workspace" => crate::links::go(app, "/workspaces/new"), | |
| 448 | 544 | "view_runners" => match workspace(app) { | |
| ⋯ | |||
| 556 | 652 | Start(String), | |
| 557 | 653 | /// `down --only <kind>`: one kind of job, stopped, the rest running on. | |
| 558 | 654 | 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), | |
| 559 | 658 | } | |
| 560 | 659 | ||
| 561 | 660 | impl Step { | |
| ⋯ | |||
| 563 | 662 | match self { | |
| 564 | 663 | Step::Up | Step::Start(_) => "up", | |
| 565 | 664 | Step::Update => "update", | |
| 566 | − | Step::Stop(_) => "down", | |
| 665 | + | Step::Stop(_) | Step::Drain(_) => "down", | |
| 567 | 666 | } | |
| 568 | 667 | } | |
| 569 | 668 | } | |
| ⋯ | |||
| 633 | 732 | failures: u32, | |
| 634 | 733 | next_update: Instant, | |
| 635 | 734 | 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, | |
| 640 | 747 | /// A kind of job that should run and doesn't, while another runs: | |
| 641 | 748 | /// when it is tried again, and how many times it has been. | |
| 642 | 749 | retry: Vec<(String, Instant, u32)>, | |
| ⋯ | |||
| 668 | 775 | failures: 0, | |
| 669 | 776 | next_update: now + FIRST_UPDATE, | |
| 670 | 777 | log: None, | |
| 671 | − | image_pending: false, | |
| 672 | 778 | update_failed: None, | |
| 779 | + | program: None, | |
| 780 | + | asked: false, | |
| 781 | + | said_checked: String::new(), | |
| 673 | 782 | retry: Vec::new(), | |
| 674 | 783 | } | |
| 675 | 784 | } | |
| ⋯ | |||
| 692 | 801 | } | |
| 693 | 802 | } | |
| 694 | 803 | self.look(); | |
| 804 | + | self.said_when(); | |
| 695 | 805 | } | |
| 696 | 806 | } | |
| 697 | 807 | ||
| 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 | + | ||
| 698 | 832 | fn set(&mut self, code: Code, extra: Extra) { | |
| 699 | 833 | let changed = { | |
| 700 | 834 | let shared = self.app.state::<Shared>(); | |
| ⋯ | |||
| 775 | 909 | } | |
| 776 | 910 | match self.code() { | |
| 777 | 911 | // 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), | |
| 782 | 913 | Code::Offline => self.halt(), | |
| 783 | 914 | _ if self.run.as_ref().is_some_and(|run| !run.listening) => self.halt(), | |
| 784 | 915 | _ => {} | |
| 785 | 916 | } | |
| 786 | 917 | } | |
| 918 | + | Ask::Check => self.check(true), | |
| 919 | + | Ask::Checked(found) => self.checked(found), | |
| 787 | 920 | Ask::Token { workspace, token } => { | |
| 788 | 921 | let shared = self.app.state::<Shared>(); | |
| 789 | 922 | let mut wanted = shared.wanted.lock().unwrap(); | |
| ⋯ | |||
| 879 | 1012 | } | |
| 880 | 1013 | self.note(job, version); | |
| 881 | 1014 | if let Some(code) = became { | |
| 882 | − | self.set(code, Extra::default()); | |
| 1015 | + | let (code, extra) = self.idle(code); | |
| 1016 | + | self.set(code, extra); | |
| 883 | 1017 | } | |
| 884 | 1018 | } | |
| 885 | 1019 | ||
| ⋯ | |||
| 1027 | 1161 | self.set(if paused { Code::Stopped } else { Code::Off }, Extra::default()); | |
| 1028 | 1162 | return; | |
| 1029 | 1163 | } | |
| 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 | + | } | |
| 1030 | 1168 | let workspace = workspace(&self.app); | |
| 1031 | 1169 | if let Some(run) = &self.run { | |
| 1032 | 1170 | let moved = run.backend != settings.runner_backend || Some(&run.workspace) != workspace.as_ref(); | |
| 1033 | 1171 | 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()); | |
| 1035 | 1173 | if back { | |
| 1036 | 1174 | if let Some(run) = self.run.as_mut() { | |
| 1037 | 1175 | run.offline_at = None; | |
| ⋯ | |||
| 1042 | 1180 | if moved && !busy && !stepping { | |
| 1043 | 1181 | self.halt_as(Down::Drain); | |
| 1044 | 1182 | 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() { | |
| 1048 | 1184 | self.kinds(workspace.as_deref()); | |
| 1049 | 1185 | } | |
| 1050 | 1186 | self.poll(); | |
| ⋯ | |||
| 1053 | 1189 | if Instant::now() < self.not_before { | |
| 1054 | 1190 | return; | |
| 1055 | 1191 | } | |
| 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 | + | } | |
| 1056 | 1199 | self.start(settings.runner_backend, workspace); | |
| 1057 | 1200 | } | |
| 1058 | 1201 | ||
| ⋯ | |||
| 1101 | 1244 | let envs = environment(&server, backend, token.as_deref()); | |
| 1102 | 1245 | let generation = self.generation + 1; | |
| 1103 | 1246 | 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| { | |
| 1106 | 1252 | let _ = std::fs::write(folder(&app).join("pid"), child.id().to_string()); | |
| 1107 | − | Ok(child) | |
| 1108 | 1253 | }); | |
| 1109 | 1254 | match started { | |
| 1110 | 1255 | Ok(mut child) => { | |
| ⋯ | |||
| 1164 | 1309 | } | |
| 1165 | 1310 | } | |
| 1166 | 1311 | ||
| 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> { | |
| 1169 | 1315 | let folder = folder(&self.app); | |
| 1170 | 1316 | let binary = native::binary(&folder); | |
| 1171 | 1317 | if native::installed(&folder).is_some() { | |
| ⋯ | |||
| 1173 | 1319 | } | |
| 1174 | 1320 | self.set(Code::Updating, detail("Downloading g1t-runner.")); | |
| 1175 | 1321 | 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)?; | |
| 1178 | 1324 | self.note(None, Some(manifest.version)); | |
| 1325 | + | self.with_updates(|updates| updates.checked_at = Some(SystemTime::now())); | |
| 1179 | 1326 | Ok(binary) | |
| 1180 | 1327 | } | |
| 1181 | 1328 | ||
| 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) { | |
| 1186 | 1352 | 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)) { | |
| 1188 | 1357 | return; | |
| 1189 | 1358 | } | |
| 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); | |
| 1200 | 1393 | } | |
| 1201 | 1394 | }; | |
| 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; | |
| 1207 | 1455 | } | |
| 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) { | |
| 1215 | 1506 | 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 | + | } | |
| 1220 | 1511 | } | |
| 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 | + | } | |
| 1222 | 1517 | } | |
| 1223 | − | self.not_before = Instant::now(); | |
| 1518 | + | self.done_with(|updates| { | |
| 1519 | + | updates.program = false; | |
| 1520 | + | updates.installing.retain(|kind| kind == "linux"); | |
| 1521 | + | }); | |
| 1224 | 1522 | } | |
| 1225 | 1523 | ||
| 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; | |
| 1233 | 1535 | } | |
| 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); | |
| 1236 | 1537 | } | |
| 1237 | 1538 | ||
| 1238 | 1539 | // --- The sandbox (launcher.rs) ------------------------------------------------ | |
| ⋯ | |||
| 1244 | 1545 | let server = config::server(&app); | |
| 1245 | 1546 | let binary = match self.ensure_binary(&server) { | |
| 1246 | 1547 | Ok(binary) => binary, | |
| 1247 | − | Err(why) => return self.not_started(workspace, None, why), | |
| 1548 | + | Err(fault) => return self.not_installed(workspace, None, fault), | |
| 1248 | 1549 | }; | |
| 1249 | 1550 | let dir = native_dir(&app, Backend::Sandboxed); | |
| 1250 | 1551 | let mut status = match launcher::status(&binary, &dir) { | |
| ⋯ | |||
| 1317 | 1618 | self.generation = generation; | |
| 1318 | 1619 | self.run = Some(Run::new(generation, Engine::Launcher, Backend::Sandboxed, workspace.clone(), Process::Sandbox(None), token.is_some())); | |
| 1319 | 1620 | // 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 | + | } | |
| 1322 | 1628 | let name = backend::machine_name(); | |
| 1323 | 1629 | let url = server.as_str().trim_end_matches('/').to_owned(); | |
| 1324 | 1630 | let up = launcher::Up { url: &url, name: &name, labels: "desktop", token: token.as_deref(), only: wanted.iter().map(String::as_str).collect() }; | |
| ⋯ | |||
| 1362 | 1668 | return; | |
| 1363 | 1669 | } | |
| 1364 | 1670 | 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(); | |
| 1365 | 1673 | for capability in &status.capabilities { | |
| 1366 | 1674 | if !on.on(&capability.id) && !capability.off() { | |
| 1367 | 1675 | return self.kind_step(Step::Stop(capability.id.clone()), None); | |
| 1368 | 1676 | } | |
| 1369 | − | if on.on(&capability.id) && capability.off() && capability.available { | |
| 1677 | + | if on.on(&capability.id) && capability.off() && capability.available && !(replacing && capability.id != "linux") { | |
| 1370 | 1678 | return self.start_kind(capability, workspace); | |
| 1371 | 1679 | } | |
| 1372 | 1680 | } | |
| ⋯ | |||
| 1430 | 1738 | (launcher::up_args(&dir, &up), vec![kind.clone()], format!("starting {}", diagnosis::kind_label(kind))) | |
| 1431 | 1739 | } | |
| 1432 | 1740 | 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))), | |
| 1433 | 1742 | Step::Up | Step::Update => return, | |
| 1434 | 1743 | }; | |
| 1435 | 1744 | self.write_log(&format!("g1t: {said}.")); | |
| ⋯ | |||
| 1445 | 1754 | self.write_log(&format!("g1t: the sandbox's status: {why}")); | |
| 1446 | 1755 | self.failures += 1; | |
| 1447 | 1756 | 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() | |
| 1451 | 1760 | { | |
| 1452 | 1761 | self.note(None, Some(version)); | |
| 1453 | 1762 | self.not_before = Instant::now(); | |
| ⋯ | |||
| 1570 | 1879 | return self.sandbox_stopped(&status, code, extra); | |
| 1571 | 1880 | } | |
| 1572 | 1881 | 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)), | |
| 1574 | 1883 | (code, _) => self.set(code, extra), | |
| 1575 | 1884 | } | |
| 1576 | 1885 | } | |
| ⋯ | |||
| 1645 | 1954 | if let Some(error) = &error { | |
| 1646 | 1955 | self.write_log(&format!("g1t: sandbox {}: {error}", step.name())); | |
| 1647 | 1956 | } | |
| 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 | + | } | |
| 1648 | 1968 | // One kind of job started or stopped: how the sandbox is now says | |
| 1649 | 1969 | // 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(_)) { | |
| 1651 | 1971 | if let Some(status) = status { | |
| 1652 | 1972 | self.sandbox_now(status); | |
| 1653 | 1973 | } | |
| ⋯ | |||
| 1663 | 1983 | registration.save(&app); | |
| 1664 | 1984 | } | |
| 1665 | 1985 | } | |
| 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 | + | } | |
| 1666 | 1993 | if let Some(error) = error.clone() { | |
| 1667 | 1994 | if self.registration_trouble(&error) { | |
| 1668 | 1995 | return; | |
| 1669 | 1996 | } | |
| 1670 | 1997 | 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}"))); | |
| 1672 | 1999 | } else if !running { | |
| 1673 | 2000 | // Nothing runs: tidied away, and tried again in a while; | |
| 1674 | 2001 | // a release without the sandbox's images every few minutes. | |
| ⋯ | |||
| 1707 | 2034 | /// What the tray says while the sandbox starts. | |
| 1708 | 2035 | const STARTING_SANDBOX: &str = "Starting the sandbox. The first time, it downloads and checks the sandbox's image, which takes a few minutes."; | |
| 1709 | 2036 | ||
| 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 | + | ||
| 1710 | 2051 | fn some_text(text: &str) -> Option<String> { | |
| 1711 | 2052 | Some(text.to_owned()) | |
| 1712 | 2053 | } | |
| ⋯ | |||
| 1846 | 2187 | } | |
| 1847 | 2188 | ||
| 1848 | 2189 | #[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] | |
| 1849 | 2213 | fn the_site_names_workspaces_in_its_own_words() { | |
| 1850 | 2214 | let offered: Vec<Offered> = serde_json::from_str(r#"[{"slug":"acme","name":"Acme"},{"slug":"solo"}]"#).unwrap(); | |
| 1851 | 2215 | assert_eq!(offered[1], Offered { slug: "solo".into(), name: None }); | |
| 4 | 4 | //! | |
| 5 | 5 | //! The app installs and updates it from the server's releases: the | |
| 6 | 6 | //! 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. | |
| 11 | 15 | ||
| 12 | 16 | use std::collections::BTreeMap; | |
| 13 | 17 | use std::path::{Path, PathBuf}; | |
| 14 | 18 | use std::process::{Child, Stdio}; | |
| 15 | 19 | use std::time::Duration; | |
| 16 | 20 | ||
| 21 | + | use base64::Engine; | |
| 22 | + | use base64::engine::general_purpose::STANDARD; | |
| 17 | 23 | use serde::Deserialize; | |
| 18 | 24 | use sha2::{Digest, Sha256}; | |
| 19 | 25 | use url::Url; | |
| 20 | 26 | ||
| 21 | 27 | use super::backend::quiet; | |
| 22 | 28 | ||
| 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 | + | ||
| 23 | 58 | #[derive(Deserialize)] | |
| 24 | 59 | pub struct File { | |
| 25 | 60 | pub name: String, | |
| ⋯ | |||
| 30 | 65 | pub struct Manifest { | |
| 31 | 66 | pub version: String, | |
| 32 | 67 | 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 | + | } | |
| 33 | 85 | } | |
| 34 | 86 | ||
| 35 | 87 | /// This computer's platform, as releases name it. | |
| ⋯ | |||
| 78 | 130 | }) | |
| 79 | 131 | } | |
| 80 | 132 | ||
| 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}"))) | |
| 85 | 155 | } | |
| 86 | 156 | ||
| 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 | + | ||
| 87 | 166 | /// Downloads a release's program for this platform and checks it against | |
| 88 | 167 | /// the manifest's SHA-256. | |
| 89 | 168 | pub fn download(server: &Url, manifest: &Manifest) -> Result<Vec<u8>, String> { | |
| ⋯ | |||
| 95 | 174 | Ok(bytes) | |
| 96 | 175 | } | |
| 97 | 176 | ||
| 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. | |
| 100 | 183 | pub fn put(folder: &Path, bytes: &[u8], version: &str) -> Result<(), String> { | |
| 101 | 184 | let path = binary(folder); | |
| 102 | 185 | let dir = path.parent().unwrap_or(folder); | |
| 103 | 186 | 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 | + | } | |
| 104 | 192 | let new = path.with_extension("new"); | |
| 105 | 193 | std::fs::write(&new, bytes).map_err(|e| e.to_string())?; | |
| 106 | 194 | #[cfg(unix)] | |
| ⋯ | |||
| 108 | 196 | use std::os::unix::fs::PermissionsExt; | |
| 109 | 197 | std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?; | |
| 110 | 198 | } | |
| 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 | + | } | |
| 112 | 208 | std::fs::write(dir.join("version"), version).map_err(|e| e.to_string()) | |
| 113 | 209 | } | |
| 114 | 210 | ||
| ⋯ | |||
| 214 | 310 | assert_eq!(downloads(&Url::parse("https://g1t.sh/").unwrap()), "https://g1t.sh/downloads/runner"); | |
| 215 | 311 | } | |
| 216 | 312 | ||
| 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 | + | ||
| 217 | 368 | #[test] | |
| 218 | 369 | fn nothing_is_installed_in_an_empty_folder() { | |
| 219 | 370 | let dir = std::env::temp_dir().join(format!("g1t-desktop-runner-{}", std::process::id())); | |
| 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 | + | Dg1HkgLm7YdhES6DgrqXmVyX+9j7KGqDcfKF/iyL3Ze7svr9ejIfQa+Wi/qLsy6VXiHXhKv6n9Np41M1RRNLCg== |
| 136 | 136 | | **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. | | |
| 137 | 137 | | **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. | | |
| 138 | 138 | | **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. | | |
| 140 | 140 | | **Run jobs on this computer while g1t is open** | On | Starts [the runner](#the-runner) a few seconds after the window. Off, nothing runs. | | |
| 141 | 141 | | **How jobs run** | Sandboxed | Sandboxed, in Docker, or directly on this computer. See [how jobs run](#how-jobs-run). | | |
| 142 | 142 | | **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). | | |
| ⋯ | |||
| 213 | 213 | | 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. | | |
| 214 | 214 | | 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. | | |
| 215 | 215 | | 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. | | |
| 217 | 218 | ||
| 218 | 219 | The other two choices run `g1t-runner` from the same data folder, with | |
| 219 | 220 | each one's registration beside it. | |
| ⋯ | |||
| 244 | 245 | ||
| 245 | 246 | ### Keeping it up to date | |
| 246 | 247 | ||
| 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. | |
| 254 | 274 | ||
| 255 | 275 | ### Seeing it | |
| 256 | 276 | ||
| 257 | 277 | | Where | What it shows | | |
| 258 | 278 | | --- | --- | | |
| 259 | 279 | | 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. | | |
| 262 | 282 | ||
| 263 | 283 | ### When it isn't running | |
| 264 | 284 | ||
| ⋯ | |||
| 295 | 315 | | *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**. | | |
| 296 | 316 | | *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. | | |
| 297 | 317 | | *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. | | |
| 299 | 322 | | *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**. | | |
| 300 | 323 | | *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. | | |
| 301 | 324 | ||
| ⋯ | |||
| 383 | 406 | ||
| 384 | 407 | | What | Where | | |
| 385 | 408 | | --- | --- | | |
| 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. | | |
| 386 | 410 | | 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. | | |
| 387 | 411 | | Linux builds | g1t's own Linux machines, in every release. | | |
| 388 | 412 | | 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. | | |
| 2 | 2 | import { type ReactNode, useState } from "react"; | |
| 3 | 3 | import { Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import type { DesktopBackend, DesktopDiagnosis, DesktopRunner, Runner } from "@g1t/contracts"; | |
| 5 | + | import type { DesktopBackend, DesktopDiagnosis, DesktopRunner, DesktopUpdate, Runner } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { BACKEND_LABEL, type DesktopRunnerAction, type DesktopRunnerCapability, desktopRunner, jobHref, useDesktopRunner } from "../lib/desktop-runner"; | |
| 8 | 8 | import { EmptyState, TimeAgo } from "./ui"; | |
| ⋯ | |||
| 57 | 57 | inCapabilities: boolean; | |
| 58 | 58 | here: boolean; | |
| 59 | 59 | 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; | |
| 60 | 64 | }; | |
| 61 | 65 | ||
| 62 | 66 | /** The desktops to list: this computer, live, first; then the others, as their apps last said, or closed. */ | |
| ⋯ | |||
| 75 | 79 | inCapabilities: !!here.diagnosis.in_capabilities && capabilities.length > 0, | |
| 76 | 80 | here: true, | |
| 77 | 81 | reportedAt: null, | |
| 82 | + | update: here.diagnosis.update ?? null, | |
| 83 | + | check: here.diagnosis.update?.checking ? null : (here.diagnosis.update?.check ?? null), | |
| 78 | 84 | }); | |
| 79 | 85 | } | |
| 80 | 86 | for (const desktop of desktops) { | |
| ⋯ | |||
| 91 | 97 | inCapabilities: desktop.open && !!desktop.diagnosis.in_capabilities && capabilities.length > 0, | |
| 92 | 98 | here: false, | |
| 93 | 99 | reportedAt: desktop.reportedAt, | |
| 100 | + | update: desktop.open ? (desktop.diagnosis.update ?? null) : null, | |
| 101 | + | check: null, | |
| 94 | 102 | }); | |
| 95 | 103 | } | |
| 96 | 104 | return rows; | |
| ⋯ | |||
| 143 | 151 | ); | |
| 144 | 152 | } | |
| 145 | 153 | ||
| 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 | + | ||
| 146 | 164 | /** 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 }) { | |
| 148 | 166 | const href = jobHref(capability, runners); | |
| 149 | 167 | const isolation = capability.isolation ? (ISOLATION[capability.isolation] ?? { label: capability.isolation, hint: null }) : null; | |
| 150 | 168 | const edge = capability.tone === "problem" ? "border-danger/40" : capability.tone === "warning" ? "border-warn/35" : ""; | |
| ⋯ | |||
| 196 | 214 | <dl className="space-y-0.5 border-t border-line pt-2 text-xs"> | |
| 197 | 215 | {capability.runner && <Detail term="On g1t">{capability.runner}</Detail>} | |
| 198 | 216 | {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 | + | )} | |
| 199 | 223 | {isolation && ( | |
| 200 | 224 | <Detail term="Isolation"> | |
| 201 | 225 | {isolation.hint ? ( | |
| ⋯ | |||
| 261 | 285 | </div> | |
| 262 | 286 | <div className="shrink-0 text-right text-xs text-faint"> | |
| 263 | 287 | {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 | + | )} | |
| 264 | 295 | {row.reportedAt && ( | |
| 265 | 296 | <div className="mt-0.5"> | |
| 266 | 297 | Heard from <TimeAgo at={row.reportedAt} /> | |
| 267 | 298 | </div> | |
| 268 | 299 | )} | |
| 300 | + | {row.check && ( | |
| 301 | + | <div className="mt-2"> | |
| 302 | + | <Actions actions={[row.check]} primary={false} /> | |
| 303 | + | </div> | |
| 304 | + | )} | |
| 269 | 305 | </div> | |
| 270 | 306 | </div> | |
| 271 | 307 | {!row.inCapabilities && ( | |
| ⋯ | |||
| 286 | 322 | {row.capabilities.length > 0 && ( | |
| 287 | 323 | <div className={`grid gap-3 ${row.capabilities.length > 1 ? "sm:grid-cols-2" : ""}`}> | |
| 288 | 324 | {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} /> | |
| 290 | 326 | ))} | |
| 291 | 327 | </div> | |
| 292 | 328 | )} | |
| 165 | 165 | assert.equal(backendOf("host"), "host"); | |
| 166 | 166 | assert.equal(backendOf("vm"), null); | |
| 167 | 167 | }); | |
| 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 | + | }); |
| 18 | 18 | */ | |
| 19 | 19 | import { useEffect, useState } from "react"; | |
| 20 | 20 | ||
| 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"; | |
| 22 | 22 | ||
| 23 | 23 | import { agentRunHref, workflowRunHref } from "./runners.ts"; | |
| 24 | 24 | ||
| ⋯ | |||
| 39 | 39 | job?: (DesktopJob & { link?: string | null }) | null; | |
| 40 | 40 | }; | |
| 41 | 41 | ||
| 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 | + | ||
| 42 | 55 | /** What the app says of its runner (`runner::View` in the app). */ | |
| 43 | 56 | 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 }; | |
| 45 | 58 | closed: DesktopDiagnosis & { actions: DesktopRunnerAction[] }; | |
| 46 | 59 | running: boolean; | |
| 47 | 60 | version: string | null; | |
| ⋯ | |||
| 103 | 116 | ||
| 104 | 117 | /** A kind of job as the server keeps it: what it says, without what only works or changes on that computer. */ | |
| 105 | 118 | 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; | |
| 107 | 120 | const said: DesktopCapability = { id, label, tone, title, cause: cause ?? null, fix: fix ?? null }; | |
| 108 | 121 | if (state) said.state = state; | |
| 109 | 122 | if (headline) said.headline = headline; | |
| ⋯ | |||
| 113 | 126 | if (runner) said.runner = runner; | |
| 114 | 127 | if (isolation) said.isolation = isolation; | |
| 115 | 128 | if (version) said.version = version; | |
| 129 | + | if (update) said.update = update; | |
| 116 | 130 | return said; | |
| 117 | 131 | } | |
| 118 | 132 | ||
| 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 | + | ||
| 119 | 139 | /** What goes to the server, without the buttons, which only work on that computer. */ | |
| 120 | 140 | export function reportOf(view: DesktopRunnerView): DesktopReport | null { | |
| 121 | 141 | 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 => { | |
| 123 | 143 | const said: DesktopDiagnosis = { code, tone, title, cause, fix }; | |
| 124 | 144 | if (capabilities?.length) { | |
| 125 | 145 | said.capabilities = capabilities.map(plainCapability); | |
| 126 | 146 | if (in_capabilities) said.in_capabilities = true; | |
| 127 | 147 | } | |
| 148 | + | const updates = plainUpdate(update); | |
| 149 | + | if (updates) said.update = updates; | |
| 128 | 150 | return said; | |
| 129 | 151 | }; | |
| 130 | 152 | return { workspace: view.workspace, machine: view.machine, backend: view.backend, version: view.version, diagnosis: plain(view.diagnosis), closed: plain(view.closed) }; | |
| ⋯ | |||
| 138 | 160 | ||
| 139 | 161 | export function reportKey(report: DesktopReport): string { | |
| 140 | 162 | 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"); | |
| 143 | 166 | } | |
| 144 | 167 | ||
| 145 | 168 | /** A string with something in it, or null. */ | |
| ⋯ | |||
| 169 | 192 | said.capabilities = capabilities; | |
| 170 | 193 | if (given.in_capabilities === true) said.in_capabilities = true; | |
| 171 | 194 | } | |
| 195 | + | const update = updateOf(given.update); | |
| 196 | + | if (update) said.update = update; | |
| 172 | 197 | return said; | |
| 173 | 198 | } | |
| 174 | 199 | ||
| 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 | + | ||
| 175 | 213 | /** The most kinds of job a desktop reports (the server keeps as many). */ | |
| 176 | 214 | const MAX_CAPABILITIES = 4; | |
| 177 | 215 | ||
| ⋯ | |||
| 187 | 225 | const said: DesktopCapability = { id, label, tone, title, cause: text(given.cause), fix: text(given.fix) }; | |
| 188 | 226 | const state = STATES.find((s) => s === given.state); | |
| 189 | 227 | 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) { | |
| 191 | 229 | const value = text(given[key]); | |
| 192 | 230 | if (value) said[key] = value; | |
| 193 | 231 | } | |
Loading the diff…
Loading the diff…
Loading the diff…