Skip to content
223 linesCodeBlameRaw
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}