From 4437ff0f71c79f24d714c7089a97397e4902ad23 Mon Sep 17 00:00:00 2001 From: lcomplete Date: Thu, 30 Apr 2026 12:19:46 +0800 Subject: [PATCH] feat(tauri): add backend-driven Tauri update check and improve server auto-update scheduling - Add check_tauri_update Tauri command that fetches the latest.json manifest from GitHub releases and delegates to the updater plugin, bypassing the hardcoded endpoint limitation - Persist server JAR release version in a sidecar version file so the displayed version survives JAR upgrades that strip metadata - Replace one-shot server auto-update flag with a proper interval scheduler (24h, persisted via localStorage) - Move update status alerts inline with their respective settings sections; show current app/JAR versions next to each heading - Fix Maven release build to pass -Drevision so the packaged JAR carries the correct version string Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 2 +- app/tauri/src-tauri/src/lib.rs | 186 ++++++++++++++++++++--- app/tauri/src/App.tsx | 91 +++++++++-- app/tauri/src/components/SettingsTab.tsx | 172 +++++++++++++-------- 4 files changed, 350 insertions(+), 101 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 983ae07..3d4badb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,7 +98,7 @@ jobs: - name: Build with Maven run: | cd app/server - mvn clean package + mvn -Drevision=${{ steps.get_version.outputs.version-without-v }} clean package env: CI: false diff --git a/app/tauri/src-tauri/src/lib.rs b/app/tauri/src-tauri/src/lib.rs index c4a8131..8312447 100644 --- a/app/tauri/src-tauri/src/lib.rs +++ b/app/tauri/src-tauri/src/lib.rs @@ -1,4 +1,4 @@ -use reqwest::StatusCode; +use reqwest::{StatusCode, Url}; use std::fs::File; use std::io::{BufRead, BufReader}; #[cfg(target_os = "windows")] @@ -12,9 +12,10 @@ use tauri::{ command, menu::{MenuBuilder, MenuItemBuilder}, tray::{MouseButton, MouseButtonState, TrayIconEvent}, - AppHandle, Manager, RunEvent, WindowEvent, + AppHandle, Manager, ResourceId, RunEvent, Webview, WindowEvent, }; use tauri_plugin_autostart::MacosLauncher; +use tauri_plugin_updater::UpdaterExt; #[macro_use] extern crate lazy_static; @@ -22,11 +23,14 @@ extern crate lazy_static; #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x08000000; const SERVER_JAR_FILE_NAME: &str = "huntly-server.jar"; +const SERVER_JAR_VERSION_FILE_NAME: &str = "huntly-server.version"; const SERVER_JAR_RESOURCE_PATH: &str = "server_bin/huntly-server.jar"; const SERVER_JAR_DATA_DIR: &str = "server_bin"; const GITHUB_RELEASES_API: &str = "https://api.github.com/repos/lcomplete/huntly/releases?per_page=100"; const GITHUB_USER_AGENT: &str = "Huntly-Tauri"; +const TAURI_RELEASE_TAG_PREFIX: &str = "tauri/v"; +const TAURI_UPDATE_MANIFEST_FILE_NAME: &str = "latest.json"; lazy_static! { static ref SPRING_BOOT_PROCESS: Mutex> = Mutex::new(None); @@ -89,6 +93,17 @@ struct ServerUpdateInfo { release_url: Option, } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct TauriUpdateMetadata { + rid: ResourceId, + current_version: String, + version: String, + date: Option, + body: Option, + raw_json: serde_json::Value, +} + #[derive(Clone)] struct ServerRelease { version: String, @@ -219,7 +234,7 @@ fn collect_server_info(app: &AppHandle) -> ServerInfo { let jar_path_buf = active_server_jar_path(app); let java_version = java_path_buf.as_ref().and_then(read_java_version); - let jar_version = jar_path_buf.as_ref().and_then(read_server_jar_version); + let jar_version = current_server_jar_version(app); ServerInfo { jar_version, @@ -271,6 +286,52 @@ fn writable_server_jar_path(app: &AppHandle) -> Result { Ok(server_bin_dir.join(SERVER_JAR_FILE_NAME)) } +fn writable_server_jar_version_path(app: &AppHandle) -> Result { + let app_data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let server_bin_dir = app_data_dir.join(SERVER_JAR_DATA_DIR); + std::fs::create_dir_all(&server_bin_dir).map_err(|e| e.to_string())?; + Ok(server_bin_dir.join(SERVER_JAR_VERSION_FILE_NAME)) +} + +fn current_server_jar_version(app: &AppHandle) -> Option { + let jar_path = active_server_jar_path(app)?; + if is_writable_server_jar_path(app, &jar_path) { + return read_server_jar_release_version(app).or_else(|| read_server_jar_version(&jar_path)); + } + + read_server_jar_version(&jar_path) +} + +fn is_writable_server_jar_path(app: &AppHandle, jar_path: &Path) -> bool { + app.path() + .app_data_dir() + .map(|app_data_dir| { + jar_path + == app_data_dir + .join(SERVER_JAR_DATA_DIR) + .join(SERVER_JAR_FILE_NAME) + }) + .unwrap_or(false) +} + +fn read_server_jar_release_version(app: &AppHandle) -> Option { + let version_path = app + .path() + .app_data_dir() + .ok()? + .join(SERVER_JAR_DATA_DIR) + .join(SERVER_JAR_VERSION_FILE_NAME); + let version = std::fs::read_to_string(version_path) + .ok()? + .trim() + .to_string(); + if version.is_empty() { + None + } else { + Some(version) + } +} + fn read_java_version(java_path: &PathBuf) -> Option { let output = Command::new(java_path).arg("-version").output().ok()?; let stderr = String::from_utf8_lossy(&output.stderr); @@ -315,8 +376,7 @@ fn read_server_jar_version(jar_path: &PathBuf) -> Option { #[command] async fn check_server_update(app: AppHandle) -> Result { - let current_version = - active_server_jar_path(&app).and_then(|path| read_server_jar_version(&path)); + let current_version = current_server_jar_version(&app); let release = fetch_latest_server_release().await?; let available = current_version .as_deref() @@ -342,8 +402,7 @@ async fn install_server_update(app: AppHandle) -> Result { return Err("Server bundle updates are disabled by HUNTLY_NO_SERVER_JAR.".to_string()); } - let current_version = - active_server_jar_path(&app).and_then(|path| read_server_jar_version(&path)); + let current_version = current_server_jar_version(&app); let release = fetch_latest_server_release().await?; let available = current_version .as_deref() @@ -369,12 +428,15 @@ async fn install_server_update(app: AppHandle) -> Result { let dest_path = writable_server_jar_path(&app)?; let temp_path = dest_path.with_extension("jar.download"); let backup_path = dest_path.with_extension("jar.bak"); + let version_path = writable_server_jar_version_path(&app)?; std::fs::write(&temp_path, bytes.as_ref()).map_err(|e| e.to_string())?; let downloaded_version = read_server_jar_version(&temp_path) .ok_or_else(|| "Downloaded server JAR does not include version metadata.".to_string())?; - if normalize_version(&downloaded_version) != normalize_version(&release.version) { + if normalize_version(&downloaded_version) != normalize_version(&release.version) + && !is_server_jar_asset_for_version(&release.asset.name, &release.version) + { let _ = std::fs::remove_file(&temp_path); return Err(format!( "Downloaded server JAR version {} does not match release version {}.", @@ -401,12 +463,102 @@ async fn install_server_update(app: AppHandle) -> Result { let _ = std::fs::remove_file(&backup_path); } + std::fs::write(&version_path, format!("{}\n", release.version)).map_err(|e| e.to_string())?; + Ok(collect_server_info(&app)) } +#[command] +async fn check_tauri_update(webview: Webview) -> Result, String> { + let manifest_url = match fetch_latest_tauri_update_manifest_url().await? { + Some(manifest_url) => manifest_url, + None => return Ok(None), + }; + + let endpoint = Url::parse(&manifest_url).map_err(|e| e.to_string())?; + let updater = webview + .updater_builder() + .endpoints(vec![endpoint]) + .map_err(|e| e.to_string())? + .build() + .map_err(|e| e.to_string())?; + + let update = match updater.check().await { + Ok(update) => update, + Err(tauri_plugin_updater::Error::ReleaseNotFound) => return Ok(None), + Err(e) => return Err(e.to_string()), + }; + + if let Some(update) = update { + let current_version = update.current_version.clone(); + let version = update.version.clone(); + let date = update + .raw_json + .get("pub_date") + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let body = update.body.clone(); + let raw_json = update.raw_json.clone(); + let rid = webview.resources_table().add(update); + + Ok(Some(TauriUpdateMetadata { + rid, + current_version, + version, + date, + body, + raw_json, + })) + } else { + Ok(None) + } +} + +async fn fetch_latest_tauri_update_manifest_url() -> Result, String> { + let releases = fetch_github_releases().await?; + Ok(releases.into_iter().find_map(tauri_update_manifest_url)) +} + +fn tauri_update_manifest_url(release: GithubRelease) -> Option { + if release.draft || release.prerelease || tauri_release_version(&release.tag_name).is_none() { + return None; + } + + release + .assets + .into_iter() + .find(|asset| asset.name == TAURI_UPDATE_MANIFEST_FILE_NAME) + .map(|asset| asset.browser_download_url) +} + +fn tauri_release_version(tag_name: &str) -> Option { + let version = tag_name.strip_prefix(TAURI_RELEASE_TAG_PREFIX)?; + let version_parts: Vec<&str> = version.split('.').collect(); + if version_parts.len() < 3 + || !version_parts.iter().take(3).all(|part| { + part.chars() + .next() + .map(|ch| ch.is_ascii_digit()) + .unwrap_or(false) + }) + { + return None; + } + Some(version.to_string()) +} + async fn fetch_latest_server_release() -> Result { + let releases = fetch_github_releases().await?; + + releases + .into_iter() + .find_map(main_server_release) + .ok_or_else(|| "No main Huntly release with a server JAR asset was found.".to_string()) +} + +async fn fetch_github_releases() -> Result, String> { let client = github_client()?; - let releases: Vec = client + client .get(GITHUB_RELEASES_API) .send() .await @@ -415,12 +567,7 @@ async fn fetch_latest_server_release() -> Result { .map_err(|e| e.to_string())? .json() .await - .map_err(|e| e.to_string())?; - - releases - .into_iter() - .find_map(main_server_release) - .ok_or_else(|| "No main Huntly release with a server JAR asset was found.".to_string()) + .map_err(|e| e.to_string()) } fn github_client() -> Result { @@ -471,9 +618,11 @@ fn main_release_version(tag_name: &str) -> Option { } fn is_server_jar_asset(asset_name: &str, version: &str) -> bool { - asset_name == SERVER_JAR_FILE_NAME - || asset_name == format!("huntly-server-{}.jar", version) - || (asset_name.starts_with("huntly-server-") && asset_name.ends_with(".jar")) + asset_name == SERVER_JAR_FILE_NAME || is_server_jar_asset_for_version(asset_name, version) +} + +fn is_server_jar_asset_for_version(asset_name: &str, version: &str) -> bool { + asset_name == format!("huntly-server-{}.jar", normalize_version(version)) } fn normalize_version(version: &str) -> String { @@ -906,6 +1055,7 @@ pub fn run() { read_settings, has_server_jar, get_server_info, + check_tauri_update, check_server_update, install_server_update, set_tray_visible, diff --git a/app/tauri/src/App.tsx b/app/tauri/src/App.tsx index b48ae05..5bcab2a 100644 --- a/app/tauri/src/App.tsx +++ b/app/tauri/src/App.tsx @@ -12,10 +12,13 @@ import { import SettingsIcon from "@mui/icons-material/Settings"; import DnsIcon from "@mui/icons-material/Dns"; import { enable, isEnabled, disable } from "@tauri-apps/plugin-autostart"; -import { check } from "@tauri-apps/plugin-updater"; +import { Update } from "@tauri-apps/plugin-updater"; import ServerTab from "./components/ServerTab"; import SettingsTab from "./components/SettingsTab"; +const SERVER_AUTO_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000; +const SERVER_AUTO_UPDATE_LAST_CHECK_KEY = "huntly.serverAutoUpdate.lastCheckedAt"; + type AppSettings = { port: number; listen_public: boolean; @@ -46,6 +49,15 @@ type UpdateState = { installed: boolean; }; +type TauriUpdateMetadata = { + rid: number; + currentVersion: string; + version: string; + date?: string; + body?: string; + rawJson: Record; +}; + type ServerJarUpdateInfo = { current_version: string | null; latest_version: string | null; @@ -123,9 +135,14 @@ function App() { error: null, installed: false, }); - const updateRef = useRef> | null>(null); + const updateRef = useRef(null); const autoUpdateTriggeredRef = useRef(false); - const autoServerUpdateTriggeredRef = useRef(false); + const serverAutoUpdateInFlightRef = useRef(false); + const checkServerUpdateRef = useRef(handleCheckServerUpdate); + + useEffect(() => { + checkServerUpdateRef.current = handleCheckServerUpdate; + }); useEffect(() => { invoke("has_server_jar") @@ -170,13 +187,59 @@ function App() { }, [settings.auto_update]); useEffect(() => { - if (!settings.server_auto_update) { - autoServerUpdateTriggeredRef.current = false; - return; + if (!settings.server_auto_update) return; + + let cancelled = false; + let timeoutId: number | undefined; + + const scheduleNextCheck = (delay: number) => { + timeoutId = window.setTimeout(runScheduledCheck, Math.max(delay, 0)); + }; + + const getLastCheckedAt = () => { + const storedValue = window.localStorage.getItem( + SERVER_AUTO_UPDATE_LAST_CHECK_KEY + ); + const lastCheckedAt = storedValue ? Number(storedValue) : 0; + return Number.isFinite(lastCheckedAt) && lastCheckedAt > 0 + ? lastCheckedAt + : 0; + }; + + const recordCheckAttempt = () => { + window.localStorage.setItem( + SERVER_AUTO_UPDATE_LAST_CHECK_KEY, + Date.now().toString() + ); + }; + + async function runScheduledCheck() { + if (cancelled || serverAutoUpdateInFlightRef.current) return; + + const elapsed = Math.max(Date.now() - getLastCheckedAt(), 0); + if (elapsed < SERVER_AUTO_UPDATE_INTERVAL_MS) { + scheduleNextCheck(SERVER_AUTO_UPDATE_INTERVAL_MS - elapsed); + return; + } + + serverAutoUpdateInFlightRef.current = true; + recordCheckAttempt(); + try { + await checkServerUpdateRef.current({ autoInstall: true }); + } finally { + serverAutoUpdateInFlightRef.current = false; + if (!cancelled) { + scheduleNextCheck(SERVER_AUTO_UPDATE_INTERVAL_MS); + } + } } - if (autoServerUpdateTriggeredRef.current) return; - autoServerUpdateTriggeredRef.current = true; - handleCheckServerUpdate({ autoInstall: true }); + + runScheduledCheck(); + + return () => { + cancelled = true; + clearTimeout(timeoutId); + }; }, [settings.server_auto_update]); function startServer() { @@ -334,13 +397,10 @@ function App() { ...formSettings.values, server_auto_update: event.target.checked, }; - await persistSettings(next, { restartServer: false }); if (event.target.checked) { - autoServerUpdateTriggeredRef.current = true; - await handleCheckServerUpdate({ autoInstall: true }); - } else { - autoServerUpdateTriggeredRef.current = false; + window.localStorage.removeItem(SERVER_AUTO_UPDATE_LAST_CHECK_KEY); } + await persistSettings(next, { restartServer: false }); } async function handleListenPublicChange(event: ChangeEvent) { @@ -363,7 +423,8 @@ function App() { lastCheckedAt: new Date().toISOString(), })); try { - const update = await check(); + const metadata = await invoke("check_tauri_update"); + const update = metadata ? new Update(metadata) : null; updateRef.current = update; if (!update) { setUpdateState((prev) => ({ diff --git a/app/tauri/src/components/SettingsTab.tsx b/app/tauri/src/components/SettingsTab.tsx index 6fce5fe..6d9f109 100644 --- a/app/tauri/src/components/SettingsTab.tsx +++ b/app/tauri/src/components/SettingsTab.tsx @@ -19,6 +19,10 @@ import { open } from "@tauri-apps/plugin-shell"; const GITHUB_URL = "https://github.com/lcomplete/huntly"; +function formatCheckedAt(value: string) { + return new Date(value).toLocaleString(); +} + type UpdateState = { checking: boolean; installing: boolean; @@ -94,6 +98,26 @@ export default function SettingsTab({ getVersion().then(setAppVersion).catch(() => setAppVersion(null)); }, []); + const showAppUpToDate = + !updateState.error && + !updateState.available && + !updateState.installed && + !!updateState.lastCheckedAt && + !updateState.checking; + const showServerJarUpToDate = + !serverUpdateState.error && + !serverUpdateState.available && + !serverUpdateState.installed && + !!serverUpdateState.lastCheckedAt && + !serverUpdateState.checking; + const showAppUpdateStatus = + !!updateState.error || updateState.available || updateState.installed || showAppUpToDate; + const showServerJarUpdateStatus = + !!serverUpdateState.error || + serverUpdateState.available || + serverUpdateState.installed || + showServerJarUpToDate; + return ( Desktop App Updates
Check for new versions of the Huntly desktop app. + {appVersion && ( + + Current version: v{appVersion} + + )}
+ {showAppUpdateStatus && ( + + {updateState.error && ( + + {updateState.error} + + )} + {!updateState.error && updateState.available && ( + + Update available: v{updateState.version} + {updateState.date ? ` (${updateState.date})` : ""} + + )} + {showAppUpToDate && updateState.lastCheckedAt && ( + + Desktop app is up to date. Last checked{" "} + {formatCheckedAt(updateState.lastCheckedAt)}. + + )} + {updateState.installed && ( + + Update installed. Restart Huntly to finish. + + )} + {updateState.notes && updateState.available && ( + + {updateState.notes} + + )} + + )}