Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
2,369 linesCodeBlameRaw
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 once the site's
10//! page has loaded (or 20 s have passed) and 5 s more, never before, on a
11//! thread of its own that a panic restarts rather than ending the app.
12//! - **How.** The backend settings choose (backend.rs): Sandboxed, the
13//! default, where nothing a job does reaches this computer; Docker; or
14//! directly on this computer. The two that are not sandboxes are only
15//! ever chosen by the person, and nothing falls back to them. Sandboxed
16//! is `g1t-runner sandbox` (launcher.rs) on every OS: `up` a few seconds
17//! after the window, `status` every few seconds while it runs, `down`
18//! as g1t quits, and `down --drain` when another backend is chosen.
19//! Each kind of job it runs (Linux jobs, Windows jobs) starts and stops
20//! on its own too: settings keep which are on (`runnerCapabilities`),
21//! every `up` names them (`--only`), and stopping one is `down --only`
22//! it, which leaves the other running. One that should run and doesn't
23//! while the other runs is tried again by itself, every few minutes, and
24//! every five while the newest release has no sandbox images.
25//! - **Registering.** The first time, and when the workspace or backend
26//! changes, the app needs a registration token for the workspace. Only
27//! the site can make one, for an owner who is signed in: the app asks
28//! the site's page (`g1t-runner-wanted`), the page asks the server
29//! (`POST /-/desktop/runner-token`) and hands the token to the app
30//! (`runner_register` in bridge.rs), which gives it to `g1t-runner`
31//! once: in its environment, or to the sandbox's first `up`. It is never
32//! shown or written to the log. The registration is kept
33//! (registration.json and the runner's own folder; the sandbox keeps its
34//! own, and sandbox-registration.json says whose it is) and used on
35//! every later start.
36//! - **Up to date.** Soon after it starts and every hour, and when the
37//! person asks ("Check for runner updates"), the app looks for a newer
38//! release on a thread of its own: its manifest only when g1t's release
39//! key signed it (native.rs), its program only when its SHA-256 is the
40//! manifest's. What it finds waits, downloaded, until each kind of job
41//! is idle, and goes in kind by kind, never in the middle of a job: the
42//! sandbox's Linux jobs take its image (`sandbox update`, which drains
43//! them itself) once they have no job; its Windows jobs, whose runner is
44//! the program itself, are drained (`down --only windows --drain`), the
45//! program is replaced, and they start again; a runner in Docker or on
46//! this computer is stopped between jobs and started on the new one.
47//! "Download updates by themselves" off, it looks only when asked.
48//! - **Seen.** What it is doing, and when it is not running why and how to
49//! fix it (diagnosis.rs), is in the title bar's chip, the Runner window
50//! (launcher/runner.html), the tray, a line in settings, and on the
51//! site's Runners page (`g1t-runner-status`), read from what its log says
52//! (log.rs), or for the sandbox from its status. The whole log is kept
53//! in `runner/runner.log`.
54//!
55//! All of it runs on a thread of its own, which owns the runner's process
56//! and is told what happened through one channel.
57
58mod backend;
59pub mod diagnosis;
60mod launcher;
61mod log;
62mod native;
63
64use std::collections::VecDeque;
65use std::fs::{File, OpenOptions};
66use std::io::{BufRead, BufReader, Read, Write};
67use std::path::PathBuf;
68use std::process::{Child, Stdio};
69use std::panic::{AssertUnwindSafe, catch_unwind};
70use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
71use std::sync::{Mutex, MutexGuard, PoisonError};
72use std::time::{Duration, Instant, SystemTime};
73
74use serde::{Deserialize, Serialize};
75use tauri::{AppHandle, Emitter, EventTarget, Manager};
76use url::Url;
77
78pub use backend::Backend;
79use backend::{Engine, Missing};
80use diagnosis::{Code, Diagnosis, Facts};
81use launcher::{Down, State, Status};
82use native::Fault;
83
84use crate::{config, frame, settings, tray};
85
86/// What settings, the tray and the site listen for: the runner's status changed.
87pub const STATUS_EVENT: &str = "g1t-runner-status";
88/// What the site's page listens for: the app needs a registration token for a workspace.
89pub const WANTED_EVENT: &str = "g1t-runner-wanted";
90
91/// How long after the site's page has loaded the runner starts: the
92/// window, the page and its first calls to the app come first.
93const AFTER_START: Duration = Duration::from_secs(5);
94/// The longest the runner waits for the site's page before starting
95/// anyway (offline, the server picker, a site too old to say it loaded).
96const SITE_WAIT: Duration = Duration::from_secs(20);
97/// How long after the runner's thread failed it starts again.
98const AFTER_PANIC: Duration = Duration::from_secs(15);
99
100/// A lock that a failed runner thread can't take the app down with: a
101/// panic while holding one leaves it poisoned, and `unwrap` on the main
102/// thread would then end the app. The data is plain state, never half
103/// written in a way that matters, so it is used as it is.
104trait Held<T> {
105 fn held(&self) -> MutexGuard<'_, T>;
106}
107
108impl<T> Held<T> for Mutex<T> {
109 fn held(&self) -> MutexGuard<'_, T> {
110 self.lock().unwrap_or_else(PoisonError::into_inner)
111 }
112}
113/// How often the thread looks at the runner when nothing has happened.
114const TICK: Duration = Duration::from_secs(5);
115/// How long after starting the first look for an update is, then how often.
116const FIRST_UPDATE: Duration = Duration::from_secs(90);
117const UPDATE_EVERY: Duration = Duration::from_secs(60 * 60);
118/// How often a backend that cannot start is looked at again.
119const LOOK_AGAIN: Duration = Duration::from_secs(30);
120/// How long the app waits for the site to hand over a token before asking again.
121const ASK_AGAIN: Duration = Duration::from_secs(60);
122/// The longest the app waits before starting a runner that stopped by itself again.
123const MOST_BACKOFF: Duration = Duration::from_secs(10 * 60);
124/// The runner says when a poll fails, and nothing when one works: this
125/// long without another failure, it is back (its longest wait is 60s).
126const BACK_ONLINE: Duration = Duration::from_secs(75);
127/// The runner's log is started over when it is bigger than this.
128const LOG_LIMIT: u64 = 5 * 1024 * 1024;
129/// The last lines of the log settings show.
130const TAIL: usize = 40;
131
132/// A workspace the site says the person may add runners to: those they own.
133#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
134pub struct Offered {
135 pub slug: String,
136 #[serde(default)]
137 pub name: Option<String>,
138}
139
140/// What the runner is doing, as the thread last said.
141#[derive(Clone, Debug, Default, PartialEq)]
142struct Now {
143 code: Code,
144 detail: Option<String>,
145 exit_code: Option<i32>,
146 wait_seconds: Option<u64>,
147 job: Option<String>,
148 version: Option<String>,
149 /// What the sandbox says to do, for a cause in its own words.
150 fix: Option<String>,
151 /// How the sandbox is, while jobs run sandboxed.
152 sandbox: Option<Status>,
153 /// A sandbox run is up, or on its way.
154 live: bool,
155 /// The kinds of job a launcher command is starting now.
156 starting: Vec<String>,
157 /// How far the sandbox's image has downloaded, while it does.
158 download: Option<launcher::Download>,
159}
160
161/// How the runner's updates stand, as the thread last said.
162#[derive(Clone, Debug, Default, PartialEq)]
163struct Updates {
164 /// When the releases last answered.
165 checked_at: Option<SystemTime>,
166 checking: bool,
167 /// A newer version, downloaded and checked, waiting.
168 ready: Option<String>,
169 /// Its program is still to be put in place.
170 program: bool,
171 /// The sandbox's image is still to take it.
172 image: bool,
173 /// The kinds of job taking it now.
174 installing: Vec<String>,
175}
176
177/// What a look at the releases found.
178struct Found {
179 version: String,
180 /// Newer than the program here.
181 newer: bool,
182 /// Its program, downloaded and checked: None when it isn't newer, or is
183 /// already waiting.
184 program: Option<Vec<u8>>,
185 /// The release has the sandbox's images.
186 images: bool,
187}
188
189/// What the site last said about the person.
190#[derive(Clone, Debug, Default, PartialEq)]
191struct Site {
192 /// Unknown until the site's first page loads.
193 signed_in: Option<bool>,
194 owned: Vec<Offered>,
195 current: Option<String>,
196}
197
198/// What the rest of the app shares with the thread.
199#[derive(Default)]
200pub struct Shared {
201 now: Mutex<Now>,
202 /// The workspace a token is wanted for.
203 wanted: Mutex<Option<String>>,
204 site: Mutex<Site>,
205 /// Stopped by the person until Start, or until g1t starts again.
206 paused: Mutex<bool>,
207 tail: Mutex<VecDeque<String>>,
208 tx: Mutex<Option<Sender<Ask>>>,
209 updates: Mutex<Updates>,
210}
211
212/// What settings, the tray and the site are shown.
213#[derive(Clone, Debug, PartialEq, Serialize)]
214#[serde(rename_all = "camelCase")]
215pub struct View {
216 pub diagnosis: Diagnosis,
217 /// What the site says of this computer once g1t is closed on it.
218 pub closed: Diagnosis,
219 pub running: bool,
220 pub version: Option<String>,
221 pub backend: Backend,
222 pub workspace: Option<String>,
223 /// The workspaces it may run for, from the site.
224 pub workspaces: Vec<Offered>,
225 /// The setting: run jobs while g1t is open.
226 pub enabled: bool,
227 pub paused: bool,
228 /// The workspace's Runners page.
229 pub link: Option<String>,
230 /// This computer's name, which the runner has on g1t.
231 pub machine: String,
232 pub platform: String,
233 /// The last lines of the runner's log.
234 pub log: Vec<String>,
235 /// In a few words, for the title bar's chip.
236 pub chip: diagnosis::Chip,
237}
238
239/// What the thread is told.
240enum Ask {
241 /// Something changed: settings, Start or Stop, Retry.
242 Look,
243 /// A kind of job was started: start what should run now, stopping nothing.
244 Soon,
245 Retry,
246 /// "Remove it and start again": the sandbox this computer has, removed
247 /// with its registration, and the app's own made in its place.
248 RemoveSandbox,
249 /// "Check for runner updates": look now, and install what is found
250 /// even with "Download updates by themselves" off.
251 Check,
252 /// What a look at the releases found.
253 Checked(Result<Found, Fault>),
254 Token { workspace: String, token: String },
255 Refused { workspace: String, allowed: bool, why: String },
256 /// A line of the runner's output, or the launcher's; true from its standard output.
257 Line(u64, String, bool),
258 Exited(u64),
259 /// What `sandbox status` said.
260 Sandbox(u64, Result<Box<Status>, String>),
261 Quit(Sender<()>),
262}
263
264/// The registration the app made, besides what the runner keeps itself.
265#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
266struct Registration {
267 server: String,
268 workspace: String,
269 backend: Backend,
270}
271
272/// Whose the sandbox's registration is. The sandbox keeps its own, and
273/// keeps it while another backend runs, so choosing Sandboxed again needs
274/// no new one; this says which workspace it is for.
275#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
276struct SandboxRegistration {
277 server: String,
278 workspace: String,
279 /// False from the `up` that was given a token until it registered.
280 registered: bool,
281}
282
283impl SandboxRegistration {
284 fn path(app: &AppHandle) -> PathBuf {
285 folder(app).join("sandbox-registration.json")
286 }
287 fn load(app: &AppHandle) -> Option<SandboxRegistration> {
288 std::fs::read(Self::path(app)).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok())
289 }
290 fn save(&self, app: &AppHandle) {
291 let _ = std::fs::create_dir_all(folder(app));
292 let _ = std::fs::write(Self::path(app), serde_json::to_vec_pretty(self).unwrap_or_default());
293 }
294 fn clear(app: &AppHandle) {
295 let _ = std::fs::remove_file(Self::path(app));
296 }
297}
298
299/// Where the runner keeps everything: its program, registrations, log and
300/// the sandbox's VM. In the app's local data, which stays on this computer:
301/// roaming data (Windows' %APPDATA%) is copied to a profile server on
302/// networks with roaming profiles, and a VM's disk and a runner's
303/// credential have no business there. A computer whose runner already
304/// lives in roaming data keeps it there: its WSL distribution is
305/// registered at that path, and moving its disk would break it. On macOS
306/// and Linux the two are the same folder.
307fn folder(app: &AppHandle) -> PathBuf {
308 let fallback = || std::env::temp_dir().join("sh.g1t.desktop");
309 let local = app.path().app_local_data_dir().unwrap_or_else(|_| fallback()).join("runner");
310 let roaming = app.path().app_data_dir().unwrap_or_else(|_| fallback()).join("runner");
311 chosen_folder(local, roaming)
312}
313
314/// The local folder, unless the runner was set up in the roaming one before.
315fn chosen_folder(local: PathBuf, roaming: PathBuf) -> PathBuf {
316 if !local.exists() && roaming.exists() { roaming } else { local }
317}
318
319impl Registration {
320 fn path(app: &AppHandle) -> PathBuf {
321 folder(app).join("registration.json")
322 }
323 fn load(app: &AppHandle) -> Option<Registration> {
324 std::fs::read(Self::path(app)).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok())
325 }
326 fn save(&self, app: &AppHandle) {
327 let _ = std::fs::create_dir_all(folder(app));
328 let _ = std::fs::write(Self::path(app), serde_json::to_vec_pretty(self).unwrap_or_default());
329 }
330 fn clear(app: &AppHandle) {
331 let _ = std::fs::remove_file(Self::path(app));
332 }
333}
334
335/// Where a native runner of a backend keeps its configuration.
336fn native_dir(app: &AppHandle, backend: Backend) -> PathBuf {
337 folder(app).join(match backend {
338 Backend::Docker => "docker",
339 Backend::Host => "host",
340 Backend::Sandboxed => launcher::FOLDER,
341 })
342}
343
344/// The workspace the runner is for: the one settings name, else the one it
345/// is registered with, else the one the person is in on the site, else the
346/// first they own.
347fn workspace(app: &AppHandle) -> Option<String> {
348 let settings = config::settings(app);
349 if let Some(chosen) = settings.runner_workspace.filter(|w| !w.is_empty()) {
350 return Some(chosen);
351 }
352 let server = config::server(app).to_string();
353 if let Some(registration) = Registration::load(app).filter(|r| r.server == server) {
354 return Some(registration.workspace);
355 }
356 let site = app.state::<Shared>().site.held().clone();
357 site.current.filter(|slug| site.owned.iter().any(|o| &o.slug == slug)).or_else(|| site.owned.first().map(|o| o.slug.clone()))
358}
359
360/// The kinds of job a sandbox can have.
361const KINDS: [&str; 3] = ["linux", "windows", "macos"];
362
363fn facts(app: &AppHandle, now: &Now) -> Facts {
364 let server = config::server(app);
365 let settings = config::settings(app);
366 let backend = settings.runner_backend;
367 Facts {
368 workspace: workspace(app),
369 backend: backend.label(),
370 machine: backend::machine_name(),
371 job: now.job.clone(),
372 detail: now.detail.clone(),
373 exit_code: now.exit_code,
374 wait_seconds: now.wait_seconds,
375 server: server.host_str().unwrap_or("g1t").to_owned(),
376 site: server.as_str().trim_end_matches('/').to_owned(),
377 platform: std::env::consts::OS.into(),
378 fix: now.fix.clone(),
379 sandbox: now.sandbox.clone().filter(|_| backend == Backend::Sandboxed),
380 off: KINDS.iter().filter(|kind| !settings.runner_capabilities.on(kind)).map(|kind| (*kind).to_owned()).collect(),
381 live: now.live,
382 starting: now.starting.clone(),
383 download: now.download.clone(),
384 held: !settings.runner || *app.state::<Shared>().paused.held(),
385 update: update_facts(app, now, backend),
386 }
387}
388
389/// How the runner's updates stand, for the words: what is still to take a
390/// waiting version. The sandbox's Windows jobs (any kind but Linux) take
391/// its program while they run; a runner with no kinds of job takes it
392/// itself.
393fn update_facts(app: &AppHandle, now: &Now, backend: Backend) -> diagnosis::Update {
394 let updates = app.state::<Shared>().updates.held().clone();
395 let sandbox = now.sandbox.as_ref().filter(|_| backend == Backend::Sandboxed);
396 let mut waiting = Vec::new();
397 if updates.image {
398 waiting.push("linux".to_owned());
399 }
400 if updates.program {
401 match sandbox {
402 Some(status) => waiting.extend(native_kinds(status).into_iter().map(|c| c.id.clone())),
403 None => waiting.push("runner".to_owned()),
404 }
405 }
406 diagnosis::Update {
407 installed: native::installed(&folder(app)),
408 checked_at: updates.checked_at.map(rfc3339),
409 checked_ago: updates.checked_at.and_then(|at| at.elapsed().ok()).map(|ago| ago.as_secs()),
410 checking: updates.checking,
411 ready: updates.ready,
412 waiting,
413 installing: updates.installing,
414 }
415}
416
417/// The kinds of job running on the program itself, which replacing it
418/// stops: the sandbox's Windows jobs (not Linux jobs, which run in its VM).
419fn native_kinds(status: &Status) -> Vec<&launcher::Capability> {
420 status.capabilities.iter().filter(|c| c.id != "linux" && !c.off() && matches!(c.state, State::Running | State::Starting)).collect()
421}
422
423/// A time as RFC 3339, in UTC to the second: `2026-10-12T12:04:05Z`.
424fn rfc3339(at: SystemTime) -> String {
425 let seconds = at.duration_since(SystemTime::UNIX_EPOCH).map_or(0, |since| since.as_secs());
426 let (days, rest) = (seconds / 86_400, seconds % 86_400);
427 // Days since 1970 to a date in the proleptic Gregorian calendar (Howard Hinnant's civil_from_days).
428 let z = days as i64 + 719_468;
429 let era = z.div_euclid(146_097);
430 let day_of_era = z.rem_euclid(146_097);
431 let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
432 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
433 let month_index = (5 * day_of_year + 2) / 153;
434 let day = day_of_year - (153 * month_index + 2) / 5 + 1;
435 let month = if month_index < 10 { month_index + 3 } else { month_index - 9 };
436 let year = year_of_era + era * 400 + i64::from(month <= 2);
437 format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", rest / 3600, rest % 3600 / 60, rest % 60)
438}
439
440pub fn view(app: &AppHandle) -> View {
441 let shared = app.state::<Shared>();
442 let now = shared.now.held().clone();
443 let settings = config::settings(app);
444 let facts = facts(app, &now);
445 let server = config::server(app);
446 let diagnosed = diagnosis::diagnose(now.code, &facts);
447 View {
448 chip: diagnosis::chip(&diagnosed),
449 diagnosis: diagnosed,
450 closed: diagnosis::closed(&facts),
451 running: matches!(now.code, Code::Idle | Code::Busy | Code::Offline) || facts.sandbox.as_ref().is_some_and(Status::running),
452 version: now.version,
453 backend: settings.runner_backend,
454 link: facts.workspace.as_ref().and_then(|w| server.join(&format!("{w}/-/runners")).ok()).map(|u| u.to_string()),
455 workspace: facts.workspace,
456 workspaces: shared.site.held().owned.clone(),
457 enabled: settings.runner,
458 paused: *shared.paused.held(),
459 machine: facts.machine,
460 platform: facts.platform,
461 log: shared.tail.held().iter().cloned().collect(),
462 }
463}
464
465/// Whether the runner is running a job: what restarts g1t waits for it,
466/// since restarting stops the runner and would cut the job short.
467pub fn busy(app: &AppHandle) -> bool {
468 let now = app.state::<Shared>().now.held().clone();
469 now.code == Code::Busy || now.sandbox.as_ref().is_some_and(|status| status.job().is_some())
470}
471
472/// The tray's line: `Runner: Waiting for jobs`, at most 60 characters.
473pub fn headline(view: &View) -> String {
474 let line = format!("Runner: {}", view.diagnosis.title);
475 if line.chars().count() > 60 { line.chars().take(59).collect::<String>() + "…" } else { line }
476}
477
478/// Tells everyone what the runner is doing now.
479fn publish(app: &AppHandle) {
480 let view = view(app);
481 tray::runner_changed(app, &view);
482 let _ = app.emit_to(settings::SETTINGS, STATUS_EVENT, view.clone());
483 let _ = app.emit_to(settings::RUNNER, STATUS_EVENT, view.clone());
484 let _ = app.emit_to(EventTarget::webview(frame::BAR), STATUS_EVENT, view.chip.clone());
485 let _ = app.emit_to(EventTarget::webview(frame::SITE), STATUS_EVENT, view);
486}
487
488fn ask(app: &AppHandle, ask: Ask) {
489 if let Some(tx) = app.state::<Shared>().tx.held().as_ref() {
490 let _ = tx.send(ask);
491 }
492}
493
494/// Starts the thread that keeps the runner, as the app starts.
495pub fn install(app: &AppHandle) {
496 let (tx, rx) = mpsc::channel();
497 *app.state::<Shared>().tx.held() = Some(tx.clone());
498 let handle = app.clone();
499 let spawned = std::thread::Builder::new().name("g1t-runner".into()).spawn(move || supervise(handle, tx, rx));
500 if let Err(error) = spawned {
501 eprintln!("g1t: the runner's thread could not start: {error}");
502 }
503}
504
505/// The runner's thread, kept apart from the app: it starts only once the
506/// window and the site's page are up, and a panic in it ends neither the
507/// app nor the runner for good: the keeper starts again a little later,
508/// reaping what the last one left, as it does after a crash of the app.
509fn supervise(app: AppHandle, tx: Sender<Ask>, rx: Receiver<Ask>) {
510 let mut held = match settle(&app, &rx) {
511 Ok(held) => held,
512 Err(done) => {
513 let _ = done.send(());
514 return;
515 }
516 };
517 loop {
518 let keeper = Keeper::new(app.clone(), tx.clone());
519 let asks = std::mem::take(&mut held);
520 match catch_unwind(AssertUnwindSafe(|| keeper.keep(&rx, asks))) {
521 Ok(()) => return,
522 Err(panic) => {
523 let why = panic.downcast_ref::<&str>().map(|s| (*s).to_owned()).or_else(|| panic.downcast_ref::<String>().cloned()).unwrap_or_default();
524 eprintln!("g1t: the runner's thread failed ({why}); starting it again in {}s", AFTER_PANIC.as_secs());
525 // Asks that come meanwhile wait for the next keeper; Quit ends it all.
526 let until = Instant::now() + AFTER_PANIC;
527 while let Some(left) = until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()) {
528 match rx.recv_timeout(left) {
529 Ok(Ask::Quit(done)) => {
530 let _ = done.send(());
531 return;
532 }
533 Ok(ask) => held.push(ask),
534 Err(RecvTimeoutError::Timeout) => break,
535 Err(RecvTimeoutError::Disconnected) => return,
536 }
537 }
538 }
539 }
540 }
541}
542
543/// Waits, doing nothing the window could notice, until the site's page has
544/// loaded (or SITE_WAIT has passed) and AFTER_START more: the runner starts
545/// with the app, but never in its way. What is asked meanwhile is kept for
546/// after; Quit returns its sender at once.
547fn settle(app: &AppHandle, rx: &Receiver<Ask>) -> Result<Vec<Ask>, Sender<()>> {
548 let began = Instant::now();
549 let mut loaded: Option<Instant> = None;
550 let mut held = Vec::new();
551 loop {
552 if loaded.is_none() && (config::site_ready(app) || began.elapsed() >= SITE_WAIT) {
553 loaded = Some(Instant::now());
554 }
555 if loaded.is_some_and(|at| at.elapsed() >= AFTER_START) {
556 return Ok(held);
557 }
558 match rx.recv_timeout(Duration::from_millis(250)) {
559 Ok(Ask::Quit(done)) => return Err(done),
560 Ok(ask) => held.push(ask),
561 Err(RecvTimeoutError::Timeout) => {}
562 Err(RecvTimeoutError::Disconnected) => return Ok(held),
563 }
564 }
565}
566
567/// Settings changed: the thread looks again.
568pub fn settings_changed(app: &AppHandle) {
569 ask(app, Ask::Look);
570 publish(app);
571}
572
573/// Start or Stop, from settings or the tray.
574pub fn set_paused(app: &AppHandle, paused: bool) {
575 *app.state::<Shared>().paused.held() = paused;
576 // Start with the setting off turns the setting on: that is what was asked.
577 if !paused && !config::settings(app).runner {
578 let mut settings = config::settings(app);
579 settings.runner = true;
580 let _ = settings::apply(app, settings);
581 }
582 settings_changed(app);
583}
584
585/// Start or Stop on one kind of job's card: kept in settings, and the
586/// thread starts or stops that kind alone. Starting one with the runner
587/// off or stopped starts the runner too: that is what was asked.
588pub fn set_kind(app: &AppHandle, kind: &str, on: bool) -> Result<(), String> {
589 let mut settings = config::settings(app);
590 if !settings.runner_capabilities.set(kind, on) {
591 return Err(format!("g1t runs no {kind} jobs"));
592 }
593 if on {
594 settings.runner = true;
595 *app.state::<Shared>().paused.held() = false;
596 }
597 settings::apply(app, settings)?;
598 if on {
599 ask(app, Ask::Soon);
600 }
601 settings_changed(app);
602 Ok(())
603}
604
605/// The tray's Start or Stop row.
606pub fn toggle(app: &AppHandle) {
607 let view = view(app);
608 set_paused(app, view.enabled && !view.paused);
609}
610
611/// Retry and Restart: now, rather than when the wait is over.
612pub fn retry(app: &AppHandle) {
613 *app.state::<Shared>().paused.held() = false;
614 ask(app, Ask::Retry);
615}
616
617/// What the site says about the person, on every page: whether they are
618/// signed in, the workspaces they own, and the one they are in.
619pub fn offer(app: &AppHandle, signed_in: bool, owned: Vec<Offered>, current: Option<String>) {
620 let next = Site { signed_in: Some(signed_in), owned, current: current.map(|c| c.to_ascii_lowercase()) };
621 let changed = {
622 let shared = app.state::<Shared>();
623 let mut site = shared.site.held();
624 let changed = *site != next;
625 *site = next;
626 changed
627 };
628 if changed {
629 settings_changed(app);
630 }
631}
632
633/// The site handed over a registration token, or said why it could not:
634/// `allowed` is false when the person may not add runners to the workspace.
635pub fn register(app: &AppHandle, workspace: String, token: Option<String>, allowed: bool, error: Option<String>) {
636 match token {
637 Some(token) if token.starts_with("g1trt_") => ask(app, Ask::Token { workspace, token }),
638 _ => ask(app, Ask::Refused { workspace, allowed, why: error.unwrap_or_else(|| "g1t didn't make a registration token.".into()) }),
639 }
640}
641
642/// A diagnosis's button, or Start and Stop: from the settings window, or
643/// from the site's card for this computer. With a kind of job (`linux`,
644/// `windows`), Start and Stop are that kind's alone.
645pub fn act(app: &AppHandle, action: &str, kind: Option<&str>) -> Result<(), String> {
646 use tauri_plugin_opener::OpenerExt;
647 let browse = |url: &str| app.opener().open_url(url, None::<&str>).map_err(|e| e.to_string());
648 if let ("start" | "stop", Some(kind)) = (action, kind) {
649 return set_kind(app, kind, action == "start");
650 }
651 match action {
652 "start" => {
653 set_paused(app, false);
654 retry(app);
655 }
656 "stop" => set_paused(app, true),
657 "retry" | "restart" => retry(app),
658 "remove_sandbox" => {
659 *app.state::<Shared>().paused.held() = false;
660 ask(app, Ask::RemoveSandbox);
661 }
662 "check_updates" => ask(app, Ask::Check),
663 "sign_in" => crate::links::go(app, "/login"),
664 "new_workspace" => crate::links::go(app, "/workspaces/new"),
665 "view_runners" => match workspace(app) {
666 Some(workspace) => crate::links::go(app, &format!("/{workspace}/-/runners")),
667 None => crate::links::go(app, "/"),
668 },
669 // They are chosen in the settings' Runner section.
670 "choose_workspace" | "choose_backend" => settings::open_runner_settings(app),
671 "open_runner" => settings::runner_menu(app, false, None),
672 // The Runner window, bundled with the app: where jobs are taken out
673 // of the sandbox, which no page from the network may do (bridge.rs).
674 "runner_window" => settings::open_runner_window(app),
675 "view_log" => {
676 let path = log_file(app);
677 if !path.exists() {
678 return Err("The runner hasn't written a log yet.".into());
679 }
680 app.opener().open_path(path.to_string_lossy(), None::<&str>).map_err(|e| e.to_string())?;
681 }
682 "open_docker" => open_docker(app)?,
683 "copy_wsl_install" => copy(launcher::WSL_INSTALL)?,
684 "copy_vfkit_install" => copy(launcher::VFKIT_INSTALL)?,
685 "windows_features" => windows_features()?,
686 "get_docker" => browse("https://docs.docker.com/get-started/get-docker/")?,
687 "install_sysbox" => browse("https://github.com/nestybox/sysbox#installation")?,
688 "learn_more" => browse("https://docs.g1t.sh/guides/desktop/#how-jobs-run")?,
689 "report" => browse(config::server(app).join("support").map_err(|e| e.to_string())?.as_str())?,
690 other => return Err(format!("the runner has no action called {other}")),
691 }
692 Ok(())
693}
694
695/// Starts Docker Desktop, where it is installed in its usual place.
696fn open_docker(app: &AppHandle) -> Result<(), String> {
697 use tauri_plugin_opener::OpenerExt;
698 if cfg!(target_os = "macos") {
699 return app.opener().open_path("/Applications/Docker.app", None::<&str>).map_err(|e| e.to_string());
700 }
701 if cfg!(windows) {
702 let program = std::env::var_os("ProgramFiles").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(r"C:\Program Files"));
703 let desktop = program.join("Docker").join("Docker").join("Docker Desktop.exe");
704 if desktop.exists() {
705 return app.opener().open_path(desktop.to_string_lossy(), None::<&str>).map_err(|e| e.to_string());
706 }
707 return Err("Docker Desktop isn't installed in its usual place. Get Docker to install it.".into());
708 }
709 Err("Start Docker with your system's service manager, such as `sudo systemctl start docker`.".into())
710}
711
712/// Puts a command on the clipboard, for the person to paste where it runs.
713fn copy(text: &str) -> Result<(), String> {
714 let tools: &[(&str, &[&str])] = if cfg!(windows) {
715 &[("clip", &[])]
716 } else if cfg!(target_os = "macos") {
717 &[("pbcopy", &[])]
718 } else {
719 &[("wl-copy", &[]), ("xclip", &["-selection", "clipboard"]), ("xsel", &["--clipboard", "--input"])]
720 };
721 for (program, args) in tools {
722 let Ok(mut child) = backend::quiet(program).args(*args).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else { continue };
723 if let Some(mut input) = child.stdin.take() {
724 let _ = input.write_all(text.as_bytes());
725 }
726 if child.wait().is_ok_and(|status| status.success()) {
727 return Ok(());
728 }
729 }
730 Err(format!("It couldn't be copied. The command is: {text}"))
731}
732
733/// Windows' "Turn Windows features on or off", where Windows Sandbox and
734/// Virtual Machine Platform are.
735fn windows_features() -> Result<(), String> {
736 if !cfg!(windows) {
737 return Err("Windows features are only on Windows.".into());
738 }
739 backend::quiet("optionalfeatures.exe").spawn().map(|_| ()).map_err(|e| format!("Windows features couldn't be opened: {e}"))
740}
741
742/// The workspace a token is wanted for now, if one is: the site asks as it loads.
743pub fn wanted(app: &AppHandle) -> Option<String> {
744 app.state::<Shared>().wanted.held().clone()
745}
746
747/// The runner's log, for the settings window to open.
748pub fn log_file(app: &AppHandle) -> PathBuf {
749 folder(app).join("runner.log")
750}
751
752/// Stops the runner as g1t quits or restarts, and waits for it.
753pub fn shutdown(app: &AppHandle) {
754 let tx = app.state::<Shared>().tx.held().take();
755 if let Some(tx) = tx {
756 let (done, wait) = mpsc::channel();
757 if tx.send(Ask::Quit(done)).is_ok() {
758 let _ = wait.recv_timeout(Duration::from_secs(30));
759 }
760 }
761}
762
763/// How the runner is started this time.
764enum Process {
765 /// `g1t-runner`, a child of the app, whose output is the runner's log.
766 Child(Child),
767 /// The sandbox, which runs on its own once `up` is done; the launcher
768 /// command the app is waiting for, while there is one.
769 Sandbox(Option<(Step, Child)>),
770}
771
772/// A launcher command that takes a while.
773#[derive(Clone, Debug, PartialEq, Eq)]
774enum Step {
775 Up,
776 Update,
777 /// `up --only <kind>`: one kind of job, started while the rest run.
778 Start(String),
779 /// `down --only <kind>`: one kind of job, stopped, the rest running on.
780 Stop(String),
781 /// `down --only <kind> --drain`: one kind of job, stopped after its
782 /// job, so the program its runner is can be replaced.
783 Drain(String),
784}
785
786impl Step {
787 fn name(&self) -> &'static str {
788 match self {
789 Step::Up | Step::Start(_) => "up",
790 Step::Update => "update",
791 Step::Stop(_) | Step::Drain(_) => "down",
792 }
793 }
794}
795
796struct Run {
797 generation: u64,
798 engine: Engine,
799 backend: Backend,
800 workspace: String,
801 process: Process,
802 /// Registered and polling.
803 listening: bool,
804 busy: bool,
805 /// When a poll last failed to reach g1t.
806 offline_at: Option<Instant>,
807 /// Started with a registration token, which a container keeps in its configuration until replaced.
808 with_token: bool,
809 forgotten: bool,
810 token_refused: bool,
811 error: Option<String>,
812 /// The status a launcher command is printing, line by line.
813 json: Option<Vec<String>>,
814 /// Asking the sandbox how it is, and when it was last asked.
815 polling: bool,
816 polled_at: Option<Instant>,
817 /// A poll asked before a launcher command ended, whose answer is old.
818 stale: bool,
819}
820
821impl Run {
822 fn new(generation: u64, engine: Engine, backend: Backend, workspace: String, process: Process, with_token: bool) -> Run {
823 Run {
824 generation,
825 engine,
826 backend,
827 workspace,
828 process,
829 listening: false,
830 busy: false,
831 offline_at: None,
832 with_token,
833 forgotten: false,
834 token_refused: false,
835 error: None,
836 json: None,
837 polling: false,
838 polled_at: None,
839 stale: false,
840 }
841 }
842
843 fn step(&self) -> Option<Step> {
844 match &self.process {
845 Process::Sandbox(Some((step, _))) => Some(step.clone()),
846 _ => None,
847 }
848 }
849}
850
851struct Keeper {
852 app: AppHandle,
853 tx: Sender<Ask>,
854 run: Option<Run>,
855 generation: u64,
856 token: Option<(String, String)>,
857 not_before: Instant,
858 failures: u32,
859 next_update: Instant,
860 log: Option<File>,
861 /// Why the last update failed (`UpdateFailed`, or `UpdateRefused` for
862 /// a release g1t didn't sign), said while the runner is idle, until
863 /// the next one works.
864 update_failed: Option<(Code, String)>,
865 /// A newer program, downloaded and checked, waiting for the kinds of
866 /// job that run on it to be idle: its version and its bytes.
867 program: Option<(String, Vec<u8>)>,
868 /// The person asked for this update: it installs with "Download
869 /// updates by themselves" off.
870 asked: bool,
871 /// When g1t last looked, as settings last said it.
872 said_checked: String,
873 /// A kind of job that should run and doesn't, while another runs:
874 /// when it is tried again, and how many times it has been.
875 retry: Vec<(String, Instant, u32)>,
876}
877
878/// What a set of the state carries besides its code.
879#[derive(Default)]
880struct Extra {
881 detail: Option<String>,
882 exit_code: Option<i32>,
883 wait_seconds: Option<u64>,
884 fix: Option<String>,
885}
886
887fn detail(text: impl Into<String>) -> Extra {
888 Extra { detail: Some(text.into()), ..Extra::default() }
889}
890
891impl Keeper {
892 fn new(app: AppHandle, tx: Sender<Ask>) -> Keeper {
893 let now = Instant::now();
894 Keeper {
895 app,
896 tx,
897 run: None,
898 generation: 0,
899 token: None,
900 not_before: now + AFTER_START,
901 failures: 0,
902 next_update: now + FIRST_UPDATE,
903 log: None,
904 update_failed: None,
905 program: None,
906 asked: false,
907 said_checked: String::new(),
908 retry: Vec::new(),
909 }
910 }
911
912 fn keep(mut self, rx: &Receiver<Ask>, asks: Vec<Ask>) {
913 self.reap();
914 self.set(Code::Starting, detail("Starting after g1t's window."));
915 for ask in asks {
916 self.handle(ask);
917 }
918 loop {
919 match rx.recv_timeout(TICK) {
920 Ok(Ask::Quit(done)) => {
921 self.halt();
922 let _ = done.send(());
923 return;
924 }
925 Ok(ask) => self.handle(ask),
926 Err(RecvTimeoutError::Timeout) => {}
927 Err(RecvTimeoutError::Disconnected) => {
928 self.halt();
929 return;
930 }
931 }
932 self.look();
933 self.said_when();
934 }
935 }
936
937 /// Settings say when g1t last looked for a newer runner, in minutes
938 /// and hours: said again as that changes.
939 fn said_when(&mut self) {
940 let (checked_at, checking) = self.with_updates(|updates| (updates.checked_at, updates.checking));
941 let checked_ago = checked_at.and_then(|at| at.elapsed().ok()).map(|ago| ago.as_secs());
942 let checked = diagnosis::checked(&diagnosis::Update { checked_ago, checking, ..diagnosis::Update::default() });
943 if checked != self.said_checked {
944 self.said_checked = checked;
945 publish(&self.app);
946 }
947 }
948
949 fn with_updates<R>(&self, change: impl FnOnce(&mut Updates) -> R) -> R {
950 change(&mut self.app.state::<Shared>().updates.held())
951 }
952
953 /// What an idle runner shows: why its last update failed, until one works.
954 fn idle(&self, code: Code) -> (Code, Extra) {
955 match (&self.update_failed, code) {
956 (Some((failed, why)), Code::Idle) => (*failed, detail(why.clone())),
957 _ => (code, Extra::default()),
958 }
959 }
960
961 fn set(&mut self, code: Code, extra: Extra) {
962 let changed = {
963 let shared = self.app.state::<Shared>();
964 let mut now = shared.now.held();
965 let job = if code == Code::Busy { now.job.clone() } else { None };
966 let next = Now {
967 code,
968 detail: extra.detail,
969 exit_code: extra.exit_code,
970 wait_seconds: extra.wait_seconds,
971 job,
972 fix: extra.fix,
973 live: self.run.as_ref().is_some_and(|run| run.engine == Engine::Launcher),
974 ..now.clone()
975 };
976 let changed = *now != next;
977 *now = next;
978 changed
979 };
980 if changed {
981 publish(&self.app);
982 }
983 }
984
985 fn code(&self) -> Code {
986 self.app.state::<Shared>().now.held().code
987 }
988
989 fn note(&mut self, job: Option<String>, version: Option<String>) {
990 let shared = self.app.state::<Shared>();
991 let mut now = shared.now.held();
992 if job.is_some() {
993 now.job = job;
994 }
995 if version.is_some() {
996 now.version = version;
997 }
998 }
999
1000 /// A line of the runner's log, into the log file and the tail settings show.
1001 fn write_log(&mut self, line: &str) {
1002 {
1003 let shared = self.app.state::<Shared>();
1004 let mut tail = shared.tail.held();
1005 if tail.len() == TAIL {
1006 tail.pop_front();
1007 }
1008 tail.push_back(line.to_owned());
1009 }
1010 let path = log_file(&self.app);
1011 if self.log.is_none() {
1012 let _ = std::fs::create_dir_all(folder(&self.app));
1013 if std::fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) {
1014 let _ = std::fs::rename(&path, path.with_extension("log.old"));
1015 }
1016 self.log = OpenOptions::new().create(true).append(true).open(&path).ok();
1017 }
1018 if let Some(file) = self.log.as_mut() {
1019 let _ = writeln!(file, "{line}");
1020 }
1021 }
1022
1023 fn handle(&mut self, ask: Ask) {
1024 match ask {
1025 Ask::Look | Ask::Quit(_) => {}
1026 Ask::Soon => {
1027 self.not_before = Instant::now();
1028 for (_, at, _) in &mut self.retry {
1029 *at = Instant::now();
1030 }
1031 }
1032 Ask::RemoveSandbox => {
1033 self.set(Code::Starting, detail("Removing this computer's sandbox."));
1034 self.halt();
1035 self.forget_sandbox();
1036 self.failures = 0;
1037 self.not_before = Instant::now();
1038 self.set(Code::Starting, detail("Starting a sandbox of g1t's own."));
1039 }
1040 Ask::Retry => {
1041 self.not_before = Instant::now();
1042 self.failures = 0;
1043 // Each kind of job that waits to be tried again is tried now.
1044 for (_, at, _) in &mut self.retry {
1045 *at = Instant::now();
1046 }
1047 match self.code() {
1048 // The runner is fine: only the update is tried again.
1049 Code::UpdateFailed | Code::UpdateRefused if self.run.is_some() => self.check(true),
1050 Code::Offline => self.halt(),
1051 _ if self.run.as_ref().is_some_and(|run| !run.listening) => self.halt(),
1052 _ => {}
1053 }
1054 }
1055 Ask::Check => self.check(true),
1056 Ask::Checked(found) => self.checked(found),
1057 Ask::Token { workspace, token } => {
1058 let shared = self.app.state::<Shared>();
1059 let mut wanted = shared.wanted.held();
1060 if wanted.as_deref() == Some(workspace.as_str()) {
1061 *wanted = None;
1062 self.token = Some((workspace, token));
1063 self.not_before = Instant::now();
1064 }
1065 }
1066 Ask::Refused { workspace, allowed, why } => {
1067 let matched = {
1068 let shared = self.app.state::<Shared>();
1069 let mut wanted = shared.wanted.held();
1070 let matched = wanted.as_deref() == Some(workspace.as_str());
1071 if matched {
1072 *wanted = None;
1073 }
1074 matched
1075 };
1076 if matched {
1077 self.not_before = Instant::now() + MOST_BACKOFF;
1078 self.set(if allowed { Code::RegisterFailed } else { Code::NotAllowed }, detail(why));
1079 }
1080 }
1081 Ask::Line(generation, line, out) => self.line(generation, &line, out),
1082 Ask::Exited(generation) => self.exited(generation),
1083 Ask::Sandbox(generation, status) => self.polled(generation, status),
1084 }
1085 }
1086
1087 fn line(&mut self, generation: u64, line: &str, out: bool) {
1088 let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return };
1089 if run.engine == Engine::Launcher {
1090 // The launcher's log, then its status, which is read when it ends.
1091 if out && (run.json.is_some() || launcher::starts_status(line)) {
1092 run.json.get_or_insert_with(Vec::new).push(line.to_owned());
1093 } else {
1094 if let Some(download) = launcher::download(line) {
1095 let changed = {
1096 let shared = self.app.state::<Shared>();
1097 let mut now = shared.now.held();
1098 let changed = now.download.as_ref() != Some(&download);
1099 now.download = Some(download);
1100 changed
1101 };
1102 if changed {
1103 publish(&self.app);
1104 }
1105 }
1106 self.write_log(line);
1107 }
1108 return;
1109 }
1110 let mut became = None;
1111 let mut job = None;
1112 let mut version = None;
1113 match log::read(line) {
1114 log::Event::Listening { version: v } => {
1115 run.listening = true;
1116 version = Some(v);
1117 became = Some(Code::Idle);
1118 }
1119 log::Event::Took { name, repo } => {
1120 run.busy = true;
1121 run.offline_at = None;
1122 job = Some(format!("{name} ({repo})"));
1123 became = Some(Code::Busy);
1124 }
1125 log::Event::Ended => {
1126 run.busy = false;
1127 became = Some(Code::Idle);
1128 }
1129 log::Event::Offline => {
1130 run.offline_at = Some(Instant::now());
1131 if !run.busy {
1132 became = Some(Code::Offline);
1133 }
1134 }
1135 log::Event::Forgotten => run.forgotten = true,
1136 log::Event::TokenRefused => run.token_refused = true,
1137 log::Event::Failed(error) => run.error = Some(error),
1138 log::Event::Other => {}
1139 }
1140 let registered = version.is_some().then(|| Registration {
1141 server: config::server(&self.app).to_string(),
1142 workspace: run.workspace.clone(),
1143 backend: run.backend,
1144 });
1145 self.write_log(line);
1146 if let Some(registration) = registered {
1147 registration.save(&self.app);
1148 self.failures = 0;
1149 }
1150 self.note(job, version);
1151 if let Some(code) = became {
1152 let (code, extra) = self.idle(code);
1153 self.set(code, extra);
1154 }
1155 }
1156
1157 fn exited(&mut self, generation: u64) {
1158 if self.run.as_ref().is_none_or(|run| run.generation != generation) {
1159 return;
1160 }
1161 if self.run.as_ref().is_some_and(|run| run.engine == Engine::Launcher) {
1162 return self.launched(generation);
1163 }
1164 let mut run = self.run.take().expect("checked above");
1165 let exit_code = match &mut run.process {
1166 Process::Child(child) => child.wait().ok().and_then(|status| status.code()),
1167 Process::Sandbox(_) => None,
1168 };
1169 self.stop(&mut run, Down::Now);
1170 if run.forgotten || run.token_refused {
1171 // The registration is no more, or was never made: the next start asks for a token.
1172 self.forget();
1173 self.not_before = Instant::now();
1174 return;
1175 }
1176 self.failures += 1;
1177 let wait = Duration::from_secs(15 * 2u64.pow(self.failures.min(6))).min(MOST_BACKOFF);
1178 self.not_before = Instant::now() + wait;
1179 let offline = run.error.as_deref().is_some_and(|e| e.contains("could not reach g1t"));
1180 if offline {
1181 self.set(Code::Offline, Extra::default());
1182 } else if !run.listening && Registration::load(&self.app).is_none() {
1183 self.set(Code::RegisterFailed, Extra { detail: run.error, ..Extra::default() });
1184 } else {
1185 self.set(Code::Crashed, Extra { detail: run.error, exit_code, wait_seconds: Some(wait.as_secs()), ..Extra::default() });
1186 }
1187 }
1188
1189 /// Stops a run's process, the way it was started: the sandbox `how` says.
1190 fn stop(&mut self, run: &mut Run, how: Down) {
1191 match (&mut run.process, run.engine) {
1192 (Process::Sandbox(step), _) => {
1193 // A launcher command still going is ended first, then the sandbox stopped.
1194 if let Some((_, child)) = step.as_mut() {
1195 native::stop(child);
1196 }
1197 let binary = native::binary(&folder(&self.app));
1198 // How it is once stopped, so each kind of job's card says so.
1199 let stopped = match launcher::down(&binary, &native_dir(&self.app, Backend::Sandboxed), how) {
1200 Ok(status) => Some(status),
1201 Err(why) => {
1202 self.write_log(&format!("g1t: the sandbox didn't stop cleanly: {why}"));
1203 None
1204 }
1205 };
1206 let shared = self.app.state::<Shared>();
1207 let mut now = shared.now.held();
1208 now.sandbox = stopped.filter(|_| how != Down::Delete);
1209 now.starting.clear();
1210 now.download = None;
1211 }
1212 (Process::Child(child), Engine::Launcher) => native::stop(child),
1213 (Process::Child(child), Engine::Native { docker }) => {
1214 native::stop(child);
1215 // A job's container outlives the runner killed under it: removed now, not at its next start.
1216 if docker {
1217 native::sweep(&native_dir(&self.app, run.backend));
1218 }
1219 }
1220 }
1221 let _ = std::fs::remove_file(folder(&self.app).join("pid"));
1222 }
1223
1224 /// Stops whatever runs now.
1225 fn halt(&mut self) {
1226 self.halt_as(Down::Now);
1227 }
1228
1229 /// Stops whatever runs: the sandbox now, after its job, or for good.
1230 fn halt_as(&mut self, how: Down) {
1231 self.generation += 1;
1232 if let Some(mut run) = self.run.take() {
1233 self.stop(&mut run, how);
1234 }
1235 }
1236
1237 /// A runner an earlier g1t started and did not stop: it ended without quitting.
1238 fn reap(&self) {
1239 if let Some(pid) = std::fs::read_to_string(folder(&self.app).join("pid")).ok().and_then(|text| text.trim().parse().ok()) {
1240 native::reap(pid);
1241 let _ = std::fs::remove_file(folder(&self.app).join("pid"));
1242 }
1243 }
1244
1245 /// Unregisters the runner the app registered, whatever backend it was.
1246 fn forget(&mut self) {
1247 let app = self.app.clone();
1248 let Some(registration) = Registration::load(&app) else { return };
1249 let binary = native::binary(&folder(&app));
1250 match registration.backend {
1251 // The sandbox keeps its registration for when it is chosen
1252 // again (sandbox-registration.json says whose): it only stops,
1253 // after its job.
1254 Backend::Sandboxed => {
1255 let _ = launcher::down(&binary, &native_dir(&app, Backend::Sandboxed), Down::Drain);
1256 }
1257 backend => native::forget(&binary, &native_dir(&app, backend)),
1258 }
1259 Registration::clear(&app);
1260 }
1261
1262 /// Removes the sandbox and its registration: it was for another
1263 /// workspace, or g1t no longer knows its runners.
1264 fn forget_sandbox(&mut self) {
1265 let binary = native::binary(&folder(&self.app));
1266 if let Err(why) = launcher::down(&binary, &native_dir(&self.app, Backend::Sandboxed), Down::Delete) {
1267 self.write_log(&format!("g1t: the sandbox couldn't be removed: {why}"));
1268 }
1269 SandboxRegistration::clear(&self.app);
1270 if Registration::load(&self.app).is_some_and(|r| r.backend == Backend::Sandboxed) {
1271 Registration::clear(&self.app);
1272 }
1273 }
1274
1275 /// Whether what a native runner keeps of a registration is still here.
1276 fn kept(&self, backend: Backend) -> bool {
1277 native_dir(&self.app, backend).join("config.json").exists()
1278 }
1279
1280 /// Asks the site for a registration token for `workspace`.
1281 fn want(&mut self, workspace: &str) {
1282 *self.app.state::<Shared>().wanted.held() = Some(workspace.to_owned());
1283 let _ = self.app.emit_to(EventTarget::webview(frame::SITE), WANTED_EVENT, workspace.to_owned());
1284 self.not_before = Instant::now() + ASK_AGAIN;
1285 let signed_out = self.app.state::<Shared>().site.held().signed_in == Some(false);
1286 self.set(if signed_out { Code::SignedOut } else { Code::Registering }, Extra::default());
1287 }
1288
1289 /// The thread's look round: what should run, and whether it does.
1290 fn look(&mut self) {
1291 let settings = config::settings(&self.app);
1292 let paused = *self.app.state::<Shared>().paused.held();
1293 if !settings.runner || paused {
1294 if self.run.is_some() {
1295 self.halt();
1296 }
1297 *self.app.state::<Shared>().wanted.held() = None;
1298 self.set(if paused { Code::Stopped } else { Code::Off }, Extra::default());
1299 return;
1300 }
1301 // Looking needs a runner here to compare with; the first comes with the first start.
1302 if Instant::now() >= self.next_update && native::installed(&folder(&self.app)).is_some() {
1303 self.check(false);
1304 }
1305 let workspace = workspace(&self.app);
1306 if let Some(run) = &self.run {
1307 let moved = run.backend != settings.runner_backend || Some(&run.workspace) != workspace.as_ref();
1308 let back = run.offline_at.is_some_and(|at| at.elapsed() >= BACK_ONLINE);
1309 let (busy, stepping) = (run.busy, run.step().is_some());
1310 if back {
1311 if let Some(run) = self.run.as_mut() {
1312 run.offline_at = None;
1313 }
1314 self.set(Code::Idle, Extra::default());
1315 }
1316 // A change of backend or workspace waits for the job to end, as an update does.
1317 if moved && !busy && !stepping {
1318 self.halt_as(Down::Drain);
1319 self.not_before = Instant::now();
1320 } else if !moved && !stepping && !self.install() {
1321 self.kinds(workspace.as_deref());
1322 }
1323 self.poll();
1324 return;
1325 }
1326 if Instant::now() < self.not_before {
1327 return;
1328 }
1329 // Nothing runs: a waiting program goes in before anything starts.
1330 if self.program.is_some()
1331 && self.allowed()
1332 && let Some((version, bytes)) = self.program.take()
1333 {
1334 self.put(version, &bytes);
1335 }
1336 self.start(settings.runner_backend, workspace);
1337 }
1338
1339 fn start(&mut self, backend: Backend, workspace: Option<String>) {
1340 let app = self.app.clone();
1341 let engine = match backend::engine(backend) {
1342 Ok(engine) => engine,
1343 Err(missing) => {
1344 self.not_before = Instant::now() + LOOK_AGAIN;
1345 let code = match missing {
1346 Missing::Docker => Code::NoDocker,
1347 };
1348 self.set(code, Extra::default());
1349 return;
1350 }
1351 };
1352 let Some(workspace) = workspace else {
1353 self.not_before = Instant::now() + LOOK_AGAIN;
1354 let code = match app.state::<Shared>().site.held().signed_in {
1355 Some(false) => Code::SignedOut,
1356 Some(true) => Code::NoWorkspace,
1357 None => Code::Starting,
1358 };
1359 self.set(code, detail("Waiting for g1t to open."));
1360 return;
1361 };
1362 if engine == Engine::Launcher {
1363 return self.start_sandbox(workspace);
1364 }
1365 let server = config::server(&app);
1366 let registration = Registration::load(&app);
1367 let ours = registration.as_ref().is_some_and(|r| r.server == server.as_str() && r.workspace == workspace && r.backend == backend) && self.kept(backend);
1368 let mut token = None;
1369 if !ours {
1370 if registration.is_some() {
1371 self.set(Code::Starting, detail("Removing this computer's earlier registration."));
1372 self.forget();
1373 }
1374 match self.token.take() {
1375 Some((for_workspace, given)) if for_workspace == workspace => token = Some(given),
1376 _ => return self.want(&workspace),
1377 }
1378 }
1379 self.hold_workspace(&workspace);
1380 self.set(Code::Starting, Extra::default());
1381 let envs = environment(&server, backend, token.as_deref());
1382 let generation = self.generation + 1;
1383 self.generation = generation;
1384 let binary = match self.ensure_binary(&server) {
1385 Ok(binary) => binary,
1386 Err(fault) => return self.not_installed(workspace, token, fault),
1387 };
1388 let started = native::spawn(&binary, &native_dir(&app, backend), &envs).map_err(|e| format!("g1t-runner could not start: {e}")).inspect(|child| {
1389 let _ = std::fs::write(folder(&app).join("pid"), child.id().to_string());
1390 });
1391 match started {
1392 Ok(mut child) => {
1393 self.follow(generation, &mut child);
1394 self.run = Some(Run::new(generation, engine, backend, workspace, Process::Child(child), token.is_some()));
1395 self.set(Code::Starting, Extra::default());
1396 }
1397 Err(why) => self.not_started(workspace, token, why),
1398 }
1399 }
1400
1401 /// The runner couldn't be started: the token is kept for the next try,
1402 /// while it lasts, and it is tried again in a while.
1403 fn not_started(&mut self, workspace: String, token: Option<String>, why: String) {
1404 if let Some(given) = token {
1405 self.token = Some((workspace, given));
1406 }
1407 self.failures += 1;
1408 self.not_before = Instant::now() + LOOK_AGAIN;
1409 let offline = why.contains("error sending request") || why.contains("dns") || why.contains("connect");
1410 self.write_log(&format!("g1t: the runner could not start: {why}"));
1411 if offline {
1412 self.set(Code::Offline, Extra::default());
1413 } else {
1414 self.set(Code::Crashed, Extra { detail: Some(why), wait_seconds: Some(LOOK_AGAIN.as_secs()), ..Extra::default() });
1415 }
1416 }
1417
1418 /// The setting names the workspace from now on, so browsing another one never moves the runner.
1419 fn hold_workspace(&self, workspace: &str) {
1420 if config::settings(&self.app).runner_workspace.as_deref() != Some(workspace) {
1421 let mut settings = config::settings(&self.app);
1422 settings.runner_workspace = Some(workspace.to_owned());
1423 let _ = config::set_settings(&self.app, settings);
1424 }
1425 }
1426
1427 /// Reads the child's output, a line at a time, onto the thread's channel.
1428 fn follow(&self, generation: u64, child: &mut Child) {
1429 fn pipe(tx: Sender<Ask>, generation: u64, from: impl Read + Send + 'static, last: bool) {
1430 std::thread::spawn(move || {
1431 for line in BufReader::new(from).lines().map_while(Result::ok) {
1432 if tx.send(Ask::Line(generation, line, last)).is_err() {
1433 return;
1434 }
1435 }
1436 if last {
1437 let _ = tx.send(Ask::Exited(generation));
1438 }
1439 });
1440 }
1441 if let Some(err) = child.stderr.take() {
1442 pipe(self.tx.clone(), generation, err, false);
1443 }
1444 if let Some(out) = child.stdout.take() {
1445 pipe(self.tx.clone(), generation, out, true);
1446 }
1447 }
1448
1449 /// `g1t-runner` installed, from the newest release when it is not:
1450 /// one g1t signed, as every update is.
1451 fn ensure_binary(&mut self, server: &Url) -> Result<PathBuf, Fault> {
1452 let folder = folder(&self.app);
1453 let binary = native::binary(&folder);
1454 if native::installed(&folder).is_some() {
1455 return Ok(binary);
1456 }
1457 self.set(Code::Updating, detail("Downloading g1t-runner."));
1458 let manifest = native::latest(server)?;
1459 let bytes = native::download(server, &manifest).map_err(Fault::Failed)?;
1460 native::put(&folder, &bytes, &manifest.version).map_err(Fault::Failed)?;
1461 self.note(None, Some(manifest.version));
1462 self.with_updates(|updates| updates.checked_at = Some(SystemTime::now()));
1463 Ok(binary)
1464 }
1465
1466 /// There is no `g1t-runner` here, and none could be had: a release g1t
1467 /// didn't sign is refused, and asked for again in a while.
1468 fn not_installed(&mut self, workspace: String, token: Option<String>, fault: Fault) {
1469 match fault {
1470 Fault::Failed(why) => self.not_started(workspace, token, why),
1471 Fault::Unsigned(why) => {
1472 if let Some(given) = token {
1473 self.token = Some((workspace, given));
1474 }
1475 self.write_log(&format!("g1t: g1t-runner wasn't installed: {why}"));
1476 self.not_before = Instant::now() + MOST_BACKOFF;
1477 self.set(Code::UpdateRefused, detail(why));
1478 }
1479 }
1480 }
1481
1482 // --- Updates ------------------------------------------------------------------
1483
1484 /// Looks for a newer release on a thread of its own, so the runner is
1485 /// looked after meanwhile: its signed manifest, and its program when it
1486 /// is newer than the one here. `asked` is the person asking, which
1487 /// works with "Download updates by themselves" off.
1488 fn check(&mut self, asked: bool) {
1489 self.next_update = Instant::now() + UPDATE_EVERY;
1490 if !asked && !config::settings(&self.app).auto_update {
1491 return;
1492 }
1493 if self.with_updates(|updates| std::mem::replace(&mut updates.checking, true)) {
1494 return;
1495 }
1496 self.asked |= asked;
1497 publish(&self.app);
1498 let (server, folder) = (config::server(&self.app), folder(&self.app));
1499 let waiting = self.program.as_ref().map(|(version, _)| version.clone());
1500 let tx = self.tx.clone();
1501 let spawned = std::thread::Builder::new().name("g1t-runner-releases".into()).spawn(move || {
1502 let _ = tx.send(Ask::Checked(look_for_release(&server, &folder, waiting.as_deref())));
1503 });
1504 if spawned.is_err() {
1505 self.with_updates(|updates| updates.checking = false);
1506 }
1507 }
1508
1509 /// What the look found: a newer program waits for its kinds of job to
1510 /// be idle, and so does the sandbox's image when it is older than the
1511 /// release's. A release g1t didn't sign, or one that didn't download,
1512 /// leaves the runner as it is, and is said while it is idle.
1513 fn checked(&mut self, found: Result<Found, Fault>) {
1514 self.with_updates(|updates| updates.checking = false);
1515 let found = match found {
1516 Ok(found) => found,
1517 Err(fault) => {
1518 let failed = match fault {
1519 Fault::Unsigned(why) => (Code::UpdateRefused, why),
1520 Fault::Failed(why) => (Code::UpdateFailed, format!("g1t-runner couldn't be updated: {why}")),
1521 };
1522 self.write_log(&format!("g1t: {}", failed.1));
1523 self.update_failed = Some(failed);
1524 self.asked = false;
1525 if matches!(self.code(), Code::Idle | Code::UpdateFailed | Code::UpdateRefused) {
1526 let (code, extra) = self.idle(Code::Idle);
1527 self.set(code, extra);
1528 }
1529 return publish(&self.app);
1530 }
1531 };
1532 if let Some(bytes) = found.program {
1533 self.program = Some((found.version.clone(), bytes));
1534 }
1535 if !found.newer {
1536 self.program = None;
1537 }
1538 // The sandbox's image is the release's version; one older takes the newest.
1539 let sandbox = self.app.state::<Shared>().now.held().sandbox.clone();
1540 let launcher = self.run.as_ref().is_some_and(|run| run.engine == Engine::Launcher);
1541 let image = launcher
1542 && found.images
1543 && sandbox.is_some_and(|status| status.linux().state != State::Absent && status.image_version.as_deref().is_some_and(|have| native::newer(&found.version, have)));
1544 let program = self.program.is_some();
1545 self.with_updates(|updates| {
1546 updates.checked_at = Some(SystemTime::now());
1547 updates.program = program;
1548 updates.image = image;
1549 updates.ready = (program || image).then(|| found.version.clone());
1550 });
1551 if program || image {
1552 self.write_log(&format!("g1t: g1t-runner {} is ready, and installs as each kind of job is idle.", found.version));
1553 } else {
1554 self.asked = false;
1555 }
1556 // It worked: what the last failure said goes.
1557 if self.update_failed.take().is_some() && matches!(self.code(), Code::UpdateFailed | Code::UpdateRefused) {
1558 self.set(Code::Idle, Extra::default());
1559 }
1560 publish(&self.app);
1561 }
1562
1563 /// Whether a waiting update may go in: "Download updates by
1564 /// themselves" is on, or the person asked for it. Turned off since it
1565 /// downloaded, it no longer waits.
1566 fn allowed(&mut self) -> bool {
1567 if self.asked || config::settings(&self.app).auto_update {
1568 return true;
1569 }
1570 self.program = None;
1571 self.with_updates(|updates| {
1572 updates.program = false;
1573 updates.image = false;
1574 updates.ready = None;
1575 });
1576 false
1577 }
1578
1579 /// Puts a waiting update in, kind by kind, as each kind of job is idle:
1580 /// true when it started something, which is waited for. Never in the
1581 /// middle of a job.
1582 fn install(&mut self) -> bool {
1583 let image = self.with_updates(|updates| updates.image);
1584 if (self.program.is_none() && !image) || !self.allowed() {
1585 return false;
1586 }
1587 let Some((engine, busy, listening)) = self.run.as_ref().map(|run| (run.engine, run.busy, run.listening)) else { return false };
1588 if engine != Engine::Launcher {
1589 // A runner in Docker or on this computer is the program: stopped between jobs, started on the new one.
1590 if busy || !listening {
1591 return false;
1592 }
1593 let Some((version, bytes)) = self.program.take() else { return false };
1594 self.set(Code::Updating, detail(format!("Updating g1t-runner to {version}.")));
1595 self.halt();
1596 self.put(version, &bytes);
1597 self.not_before = Instant::now();
1598 return true;
1599 }
1600 let Some(status) = self.app.state::<Shared>().now.held().sandbox.clone() else { return false };
1601 if self.program.is_some() {
1602 let running = native_kinds(&status);
1603 if running.is_empty() {
1604 // Nothing runs on the program: it is replaced now, and
1605 // kinds() starts what was drained on the new one first,
1606 // before the image, which takes minutes, is updated.
1607 let (version, bytes) = self.program.take().expect("checked above");
1608 self.put(version, &bytes);
1609 return false;
1610 } else if !status.per_kind() {
1611 // A launcher from before kinds of job stopped on their own: the whole sandbox stops between jobs.
1612 if busy {
1613 return false;
1614 }
1615 let (version, bytes) = self.program.take().expect("checked above");
1616 self.set(Code::Updating, detail(format!("Updating g1t-runner to {version}.")));
1617 self.halt_as(Down::Drain);
1618 self.put(version, &bytes);
1619 self.with_updates(|updates| updates.image = true);
1620 self.not_before = Instant::now();
1621 return true;
1622 } else if let Some(idle) = running.iter().find(|kind| kind.job.is_none()) {
1623 // Stopped after any job it takes meanwhile; replaced once it has.
1624 let kind = idle.id.clone();
1625 self.with_updates(|updates| updates.installing = vec![kind.clone()]);
1626 self.kind_step(Step::Drain(kind), None);
1627 return true;
1628 }
1629 }
1630 // Linux jobs take the image once they have no job (`update` drains them itself too).
1631 let image = self.with_updates(|updates| updates.image);
1632 if image && status.linux().job.is_none() {
1633 self.with_updates(|updates| updates.installing.push("linux".into()));
1634 self.sandbox_step(Step::Update);
1635 return true;
1636 }
1637 false
1638 }
1639
1640 /// Puts a downloaded program in place, with nothing running on it.
1641 fn put(&mut self, version: String, bytes: &[u8]) {
1642 match native::put(&folder(&self.app), bytes, &version) {
1643 Ok(()) => {
1644 self.write_log(&format!("g1t: g1t-runner {version} is in place."));
1645 if self.run.as_ref().is_none_or(|run| run.engine != Engine::Launcher) {
1646 self.note(None, Some(version));
1647 }
1648 }
1649 Err(why) => {
1650 let why = format!("g1t-runner {version} couldn't be put in place: {why}");
1651 self.write_log(&format!("g1t: {why}"));
1652 self.update_failed = Some((Code::UpdateFailed, why));
1653 }
1654 }
1655 self.done_with(|updates| {
1656 updates.program = false;
1657 updates.installing.retain(|kind| kind == "linux");
1658 });
1659 }
1660
1661 /// Part of an update is done: what waits is said again.
1662 fn done_with(&mut self, change: impl FnOnce(&mut Updates)) {
1663 let left = self.with_updates(|updates| {
1664 change(updates);
1665 if !updates.program && !updates.image {
1666 updates.ready = None;
1667 }
1668 updates.program || updates.image
1669 });
1670 if !left {
1671 self.asked = false;
1672 }
1673 publish(&self.app);
1674 }
1675
1676 // --- The sandbox (launcher.rs) ------------------------------------------------
1677
1678 /// Starts the sandbox for `workspace`: registered with it the first
1679 /// time, with a token from the site; from its own registration after.
1680 fn start_sandbox(&mut self, workspace: String) {
1681 let app = self.app.clone();
1682 let server = config::server(&app);
1683 let binary = match self.ensure_binary(&server) {
1684 Ok(binary) => binary,
1685 Err(fault) => return self.not_installed(workspace, None, fault),
1686 };
1687 let dir = native_dir(&app, Backend::Sandboxed);
1688 let mut status = match launcher::status(&binary, &dir) {
1689 Ok(status) => status,
1690 Err(why) => return self.no_status(why),
1691 };
1692 self.seen(&status);
1693 // Nothing here can be sandboxed: why, and how to fix it; looked at again in a while.
1694 if !status.anything_available() {
1695 self.not_before = Instant::now() + LOOK_AGAIN;
1696 let (code, extra) = shown(&status);
1697 return self.set(code, extra);
1698 }
1699 // Another backend's registration goes; the sandbox's own is kept for its workspace.
1700 if Registration::load(&app).is_some_and(|r| r.backend != Backend::Sandboxed) {
1701 self.set(Code::Starting, detail("Removing this computer's earlier registration."));
1702 self.forget();
1703 }
1704 let kept = SandboxRegistration::load(&app);
1705 let ours = kept.as_ref().is_some_and(|k| k.server == server.as_str() && k.workspace == workspace);
1706 let made = status.capabilities.iter().any(|c| c.state != State::Absent) || status.state != State::Absent;
1707 if !ours && made && kept.is_none() {
1708 // Made by hand (`g1t-runner sandbox up`), and there is one per computer: it is the person's to remove.
1709 self.not_before = Instant::now() + LOOK_AGAIN;
1710 return self.set(Code::SandboxTaken, Extra::default());
1711 }
1712 if !ours && made {
1713 // The app's sandbox, registered with another workspace or server.
1714 self.set(Code::Starting, detail("Removing this computer's sandbox for another workspace."));
1715 self.forget_sandbox();
1716 status = match launcher::status(&binary, &dir) {
1717 Ok(status) => status,
1718 Err(why) => return self.no_status(why),
1719 };
1720 }
1721 // The kinds of job the person stopped are stopped here too, before
1722 // anything starts (the launcher keeps them off); with every kind
1723 // stopped, nothing starts.
1724 let kinds = config::settings(&app).runner_capabilities;
1725 if status.per_kind() {
1726 let stopping: Vec<String> = status.capabilities.iter().filter(|c| !kinds.on(&c.id) && !c.off()).map(|c| c.id.clone()).collect();
1727 for kind in &stopping {
1728 if let Err(why) = launcher::down_only(&binary, &dir, kind) {
1729 self.write_log(&format!("g1t: {} couldn't be stopped: {why}", diagnosis::kind_label(kind)));
1730 }
1731 }
1732 if !stopping.is_empty() {
1733 status = match launcher::status(&binary, &dir) {
1734 Ok(status) => status,
1735 Err(why) => return self.no_status(why),
1736 };
1737 self.seen(&status);
1738 }
1739 }
1740 let wanted: Vec<String> = status.capabilities.iter().filter(|c| c.available && kinds.on(&c.id)).map(|c| c.id.clone()).collect();
1741 if status.capabilities.iter().any(|c| c.available) && wanted.is_empty() {
1742 self.not_before = Instant::now() + LOOK_AGAIN;
1743 return self.set(Code::KindsStopped, Extra::default());
1744 }
1745 let mut token = None;
1746 if !ours || !kept.as_ref().is_some_and(|k| k.registered) || status.unregistered() {
1747 match self.token.take() {
1748 Some((for_workspace, given)) if for_workspace == workspace => token = Some(given),
1749 _ => return self.want(&workspace),
1750 }
1751 SandboxRegistration { server: server.to_string(), workspace: workspace.clone(), registered: false }.save(&app);
1752 }
1753 self.hold_workspace(&workspace);
1754 let generation = self.generation + 1;
1755 self.generation = generation;
1756 self.run = Some(Run::new(generation, Engine::Launcher, Backend::Sandboxed, workspace.clone(), Process::Sandbox(None), token.is_some()));
1757 // A new release's image first, then the sandbox starts again.
1758 let image = self.with_updates(|updates| std::mem::take(&mut updates.image));
1759 let step = if image && status.linux().state != State::Absent { Step::Update } else { Step::Up };
1760 if step == Step::Update {
1761 self.with_updates(|updates| updates.installing.push("linux".into()));
1762 } else if image {
1763 self.done_with(|_| {});
1764 }
1765 let name = backend::machine_name();
1766 let url = server.as_str().trim_end_matches('/').to_owned();
1767 let up = launcher::Up { url: &url, name: &name, labels: "desktop", token: token.as_deref(), only: wanted.iter().map(String::as_str).collect() };
1768 let (args, starting) = match step {
1769 Step::Update => (launcher::update_args(&dir), vec![]),
1770 _ => (launcher::up_args(&dir, &up), wanted.clone()),
1771 };
1772 if let Err(why) = self.spawn_step(step, args, up.token) {
1773 self.run = None;
1774 return self.not_started(workspace, token, why);
1775 }
1776 self.now_starting(starting);
1777 self.set(Code::Starting, detail(STARTING_SANDBOX));
1778 }
1779
1780 /// Says which kinds of job a launcher command is starting now.
1781 fn now_starting(&self, kinds: Vec<String>) {
1782 let changed = {
1783 let shared = self.app.state::<Shared>();
1784 let mut now = shared.now.held();
1785 let changed = now.starting != kinds;
1786 now.starting = kinds;
1787 changed
1788 };
1789 if changed {
1790 publish(&self.app);
1791 }
1792 }
1793
1794 /// Each kind of job as the person has it: one they stopped is stopped
1795 /// (`down --only`), one they started is started (`up --only`), and one
1796 /// that should run and doesn't while another runs is tried again in a
1797 /// while: every five minutes while the release has no sandbox images.
1798 /// One launcher command at a time, each leaving the others as they are.
1799 fn kinds(&mut self, workspace: Option<&str>) {
1800 if !self.run.as_ref().is_some_and(|run| run.engine == Engine::Launcher) {
1801 return;
1802 }
1803 let Some(status) = self.app.state::<Shared>().now.held().sandbox.clone() else { return };
1804 if !status.per_kind() {
1805 return;
1806 }
1807 let on = config::settings(&self.app).runner_capabilities;
1808 // A kind of job drained for a new program starts on the new one, once it is in.
1809 let replacing = self.program.is_some();
1810 for capability in &status.capabilities {
1811 if !on.on(&capability.id) && !capability.off() {
1812 return self.kind_step(Step::Stop(capability.id.clone()), None);
1813 }
1814 if on.on(&capability.id) && capability.off() && capability.available && !(replacing && capability.id != "linux") {
1815 return self.start_kind(capability, workspace);
1816 }
1817 }
1818 // Nothing runs: the sandbox as a whole starts again (sandbox_stopped).
1819 if !status.running() {
1820 self.retry.clear();
1821 return;
1822 }
1823 let now = Instant::now();
1824 for capability in &status.capabilities {
1825 let id = capability.id.as_str();
1826 let wanting = on.on(id) && !capability.off() && capability.available && matches!(capability.state, State::Error | State::Stopped | State::Absent);
1827 if !wanting {
1828 self.retry.retain(|(kind, _, _)| kind != id);
1829 continue;
1830 }
1831 let images = id == "linux" && status.trouble().is_some_and(|(cause, _)| launcher::why(&cause) == launcher::Why::NoImages);
1832 match self.retry.iter().position(|(kind, _, _)| kind == id) {
1833 // Just seen not running: tried again in a while.
1834 None => self.retry.push((id.to_owned(), now + again(images, 1), 1)),
1835 Some(at) if now >= self.retry[at].1 => {
1836 let tries = self.retry[at].2 + 1;
1837 self.retry[at] = (id.to_owned(), now + again(images, tries), tries);
1838 self.write_log(&format!("g1t: trying {} again.", diagnosis::kind_label(id)));
1839 return self.start_kind(capability, workspace);
1840 }
1841 Some(_) => {}
1842 }
1843 }
1844 }
1845
1846 /// Starts one kind of job; one never registered needs a token from the
1847 /// site first, which is asked for, and it starts once it is here.
1848 fn start_kind(&mut self, capability: &launcher::Capability, workspace: Option<&str>) {
1849 let mut token = None;
1850 if capability.state == State::Absent {
1851 let Some(workspace) = workspace else { return };
1852 match self.token.take() {
1853 Some((for_workspace, given)) if for_workspace == workspace => token = Some(given),
1854 other => {
1855 self.token = other;
1856 if wanted(&self.app).is_none() {
1857 *self.app.state::<Shared>().wanted.held() = Some(workspace.to_owned());
1858 let _ = self.app.emit_to(EventTarget::webview(frame::SITE), WANTED_EVENT, workspace.to_owned());
1859 }
1860 return;
1861 }
1862 }
1863 }
1864 self.kind_step(Step::Start(capability.id.clone()), token);
1865 }
1866
1867 /// `up --only` or `down --only` one kind of job, while the rest run on.
1868 fn kind_step(&mut self, step: Step, token: Option<String>) {
1869 let dir = native_dir(&self.app, Backend::Sandboxed);
1870 let (args, starting, said) = match &step {
1871 Step::Start(kind) => {
1872 let url = config::server(&self.app).as_str().trim_end_matches('/').to_owned();
1873 let name = backend::machine_name();
1874 let up = launcher::Up { url: &url, name: &name, labels: "desktop", token: token.as_deref(), only: vec![kind.as_str()] };
1875 (launcher::up_args(&dir, &up), vec![kind.clone()], format!("starting {}", diagnosis::kind_label(kind)))
1876 }
1877 Step::Stop(kind) => (launcher::down_only_args(&dir, kind), vec![], format!("stopping {}", diagnosis::kind_label(kind))),
1878 Step::Drain(kind) => (launcher::drain_only_args(&dir, kind), vec![], format!("stopping {} after their job, to update g1t-runner", diagnosis::kind_label(kind))),
1879 Step::Up | Step::Update => return,
1880 };
1881 self.write_log(&format!("g1t: {said}."));
1882 match self.spawn_step(step, args, token.as_deref()) {
1883 Ok(()) => self.now_starting(starting),
1884 Err(why) => self.write_log(&format!("g1t: {why}")),
1885 }
1886 }
1887
1888 /// `sandbox status` didn't answer: a `g1t-runner` from before its
1889 /// launcher, which a newer release replaces, or one that failed.
1890 fn no_status(&mut self, why: String) {
1891 self.write_log(&format!("g1t: the sandbox's status: {why}"));
1892 self.failures += 1;
1893 if why.contains("older than its sandbox launcher") {
1894 let (server, folder) = (config::server(&self.app), folder(&self.app));
1895 if let Ok(Found { version, program: Some(bytes), .. }) = look_for_release(&server, &folder, None)
1896 && native::put(&folder, &bytes, &version).is_ok()
1897 {
1898 self.note(None, Some(version));
1899 self.not_before = Instant::now();
1900 return;
1901 }
1902 self.not_before = Instant::now() + MOST_BACKOFF;
1903 return self.set(Code::NoLauncher, detail(why));
1904 }
1905 self.not_before = Instant::now() + LOOK_AGAIN;
1906 self.set(Code::SandboxFailed, Extra { detail: Some(why), wait_seconds: Some(LOOK_AGAIN.as_secs()), ..Extra::default() });
1907 }
1908
1909 /// Runs a launcher command for the sandbox's run, reading its output.
1910 fn spawn_step(&mut self, step: Step, args: Vec<std::ffi::OsString>, token: Option<&str>) -> Result<(), String> {
1911 let binary = native::binary(&folder(&self.app));
1912 let mut command = launcher::command(&binary, args);
1913 launcher::with_token(&mut command, token);
1914 let mut child = command.spawn().map_err(|e| format!("g1t-runner sandbox {} could not start: {e}", step.name()))?;
1915 let _ = std::fs::write(folder(&self.app).join("pid"), child.id().to_string());
1916 let Some(generation) = self.run.as_ref().map(|run| run.generation) else {
1917 native::stop(&mut child);
1918 return Ok(());
1919 };
1920 self.follow(generation, &mut child);
1921 let run = self.run.as_mut().expect("checked above");
1922 run.json = None;
1923 run.process = Process::Sandbox(Some((step, child)));
1924 Ok(())
1925 }
1926
1927 /// `update` on the running sandbox, while it has no job.
1928 fn sandbox_step(&mut self, step: Step) {
1929 if self.run.as_ref().is_none_or(|run| run.step().is_some()) {
1930 return;
1931 }
1932 let dir = native_dir(&self.app, Backend::Sandboxed);
1933 if let Err(why) = self.spawn_step(step, launcher::update_args(&dir), None) {
1934 self.write_log(&format!("g1t: {why}"));
1935 }
1936 }
1937
1938 /// Asks the sandbox how it is, every few seconds, on a thread of its own.
1939 fn poll(&mut self) {
1940 let Some(run) = self.run.as_mut().filter(|run| run.engine == Engine::Launcher) else { return };
1941 if run.polling || run.polled_at.is_some_and(|at| at.elapsed() < launcher::STATUS_EVERY) {
1942 return;
1943 }
1944 run.polling = true;
1945 run.polled_at = Some(Instant::now());
1946 let (tx, generation) = (self.tx.clone(), run.generation);
1947 let binary = native::binary(&folder(&self.app));
1948 let dir = native_dir(&self.app, Backend::Sandboxed);
1949 let spawned = std::thread::Builder::new().name("g1t-sandbox-status".into()).spawn(move || {
1950 let _ = tx.send(Ask::Sandbox(generation, launcher::status(&binary, &dir).map(Box::new)));
1951 });
1952 if spawned.is_err() {
1953 run.polling = false;
1954 }
1955 }
1956
1957 /// What a poll said.
1958 fn polled(&mut self, generation: u64, status: Result<Box<Status>, String>) {
1959 let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return };
1960 run.polling = false;
1961 if std::mem::take(&mut run.stale) {
1962 return;
1963 }
1964 match status {
1965 Ok(status) => self.sandbox_now(*status),
1966 // Once is nothing: the next poll asks again.
1967 Err(why) => self.write_log(&format!("g1t: the sandbox's status: {why}")),
1968 }
1969 }
1970
1971 /// Keeps what a status says for the tray, settings and the site.
1972 fn seen(&mut self, status: &Status) {
1973 let shared = self.app.state::<Shared>();
1974 let mut now = shared.now.held();
1975 now.sandbox = Some(status.clone());
1976 if let Some(version) = status.version() {
1977 now.version = Some(version);
1978 }
1979 now.job = status.job().map(launcher::Job::said);
1980 }
1981
1982 /// The sandbox as it is now, while it is the run.
1983 fn sandbox_now(&mut self, status: Status) {
1984 let had = self.app.state::<Shared>().now.held().job.clone();
1985 let Some(run) = self.run.as_mut() else { return };
1986 let step = run.step();
1987 let job = status.job().map(launcher::Job::said);
1988 let was = had.filter(|_| run.busy);
1989 run.busy = job.is_some();
1990 run.listening = status.running();
1991 let update_failed = self.update_failed.clone();
1992 if was != job {
1993 if let Some(ended) = &was {
1994 self.write_log(&format!("g1t: {ended} ended."));
1995 }
1996 if let Some(took) = &job {
1997 self.write_log(&format!("g1t: Took {took}."));
1998 }
1999 }
2000 self.seen(&status);
2001 match &step {
2002 Some(Step::Up) => return self.set(Code::Starting, detail(STARTING_SANDBOX)),
2003 Some(Step::Update) if status.state == State::Updating => return self.set(Code::Updating, detail("Updating the sandbox to the newest image.")),
2004 _ => {}
2005 }
2006 let (code, extra) = shown(&status);
2007 // Every kind of job stopped by the person: nothing to start again.
2008 if step.is_none() && status.all_off() && !status.running() {
2009 self.run = None;
2010 self.generation += 1;
2011 self.retry.clear();
2012 self.not_before = Instant::now() + LOOK_AGAIN;
2013 return self.set(Code::KindsStopped, Extra::default());
2014 }
2015 if step.is_none() && !status.running() && !matches!(status.state, State::Starting | State::Updating) {
2016 return self.sandbox_stopped(&status, code, extra);
2017 }
2018 match (code, update_failed) {
2019 (Code::Idle, Some((failed, why))) => self.set(failed, detail(why)),
2020 (code, _) => self.set(code, extra),
2021 }
2022 }
2023
2024 /// The sandbox stopped by itself, or never got going: started again,
2025 /// waiting longer each time, or registered again when that is why.
2026 fn sandbox_stopped(&mut self, status: &Status, code: Code, extra: Extra) {
2027 let error = status.error.clone().or_else(|| extra.detail.clone()).unwrap_or_default();
2028 if self.registration_trouble(&error) {
2029 return;
2030 }
2031 self.run = None;
2032 self.generation += 1;
2033 self.failures += 1;
2034 // A release without the sandbox's images is looked at again every few minutes.
2035 let wait = if code == Code::WaitingForImage { launcher::IMAGES_AGAIN } else { again(false, self.failures) };
2036 self.not_before = Instant::now() + wait;
2037 let (code, extra) = match code {
2038 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() }),
2039 code => (code, extra),
2040 };
2041 self.set(code, Extra { wait_seconds: Some(wait.as_secs()), ..extra });
2042 }
2043
2044 /// When the sandbox's trouble is its registration: true when the app
2045 /// has seen to it, and the next start registers again.
2046 fn registration_trouble(&mut self, error: &str) -> bool {
2047 match launcher::why(error) {
2048 launcher::Why::TokenRefused | launcher::Why::Unregistered => {
2049 if let Some(mut kept) = SandboxRegistration::load(&self.app) {
2050 kept.registered = false;
2051 kept.save(&self.app);
2052 }
2053 self.halt();
2054 self.not_before = Instant::now();
2055 true
2056 }
2057 launcher::Why::Forgotten => {
2058 self.halt();
2059 self.forget_sandbox();
2060 self.not_before = Instant::now();
2061 true
2062 }
2063 _ => false,
2064 }
2065 }
2066
2067 /// A launcher command ended: what it printed last is the sandbox's status.
2068 fn launched(&mut self, generation: u64) {
2069 let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return };
2070 let Process::Sandbox(slot) = &mut run.process else { return };
2071 let Some((step, mut child)) = slot.take() else { return };
2072 let worked = child.wait().is_ok_and(|status| status.success());
2073 let _ = std::fs::remove_file(folder(&self.app).join("pid"));
2074 let printed = run.json.take().unwrap_or_default().join("\n");
2075 run.stale = run.polling;
2076 let with_token = run.with_token;
2077 let status = launcher::parse(&printed);
2078 {
2079 let shared = self.app.state::<Shared>();
2080 let mut now = shared.now.held();
2081 now.starting.clear();
2082 now.download = None;
2083 }
2084 let error = match (&status, worked) {
2085 (Ok(status), true) => status.error.clone().filter(|_| !status.running()),
2086 (Ok(status), false) => Some(status.error.clone().unwrap_or_else(|| format!("g1t-runner sandbox {} failed.", step.name()))),
2087 (Err(why), _) => Some(why.clone()),
2088 };
2089 let status = status.ok();
2090 let running = status.as_ref().is_some_and(Status::running);
2091 if let Some(error) = &error {
2092 self.write_log(&format!("g1t: sandbox {}: {error}", step.name()));
2093 }
2094 // Drained for a new program, which goes in on the next look; one
2095 // that didn't stop leaves the update for the next one, rather than
2096 // being asked again and again.
2097 if let (Step::Drain(_), Some(error)) = (&step, &error) {
2098 self.program = None;
2099 self.update_failed = Some((Code::UpdateFailed, format!("g1t-runner couldn't be updated: {error}")));
2100 self.done_with(|updates| {
2101 updates.program = false;
2102 updates.installing.retain(|kind| kind == "linux");
2103 });
2104 }
2105 // One kind of job started or stopped: how the sandbox is now says
2106 // the rest, and one that didn't start is tried again in a while.
2107 if matches!(step, Step::Start(_) | Step::Stop(_) | Step::Drain(_)) {
2108 if let Some(status) = status {
2109 self.sandbox_now(status);
2110 }
2111 return;
2112 }
2113 // Registered: from now on it starts with no token.
2114 if step == Step::Up && with_token && (error.is_none() || running) {
2115 let app = self.app.clone();
2116 if let Some(run) = self.run.as_mut() {
2117 run.with_token = false;
2118 let registration = Registration { server: config::server(&app).to_string(), workspace: run.workspace.clone(), backend: Backend::Sandboxed };
2119 SandboxRegistration { server: registration.server.clone(), workspace: registration.workspace.clone(), registered: true }.save(&app);
2120 registration.save(&app);
2121 }
2122 }
2123 // The image is taken, or failed to be: the next look says whether another waits.
2124 if step == Step::Update {
2125 self.done_with(|updates| {
2126 updates.image = false;
2127 updates.installing.retain(|kind| kind != "linux");
2128 });
2129 }
2130 if let Some(error) = error.clone() {
2131 if self.registration_trouble(&error) {
2132 return;
2133 }
2134 if step == Step::Update {
2135 self.update_failed = Some((Code::UpdateFailed, format!("The sandbox's image couldn't be updated: {error}")));
2136 } else if !running {
2137 // Nothing runs: tidied away, and tried again in a while;
2138 // a release without the sandbox's images every few minutes.
2139 self.halt();
2140 self.failures += 1;
2141 let (code, cause, fix) = status
2142 .as_ref()
2143 .and_then(diagnosis::sandbox_trouble)
2144 .unwrap_or_else(|| (diagnosis::sandbox_code(launcher::why(&error)), error.clone(), None));
2145 let wait = if code == Code::WaitingForImage { launcher::IMAGES_AGAIN } else { again(false, self.failures) };
2146 self.not_before = Instant::now() + wait;
2147 // The cards say why from what `up` said, not from the stopped sandbox.
2148 if let Some(failed) = status {
2149 self.app.state::<Shared>().now.held().sandbox = Some(failed);
2150 }
2151 return self.set(code, Extra { detail: Some(cause), fix, wait_seconds: Some(wait.as_secs()), ..Extra::default() });
2152 }
2153 } else if step == Step::Up {
2154 self.failures = 0;
2155 } else {
2156 self.update_failed = None;
2157 }
2158 if step == Step::Update && !running {
2159 // Updated while stopped: it starts again with the new image.
2160 self.run = None;
2161 self.generation += 1;
2162 self.not_before = Instant::now();
2163 return;
2164 }
2165 if let Some(status) = status {
2166 self.sandbox_now(status);
2167 }
2168 }
2169}
2170
2171/// What the tray says while the sandbox starts.
2172const STARTING_SANDBOX: &str = "Starting the sandbox. The first time, it downloads and checks the sandbox's image, which takes a few minutes.";
2173
2174/// The newest release, signed, and its program when it is newer than the
2175/// one in `folder` and isn't already `waiting`: downloaded and checked
2176/// against its SHA-256. On the update thread, never the runner's.
2177fn look_for_release(server: &Url, folder: &std::path::Path, waiting: Option<&str>) -> Result<Found, Fault> {
2178 let manifest = native::latest(server)?;
2179 let newer = native::installed(folder).is_none_or(|have| native::newer(&manifest.version, &have));
2180 let program = if newer && waiting != Some(manifest.version.as_str()) {
2181 Some(native::download(server, &manifest).map_err(|why| Fault::Failed(format!("{} didn't download: {why}", manifest.version)))?)
2182 } else {
2183 None
2184 };
2185 Ok(Found { images: manifest.has_images(), version: manifest.version, newer, program })
2186}
2187
2188fn some_text(text: &str) -> Option<String> {
2189 Some(text.to_owned())
2190}
2191
2192/// How long before trying again, the `tries`th time: every few minutes
2193/// while the release has no sandbox images, else longer each time.
2194fn again(images: bool, tries: u32) -> Duration {
2195 if images { launcher::IMAGES_AGAIN } else { Duration::from_secs(15 * 2u64.pow(tries.min(6))).min(MOST_BACKOFF) }
2196}
2197
2198/// What the app shows for how the sandbox is, with no launcher command
2199/// running: what its runners do while one runs (each kind of job's card
2200/// says the rest), else what is wrong with Linux jobs.
2201fn shown(status: &Status) -> (Code, Extra) {
2202 if status.job().is_some() {
2203 return (Code::Busy, Extra::default());
2204 }
2205 if status.running() {
2206 return (Code::Idle, Extra::default());
2207 }
2208 if let Some((code, cause, fix)) = diagnosis::sandbox_trouble(status) {
2209 return (code, Extra { detail: Some(cause), fix, ..Extra::default() });
2210 }
2211 match status.linux().state {
2212 State::Starting => (Code::Starting, detail(STARTING_SANDBOX)),
2213 State::Updating => (Code::Updating, detail("Updating the sandbox to the newest image.")),
2214 _ if status.all_off() => (Code::KindsStopped, Extra::default()),
2215 _ => (Code::SandboxFailed, detail("The sandbox isn't running.")),
2216 }
2217}
2218
2219/// What `register-and-run` reads: where g1t is, the runner's name and
2220/// labels, and the token, the first time only. The app updates the runner,
2221/// so the runner does not update itself; registering again takes the
2222/// place of this computer's earlier runner of the same name.
2223fn environment(server: &Url, backend: Backend, token: Option<&str>) -> Vec<(&'static str, String)> {
2224 let mut envs = vec![
2225 ("G1T_RUNNER_URL", server.as_str().trim_end_matches('/').to_owned()),
2226 ("G1T_RUNNER_NAME", backend::machine_name()),
2227 ("G1T_RUNNER_LABELS", "desktop".to_owned()),
2228 ("G1T_RUNNER_REPLACE", "1".to_owned()),
2229 ("G1T_RUNNER_NO_AUTO_UPDATE", "1".to_owned()),
2230 ];
2231 if backend == Backend::Host {
2232 envs.push(("G1T_RUNNER_NO_DOCKER", "1".to_owned()));
2233 }
2234 if let Some(token) = token {
2235 envs.push(("G1T_RUNNER_TOKEN", token.to_owned()));
2236 }
2237 envs
2238}
2239
2240#[cfg(test)]
2241mod tests {
2242 use super::*;
2243
2244 fn view(code: Code, job: Option<&str>) -> View {
2245 let facts = Facts { workspace: Some("acme".into()), backend: "sandboxed", machine: "box".into(), job: job.map(str::to_owned), ..Facts::default() };
2246 let diagnosed = diagnosis::diagnose(code, &facts);
2247 View {
2248 chip: diagnosis::chip(&diagnosed),
2249 diagnosis: diagnosed,
2250 closed: diagnosis::closed(&facts),
2251 running: code == Code::Idle,
2252 version: None,
2253 backend: Backend::Sandboxed,
2254 workspace: Some("acme".into()),
2255 workspaces: vec![],
2256 enabled: true,
2257 paused: false,
2258 link: None,
2259 machine: "box".into(),
2260 platform: "windows".into(),
2261 log: vec![],
2262 }
2263 }
2264
2265 #[test]
2266 fn the_tray_says_what_the_runner_is_doing() {
2267 assert_eq!(headline(&view(Code::Idle, None)), "Runner: Waiting for jobs");
2268 assert_eq!(headline(&view(Code::Busy, Some("Build (acme/web)"))), "Runner: Running Build (acme/web)");
2269 assert_eq!(headline(&view(Code::NoDocker, None)), "Runner: Not running: Docker isn't running");
2270 assert_eq!(headline(&view(Code::Busy, Some(&"x".repeat(200)))).chars().count(), 60);
2271 }
2272
2273 #[test]
2274 fn the_runner_is_told_where_and_who_and_the_token_only_once() {
2275 let server = Url::parse("https://g1t.sh/").unwrap();
2276 let envs = environment(&server, Backend::Host, Some("g1trt_abc"));
2277 let get = |name: &str| envs.iter().find(|(n, _)| *n == name).map(|(_, v)| v.as_str());
2278 assert_eq!(get("G1T_RUNNER_URL"), Some("https://g1t.sh"));
2279 assert_eq!(get("G1T_RUNNER_LABELS"), Some("desktop"));
2280 assert_eq!(get("G1T_RUNNER_NO_DOCKER"), Some("1"));
2281 assert_eq!(get("G1T_RUNNER_NO_AUTO_UPDATE"), Some("1"));
2282 assert_eq!(get("G1T_RUNNER_TOKEN"), Some("g1trt_abc"));
2283 let again = environment(&server, Backend::Docker, None);
2284 assert!(!again.iter().any(|(name, _)| *name == "G1T_RUNNER_TOKEN" || *name == "G1T_RUNNER_NO_DOCKER"));
2285 }
2286
2287 fn status(json: &str) -> Status {
2288 launcher::parse(json).unwrap()
2289 }
2290
2291 #[test]
2292 fn the_sandbox_shows_what_its_runners_do_and_linux_trouble_first() {
2293 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)."}]}"#);
2294 assert_eq!(shown(&running).0, Code::Idle);
2295 let busy = status(r#"{"schema":1,"state":"running","job":{"kind":"workflow","id":"j","name":"build","repo":"acme/web"}}"#);
2296 assert_eq!(shown(&busy).0, Code::Busy);
2297 let starting = status(r#"{"schema":1,"state":"starting","capabilities":[{"id":"linux","available":true,"state":"starting"}]}"#);
2298 assert_eq!(shown(&starting).0, Code::Starting);
2299 let updating = status(r#"{"schema":1,"state":"updating","capabilities":[{"id":"linux","available":true,"state":"updating"}]}"#);
2300 assert_eq!(shown(&updating).0, Code::Updating);
2301 // Windows jobs run while WSL is missing: the runner waits for jobs,
2302 // and the Linux card says what is wrong (diagnosis.rs).
2303 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":[
2304 {"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`."},
2305 {"id":"windows","available":true,"state":"running"}]}"#);
2306 assert_eq!(shown(&no_wsl).0, Code::Idle);
2307 let (code, extra) = shown(&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":[
2308 {"id":"linux","available":false,"state":"error","cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own."},
2309 {"id":"windows","available":false,"state":"absent"}]}"#));
2310 assert_eq!(code, Code::NoWsl);
2311 assert!(extra.detail.unwrap().starts_with("WSL is not installed"));
2312 // 0.3.0 without its images, on Home: waiting for them, not failing.
2313 let images = status(r#"{"schema":1,"state":"error","error":"release 0.3.0 has no sandbox images","capabilities":[{"id":"linux","available":true,"state":"error","turned_off":false},{"id":"windows","available":false,"state":"absent","turned_off":false}]}"#);
2314 assert_eq!(shown(&images).0, Code::WaitingForImage);
2315 assert_eq!(again(true, 5), launcher::IMAGES_AGAIN);
2316 assert_eq!(again(false, 1), Duration::from_secs(30));
2317 assert_eq!(again(false, 9), MOST_BACKOFF);
2318 // Every kind of job stopped by the person.
2319 let off = status(r#"{"schema":1,"state":"stopped","capabilities":[{"id":"linux","available":true,"state":"stopped","turned_off":true},{"id":"windows","available":true,"state":"stopped","turned_off":true}]}"#);
2320 assert_eq!(shown(&off).0, Code::KindsStopped);
2321 let failed = status(r#"{"schema":1,"state":"error","error":"the sandbox did not start within 180 seconds","capabilities":[{"id":"linux","available":true,"state":"error"}]}"#);
2322 let (code, extra) = shown(&failed);
2323 assert_eq!((code, extra.detail.as_deref()), (Code::SandboxFailed, Some("the sandbox did not start within 180 seconds")));
2324 let stopped = status(r#"{"schema":1,"state":"stopped","capabilities":[{"id":"linux","available":true,"state":"stopped"},{"id":"windows","available":true,"state":"stopped"}]}"#);
2325 assert_eq!(shown(&stopped).0, Code::SandboxFailed);
2326 }
2327
2328 #[test]
2329 fn a_new_runner_lives_in_local_data_and_an_old_one_stays_put() {
2330 let base = std::env::temp_dir().join(format!("g1t-desktop-folders-{}", std::process::id()));
2331 let _ = std::fs::remove_dir_all(&base);
2332 let (local, roaming) = (base.join("local").join("runner"), base.join("roaming").join("runner"));
2333 assert_eq!(chosen_folder(local.clone(), roaming.clone()), local);
2334 std::fs::create_dir_all(&roaming).unwrap();
2335 assert_eq!(chosen_folder(local.clone(), roaming.clone()), roaming);
2336 std::fs::create_dir_all(&local).unwrap();
2337 assert_eq!(chosen_folder(local.clone(), roaming), local);
2338 let _ = std::fs::remove_dir_all(&base);
2339 }
2340
2341 #[test]
2342 fn when_is_said_as_the_site_reads_it() {
2343 let at = |seconds: u64| rfc3339(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds));
2344 assert_eq!(at(0), "1970-01-01T00:00:00Z");
2345 assert_eq!(at(1_791_806_645), "2026-10-12T12:04:05Z");
2346 // The day after February 29th of a leap year.
2347 assert_eq!(at(1_709_251_199 + 1), "2024-03-01T00:00:00Z");
2348 assert_eq!(at(951_782_400), "2000-02-29T00:00:00Z");
2349 }
2350
2351 /// What is running on the program itself, which an update drains first:
2352 /// Windows jobs running or starting, not Linux jobs, nor ones stopped.
2353 #[test]
2354 fn an_update_drains_only_what_runs_on_the_program() {
2355 let kinds = |json: &str| native_kinds(&status(json)).iter().map(|c| c.id.clone()).collect::<Vec<_>>();
2356 assert_eq!(
2357 kinds(r#"{"schema":1,"state":"running","capabilities":[{"id":"linux","available":true,"state":"running","turned_off":false},{"id":"windows","available":true,"state":"running","turned_off":false}]}"#),
2358 ["windows"]
2359 );
2360 assert!(kinds(r#"{"schema":1,"state":"running","capabilities":[{"id":"linux","available":true,"state":"running","turned_off":false},{"id":"windows","available":true,"state":"stopped","turned_off":true}]}"#).is_empty());
2361 assert!(kinds(r#"{"schema":1,"state":"running","capabilities":[{"id":"linux","available":true,"state":"running"},{"id":"windows","available":false,"state":"absent"}]}"#).is_empty());
2362 }
2363
2364 #[test]
2365 fn the_site_names_workspaces_in_its_own_words() {
2366 let offered: Vec<Offered> = serde_json::from_str(r#"[{"slug":"acme","name":"Acme"},{"slug":"solo"}]"#).unwrap();
2367 assert_eq!(offered[1], Offered { slug: "solo".into(), name: None });
2368 }
2369}