diff --git a/Cargo.lock b/Cargo.lock index ef06a5a..03eb57d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2588,6 +2588,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6984d7b..3724e72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ chrono = { version = "0.4", features = ["serde"] } toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2.5" modelrelay-protocol = { path = "crates/modelrelay-protocol" } [workspace.lints.clippy] diff --git a/crates/modelrelay-desktop/Cargo.toml b/crates/modelrelay-desktop/Cargo.toml index 3217e55..d18ed57 100644 --- a/crates/modelrelay-desktop/Cargo.toml +++ b/crates/modelrelay-desktop/Cargo.toml @@ -22,6 +22,7 @@ serde_json = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +url = { workspace = true } modelrelay-worker = { path = "../modelrelay-worker" } modelrelay-protocol = { path = "../modelrelay-protocol" } diff --git a/crates/modelrelay-desktop/src/lib.rs b/crates/modelrelay-desktop/src/lib.rs index ed5b8c2..fd40e9b 100644 --- a/crates/modelrelay-desktop/src/lib.rs +++ b/crates/modelrelay-desktop/src/lib.rs @@ -5,9 +5,35 @@ use modelrelay_worker::{WorkerDaemon, WorkerDaemonConfig}; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use tokio::task::JoinHandle; +use url::Url; pub mod updater; +/// Validate that `candidate` parses as an absolute http/https URL with a non-empty host. +/// +/// The desktop app hits `backend_url` for local OpenAI-compatible servers (Ollama, +/// LM Studio, Kiln, etc.) and `relay_url` for the relay proxy. Both must be valid +/// before we start a worker — a malformed URL leads to confusing runtime errors +/// in the worker loop. +/// +/// # Errors +/// Returns a human-readable error describing why the URL is invalid. +pub fn validate_http_url(candidate: &str, field: &str) -> Result<(), String> { + let trimmed = candidate.trim(); + if trimmed.is_empty() { + return Err(format!("{field} is required")); + } + let parsed = Url::parse(trimmed).map_err(|e| format!("{field} is not a valid URL: {e}"))?; + match parsed.scheme() { + "http" | "https" => {} + other => return Err(format!("{field} must use http or https (got '{other}')")), + } + if parsed.host_str().is_none_or(str::is_empty) { + return Err(format!("{field} is missing a hostname")); + } + Ok(()) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppSettings { pub backend_url: String, @@ -85,8 +111,10 @@ impl WorkerManager { } /// # Errors - /// Returns an error if settings cannot be persisted to disk. + /// Returns an error if URLs fail validation or settings cannot be persisted to disk. pub async fn save_settings(&self, new_settings: AppSettings) -> Result<(), String> { + validate_http_url(&new_settings.backend_url, "Backend URL")?; + validate_http_url(&new_settings.relay_url, "Relay Server URL")?; self.persist_settings(&new_settings)?; *self.settings.lock().await = new_settings; Ok(()) @@ -174,3 +202,68 @@ impl WorkerManager { s.error = None; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_ollama_default() { + validate_http_url("http://localhost:11434", "Backend URL").unwrap(); + } + + #[test] + fn accepts_lm_studio_default() { + validate_http_url("http://localhost:1234", "Backend URL").unwrap(); + } + + #[test] + fn accepts_kiln_default() { + validate_http_url("http://localhost:8420", "Backend URL").unwrap(); + } + + #[test] + fn accepts_remote_https_host() { + validate_http_url("https://api.modelrelay.io", "Relay Server URL").unwrap(); + } + + #[test] + fn accepts_ip_and_path() { + validate_http_url("http://192.168.1.42:11434/v1", "Backend URL").unwrap(); + } + + #[test] + fn rejects_empty() { + let err = validate_http_url("", "Backend URL").unwrap_err(); + assert!(err.contains("required"), "got: {err}"); + } + + #[test] + fn rejects_whitespace_only() { + let err = validate_http_url(" ", "Backend URL").unwrap_err(); + assert!(err.contains("required"), "got: {err}"); + } + + #[test] + fn rejects_missing_scheme() { + assert!(validate_http_url("localhost:11434", "Backend URL").is_err()); + } + + #[test] + fn rejects_garbage() { + assert!(validate_http_url("not a url", "Backend URL").is_err()); + } + + #[test] + fn rejects_non_http_scheme() { + let err = validate_http_url("ftp://localhost:11434", "Backend URL").unwrap_err(); + assert!(err.contains("http or https"), "got: {err}"); + } + + #[test] + fn rejects_missing_host() { + // The url crate rejects empty authority like `http://:8080/x` with + // an "empty host" error — make sure we surface that as a validation failure. + assert!(validate_http_url("http://:8080/x", "Backend URL").is_err()); + } +} diff --git a/crates/modelrelay-desktop/ui/index.html b/crates/modelrelay-desktop/ui/index.html index 622f524..bd3e3ae 100644 --- a/crates/modelrelay-desktop/ui/index.html +++ b/crates/modelrelay-desktop/ui/index.html @@ -325,6 +325,15 @@ margin-top: 4px; line-height: 1.4; } + .form-group .field-error { + font-size: 11px; + color: var(--danger); + margin-top: 4px; + line-height: 1.4; + display: none; + } + .form-group.has-error .field-error { display: block; } + .form-group.has-error .hint { display: none; } .form-group input[type="text"], .form-group input[type="password"], @@ -495,10 +504,11 @@
http://localhost:11434, LM Studio http://localhost:1234, Kiln http://localhost:8420.http://localhost:11434, LM Studio http://localhost:1234, Kiln http://localhost:8420. Remote servers, Docker hosts, and custom ports are all supported.