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