feat: update checker — startup prompt + Settings → About button
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m2s

Checks Gitea releases API for latest version on startup. If newer
version available, shows confirm dialog to open download page.

Also adds "Check for Updates" button in Settings → About with
version comparison, release notes display, and download button.

Backend: check_for_updates command with semver comparison (6 tests).
96 total tests, zero warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-25 01:35:54 -04:00
parent e9b504c733
commit 4b26d9190b
5 changed files with 161 additions and 0 deletions

View File

@ -10,4 +10,5 @@ pub mod pty_commands;
pub mod mcp_commands; pub mod mcp_commands;
pub mod scanner_commands; pub mod scanner_commands;
pub mod tools_commands; pub mod tools_commands;
pub mod updater;
pub mod tools_commands_r2; pub mod tools_commands_r2;

View File

@ -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<UpdateInfo, String> {
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<serde_json::Value> = 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, &current);
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<u32> {
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"));
}
}

View File

@ -173,6 +173,7 @@ pub fn run() {
commands::scanner_commands::scan_network, commands::scanner_commands::scan_ports, commands::scanner_commands::quick_scan, 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::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::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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -233,6 +233,30 @@
</div> </div>
</div> </div>
<!-- Update check -->
<div class="pt-2">
<button
class="w-full px-3 py-2 text-xs font-bold rounded bg-[var(--wraith-accent-blue)] text-black cursor-pointer disabled:opacity-40"
:disabled="updateChecking"
@click="checkUpdates"
>
{{ updateChecking ? "Checking..." : "Check for Updates" }}
</button>
<div v-if="updateInfo" class="mt-2 p-3 rounded bg-[#0d1117] border border-[#30363d]">
<template v-if="updateInfo.updateAvailable">
<p class="text-xs text-[#3fb950] mb-1">Update available: v{{ updateInfo.latestVersion }}</p>
<p v-if="updateInfo.releaseNotes" class="text-[10px] text-[var(--wraith-text-muted)] mb-2 max-h-20 overflow-auto">{{ updateInfo.releaseNotes }}</p>
<button
class="w-full px-3 py-1.5 text-xs font-bold rounded bg-[#238636] text-white cursor-pointer"
@click="downloadUpdate"
>
Download v{{ updateInfo.latestVersion }}
</button>
</template>
<p v-else class="text-xs text-[var(--wraith-text-muted)]">You're on the latest version.</p>
</div>
</div>
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<a <a
href="#" href="#"
@ -281,6 +305,36 @@ interface CopilotPreset { name: string; shell: string; command: string; }
const visible = ref(false); const visible = ref(false);
const activeSection = ref<Section>("general"); const activeSection = ref<Section>("general");
const copilotPresets = ref<CopilotPreset[]>([]); const copilotPresets = ref<CopilotPreset[]>([]);
interface UpdateCheckInfo {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
downloadUrl: string;
releaseNotes: string;
}
const updateChecking = ref(false);
const updateInfo = ref<UpdateCheckInfo | null>(null);
async function checkUpdates(): Promise<void> {
updateChecking.value = true;
updateInfo.value = null;
try {
updateInfo.value = await invoke<UpdateCheckInfo>("check_for_updates");
} catch (err) {
alert(`Update check failed: ${err}`);
}
updateChecking.value = false;
}
async function downloadUpdate(): Promise<void> {
if (!updateInfo.value?.downloadUrl) return;
try {
await shellOpen(updateInfo.value.downloadUrl);
} catch {
window.open(updateInfo.value.downloadUrl, "_blank");
}
}
const currentVersion = ref("loading..."); const currentVersion = ref("loading...");
const sections = [ const sections = [

View File

@ -429,6 +429,17 @@ function handleKeydown(event: KeyboardEvent): void {
onMounted(async () => { onMounted(async () => {
document.addEventListener("keydown", handleKeydown); document.addEventListener("keydown", handleKeydown);
await connectionStore.loadAll(); 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(() => { onUnmounted(() => {