Getting the runner running, a pass for quality (#50). What Windows says of itself is kept in the sandbox's folder until Windows restarts, since none of it changes before then: a status while something is missing no longer starts PowerShell every few seconds (2.8 s the first time, 1 ms after). Windows jobs with Windows Sandbox on but virtualization off in the firmware now say so before a job, not as it fails. Set up that turned a feature on and then couldn't install WSL still asks for the restart, and offers WSL again after it. Restart now is asked with Windows' own dialog, wherever the button is, so no page can restart a computer by itself.
- Checked:
- cargo test -p g1t-runner (259 and 5), the app's tests (55), clippy on both; on this computer the facts read live and are read back from the folder in 1 ms, the script's WSL step runs wsl.exe unelevated and logs what it said, and unelevated the script changes nothing and says it needs an administrator.
- G1t-Session:
- ses_01m4yfkg80ev2t3yyd05fak46z
6 files+171−140/6 viewed
| 386 | 386 | /// What Windows can do itself for the sandbox, at once, behind its own | |
| 387 | 387 | /// administrator prompt (setup.rs): busy until the card says it is setting up. | |
| 388 | 388 | const SET_UP: Action = Action::waits("set_up", "Set up"); | |
| 389 | − | const RESTART_WINDOWS: Action = Action::waits("restart_windows", "Restart now") | |
| 390 | − | .confirming("Restart Windows now? Save your work first. g1t opens again after the restart and finishes setting up the runner."); | |
| 389 | + | /// Asked with Windows' own dialog (setup.rs), which no page can answer for | |
| 390 | + | /// the person, rather than a page's: done once the app has it. | |
| 391 | + | const RESTART_WINDOWS: Action = Action::now("restart_windows", "Restart now"); | |
| 391 | 392 | const RESTART_LATER: Action = Action::waits("restart_later", "Later"); | |
| 392 | 393 | const WINDOWS_UPDATE: Action = Action::now("windows_update", "Open Windows Update"); | |
| 393 | 394 | const WINDOWS_ACTIVATION: Action = Action::now("windows_activation", "Open Activation"); | |
| ⋯ | |||
| 1651 | 1652 | let linux = &restart.capabilities[0]; | |
| 1652 | 1653 | assert_eq!((linux.title.as_str(), linux.tone), ("Restart needed", Tone::Warning)); | |
| 1653 | 1654 | assert_eq!(ids(&linux.actions), ["restart_windows", "restart_later"]); | |
| 1654 | − | assert!(RESTART_WINDOWS.confirm.is_some_and(|q| q.starts_with("Restart Windows now?"))); | |
| 1655 | + | // Windows' own dialog asks, not a page's, which a page could skip. | |
| 1656 | + | assert!(RESTART_WINDOWS.confirm.is_none() && !RESTART_WINDOWS.waits); | |
| 1657 | + | assert!(super::super::setup::RESTART_QUESTION.starts_with("Restart Windows now")); | |
| 1655 | 1658 | assert_eq!(linux.fix.as_deref(), Some("Restart now, or later: g1t opens again after the restart and carries on by itself.")); | |
| 1656 | 1659 | let later = shown_for(needing(Some(&["restart"]), None), Facts { setup: SetUp { later: true, ..SetUp::default() }, ..facts() }); | |
| 1657 | 1660 | screen("windows-restart-later", &later); | |
| 699 | 699 | "firmware_help" => browse("https://docs.g1t.sh/guides/desktop/#turning-on-virtualization")?, | |
| 700 | 700 | "nested_help" => browse("https://docs.g1t.sh/guides/desktop/#in-a-virtual-machine")?, | |
| 701 | 701 | "set_up" => set_up(app)?, | |
| 702 | − | "restart_windows" => setup::restart_windows(&std::env::current_exe().map_err(|e| e.to_string())?)?, | |
| 702 | + | "restart_windows" => setup::ask_and_restart(std::env::current_exe().map_err(|e| e.to_string())?)?, | |
| 703 | 703 | "restart_later" => { | |
| 704 | 704 | if let Ok(exe) = std::env::current_exe() { | |
| 705 | 705 | setup::open_after_restart(&exe); |
| 137 | 137 | throw 'WSL could not be installed or updated.' | |
| 138 | 138 | }} | |
| 139 | 139 | $restart = $false | |
| 140 | + | function Restart-Pending {{ | |
| 141 | + | Set-Content -LiteralPath {marker} -Value (Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString('o') | |
| 142 | + | }} | |
| 140 | 143 | try {{ | |
| 141 | 144 | foreach ($name in @({features})) {{ | |
| 142 | 145 | $feature = Get-WindowsOptionalFeature -Online -FeatureName $name | |
| ⋯ | |||
| 148 | 151 | }} | |
| 149 | 152 | {wsl} | |
| 150 | 153 | if ($restart) {{ | |
| 151 | − | Set-Content -LiteralPath {marker} -Value (Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString('o') | |
| 154 | + | Restart-Pending | |
| 152 | 155 | Say 'Done; Windows needs a restart to finish.' | |
| 153 | 156 | exit {NEEDS_RESTART} | |
| 154 | 157 | }} | |
| ⋯ | |||
| 156 | 159 | exit 0 | |
| 157 | 160 | }} catch {{ | |
| 158 | 161 | Say ('Failed: ' + $_.Exception.Message) | |
| 162 | + | # What was turned on still takes the restart; after it, Set up offers what is left. | |
| 163 | + | if ($restart) {{ | |
| 164 | + | Restart-Pending | |
| 165 | + | Say 'Windows needs a restart for what was turned on; Set up does the rest after it.' | |
| 166 | + | exit {NEEDS_RESTART} | |
| 167 | + | }} | |
| 159 | 168 | exit 1 | |
| 160 | 169 | }} | |
| 161 | 170 | "#, | |
| ⋯ | |||
| 276 | 285 | Outcome::Failed("Set up is for Windows; the runner's card says what to do here.".into()) | |
| 277 | 286 | } | |
| 278 | 287 | ||
| 279 | − | /// Restarts Windows now. g1t opens again after, once (RunOnce), and | |
| 280 | − | /// carries on setting up. | |
| 281 | − | pub fn restart_windows(app_exe: &Path) -> Result<(), String> { | |
| 288 | + | /// What Windows' own dialog asks before g1t restarts it. | |
| 289 | + | pub const RESTART_QUESTION: &str = "Restart Windows now to finish setting up the runner?\n\nSave your work first. g1t opens again after the restart and carries on by itself."; | |
| 290 | + | ||
| 291 | + | /// Asks with Windows' own dialog, which no page can answer for the | |
| 292 | + | /// person, and restarts Windows on Yes: on a thread of its own, as the | |
| 293 | + | /// dialog waits for them. Restarting is asked this way wherever the button | |
| 294 | + | /// is, the site's pages included. | |
| 295 | + | pub fn ask_and_restart(app_exe: std::path::PathBuf) -> Result<(), String> { | |
| 282 | 296 | if !cfg!(windows) { | |
| 283 | 297 | return Err("Only Windows is restarted from g1t.".into()); | |
| 284 | 298 | } | |
| 299 | + | std::thread::Builder::new() | |
| 300 | + | .name("g1t-restart-windows".into()) | |
| 301 | + | .spawn(move || { | |
| 302 | + | if asked_yes(RESTART_QUESTION) { | |
| 303 | + | let _ = restart_windows(&app_exe); | |
| 304 | + | } | |
| 305 | + | }) | |
| 306 | + | .map(|_| ()) | |
| 307 | + | .map_err(|e| format!("Windows couldn't be asked: {e}")) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | #[cfg(windows)] | |
| 311 | + | fn asked_yes(question: &str) -> bool { | |
| 312 | + | use std::ffi::OsStr; | |
| 313 | + | use std::os::windows::ffi::OsStrExt; | |
| 314 | + | #[link(name = "user32")] | |
| 315 | + | unsafe extern "system" { | |
| 316 | + | fn MessageBoxW(window: isize, text: *const u16, caption: *const u16, kind: u32) -> i32; | |
| 317 | + | } | |
| 318 | + | const MB_YESNO: u32 = 0x4; | |
| 319 | + | const MB_ICONQUESTION: u32 = 0x20; | |
| 320 | + | const MB_DEFBUTTON2: u32 = 0x100; | |
| 321 | + | const MB_SETFOREGROUND: u32 = 0x1_0000; | |
| 322 | + | const MB_TOPMOST: u32 = 0x4_0000; | |
| 323 | + | const IDYES: i32 = 6; | |
| 324 | + | let wide = |text: &str| OsStr::new(text).encode_wide().chain(std::iter::once(0)).collect::<Vec<u16>>(); | |
| 325 | + | let (text, caption) = (wide(question), wide("g1t")); | |
| 326 | + | // SAFETY: both strings live until the call returns. No owner window: this | |
| 327 | + | // isn't the window's thread, and the dialog stays on top by itself. | |
| 328 | + | unsafe { MessageBoxW(0, text.as_ptr(), caption.as_ptr(), MB_YESNO | MB_ICONQUESTION | MB_DEFBUTTON2 | MB_SETFOREGROUND | MB_TOPMOST) == IDYES } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | #[cfg(not(windows))] | |
| 332 | + | fn asked_yes(_question: &str) -> bool { | |
| 333 | + | false | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | /// Restarts Windows now. g1t opens again after, once (RunOnce), and | |
| 337 | + | /// carries on setting up. | |
| 338 | + | fn restart_windows(app_exe: &Path) -> Result<(), String> { | |
| 285 | 339 | open_after_restart(app_exe); | |
| 286 | 340 | let status = super::backend::quiet("shutdown.exe") | |
| 287 | 341 | .args(["/r", "/t", "0", "/c", "g1t is restarting Windows to finish setting up the runner."]) | |
| ⋯ | |||
| 353 | 407 | assert_eq!(encoded("a"), "YQA="); | |
| 354 | 408 | } | |
| 355 | 409 | ||
| 410 | + | /// Runs the script's WSL step as this user with `--version`, which | |
| 411 | + | /// changes nothing: wsl.exe started hidden, what it said logged line by | |
| 412 | + | /// line, and its exit code read. `cargo test -- --ignored the_wsl_step --nocapture`. | |
| 413 | + | #[test] | |
| 414 | + | #[ignore] | |
| 415 | + | fn the_wsl_step_runs_wsl_and_logs_what_it_said() { | |
| 416 | + | let dir = std::env::temp_dir().join(format!("g1t-setup-wsl-{}", std::process::id())); | |
| 417 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 418 | + | let (log, marker) = (dir.join("setup.log"), dir.join(RESTART_FILE)); | |
| 419 | + | let script = script(&Plan { features: vec![], wsl: Some(Wsl::Update) }, &log, &marker).replace("Install-Wsl '--update'", "Install-Wsl '--version'"); | |
| 420 | + | let code = super::super::backend::quiet("powershell.exe") | |
| 421 | + | .args(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", &encoded(&script)]) | |
| 422 | + | .status() | |
| 423 | + | .unwrap() | |
| 424 | + | .code() | |
| 425 | + | .unwrap(); | |
| 426 | + | let said = std::fs::read_to_string(&log).unwrap(); | |
| 427 | + | println!("exit {code}\n{said}"); | |
| 428 | + | assert_eq!(outcome(code as u32), Outcome::Done, "{said}"); | |
| 429 | + | assert!(said.contains("g1t setup: wsl: "), "{said}"); | |
| 430 | + | assert!(said.trim_end().ends_with("g1t setup: Done."), "{said}"); | |
| 431 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 432 | + | } | |
| 433 | + | ||
| 356 | 434 | /// Runs the script as this user, not as administrator, so it changes | |
| 357 | 435 | /// nothing: it must parse, log, and fail plainly. Windows only: | |
| 358 | 436 | /// `cargo test -- --ignored the_script_runs --nocapture`. | |
| 460 | 460 | "windows" => { | |
| 461 | 461 | let found = WsbFound::look(); | |
| 462 | 462 | let available = found.availability(); | |
| 463 | + | // What exactly is missing, from what Windows says of itself (kept until it restarts). | |
| 464 | + | let windows = WindowsFacts::look_in(dir); | |
| 463 | 465 | if available.available { | |
| 464 | − | return Some(("windows", available)); | |
| 466 | + | // Windows Sandbox is on, but with virtualization off in the firmware no | |
| 467 | + | // hypervisor runs, and it would fail only as a job starts. | |
| 468 | + | return Some(("windows", if windows.firmware_off() { Availability::needing(vec![Need::Virtualization], "windows") } else { available })); | |
| 465 | 469 | } | |
| 466 | − | // What exactly is missing: asked of Windows only now, as it takes a second. | |
| 467 | − | let windows = WindowsFacts::look(); | |
| 468 | 470 | let needs = needs::windows_sandbox_needs(&windows, found.feature, found.cli, needs::restart_pending(dir, &windows)); | |
| 469 | 471 | Some(("windows", if needs.is_empty() { available } else { Availability::needing(needs, "windows") })) | |
| 470 | 472 | } | |
| ⋯ | |||
| 634 | 636 | && state == "error" | |
| 635 | 637 | && let Some(said) = error.as_deref().filter(|e| needs::about_virtualization(e)) | |
| 636 | 638 | { | |
| 637 | − | let need = WindowsFacts::look().no_virtualization(); | |
| 639 | + | let need = WindowsFacts::look_in(dir).no_virtualization(); | |
| 638 | 640 | let (words, to_do) = need.said("linux"); | |
| 639 | 641 | needs = vec![need]; | |
| 640 | 642 | cause = Some(format!("{words} ({said})")); | |
| 148 | 148 | let version = || wsl().arg("--version").stderr(Stdio::null()).output().ok().filter(|o| o.status.success()).and_then(|o| needs::wsl_version(&String::from_utf8_lossy(&o.stdout))); | |
| 149 | 149 | let listed = || wsl().args(["--list", "--verbose"]).output().map(|o| has_wsl2(&String::from_utf8_lossy(&o.stdout))).unwrap_or(false); | |
| 150 | 150 | let facts = WslFacts { installed, version: if installed { version() } else { None }, has_wsl2: installed && listed() }; | |
| 151 | − | let windows = WindowsFacts::look(); | |
| 151 | + | let windows = WindowsFacts::look_in(&self.dir); | |
| 152 | 152 | Availability::needing(needs::wsl_needs(&windows, &facts, needs::restart_pending(&self.dir, &windows)), "linux") | |
| 153 | 153 | } | |
| 154 | 154 |
| 141 | 141 | ||
| 142 | 142 | /// What Windows says of itself, read without an administrator. Each is | |
| 143 | 143 | /// None (or empty) when it couldn't be read, and nothing is concluded from it. | |
| 144 | − | #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] | |
| 144 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 145 | 145 | #[serde(default)] | |
| 146 | 146 | pub struct WindowsFacts { | |
| 147 | 147 | /// `EditionID`: `Core` (Home), `Professional`, `Enterprise`, … | |
| ⋯ | |||
| 206 | 206 | .clone() | |
| 207 | 207 | } | |
| 208 | 208 | ||
| 209 | + | /// [`look`](Self::look), kept in the launcher's state folder until | |
| 210 | + | /// Windows restarts: what it says (the edition, the processor, a | |
| 211 | + | /// hypervisor running, a feature turned on) changes only across a | |
| 212 | + | /// restart, and asking takes a second or two, which a status every | |
| 213 | + | /// few seconds shouldn't. | |
| 214 | + | pub fn look_in(dir: &Path) -> WindowsFacts { | |
| 215 | + | let path = dir.join(FACTS_FILE); | |
| 216 | + | let Some(boot) = booted_at() else { return WindowsFacts::look() }; | |
| 217 | + | let kept: Option<Kept> = std::fs::read(&path).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok()); | |
| 218 | + | if let Some(kept) = kept.filter(|kept| same_boot(kept.boot, boot)) { | |
| 219 | + | return kept.facts; | |
| 220 | + | } | |
| 221 | + | let facts = WindowsFacts::look(); | |
| 222 | + | // Kept only when Windows answered. | |
| 223 | + | if facts.booted.is_some() { | |
| 224 | + | let _ = std::fs::create_dir_all(dir); | |
| 225 | + | let _ = std::fs::write(&path, serde_json::to_vec(&Kept { boot, facts: facts.clone() }).unwrap_or_default()); | |
| 226 | + | } | |
| 227 | + | facts | |
| 228 | + | } | |
| 229 | + | ||
| 209 | 230 | /// Windows Home (`Core`, `CoreN`, `CoreSingleLanguage`, `CoreCountrySpecific`). | |
| 210 | 231 | pub fn home(&self) -> bool { | |
| 211 | 232 | self.edition.as_deref().is_some_and(|e| e.starts_with("Core")) | |
| ⋯ | |||
| 235 | 256 | } | |
| 236 | 257 | } | |
| 237 | 258 | ||
| 259 | + | /// The file [`WindowsFacts::look_in`] keeps them in. | |
| 260 | + | pub const FACTS_FILE: &str = "windows-facts.json"; | |
| 261 | + | ||
| 262 | + | #[derive(Serialize, Deserialize)] | |
| 263 | + | struct Kept { | |
| 264 | + | /// When Windows started, in seconds since 1970. | |
| 265 | + | boot: u64, | |
| 266 | + | facts: WindowsFacts, | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | /// Two readings of when Windows started are the same boot: they are | |
| 270 | + | /// worked out from the time now and the time since, so differ by a second or so. | |
| 271 | + | fn same_boot(a: u64, b: u64) -> bool { | |
| 272 | + | a.abs_diff(b) <= 30 | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | /// When Windows started, in seconds since 1970: now, less how long it has been running. | |
| 276 | + | fn booted_at() -> Option<u64> { | |
| 277 | + | #[cfg(windows)] | |
| 278 | + | { | |
| 279 | + | unsafe extern "system" { | |
| 280 | + | fn GetTickCount64() -> u64; | |
| 281 | + | } | |
| 282 | + | // SAFETY: GetTickCount64 takes nothing and only reads a counter. | |
| 283 | + | let up = unsafe { GetTickCount64() } / 1000; | |
| 284 | + | let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); | |
| 285 | + | Some(now.saturating_sub(up)) | |
| 286 | + | } | |
| 287 | + | #[cfg(not(windows))] | |
| 288 | + | None | |
| 289 | + | } | |
| 290 | + | ||
| 238 | 291 | /// Whether setup left a restart to do, this boot: the file setup wrote | |
| 239 | 292 | /// names the boot it ran in. One from an earlier boot is removed. | |
| 240 | 293 | pub fn restart_pending(dir: &Path, facts: &WindowsFacts) -> bool { | |
| ⋯ | |||
| 507 | 560 | println!("{facts:#?}"); | |
| 508 | 561 | assert!(facts.edition.is_some() && facts.build.is_some() && facts.booted.is_some(), "{facts:?}"); | |
| 509 | 562 | assert!(facts.features.contains_key(VM_PLATFORM), "{facts:?}"); | |
| 563 | + | // Kept until Windows restarts: asked once, then read back at once. | |
| 564 | + | let dir = std::env::temp_dir().join(format!("g1t-facts-{}", std::process::id())); | |
| 565 | + | let first = WindowsFacts::look_in(&dir); | |
| 566 | + | let started = std::time::Instant::now(); | |
| 567 | + | assert_eq!(WindowsFacts::look_in(&dir), first); | |
| 568 | + | println!("read back in {:?}", started.elapsed()); | |
| 569 | + | assert!(started.elapsed() < std::time::Duration::from_millis(100)); | |
| 570 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | #[test] | |
| 574 | + | fn what_windows_says_is_kept_until_it_restarts() { | |
| 575 | + | assert!(same_boot(1_760_000_000, 1_760_000_002)); | |
| 576 | + | assert!(!same_boot(1_760_000_000, 1_760_003_600)); | |
| 577 | + | let dir = std::env::temp_dir().join(format!("g1t-facts-kept-{}", std::process::id())); | |
| 578 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 579 | + | let Some(boot) = booted_at() else { return }; | |
| 580 | + | let kept = Kept { boot, facts: clean_home() }; | |
| 581 | + | std::fs::write(dir.join(FACTS_FILE), serde_json::to_vec(&kept).unwrap()).unwrap(); | |
| 582 | + | assert_eq!(WindowsFacts::look_in(&dir), clean_home()); | |
| 583 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 510 | 584 | } | |
| 511 | 585 | ||
| 512 | 586 | #[test] | |
Comments
Sign in to comment.