Skip to content

Commit 83d2ea3

Browse files
author
Cloud Eric
committed
desktop: validate backend and relay URLs in settings
Both URLs were persisted as free-form strings. An invalid host or missing scheme fell through to the worker loop as a confusing runtime error. Validate at two layers: - Rust: reject empty, non-http/https, and hostless URLs in WorkerManager::save_settings before persist (url::Url::parse). - UI: live validation in the settings form and onboarding wizard using new URL(), with inline per-field error messages. Hints now name the common local backends (Ollama 11434, LM Studio 1234, Kiln 8420) so the field is self-documenting. 11 unit tests cover the accept and reject cases, including the three default ports and an http://:port/path hostless case.
1 parent 0df10cc commit 83d2ea3

5 files changed

Lines changed: 203 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ chrono = { version = "0.4", features = ["serde"] }
3333
toml = "0.8"
3434
tracing = "0.1"
3535
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
36+
url = "2.5"
3637
modelrelay-protocol = { path = "crates/modelrelay-protocol" }
3738

3839
[workspace.lints.clippy]

crates/modelrelay-desktop/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ serde_json = { workspace = true }
2222
tokio = { workspace = true }
2323
tracing = { workspace = true }
2424
tracing-subscriber = { workspace = true }
25+
url = { workspace = true }
2526
modelrelay-worker = { path = "../modelrelay-worker" }
2627
modelrelay-protocol = { path = "../modelrelay-protocol" }
2728

crates/modelrelay-desktop/src/lib.rs

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,35 @@ use modelrelay_worker::{WorkerDaemon, WorkerDaemonConfig};
55
use serde::{Deserialize, Serialize};
66
use tokio::sync::Mutex;
77
use tokio::task::JoinHandle;
8+
use url::Url;
89

910
pub mod updater;
1011

12+
/// Validate that `candidate` parses as an absolute http/https URL with a non-empty host.
13+
///
14+
/// The desktop app hits `backend_url` for local OpenAI-compatible servers (Ollama,
15+
/// LM Studio, Kiln, etc.) and `relay_url` for the relay proxy. Both must be valid
16+
/// before we start a worker — a malformed URL leads to confusing runtime errors
17+
/// in the worker loop.
18+
///
19+
/// # Errors
20+
/// Returns a human-readable error describing why the URL is invalid.
21+
pub fn validate_http_url(candidate: &str, field: &str) -> Result<(), String> {
22+
let trimmed = candidate.trim();
23+
if trimmed.is_empty() {
24+
return Err(format!("{field} is required"));
25+
}
26+
let parsed = Url::parse(trimmed).map_err(|e| format!("{field} is not a valid URL: {e}"))?;
27+
match parsed.scheme() {
28+
"http" | "https" => {}
29+
other => return Err(format!("{field} must use http or https (got '{other}')")),
30+
}
31+
if parsed.host_str().is_none_or(str::is_empty) {
32+
return Err(format!("{field} is missing a hostname"));
33+
}
34+
Ok(())
35+
}
36+
1137
#[derive(Debug, Clone, Serialize, Deserialize)]
1238
pub struct AppSettings {
1339
pub backend_url: String,
@@ -85,8 +111,10 @@ impl WorkerManager {
85111
}
86112

87113
/// # Errors
88-
/// Returns an error if settings cannot be persisted to disk.
114+
/// Returns an error if URLs fail validation or settings cannot be persisted to disk.
89115
pub async fn save_settings(&self, new_settings: AppSettings) -> Result<(), String> {
116+
validate_http_url(&new_settings.backend_url, "Backend URL")?;
117+
validate_http_url(&new_settings.relay_url, "Relay Server URL")?;
90118
self.persist_settings(&new_settings)?;
91119
*self.settings.lock().await = new_settings;
92120
Ok(())
@@ -174,3 +202,68 @@ impl WorkerManager {
174202
s.error = None;
175203
}
176204
}
205+
206+
#[cfg(test)]
207+
mod tests {
208+
use super::*;
209+
210+
#[test]
211+
fn accepts_ollama_default() {
212+
validate_http_url("http://localhost:11434", "Backend URL").unwrap();
213+
}
214+
215+
#[test]
216+
fn accepts_lm_studio_default() {
217+
validate_http_url("http://localhost:1234", "Backend URL").unwrap();
218+
}
219+
220+
#[test]
221+
fn accepts_kiln_default() {
222+
validate_http_url("http://localhost:8420", "Backend URL").unwrap();
223+
}
224+
225+
#[test]
226+
fn accepts_remote_https_host() {
227+
validate_http_url("https://api.modelrelay.io", "Relay Server URL").unwrap();
228+
}
229+
230+
#[test]
231+
fn accepts_ip_and_path() {
232+
validate_http_url("http://192.168.1.42:11434/v1", "Backend URL").unwrap();
233+
}
234+
235+
#[test]
236+
fn rejects_empty() {
237+
let err = validate_http_url("", "Backend URL").unwrap_err();
238+
assert!(err.contains("required"), "got: {err}");
239+
}
240+
241+
#[test]
242+
fn rejects_whitespace_only() {
243+
let err = validate_http_url(" ", "Backend URL").unwrap_err();
244+
assert!(err.contains("required"), "got: {err}");
245+
}
246+
247+
#[test]
248+
fn rejects_missing_scheme() {
249+
assert!(validate_http_url("localhost:11434", "Backend URL").is_err());
250+
}
251+
252+
#[test]
253+
fn rejects_garbage() {
254+
assert!(validate_http_url("not a url", "Backend URL").is_err());
255+
}
256+
257+
#[test]
258+
fn rejects_non_http_scheme() {
259+
let err = validate_http_url("ftp://localhost:11434", "Backend URL").unwrap_err();
260+
assert!(err.contains("http or https"), "got: {err}");
261+
}
262+
263+
#[test]
264+
fn rejects_missing_host() {
265+
// The url crate rejects empty authority like `http://:8080/x` with
266+
// an "empty host" error — make sure we surface that as a validation failure.
267+
assert!(validate_http_url("http://:8080/x", "Backend URL").is_err());
268+
}
269+
}

crates/modelrelay-desktop/ui/index.html

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,15 @@
325325
margin-top: 4px;
326326
line-height: 1.4;
327327
}
328+
.form-group .field-error {
329+
font-size: 11px;
330+
color: var(--danger);
331+
margin-top: 4px;
332+
line-height: 1.4;
333+
display: none;
334+
}
335+
.form-group.has-error .field-error { display: block; }
336+
.form-group.has-error .hint { display: none; }
328337

329338
.form-group input[type="text"],
330339
.form-group input[type="password"],
@@ -495,10 +504,11 @@ <h1>Welcome to ModelRelay</h1>
495504
<!-- Step 0: Backend URL -->
496505
<div class="wizard-panel active" data-panel="0">
497506
<div class="card">
498-
<div class="form-group">
507+
<div class="form-group" id="wizBackendUrlGroup">
499508
<label for="wizBackendUrl">Backend URL</label>
500509
<input type="text" id="wizBackendUrl" placeholder="http://localhost:11434" value="http://localhost:11434">
501-
<div class="hint">The address of your local LLM server (Ollama, LM Studio, etc).</div>
510+
<div class="hint">The address of your local LLM server. Examples: Ollama <code>http://localhost:11434</code>, LM Studio <code>http://localhost:1234</code>, Kiln <code>http://localhost:8420</code>.</div>
511+
<div class="field-error" id="wizBackendUrlError"></div>
502512
</div>
503513
</div>
504514
<div class="wizard-nav">
@@ -509,9 +519,10 @@ <h1>Welcome to ModelRelay</h1>
509519
<!-- Step 1: Relay Server + Secret -->
510520
<div class="wizard-panel" data-panel="1">
511521
<div class="card">
512-
<div class="form-group">
522+
<div class="form-group" id="wizRelayUrlGroup">
513523
<label for="wizRelayUrl">Relay Server URL</label>
514524
<input type="text" id="wizRelayUrl" placeholder="https://api.modelrelay.io" value="https://api.modelrelay.io">
525+
<div class="field-error" id="wizRelayUrlError"></div>
515526
</div>
516527
<div class="form-group">
517528
<label for="wizWorkerSecret">Worker Secret / API Key</label>
@@ -616,14 +627,16 @@ <h1>Welcome to ModelRelay</h1>
616627
<!-- Connection section -->
617628
<div class="form-section">
618629
<div class="form-section-title">Connection</div>
619-
<div class="form-group">
630+
<div class="form-group" id="backendUrlGroup">
620631
<label for="backendUrl">Backend URL</label>
621632
<input type="text" id="backendUrl" placeholder="http://localhost:11434">
622-
<div class="hint">Local LLM server address (Ollama, LM Studio, vLLM, etc.)</div>
633+
<div class="hint">Local LLM server address. Common defaults: Ollama <code>http://localhost:11434</code>, LM Studio <code>http://localhost:1234</code>, Kiln <code>http://localhost:8420</code>. Remote servers, Docker hosts, and custom ports are all supported.</div>
634+
<div class="field-error" id="backendUrlError"></div>
623635
</div>
624-
<div class="form-group">
636+
<div class="form-group" id="relayUrlGroup">
625637
<label for="relayUrlSetting">Relay Server URL</label>
626638
<input type="text" id="relayUrlSetting" placeholder="https://api.modelrelay.io">
639+
<div class="field-error" id="relayUrlError"></div>
627640
</div>
628641
<div class="form-group">
629642
<label for="workerSecret">Worker Secret / API Key</label>
@@ -834,6 +847,50 @@ <h1>Welcome to ModelRelay</h1>
834847
}
835848
}
836849

850+
// Validate an http(s) URL string. Mirrors validate_http_url in src/lib.rs
851+
// so the UI blocks obviously-bad input before the Rust command re-checks.
852+
function validateHttpUrl(candidate, field) {
853+
const trimmed = (candidate || "").trim();
854+
if (!trimmed) return field + " is required";
855+
let parsed;
856+
try {
857+
parsed = new URL(trimmed);
858+
} catch (_e) {
859+
return field + " is not a valid URL";
860+
}
861+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
862+
return field + " must use http or https";
863+
}
864+
if (!parsed.hostname) return field + " is missing a hostname";
865+
return null;
866+
}
867+
868+
function setFieldError(groupId, errorId, message) {
869+
const group = document.getElementById(groupId);
870+
const errEl = document.getElementById(errorId);
871+
const input = group ? group.querySelector("input") : null;
872+
if (!group || !errEl || !input) return;
873+
if (message) {
874+
group.classList.add("has-error");
875+
input.classList.add("invalid");
876+
errEl.textContent = message;
877+
} else {
878+
group.classList.remove("has-error");
879+
input.classList.remove("invalid");
880+
errEl.textContent = "";
881+
}
882+
}
883+
884+
function validateSettingsForm() {
885+
const backendErr = validateHttpUrl(document.getElementById("backendUrl").value, "Backend URL");
886+
const relayErr = validateHttpUrl(document.getElementById("relayUrlSetting").value, "Relay Server URL");
887+
setFieldError("backendUrlGroup", "backendUrlError", backendErr);
888+
setFieldError("relayUrlGroup", "relayUrlError", relayErr);
889+
const saveBtn = document.querySelector("#tab-settings .btn-save");
890+
if (saveBtn) saveBtn.disabled = Boolean(backendErr || relayErr);
891+
return !(backendErr || relayErr);
892+
}
893+
837894
// Settings: load and save
838895
async function loadSettings() {
839896
try {
@@ -847,16 +904,32 @@ <h1>Welcome to ModelRelay</h1>
847904
document.getElementById("maxConcurrent").value = s.max_concurrent;
848905
document.getElementById("pollInterval").value = s.poll_interval_secs;
849906
document.getElementById("autoStart").checked = s.auto_start;
907+
validateSettingsForm();
850908
} catch (e) {
851909
console.error("Failed to load settings:", e);
852910
}
853911
}
854912

913+
// Attach live validation to the two URL fields on the settings form.
914+
["backendUrl", "relayUrlSetting"].forEach((id) => {
915+
const el = document.getElementById(id);
916+
if (el) {
917+
el.addEventListener("input", validateSettingsForm);
918+
el.addEventListener("blur", validateSettingsForm);
919+
}
920+
});
921+
855922
async function doSave() {
856923
const feedback = document.getElementById("saveFeedback");
857924
feedback.textContent = "";
858925
feedback.className = "save-feedback";
859926

927+
if (!validateSettingsForm()) {
928+
feedback.textContent = "Fix the errors above before saving.";
929+
feedback.className = "save-feedback error";
930+
return;
931+
}
932+
860933
try {
861934
const modelsRaw = document.getElementById("modelsInput").value;
862935
const models = modelsRaw.split(",").map(m => m.trim()).filter(m => m.length > 0);
@@ -899,7 +972,21 @@ <h1>Welcome to ModelRelay</h1>
899972
}
900973

901974
function wizardNext() {
975+
if (wizardStep === 0) {
976+
const err = validateHttpUrl(document.getElementById("wizBackendUrl").value, "Backend URL");
977+
setFieldError("wizBackendUrlGroup", "wizBackendUrlError", err);
978+
if (err) {
979+
document.getElementById("wizBackendUrl").focus();
980+
return;
981+
}
982+
}
902983
if (wizardStep === 1) {
984+
const relayErr = validateHttpUrl(document.getElementById("wizRelayUrl").value, "Relay Server URL");
985+
setFieldError("wizRelayUrlGroup", "wizRelayUrlError", relayErr);
986+
if (relayErr) {
987+
document.getElementById("wizRelayUrl").focus();
988+
return;
989+
}
903990
const secret = document.getElementById("wizWorkerSecret").value.trim();
904991
if (!secret) {
905992
const input = document.getElementById("wizWorkerSecret");
@@ -913,6 +1000,19 @@ <h1>Welcome to ModelRelay</h1>
9131000
wizardUpdateDots();
9141001
}
9151002

1003+
// Clear wizard errors as the user fixes them.
1004+
["wizBackendUrl", "wizRelayUrl"].forEach((id) => {
1005+
const el = document.getElementById(id);
1006+
if (!el) return;
1007+
el.addEventListener("input", () => {
1008+
const groupId = id + "Group";
1009+
const errorId = id + "Error";
1010+
const label = id === "wizBackendUrl" ? "Backend URL" : "Relay Server URL";
1011+
const err = validateHttpUrl(el.value, label);
1012+
setFieldError(groupId, errorId, err);
1013+
});
1014+
});
1015+
9161016
function wizardBack() {
9171017
wizardStep = Math.max(wizardStep - 1, 0);
9181018
wizardUpdateDots();

0 commit comments

Comments
 (0)