use futures_util::StreamExt; use serde::Serialize; use std::io::Write; use tauri::{AppHandle, Emitter}; use crate::sidecar::{SidecarManager, DATA_DIR}; const REPO_API: &str = "https://repo.anhonesthost.net/api/v1/repos/MacroPad/voice-to-notes"; #[derive(Serialize, Clone)] struct DownloadProgress { downloaded: u64, total: u64, percent: u8, } #[derive(Serialize)] pub struct UpdateInfo { pub current_version: String, pub latest_version: String, } /// Read the locally installed sidecar version from `sidecar-version.txt`. /// Returns `None` if the file doesn't exist or can't be read. fn read_local_sidecar_version() -> Option { let data_dir = DATA_DIR.get()?; let version_file = data_dir.join("sidecar-version.txt"); std::fs::read_to_string(version_file) .ok() .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) } /// Write the sidecar version to `sidecar-version.txt` after a successful download. fn write_local_sidecar_version(version: &str) -> Result<(), String> { let data_dir = DATA_DIR.get().ok_or("App data directory not initialized")?; let version_file = data_dir.join("sidecar-version.txt"); std::fs::write(&version_file, version) .map_err(|e| format!("Failed to write sidecar version file: {}", e)) } /// Fetch releases from the Gitea API and find the latest sidecar release /// (one whose tag_name starts with "sidecar-v"). async fn fetch_latest_sidecar_release( client: &reqwest::Client, ) -> Result { let releases_url = format!("{}/releases?limit=20", REPO_API); let resp = client .get(&releases_url) .header("Accept", "application/json") .send() .await .map_err(|e| format!("Failed to fetch releases: {}", e))?; if !resp.status().is_success() { return Err(format!("Failed to fetch releases: HTTP {}", resp.status())); } let releases = resp .json::>() .await .map_err(|e| format!("Failed to parse releases JSON: {}", e))?; releases .into_iter() .find(|r| { r["tag_name"] .as_str() .map_or(false, |t| t.starts_with("sidecar-v")) }) .ok_or_else(|| "No sidecar release found".to_string()) } /// Extract the version string from a sidecar tag name (e.g. "sidecar-v1.0.1" -> "1.0.1"). fn version_from_sidecar_tag(tag: &str) -> &str { tag.strip_prefix("sidecar-v").unwrap_or(tag) } /// Check if the sidecar binary exists for the currently installed version. #[tauri::command] pub fn check_sidecar() -> bool { let data_dir = match DATA_DIR.get() { Some(d) => d, None => return false, }; let version = match read_local_sidecar_version() { Some(v) => v, None => return false, }; let binary_name = if cfg!(target_os = "windows") { "voice-to-notes-sidecar.exe" } else { "voice-to-notes-sidecar" }; let extract_dir = data_dir.join(format!("sidecar-{}", version)); extract_dir.join(binary_name).exists() } /// Determine the current platform name for asset downloads. fn platform_os() -> &'static str { if cfg!(target_os = "windows") { "windows" } else if cfg!(target_os = "macos") { "macos" } else { "linux" } } /// Determine the current architecture name for asset downloads. fn platform_arch() -> &'static str { if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" } } /// Download the sidecar binary for the given variant (cpu or cuda). #[tauri::command] pub async fn download_sidecar(app: AppHandle, variant: String) -> Result<(), String> { let data_dir = DATA_DIR.get().ok_or("App data directory not initialized")?; let os = platform_os(); let arch = platform_arch(); let asset_name = format!("sidecar-{}-{}-{}.zip", os, arch, variant); // Fetch the latest sidecar release from Gitea API let client = reqwest::Client::new(); let sidecar_release = fetch_latest_sidecar_release(&client).await?; let tag = sidecar_release["tag_name"] .as_str() .ok_or("No tag_name in sidecar release")?; let sidecar_version = version_from_sidecar_tag(tag).to_string(); // Find the matching asset let assets = sidecar_release["assets"] .as_array() .ok_or("No assets found in sidecar release")?; let download_url = assets .iter() .find(|a| a["name"].as_str() == Some(&asset_name)) .and_then(|a| a["browser_download_url"].as_str()) .ok_or_else(|| { format!( "Asset '{}' not found in sidecar release {}", asset_name, tag ) })? .to_string(); // Stream download with progress events let response: reqwest::Response = client .get(&download_url) .send() .await .map_err(|e| format!("Failed to start download: {}", e))?; if !response.status().is_success() { return Err(format!("Download failed: HTTP {}", response.status())); } let total: u64 = response.content_length().unwrap_or(0); let mut downloaded: u64 = 0; let mut stream = response.bytes_stream(); let zip_path = data_dir.join("sidecar.zip"); let mut file = std::fs::File::create(&zip_path) .map_err(|e| format!("Failed to create zip file: {}", e))?; while let Some(chunk) = stream.next().await { let chunk: bytes::Bytes = chunk.map_err(|e| format!("Download stream error: {}", e))?; file.write_all(&chunk) .map_err(|e| format!("Failed to write chunk: {}", e))?; downloaded += chunk.len() as u64; let percent = if total > 0 { (downloaded * 100 / total) as u8 } else { 0 }; let _ = app.emit( "sidecar-download-progress", DownloadProgress { downloaded, total, percent, }, ); } // Extract the downloaded zip let extract_dir = data_dir.join(format!("sidecar-{}", sidecar_version)); SidecarManager::extract_zip(&zip_path, &extract_dir)?; // Make the binary executable on Unix #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let binary_path = extract_dir.join("voice-to-notes-sidecar"); if let Ok(meta) = std::fs::metadata(&binary_path) { let mut perms = meta.permissions(); perms.set_mode(0o755); let _ = std::fs::set_permissions(&binary_path, perms); } } // Write the sidecar version file write_local_sidecar_version(&sidecar_version)?; // Clean up the zip file and old sidecar versions let _ = std::fs::remove_file(&zip_path); SidecarManager::cleanup_old_sidecars(data_dir, &sidecar_version); Ok(()) } /// Check if a sidecar update is available. #[tauri::command] pub async fn check_sidecar_update() -> Result, String> { // If sidecar doesn't exist yet, return None (first launch handled separately) if !check_sidecar() { return Ok(None); } let current_version = match read_local_sidecar_version() { Some(v) => v, None => return Ok(None), }; // Fetch latest sidecar release from Gitea API let client = reqwest::Client::new(); let sidecar_release = fetch_latest_sidecar_release(&client).await?; let latest_tag = sidecar_release["tag_name"] .as_str() .ok_or("No tag_name in sidecar release")?; let latest_version = version_from_sidecar_tag(latest_tag); if latest_version != current_version { Ok(Some(UpdateInfo { current_version, latest_version: latest_version.to_string(), })) } else { Ok(None) } }