use crate::api::Result; use futures::StreamExt; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::sync::{Arc, Mutex}; use tauri::http::HeaderValue; use tauri::http::header::ACCEPT; use tauri::{Manager, ResourceId, Runtime, Webview}; use tauri_plugin_http::reqwest; use tauri_plugin_http::reqwest::ClientBuilder; use tauri_plugin_updater::{Error, Update, UpdaterExt}; use theseus::{ LoadingBarType, emit_loading, init_loading, launcher_user_agent, }; use tokio::time::Instant; use url::Url; const STARLIGHT_UPDATE_LATEST_URL: &str = "https://skin.starlight.cool/starlight/launcher/latest"; const STARLIGHT_UPDATE_VERSIONS_URL: &str = "https://skin.starlight.cool/starlight/launcher/versions"; const STARLIGHT_UPDATE_BASE_URL: &str = "https://skin.starlight.cool/"; // The updater plugin builds `Update` with no request timeout, so a stalled // connection would hang the download forever. Bound the whole download. const UPDATE_DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60); // ── Shared types ───────────────────────────────────────────────── #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct UpdateMetadata { rid: ResourceId, current_version: String, version: String, date: Option, body: Option, published_at: Option, force_update: bool, raw_json: serde_json::Value, } #[derive(Default)] pub struct PendingUpdateData(pub Mutex, Vec)>>); // ── Update Server API types ───────────────────────────────────── #[derive(Deserialize)] struct VersionsResponse { versions: Vec, } #[derive(Deserialize)] struct VersionEntry { version: String, artifacts: Vec, } #[derive(Deserialize)] struct ArtifactEntry { kind: String, #[serde(default)] variant: Option, platform: String, architecture: String, #[serde(default)] relative_path: Option, #[serde(default)] download_url: Option, #[serde(default)] sha256: Option, #[serde(default)] size: u64, } /// The .deb asset for an apt-managed Linux update, from the Update Server /// catalog (`/api/versions`). The deb has no minisign signature, so its /// integrity is verified with the catalog's sha256 and size instead. struct AptDebAsset { url: Url, sha256: String, size: u64, } fn apt_deb_arch() -> Result<&'static str> { match std::env::consts::ARCH { "x86_64" => Ok("amd64"), "aarch64" => Ok("arm64"), arch => Err(theseus::Error::from(theseus::ErrorKind::OtherError( format!("Unsupported architecture for apt updates: {arch}"), )) .into()), } } async fn fetch_apt_deb_asset(version: &str) -> Result { let response = ClientBuilder::new() .user_agent(launcher_user_agent()) .timeout(UPDATE_DOWNLOAD_TIMEOUT) .build()? .get(STARLIGHT_UPDATE_VERSIONS_URL) .send() .await?; if !response.status().is_success() { return Err(Error::Network(format!( "Failed to fetch update catalog: {}", response.status() )) .into()); } let catalog: VersionsResponse = response.json().await?; let release = catalog .versions .iter() .find(|entry| entry.version == version) .ok_or_else(|| { theseus::Error::from(theseus::ErrorKind::OtherError(format!( "Update catalog has no entry for version {version}" ))) })?; let arch = std::env::consts::ARCH; let artifact = release .artifacts .iter() .find(|entry| { entry.kind == "installer" && entry.variant.as_deref() == Some("deb") && entry.platform == "linux" && entry.architecture == arch }) .ok_or_else(|| { theseus::Error::from(theseus::ErrorKind::OtherError(format!( "Update catalog has no deb artifact for {version} on {arch}" ))) })?; let sha256 = artifact.sha256.clone().ok_or_else(|| { theseus::Error::from(theseus::ErrorKind::OtherError(format!( "Update catalog has no sha256 for the deb artifact of {version}" ))) })?; let url = artifact_download_url(artifact)?; Ok(AptDebAsset { url, sha256, size: artifact.size, }) } fn artifact_download_url(artifact: &ArtifactEntry) -> Result { if let Some(download_url) = artifact.download_url.as_deref() { return Url::parse(download_url).map_err(|error| { theseus::Error::from(theseus::ErrorKind::OtherError( error.to_string(), )) .into() }); } let relative_path = artifact.relative_path.as_deref().ok_or_else(|| { theseus::Error::from(theseus::ErrorKind::OtherError( "Update catalog artifact has no download URL".to_string(), )) })?; Url::parse(STARLIGHT_UPDATE_BASE_URL) .and_then(|base| base.join(relative_path)) .map_err(|error| { theseus::Error::from(theseus::ErrorKind::OtherError( error.to_string(), )) .into() }) } // ── Updater plugin helpers ─────────────────────────────────────── fn update_channel(channel: &str) -> Result<&str> { match channel { "release" | "beta" => Ok(channel), _ => Err(theseus::Error::from(theseus::ErrorKind::OtherError( format!("Unknown update channel: {channel}"), )) .into()), } } fn update_platform() -> Result<&'static str> { match (std::env::consts::OS, std::env::consts::ARCH) { ("windows", "x86_64") => Ok("windows-x86_64"), ("linux", "x86_64") => Ok("linux-x86_64"), ("linux", "aarch64") => Ok("linux-aarch64"), ("macos", "x86_64") => Ok("darwin-x86_64"), ("macos", "aarch64") => Ok("darwin-aarch64"), (os, arch) => { Err(theseus::Error::from(theseus::ErrorKind::OtherError(format!( "Unsupported updater platform: {os}-{arch}" ))) .into()) } } } fn update_endpoint() -> Result { Url::parse(STARLIGHT_UPDATE_LATEST_URL).map_err(|error| { theseus::Error::from(theseus::ErrorKind::OtherError(error.to_string())) .into() }) } /// Build the platform-updater with the given endpoints and run a check. async fn check_with_endpoints( webview: &Webview, channel: &str, ) -> Result> { let channel = update_channel(channel)?; let platform = update_platform()?; let current_version = webview.app_handle().package_info().version.to_string(); let mut updater = webview .updater_builder() .endpoints(vec![update_endpoint()?])? .header("Accept", "application/json")? .header("X-Axolotl-Channel", channel)? .header("X-Axolotl-Platform", platform)? .header("X-Axolotl-Version", current_version)?; #[cfg(target_os = "windows")] { let install_dir = std::env::current_exe() .map_err(|error| { theseus::Error::from(theseus::ErrorKind::OtherError(format!( "Failed to resolve current executable: {error}" ))) })? .parent() .ok_or_else(|| { theseus::Error::from(theseus::ErrorKind::OtherError( "Current executable has no parent directory".to_string(), )) })? .to_path_buf(); tracing::debug!( install_dir = %install_dir.display(), "Using current executable directory for Windows app updates" ); updater = updater.installer_arg(format!( "/INSTALL_DIR=\"{}\"", install_dir.display() )); } let updater = updater.build()?; updater.check().await.map_err(Into::into) } /// Check the updater manifest through the configured Update Server endpoint. async fn check_with_updater( webview: &Webview, channel: &str, ) -> Result> { let Some(mut update) = check_with_endpoints(webview, channel).await? else { return Ok(None); }; update.timeout = Some(UPDATE_DOWNLOAD_TIMEOUT); // On Debian and derivatives the plugin's minisign signature check cannot // validate the unsigned .deb, so point the download at the deb from the // Update Server catalog instead of the AppImage artifact. Its integrity // is verified with the catalog's sha256/size during the download. if is_apt_linux() { update.download_url = fetch_apt_deb_asset(&update.version).await?.url; } let published_at = update .raw_json .get("published_at") .and_then(serde_json::Value::as_str) .map(str::to_owned); let force_update = update .raw_json .get("force_update") .and_then(serde_json::Value::as_bool) .unwrap_or(false); let metadata = UpdateMetadata { rid: webview.resources_table().add(update.clone()), current_version: update.current_version.clone(), version: update.version.clone(), date: None, body: update.body.clone(), published_at, force_update, raw_json: update.raw_json, }; Ok(Some(metadata)) } // ── Tauri commands ─────────────────────────────────────────────── #[tauri::command] pub async fn check_app_update( webview: Webview, channel: String, ) -> Result> { check_with_updater(&webview, &channel).await } // Reimplementation of Update::download mostly, minus the actual download part #[tauri::command] pub async fn get_update_size( webview: Webview, rid: ResourceId, ) -> Result> { let update = webview.resources_table().get::(rid)?; let mut headers = update.headers.clone(); if !headers.contains_key(ACCEPT) { headers.insert( ACCEPT, HeaderValue::from_static("application/octet-stream"), ); } let mut request = ClientBuilder::new().user_agent(launcher_user_agent()); if let Some(timeout) = update.timeout { request = request.timeout(timeout); } if let Some(ref proxy) = update.proxy { let proxy = reqwest::Proxy::all(proxy.as_str())?; request = request.proxy(proxy); } let response = request .build()? .head(update.download_url.clone()) .headers(headers) .send() .await?; if !response.status().is_success() { return Err(Error::Network(format!( "Download request failed with status: {}", response.status() )) .into()); } let content_length = response .headers() .get("Content-Length") .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse().ok()); Ok(content_length) } #[tauri::command] pub async fn enqueue_update_for_installation( webview: Webview, rid: ResourceId, ) -> Result<()> { let pending_data = webview.state::().inner(); let update = webview.resources_table().get::(rid)?; let progress = init_loading( LoadingBarType::LauncherUpdate { version: update.version.clone(), current_version: update.current_version.clone(), }, 1.0, "Downloading update...", ) .await?; let download_start = Instant::now(); let update_data = if is_apt_linux() { // The .deb carries no minisign signature, so the plugin's signed // download cannot be used. Fetch the catalog entry and verify the // downloaded bytes against its sha256 and size instead. let asset = fetch_apt_deb_asset(&update.version).await?; let mut headers = update.headers.clone(); if !headers.contains_key(ACCEPT) { headers.insert( ACCEPT, HeaderValue::from_static("application/octet-stream"), ); } let mut request = ClientBuilder::new().user_agent(launcher_user_agent()); if let Some(timeout) = update.timeout { request = request.timeout(timeout); } if let Some(ref proxy) = update.proxy { let proxy = reqwest::Proxy::all(proxy.as_str())?; request = request.proxy(proxy); } let response = request .build()? .get(update.download_url.clone()) .headers(headers) .send() .await?; if !response.status().is_success() { return Err(Error::Network(format!( "Download request failed with status: {}", response.status() )) .into()); } let total_size = response.content_length().unwrap_or(asset.size); let mut buffer = Vec::new(); let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk?; buffer.extend_from_slice(&chunk); if total_size > 0 { if let Err(e) = emit_loading( &progress, buffer.len() as f64 / total_size as f64, None, ) { tracing::error!( "Failed to update download progress bar: {e}" ); } } } if buffer.len() as u64 != asset.size { return Err(theseus::Error::from(theseus::ErrorKind::OtherError( format!( "Downloaded deb size mismatch: expected {}, got {}", asset.size, buffer.len() ), )) .into()); } let digest = Sha256::digest(&buffer); let digest_hex = digest .iter() .map(|byte| format!("{byte:02x}")) .collect::(); if digest_hex != asset.sha256 { return Err(theseus::Error::from(theseus::ErrorKind::OtherError( "Downloaded deb sha256 mismatch".to_string(), )) .into()); } buffer } else { update .download( |chunk_size, total_size| { let Some(total_size) = total_size else { return; }; if let Err(e) = emit_loading( &progress, chunk_size as f64 / total_size as f64, None, ) { tracing::error!( "Failed to update download progress bar: {e}" ); } }, || {}, ) .await? }; let download_duration = download_start.elapsed(); tracing::info!("Downloaded update in {download_duration:?}"); pending_data .0 .lock() .unwrap() .replace((update, update_data)); Ok(()) } #[tauri::command] pub fn remove_enqueued_update(webview: Webview) { let pending_data = webview.state::().inner(); pending_data.0.lock().unwrap().take(); } // ── Debian / derivatives apt update ───────────────────────────── /// Whether this Linux system updates Axolotl through apt (Debian and its /// derivatives) and has `pkexec` available for a single privileged prompt. #[tauri::command] pub fn is_apt_linux() -> bool { #[cfg(target_os = "linux")] { let debian_like = std::path::Path::new("/etc/debian_version").exists() || std::path::Path::new("/etc/apt").is_dir() || std::path::Path::new("/usr/bin/apt-get").exists(); let has_pkexec = ["/usr/bin/pkexec", "/bin/pkexec"] .iter() .any(|path| std::path::Path::new(path).exists()); debian_like && has_pkexec } #[cfg(not(target_os = "linux"))] { false } } /// Install the downloaded .deb on Debian and its derivatives, prompting for /// root once via `pkexec`. The package is installed from the absolute path /// of a temporary file, which is removed afterwards. pub async fn install_apt_package(version: &str, data: &[u8]) -> Result<()> { if !is_apt_linux() { return Err(theseus::Error::from(theseus::ErrorKind::OtherError( "apt updates are only supported on Debian-based Linux systems with pkexec" .to_string(), )) .into()); } let arch = apt_deb_arch()?; let deb_path = std::env::temp_dir() .join(format!("Axolotl.Launcher_{version}_{arch}.deb")); std::fs::write(&deb_path, data).map_err(|io| { theseus::Error::from(theseus::ErrorKind::OtherError(format!( "Failed to write the downloaded deb: {io}" ))) })?; let _deb_cleanup = TempDebFile(deb_path.clone()); let install_path = deb_path.clone(); let output = tokio::task::spawn_blocking(move || { std::process::Command::new("pkexec") .arg("apt") .arg("install") .arg("-y") .arg(&install_path) .output() }) .await .map_err(|join| { theseus::Error::from(theseus::ErrorKind::OtherError(format!( "Failed to run the apt updater: {join}" ))) })? .map_err(|io| { theseus::Error::from(theseus::ErrorKind::OtherError(format!( "Failed to start pkexec: {io}" ))) })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(theseus::Error::from(theseus::ErrorKind::OtherError( format!("apt install failed: {}", stderr.trim()), )) .into()); } Ok(()) } /// Removes a temporary Debian package when installation finishes or fails. /// /// The installer awaits a blocking task and has several fallible operations /// after creating the file. Keeping cleanup in `Drop` makes every return path /// (including task and process-launch errors) remove the package. struct TempDebFile(std::path::PathBuf); impl Drop for TempDebFile { fn drop(&mut self) { if let Err(error) = std::fs::remove_file(&self.0) { if error.kind() != std::io::ErrorKind::NotFound { tracing::warn!( path = %self.0.display(), error = %error, "Failed to remove temporary deb file" ); } } } }