use semver::Version; use serde::{Deserialize, Serialize}; use std::{ fs, io::Read, path::Path, sync::Mutex, time::{Duration, Instant}, }; use tauri::{ webview::{NewWindowResponse, PageLoadEvent}, AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, }; use tauri_plugin_updater::{Update, UpdaterExt}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Source { base_url: String, public_key: String, } fn source() -> &'static Source { static SOURCE: std::sync::LazyLock = std::sync::LazyLock::new(|| { serde_json::from_str(include_str!("../../update-source.json")) .expect("Bundled update source must be valid") }); &SOURCE } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct Preferences { automatic: bool, interval_hours: u64, } impl Default for Preferences { fn default() -> Self { Self { automatic: true, interval_hours: 6, } } } impl Preferences { fn valid(&self) -> bool { [1, 6, 24].contains(&self.interval_hours) } } #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct View { phase: &'static str, current_version: String, build: String, version: Option, notes: Option, downloaded: u64, total: Option, error: Option<&'static str>, preferences: Preferences, } struct State { view: View, update: Option, last_check: Option, } impl State { fn busy(&self) -> bool { matches!( self.view.phase, "checking" | "downloading" | "verifying" | "installing" | "restarting" ) } fn begin_check(&mut self, manual: bool, now: Instant) -> bool { if self.busy() || (!manual && (!self.view.preferences.automatic || self.last_check.is_some_and(|last| { now.duration_since(last) < Duration::from_secs(self.view.preferences.interval_hours * 3600) }))) { return false; } self.last_check = Some(now); self.view.phase = "checking"; self.view.error = None; self.update = None; self.view.version = None; self.view.notes = None; true } } fn target() -> String { format!("{}-apple-darwin", std::env::consts::ARCH) } fn feed_url() -> Url { Url::parse(&format!("{}/{}/latest.json", source().base_url, target())).unwrap() } fn newer_stable(current: &str, candidate: &str) -> bool { match (Version::parse(current), Version::parse(candidate)) { (Ok(current), Ok(candidate)) => { candidate.pre.is_empty() && candidate.build.is_empty() && candidate > current } _ => false, } } fn valid_download(current: &str, version: &str, url: &Url) -> bool { newer_stable(current, version) && url.as_str() == format!( "{}/{}/{version}/KanbanGit-{version}-{}.app.tar.gz", source().base_url, target(), target() ) } fn trusted_navigation(url: &Url) -> bool { url.scheme() == "tauri" && url.host_str() == Some("localhost") && url.port().is_none() && url.username().is_empty() && url.password().is_none() } pub fn refresh_menu(app: &AppHandle, available: bool, busy: bool) { if let Some(menu) = app .menu() .and_then(|menu| menu.get("app-menu")) .and_then(|item| { item.as_submenu() .and_then(|menu| menu.get("install-update")) }) { if let Some(item) = menu.as_menuitem() { let _ = item.set_enabled(available && !busy); } } } pub fn refresh(app: &AppHandle) { let (view, available, busy) = { let state = app.state::>(); let state = state.lock().unwrap(); (state.view.clone(), state.update.is_some(), state.busy()) }; refresh_menu(app, available, busy); if let Some(window) = app.get_webview_window("updates") { if let Ok(json) = serde_json::to_string(&view) { let _ = window.eval(format!("window.updateStatus?.({json})")); } } if let Some(window) = app.get_webview_window("launcher") { let _ = window.eval(format!("window.updateAvailable?.({available})")); } } pub fn show(app: &AppHandle) { if let Some(window) = app.get_webview_window("updates") { let _ = window.show(); let _ = window.set_focus(); } refresh(app); } fn fail(app: &AppHandle, message: &'static str) { { let state = app.state::>(); let mut state = state.lock().unwrap(); state.view.phase = "error"; state.view.error = Some(message); } refresh(app); } fn update_error(error: &tauri_plugin_updater::Error) -> &'static str { use tauri_plugin_updater::Error; match error { Error::Minisign(_) | Error::Base64(_) | Error::SignatureUtf8(_) | Error::SignedVersionMismatch { .. } | Error::MissingSignedVersion => "signatureError", Error::Serialization(_) | Error::Semver(_) | Error::TargetsNotFound(_) | Error::TargetNotFound(_) => "metadataError", _ => "networkError", } } pub fn check(app: &AppHandle, manual: bool) { if manual { show(app); } let begin = app .state::>() .lock() .unwrap() .begin_check(manual, Instant::now()); if !begin { return; } refresh(app); let app = app.clone(); tauri::async_runtime::spawn(async move { let result = async { app.updater_builder() .pubkey(&source().public_key) .endpoints(vec![feed_url()])? .timeout(Duration::from_secs(60)) .configure_client(|client| client.https_only(true)) .version_comparator(|current, release| { newer_stable(¤t.to_string(), &release.version.to_string()) }) .build()? .check() .await } .await; match result { Ok(Some(mut update)) => { if !valid_download( &update.current_version, &update.version, &update.download_url, ) || update.signature.trim().is_empty() { fail(&app, "metadataError"); return; } update.timeout = Some(Duration::from_secs(600)); let state = app.state::>(); let mut state = state.lock().unwrap(); state.view.phase = "available"; state.view.version = Some(update.version.clone()); state.view.notes = update.body.clone(); state.update = Some(update); } Ok(None) => app.state::>().lock().unwrap().view.phase = "current", Err(error) => { fail(&app, update_error(&error)); return; } } refresh(&app); }); } fn command(program: &str, args: &[&str]) -> Result { let output = std::process::Command::new(program) .args(args) .output() .map_err(|_| "verificationError")?; if !output.status.success() { return Err("verificationError"); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) } fn matches_architecture(header: &[u8]) -> bool { let expected_cpu: u32 = if cfg!(target_arch = "aarch64") { 0x0100_000c } else { 0x0100_0007 }; header.len() >= 8 && header[..4] == [0xcf, 0xfa, 0xed, 0xfe] && header[4..8] == expected_cpu.to_le_bytes() } fn verify_bundle(archive: &Path, directory: &Path, version: &str) -> Result<(), &'static str> { command( "/usr/bin/tar", &[ "-xzf", archive.to_str().ok_or("verificationError")?, "-C", directory.to_str().ok_or("verificationError")?, ], )?; let app = directory.join("KanbanGit.app"); let plist = app.join("Contents/Info.plist"); let plist = plist.to_str().ok_or("verificationError")?; for (field, expected) in [ ("CFBundleShortVersionString", version), ("CFBundleIdentifier", "de.kanbangit.desktop"), ] { if command( "/usr/bin/plutil", &["-extract", field, "raw", "-o", "-", plist], )? != expected { return Err("verificationError"); } } let executable = app.join("Contents/MacOS/kanbangit-native"); let mut header = [0u8; 8]; fs::File::open(executable) .and_then(|mut file| file.read_exact(&mut header)) .map_err(|_| "verificationError")?; if !matches_architecture(&header) { return Err("verificationError"); } command("/usr/bin/codesign", &["--verify", "--deep", "--strict", "-R", "=anchor apple generic and identifier \"de.kanbangit.desktop\" and certificate leaf[subject.OU] = \"ZZX2KZGTHU\" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists", app.to_str().ok_or("verificationError")?])?; Ok(()) } fn install(app: &AppHandle, confirmed_version: Option<&str>) { let update = { let state = app.state::>(); let mut state = state.lock().unwrap(); if state.busy() { return; } let Some(update) = state.update.clone() else { return; }; if confirmed_version != Some(update.version.as_str()) { return; } state.view.phase = "downloading"; state.view.error = None; state.view.downloaded = 0; state.view.total = None; update }; refresh(app); let app = app.clone(); tauri::async_runtime::spawn(async move { let bytes = update .download( |chunk, total| { { let state = app.state::>(); let mut state = state.lock().unwrap(); state.view.downloaded += chunk as u64; state.view.total = total; } refresh(&app); }, || { app.state::>().lock().unwrap().view.phase = "verifying"; refresh(&app); }, ) .await; let bytes = match bytes { Ok(bytes) => bytes, Err(error) => { fail(&app, update_error(&error)); return; } }; let handle = app.clone(); let result = tauri::async_runtime::spawn_blocking(move || -> Result<(), &'static str> { let cache = handle .path() .app_cache_dir() .map_err(|_| "installationError")? .join("verified-update"); if cache.exists() { fs::remove_dir_all(&cache).map_err(|_| "installationError")?; } fs::create_dir_all(&cache).map_err(|_| "installationError")?; let archive = cache.join("update.tar.gz"); let result = (|| { fs::write(&archive, &bytes).map_err(|_| "installationError")?; verify_bundle(&archive, &cache, &update.version)?; handle.state::>().lock().unwrap().view.phase = "installing"; refresh(&handle); update.install(&bytes).map_err(|_| "installationError") })(); let _ = fs::remove_dir_all(cache); result }) .await; match result { Ok(Ok(())) => { app.state::>().lock().unwrap().view.phase = "restarting"; refresh(&app); app.restart(); } Ok(Err(message)) => fail(&app, message), Err(_) => fail(&app, "installationError"), } }); } fn action(app: &AppHandle, url: &Url) { match url.path() { "/updates/check" => check(app, true), "/updates/install" => install( app, url.query_pairs() .find(|(key, _)| key == "version") .as_ref() .map(|(_, value)| value.as_ref()), ), "/updates/preferences" => { let params: std::collections::HashMap<_, _> = url.query_pairs().collect(); let preferences = Preferences { automatic: params.get("automatic").is_some_and(|v| v == "true"), interval_hours: params .get("interval") .and_then(|v| v.parse().ok()) .unwrap_or(0), }; if !preferences.valid() { fail(app, "preferencesError"); return; } let saved = (|| -> Result<(), Box> { let directory = app.path().app_config_dir()?; fs::create_dir_all(&directory)?; let pending = directory.join("update-preferences.pending"); fs::write(&pending, serde_json::to_vec(&preferences)?)?; fs::rename(pending, directory.join("update-preferences.json"))?; Ok(()) })(); if saved.is_err() { fail(app, "preferencesError"); return; } app.state::>().lock().unwrap().view.preferences = preferences; refresh(app); } _ => {} } } pub fn setup(app: &AppHandle) -> tauri::Result<()> { app.plugin( tauri_plugin_updater::Builder::new() .pubkey(&source().public_key) .build(), )?; let path = app.path().app_config_dir()?.join("update-preferences.json"); let (preferences, error) = match fs::read(path) { Ok(bytes) => match serde_json::from_slice::(&bytes) { Ok(p) if p.valid() => (p, None), _ => ( Preferences { automatic: false, ..Default::default() }, Some("preferencesError"), ), }, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { (Preferences::default(), None) } Err(_) => ( Preferences { automatic: false, ..Default::default() }, Some("preferencesError"), ), }; app.manage(Mutex::new(State { view: View { phase: if error.is_some() { "error" } else { "idle" }, current_version: app.package_info().version.to_string(), build: crate::metadata::build_number().into(), version: None, notes: None, downloaded: 0, total: None, error, preferences, }, update: None, last_check: None, })); let handle = app.clone(); let window = WebviewWindowBuilder::new(app, "updates", WebviewUrl::App("updates.html".into())) .title(crate::locale::en::UPDATES) .inner_size(560.0, 690.0) .min_inner_size(420.0, 580.0) .center() .visible(false) .on_navigation(move |url| { if !trusted_navigation(url) { return false; } if url.path().starts_with("/updates/") { action(&handle, url); return false; } url.path() == "/updates.html" }) .on_new_window(|_, _| NewWindowResponse::Deny) .on_page_load(|window, payload| { if payload.event() == PageLoadEvent::Finished { refresh(window.app_handle()); } }) .build()?; let hide = window.clone(); window.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); let _ = hide.hide(); } }); let handle = app.clone(); std::thread::spawn(move || loop { check(&handle, false); std::thread::sleep(Duration::from_secs(30)); }); Ok(()) } #[cfg(test)] mod tests { use super::*; fn state() -> State { State { view: View { phase: "idle", current_version: "1.0.0".into(), build: "1".into(), version: None, notes: None, downloaded: 0, total: None, error: None, preferences: Preferences::default(), }, update: None, last_check: None, } } #[test] fn accepts_only_newer_stable_versions_and_exact_release_paths() { assert!(newer_stable("1.9.9", "1.10.0")); for version in ["0.9.0", "1.0.0", "1.2.0-beta", "1.2.0+build", "invalid"] { assert!(!newer_stable("1.0.0", version)); } let good = format!( "{}/{}/1.1.0/KanbanGit-1.1.0-{}.app.tar.gz", source().base_url, target(), target() ); assert!(valid_download( "1.0.0", "1.1.0", &Url::parse(&good).unwrap() )); for input in [ good.replace("https:", "http:"), good.replace(".app.tar.gz", ".dmg"), good.replace("1.1.0/", "0.9.0/"), good.replace(&target(), "foreign-apple-darwin"), format!("{good}?token=anything"), format!("{good}#fragment"), good.replace("https://", "https://user@"), "https://untrusted.example/update.app.tar.gz".into(), ] { assert!(!valid_download( "1.0.0", "1.1.0", &Url::parse(&input).unwrap() )); } } #[test] fn automatic_checks_start_once_then_respect_interval_and_opt_out() { let mut state = state(); let now = Instant::now(); assert!(state.begin_check(false, now)); assert!(!state.begin_check(false, now)); state.view.phase = "current"; assert!(!state.begin_check(false, now + Duration::from_secs(6 * 3600 - 1))); assert!(state.begin_check(false, now + Duration::from_secs(6 * 3600))); state.view.phase = "current"; state.view.preferences.automatic = false; assert!(!state.begin_check(false, now + Duration::from_secs(24 * 3600))); assert!(state.begin_check(true, now + Duration::from_secs(24 * 3600))); } #[test] fn manual_checks_retry_errors_but_never_overlap_downloads_or_installation() { let mut state = state(); state.view.phase = "error"; state.view.error = Some("networkError"); assert!(state.begin_check(true, Instant::now())); assert!(state.view.error.is_none()); for phase in [ "checking", "downloading", "verifying", "installing", "restarting", ] { state.view.phase = phase; assert!(!state.begin_check(true, Instant::now())); } } #[test] fn preferences_validate_and_round_trip_without_server_settings() { let p = Preferences { automatic: false, interval_hours: 24, }; let json = serde_json::to_string(&p).unwrap(); let loaded: Preferences = serde_json::from_str(&json).unwrap(); assert!(!loaded.automatic); assert_eq!(loaded.interval_hours, 24); assert!(loaded.valid()); for interval in [0, 2, 100, u64::MAX] { assert!(!Preferences { automatic: true, interval_hours: interval } .valid()); } } #[test] fn update_actions_are_confined_to_the_local_trusted_window() { assert!(trusted_navigation( &Url::parse("tauri://localhost/updates/install").unwrap() )); for address in [ "https://kanbangit.de/updates/install", "https://localhost/updates/install", "tauri://user@localhost/updates/install", "tauri://localhost:443/updates/install", "tauri://other/updates/install", ] { assert!(!trusted_navigation(&Url::parse(address).unwrap())); } } #[test] fn rejects_foreign_or_invalid_mach_o_headers_without_developer_tools() { let cpu: u32 = if cfg!(target_arch = "aarch64") { 0x0100_000c } else { 0x0100_0007 }; let mut header = vec![0xcf, 0xfa, 0xed, 0xfe]; header.extend(cpu.to_le_bytes()); assert!(matches_architecture(&header)); header[4] ^= 1; assert!(!matches_architecture(&header)); assert!(!matches_architecture(b"MZ")); assert!(!matches_architecture(&[0; 8])); } #[test] fn error_categories_offer_specific_retryable_messages() { use tauri_plugin_updater::Error; assert_eq!( update_error(&Error::Network("offline".into())), "networkError" ); assert_eq!( update_error(&Error::TargetNotFound("darwin-other".into())), "metadataError" ); assert_eq!( update_error(&Error::SignatureUtf8("invalid".into())), "signatureError" ); } } #[cfg(test)] mod signed_upgrade_test { use super::*; use std::{io::Write, net::TcpListener}; #[test] #[ignore = "Requires KANBANGIT_UPGRADE_BASE and KANBANGIT_UPGRADE_ARCHIVE pointing to two Developer-ID-signed macOS versions"] fn installs_a_real_signed_upgrade_and_rejects_tampering() { let base = std::env::var("KANBANGIT_UPGRADE_BASE").expect("Set the signed base app path"); let archive = std::env::var("KANBANGIT_UPGRADE_ARCHIVE") .expect("Set the signed updater archive path"); let base_version = env!("CARGO_PKG_VERSION"); let mut next_version = Version::parse(base_version).unwrap(); next_version.patch += 1; let candidate_version = std::env::var("KANBANGIT_UPGRADE_VERSION").unwrap_or_else(|_| next_version.to_string()); assert!(newer_stable(base_version, &candidate_version)); let root = std::env::temp_dir().join(format!("kanbangit-upgrade-{}", std::process::id())); fs::create_dir(&root).unwrap(); struct Cleanup(std::path::PathBuf); impl Drop for Cleanup { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } let _cleanup = Cleanup(root.clone()); let installed = root.join("KanbanGit.app"); command("/usr/bin/ditto", &[&base, installed.to_str().unwrap()]).unwrap(); let data = root.join("user-data"); fs::create_dir(&data).unwrap(); fs::write(data.join("active-server"), "https://workspace.example/").unwrap(); fs::write(data.join("session"), "existing-session").unwrap(); let bytes = fs::read(&archive).unwrap(); let signature = fs::read_to_string(format!("{archive}.sig")).unwrap(); let verify_dir = root.join("verify"); fs::create_dir(&verify_dir).unwrap(); verify_bundle(Path::new(&archive), &verify_dir, &candidate_version).unwrap(); assert!(verify_bundle(Path::new(&archive), &verify_dir, base_version).is_err()); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let download = bytes.clone(); let announced_version = candidate_version.clone(); let server = std::thread::spawn(move || { for index in 0..3 { let (mut stream, _) = listener.accept().unwrap(); stream .set_read_timeout(Some(Duration::from_secs(10))) .unwrap(); let mut request = [0; 4096]; let _ = stream.read(&mut request).unwrap(); let body = if index == 0 { serde_json::to_vec(&serde_json::json!({"version":announced_version, "platforms":{(format!("darwin-{}", std::env::consts::ARCH)): {"url":format!("http://{address}/update.tar.gz"), "signature":signature.trim()}}})).unwrap() } else if index == 1 { b"tampered update".to_vec() } else { download.clone() }; write!( stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ) .unwrap(); stream.write_all(&body).unwrap(); } }); let mut context = crate::desktop::context(); context.config_mut().plugins.0.insert("updater".into(), serde_json::json!({"pubkey":source().public_key, "dangerousInsecureTransportProtocol":true})); let app = tauri::test::mock_builder() .plugin(tauri_plugin_updater::Builder::new().build()) .build(context) .unwrap(); let update = tauri::async_runtime::block_on(async { app.updater_builder() .executable_path(installed.join("Contents/MacOS/kanbangit-native")) .endpoints(vec![ Url::parse(&format!("http://{address}/latest.json")).unwrap() ]) .unwrap() .timeout(Duration::from_secs(20)) .build() .unwrap() .check() .await .unwrap() .unwrap() }); assert_eq!(update.current_version, base_version); let rejected = tauri::async_runtime::block_on(update.download(|_, _| {}, || {})).unwrap_err(); assert_eq!(update_error(&rejected), "signatureError"); let plist = installed.join("Contents/Info.plist"); assert_eq!( command( "/usr/bin/plutil", &[ "-extract", "CFBundleShortVersionString", "raw", "-o", "-", plist.to_str().unwrap() ] ) .unwrap(), base_version ); let verified = tauri::async_runtime::block_on(update.download(|_, _| {}, || {})).unwrap(); assert_eq!(verified, bytes); update.install(verified).unwrap(); assert_eq!( command( "/usr/bin/plutil", &[ "-extract", "CFBundleShortVersionString", "raw", "-o", "-", plist.to_str().unwrap() ] ) .unwrap(), candidate_version ); command( "/usr/bin/codesign", &[ "--verify", "--deep", "--strict", installed.to_str().unwrap(), ], ) .unwrap(); assert_eq!( fs::read_to_string(data.join("active-server")).unwrap(), "https://workspace.example/" ); assert_eq!( fs::read_to_string(data.join("session")).unwrap(), "existing-session" ); server.join().unwrap(); } }