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 @@

Welcome to ModelRelay

-
+
-
The address of your local LLM server (Ollama, LM Studio, etc).
+
The address of your local LLM server. Examples: Ollama http://localhost:11434, LM Studio http://localhost:1234, Kiln http://localhost:8420.
+
@@ -509,9 +519,10 @@

Welcome to ModelRelay

-
+
+
@@ -616,14 +627,16 @@

Welcome to ModelRelay

Connection
-
+
-
Local LLM server address (Ollama, LM Studio, vLLM, etc.)
+
Local LLM server address. Common defaults: Ollama http://localhost:11434, LM Studio http://localhost:1234, Kiln http://localhost:8420. Remote servers, Docker hosts, and custom ports are all supported.
+
-
+
+
@@ -834,6 +847,50 @@

Welcome to ModelRelay

} } + // Validate an http(s) URL string. Mirrors validate_http_url in src/lib.rs + // so the UI blocks obviously-bad input before the Rust command re-checks. + function validateHttpUrl(candidate, field) { + const trimmed = (candidate || "").trim(); + if (!trimmed) return field + " is required"; + let parsed; + try { + parsed = new URL(trimmed); + } catch (_e) { + return field + " is not a valid URL"; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return field + " must use http or https"; + } + if (!parsed.hostname) return field + " is missing a hostname"; + return null; + } + + function setFieldError(groupId, errorId, message) { + const group = document.getElementById(groupId); + const errEl = document.getElementById(errorId); + const input = group ? group.querySelector("input") : null; + if (!group || !errEl || !input) return; + if (message) { + group.classList.add("has-error"); + input.classList.add("invalid"); + errEl.textContent = message; + } else { + group.classList.remove("has-error"); + input.classList.remove("invalid"); + errEl.textContent = ""; + } + } + + function validateSettingsForm() { + const backendErr = validateHttpUrl(document.getElementById("backendUrl").value, "Backend URL"); + const relayErr = validateHttpUrl(document.getElementById("relayUrlSetting").value, "Relay Server URL"); + setFieldError("backendUrlGroup", "backendUrlError", backendErr); + setFieldError("relayUrlGroup", "relayUrlError", relayErr); + const saveBtn = document.querySelector("#tab-settings .btn-save"); + if (saveBtn) saveBtn.disabled = Boolean(backendErr || relayErr); + return !(backendErr || relayErr); + } + // Settings: load and save async function loadSettings() { try { @@ -847,16 +904,32 @@

Welcome to ModelRelay

document.getElementById("maxConcurrent").value = s.max_concurrent; document.getElementById("pollInterval").value = s.poll_interval_secs; document.getElementById("autoStart").checked = s.auto_start; + validateSettingsForm(); } catch (e) { console.error("Failed to load settings:", e); } } + // Attach live validation to the two URL fields on the settings form. + ["backendUrl", "relayUrlSetting"].forEach((id) => { + const el = document.getElementById(id); + if (el) { + el.addEventListener("input", validateSettingsForm); + el.addEventListener("blur", validateSettingsForm); + } + }); + async function doSave() { const feedback = document.getElementById("saveFeedback"); feedback.textContent = ""; feedback.className = "save-feedback"; + if (!validateSettingsForm()) { + feedback.textContent = "Fix the errors above before saving."; + feedback.className = "save-feedback error"; + return; + } + try { const modelsRaw = document.getElementById("modelsInput").value; const models = modelsRaw.split(",").map(m => m.trim()).filter(m => m.length > 0); @@ -899,7 +972,21 @@

Welcome to ModelRelay

} function wizardNext() { + if (wizardStep === 0) { + const err = validateHttpUrl(document.getElementById("wizBackendUrl").value, "Backend URL"); + setFieldError("wizBackendUrlGroup", "wizBackendUrlError", err); + if (err) { + document.getElementById("wizBackendUrl").focus(); + return; + } + } if (wizardStep === 1) { + const relayErr = validateHttpUrl(document.getElementById("wizRelayUrl").value, "Relay Server URL"); + setFieldError("wizRelayUrlGroup", "wizRelayUrlError", relayErr); + if (relayErr) { + document.getElementById("wizRelayUrl").focus(); + return; + } const secret = document.getElementById("wizWorkerSecret").value.trim(); if (!secret) { const input = document.getElementById("wizWorkerSecret"); @@ -913,6 +1000,19 @@

Welcome to ModelRelay

wizardUpdateDots(); } + // Clear wizard errors as the user fixes them. + ["wizBackendUrl", "wizRelayUrl"].forEach((id) => { + const el = document.getElementById(id); + if (!el) return; + el.addEventListener("input", () => { + const groupId = id + "Group"; + const errorId = id + "Error"; + const label = id === "wizBackendUrl" ? "Backend URL" : "Relay Server URL"; + const err = validateHttpUrl(el.value, label); + setFieldError(groupId, errorId, err); + }); + }); + function wizardBack() { wizardStep = Math.max(wizardStep - 1, 0); wizardUpdateDots();