@@ -95,62 +95,35 @@ fun isValidCertPin(raw: String): Boolean {
9595 return entries.all { it.matches(pinRegex) }
9696}
9797
98−fun isValidUrl(url: String): Boolean {
99− val trimmed = url.trim()
100− if (trimmed.isBlank()) return false
101− // Parse with OkHttp's own parser (like HttpClientFactory) — java.net.URI rejects hosts
102− // OkHttp accepts (e.g. underscores in "my_server.local") and demands an exact-lowercase
103− // scheme ("HTTP://" fails). toHttpUrlOrNull only accepts http/https, so a successful parse
104− // already implies a valid scheme; a bare host without a scheme returns null, keeping the
105− // "suggest http:// prefix" flow in suggestUrlScheme() intact.
106− return trimmed.toHttpUrlOrNull() != null
107−}
108−
10998/**
110− * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
111− * return a scheme-prefixed form (only when it parses to a valid URL). Lets the UI offer a
112− * one-tap fix instead of a generic "invalid URL" error.
113− *
114− * Picks `https://` for shapes that imply TLS by convention (explicit :443 port, or a
115− * public-looking domain with no port) so the quick-fix doesn't nudge users onto cleartext.
116− * Local/LAN hosts (localhost, private IP ranges, `*.local`) stay `http://` — the common
117− * `opencode serve` case over the LAN.
99+ * Return [input] with an `https://` scheme prepended when it has no scheme, mirroring
100+ * [HttpClientFactory.normalizeBaseUrl]. Lets the user type a bare host ("example.com",
101+ * "192.168.1.10:4096") without manually prefixing a protocol; we always assume HTTPS.
102+ * Pass through unchanged when [input] already has a scheme.
118103 */
119−fun suggestUrlScheme(input: String): String? {
104+fun normalizeForSave(input: String): String {
120105 val trimmed = input.trim()
121− if (trimmed.isEmpty() || "://" in trimmed) return null
122− val scheme = if (looksLikeTls(trimmed)) "https" else "http"
123− val candidate = "$scheme://$trimmed"
124− return if (isValidUrl(candidate)) candidate else null
125−}
126−
127−private fun looksLikeTls(input: String): Boolean {
128− val host = input.substringBefore('/').lowercase()
129− val hostNoPort = host.substringBefore(':')
130− val port = host.substringAfter(':', missingDelimiterValue = "")
131− if (port == "443") return true
132− if (port.isNotEmpty()) return false
133− if (hostNoPort == "localhost") return false
134− if (isPrivateHost(hostNoPort)) return false
135− // A dotless single-label host (e.g. an mDNS/Bonjour name on some setups) is ambiguous;
136− // keep cleartext as the LAN-friendly default rather than suggesting TLS.
137− if ('.' !in hostNoPort) return false
138− return true
106+ val lower = trimmed.lowercase()
107+ return if (lower.startsWith("http://") || lower.startsWith("https://")) {
108+ trimmed
109+ } else {
110+ "https://$trimmed"
111+ }
139112}
140113
141−private fun isPrivateHost(host: String): Boolean {
142− if (host.endsWith(".local")) return true
143− val octets = host.split('.')
144− if (octets.size == 4 && octets.all { it.toIntOrNull() in 0..255 }) {
145− val a = octets[0].toInt()
146− val b = octets[1].toInt()
147− if (a == 10) return true
148− if (a == 192 && b == 168) return true
149− if (a == 172 && b in 16..31) return true
150− if (a == 127) return true
151− if (a == 169 && b == 254) return true
152− }
153− return false
114+fun isValidUrl(url: String): Boolean {
115+ val trimmed = url.trim()
116+ if (trimmed.isBlank()) return false
117+ // Accept any URL OkHttp can parse, *or* a schemeless bare host that we can prefix with
118+ // https:// ourselves. This lets the user type "example.com" or "192.168.1.10:4096"
119+ // without a protocol: canSave flips true and Save/Connect prepend https:// via
120+ // [normalizeForSave]. A non-http scheme like "ftp://…" is rejected explicitly so we
121+ // don't accidentally accept "ftp://example.com" by prepending https:// (which would
122+ // parse as host="ftp", path="//example.com"). Truly invalid input ("not a url") still
123+ // fails here so canSave stays false.
124+ if (trimmed.toHttpUrlOrNull() != null) return true
125+ if ("://" in trimmed) return false
126+ return "https://$trimmed".toHttpUrlOrNull() != null
154127}
155128
156129class ServerEditViewModel(
@@ -278,7 +251,9 @@ class ServerEditViewModel(
278251 _state.update { it.copy(saving = true, error = null, credentialsResult = null) }
279252 // FIX 21(b)/FIX 11: probe the trimmed URL save() would store, and remember it so a stale
280253 // result is dropped if the user edited the base URL away while the probe was in flight.
281− val probedUrl = s.baseUrl.trim()
254+ // normalizeForSave applies the same scheme prefix save() would persist, so a bare host
255+ // typed without http:// is probed at the URL it'll actually be saved as.
256+ val probedUrl = normalizeForSave(s.baseUrl)
282257 val probedRequireHttps = s.requireHttps
283258 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
284259 viewModelScope.launch {
@@ -286,7 +261,7 @@ class ServerEditViewModel(
286261 result.onSuccess { pr ->
287262 // Stale result (URL edited away mid-probe): clear the spinner and stop, else
288263 // `saving` latches true and blocks re-connecting.
289− if (_state.value.baseUrl.trim() != probedUrl) {
264+ if (normalizeForSave(_state.value.baseUrl) != probedUrl) {
290265 _state.update { it.copy(saving = false) }
291266 return@onSuccess
292267 }
@@ -304,7 +279,7 @@ class ServerEditViewModel(
304279 }
305280 }.onFailure { e ->
306281 _state.update {
307− if (it.baseUrl.trim() != probedUrl) it.copy(saving = false)
282+ if (normalizeForSave(it.baseUrl) != probedUrl) it.copy(saving = false)
308283 else it.copy(saving = false, error = container.friendlyError(e))
309284 }
310285 }
@@ -325,7 +300,7 @@ class ServerEditViewModel(
325300 // FIX 21(b): test the trimmed URL/credentials that save() would actually store.
326301 // FIX 11: remember the probed URL so a stale result is dropped if the user
327302 // edited the base URL away while the test was in flight.
328− val probedUrl = s.baseUrl.trim()
303+ val probedUrl = normalizeForSave(s.baseUrl)
329304 val probedUser = s.username.trim()
330305 val probedPass = s.password.trim()
331306 val probedRequireHttps = s.requireHttps
@@ -342,7 +317,7 @@ class ServerEditViewModel(
342317 result.onSuccess { ok ->
343318 _state.update {
344319 // Stale result: clear the spinner but drop the verdict (see probe()).
345− if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
320+ if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingCredentials = false)
346321 it.copy(
347322 testingCredentials = false,
348323 credentialsResult = ok,
@@ -351,7 +326,7 @@ class ServerEditViewModel(
351326 }
352327 }.onFailure { e ->
353328 _state.update {
354− if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
329+ if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingCredentials = false)
355330 it.copy(
356331 testingCredentials = false,
357332 credentialsResult = false,
@@ -377,7 +352,7 @@ class ServerEditViewModel(
377352 val s = _state.value
378353 if (!s.canSave || s.testingConnection) return
379354 _state.update { it.copy(testingConnection = true, connectionResult = null, error = null) }
380− val probedUrl = s.baseUrl.trim()
355+ val probedUrl = normalizeForSave(s.baseUrl)
381356 val probedRequireHttps = s.requireHttps
382357 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
383358 viewModelScope.launch {
@@ -389,7 +364,7 @@ class ServerEditViewModel(
389364 }
390365 val latency = System.currentTimeMillis() - start
391366 _state.update {
392− if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingConnection = false)
367+ if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingConnection = false)
393368 val verdict = result.fold(
394369 onSuccess = { probe ->
395370 when (probe) {
@@ -436,6 +411,10 @@ class ServerEditViewModel(
436411 val s = _state.value
437412 if (!s.canSave || s.saving) return
438413 _state.update { it.copy(saving = true) }
414+ // Prepend https:// to a bare host so the saved profile is immediately connectable
415+ // and re-opening the editor doesn't re-show an "add scheme" hint. The user can still
416+ // opt into cleartext by typing http:// explicitly.
417+ val savedBaseUrl = normalizeForSave(s.baseUrl)
439418 viewModelScope.launch {
440419 val result = runCatchingCancellable {
441420 // Duplicate detection: warn when a different profile with the same URL
@@ -445,7 +424,7 @@ class ServerEditViewModel(
445424 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
446425 container.profileStore.profiles.first()
447426 } ?: emptyList()
448− val duplicate = existing.any { it.id != s.id && it.baseUrl == s.baseUrl.trim() && it.label == s.label.trim() }
427+ val duplicate = existing.any { it.id != s.id && it.baseUrl == savedBaseUrl && it.label == s.label.trim() }
449428 if (duplicate) {
450429 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
451430 }
@@ -462,7 +441,7 @@ class ServerEditViewModel(
462441 val saved = ServerProfile(
463442 id = s.id ?: UUID.randomUUID().toString(),
464443 label = s.label.trim(),
465− baseUrl = s.baseUrl.trim(),
444+ baseUrl = savedBaseUrl,
466445 username = s.username.trim().takeIf { it.isNotBlank() },
467446 password = s.password.trim().takeIf { it.isNotEmpty() },
468447 lastUsed = existingLastUsed,
@@ -476,9 +455,12 @@ class ServerEditViewModel(
476455 // would see id == null again, mint a new UUID and create a DUPLICATE
477456 // profile instead of upserting the same row. Also refresh the dirty
478457 // snapshot so the just-saved values no longer read as unsaved changes.
458+ // The base URL is updated to the schemed form too, so a freshly-saved
459+ // bare-host entry doesn't read as dirty against its own saved value.
479460 _state.update {
480461 it.copy(
481462 id = saved.id,
463+ baseUrl = saved.baseUrl,
482464 initial = InitialProfile(
483465 label = saved.label,
484466 baseUrl = saved.baseUrl,