@@ -95,62 +95,35 @@ fun isValidCertPin(raw: String): Boolean {
95 return entries.all { it.matches(pinRegex) }
95 return entries.all { it.matches(pinRegex) }
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
110− * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
99+ * Return [input] with an `https://` scheme prepended when it has no scheme, mirroring
111− * return a scheme-prefixed form (only when it parses to a valid URL). Lets the UI offer a
100+ * [HttpClientFactory.normalizeBaseUrl]. Lets the user type a bare host ("example.com",
112− * one-tap fix instead of a generic "invalid URL" error.
101+ * "192.168.1.10:4096") without manually prefixing a protocol; we always assume HTTPS.
113− *
102+ * Pass through unchanged when [input] already has a scheme.
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.
119−fun suggestUrlScheme(input: String): String? {
104+fun normalizeForSave(input: String): String {
120 val trimmed = input.trim()
105 val trimmed = input.trim()
121− if (trimmed.isEmpty() || "://" in trimmed) return null
106+ val lower = trimmed.lowercase()
122− val scheme = if (looksLikeTls(trimmed)) "https" else "http"
107+ return if (lower.startsWith("http://") || lower.startsWith("https://")) {
123− val candidate = "$scheme://$trimmed"
108+ trimmed
124− return if (isValidUrl(candidate)) candidate else null
109+ } else {
125−}
110+ "https://$trimmed"
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
141−private fun isPrivateHost(host: String): Boolean {
114+fun isValidUrl(url: String): Boolean {
142− if (host.endsWith(".local")) return true
115+ val trimmed = url.trim()
143− val octets = host.split('.')
116+ if (trimmed.isBlank()) return false
144− if (octets.size == 4 && octets.all { it.toIntOrNull() in 0..255 }) {
117+ // Accept any URL OkHttp can parse, *or* a schemeless bare host that we can prefix with
145− val a = octets[0].toInt()
118+ // https:// ourselves. This lets the user type "example.com" or "192.168.1.10:4096"
146− val b = octets[1].toInt()
119+ // without a protocol: canSave flips true and Save/Connect prepend https:// via
147− if (a == 10) return true
120+ // [normalizeForSave]. A non-http scheme like "ftp://…" is rejected explicitly so we
148− if (a == 192 && b == 168) return true
121+ // don't accidentally accept "ftp://example.com" by prepending https:// (which would
149− if (a == 172 && b in 16..31) return true
122+ // parse as host="ftp", path="//example.com"). Truly invalid input ("not a url") still
123+ // fails here so canSave stays false.
150− if (a == 127) return true
124+ if (trimmed.toHttpUrlOrNull() != null) return true
151− if (a == 169 && b == 254) return true
125+ if ("://" in trimmed) return false
152− }
126+ return "https://$trimmed".toHttpUrlOrNull() != null
156class ServerEditViewModel(
129class ServerEditViewModel(
@@ -278,7 +251,9 @@ class ServerEditViewModel(
278 _state.update { it.copy(saving = true, error = null, credentialsResult = null) }
251 _state.update { it.copy(saving = true, error = null, credentialsResult = null) }
279 // FIX 21(b)/FIX 11: probe the trimmed URL save() would store, and remember it so a stale
252 // FIX 21(b)/FIX 11: probe the trimmed URL save() would store, and remember it so a stale
280 // result is dropped if the user edited the base URL away while the probe was in flight.
253 // result is dropped if the user edited the base URL away while the probe was in flight.
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.
281− val probedUrl = s.baseUrl.trim()
256+ val probedUrl = normalizeForSave(s.baseUrl)
282 val probedRequireHttps = s.requireHttps
257 val probedRequireHttps = s.requireHttps
283 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
258 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
284 viewModelScope.launch {
259 viewModelScope.launch {
@@ -286,7 +261,7 @@ class ServerEditViewModel(
286 result.onSuccess { pr ->
261 result.onSuccess { pr ->
287 // Stale result (URL edited away mid-probe): clear the spinner and stop, else
262 // Stale result (URL edited away mid-probe): clear the spinner and stop, else
288 // `saving` latches true and blocks re-connecting.
263 // `saving` latches true and blocks re-connecting.
289− if (_state.value.baseUrl.trim() != probedUrl) {
264+ if (normalizeForSave(_state.value.baseUrl) != probedUrl) {
290 _state.update { it.copy(saving = false) }
265 _state.update { it.copy(saving = false) }
291 return@onSuccess
266 return@onSuccess
@@ -304,7 +279,7 @@ class ServerEditViewModel(
305 }.onFailure { e ->
280 }.onFailure { e ->
306 _state.update {
281 _state.update {
307− if (it.baseUrl.trim() != probedUrl) it.copy(saving = false)
282+ if (normalizeForSave(it.baseUrl) != probedUrl) it.copy(saving = false)
308 else it.copy(saving = false, error = container.friendlyError(e))
283 else it.copy(saving = false, error = container.friendlyError(e))
@@ -325,7 +300,7 @@ class ServerEditViewModel(
325 // FIX 21(b): test the trimmed URL/credentials that save() would actually store.
300 // FIX 21(b): test the trimmed URL/credentials that save() would actually store.
326 // FIX 11: remember the probed URL so a stale result is dropped if the user
301 // FIX 11: remember the probed URL so a stale result is dropped if the user
327 // edited the base URL away while the test was in flight.
302 // edited the base URL away while the test was in flight.
328− val probedUrl = s.baseUrl.trim()
303+ val probedUrl = normalizeForSave(s.baseUrl)
329 val probedUser = s.username.trim()
304 val probedUser = s.username.trim()
330 val probedPass = s.password.trim()
305 val probedPass = s.password.trim()
331 val probedRequireHttps = s.requireHttps
306 val probedRequireHttps = s.requireHttps
@@ -342,7 +317,7 @@ class ServerEditViewModel(
342 result.onSuccess { ok ->
317 result.onSuccess { ok ->
343 _state.update {
318 _state.update {
344 // Stale result: clear the spinner but drop the verdict (see probe()).
319 // 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)
347 testingCredentials = false,
322 testingCredentials = false,
348 credentialsResult = ok,
323 credentialsResult = ok,
@@ -351,7 +326,7 @@ class ServerEditViewModel(
352 }.onFailure { e ->
327 }.onFailure { e ->
353 _state.update {
328 _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)
356 testingCredentials = false,
331 testingCredentials = false,
357 credentialsResult = false,
332 credentialsResult = false,
@@ -377,7 +352,7 @@ class ServerEditViewModel(
377 val s = _state.value
352 val s = _state.value
378 if (!s.canSave || s.testingConnection) return
353 if (!s.canSave || s.testingConnection) return
379 _state.update { it.copy(testingConnection = true, connectionResult = null, error = null) }
354 _state.update { it.copy(testingConnection = true, connectionResult = null, error = null) }
380− val probedUrl = s.baseUrl.trim()
355+ val probedUrl = normalizeForSave(s.baseUrl)
381 val probedRequireHttps = s.requireHttps
356 val probedRequireHttps = s.requireHttps
382 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
357 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
383 viewModelScope.launch {
358 viewModelScope.launch {
@@ -389,7 +364,7 @@ class ServerEditViewModel(
390 val latency = System.currentTimeMillis() - start
365 val latency = System.currentTimeMillis() - start
391 _state.update {
366 _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)
393 val verdict = result.fold(
368 val verdict = result.fold(
394 onSuccess = { probe ->
369 onSuccess = { probe ->
395 when (probe) {
370 when (probe) {
@@ -436,6 +411,10 @@ class ServerEditViewModel(
436 val s = _state.value
411 val s = _state.value
437 if (!s.canSave || s.saving) return
412 if (!s.canSave || s.saving) return
438 _state.update { it.copy(saving = true) }
413 _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)
439 viewModelScope.launch {
418 viewModelScope.launch {
440 val result = runCatchingCancellable {
419 val result = runCatchingCancellable {
441 // Duplicate detection: warn when a different profile with the same URL
420 // Duplicate detection: warn when a different profile with the same URL
@@ -445,7 +424,7 @@ class ServerEditViewModel(
445 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
424 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
446 container.profileStore.profiles.first()
425 container.profileStore.profiles.first()
447 } ?: emptyList()
426 } ?: 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() }
449 if (duplicate) {
428 if (duplicate) {
450 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
429 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
@@ -462,7 +441,7 @@ class ServerEditViewModel(
462 val saved = ServerProfile(
441 val saved = ServerProfile(
463 id = s.id ?: UUID.randomUUID().toString(),
442 id = s.id ?: UUID.randomUUID().toString(),
464 label = s.label.trim(),
443 label = s.label.trim(),
465− baseUrl = s.baseUrl.trim(),
444+ baseUrl = savedBaseUrl,
466 username = s.username.trim().takeIf { it.isNotBlank() },
445 username = s.username.trim().takeIf { it.isNotBlank() },
467 password = s.password.trim().takeIf { it.isNotEmpty() },
446 password = s.password.trim().takeIf { it.isNotEmpty() },
468 lastUsed = existingLastUsed,
447 lastUsed = existingLastUsed,
@@ -476,9 +455,12 @@ class ServerEditViewModel(
476 // would see id == null again, mint a new UUID and create a DUPLICATE
455 // would see id == null again, mint a new UUID and create a DUPLICATE
477 // profile instead of upserting the same row. Also refresh the dirty
456 // profile instead of upserting the same row. Also refresh the dirty
478 // snapshot so the just-saved values no longer read as unsaved changes.
457 // 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.
479 _state.update {
460 _state.update {
481 id = saved.id,
462 id = saved.id,
463+ baseUrl = saved.baseUrl,
482 initial = InitialProfile(
464 initial = InitialProfile(
483 label = saved.label,
465 label = saved.label,
484 baseUrl = saved.baseUrl,
466 baseUrl = saved.baseUrl,