diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 41c6658..a396abb 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,4 +10,5 @@ pub mod pty_commands; pub mod mcp_commands; pub mod scanner_commands; pub mod tools_commands; +pub mod updater; pub mod tools_commands_r2; diff --git a/src-tauri/src/commands/updater.rs b/src-tauri/src/commands/updater.rs new file mode 100644 index 0000000..c38d32b --- /dev/null +++ b/src-tauri/src/commands/updater.rs @@ -0,0 +1,94 @@ +//! Version check against Gitea releases API. + +use serde::Serialize; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateInfo { + pub current_version: String, + pub latest_version: String, + pub update_available: bool, + pub download_url: String, + pub release_notes: String, +} + +/// Check Gitea for the latest release and compare with current version. +#[tauri::command] +pub async fn check_for_updates() -> Result { + let current = env!("CARGO_PKG_VERSION").to_string(); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("HTTP client error: {}", e))?; + + let resp = client + .get("https://git.command.vigilcyber.com/api/v1/repos/vstockwell/wraith/releases?limit=1") + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Failed to check for updates: {}", e))?; + + let releases: Vec = resp.json().await + .map_err(|e| format!("Failed to parse releases: {}", e))?; + + let latest = releases.first() + .ok_or_else(|| "No releases found".to_string())?; + + let tag = latest.get("tag_name") + .and_then(|v| v.as_str()) + .unwrap_or("v0.0.0") + .trim_start_matches('v') + .to_string(); + + let notes = latest.get("body") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let html_url = latest.get("html_url") + .and_then(|v| v.as_str()) + .unwrap_or("https://git.command.vigilcyber.com/vstockwell/wraith/releases") + .to_string(); + + let update_available = version_is_newer(&tag, ¤t); + + Ok(UpdateInfo { + current_version: current, + latest_version: tag, + update_available, + download_url: html_url, + release_notes: notes, + }) +} + +/// Compare semver strings. Returns true if `latest` is newer than `current`. +fn version_is_newer(latest: &str, current: &str) -> bool { + let parse = |v: &str| -> Vec { + v.split('.').filter_map(|s| s.parse().ok()).collect() + }; + let l = parse(latest); + let c = parse(current); + for i in 0..3 { + let lv = l.get(i).copied().unwrap_or(0); + let cv = c.get(i).copied().unwrap_or(0); + if lv > cv { return true; } + if lv < cv { return false; } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_comparison() { + assert!(version_is_newer("1.5.7", "1.5.6")); + assert!(version_is_newer("1.6.0", "1.5.9")); + assert!(version_is_newer("2.0.0", "1.9.9")); + assert!(!version_is_newer("1.5.6", "1.5.6")); + assert!(!version_is_newer("1.5.5", "1.5.6")); + assert!(!version_is_newer("1.4.0", "1.5.0")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8ef2246..61e87d1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -173,6 +173,7 @@ pub fn run() { commands::scanner_commands::scan_network, commands::scanner_commands::scan_ports, commands::scanner_commands::quick_scan, commands::tools_commands::tool_ping, commands::tools_commands::tool_traceroute, commands::tools_commands::tool_wake_on_lan, commands::tools_commands::tool_generate_ssh_key, commands::tools_commands::tool_generate_password, commands::tools_commands_r2::tool_dns_lookup, commands::tools_commands_r2::tool_whois, commands::tools_commands_r2::tool_bandwidth_iperf, commands::tools_commands_r2::tool_bandwidth_speedtest, commands::tools_commands_r2::tool_subnet_calc, + commands::updater::check_for_updates, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/common/SettingsModal.vue b/src/components/common/SettingsModal.vue index 9bbf2ac..82ab4bd 100644 --- a/src/components/common/SettingsModal.vue +++ b/src/components/common/SettingsModal.vue @@ -233,6 +233,30 @@ + +
+ +
+ +

You're on the latest version.

+
+
+
("general"); const copilotPresets = ref([]); + +interface UpdateCheckInfo { + currentVersion: string; + latestVersion: string; + updateAvailable: boolean; + downloadUrl: string; + releaseNotes: string; +} +const updateChecking = ref(false); +const updateInfo = ref(null); + +async function checkUpdates(): Promise { + updateChecking.value = true; + updateInfo.value = null; + try { + updateInfo.value = await invoke("check_for_updates"); + } catch (err) { + alert(`Update check failed: ${err}`); + } + updateChecking.value = false; +} + +async function downloadUpdate(): Promise { + if (!updateInfo.value?.downloadUrl) return; + try { + await shellOpen(updateInfo.value.downloadUrl); + } catch { + window.open(updateInfo.value.downloadUrl, "_blank"); + } +} const currentVersion = ref("loading..."); const sections = [ diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 7a6a724..855e467 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -429,6 +429,17 @@ function handleKeydown(event: KeyboardEvent): void { onMounted(async () => { document.addEventListener("keydown", handleKeydown); await connectionStore.loadAll(); + + // Check for updates on startup (non-blocking) + invoke<{ currentVersion: string; latestVersion: string; updateAvailable: boolean; downloadUrl: string }>("check_for_updates") + .then((info) => { + if (info.updateAvailable) { + if (confirm(`Wraith v${info.latestVersion} is available (you have v${info.currentVersion}). Open download page?`)) { + import("@tauri-apps/plugin-shell").then(({ open }) => open(info.downloadUrl)).catch(() => window.open(info.downloadUrl, "_blank")); + } + } + }) + .catch(() => {}); // Silent fail — no internet is fine }); onUnmounted(() => {