diff --git a/crates/haru-apply/src/install.rs b/crates/haru-apply/src/install.rs index 157aea3..0fb0752 100644 --- a/crates/haru-apply/src/install.rs +++ b/crates/haru-apply/src/install.rs @@ -7,7 +7,7 @@ const MAX_BYTES: u64 = 256 * 1024 * 1024; const DEADLINE: std::time::Duration = std::time::Duration::from_secs(30); -const AGENT: &str = concat!("haru/", env!("CARGO_PKG_VERSION")); +pub(crate) const AGENT: &str = concat!("haru/", env!("CARGO_PKG_VERSION")); const WEBKIT: [&str; 3] = [ "libwebkit2gtk-4.1.so.0", diff --git a/crates/haru-apply/src/lib.rs b/crates/haru-apply/src/lib.rs index 8e25103..83684fa 100644 --- a/crates/haru-apply/src/lib.rs +++ b/crates/haru-apply/src/lib.rs @@ -12,6 +12,7 @@ mod socket; pub mod startup; mod stream; pub mod update; +pub mod webview2; pub use engine::{Engine, Snapshot}; pub use kirie::Kirie; diff --git a/crates/haru-apply/src/webview2.rs b/crates/haru-apply/src/webview2.rs new file mode 100644 index 0000000..0955510 --- /dev/null +++ b/crates/haru-apply/src/webview2.rs @@ -0,0 +1,237 @@ +//! The Microsoft Edge WebView2 Runtime, which kirie's web wallpapers run in on +//! Windows. +//! +//! It is a Windows component, not part of kirie: Windows 11 ships it, and Edge's +//! updater has put it on nearly every Windows 10 machine. For the rest, +//! Microsoft publishes a small bootstrapper that fetches and installs it. haru +//! checks for it, and when asked, downloads that bootstrapper, makes sure it +//! really is Microsoft's, and runs it with an administrator prompt, so the +//! runtime is installed for every account on the machine rather than just this +//! one. +//! +//! Everywhere but Windows, web wallpapers use something else, so the runtime is +//! never needed there. + +/// Microsoft's permanent link to the Evergreen bootstrapper. +pub const BOOTSTRAPPER: &str = "https://go.microsoft.com/fwlink/p/?LinkId=2124703"; + +/// The runtime's id in EdgeUpdate's registry, from Microsoft's own guidance on +/// detecting it. +#[cfg_attr(not(windows), allow(dead_code))] +const CLIENT: &str = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; + +/// Where an installed runtime records its version: per machine (under the +/// 32-bit view on 64-bit Windows, and the native one on 32-bit Windows), and +/// per user. +#[cfg_attr(not(windows), allow(dead_code))] +fn keys() -> [String; 3] { + [ + format!(r"HKLM\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{CLIENT}"), + format!(r"HKLM\SOFTWARE\Microsoft\EdgeUpdate\Clients\{CLIENT}"), + format!(r"HKCU\Software\Microsoft\EdgeUpdate\Clients\{CLIENT}"), + ] +} + +/// Whether this platform's web wallpapers need the runtime at all. +#[must_use] +pub const fn needed() -> bool { + cfg!(windows) +} + +/// The installed runtime's version, or `None` when there is none. +/// +/// Always `None` off Windows, where nothing needs it. +#[must_use] +#[cfg(windows)] +pub fn installed() -> Option { + keys().iter().find_map(|key| { + let said = crate::child::quiet("reg") + .args(["query", key, "/v", "pv"]) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + if !said.status.success() { + return None; + } + version_in(&String::from_utf8_lossy(&said.stdout)) + }) +} + +#[must_use] +#[cfg(not(windows))] +pub const fn installed() -> Option { + None +} + +/// The version `reg query ... /v pv` printed, if it names a real install. +/// +/// An uninstall can leave the key behind with `pv` empty or `0.0.0.0`, which +/// Microsoft's guidance says to read as not installed. +#[must_use] +pub fn version_in(listing: &str) -> Option { + listing.lines().find_map(|line| { + let mut words = line.split_whitespace(); + if !words.next()?.eq_ignore_ascii_case("pv") || !words.next()?.starts_with("REG_") { + return None; + } + let version = words.next()?.trim(); + (!version.is_empty() && version != "0.0.0.0").then(|| version.to_owned()) + }) +} + +/// Download Microsoft's bootstrapper and run it with an administrator prompt. +/// +/// Returns the version now installed. Fails without running anything if the +/// download is not signed by Microsoft, and says so plainly when the prompt is +/// declined. +#[cfg(windows)] +pub fn install(progress: &mut dyn FnMut(u64, u64)) -> Result { + if let Some(version) = installed() { + return Ok(version); + } + let setup = std::env::temp_dir().join(format!( + "MicrosoftEdgeWebview2Setup.{}.exe", + std::process::id() + )); + download(&setup, progress)?; + let ran = run_elevated(&setup); + let _ = std::fs::remove_file(&setup); + ran?; + installed().ok_or_else(|| { + "the installer finished, but the WebView2 Runtime still is not there".to_owned() + }) +} + +#[cfg(not(windows))] +pub fn install(_progress: &mut dyn FnMut(u64, u64)) -> Result { + Err("the WebView2 Runtime is only needed on Windows".to_owned()) +} + +#[cfg(windows)] +fn download(to: &std::path::Path, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> { + use std::io::{Read as _, Write as _}; + + // The bootstrapper is under 2 MB; anything much bigger is not it. + const LIMIT: u64 = 16 * 1024 * 1024; + + let response = ureq::get(BOOTSTRAPPER) + .set("User-Agent", crate::install::AGENT) + .timeout(std::time::Duration::from_secs(60)) + .call() + .map_err(|error| format!("could not download the WebView2 installer ({error})"))?; + let total = response + .header("Content-Length") + .and_then(|length| length.parse().ok()) + .unwrap_or(0); + let mut body = response.into_reader().take(LIMIT); + let mut file = std::fs::File::create(to) + .map_err(|error| format!("cannot save the WebView2 installer ({error})"))?; + let mut buffer = vec![0_u8; 64 * 1024]; + let mut done = 0_u64; + loop { + let read = body + .read(&mut buffer) + .map_err(|error| format!("the WebView2 download stopped ({error})"))?; + if read == 0 { + break; + } + let chunk = buffer.get(..read).ok_or("short read")?; + file.write_all(chunk) + .map_err(|error| format!("cannot save the WebView2 installer ({error})"))?; + done += read as u64; + progress(done, total.max(done)); + } + Ok(()) +} + +/// What the elevation script exits with, besides the installer's own code. +#[cfg_attr(not(windows), allow(dead_code))] +const NOT_MICROSOFT: i32 = 90; +#[cfg_attr(not(windows), allow(dead_code))] +const DECLINED: i32 = 91; + +/// The PowerShell that checks the download's signature and then runs it +/// through Windows' administrator prompt. +/// +/// `Start-Process -Verb RunAs` is the documented way to ask for elevation from +/// a script, and it throws when the user says no. The signature check comes +/// first because the file is about to be run as administrator: a valid +/// Authenticode signature from Microsoft Corporation, or nothing runs. +#[must_use] +pub fn elevation_script(setup: &std::path::Path) -> String { + let quoted = setup.display().to_string().replace('\'', "''"); + format!( + "$ErrorActionPreference = 'Stop'\n\ + $setup = '{quoted}'\n\ + $signed = Get-AuthenticodeSignature -LiteralPath $setup\n\ + if ($signed.Status -ne 'Valid' -or $signed.SignerCertificate.Subject -notlike '*O=Microsoft Corporation*') {{ exit {NOT_MICROSOFT} }}\n\ + try {{ $run = Start-Process -FilePath $setup -ArgumentList '/silent','/install' -Verb RunAs -Wait -PassThru }} catch {{ exit {DECLINED} }}\n\ + exit $run.ExitCode\n" + ) +} + +#[cfg(windows)] +fn run_elevated(setup: &std::path::Path) -> Result<(), String> { + let status = crate::child::quiet("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + &elevation_script(setup), + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|error| format!("could not start PowerShell ({error})"))?; + match status.code() { + Some(0) => Ok(()), + Some(NOT_MICROSOFT) => { + Err("the downloaded installer is not signed by Microsoft, so it was not run".to_owned()) + } + Some(DECLINED) => Err("the administrator prompt was declined".to_owned()), + Some(code) => Err(format!("the WebView2 installer failed (exit code {code})")), + None => Err("the WebView2 installer was stopped".to_owned()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_registered_version_is_read_from_reg_output() { + let listing = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\r\n pv REG_SZ 128.0.2739.79\r\n\r\n"; + assert_eq!(version_in(listing).as_deref(), Some("128.0.2739.79")); + } + + #[test] + fn a_key_left_behind_by_an_uninstall_is_not_an_install() { + assert_eq!(version_in(" pv REG_SZ 0.0.0.0\r\n"), None); + assert_eq!(version_in(" pv REG_SZ \r\n"), None); + assert_eq!( + version_in( + "ERROR: The system was unable to find the specified registry key or value.\r\n" + ), + None + ); + } + + #[test] + fn the_script_checks_the_signature_before_elevating() { + let script = elevation_script(std::path::Path::new( + r"C:\Users\o'neil\AppData\Local\Temp\setup.exe", + )); + let checked = script.find("Get-AuthenticodeSignature"); + let elevated = script.find("-Verb RunAs"); + assert!( + matches!((checked, elevated), (Some(checked), Some(elevated)) if checked < elevated), + "the signature is checked before anything is elevated:\n{script}" + ); + assert!(script.contains(r"'C:\Users\o''neil\AppData\Local\Temp\setup.exe'")); + assert!(script.contains("O=Microsoft Corporation")); + } +} diff --git a/crates/haru-ui/src/lib.rs b/crates/haru-ui/src/lib.rs index b98302c..487dd8c 100644 --- a/crates/haru-ui/src/lib.rs +++ b/crates/haru-ui/src/lib.rs @@ -5,6 +5,7 @@ mod library; mod preview; mod props; mod renderer; +mod runtime; mod settings; pub mod theme; mod tile; diff --git a/crates/haru-ui/src/renderer.rs b/crates/haru-ui/src/renderer.rs index 224fd2f..c55f147 100644 --- a/crates/haru-ui/src/renderer.rs +++ b/crates/haru-ui/src/renderer.rs @@ -10,13 +10,15 @@ use crate::theme; enum Phase { Choosing, Working(u64, u64), - Done(PathBuf), + /// Installed, and anything that went wrong on the side: the WebView2 + /// Runtime failing to install does not undo a good kirie install. + Done(PathBuf, Option), Failed(String), } enum Note { Progress(u64, u64), - Done(PathBuf), + Done(PathBuf, Option), Failed(String), } @@ -34,6 +36,11 @@ pub struct Installer { web: Web, webkit: bool, betas: bool, + /// Whether web wallpapers' runtime is missing here, which only Windows + /// asks: `None` until the prompt is first offered. + webview_missing: Option, + /// Whether to install it along with kirie. + webview: bool, phase: Phase, notes: Option>, } @@ -53,6 +60,8 @@ impl Installer { web: if webkit { Web::WebKit } else { Web::Cef }, webkit, betas: false, + webview_missing: None, + webview: true, phase: Phase::Choosing, notes: None, } @@ -62,6 +71,9 @@ impl Installer { /// whatever the updater is already set to follow. pub fn offer(&mut self, betas: bool) { self.betas = betas; + if haru_apply::webview2::needed() && self.webview_missing.is_none() { + self.webview_missing = Some(haru_apply::webview2::installed().is_none()); + } self.open = true; self.phase = Phase::Choosing; } @@ -134,7 +146,7 @@ impl Installer { if close { self.open = false; outcome = match self.phase { - Phase::Done(_) => Outcome::Installed(self.web, self.betas), + Phase::Done(..) => Outcome::Installed(self.web, self.betas), _ => Outcome::Dismissed, }; } @@ -163,10 +175,21 @@ impl Installer { .color(theme::MUTED), ); } - Phase::Done(path) => { + Phase::Done(path, aside) => { ui.label( RichText::new(format!("Installed to {}", path.display())).color(theme::ACCENT), ); + if let Some(aside) = aside { + ui.add_space(4.0); + ui.label( + RichText::new(format!( + "Web wallpapers will not run yet: {aside}. Settings › Renderer can \ + try again." + )) + .small() + .color(theme::DANGER), + ); + } ui.add_space(4.0); ui.label( RichText::new( @@ -207,6 +230,20 @@ impl Installer { self.flavours(ui); } + if self.webview_missing == Some(true) { + ui.add_space(2.0); + ui.checkbox( + &mut self.webview, + "Also install WebView2, for web wallpapers", + ) + .on_hover_text( + "Web wallpapers run in the Microsoft Edge WebView2 Runtime, which this \ + machine does not have. haru downloads Microsoft's installer, checks it \ + is signed by Microsoft, and Windows asks for administrator permission \ + before it runs.", + ); + } + ui.add_space(2.0); ui.checkbox(&mut self.betas, "Install the beta") .on_hover_text( @@ -277,6 +314,7 @@ impl Installer { let (notes, heard) = channel(); let web = self.web; let betas = self.betas; + let webview = self.webview && self.webview_missing == Some(true); let ctx = ctx.clone(); let spawned = std::thread::Builder::new() .name("haru-install".to_owned()) @@ -305,7 +343,11 @@ impl Installer { return; }; let note = match install::fetch(&build, &target, &mut report) { - Ok(path) => Note::Done(path), + Ok(path) if webview => { + let aside = haru_apply::webview2::install(&mut report).err(); + Note::Done(path, aside) + } + Ok(path) => Note::Done(path, None), Err(why) => Note::Failed(why), }; let _ = notes.send(note); @@ -328,8 +370,11 @@ impl Installer { while let Ok(note) = notes.try_recv() { match note { Note::Progress(done, total) => self.phase = Phase::Working(done, total), - Note::Done(path) => { - self.phase = Phase::Done(path); + Note::Done(path, aside) => { + if aside.is_none() { + self.webview_missing = self.webview_missing.map(|_| false); + } + self.phase = Phase::Done(path, aside); finished = true; } Note::Failed(why) => { diff --git a/crates/haru-ui/src/runtime.rs b/crates/haru-ui/src/runtime.rs new file mode 100644 index 0000000..7db1a07 --- /dev/null +++ b/crates/haru-ui/src/runtime.rs @@ -0,0 +1,121 @@ +//! The WebView2 Runtime row in Settings: whether web wallpapers can run on this +//! Windows machine, and a button that installs what they need. +//! +//! Checking means asking the registry through `reg`, which is a process start, +//! so it happens once and again only after an install. + +use std::sync::mpsc::{Receiver, channel}; + +use egui::RichText; +use haru_apply::webview2; + +use crate::theme; + +#[derive(Default)] +pub struct WebRuntime { + version: Option>, + work: Option>>, + note: String, +} + +impl WebRuntime { + /// The installed runtime's version, checking the first time it is asked. + pub fn version(&mut self) -> Option<&str> { + self.version + .get_or_insert_with(webview2::installed) + .as_deref() + } + + #[must_use] + pub const fn busy(&self) -> bool { + self.work.is_some() + } + + /// Download and run Microsoft's installer in the background. Windows puts + /// its administrator prompt up on its own. + pub fn install(&mut self, ctx: &egui::Context) { + if self.busy() { + return; + } + let (tell, heard) = channel(); + let ctx = ctx.clone(); + let started = std::thread::Builder::new() + .name("haru-webview2".to_owned()) + .spawn(move || { + let _ = tell.send(webview2::install(&mut |_, _| {})); + ctx.request_repaint(); + }); + match started { + Ok(_) => { + self.work = Some(heard); + self.note = + "Downloading the installer; Windows will ask for permission…".to_owned(); + } + Err(_) => self.note = "could not start the install".to_owned(), + } + } + + fn collect(&mut self) { + let Some(work) = self.work.as_ref() else { + return; + }; + let Ok(outcome) = work.try_recv() else { + return; + }; + self.work = None; + match outcome { + Ok(version) => { + self.note = String::new(); + self.version = Some(Some(version)); + } + Err(why) => { + self.note = why; + self.version = None; + } + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) { + if !webview2::needed() { + return; + } + self.collect(); + if self.busy() { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(250)); + } + + match self.version().map(str::to_owned) { + Some(version) => { + ui.label( + RichText::new(format!("Web wallpapers: WebView2 {version} is installed")) + .color(theme::MUTED), + ); + } + None => { + ui.label( + RichText::new( + "Web wallpapers need the Microsoft Edge WebView2 Runtime, \ + which this machine does not have.", + ) + .color(theme::MUTED), + ); + ui.add_space(4.0); + let button = ui + .add_enabled(!self.busy(), egui::Button::new("Install WebView2…")) + .on_hover_text( + "Downloads Microsoft's installer, checks it is signed by \ + Microsoft, and runs it. Windows asks for administrator \ + permission first.", + ); + if button.clicked() { + self.install(ui.ctx()); + } + } + } + if !self.note.is_empty() { + ui.add_space(2.0); + ui.label(RichText::new(&self.note).small().color(theme::MUTED)); + } + } +} diff --git a/crates/haru-ui/src/settings.rs b/crates/haru-ui/src/settings.rs index 31485ba..868fd30 100644 --- a/crates/haru-ui/src/settings.rs +++ b/crates/haru-ui/src/settings.rs @@ -108,6 +108,7 @@ pub struct Settings { library: String, status: String, cards: Option>, + web_runtime: crate::runtime::WebRuntime, } impl Settings { @@ -368,6 +369,10 @@ impl Settings { ui.add_space(6.0); Self::as_application(ui, actions); ui.add_space(6.0); + if haru_apply::webview2::needed() { + self.web_runtime.ui(ui); + ui.add_space(6.0); + } match (engine.available, engine.pid) { (true, _) => Self::running(ui, engine, actions),