Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions crates/modelrelay-desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand Down
95 changes: 94 additions & 1 deletion crates/modelrelay-desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -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());
}
}
112 changes: 106 additions & 6 deletions crates/modelrelay-desktop/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -495,10 +504,11 @@ <h1>Welcome to ModelRelay</h1>
<!-- Step 0: Backend URL -->
<div class="wizard-panel active" data-panel="0">
<div class="card">
<div class="form-group">
<div class="form-group" id="wizBackendUrlGroup">
<label for="wizBackendUrl">Backend URL</label>
<input type="text" id="wizBackendUrl" placeholder="http://localhost:11434" value="http://localhost:11434">
<div class="hint">The address of your local LLM server (Ollama, LM Studio, etc).</div>
<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>
<div class="field-error" id="wizBackendUrlError"></div>
</div>
</div>
<div class="wizard-nav">
Expand All @@ -509,9 +519,10 @@ <h1>Welcome to ModelRelay</h1>
<!-- Step 1: Relay Server + Secret -->
<div class="wizard-panel" data-panel="1">
<div class="card">
<div class="form-group">
<div class="form-group" id="wizRelayUrlGroup">
<label for="wizRelayUrl">Relay Server URL</label>
<input type="text" id="wizRelayUrl" placeholder="https://api.modelrelay.io" value="https://api.modelrelay.io">
<div class="field-error" id="wizRelayUrlError"></div>
</div>
<div class="form-group">
<label for="wizWorkerSecret">Worker Secret / API Key</label>
Expand Down Expand Up @@ -616,14 +627,16 @@ <h1>Welcome to ModelRelay</h1>
<!-- Connection section -->
<div class="form-section">
<div class="form-section-title">Connection</div>
<div class="form-group">
<div class="form-group" id="backendUrlGroup">
<label for="backendUrl">Backend URL</label>
<input type="text" id="backendUrl" placeholder="http://localhost:11434">
<div class="hint">Local LLM server address (Ollama, LM Studio, vLLM, etc.)</div>
<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>
<div class="field-error" id="backendUrlError"></div>
</div>
<div class="form-group">
<div class="form-group" id="relayUrlGroup">
<label for="relayUrlSetting">Relay Server URL</label>
<input type="text" id="relayUrlSetting" placeholder="https://api.modelrelay.io">
<div class="field-error" id="relayUrlError"></div>
</div>
<div class="form-group">
<label for="workerSecret">Worker Secret / API Key</label>
Expand Down Expand Up @@ -834,6 +847,50 @@ <h1>Welcome to ModelRelay</h1>
}
}

// 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 {
Expand All @@ -847,16 +904,32 @@ <h1>Welcome to ModelRelay</h1>
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);
Expand Down Expand Up @@ -899,7 +972,21 @@ <h1>Welcome to ModelRelay</h1>
}

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");
Expand All @@ -913,6 +1000,19 @@ <h1>Welcome to ModelRelay</h1>
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();
Expand Down