Skip to content
1,571 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//! The runner in the app: g1t's self-hosted runner, started for the
2//! signed-in person when g1t opens and stopped when it quits, so their
3//! computer runs their workspace's `runs-on: self-hosted` jobs with
4//! nothing to install, register or start by hand
5//! (docs.g1t.sh/guides/desktop/#the-runner).
6//!
7//! - **When.** On by default; settings turn it off ("Run jobs on this
8//! computer while g1t is open"), and Stop in settings or the tray stops
9//! it until Start, or until g1t starts again. It starts a few seconds
10//! after the window, never before.
11//! - **How.** The backend settings choose (backend.rs): Sandboxed, the
12//! default, where nothing a job does reaches this computer; Docker; or
13//! directly on this computer. The two that are not sandboxes are only
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.14//! ever chosen by the person, and nothing falls back to them. Sandboxed
15//! is `g1t-runner sandbox` (launcher.rs) on every OS: `up` a few seconds
16//! after the window, `status` every few seconds while it runs, `down`
17//! as g1t quits, and `down --drain` when another backend is chosen.
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.18//! - **Registering.** The first time, and when the workspace or backend
19//! changes, the app needs a registration token for the workspace. Only
20//! the site can make one, for an owner who is signed in: the app asks
21//! the site's page (`g1t-runner-wanted`), the page asks the server
22//! (`POST /-/desktop/runner-token`) and hands the token to the app
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.23//! (`runner_register` in bridge.rs), which gives it to `g1t-runner`
24//! once: in its environment, or to the sandbox's first `up`. It is never
25//! shown or written to the log. The registration is kept
26//! (registration.json and the runner's own folder; the sandbox keeps its
27//! own, and sandbox-registration.json says whose it is) and used on
28//! every later start.
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.29//! - **Up to date.** Soon after it starts and every six hours, while the
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.30//! runner is idle, the app looks for a newer release and puts it in
31//! place, and has the sandbox take the newest image (`sandbox update`):
32//! never in the middle of a job.
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.33//! - **Seen.** What it is doing, and when it is not running why and how to
34//! fix it (diagnosis.rs), is in the tray, in settings, and on the site's
35//! Runners page (`g1t-runner-status`), read from what its log says
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.36//! (log.rs), or for the sandbox from its status. The whole log is kept
37//! in `runner/runner.log`.
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.38//!
39//! All of it runs on a thread of its own, which owns the runner's process
40//! and is told what happened through one channel.
41
42mod backend;
43pub mod diagnosis;
44mod launcher;
45mod log;
46mod native;
47
48use std::collections::VecDeque;
49use std::fs::{File, OpenOptions};
50use std::io::{BufRead, BufReader, Read, Write};
51use std::path::PathBuf;
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.52use std::process::{Child, Stdio};
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.53use std::sync::Mutex;
54use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
55use std::time::{Duration, Instant};
56
57use serde::{Deserialize, Serialize};
58use tauri::{AppHandle, Emitter, EventTarget, Manager};
59use url::Url;
60
61pub use backend::Backend;
62use backend::{Engine, Missing};
63use diagnosis::{Code, Diagnosis, Facts};
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.64use launcher::{Down, State, Status};
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.65
66use crate::{config, frame, settings, tray};
67
68/// What settings, the tray and the site listen for: the runner's status changed.
69pub const STATUS_EVENT: &str = "g1t-runner-status";
70/// What the site's page listens for: the app needs a registration token for a workspace.
71pub const WANTED_EVENT: &str = "g1t-runner-wanted";
72
73/// How long after the window the runner starts.
74const AFTER_START: Duration = Duration::from_secs(4);
75/// How often the thread looks at the runner when nothing has happened.
76const TICK: Duration = Duration::from_secs(5);
77/// How long after starting the first look for an update is, then how often.
78const FIRST_UPDATE: Duration = Duration::from_secs(90);
79const UPDATE_EVERY: Duration = Duration::from_secs(6 * 60 * 60);
80/// How often a backend that cannot start is looked at again.
81const LOOK_AGAIN: Duration = Duration::from_secs(30);
82/// How long the app waits for the site to hand over a token before asking again.
83const ASK_AGAIN: Duration = Duration::from_secs(60);
84/// The longest the app waits before starting a runner that stopped by itself again.
85const MOST_BACKOFF: Duration = Duration::from_secs(10 * 60);
86/// The runner says when a poll fails, and nothing when one works: this
87/// long without another failure, it is back (its longest wait is 60s).
88const BACK_ONLINE: Duration = Duration::from_secs(75);
89/// The runner's log is started over when it is bigger than this.
90const LOG_LIMIT: u64 = 5 * 1024 * 1024;
91/// The last lines of the log settings show.
92const TAIL: usize = 40;
93
94/// A workspace the site says the person may add runners to: those they own.
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
96pub struct Offered {
97 pub slug: String,
98 #[serde(default)]
99 pub name: Option<String>,
100}
101
102/// What the runner is doing, as the thread last said.
103#[derive(Clone, Debug, Default, PartialEq)]
104struct Now {
105 code: Code,
106 detail: Option<String>,
107 exit_code: Option<i32>,
108 wait_seconds: Option<u64>,
109 job: Option<String>,
110 version: Option<String>,
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.111 /// What the sandbox says to do, for a cause in its own words.
112 fix: Option<String>,
113 /// How the sandbox is, while jobs run sandboxed.
114 sandbox: Option<Status>,
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.115}
116
117/// What the site last said about the person.
118#[derive(Clone, Debug, Default, PartialEq)]
119struct Site {
120 /// Unknown until the site's first page loads.
121 signed_in: Option<bool>,
122 owned: Vec<Offered>,
123 current: Option<String>,
124}
125
126/// What the rest of the app shares with the thread.
127#[derive(Default)]
128pub struct Shared {
129 now: Mutex<Now>,
130 /// The workspace a token is wanted for.
131 wanted: Mutex<Option<String>>,
132 site: Mutex<Site>,
133 /// Stopped by the person until Start, or until g1t starts again.
134 paused: Mutex<bool>,
135 tail: Mutex<VecDeque<String>>,
136 tx: Mutex<Option<Sender<Ask>>>,
137}
138
139/// What settings, the tray and the site are shown.
140#[derive(Clone, Debug, PartialEq, Serialize)]
141#[serde(rename_all = "camelCase")]
142pub struct View {
143 pub diagnosis: Diagnosis,
144 /// What the site says of this computer once g1t is closed on it.
145 pub closed: Diagnosis,
146 pub running: bool,
147 pub version: Option<String>,
148 pub backend: Backend,
149 pub workspace: Option<String>,
150 /// The workspaces it may run for, from the site.
151 pub workspaces: Vec<Offered>,
152 /// The setting: run jobs while g1t is open.
153 pub enabled: bool,
154 pub paused: bool,
155 /// The workspace's Runners page.
156 pub link: Option<String>,
157 /// This computer's name, which the runner has on g1t.
158 pub machine: String,
159 pub platform: String,
160 /// The last lines of the runner's log.
161 pub log: Vec<String>,
162}
163
164/// What the thread is told.
165enum Ask {
166 /// Something changed: settings, Start or Stop, Retry.
167 Look,
168 Retry,
169 Token { workspace: String, token: String },
170 Refused { workspace: String, allowed: bool, why: String },
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.171 /// A line of the runner's output, or the launcher's; true from its standard output.
172 Line(u64, String, bool),
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.173 Exited(u64),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.174 /// What `sandbox status` said.
175 Sandbox(u64, Result<Box<Status>, String>),
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.176 Quit(Sender<()>),
177}
178
179/// The registration the app made, besides what the runner keeps itself.
180#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
181struct Registration {
182 server: String,
183 workspace: String,
184 backend: Backend,
185}
186
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.187/// Whose the sandbox's registration is. The sandbox keeps its own, and
188/// keeps it while another backend runs, so choosing Sandboxed again needs
189/// no new one; this says which workspace it is for.
190#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
191struct SandboxRegistration {
192 server: String,
193 workspace: String,
194 /// False from the `up` that was given a token until it registered.
195 registered: bool,
196}
197
198impl SandboxRegistration {
199 fn path(app: &AppHandle) -> PathBuf {
200 folder(app).join("sandbox-registration.json")
201 }
202 fn load(app: &AppHandle) -> Option<SandboxRegistration> {
203 std::fs::read(Self::path(app)).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok())
204 }
205 fn save(&self, app: &AppHandle) {
206 let _ = std::fs::create_dir_all(folder(app));
207 let _ = std::fs::write(Self::path(app), serde_json::to_vec_pretty(self).unwrap_or_default());
208 }
209 fn clear(app: &AppHandle) {
210 let _ = std::fs::remove_file(Self::path(app));
211 }
212}
213
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.214fn folder(app: &AppHandle) -> PathBuf {
215 app.path().app_data_dir().unwrap_or_else(|_| std::env::temp_dir().join("sh.g1t.desktop")).join("runner")
216}
217
218impl Registration {
219 fn path(app: &AppHandle) -> PathBuf {
220 folder(app).join("registration.json")
221 }
222 fn load(app: &AppHandle) -> Option<Registration> {
223 std::fs::read(Self::path(app)).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok())
224 }
225 fn save(&self, app: &AppHandle) {
226 let _ = std::fs::create_dir_all(folder(app));
227 let _ = std::fs::write(Self::path(app), serde_json::to_vec_pretty(self).unwrap_or_default());
228 }
229 fn clear(app: &AppHandle) {
230 let _ = std::fs::remove_file(Self::path(app));
231 }
232}
233
234/// Where a native runner of a backend keeps its configuration.
235fn native_dir(app: &AppHandle, backend: Backend) -> PathBuf {
236 folder(app).join(match backend {
237 Backend::Docker => "docker",
238 Backend::Host => "host",
239 Backend::Sandboxed => launcher::FOLDER,
240 })
241}
242
243/// The workspace the runner is for: the one settings name, else the one it
244/// is registered with, else the one the person is in on the site, else the
245/// first they own.
246fn workspace(app: &AppHandle) -> Option<String> {
247 let settings = config::settings(app);
248 if let Some(chosen) = settings.runner_workspace.filter(|w| !w.is_empty()) {
249 return Some(chosen);
250 }
251 let server = config::server(app).to_string();
252 if let Some(registration) = Registration::load(app).filter(|r| r.server == server) {
253 return Some(registration.workspace);
254 }
255 let site = app.state::<Shared>().site.lock().unwrap().clone();
256 site.current.filter(|slug| site.owned.iter().any(|o| &o.slug == slug)).or_else(|| site.owned.first().map(|o| o.slug.clone()))
257}
258
259fn facts(app: &AppHandle, now: &Now) -> Facts {
260 let server = config::server(app);
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.261 let backend = config::settings(app).runner_backend;
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.262 Facts {
263 workspace: workspace(app),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.264 backend: backend.label(),
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.265 machine: backend::machine_name(),
266 job: now.job.clone(),
267 detail: now.detail.clone(),
268 exit_code: now.exit_code,
269 wait_seconds: now.wait_seconds,
270 server: server.host_str().unwrap_or("g1t").to_owned(),
271 platform: std::env::consts::OS.into(),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.272 fix: now.fix.clone(),
273 sandbox: now.sandbox.clone().filter(|_| backend == Backend::Sandboxed),
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.274 }
275}
276
277pub fn view(app: &AppHandle) -> View {
278 let shared = app.state::<Shared>();
279 let now = shared.now.lock().unwrap().clone();
280 let settings = config::settings(app);
281 let facts = facts(app, &now);
282 let server = config::server(app);
283 View {
284 diagnosis: diagnosis::diagnose(now.code, &facts),
285 closed: diagnosis::closed(&facts),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.286 running: matches!(now.code, Code::Idle | Code::Busy | Code::Offline) || facts.sandbox.as_ref().is_some_and(Status::running),
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.287 version: now.version,
288 backend: settings.runner_backend,
289 link: facts.workspace.as_ref().and_then(|w| server.join(&format!("{w}/-/runners")).ok()).map(|u| u.to_string()),
290 workspace: facts.workspace,
291 workspaces: shared.site.lock().unwrap().owned.clone(),
292 enabled: settings.runner,
293 paused: *shared.paused.lock().unwrap(),
294 machine: facts.machine,
295 platform: facts.platform,
296 log: shared.tail.lock().unwrap().iter().cloned().collect(),
297 }
298}
299
300/// The tray's line: `Runner: Waiting for jobs`, at most 60 characters.
301pub fn headline(view: &View) -> String {
302 let line = format!("Runner: {}", view.diagnosis.title);
303 if line.chars().count() > 60 { line.chars().take(59).collect::<String>() + "…" } else { line }
304}
305
306/// Tells everyone what the runner is doing now.
307fn publish(app: &AppHandle) {
308 let view = view(app);
309 tray::runner_changed(app, &view);
310 let _ = app.emit_to(settings::SETTINGS, STATUS_EVENT, view.clone());
311 let _ = app.emit_to(EventTarget::webview(frame::SITE), STATUS_EVENT, view);
312}
313
314fn ask(app: &AppHandle, ask: Ask) {
315 if let Some(tx) = app.state::<Shared>().tx.lock().unwrap().as_ref() {
316 let _ = tx.send(ask);
317 }
318}
319
320/// Starts the thread that keeps the runner, as the app starts.
321pub fn install(app: &AppHandle) {
322 let (tx, rx) = mpsc::channel();
323 *app.state::<Shared>().tx.lock().unwrap() = Some(tx.clone());
324 let handle = app.clone();
325 let spawned = std::thread::Builder::new().name("g1t-runner".into()).spawn(move || Keeper::new(handle, tx).keep(rx));
326 if let Err(error) = spawned {
327 eprintln!("g1t: the runner's thread could not start: {error}");
328 }
329}
330
331/// Settings changed: the thread looks again.
332pub fn settings_changed(app: &AppHandle) {
333 ask(app, Ask::Look);
334 publish(app);
335}
336
337/// Start or Stop, from settings or the tray.
338pub fn set_paused(app: &AppHandle, paused: bool) {
339 *app.state::<Shared>().paused.lock().unwrap() = paused;
340 // Start with the setting off turns the setting on: that is what was asked.
341 if !paused && !config::settings(app).runner {
342 let mut settings = config::settings(app);
343 settings.runner = true;
344 let _ = settings::apply(app, settings);
345 }
346 settings_changed(app);
347}
348
349/// The tray's Start or Stop row.
350pub fn toggle(app: &AppHandle) {
351 let view = view(app);
352 set_paused(app, view.enabled && !view.paused);
353}
354
355/// Retry and Restart: now, rather than when the wait is over.
356pub fn retry(app: &AppHandle) {
357 *app.state::<Shared>().paused.lock().unwrap() = false;
358 ask(app, Ask::Retry);
359}
360
361/// What the site says about the person, on every page: whether they are
362/// signed in, the workspaces they own, and the one they are in.
363pub fn offer(app: &AppHandle, signed_in: bool, owned: Vec<Offered>, current: Option<String>) {
364 let next = Site { signed_in: Some(signed_in), owned, current: current.map(|c| c.to_ascii_lowercase()) };
365 let changed = {
366 let shared = app.state::<Shared>();
367 let mut site = shared.site.lock().unwrap();
368 let changed = *site != next;
369 *site = next;
370 changed
371 };
372 if changed {
373 settings_changed(app);
374 }
375}
376
377/// The site handed over a registration token, or said why it could not:
378/// `allowed` is false when the person may not add runners to the workspace.
379pub fn register(app: &AppHandle, workspace: String, token: Option<String>, allowed: bool, error: Option<String>) {
380 match token {
381 Some(token) if token.starts_with("g1trt_") => ask(app, Ask::Token { workspace, token }),
382 _ => ask(app, Ask::Refused { workspace, allowed, why: error.unwrap_or_else(|| "g1t didn't make a registration token.".into()) }),
383 }
384}
385
386/// A diagnosis's button, or Start and Stop: from the settings window, or
387/// from the site's card for this computer.
388pub fn act(app: &AppHandle, action: &str) -> Result<(), String> {
389 use tauri_plugin_opener::OpenerExt;
390 let browse = |url: &str| app.opener().open_url(url, None::<&str>).map_err(|e| e.to_string());
391 match action {
392 "start" => {
393 set_paused(app, false);
394 retry(app);
395 }
396 "stop" => set_paused(app, true),
397 "retry" | "restart" => retry(app),
398 "sign_in" => crate::links::go(app, "/login"),
399 "new_workspace" => crate::links::go(app, "/workspaces/new"),
400 "view_runners" => match workspace(app) {
401 Some(workspace) => crate::links::go(app, &format!("/{workspace}/-/runners")),
402 None => crate::links::go(app, "/"),
403 },
404 "choose_workspace" | "choose_backend" => settings::open(app),
405 "view_log" => {
406 let path = log_file(app);
407 if !path.exists() {
408 return Err("The runner hasn't written a log yet.".into());
409 }
410 app.opener().open_path(path.to_string_lossy(), None::<&str>).map_err(|e| e.to_string())?;
411 }
412 "open_docker" => open_docker(app)?,
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.413 "copy_wsl_install" => copy(launcher::WSL_INSTALL)?,
414 "copy_vfkit_install" => copy(launcher::VFKIT_INSTALL)?,
415 "windows_features" => windows_features()?,
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.416 "get_docker" => browse("https://docs.docker.com/get-started/get-docker/")?,
417 "install_sysbox" => browse("https://github.com/nestybox/sysbox#installation")?,
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.418 "learn_more" => browse("https://docs.g1t.sh/guides/desktop/#how-jobs-run")?,
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.419 "report" => browse(config::server(app).join("support").map_err(|e| e.to_string())?.as_str())?,
420 other => return Err(format!("the runner has no action called {other}")),
421 }
422 Ok(())
423}
424
425/// Starts Docker Desktop, where it is installed in its usual place.
426fn open_docker(app: &AppHandle) -> Result<(), String> {
427 use tauri_plugin_opener::OpenerExt;
428 if cfg!(target_os = "macos") {
429 return app.opener().open_path("/Applications/Docker.app", None::<&str>).map_err(|e| e.to_string());
430 }
431 if cfg!(windows) {
432 let program = std::env::var_os("ProgramFiles").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(r"C:\Program Files"));
433 let desktop = program.join("Docker").join("Docker").join("Docker Desktop.exe");
434 if desktop.exists() {
435 return app.opener().open_path(desktop.to_string_lossy(), None::<&str>).map_err(|e| e.to_string());
436 }
437 return Err("Docker Desktop isn't installed in its usual place. Get Docker to install it.".into());
438 }
439 Err("Start Docker with your system's service manager, such as `sudo systemctl start docker`.".into())
440}
441
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.442/// Puts a command on the clipboard, for the person to paste where it runs.
443fn copy(text: &str) -> Result<(), String> {
444 let tools: &[(&str, &[&str])] = if cfg!(windows) {
445 &[("clip", &[])]
446 } else if cfg!(target_os = "macos") {
447 &[("pbcopy", &[])]
448 } else {
449 &[("wl-copy", &[]), ("xclip", &["-selection", "clipboard"]), ("xsel", &["--clipboard", "--input"])]
450 };
451 for (program, args) in tools {
452 let Ok(mut child) = backend::quiet(program).args(*args).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else { continue };
453 if let Some(mut input) = child.stdin.take() {
454 let _ = input.write_all(text.as_bytes());
455 }
456 if child.wait().is_ok_and(|status| status.success()) {
457 return Ok(());
458 }
459 }
460 Err(format!("It couldn't be copied. The command is: {text}"))
461}
462
463/// Windows' "Turn Windows features on or off", where Windows Sandbox and
464/// Virtual Machine Platform are.
465fn windows_features() -> Result<(), String> {
466 if !cfg!(windows) {
467 return Err("Windows features are only on Windows.".into());
468 }
469 backend::quiet("optionalfeatures.exe").spawn().map(|_| ()).map_err(|e| format!("Windows features couldn't be opened: {e}"))
470}
471
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.472/// The workspace a token is wanted for now, if one is: the site asks as it loads.
473pub fn wanted(app: &AppHandle) -> Option<String> {
474 app.state::<Shared>().wanted.lock().unwrap().clone()
475}
476
477/// The runner's log, for the settings window to open.
478pub fn log_file(app: &AppHandle) -> PathBuf {
479 folder(app).join("runner.log")
480}
481
482/// Stops the runner as g1t quits or restarts, and waits for it.
483pub fn shutdown(app: &AppHandle) {
484 let tx = app.state::<Shared>().tx.lock().unwrap().take();
485 if let Some(tx) = tx {
486 let (done, wait) = mpsc::channel();
487 if tx.send(Ask::Quit(done)).is_ok() {
488 let _ = wait.recv_timeout(Duration::from_secs(30));
489 }
490 }
491}
492
493/// How the runner is started this time.
494enum Process {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.495 /// `g1t-runner`, a child of the app, whose output is the runner's log.
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.496 Child(Child),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.497 /// The sandbox, which runs on its own once `up` is done; the launcher
498 /// command the app is waiting for, while there is one.
499 Sandbox(Option<(Step, Child)>),
500}
501
502/// A launcher command that takes a while.
503#[derive(Clone, Copy, Debug, PartialEq, Eq)]
504enum Step {
505 Up,
506 Update,
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.507}
508
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.509impl Step {
510 fn name(self) -> &'static str {
511 match self {
512 Step::Up => "up",
513 Step::Update => "update",
514 }
515 }
516}
517
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.518struct Run {
519 generation: u64,
520 engine: Engine,
521 backend: Backend,
522 workspace: String,
523 process: Process,
524 /// Registered and polling.
525 listening: bool,
526 busy: bool,
527 /// When a poll last failed to reach g1t.
528 offline_at: Option<Instant>,
529 /// Started with a registration token, which a container keeps in its configuration until replaced.
530 with_token: bool,
531 forgotten: bool,
532 token_refused: bool,
533 error: Option<String>,
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.534 /// The status a launcher command is printing, line by line.
535 json: Option<Vec<String>>,
536 /// Asking the sandbox how it is, and when it was last asked.
537 polling: bool,
538 polled_at: Option<Instant>,
539 /// A poll asked before a launcher command ended, whose answer is old.
540 stale: bool,
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.541}
542
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.543impl Run {
544 fn new(generation: u64, engine: Engine, backend: Backend, workspace: String, process: Process, with_token: bool) -> Run {
545 Run {
546 generation,
547 engine,
548 backend,
549 workspace,
550 process,
551 listening: false,
552 busy: false,
553 offline_at: None,
554 with_token,
555 forgotten: false,
556 token_refused: false,
557 error: None,
558 json: None,
559 polling: false,
560 polled_at: None,
561 stale: false,
562 }
563 }
564
565 fn step(&self) -> Option<Step> {
566 match &self.process {
567 Process::Sandbox(Some((step, _))) => Some(*step),
568 _ => None,
569 }
570 }
571}
572
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.573struct Keeper {
574 app: AppHandle,
575 tx: Sender<Ask>,
576 run: Option<Run>,
577 generation: u64,
578 token: Option<(String, String)>,
579 not_before: Instant,
580 failures: u32,
581 next_update: Instant,
582 log: Option<File>,
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.583 /// The runner was updated: the sandbox takes its image before it starts again.
584 image_pending: bool,
585 /// Why the last update failed, said until the next one works.
586 update_failed: Option<String>,
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.587}
588
589/// What a set of the state carries besides its code.
590#[derive(Default)]
591struct Extra {
592 detail: Option<String>,
593 exit_code: Option<i32>,
594 wait_seconds: Option<u64>,
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.595 fix: Option<String>,
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.596}
597
598fn detail(text: impl Into<String>) -> Extra {
599 Extra { detail: Some(text.into()), ..Extra::default() }
600}
601
602impl Keeper {
603 fn new(app: AppHandle, tx: Sender<Ask>) -> Keeper {
604 let now = Instant::now();
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.605 Keeper { app, tx, run: None, generation: 0, token: None, not_before: now + AFTER_START, failures: 0, next_update: now + FIRST_UPDATE, log: None, image_pending: false, update_failed: None }
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.606 }
607
608 fn keep(mut self, rx: Receiver<Ask>) {
609 self.reap();
610 self.set(Code::Starting, detail("Starting after g1t's window."));
611 loop {
612 match rx.recv_timeout(TICK) {
613 Ok(Ask::Quit(done)) => {
614 self.halt();
615 let _ = done.send(());
616 return;
617 }
618 Ok(ask) => self.handle(ask),
619 Err(RecvTimeoutError::Timeout) => {}
620 Err(RecvTimeoutError::Disconnected) => {
621 self.halt();
622 return;
623 }
624 }
625 self.look();
626 }
627 }
628
629 fn set(&mut self, code: Code, extra: Extra) {
630 let changed = {
631 let shared = self.app.state::<Shared>();
632 let mut now = shared.now.lock().unwrap();
633 let job = if code == Code::Busy { now.job.clone() } else { None };
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.634 let next = Now {
635 code,
636 detail: extra.detail,
637 exit_code: extra.exit_code,
638 wait_seconds: extra.wait_seconds,
639 job,
640 version: now.version.clone(),
641 fix: extra.fix,
642 sandbox: now.sandbox.clone(),
643 };
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.644 let changed = *now != next;
645 *now = next;
646 changed
647 };
648 if changed {
649 publish(&self.app);
650 }
651 }
652
653 fn code(&self) -> Code {
654 self.app.state::<Shared>().now.lock().unwrap().code
655 }
656
657 fn note(&mut self, job: Option<String>, version: Option<String>) {
658 let shared = self.app.state::<Shared>();
659 let mut now = shared.now.lock().unwrap();
660 if job.is_some() {
661 now.job = job;
662 }
663 if version.is_some() {
664 now.version = version;
665 }
666 }
667
668 /// A line of the runner's log, into the log file and the tail settings show.
669 fn write_log(&mut self, line: &str) {
670 {
671 let shared = self.app.state::<Shared>();
672 let mut tail = shared.tail.lock().unwrap();
673 if tail.len() == TAIL {
674 tail.pop_front();
675 }
676 tail.push_back(line.to_owned());
677 }
678 let path = log_file(&self.app);
679 if self.log.is_none() {
680 let _ = std::fs::create_dir_all(folder(&self.app));
681 if std::fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) {
682 let _ = std::fs::rename(&path, path.with_extension("log.old"));
683 }
684 self.log = OpenOptions::new().create(true).append(true).open(&path).ok();
685 }
686 if let Some(file) = self.log.as_mut() {
687 let _ = writeln!(file, "{line}");
688 }
689 }
690
691 fn handle(&mut self, ask: Ask) {
692 match ask {
693 Ask::Look | Ask::Quit(_) => {}
694 Ask::Retry => {
695 self.not_before = Instant::now();
696 self.failures = 0;
697 match self.code() {
698 // The runner is fine: only the update is tried again.
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.699 Code::UpdateFailed => {
700 self.next_update = Instant::now();
701 self.update_failed = None;
702 }
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.703 Code::Offline => self.halt(),
704 _ if self.run.as_ref().is_some_and(|run| !run.listening) => self.halt(),
705 _ => {}
706 }
707 }
708 Ask::Token { workspace, token } => {
709 let shared = self.app.state::<Shared>();
710 let mut wanted = shared.wanted.lock().unwrap();
711 if wanted.as_deref() == Some(workspace.as_str()) {
712 *wanted = None;
713 self.token = Some((workspace, token));
714 self.not_before = Instant::now();
715 }
716 }
717 Ask::Refused { workspace, allowed, why } => {
718 let matched = {
719 let shared = self.app.state::<Shared>();
720 let mut wanted = shared.wanted.lock().unwrap();
721 let matched = wanted.as_deref() == Some(workspace.as_str());
722 if matched {
723 *wanted = None;
724 }
725 matched
726 };
727 if matched {
728 self.not_before = Instant::now() + MOST_BACKOFF;
729 self.set(if allowed { Code::RegisterFailed } else { Code::NotAllowed }, detail(why));
730 }
731 }
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.732 Ask::Line(generation, line, out) => self.line(generation, &line, out),
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.733 Ask::Exited(generation) => self.exited(generation),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.734 Ask::Sandbox(generation, status) => self.polled(generation, status),
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.735 }
736 }
737
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.738 fn line(&mut self, generation: u64, line: &str, out: bool) {
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.739 let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return };
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.740 if run.engine == Engine::Launcher {
741 // The launcher's log, then its status, which is read when it ends.
742 if out && (run.json.is_some() || launcher::starts_status(line)) {
743 run.json.get_or_insert_with(Vec::new).push(line.to_owned());
744 } else {
745 self.write_log(line);
746 }
747 return;
748 }
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.749 let mut became = None;
750 let mut job = None;
751 let mut version = None;
752 match log::read(line) {
753 log::Event::Listening { version: v } => {
754 run.listening = true;
755 version = Some(v);
756 became = Some(Code::Idle);
757 }
758 log::Event::Took { name, repo } => {
759 run.busy = true;
760 run.offline_at = None;
761 job = Some(format!("{name} ({repo})"));
762 became = Some(Code::Busy);
763 }
764 log::Event::Ended => {
765 run.busy = false;
766 became = Some(Code::Idle);
767 }
768 log::Event::Offline => {
769 run.offline_at = Some(Instant::now());
770 if !run.busy {
771 became = Some(Code::Offline);
772 }
773 }
774 log::Event::Forgotten => run.forgotten = true,
775 log::Event::TokenRefused => run.token_refused = true,
776 log::Event::Failed(error) => run.error = Some(error),
777 log::Event::Other => {}
778 }
779 let registered = version.is_some().then(|| Registration {
780 server: config::server(&self.app).to_string(),
781 workspace: run.workspace.clone(),
782 backend: run.backend,
783 });
784 self.write_log(line);
785 if let Some(registration) = registered {
786 registration.save(&self.app);
787 self.failures = 0;
788 }
789 self.note(job, version);
790 if let Some(code) = became {
791 self.set(code, Extra::default());
792 }
793 }
794
795 fn exited(&mut self, generation: u64) {
796 if self.run.as_ref().is_none_or(|run| run.generation != generation) {
797 return;
798 }
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.799 if self.run.as_ref().is_some_and(|run| run.engine == Engine::Launcher) {
800 return self.launched(generation);
801 }
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.802 let mut run = self.run.take().expect("checked above");
803 let exit_code = match &mut run.process {
804 Process::Child(child) => child.wait().ok().and_then(|status| status.code()),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.805 Process::Sandbox(_) => None,
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.806 };
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.807 self.stop(&mut run, Down::Now);
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.808 if run.forgotten || run.token_refused {
809 // The registration is no more, or was never made: the next start asks for a token.
810 self.forget();
811 self.not_before = Instant::now();
812 return;
813 }
814 self.failures += 1;
815 let wait = Duration::from_secs(15 * 2u64.pow(self.failures.min(6))).min(MOST_BACKOFF);
816 self.not_before = Instant::now() + wait;
817 let offline = run.error.as_deref().is_some_and(|e| e.contains("could not reach g1t"));
818 if offline {
819 self.set(Code::Offline, Extra::default());
820 } else if !run.listening && Registration::load(&self.app).is_none() {
821 self.set(Code::RegisterFailed, Extra { detail: run.error, ..Extra::default() });
822 } else {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.823 self.set(Code::Crashed, Extra { detail: run.error, exit_code, wait_seconds: Some(wait.as_secs()), ..Extra::default() });
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.824 }
825 }
826
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.827 /// Stops a run's process, the way it was started: the sandbox `how` says.
828 fn stop(&mut self, run: &mut Run, how: Down) {
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.829 match (&mut run.process, run.engine) {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.830 (Process::Sandbox(step), _) => {
831 // A launcher command still going is ended first, then the sandbox stopped.
832 if let Some((_, child)) = step.as_mut() {
833 native::stop(child);
834 }
835 let binary = native::binary(&folder(&self.app));
836 if let Err(why) = launcher::down(&binary, &native_dir(&self.app, Backend::Sandboxed), how) {
837 self.write_log(&format!("g1t: the sandbox didn't stop cleanly: {why}"));
838 }
839 self.app.state::<Shared>().now.lock().unwrap().sandbox = None;
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.840 }
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.841 (Process::Child(child), Engine::Launcher) => native::stop(child),
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.842 (Process::Child(child), Engine::Native { docker }) => {
843 native::stop(child);
844 // A job's container outlives the runner killed under it: removed now, not at its next start.
845 if docker {
846 native::sweep(&native_dir(&self.app, run.backend));
847 }
848 }
849 }
850 let _ = std::fs::remove_file(folder(&self.app).join("pid"));
851 }
852
853 /// Stops whatever runs now.
854 fn halt(&mut self) {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.855 self.halt_as(Down::Now);
856 }
857
858 /// Stops whatever runs: the sandbox now, after its job, or for good.
859 fn halt_as(&mut self, how: Down) {
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.860 self.generation += 1;
861 if let Some(mut run) = self.run.take() {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.862 self.stop(&mut run, how);
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.863 }
864 }
865
866 /// A runner an earlier g1t started and did not stop: it ended without quitting.
867 fn reap(&self) {
868 if let Some(pid) = std::fs::read_to_string(folder(&self.app).join("pid")).ok().and_then(|text| text.trim().parse().ok()) {
869 native::reap(pid);
870 let _ = std::fs::remove_file(folder(&self.app).join("pid"));
871 }
872 }
873
874 /// Unregisters the runner the app registered, whatever backend it was.
875 fn forget(&mut self) {
876 let app = self.app.clone();
877 let Some(registration) = Registration::load(&app) else { return };
878 let binary = native::binary(&folder(&app));
879 match registration.backend {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.880 // The sandbox keeps its registration for when it is chosen
881 // again (sandbox-registration.json says whose): it only stops,
882 // after its job.
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.883 Backend::Sandboxed => {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.884 let _ = launcher::down(&binary, &native_dir(&app, Backend::Sandboxed), Down::Drain);
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.885 }
886 backend => native::forget(&binary, &native_dir(&app, backend)),
887 }
888 Registration::clear(&app);
889 }
890
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.891 /// Removes the sandbox and its registration: it was for another
892 /// workspace, or g1t no longer knows its runners.
893 fn forget_sandbox(&mut self) {
894 let binary = native::binary(&folder(&self.app));
895 if let Err(why) = launcher::down(&binary, &native_dir(&self.app, Backend::Sandboxed), Down::Delete) {
896 self.write_log(&format!("g1t: the sandbox couldn't be removed: {why}"));
897 }
898 SandboxRegistration::clear(&self.app);
899 if Registration::load(&self.app).is_some_and(|r| r.backend == Backend::Sandboxed) {
900 Registration::clear(&self.app);
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.901 }
902 }
903
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.904 /// Whether what a native runner keeps of a registration is still here.
905 fn kept(&self, backend: Backend) -> bool {
906 native_dir(&self.app, backend).join("config.json").exists()
907 }
908
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.909 /// Asks the site for a registration token for `workspace`.
910 fn want(&mut self, workspace: &str) {
911 *self.app.state::<Shared>().wanted.lock().unwrap() = Some(workspace.to_owned());
912 let _ = self.app.emit_to(EventTarget::webview(frame::SITE), WANTED_EVENT, workspace.to_owned());
913 self.not_before = Instant::now() + ASK_AGAIN;
914 let signed_out = self.app.state::<Shared>().site.lock().unwrap().signed_in == Some(false);
915 self.set(if signed_out { Code::SignedOut } else { Code::Registering }, Extra::default());
916 }
917
918 /// The thread's look round: what should run, and whether it does.
919 fn look(&mut self) {
920 let settings = config::settings(&self.app);
921 let paused = *self.app.state::<Shared>().paused.lock().unwrap();
922 if !settings.runner || paused {
923 if self.run.is_some() {
924 self.halt();
925 }
926 *self.app.state::<Shared>().wanted.lock().unwrap() = None;
927 self.set(if paused { Code::Stopped } else { Code::Off }, Extra::default());
928 return;
929 }
930 let workspace = workspace(&self.app);
931 if let Some(run) = &self.run {
932 let moved = run.backend != settings.runner_backend || Some(&run.workspace) != workspace.as_ref();
933 let back = run.offline_at.is_some_and(|at| at.elapsed() >= BACK_ONLINE);
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.934 let (busy, listening, stepping) = (run.busy, run.listening, run.step().is_some());
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.935 if back {
936 if let Some(run) = self.run.as_mut() {
937 run.offline_at = None;
938 }
939 self.set(Code::Idle, Extra::default());
940 }
941 // A change of backend or workspace waits for the job to end, as an update does.
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.942 if moved && !busy && !stepping {
943 self.halt_as(Down::Drain);
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.944 self.not_before = Instant::now();
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.945 } else if !busy && listening && !stepping && Instant::now() >= self.next_update {
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.946 self.update();
947 }
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.948 self.poll();
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.949 return;
950 }
951 if Instant::now() < self.not_before {
952 return;
953 }
954 self.start(settings.runner_backend, workspace);
955 }
956
957 fn start(&mut self, backend: Backend, workspace: Option<String>) {
958 let app = self.app.clone();
959 let engine = match backend::engine(backend) {
960 Ok(engine) => engine,
961 Err(missing) => {
962 self.not_before = Instant::now() + LOOK_AGAIN;
963 let code = match missing {
964 Missing::Docker => Code::NoDocker,
965 };
966 self.set(code, Extra::default());
967 return;
968 }
969 };
970 let Some(workspace) = workspace else {
971 self.not_before = Instant::now() + LOOK_AGAIN;
972 let code = match app.state::<Shared>().site.lock().unwrap().signed_in {
973 Some(false) => Code::SignedOut,
974 Some(true) => Code::NoWorkspace,
975 None => Code::Starting,
976 };
977 self.set(code, detail("Waiting for g1t to open."));
978 return;
979 };
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.980 if engine == Engine::Launcher {
981 return self.start_sandbox(workspace);
982 }
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.983 let server = config::server(&app);
984 let registration = Registration::load(&app);
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.985 let ours = registration.as_ref().is_some_and(|r| r.server == server.as_str() && r.workspace == workspace && r.backend == backend) && self.kept(backend);
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.986 let mut token = None;
987 if !ours {
988 if registration.is_some() {
989 self.set(Code::Starting, detail("Removing this computer's earlier registration."));
990 self.forget();
991 }
992 match self.token.take() {
993 Some((for_workspace, given)) if for_workspace == workspace => token = Some(given),
994 _ => return self.want(&workspace),
995 }
996 }
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.997 self.hold_workspace(&workspace);
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.998 self.set(Code::Starting, Extra::default());
999 let envs = environment(&server, backend, token.as_deref());
1000 let generation = self.generation + 1;
1001 self.generation = generation;
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1002 let started = self.ensure_binary(&server).and_then(|binary| {
1003 let child = native::spawn(&binary, &native_dir(&app, backend), &envs).map_err(|e| format!("g1t-runner could not start: {e}"))?;
1004 let _ = std::fs::write(folder(&app).join("pid"), child.id().to_string());
1005 Ok(child)
1006 });
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.1007 match started {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1008 Ok(mut child) => {
1009 self.follow(generation, &mut child);
1010 self.run = Some(Run::new(generation, engine, backend, workspace, Process::Child(child), token.is_some()));
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.1011 self.set(Code::Starting, Extra::default());
1012 }
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1013 Err(why) => self.not_started(workspace, token, why),
1014 }
1015 }
1016
1017 /// The runner couldn't be started: the token is kept for the next try,
1018 /// while it lasts, and it is tried again in a while.
1019 fn not_started(&mut self, workspace: String, token: Option<String>, why: String) {
1020 if let Some(given) = token {
1021 self.token = Some((workspace, given));
1022 }
1023 self.failures += 1;
1024 self.not_before = Instant::now() + LOOK_AGAIN;
1025 let offline = why.contains("error sending request") || why.contains("dns") || why.contains("connect");
1026 self.write_log(&format!("g1t: the runner could not start: {why}"));
1027 if offline {
1028 self.set(Code::Offline, Extra::default());
1029 } else {
1030 self.set(Code::Crashed, Extra { detail: Some(why), wait_seconds: Some(LOOK_AGAIN.as_secs()), ..Extra::default() });
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.1031 }
1032 }
1033
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1034 /// The setting names the workspace from now on, so browsing another one never moves the runner.
1035 fn hold_workspace(&self, workspace: &str) {
1036 if config::settings(&self.app).runner_workspace.as_deref() != Some(workspace) {
1037 let mut settings = config::settings(&self.app);
1038 settings.runner_workspace = Some(workspace.to_owned());
1039 let _ = config::set_settings(&self.app, settings);
1040 }
1041 }
1042
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.1043 /// Reads the child's output, a line at a time, onto the thread's channel.
1044 fn follow(&self, generation: u64, child: &mut Child) {
1045 fn pipe(tx: Sender<Ask>, generation: u64, from: impl Read + Send + 'static, last: bool) {
1046 std::thread::spawn(move || {
1047 for line in BufReader::new(from).lines().map_while(Result::ok) {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1048 if tx.send(Ask::Line(generation, line, last)).is_err() {
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.1049 return;
1050 }
1051 }
1052 if last {
1053 let _ = tx.send(Ask::Exited(generation));
1054 }
1055 });
1056 }
1057 if let Some(err) = child.stderr.take() {
1058 pipe(self.tx.clone(), generation, err, false);
1059 }
1060 if let Some(out) = child.stdout.take() {
1061 pipe(self.tx.clone(), generation, out, true);
1062 }
1063 }
1064
1065 /// `g1t-runner` installed, from the newest release when it is not.
1066 fn ensure_binary(&mut self, server: &Url) -> Result<PathBuf, String> {
1067 let folder = folder(&self.app);
1068 let binary = native::binary(&folder);
1069 if native::installed(&folder).is_some() {
1070 return Ok(binary);
1071 }
1072 self.set(Code::Updating, detail("Downloading g1t-runner."));
1073 let manifest = native::latest(server)?;
1074 let bytes = native::download(server, &manifest)?;
1075 native::put(&folder, &bytes, &manifest.version)?;
1076 self.note(None, Some(manifest.version));
1077 Ok(binary)
1078 }
1079
1080 /// A newer release or image, put in place while the runner is idle.
1081 /// The new one is downloaded and checked before the runner stops, so
1082 /// one that fails leaves it running as it was.
1083 fn update(&mut self) {
1084 self.next_update = Instant::now() + UPDATE_EVERY;
1085 if !config::settings(&self.app).auto_update {
1086 return;
1087 }
1088 let Some(engine) = self.run.as_ref().map(|run| run.engine) else { return };
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1089 let server = config::server(&self.app);
1090 let folder = folder(&self.app);
1091 let release = match self.newer_release(&server) {
1092 Ok(release) => release,
1093 Err(why) => {
1094 let why = format!("g1t-runner couldn't be updated: {why}");
1095 self.write_log(&format!("g1t: {why}"));
1096 self.update_failed = Some(why.clone());
1097 return self.set(Code::UpdateFailed, detail(why));
1098 }
1099 };
1100 let Some((version, bytes)) = release else {
1101 // The program is the newest; the sandbox's image may not be
1102 // (`update` does nothing when it is).
1103 if engine == Engine::Launcher {
1104 self.sandbox_step(Step::Update);
1105 }
1106 return;
1107 };
1108 self.set(Code::Updating, detail(format!("Updating g1t-runner to {version}.")));
1109 // The sandbox's runner for Windows jobs is this program too: it
1110 // stops, after its job, before the program is replaced.
1111 self.halt_as(Down::Drain);
1112 match native::put(&folder, &bytes, &version) {
1113 Ok(()) => {
1114 self.note(None, Some(version));
1115 self.update_failed = None;
1116 // The sandbox's image comes with each release.
1117 self.image_pending = engine == Engine::Launcher;
1118 }
1119 Err(why) => self.write_log(&format!("g1t: g1t-runner {version} couldn't be put in place: {why}")),
1120 }
1121 self.not_before = Instant::now();
1122 }
1123
1124 /// A release newer than the `g1t-runner` here, downloaded and checked
1125 /// against its SHA-256: its version and program. None when this one is
1126 /// the newest, or the releases can't be asked now.
1127 fn newer_release(&self, server: &Url) -> Result<Option<(String, Vec<u8>)>, String> {
1128 let Ok(manifest) = native::latest(server) else { return Ok(None) };
1129 if !native::installed(&folder(&self.app)).is_none_or(|have| native::newer(&manifest.version, &have)) {
1130 return Ok(None);
1131 }
1132 let bytes = native::download(server, &manifest).map_err(|why| format!("{} didn't download: {why}", manifest.version))?;
1133 Ok(Some((manifest.version, bytes)))
1134 }
1135
1136 // --- The sandbox (launcher.rs) ------------------------------------------------
1137
1138 /// Starts the sandbox for `workspace`: registered with it the first
1139 /// time, with a token from the site; from its own registration after.
1140 fn start_sandbox(&mut self, workspace: String) {
1141 let app = self.app.clone();
1142 let server = config::server(&app);
1143 let binary = match self.ensure_binary(&server) {
1144 Ok(binary) => binary,
1145 Err(why) => return self.not_started(workspace, None, why),
1146 };
1147 let dir = native_dir(&app, Backend::Sandboxed);
1148 let mut status = match launcher::status(&binary, &dir) {
1149 Ok(status) => status,
1150 Err(why) => return self.no_status(why),
1151 };
1152 self.seen(&status);
1153 // Nothing here can be sandboxed: why, and how to fix it; looked at again in a while.
1154 if !status.anything_available() {
1155 self.not_before = Instant::now() + LOOK_AGAIN;
1156 let (code, extra) = shown(&status);
1157 return self.set(code, extra);
1158 }
1159 // Another backend's registration goes; the sandbox's own is kept for its workspace.
1160 if Registration::load(&app).is_some_and(|r| r.backend != Backend::Sandboxed) {
1161 self.set(Code::Starting, detail("Removing this computer's earlier registration."));
1162 self.forget();
1163 }
1164 let kept = SandboxRegistration::load(&app);
1165 let ours = kept.as_ref().is_some_and(|k| k.server == server.as_str() && k.workspace == workspace);
1166 let made = status.capabilities.iter().any(|c| c.state != State::Absent) || status.state != State::Absent;
1167 if !ours && made && kept.is_none() {
1168 // Made by hand (`g1t-runner sandbox up`), and there is one per computer: it is the person's to remove.
1169 self.not_before = Instant::now() + LOOK_AGAIN;
1170 return self.set(Code::SandboxTaken, Extra::default());
1171 }
1172 if !ours && made {
1173 // The app's sandbox, registered with another workspace or server.
1174 self.set(Code::Starting, detail("Removing this computer's sandbox for another workspace."));
1175 self.forget_sandbox();
1176 status = match launcher::status(&binary, &dir) {
1177 Ok(status) => status,
1178 Err(why) => return self.no_status(why),
1179 };
1180 }
1181 let mut token = None;
1182 if !ours || !kept.as_ref().is_some_and(|k| k.registered) || status.unregistered() {
1183 match self.token.take() {
1184 Some((for_workspace, given)) if for_workspace == workspace => token = Some(given),
1185 _ => return self.want(&workspace),
1186 }
1187 SandboxRegistration { server: server.to_string(), workspace: workspace.clone(), registered: false }.save(&app);
1188 }
1189 self.hold_workspace(&workspace);
1190 let generation = self.generation + 1;
1191 self.generation = generation;
1192 self.run = Some(Run::new(generation, Engine::Launcher, Backend::Sandboxed, workspace.clone(), Process::Sandbox(None), token.is_some()));
1193 // A new release's image first, then the sandbox starts again.
1194 let step = if self.image_pending && status.linux().state != State::Absent { Step::Update } else { Step::Up };
1195 self.image_pending = false;
1196 let name = backend::machine_name();
1197 let url = server.as_str().trim_end_matches('/').to_owned();
1198 let up = launcher::Up { url: &url, name: &name, labels: "desktop", token: token.as_deref() };
1199 let args = match step {
1200 Step::Up => launcher::up_args(&dir, &up),
1201 Step::Update => launcher::update_args(&dir),
1202 };
A registration token never shows in the process list. The desktop app passed it to g1t-runner sandbox up as --token, which anyone on the computer can read while up runs; it goes in up's environment now, as G1T_RUNNER_TOKEN, which the launcher reads when --token is left out, as register-and-run already did.1203 if let Err(why) = self.spawn_step(step, args, up.token) {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1204 self.run = None;
1205 return self.not_started(workspace, token, why);
1206 }
1207 self.set(Code::Starting, detail(STARTING_SANDBOX));
1208 }
1209
1210 /// `sandbox status` didn't answer: a `g1t-runner` from before its
1211 /// launcher, which a newer release replaces, or one that failed.
1212 fn no_status(&mut self, why: String) {
1213 self.write_log(&format!("g1t: the sandbox's status: {why}"));
1214 self.failures += 1;
1215 if why.contains("older than its sandbox launcher") {
1216 let server = config::server(&self.app);
1217 if let Ok(Some((version, bytes))) = self.newer_release(&server)
1218 && native::put(&folder(&self.app), &bytes, &version).is_ok()
1219 {
1220 self.note(None, Some(version));
1221 self.not_before = Instant::now();
1222 return;
1223 }
1224 self.not_before = Instant::now() + MOST_BACKOFF;
1225 return self.set(Code::NoLauncher, detail(why));
1226 }
1227 self.not_before = Instant::now() + LOOK_AGAIN;
1228 self.set(Code::SandboxFailed, Extra { detail: Some(why), wait_seconds: Some(LOOK_AGAIN.as_secs()), ..Extra::default() });
1229 }
1230
1231 /// Runs a launcher command for the sandbox's run, reading its output.
A registration token never shows in the process list. The desktop app passed it to g1t-runner sandbox up as --token, which anyone on the computer can read while up runs; it goes in up's environment now, as G1T_RUNNER_TOKEN, which the launcher reads when --token is left out, as register-and-run already did.1232 fn spawn_step(&mut self, step: Step, args: Vec<std::ffi::OsString>, token: Option<&str>) -> Result<(), String> {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1233 let binary = native::binary(&folder(&self.app));
A registration token never shows in the process list. The desktop app passed it to g1t-runner sandbox up as --token, which anyone on the computer can read while up runs; it goes in up's environment now, as G1T_RUNNER_TOKEN, which the launcher reads when --token is left out, as register-and-run already did.1234 let mut command = launcher::command(&binary, args);
1235 launcher::with_token(&mut command, token);
1236 let mut child = command.spawn().map_err(|e| format!("g1t-runner sandbox {} could not start: {e}", step.name()))?;
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1237 let _ = std::fs::write(folder(&self.app).join("pid"), child.id().to_string());
1238 let Some(generation) = self.run.as_ref().map(|run| run.generation) else {
1239 native::stop(&mut child);
1240 return Ok(());
1241 };
1242 self.follow(generation, &mut child);
1243 let run = self.run.as_mut().expect("checked above");
1244 run.json = None;
1245 run.process = Process::Sandbox(Some((step, child)));
1246 Ok(())
1247 }
1248
1249 /// `update` on the running sandbox, while it has no job.
1250 fn sandbox_step(&mut self, step: Step) {
1251 if self.run.as_ref().is_none_or(|run| run.step().is_some()) {
1252 return;
1253 }
1254 let dir = native_dir(&self.app, Backend::Sandboxed);
A registration token never shows in the process list. The desktop app passed it to g1t-runner sandbox up as --token, which anyone on the computer can read while up runs; it goes in up's environment now, as G1T_RUNNER_TOKEN, which the launcher reads when --token is left out, as register-and-run already did.1255 if let Err(why) = self.spawn_step(step, launcher::update_args(&dir), None) {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1256 self.write_log(&format!("g1t: {why}"));
1257 }
1258 }
1259
1260 /// Asks the sandbox how it is, every few seconds, on a thread of its own.
1261 fn poll(&mut self) {
1262 let Some(run) = self.run.as_mut().filter(|run| run.engine == Engine::Launcher) else { return };
1263 if run.polling || run.polled_at.is_some_and(|at| at.elapsed() < launcher::STATUS_EVERY) {
1264 return;
1265 }
1266 run.polling = true;
1267 run.polled_at = Some(Instant::now());
1268 let (tx, generation) = (self.tx.clone(), run.generation);
1269 let binary = native::binary(&folder(&self.app));
1270 let dir = native_dir(&self.app, Backend::Sandboxed);
1271 let spawned = std::thread::Builder::new().name("g1t-sandbox-status".into()).spawn(move || {
1272 let _ = tx.send(Ask::Sandbox(generation, launcher::status(&binary, &dir).map(Box::new)));
1273 });
1274 if spawned.is_err() {
1275 run.polling = false;
1276 }
1277 }
1278
1279 /// What a poll said.
1280 fn polled(&mut self, generation: u64, status: Result<Box<Status>, String>) {
1281 let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return };
1282 run.polling = false;
1283 if std::mem::take(&mut run.stale) {
1284 return;
1285 }
1286 match status {
1287 Ok(status) => self.sandbox_now(*status),
1288 // Once is nothing: the next poll asks again.
1289 Err(why) => self.write_log(&format!("g1t: the sandbox's status: {why}")),
1290 }
1291 }
1292
1293 /// Keeps what a status says for the tray, settings and the site.
1294 fn seen(&mut self, status: &Status) {
1295 let shared = self.app.state::<Shared>();
1296 let mut now = shared.now.lock().unwrap();
1297 now.sandbox = Some(status.clone());
1298 if let Some(version) = status.version() {
1299 now.version = Some(version);
1300 }
1301 now.job = status.job().map(launcher::Job::said);
1302 }
1303
1304 /// The sandbox as it is now, while it is the run.
1305 fn sandbox_now(&mut self, status: Status) {
1306 let had = self.app.state::<Shared>().now.lock().unwrap().job.clone();
1307 let Some(run) = self.run.as_mut() else { return };
1308 let step = run.step();
1309 let job = status.job().map(launcher::Job::said);
1310 let was = had.filter(|_| run.busy);
1311 run.busy = job.is_some();
1312 run.listening = status.running();
1313 let update_failed = self.update_failed.clone();
1314 if was != job {
1315 if let Some(ended) = &was {
1316 self.write_log(&format!("g1t: {ended} ended."));
1317 }
1318 if let Some(took) = &job {
1319 self.write_log(&format!("g1t: Took {took}."));
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.1320 }
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1321 }
1322 self.seen(&status);
1323 match step {
1324 Some(Step::Up) => return self.set(Code::Starting, detail(STARTING_SANDBOX)),
1325 Some(Step::Update) if status.state == State::Updating => return self.set(Code::Updating, detail("Updating the sandbox to the newest image.")),
1326 _ => {}
1327 }
1328 let (code, extra) = shown(&status);
1329 if step.is_none() && !status.running() && !matches!(status.state, State::Starting | State::Updating) {
1330 return self.sandbox_stopped(&status, code, extra);
1331 }
1332 match (code, update_failed) {
1333 (Code::Idle, Some(why)) => self.set(Code::UpdateFailed, detail(why)),
1334 (code, _) => self.set(code, extra),
1335 }
1336 }
1337
1338 /// The sandbox stopped by itself, or never got going: started again,
1339 /// waiting longer each time, or registered again when that is why.
1340 fn sandbox_stopped(&mut self, status: &Status, code: Code, extra: Extra) {
1341 let error = status.error.clone().or_else(|| extra.detail.clone()).unwrap_or_default();
1342 if self.registration_trouble(&error) {
1343 return;
1344 }
1345 self.run = None;
1346 self.generation += 1;
1347 self.failures += 1;
1348 let wait = Duration::from_secs(15 * 2u64.pow(self.failures.min(6))).min(MOST_BACKOFF);
1349 self.not_before = Instant::now() + wait;
1350 let (code, extra) = match code {
1351 Code::Idle | Code::Busy | Code::Starting | Code::Updating => (Code::SandboxFailed, Extra { detail: status.error.clone().or_else(|| some_text("The sandbox stopped by itself.")), ..Extra::default() }),
1352 code => (code, extra),
1353 };
1354 self.set(code, Extra { wait_seconds: Some(wait.as_secs()), ..extra });
1355 }
1356
1357 /// When the sandbox's trouble is its registration: true when the app
1358 /// has seen to it, and the next start registers again.
1359 fn registration_trouble(&mut self, error: &str) -> bool {
1360 match launcher::why(error) {
1361 launcher::Why::TokenRefused | launcher::Why::Unregistered => {
1362 if let Some(mut kept) = SandboxRegistration::load(&self.app) {
1363 kept.registered = false;
1364 kept.save(&self.app);
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.1365 }
1366 self.halt();
1367 self.not_before = Instant::now();
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1368 true
1369 }
1370 launcher::Why::Forgotten => {
1371 self.halt();
1372 self.forget_sandbox();
1373 self.not_before = Instant::now();
1374 true
1375 }
1376 _ => false,
1377 }
1378 }
1379
1380 /// A launcher command ended: what it printed last is the sandbox's status.
1381 fn launched(&mut self, generation: u64) {
1382 let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return };
1383 let Process::Sandbox(slot) = &mut run.process else { return };
1384 let Some((step, mut child)) = slot.take() else { return };
1385 let worked = child.wait().is_ok_and(|status| status.success());
1386 let _ = std::fs::remove_file(folder(&self.app).join("pid"));
1387 let printed = run.json.take().unwrap_or_default().join("\n");
1388 run.stale = run.polling;
1389 let with_token = run.with_token;
1390 let status = launcher::parse(&printed);
1391 let error = match (&status, worked) {
1392 (Ok(status), true) => status.error.clone().filter(|_| !status.running()),
1393 (Ok(status), false) => Some(status.error.clone().unwrap_or_else(|| format!("g1t-runner sandbox {} failed.", step.name()))),
1394 (Err(why), _) => Some(why.clone()),
1395 };
1396 let status = status.ok();
1397 let running = status.as_ref().is_some_and(Status::running);
1398 if let Some(error) = &error {
1399 self.write_log(&format!("g1t: sandbox {}: {error}", step.name()));
1400 }
1401 // Registered: from now on it starts with no token.
1402 if step == Step::Up && with_token && (error.is_none() || running) {
1403 let app = self.app.clone();
1404 if let Some(run) = self.run.as_mut() {
1405 run.with_token = false;
1406 let registration = Registration { server: config::server(&app).to_string(), workspace: run.workspace.clone(), backend: Backend::Sandboxed };
1407 SandboxRegistration { server: registration.server.clone(), workspace: registration.workspace.clone(), registered: true }.save(&app);
1408 registration.save(&app);
1409 }
1410 }
1411 if let Some(error) = error.clone() {
1412 if self.registration_trouble(&error) {
1413 return;
1414 }
1415 if step == Step::Update {
1416 self.update_failed = Some(format!("The sandbox's image couldn't be updated: {error}"));
1417 } else if !running {
1418 // Nothing runs: tidied away, and tried again in a while.
1419 self.halt();
1420 self.failures += 1;
1421 let wait = Duration::from_secs(15 * 2u64.pow(self.failures.min(6))).min(MOST_BACKOFF);
1422 self.not_before = Instant::now() + wait;
1423 let (code, cause, fix) = status
1424 .as_ref()
1425 .and_then(diagnosis::sandbox_trouble)
1426 .unwrap_or_else(|| (diagnosis::sandbox_code(launcher::why(&error)), error.clone(), None));
1427 return self.set(code, Extra { detail: Some(cause), fix, wait_seconds: Some(wait.as_secs()), ..Extra::default() });
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.1428 }
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1429 } else if step == Step::Up {
1430 self.failures = 0;
1431 } else {
1432 self.update_failed = None;
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.1433 }
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1434 if step == Step::Update && !running {
1435 // Updated while stopped: it starts again with the new image.
1436 self.run = None;
1437 self.generation += 1;
1438 self.not_before = Instant::now();
1439 return;
1440 }
1441 if let Some(status) = status {
1442 self.sandbox_now(status);
1443 }
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.1444 }
1445}
1446
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1447/// What the tray says while the sandbox starts.
1448const STARTING_SANDBOX: &str = "Starting the sandbox. The first time, it downloads and checks the sandbox's image, which takes a few minutes.";
1449
1450fn some_text(text: &str) -> Option<String> {
1451 Some(text.to_owned())
1452}
1453
1454/// What the app shows for how the sandbox is, with no launcher command
1455/// running: what is wrong with Linux jobs first, else what its runners do.
1456fn shown(status: &Status) -> (Code, Extra) {
1457 if let Some((code, cause, fix)) = diagnosis::sandbox_trouble(status) {
1458 return (code, Extra { detail: Some(cause), fix, ..Extra::default() });
1459 }
1460 let linux = status.linux();
1461 match (linux.state, status.job()) {
1462 (_, Some(_)) => (Code::Busy, Extra::default()),
1463 (State::Running, None) => (Code::Idle, Extra::default()),
1464 (State::Starting, None) => (Code::Starting, detail(STARTING_SANDBOX)),
1465 (State::Updating, None) => (Code::Updating, detail("Updating the sandbox to the newest image.")),
1466 // Linux jobs aren't running, but Windows jobs are.
1467 _ if status.running() => (Code::Idle, Extra::default()),
1468 _ => (Code::SandboxFailed, detail("The sandbox isn't running.")),
1469 }
1470}
1471
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.1472/// What `register-and-run` reads: where g1t is, the runner's name and
1473/// labels, and the token, the first time only. The app updates the runner,
1474/// so the runner does not update itself; registering again takes the
1475/// place of this computer's earlier runner of the same name.
1476fn environment(server: &Url, backend: Backend, token: Option<&str>) -> Vec<(&'static str, String)> {
1477 let mut envs = vec![
1478 ("G1T_RUNNER_URL", server.as_str().trim_end_matches('/').to_owned()),
1479 ("G1T_RUNNER_NAME", backend::machine_name()),
1480 ("G1T_RUNNER_LABELS", "desktop".to_owned()),
1481 ("G1T_RUNNER_REPLACE", "1".to_owned()),
1482 ("G1T_RUNNER_NO_AUTO_UPDATE", "1".to_owned()),
1483 ];
1484 if backend == Backend::Host {
1485 envs.push(("G1T_RUNNER_NO_DOCKER", "1".to_owned()));
1486 }
1487 if let Some(token) = token {
1488 envs.push(("G1T_RUNNER_TOKEN", token.to_owned()));
1489 }
1490 envs
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495 use super::*;
1496
1497 fn view(code: Code, job: Option<&str>) -> View {
1498 let facts = Facts { workspace: Some("acme".into()), backend: "sandboxed", machine: "box".into(), job: job.map(str::to_owned), ..Facts::default() };
1499 View {
1500 diagnosis: diagnosis::diagnose(code, &facts),
1501 closed: diagnosis::closed(&facts),
1502 running: code == Code::Idle,
1503 version: None,
1504 backend: Backend::Sandboxed,
1505 workspace: Some("acme".into()),
1506 workspaces: vec![],
1507 enabled: true,
1508 paused: false,
1509 link: None,
1510 machine: "box".into(),
1511 platform: "windows".into(),
1512 log: vec![],
1513 }
1514 }
1515
1516 #[test]
1517 fn the_tray_says_what_the_runner_is_doing() {
1518 assert_eq!(headline(&view(Code::Idle, None)), "Runner: Waiting for jobs");
1519 assert_eq!(headline(&view(Code::Busy, Some("Build (acme/web)"))), "Runner: Running Build (acme/web)");
1520 assert_eq!(headline(&view(Code::NoDocker, None)), "Runner: Not running: Docker isn't running");
1521 assert_eq!(headline(&view(Code::Busy, Some(&"x".repeat(200)))).chars().count(), 60);
1522 }
1523
1524 #[test]
1525 fn the_runner_is_told_where_and_who_and_the_token_only_once() {
1526 let server = Url::parse("https://g1t.sh/").unwrap();
1527 let envs = environment(&server, Backend::Host, Some("g1trt_abc"));
1528 let get = |name: &str| envs.iter().find(|(n, _)| *n == name).map(|(_, v)| v.as_str());
1529 assert_eq!(get("G1T_RUNNER_URL"), Some("https://g1t.sh"));
1530 assert_eq!(get("G1T_RUNNER_LABELS"), Some("desktop"));
1531 assert_eq!(get("G1T_RUNNER_NO_DOCKER"), Some("1"));
1532 assert_eq!(get("G1T_RUNNER_NO_AUTO_UPDATE"), Some("1"));
1533 assert_eq!(get("G1T_RUNNER_TOKEN"), Some("g1trt_abc"));
1534 let again = environment(&server, Backend::Docker, None);
1535 assert!(!again.iter().any(|(name, _)| *name == "G1T_RUNNER_TOKEN" || *name == "G1T_RUNNER_NO_DOCKER"));
1536 }
1537
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1538 fn status(json: &str) -> Status {
1539 launcher::parse(json).unwrap()
1540 }
1541
1542 #[test]
1543 fn the_sandbox_shows_what_its_runners_do_and_linux_trouble_first() {
1544 let running = status(r#"{"schema":1,"state":"running","capabilities":[{"id":"linux","available":true,"state":"running"},{"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not)."}]}"#);
1545 assert_eq!(shown(&running).0, Code::Idle);
1546 let busy = status(r#"{"schema":1,"state":"running","job":{"kind":"workflow","id":"j","name":"build","repo":"acme/web"}}"#);
1547 assert_eq!(shown(&busy).0, Code::Busy);
1548 let starting = status(r#"{"schema":1,"state":"starting","capabilities":[{"id":"linux","available":true,"state":"starting"}]}"#);
1549 assert_eq!(shown(&starting).0, Code::Starting);
1550 let updating = status(r#"{"schema":1,"state":"updating","capabilities":[{"id":"linux","available":true,"state":"updating"}]}"#);
1551 assert_eq!(shown(&updating).0, Code::Updating);
1552 // Windows jobs run while WSL is missing: what is wrong with Linux jobs is what it says.
1553 let no_wsl = status(r#"{"schema":1,"state":"error","error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.","capabilities":[
1554 {"id":"linux","available":false,"state":"error","cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.","fix":"Open a terminal as administrator and run `wsl --install --no-distribution`."},
1555 {"id":"windows","available":true,"state":"running"}]}"#);
1556 let (code, extra) = shown(&no_wsl);
1557 assert_eq!(code, Code::NoWsl);
1558 assert!(extra.detail.unwrap().starts_with("WSL is not installed"));
1559 let failed = status(r#"{"schema":1,"state":"error","error":"the sandbox did not start within 180 seconds","capabilities":[{"id":"linux","available":true,"state":"error"}]}"#);
1560 let (code, extra) = shown(&failed);
1561 assert_eq!((code, extra.detail.as_deref()), (Code::SandboxFailed, Some("the sandbox did not start within 180 seconds")));
1562 let stopped = status(r#"{"schema":1,"state":"stopped","capabilities":[{"id":"linux","available":true,"state":"stopped"},{"id":"windows","available":true,"state":"stopped"}]}"#);
1563 assert_eq!(shown(&stopped).0, Code::SandboxFailed);
1564 }
1565
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.1566 #[test]
1567 fn the_site_names_workspaces_in_its_own_words() {
1568 let offered: Vec<Offered> = serde_json::from_str(r#"[{"slug":"acme","name":"Acme"},{"slug":"solo"}]"#).unwrap();
1569 assert_eq!(offered[1], Offered { slug: "solo".into(), name: None });
1570 }
1571}

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