From 6968b1ac8675cf3101d4e190f806f9565a58d946 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Thu, 21 May 2026 17:36:37 -0700 Subject: [PATCH 01/20] add `auto_check_updates` config value --- daemon/src/config.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/daemon/src/config.rs b/daemon/src/config.rs index 7085d50c..0aa68e6c 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -60,6 +60,8 @@ pub struct Config { pub ntfy_url: Option, /// Vector containing the types of enabled notifications pub enabled_notifications: Vec, + /// Whether Rayhunter should periodically check GitHub for new releases + pub auto_check_updates: bool, /// Vector containing the list of enabled analyzers pub analyzers: AnalyzerConfig, /// Minimum disk space required to start a recording @@ -134,6 +136,7 @@ impl Default for Config { analyzers: AnalyzerConfig::default(), ntfy_url: None, enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery], + auto_check_updates: true, min_space_to_start_recording_mb: 1, min_space_to_continue_recording_mb: 1, gps_mode: GpsMode::Disabled, From 8ae408da9de87cd31fb9c3eb49a7c3a130cb00c8 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Thu, 21 May 2026 17:36:48 -0700 Subject: [PATCH 02/20] add `auto_check_updates` to dist config --- dist/config.toml.in | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dist/config.toml.in b/dist/config.toml.in index 11054702..a7ad85ea 100644 --- a/dist/config.toml.in +++ b/dist/config.toml.in @@ -28,6 +28,10 @@ key_input_mode = 0 # What notification types to enable. Does nothing if the above ntfy_url is not set. enabled_notifications = ["Warning", "LowBattery"] +# If true, Rayhunter will periodically check GitHub for new releases and show +# an update notice in the web UI. +auto_check_updates = true + # Disk Space Management # Minimum free space (MB) required to start recording min_space_to_start_recording_mb = 1 From 3c8c420622dabebf3b0706f682b02c85ba74bb1a Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Thu, 21 May 2026 17:39:14 -0700 Subject: [PATCH 03/20] add `Update` `NotificationType` --- daemon/src/notifications.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/daemon/src/notifications.rs b/daemon/src/notifications.rs index da21e7d1..da661a9d 100644 --- a/daemon/src/notifications.rs +++ b/daemon/src/notifications.rs @@ -26,6 +26,7 @@ pub enum NotificationError { pub enum NotificationType { Warning, LowBattery, + Update, } pub struct Notification { From 0eea03ceefe0599b785930f043ac7ca470f4afb8 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Thu, 21 May 2026 18:16:40 -0700 Subject: [PATCH 04/20] implement update checker and worker --- daemon/src/update.rs | 250 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 daemon/src/update.rs diff --git a/daemon/src/update.rs b/daemon/src/update.rs new file mode 100644 index 00000000..441f9494 --- /dev/null +++ b/daemon/src/update.rs @@ -0,0 +1,250 @@ +use chrono::{DateTime, Local}; +use log::{error, info, warn}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::{RwLock, mpsc::Sender}; +use tokio::time; +use tokio::select; +use tokio::time::{Duration, MissedTickBehavior}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; + +use crate::notifications::{Notification, NotificationType}; + +const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); +const GITHUB_LATEST_RELEASE_URL: &str = + "https://api.github.com/repos/EFForg/rayhunter/releases/latest"; + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))] +pub struct UpdateStatus { + pub current_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_release_url: Option, + pub update_available: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_checked: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} + +impl Default for UpdateStatus { + fn default() -> Self { + Self { + current_version: get_current_version(), + // To-be-populated by update check worker + latest_version: None, + latest_release_url: None, + update_available: false, + last_checked: None, + last_error: None, + } + } +} + +#[derive(Debug, Deserialize)] +struct GitHubReleaseResponse { + tag_name: String, + html_url: String, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] +struct VersionParts { + major: u64, + minor: u64, + patch: u64, +} + +fn get_current_version() -> String { + // See https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates + env!("CARGO_PKG_VERSION").to_owned() +} + +fn parse_release_tagname(version: &str) -> Option<(VersionParts, String)> { + // Trim whitespace and leading `v`, if any + let trimmed_version = version.trim().trim_start_matches('v'); + let mut parts = trimmed_version.split('.'); + + // Ignore any pre-release/build metadata by splitting on '-' + // TODO: is this okay? + let major = parts.next()?.split('-').next()?.parse().ok()?; + let minor = parts.next()?.split('-').next()?.parse().ok()?; + let patch = parts.next()?.split('-').next()?.parse().ok()?; + let version = format!("{}.{}.{}", major, minor, patch); + Some(( + VersionParts { + major, + minor, + patch, + }, + version.to_string(), + )) +} + +fn format_update_message(current_version: &str, latest_version: &str, release_url: &str) -> String { + format!( + "Rayhunter {current_version} is installed, but {latest_version} is available. Open {release_url} to download the update." + ) +} + +async fn refresh_update_status( + status_lock: &Arc>, + http_client: &reqwest::Client, +) -> Result, String> { + let response = http_client + .get(GITHUB_LATEST_RELEASE_URL) + // TODO: do we set a user agent here? + .send() + .await + .map_err(|err| format!("failed to query GitHub releases: {err}"))?; + + if !response.status().is_success() { + return Err(format!( + "GitHub release check returned {}", + response.status() + )); + } + + let response_text = response + .text() + .await + .map_err(|err| format!("failed to read GitHub release response: {err}"))?; + let release: GitHubReleaseResponse = serde_json::from_str(&response_text) + .map_err(|err| format!("failed to parse GitHub release response: {err}"))?; + + let current_version = get_current_version(); + let (current_version_parts, current_version) = parse_release_tagname(¤t_version) + .ok_or_else(|| format!("failed to parse current version {current_version}"))?; + let (latest_version_parts, latest_version) = parse_release_tagname(&release.tag_name) + .ok_or_else(|| { + format!( + "failed to parse latest release version {}", + release.tag_name + ) + })?; + + let update_available = latest_version_parts > current_version_parts; + { + let mut status = status_lock.write().await; + status.current_version = current_version; + status.latest_version = Some(latest_version.to_owned()); + status.latest_release_url = Some(release.html_url.to_owned()); + status.update_available = update_available; + status.last_checked = Some(Local::now()); + status.last_error = None; + } + + if update_available { + Ok(Some((latest_version, release.html_url))) + } else { + Ok(None) + } +} + +pub fn run_update_check_worker( + task_tracker: &TaskTracker, + shutdown_token: CancellationToken, + update_status_lock: Arc>, + notification_sender: Sender, + enabled_notifications: Vec, +) { + task_tracker.spawn(async move { + let http_client = match reqwest::Client::builder().build() { + Ok(client) => client, + Err(err) => { + error!("failed to create update check client: {err}"); + return; + } + }; + let mut interval = time::interval(UPDATE_CHECK_INTERVAL); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + // Keep track of last notified version + let mut last_notified_version: Option = None; + + loop { + if shutdown_token.is_cancelled() { + break; + } + + match refresh_update_status(&update_status_lock, &http_client).await { + Ok(Some((latest_version, latest_release_url))) => { + if last_notified_version.as_deref() != Some(latest_version.as_str()) { + let current_version = + update_status_lock.read().await.current_version.clone(); + let message = format_update_message( + ¤t_version, + &latest_version, + &latest_release_url, + ); + if enabled_notifications.contains(&NotificationType::Update) { + if let Err(err) = notification_sender + .send(Notification::new(NotificationType::Update, message, None)) + .await + { + error!("failed to enqueue update notification: {err}"); + } else { + info!("notified about Rayhunter update {latest_version}"); + } + } + last_notified_version = Some(latest_version); + } + } + Ok(None) => { + last_notified_version = None; + } + Err(err) => { + warn!("update check failed: {err}"); + let mut status = update_status_lock.write().await; + status.last_error = Some(err); + status.last_checked = Some(Local::now()); + } + } + + select! { + _ = shutdown_token.cancelled() => break, + _ = interval.tick() => {} + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::parse_release_tagname; + + #[test] + fn parses_simple_versions() { + let (parts, version) = parse_release_tagname("0.11.1").unwrap(); + assert_eq!(parts.major, 0); + assert_eq!(parts.minor, 11); + assert_eq!(parts.patch, 1); + assert_eq!(version, "0.11.1"); + } + + #[test] + fn parses_versions_with_v_prefix_and_prerelease() { + let (parts, version) = parse_release_tagname("v0.11.1-beta.1").unwrap(); + assert_eq!(parts.major, 0); + assert_eq!(parts.minor, 11); + assert_eq!(parts.patch, 1); + assert_eq!(version, "0.11.1"); + } + + #[test] + fn returns_none_for_invalid_versions() { + assert!(parse_release_tagname("invalid").is_none()); + assert!(parse_release_tagname("v1.2").is_none()); + assert!(parse_release_tagname("v1.2.x").is_none()); + } + + #[test] + fn compares_versions_numerically() { + let (newer_version_parts, newer_version) = parse_release_tagname("v0.11.2").unwrap(); + let (older_version_parts, older_version) = parse_release_tagname("v0.11.1").unwrap(); + assert!(newer_version_parts > older_version_parts); + assert_eq!(newer_version, "0.11.2"); + assert_eq!(older_version, "0.11.1"); + } +} From 6c6b3b59127fb86e6407352c89778c62df5bc6ed Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Thu, 21 May 2026 18:19:08 -0700 Subject: [PATCH 05/20] add endpoint, add to documentation, add worker --- daemon/src/lib.rs | 2 ++ daemon/src/main.rs | 17 ++++++++++++++++- daemon/src/server.rs | 3 +++ daemon/src/stats.rs | 17 +++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/daemon/src/lib.rs b/daemon/src/lib.rs index 69fdf5bc..125a52b1 100644 --- a/daemon/src/lib.rs +++ b/daemon/src/lib.rs @@ -9,6 +9,7 @@ pub mod gps; pub mod key_input; pub mod notifications; pub mod pcap; +pub mod update; pub mod qmdl_store; pub mod server; pub mod stats; @@ -34,6 +35,7 @@ use utoipa::OpenApi; server::get_zip, stats::get_system_stats, stats::get_qmdl_manifest, + stats::get_update_status, stats::get_log, diag::start_recording, diag::stop_recording, diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 46d81440..baaa08c8 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -12,6 +12,7 @@ mod pcap; mod qmdl_store; mod server; mod stats; +mod update; mod webdav; use std::net::SocketAddr; @@ -29,7 +30,8 @@ use crate::server::{ ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_wifi_status, get_zip, scan_wifi, serve_static, set_config, set_time_offset, test_notification, }; -use crate::stats::{get_qmdl_manifest, get_system_stats}; +use crate::stats::{get_qmdl_manifest, get_system_stats, get_update_status}; +use crate::update::{UpdateStatus, run_update_check_worker}; use crate::webdav::run_webdav_upload_worker; use wifi_station::WifiStatus; @@ -63,6 +65,7 @@ fn get_router() -> AppRouter { .route("/api/qmdl/{name}", get(get_qmdl)) .route("/api/zip/{name}", get(get_zip)) .route("/api/system-stats", get(get_system_stats)) + .route("/api/update-status", get(get_update_status)) .route("/api/qmdl-manifest", get(get_qmdl_manifest)) .route("/api/log", get(get_log)) .route("/api/start-recording", post(start_recording)) @@ -217,6 +220,7 @@ async fn run_with_config( let _shutdown_guard = shutdown_token.clone().drop_guard(); let notification_service = NotificationService::new(config.ntfy_url.clone()); + let update_status_lock = Arc::new(RwLock::new(UpdateStatus::default())); if !config.debug_mode { info!("Starting Diag Thread"); @@ -258,6 +262,16 @@ async fn run_with_config( diag_tx.clone(), shutdown_token.clone(), ); + + if config.auto_check_updates { + run_update_check_worker( + &task_tracker, + shutdown_token.clone(), + update_status_lock.clone(), + notification_service.new_handler(), + config.enabled_notifications.clone(), + ); + } } let analysis_status_lock = Arc::new(RwLock::new(analysis_status)); @@ -339,6 +353,7 @@ async fn run_with_config( wifi_status, wifi_scan_lock: tokio::sync::Mutex::new(()), gps_state: Arc::new(tokio::sync::RwLock::new(initial_gps)), + update_status_lock, }); run_server(&task_tracker, state, shutdown_token.clone()).await; diff --git a/daemon/src/server.rs b/daemon/src/server.rs index ee3865bd..1f72f131 100644 --- a/daemon/src/server.rs +++ b/daemon/src/server.rs @@ -29,6 +29,7 @@ use crate::gps::GpsData; use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT; use crate::pcap::{generate_pcap_data, load_gps_records_for_entry}; use crate::qmdl_store::RecordingStore; +use crate::update::UpdateStatus; pub struct ServerState { pub config_path: String, @@ -42,6 +43,7 @@ pub struct ServerState { pub wifi_status: Arc>, pub wifi_scan_lock: tokio::sync::Mutex<()>, pub gps_state: Arc>>, + pub update_status_lock: Arc>, } #[cfg_attr(feature = "apidocs", utoipa::path( @@ -580,6 +582,7 @@ mod tests { wifi_status: Arc::new(RwLock::new(wifi_station::WifiStatus::default())), wifi_scan_lock: tokio::sync::Mutex::new(()), gps_state: Arc::new(RwLock::new(None)), + update_status_lock: Arc::new(RwLock::new(UpdateStatus::default())), }) } diff --git a/daemon/src/stats.rs b/daemon/src/stats.rs index 1c572bab..1ba56ccf 100644 --- a/daemon/src/stats.rs +++ b/daemon/src/stats.rs @@ -5,6 +5,7 @@ use crate::battery::get_battery_status; use crate::error::RayhunterError; use crate::server::ServerState; use crate::{battery::BatteryState, qmdl_store::ManifestEntry}; +use crate::update::UpdateStatus; use axum::Json; use axum::extract::State; @@ -220,6 +221,22 @@ pub async fn get_qmdl_manifest( })) } +#[cfg_attr(feature = "apidocs", utoipa::path( + get, + path = "/api/update-status", + tag = "Statistics", + responses( + (status = StatusCode::OK, description = "Success", body = UpdateStatus) + ), + summary = "Rayhunter update status", + description = "Check for available updates for Rayhunter." +))] +pub async fn get_update_status( + State(state): State>, +) -> Json { + Json(state.update_status_lock.read().await.clone()) +} + #[cfg_attr(feature = "apidocs", utoipa::path( get, path = "/api/log", From 05e5155de41ecfada10c7b8ee441a5dc69f777b4 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Thu, 21 May 2026 18:19:31 -0700 Subject: [PATCH 06/20] clone update_status_lock Arc --- daemon/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/src/main.rs b/daemon/src/main.rs index baaa08c8..3be95e4f 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -353,7 +353,7 @@ async fn run_with_config( wifi_status, wifi_scan_lock: tokio::sync::Mutex::new(()), gps_state: Arc::new(tokio::sync::RwLock::new(initial_gps)), - update_status_lock, + update_status_lock: update_status_lock.clone(), }); run_server(&task_tracker, state, shutdown_token.clone()).await; From f6b135fe6d46df52b0eb1c2fc590627b99186de6 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Thu, 21 May 2026 18:20:44 -0700 Subject: [PATCH 07/20] fmt --- daemon/src/lib.rs | 2 +- daemon/src/stats.rs | 6 ++---- daemon/src/update.rs | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/daemon/src/lib.rs b/daemon/src/lib.rs index 125a52b1..c415088e 100644 --- a/daemon/src/lib.rs +++ b/daemon/src/lib.rs @@ -9,10 +9,10 @@ pub mod gps; pub mod key_input; pub mod notifications; pub mod pcap; -pub mod update; pub mod qmdl_store; pub mod server; pub mod stats; +pub mod update; pub mod webdav; #[cfg(feature = "apidocs")] diff --git a/daemon/src/stats.rs b/daemon/src/stats.rs index 1ba56ccf..bf14a346 100644 --- a/daemon/src/stats.rs +++ b/daemon/src/stats.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use crate::battery::get_battery_status; use crate::error::RayhunterError; use crate::server::ServerState; -use crate::{battery::BatteryState, qmdl_store::ManifestEntry}; use crate::update::UpdateStatus; +use crate::{battery::BatteryState, qmdl_store::ManifestEntry}; use axum::Json; use axum::extract::State; @@ -231,9 +231,7 @@ pub async fn get_qmdl_manifest( summary = "Rayhunter update status", description = "Check for available updates for Rayhunter." ))] -pub async fn get_update_status( - State(state): State>, -) -> Json { +pub async fn get_update_status(State(state): State>) -> Json { Json(state.update_status_lock.read().await.clone()) } diff --git a/daemon/src/update.rs b/daemon/src/update.rs index 441f9494..f9650d0b 100644 --- a/daemon/src/update.rs +++ b/daemon/src/update.rs @@ -2,9 +2,9 @@ use chrono::{DateTime, Local}; use log::{error, info, warn}; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use tokio::select; use tokio::sync::{RwLock, mpsc::Sender}; use tokio::time; -use tokio::select; use tokio::time::{Duration, MissedTickBehavior}; use tokio_util::{sync::CancellationToken, task::TaskTracker}; From 78a54284743d61a094752b553b918205f9b32b7e Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Thu, 21 May 2026 18:28:44 -0700 Subject: [PATCH 08/20] add more tests --- daemon/src/update.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/daemon/src/update.rs b/daemon/src/update.rs index f9650d0b..861c2723 100644 --- a/daemon/src/update.rs +++ b/daemon/src/update.rs @@ -247,4 +247,21 @@ mod tests { assert_eq!(newer_version, "0.11.2"); assert_eq!(older_version, "0.11.1"); } + + #[test] + fn compares_major_minor_patch_correctly() { + let (v1_parts, v1) = parse_release_tagname("v1.0.0").unwrap(); + let (v2_parts, v2) = parse_release_tagname("v1.0.1").unwrap(); + let (v3_parts, v3) = parse_release_tagname("v1.1.0").unwrap(); + let (v4_parts, v4) = parse_release_tagname("v2.0.0").unwrap(); + + assert!(v2_parts > v1_parts); + assert!(v3_parts > v2_parts); + assert!(v4_parts > v3_parts); + + assert_eq!(v1, "1.0.0"); + assert_eq!(v2, "1.0.1"); + assert_eq!(v3, "1.1.0"); + assert_eq!(v4, "2.0.0"); + } } From 5278f450e2eec0bedeb98c72ba157b185c12eb73 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Thu, 21 May 2026 18:35:57 -0700 Subject: [PATCH 09/20] remove todo --- daemon/src/update.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/daemon/src/update.rs b/daemon/src/update.rs index 861c2723..da72ee7f 100644 --- a/daemon/src/update.rs +++ b/daemon/src/update.rs @@ -94,7 +94,6 @@ async fn refresh_update_status( ) -> Result, String> { let response = http_client .get(GITHUB_LATEST_RELEASE_URL) - // TODO: do we set a user agent here? .send() .await .map_err(|err| format!("failed to query GitHub releases: {err}"))?; From 018cbb1ef0af5398c9f4512679e5ae87db57bcad Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Fri, 22 May 2026 10:23:44 -0700 Subject: [PATCH 10/20] add to docs --- doc/configuration.md | 4 ++++ doc/updating-rayhunter.md | 2 ++ 2 files changed, 6 insertions(+) diff --git a/doc/configuration.md b/doc/configuration.md index 28b5ac7f..8d89c646 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -15,10 +15,12 @@ Through web UI you can set: - *Disable button control*: built-in power button of the device is not used by Rayhunter. - *Double-tap power button to start new recording*: double clicking on a built-in power button of the device stops and immediately restarts the recording. This could be useful if Rayhunter's heuristics is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button. - **Colorblind Mode** enables color blind mode (blue line is shown instead of green line, red line remains red). Please note that this does not cover all types of color blindness, but switching green to blue should be about enough to differentiate the color change for most types of color blindness. +- **Automatically check for software updates** enables periodic checks against the Rayhunter GitHub releases page. When a newer release is found, the web UI shows a notice and, if ntfy update notifications are enabled, a notification is sent. - **ntfy URL**, which allows setting a [ntfy](https://ntfy.sh/) URL to which notifications of new detections will be sent. The topic should be unique to your device, e.g., `https://ntfy.sh/rayhunter_notifications_ba9di7ie` or `https://myserver.example.com/rayhunter_notifications_ba9di7ie`. The ntfy Android and iOS apps can then be used to receive notifications. More information can be found in the [ntfy docs](https://docs.ntfy.sh/). - **Enabled Notification Types** allows enabling or disabling the following types of notifications: - *Warnings*, which will alert when a heuristic is triggered. Alerts will be sent at most once every five minutes. - *Low Battery*, which will alert when the device's battery is low. Notifications may not be supported for all devices—you can check if your device is supported by looking at whether the battery level indicator is functioning on the System Information section of the Rayhunter UI. + - *Updates*, which will alert when a new Rayhunter release is available. - With **Analyzer Heuristic Settings** you can switch on or off built-in [Rayhunter heuristics](heuristics.md). Some heuristics are experimental or can trigger a lot of false positive warnings in some networks (our tests have shown that some heuristics have different behavior in US or European networks). In that case you can decide whether you would like to have the heuristics that trigger a lot of false positives on or off. Please note that we are constantly improving and adding new heuristics, so a new release may reduce false positives in existing heuristics as well. ## GPS @@ -39,6 +41,8 @@ The GPS data is stored as a separate JSON file next to QMDL captures, and contai On the **Orbic**, **Moxee**, **UZ801**, **TMOHS1**, and **Wingtech**, Rayhunter can connect the device to an existing WiFi network while keeping the hotspot running. This gives the device internet access for [notifications](https://docs.ntfy.sh/) and lets you reach the web UI from any device on that network. +When the device is online, Rayhunter also checks GitHub for new releases and shows an update notice in the web UI. If you enable the *Updates* notification type, it can send the same notice through ntfy as well. You can disable this feature by turning off the *Automatically check for software updates* setting in the web UI. + - **Enable WiFi** turns WiFi client mode on or off. Disabling it does not erase saved credentials. - **Scan** searches for nearby networks. Select one from the dropdown, or type an SSID manually. - **Password** is required for WPA/WPA2 networks. The password is stored separately from `config.toml` (in `wpa_sta.conf` on the device) and is never exposed through the API. diff --git a/doc/updating-rayhunter.md b/doc/updating-rayhunter.md index 3f1d2bad..5113a8da 100644 --- a/doc/updating-rayhunter.md +++ b/doc/updating-rayhunter.md @@ -1,3 +1,5 @@ # Updating Rayhunter Great news: if you've successfully installed Rayhunter, you already know how to update it! Our update process is identical to the installation process: simply repeat the steps for installing Rayhunter via a [release](./installing-from-release.md) or from [source](./installing-from-source.md). + +When the device is online, Rayhunter also checks GitHub for new releases and shows an update notice in the web UI. If you enable the *Updates* notification type, it can send the same notice through ntfy as well. You can disable this feature by turning off the *Automatically check for software updates* setting in the web UI. From 0c4b854daeb70f23019e15c98f1c698149a61535 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Fri, 22 May 2026 11:24:14 -0700 Subject: [PATCH 11/20] frontend update notice --- .../web/src/lib/components/ConfigForm.svelte | 31 ++++++++++++ .../src/lib/components/UpdateNotice.svelte | 50 +++++++++++++++++++ daemon/web/src/lib/utils.svelte.ts | 15 ++++++ daemon/web/src/routes/+page.svelte | 11 ++++ 4 files changed, 107 insertions(+) create mode 100644 daemon/web/src/lib/components/UpdateNotice.svelte diff --git a/daemon/web/src/lib/components/ConfigForm.svelte b/daemon/web/src/lib/components/ConfigForm.svelte index 69a119fa..fd19c485 100644 --- a/daemon/web/src/lib/components/ConfigForm.svelte +++ b/daemon/web/src/lib/components/ConfigForm.svelte @@ -6,6 +6,7 @@ get_wifi_status, scan_wifi_networks, GpsMode, + enabled_notifications, type Config, type WifiStatus, type WifiNetwork, @@ -214,6 +215,22 @@

Notification Settings

+
+ + +
+

+ When enabled, Rayhunter periodically checks GitHub for new releases and + shows an update notice in the web UI. +

+
+
+ + +
diff --git a/daemon/web/src/lib/components/UpdateNotice.svelte b/daemon/web/src/lib/components/UpdateNotice.svelte new file mode 100644 index 00000000..f0e1de0b --- /dev/null +++ b/daemon/web/src/lib/components/UpdateNotice.svelte @@ -0,0 +1,50 @@ + + +{#if is_visible && status} +
+ + + Software Update Available + +

+ A new version of Rayhunter is available! You are currently running version {status.current_version}, + and the latest release is version {status.latest_version}. +

+
+ + View the latest release on GitHub to see what's new and download the update. + + + View Release + +
+
+{/if} diff --git a/daemon/web/src/lib/utils.svelte.ts b/daemon/web/src/lib/utils.svelte.ts index 1b5c5762..d67c729c 100644 --- a/daemon/web/src/lib/utils.svelte.ts +++ b/daemon/web/src/lib/utils.svelte.ts @@ -16,6 +16,7 @@ export interface AnalyzerConfig { export enum enabled_notifications { Warning = 'Warning', LowBattery = 'LowBattery', + Update = 'Update', } export interface WebdavConfig { @@ -52,6 +53,7 @@ export interface Config { key_input_mode: number; ntfy_url: string; enabled_notifications: enabled_notifications[]; + auto_check_updates: boolean; analyzers: AnalyzerConfig; min_space_to_start_recording_mb: number; min_space_to_continue_recording_mb: number; @@ -170,10 +172,23 @@ export interface TimeResponse { offset_seconds: number; } +export interface UpdateStatus { + current_version: string; + latest_version?: string | null; + latest_release_url?: string | null; + update_available: boolean; + last_checked?: string | null; + last_error?: string | null; +} + export async function get_daemon_time(): Promise { return JSON.parse(await req('GET', '/api/time')); } +export async function get_update_status(): Promise { + return JSON.parse(await req('GET', '/api/update-status')); +} + export interface GpsData { latitude: number; longitude: number; diff --git a/daemon/web/src/routes/+page.svelte b/daemon/web/src/routes/+page.svelte index 52bcbedc..d84db7f0 100644 --- a/daemon/web/src/routes/+page.svelte +++ b/daemon/web/src/routes/+page.svelte @@ -3,9 +3,11 @@ import { get_manifest, get_system_stats, + get_update_status, get_gps, get_config, GpsMode, + type UpdateStatus, type GpsData, } from '$lib/utils.svelte'; import ManifestTable from '$lib/components/ManifestTable.svelte'; @@ -19,6 +21,7 @@ import ActionErrors from '$lib/components/ActionErrors.svelte'; import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte'; import LogView from '$lib/components/LogView.svelte'; + import UpdateNotice from '$lib/components/UpdateNotice.svelte'; let manager: AnalysisManager = new AnalysisManager(); let loaded = $state(false); @@ -31,6 +34,7 @@ let config_shown: boolean = $state(false); let gps_data: GpsData | null = $state(null); let gps_mode: GpsMode = $state(GpsMode.Disabled); + let update_status: UpdateStatus | null = $state(null); $effect(() => { const interval = setInterval(async () => { try { @@ -49,6 +53,12 @@ current_entry = new_manifest.current_entry; system_stats = await get_system_stats(); + // Allow update status to fail + try { + update_status = await get_update_status(); + } catch { + update_status = null; + } const config = await get_config(); gps_mode = config.gps_mode; gps_data = await get_gps(); @@ -252,6 +262,7 @@ {/if} + {#if loaded}
{#if current_entry} From 0178bb90bbff155739b94d27d47efc8d48e33604 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Fri, 22 May 2026 11:24:30 -0700 Subject: [PATCH 12/20] improve name in documentation --- doc/configuration.md | 4 ++-- doc/updating-rayhunter.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/configuration.md b/doc/configuration.md index 8d89c646..6989a018 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -20,7 +20,7 @@ Through web UI you can set: - **Enabled Notification Types** allows enabling or disabling the following types of notifications: - *Warnings*, which will alert when a heuristic is triggered. Alerts will be sent at most once every five minutes. - *Low Battery*, which will alert when the device's battery is low. Notifications may not be supported for all devices—you can check if your device is supported by looking at whether the battery level indicator is functioning on the System Information section of the Rayhunter UI. - - *Updates*, which will alert when a new Rayhunter release is available. + - *Software Updates*, which will alert when a new Rayhunter release is available. - With **Analyzer Heuristic Settings** you can switch on or off built-in [Rayhunter heuristics](heuristics.md). Some heuristics are experimental or can trigger a lot of false positive warnings in some networks (our tests have shown that some heuristics have different behavior in US or European networks). In that case you can decide whether you would like to have the heuristics that trigger a lot of false positives on or off. Please note that we are constantly improving and adding new heuristics, so a new release may reduce false positives in existing heuristics as well. ## GPS @@ -41,7 +41,7 @@ The GPS data is stored as a separate JSON file next to QMDL captures, and contai On the **Orbic**, **Moxee**, **UZ801**, **TMOHS1**, and **Wingtech**, Rayhunter can connect the device to an existing WiFi network while keeping the hotspot running. This gives the device internet access for [notifications](https://docs.ntfy.sh/) and lets you reach the web UI from any device on that network. -When the device is online, Rayhunter also checks GitHub for new releases and shows an update notice in the web UI. If you enable the *Updates* notification type, it can send the same notice through ntfy as well. You can disable this feature by turning off the *Automatically check for software updates* setting in the web UI. +When the device is online, Rayhunter also checks GitHub for new releases and shows an update notice in the web UI. If you enable the *Software Updates* notification type, it can send the same notice through ntfy as well. You can disable this feature by turning off the *Automatically check for software updates* setting in the web UI. - **Enable WiFi** turns WiFi client mode on or off. Disabling it does not erase saved credentials. - **Scan** searches for nearby networks. Select one from the dropdown, or type an SSID manually. diff --git a/doc/updating-rayhunter.md b/doc/updating-rayhunter.md index 5113a8da..86f727b4 100644 --- a/doc/updating-rayhunter.md +++ b/doc/updating-rayhunter.md @@ -2,4 +2,4 @@ Great news: if you've successfully installed Rayhunter, you already know how to update it! Our update process is identical to the installation process: simply repeat the steps for installing Rayhunter via a [release](./installing-from-release.md) or from [source](./installing-from-source.md). -When the device is online, Rayhunter also checks GitHub for new releases and shows an update notice in the web UI. If you enable the *Updates* notification type, it can send the same notice through ntfy as well. You can disable this feature by turning off the *Automatically check for software updates* setting in the web UI. +When the device is online, Rayhunter also checks GitHub for new releases and shows an update notice in the web UI. If you enable the *Software Updates* notification type, it can send the same notice through ntfy as well. You can disable this feature by turning off the *Automatically check for software updates* setting in the web UI. From 80967b293ac54a99b771b32b583af3749407e715 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Fri, 22 May 2026 11:25:09 -0700 Subject: [PATCH 13/20] add user-agent to update check request --- daemon/src/update.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/daemon/src/update.rs b/daemon/src/update.rs index da72ee7f..df183512 100644 --- a/daemon/src/update.rs +++ b/daemon/src/update.rs @@ -94,6 +94,7 @@ async fn refresh_update_status( ) -> Result, String> { let response = http_client .get(GITHUB_LATEST_RELEASE_URL) + .header(reqwest::header::USER_AGENT, "rayhunter-update-checker") .send() .await .map_err(|err| format!("failed to query GitHub releases: {err}"))?; From 546a9ed63093e693992f4be023ed21cf1e67a5af Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Fri, 22 May 2026 11:44:43 -0700 Subject: [PATCH 14/20] add update check request timeout --- daemon/src/update.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/daemon/src/update.rs b/daemon/src/update.rs index df183512..b5a2f3dd 100644 --- a/daemon/src/update.rs +++ b/daemon/src/update.rs @@ -94,6 +94,7 @@ async fn refresh_update_status( ) -> Result, String> { let response = http_client .get(GITHUB_LATEST_RELEASE_URL) + .timeout(Duration::from_secs(5)) .header(reqwest::header::USER_AGENT, "rayhunter-update-checker") .send() .await From cbf8b4d398c1cba4aa0a1e13269af333d055b405 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Fri, 22 May 2026 11:53:23 -0700 Subject: [PATCH 15/20] openapi trait bound --- daemon/src/update.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/daemon/src/update.rs b/daemon/src/update.rs index b5a2f3dd..53d8260d 100644 --- a/daemon/src/update.rs +++ b/daemon/src/update.rs @@ -24,6 +24,7 @@ pub struct UpdateStatus { pub latest_release_url: Option, pub update_available: bool, #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "apidocs", schema(value_type = Option, format = "date-time"))] pub last_checked: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub last_error: Option, From 66ad8ca42dd7e1be7afe0515e67d3d98b34904f4 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Sat, 23 May 2026 19:00:29 -0700 Subject: [PATCH 16/20] do not enable `auto_check_updates` by default --- dist/config.toml.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist/config.toml.in b/dist/config.toml.in index a7ad85ea..5d98f6d4 100644 --- a/dist/config.toml.in +++ b/dist/config.toml.in @@ -30,7 +30,7 @@ enabled_notifications = ["Warning", "LowBattery"] # If true, Rayhunter will periodically check GitHub for new releases and show # an update notice in the web UI. -auto_check_updates = true +auto_check_updates = false # Disk Space Management # Minimum free space (MB) required to start recording From e701bbcf0eb60554a22263feff0909c75cd26984 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Sat, 23 May 2026 19:01:24 -0700 Subject: [PATCH 17/20] remove redundant documentation --- doc/updating-rayhunter.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/doc/updating-rayhunter.md b/doc/updating-rayhunter.md index 86f727b4..3f1d2bad 100644 --- a/doc/updating-rayhunter.md +++ b/doc/updating-rayhunter.md @@ -1,5 +1,3 @@ # Updating Rayhunter Great news: if you've successfully installed Rayhunter, you already know how to update it! Our update process is identical to the installation process: simply repeat the steps for installing Rayhunter via a [release](./installing-from-release.md) or from [source](./installing-from-source.md). - -When the device is online, Rayhunter also checks GitHub for new releases and shows an update notice in the web UI. If you enable the *Software Updates* notification type, it can send the same notice through ntfy as well. You can disable this feature by turning off the *Automatically check for software updates* setting in the web UI. From 5492ee69dde0b522a37da0cef5c8d850d8dac689 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Sat, 23 May 2026 19:02:25 -0700 Subject: [PATCH 18/20] surface fetch of update status error --- daemon/web/src/routes/+page.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/daemon/web/src/routes/+page.svelte b/daemon/web/src/routes/+page.svelte index d84db7f0..36db47b7 100644 --- a/daemon/web/src/routes/+page.svelte +++ b/daemon/web/src/routes/+page.svelte @@ -56,7 +56,8 @@ // Allow update status to fail try { update_status = await get_update_status(); - } catch { + } catch (error) { + console.error('Error fetching update status:', error); update_status = null; } const config = await get_config(); From 49b0384cec765b32a7410dc614053da11db4f870 Mon Sep 17 00:00:00 2001 From: recanman <29310982+recanman@users.noreply.github.com> Date: Sun, 24 May 2026 09:24:29 -0700 Subject: [PATCH 19/20] fail on version with pre-release for now, add additional test cases --- daemon/src/update.rs | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/daemon/src/update.rs b/daemon/src/update.rs index 53d8260d..62dbb188 100644 --- a/daemon/src/update.rs +++ b/daemon/src/update.rs @@ -67,11 +67,15 @@ fn parse_release_tagname(version: &str) -> Option<(VersionParts, String)> { let trimmed_version = version.trim().trim_start_matches('v'); let mut parts = trimmed_version.split('.'); - // Ignore any pre-release/build metadata by splitting on '-' - // TODO: is this okay? - let major = parts.next()?.split('-').next()?.parse().ok()?; - let minor = parts.next()?.split('-').next()?.parse().ok()?; - let patch = parts.next()?.split('-').next()?.parse().ok()?; + // Fail on versions with pre-release metadata: https://github.com/EFForg/rayhunter/pull/1054#issuecomment-4528407281 + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts.next()?.parse::().ok()?; + // Expect only major.minor.patch format + if parts.next().is_some() { + return None; + } + let version = format!("{}.{}.{}", major, minor, patch); Some(( VersionParts { @@ -225,20 +229,21 @@ mod tests { assert_eq!(version, "0.11.1"); } - #[test] - fn parses_versions_with_v_prefix_and_prerelease() { - let (parts, version) = parse_release_tagname("v0.11.1-beta.1").unwrap(); - assert_eq!(parts.major, 0); - assert_eq!(parts.minor, 11); - assert_eq!(parts.patch, 1); - assert_eq!(version, "0.11.1"); - } - #[test] fn returns_none_for_invalid_versions() { assert!(parse_release_tagname("invalid").is_none()); assert!(parse_release_tagname("v1.2").is_none()); assert!(parse_release_tagname("v1.2.x").is_none()); + assert!(parse_release_tagname("v1.2.3.4").is_none()); + assert!(parse_release_tagname("v1.2.-3").is_none()); + assert!(parse_release_tagname("v1.2.3-beta").is_none()); + assert!(parse_release_tagname("v1.2.3-beta.1").is_none()); + assert!(parse_release_tagname("1.2").is_none()); + assert!(parse_release_tagname("1.2.x").is_none()); + assert!(parse_release_tagname("1.2.3.4").is_none()); + assert!(parse_release_tagname("1.2.-3").is_none()); + assert!(parse_release_tagname("1.2.3-beta").is_none()); + assert!(parse_release_tagname("1.2.3-beta.1").is_none()); } #[test] From bcd135e558ff5d137e1439c9dde5316693206944 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Sun, 24 May 2026 21:25:16 +0200 Subject: [PATCH 20/20] Update configuration.md --- doc/configuration.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/doc/configuration.md b/doc/configuration.md index 6989a018..b9dd36c0 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -20,7 +20,7 @@ Through web UI you can set: - **Enabled Notification Types** allows enabling or disabling the following types of notifications: - *Warnings*, which will alert when a heuristic is triggered. Alerts will be sent at most once every five minutes. - *Low Battery*, which will alert when the device's battery is low. Notifications may not be supported for all devices—you can check if your device is supported by looking at whether the battery level indicator is functioning on the System Information section of the Rayhunter UI. - - *Software Updates*, which will alert when a new Rayhunter release is available. + - *Software Updates*, which will alert when a new Rayhunter release is available. Only triggers when *Automatically check for software updates* is enabled. - With **Analyzer Heuristic Settings** you can switch on or off built-in [Rayhunter heuristics](heuristics.md). Some heuristics are experimental or can trigger a lot of false positive warnings in some networks (our tests have shown that some heuristics have different behavior in US or European networks). In that case you can decide whether you would like to have the heuristics that trigger a lot of false positives on or off. Please note that we are constantly improving and adding new heuristics, so a new release may reduce false positives in existing heuristics as well. ## GPS @@ -41,8 +41,6 @@ The GPS data is stored as a separate JSON file next to QMDL captures, and contai On the **Orbic**, **Moxee**, **UZ801**, **TMOHS1**, and **Wingtech**, Rayhunter can connect the device to an existing WiFi network while keeping the hotspot running. This gives the device internet access for [notifications](https://docs.ntfy.sh/) and lets you reach the web UI from any device on that network. -When the device is online, Rayhunter also checks GitHub for new releases and shows an update notice in the web UI. If you enable the *Software Updates* notification type, it can send the same notice through ntfy as well. You can disable this feature by turning off the *Automatically check for software updates* setting in the web UI. - - **Enable WiFi** turns WiFi client mode on or off. Disabling it does not erase saved credentials. - **Scan** searches for nearby networks. Select one from the dropdown, or type an SSID manually. - **Password** is required for WPA/WPA2 networks. The password is stored separately from `config.toml` (in `wpa_sta.conf` on the device) and is never exposed through the API.