Skip to content
223 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.1//! `g1t-runner` itself, kept in the app's data folder and run as a child
2//! of the app: for the Docker and On this computer backends, and for the
3//! sandbox launcher, which is the same program.
4//!
5//! The app installs and updates it from the server's releases: the
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.
11
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14use std::process::{Child, Stdio};
15use std::time::Duration;
16
17use serde::Deserialize;
18use sha2::{Digest, Sha256};
19use url::Url;
20
21use super::backend::quiet;
22
23#[derive(Deserialize)]
24pub struct File {
25 pub name: String,
26 pub sha256: String,
27}
28
29#[derive(Deserialize)]
30pub struct Manifest {
31 pub version: String,
32 pub files: BTreeMap<String, File>,
33}
34
35/// This computer's platform, as releases name it.
36pub fn platform() -> String {
37 let os = match std::env::consts::OS {
38 "macos" => "macos",
39 "windows" => "windows",
40 _ => "linux",
41 };
42 let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" };
43 format!("{os}-{arch}")
44}
45
46/// Where the program is kept.
47pub fn binary(folder: &Path) -> PathBuf {
48 folder.join("bin").join(if cfg!(windows) { "g1t-runner.exe" } else { "g1t-runner" })
49}
50
51/// The version installed, as the manifest named it.
52pub fn installed(folder: &Path) -> Option<String> {
53 binary(folder).exists().then(|| std::fs::read_to_string(folder.join("bin").join("version")).ok()).flatten().map(|v| v.trim().to_owned())
54}
55
56/// `a.b.c` newer than `x.y.z`.
57pub fn newer(candidate: &str, current: &str) -> bool {
58 let parts = |v: &str| -> Vec<u64> { v.trim_start_matches('v').split(['.', '-']).take(3).map(|p| p.parse().unwrap_or(0)).collect() };
59 parts(candidate) > parts(current)
60}
61
62pub fn sha256(bytes: &[u8]) -> String {
63 Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
64}
65
66fn downloads(server: &Url) -> String {
67 format!("{}downloads/runner", server.as_str())
68}
69
70fn get(url: &str) -> Result<Vec<u8>, String> {
71 tauri::async_runtime::block_on(async {
72 let client = reqwest::Client::builder().timeout(Duration::from_secs(300)).build().map_err(|e| e.to_string())?;
73 let response = client.get(url).send().await.map_err(|e| e.to_string())?;
74 if !response.status().is_success() {
75 return Err(format!("{url} answered {}", response.status()));
76 }
77 response.bytes().await.map(|bytes| bytes.to_vec()).map_err(|e| e.to_string())
78 })
79}
80
81/// The newest release's manifest.
82pub 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}"))
85}
86
87/// Downloads a release's program for this platform and checks it against
88/// the manifest's SHA-256.
89pub fn download(server: &Url, manifest: &Manifest) -> Result<Vec<u8>, String> {
90 let file = manifest.files.get(&platform()).ok_or_else(|| format!("g1t-runner {} has no build for {}", manifest.version, platform()))?;
91 let bytes = get(&format!("{}/{}/{}", downloads(server), manifest.version, file.name))?;
92 if sha256(&bytes) != file.sha256.to_ascii_lowercase() {
93 return Err("the download's SHA-256 isn't the release's, so it was thrown away".into());
94 }
95 Ok(bytes)
96}
97
98/// Puts a checked program in place. The runner must not be running:
99/// Windows will not replace a program that is.
100pub fn put(folder: &Path, bytes: &[u8], version: &str) -> Result<(), String> {
101 let path = binary(folder);
102 let dir = path.parent().unwrap_or(folder);
103 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
104 let new = path.with_extension("new");
105 std::fs::write(&new, bytes).map_err(|e| e.to_string())?;
106 #[cfg(unix)]
107 {
108 use std::os::unix::fs::PermissionsExt;
109 std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?;
110 }
111 std::fs::rename(&new, &path).map_err(|e| format!("could not put g1t-runner in place: {e}"))?;
112 std::fs::write(dir.join("version"), version).map_err(|e| e.to_string())
113}
114
115/// Starts `register-and-run`, which registers the first time (from
116/// `G1T_RUNNER_*` in the environment the caller sets) and then runs.
117pub fn spawn(binary: &Path, dir: &Path, envs: &[(&str, String)]) -> std::io::Result<Child> {
118 let mut command = quiet(binary);
119 command.arg("register-and-run").arg("--dir").arg(dir);
120 // What the app's own environment may have set for another runner is not this one's.
121 for (name, _) in std::env::vars_os() {
122 if name.to_string_lossy().starts_with("G1T_RUNNER_") {
123 command.env_remove(&name);
124 }
125 }
126 command.envs(envs.iter().map(|(name, value)| (*name, value.as_str())));
127 command.stdout(Stdio::piped()).stderr(Stdio::piped());
128 // A group of its own, so stopping it reaches what it started.
129 #[cfg(unix)]
130 std::os::unix::process::CommandExt::process_group(&mut command, 0);
131 command.spawn()
132}
133
134/// Unregisters the runner whose configuration is in `dir`, and forgets it.
135pub fn forget(binary: &Path, dir: &Path) {
136 if binary.exists() && dir.join("config.json").exists() {
137 let _ = quiet(binary).arg("remove").arg("--dir").arg(dir).stdout(Stdio::null()).stderr(Stdio::null()).status();
138 }
139 let _ = std::fs::remove_file(dir.join("config.json"));
140}
141
142/// Stops the runner and what it started: the job it ran in host mode
143/// included. A job cut short this way fails on g1t.
144pub fn stop(child: &mut Child) {
145 let pid = child.id().to_string();
146 if cfg!(windows) {
147 let _ = quiet("taskkill").args(["/T", "/F", "/PID", &pid]).stdout(Stdio::null()).stderr(Stdio::null()).status();
148 } else {
149 let _ = quiet("kill").args(["-TERM", &format!("-{pid}")]).stdout(Stdio::null()).stderr(Stdio::null()).status();
150 for _ in 0..40 {
151 if matches!(child.try_wait(), Ok(Some(_))) {
152 return;
153 }
154 std::thread::sleep(Duration::from_millis(250));
155 }
156 }
157 let _ = child.kill();
158 let _ = child.wait();
159}
160
161/// Removes the containers, networks and volumes of the jobs of the runner
162/// registered in `dir`, as `g1t-runner` does itself when it starts: each
163/// carries the label `sh.g1t.runner=<its id>`.
164pub fn sweep(dir: &Path) {
165 #[derive(Deserialize)]
166 struct Registered {
167 runner: String,
168 }
169 let Some(registered) = std::fs::read(dir.join("config.json")).ok().and_then(|bytes| serde_json::from_slice::<Registered>(&bytes).ok()) else { return };
170 let label = format!("label=sh.g1t.runner={}", registered.runner);
171 for (list, remove) in [
172 (vec!["ps", "-aq", "--filter", &label], vec!["rm", "-f", "-v"]),
173 (vec!["network", "ls", "-q", "--filter", &label], vec!["network", "rm"]),
174 (vec!["volume", "ls", "-q", "--filter", &label], vec!["volume", "rm", "-f"]),
175 ] {
176 let Ok(out) = quiet("docker").args(&list).stderr(Stdio::null()).output() else { return };
177 let ids: Vec<String> = String::from_utf8_lossy(&out.stdout).split_whitespace().map(str::to_owned).collect();
178 if !ids.is_empty() {
179 let _ = quiet("docker").args(&remove).args(&ids).stdout(Stdio::null()).stderr(Stdio::null()).status();
180 }
181 }
182}
183
184/// A runner the app started before and did not stop (it was ended
185/// without quitting): stopped, if that process is still `g1t-runner`.
186pub fn reap(pid: u32) {
187 let pid = pid.to_string();
188 if cfg!(windows) {
189 let _ = quiet("taskkill")
190 .args(["/T", "/F", "/FI", "IMAGENAME eq g1t-runner.exe", "/PID", &pid])
191 .stdout(Stdio::null())
192 .stderr(Stdio::null())
193 .status();
194 } else {
195 let named = quiet("ps").args(["-p", &pid, "-o", "comm="]).output().is_ok_and(|out| String::from_utf8_lossy(&out.stdout).trim().ends_with("g1t-runner"));
196 if named {
197 let _ = quiet("kill").args(["-TERM", &pid]).status();
198 }
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn releases_are_named_and_compared_as_the_runner_does() {
208 assert!(["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "windows-x64", "windows-arm64"].contains(&platform().as_str()));
209 assert!(newer("0.10.0", "0.9.9"));
210 assert!(!newer("0.2.0", "0.2.0"));
211 assert_eq!(sha256(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
212 let manifest: Manifest = serde_json::from_str(r#"{"version":"0.2.3","agent_image":null,"files":{"windows-x64":{"name":"g1t-runner-windows-x64.exe","sha256":"ab"}}}"#).unwrap();
213 assert_eq!(manifest.files["windows-x64"].name, "g1t-runner-windows-x64.exe");
214 assert_eq!(downloads(&Url::parse("https://g1t.sh/").unwrap()), "https://g1t.sh/downloads/runner");
215 }
216
217 #[test]
218 fn nothing_is_installed_in_an_empty_folder() {
219 let dir = std::env::temp_dir().join(format!("g1t-desktop-runner-{}", std::process::id()));
220 assert_eq!(installed(&dir), None);
221 assert!(binary(&dir).ends_with(if cfg!(windows) { "bin/g1t-runner.exe" } else { "bin/g1t-runner" }));
222 }
223}

This file's history is long; its oldest lines are credited to the oldest commit read.