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