From 4259046de08f45a9904901945ba8b5bf83201f83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 00:40:50 +0000 Subject: [PATCH] Offer to install WebView2, with an administrator prompt, on Windows kirie now shows web wallpapers on Windows in Microsoft Edge WebView2, a system component that Windows 11 ships and most Windows 10 machines have. For the ones that do not, haru can now install it. haru reads the runtime's version from the registry, where Microsoft's guidance says to look. When it is missing, the install-kirie prompt offers to install it too, and Settings > Renderer has an Install WebView2 button. Either one downloads Microsoft's Evergreen bootstrapper, refuses to run it unless it carries a valid Authenticode signature from Microsoft Corporation, and runs it through Start-Process -Verb RunAs, so Windows puts up its administrator prompt and the runtime is installed for every account. Declining the prompt is reported as that, and a failed WebView2 install never undoes a good kirie install. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QJnt7JtdtiqnVCP9GYqUhK --- crates/haru-apply/src/install.rs | 2 +- crates/haru-apply/src/lib.rs | 1 + crates/haru-apply/src/webview2.rs | 237 ++++++++++++++++++++++++++++++ crates/haru-ui/src/lib.rs | 1 + crates/haru-ui/src/renderer.rs | 59 +++++++- crates/haru-ui/src/runtime.rs | 121 +++++++++++++++ crates/haru-ui/src/settings.rs | 5 + 7 files changed, 418 insertions(+), 8 deletions(-) create mode 100644 crates/haru-apply/src/webview2.rs create mode 100644 crates/haru-ui/src/runtime.rs 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),