diff --git a/src/trx-client/src/config.rs b/src/trx-client/src/config.rs index 10fe21e..3d08496 100644 --- a/src/trx-client/src/config.rs +++ b/src/trx-client/src/config.rs @@ -37,6 +37,8 @@ pub struct ClientConfig { pub struct GeneralConfig { /// Callsign or owner label to display in frontends pub callsign: Option, + /// Optional website URL to use as the web UI header title link. + pub website_url: Option, /// Log level (trace, debug, info, warn, error) pub log_level: Option, } @@ -45,6 +47,7 @@ impl Default for GeneralConfig { fn default() -> Self { Self { callsign: Some("N0CALL".to_string()), + website_url: None, log_level: None, } } @@ -334,6 +337,11 @@ impl ClientConfig { return Err("[remote.auth].token must not be empty when set".to_string()); } } + if let Some(url) = &self.general.website_url { + if url.trim().is_empty() { + return Err("[general].website_url must not be empty when set".to_string()); + } + } if self.frontends.http.enabled && self.frontends.http.port == 0 { return Err("[frontends.http].port must be > 0 when enabled".to_string()); @@ -424,6 +432,7 @@ impl ClientConfig { let example = ClientConfig { general: GeneralConfig { callsign: Some("N0CALL".to_string()), + website_url: Some("https://haxx.space".to_string()), log_level: Some("info".to_string()), }, remote: RemoteConfig { @@ -545,6 +554,7 @@ mod tests { assert!(config.frontends.http_json.enabled); assert_eq!(config.frontends.http_json.port, 0); assert!(config.remote.url.is_none()); + assert!(config.general.website_url.is_none()); assert_eq!(config.remote.poll_interval_ms, 750); assert!(config.frontends.audio.enabled); assert_eq!(config.frontends.audio.server_port, 4531); @@ -559,6 +569,7 @@ mod tests { let toml_str = r#" [general] callsign = "W1AW" +website_url = "https://example.com" [remote] url = "192.168.1.100:9000" @@ -576,6 +587,7 @@ initial_map_zoom = 12 let config: ClientConfig = toml::from_str(toml_str).unwrap(); assert_eq!(config.general.callsign, Some("W1AW".to_string())); + assert_eq!(config.general.website_url, Some("https://example.com".to_string())); assert_eq!(config.remote.url, Some("192.168.1.100:9000".to_string())); assert_eq!(config.remote.rig_id, Some("hf".to_string())); assert_eq!(config.remote.auth.token, Some("my-token".to_string())); diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index 742c70a..6e2d8dc 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -244,6 +244,7 @@ async fn async_init() -> DynResult { .clone() .or_else(|| cfg.general.callsign.clone()); frontend_runtime.owner_callsign = callsign.clone(); + frontend_runtime.owner_website_url = cfg.general.website_url.clone(); info!( "Starting trx-client (remote: {}, frontends: {})", diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index 95cb304..15986d3 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -176,6 +176,8 @@ pub struct FrontendRuntimeContext { pub remote_rigs: Arc>>, /// Owner callsign from trx-client config/CLI for frontend display. pub owner_callsign: Option, + /// Optional website URL for the web UI header title link. + pub owner_website_url: Option, /// Latest spectrum frame from the active SDR rig; None for non-SDR backends. pub spectrum: Arc>, } @@ -208,6 +210,7 @@ impl FrontendRuntimeContext { remote_active_rig_id: Arc::new(Mutex::new(None)), remote_rigs: Arc::new(Mutex::new(Vec::new())), owner_callsign: None, + owner_website_url: None, spectrum: Arc::new(Mutex::new(SharedSpectrum::default())), } } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 2af7c4a..16d2667 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -1265,6 +1265,7 @@ let serverVersion = null; let serverBuildDate = null; let serverCallsign = null; let ownerCallsign = null; +let ownerWebsiteUrl = null; let serverRigs = []; let serverActiveRigId = null; let serverLat = null; @@ -1282,11 +1283,26 @@ function updateFooterBuildInfo() { function updateTitle() { const titleEl = document.getElementById("rig-title"); if (titleEl) { - titleEl.textContent = serverVersion ? `trx-rs v${serverVersion}` : "trx-rs"; + if (ownerWebsiteUrl) { + const label = ownerCallsign || displayLabelFromUrl(ownerWebsiteUrl); + titleEl.innerHTML = + `${escapeMapHtml(label)}`; + } else { + titleEl.textContent = serverVersion ? `trx-rs v${serverVersion}` : "trx-rs"; + } } updateDocumentTitle(lastSpectrumData?.rds ?? null); } +function displayLabelFromUrl(url) { + try { + const host = new URL(url).hostname.replace(/^www\./i, ""); + return host || url; + } catch (_e) { + return url; + } +} + function render(update) { if (!update) return; if (update.server_version) serverVersion = update.server_version; @@ -1295,6 +1311,9 @@ function render(update) { if (typeof update.owner_callsign === "string" && update.owner_callsign.length > 0) { ownerCallsign = update.owner_callsign; } + if (typeof update.owner_website_url === "string" && update.owner_website_url.length > 0) { + ownerWebsiteUrl = update.owner_website_url; + } if (update.server_latitude != null) serverLat = update.server_latitude; if (update.server_longitude != null) serverLon = update.server_longitude; if (typeof update.initial_map_zoom === "number" && Number.isFinite(update.initial_map_zoom)) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 0eae7c0..65d3b13 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -508,6 +508,14 @@ small { color: var(--text-muted); } flex: 0 1 auto; } .title { font-size: 1.4rem; font-weight: 700; display: inline-flex; align-items: center; gap: 0.35rem; } +.title-link { + color: inherit; + text-decoration: none; +} +.title-link:hover { + text-decoration: underline; + text-underline-offset: 0.14em; +} .overview-strip { width: 100%; margin: 0; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index 3aad0c5..cddffd7 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -36,6 +36,7 @@ struct FrontendMeta { active_rig_id: Option, rig_ids: Vec, owner_callsign: Option, + owner_website_url: Option, show_sdr_gain_control: bool, initial_map_zoom: u8, } @@ -82,6 +83,9 @@ fn inject_frontend_meta(json: &str, meta: FrontendMeta) -> String { if let Some(owner) = meta.owner_callsign { map.insert("owner_callsign".to_string(), serde_json::json!(owner)); } + if let Some(url) = meta.owner_website_url { + map.insert("owner_website_url".to_string(), serde_json::json!(url)); + } map.insert( "show_sdr_gain_control".to_string(), serde_json::json!(meta.show_sdr_gain_control), @@ -105,6 +109,7 @@ fn frontend_meta_from_context( active_rig_id: active_rig_id_from_context(context), rig_ids: rig_ids_from_context(context), owner_callsign: owner_callsign_from_context(context), + owner_website_url: owner_website_url_from_context(context), show_sdr_gain_control: show_sdr_gain_control_from_context(context), initial_map_zoom: initial_map_zoom_from_context(context), } @@ -140,6 +145,10 @@ fn owner_callsign_from_context(context: &FrontendRuntimeContext) -> Option Option { + context.owner_website_url.clone() +} + fn show_sdr_gain_control_from_context(context: &FrontendRuntimeContext) -> bool { context.http_show_sdr_gain_control }