1package soy.iko.opencode.ui.server
3import androidx.compose.runtime.Immutable
4import androidx.lifecycle.ViewModel
5import androidx.lifecycle.viewModelScope
6import soy.iko.opencode.data.model.ServerProfile
7import soy.iko.opencode.data.network.NetworkConfig
8import soy.iko.opencode.di.AppContainer
9import soy.iko.opencode.di.ProbeResult
10import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.first
14import kotlinx.coroutines.flow.update
15import kotlinx.coroutines.launch
16import kotlinx.coroutines.withTimeoutOrNull
17import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
18import soy.iko.opencode.R
19import soy.iko.opencode.util.runCatchingCancellable
23data class ServerEditState(
24 val id: String? = null,
25 val label: String = "",
26 val baseUrl: String = "",
27 val username: String = "",
28 val password: String = "",
29 /** Upgrade cleartext http:// to https:// for this server. */
30 val requireHttps: Boolean = false,
31 /** Optional OkHttp certificate pin(s), "sha256/<base64>" (whitespace/comma separated). */
32 val certPin: String = "",
33 val loaded: Boolean = false,
34 val saving: Boolean = false,
35 val error: String? = null,
36 /** Whether the auth (username/password) fields are shown. */
37 val authFieldsVisible: Boolean = false,
38 /** Snapshot of the profile as loaded (for dirty detection). Null until loaded. */
39 val initial: InitialProfile? = null,
40 /** Whether a credential test is in progress. */
41 val testingCredentials: Boolean = false,
42 /** Non-null when the last credential test succeeded (true) or failed (false). */
43 val credentialsResult: Boolean? = null,
45 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl) && certPinValid
46 val isNew: Boolean get() = id == null
47 /** A blank pin is fine (feature off); otherwise every entry must be a valid OkHttp pin. */
48 val certPinValid: Boolean get() = isValidCertPin(certPin)
49 /** True if any field differs from its loaded value. */
52 val init = initial ?: return false
53 return label.trim() != init.label ||
54 baseUrl.trim() != init.baseUrl ||
55 username.trim() != init.username ||
56 password.trim() != init.password ||
57 requireHttps != init.requireHttps ||
58 certPin.trim() != init.certPin
62/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
63data class InitialProfile(
68 val requireHttps: Boolean = false,
69 val certPin: String = "",
72/** True when the certificate-pin field is empty (feature off) or every whitespace/comma
73 * separated entry is a well-formed OkHttp "sha256/<base64>" (or legacy "sha1/<base64>") pin. */
74fun isValidCertPin(raw: String): Boolean {
75 val entries = raw.split(Regex("[\\s,]+")).map { it.trim() }.filter { it.isNotEmpty() }
76 if (entries.isEmpty()) return true
77 // Require a well-formed sha256/ or sha1/ <base64>= pin (at least one base64 char before the
78 // padding). This rejects obviously malformed entries like "sha256/=" or "sha256/===" and
79 // unsupported algorithms like "md5/…". sha1 is accepted because OkHttp's CertificatePinner
80 // accepts it too — the validator must match what the runtime accepts, or a well-formed
81 // sha1 pin would be un-saveable. A real pin is 43 base64 chars + '=', but we accept any
82 // length for demo/test pins.
83 val pinRegex = Regex("(sha256|sha1)/[A-Za-z0-9+/]+=")
84 return entries.all { it.matches(pinRegex) }
87fun isValidUrl(url: String): Boolean {
88 val trimmed = url.trim()
89 if (trimmed.isBlank()) return false
90 // Parse with OkHttp's own parser (like HttpClientFactory) — java.net.URI rejects hosts
91 // OkHttp accepts (e.g. underscores in "my_server.local") and demands an exact-lowercase
92 // scheme ("HTTP://" fails). toHttpUrlOrNull only accepts http/https, so a successful parse
93 // already implies a valid scheme; a bare host without a scheme returns null, keeping the
94 // "suggest http:// prefix" flow in suggestUrlScheme() intact.
95 return trimmed.toHttpUrlOrNull() != null
99 * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
100 * return a scheme-prefixed form (only when it parses to a valid URL). Lets the UI offer a
101 * one-tap fix instead of a generic "invalid URL" error.
103 * Picks `https://` for shapes that imply TLS by convention (explicit :443 port, or a
104 * public-looking domain with no port) so the quick-fix doesn't nudge users onto cleartext.
105 * Local/LAN hosts (localhost, private IP ranges, `*.local`) stay `http://` — the common
106 * `opencode serve` case over the LAN.
108fun suggestUrlScheme(input: String): String? {
109 val trimmed = input.trim()
110 if (trimmed.isEmpty() || "://" in trimmed) return null
111 val scheme = if (looksLikeTls(trimmed)) "https" else "http"
112 val candidate = "$scheme://$trimmed"
113 return if (isValidUrl(candidate)) candidate else null
116private fun looksLikeTls(input: String): Boolean {
117 val host = input.substringBefore('/').lowercase()
118 val hostNoPort = host.substringBefore(':')
119 val port = host.substringAfter(':', missingDelimiterValue = "")
120 if (port == "443") return true
121 if (port.isNotEmpty()) return false
122 if (hostNoPort == "localhost") return false
123 if (isPrivateHost(hostNoPort)) return false
124 // A dotless single-label host (e.g. an mDNS/Bonjour name on some setups) is ambiguous;
125 // keep cleartext as the LAN-friendly default rather than suggesting TLS.
126 if ('.' !in hostNoPort) return false
130private fun isPrivateHost(host: String): Boolean {
131 if (host.endsWith(".local")) return true
132 val octets = host.split('.')
133 if (octets.size == 4 && octets.all { it.toIntOrNull() in 0..255 }) {
134 val a = octets[0].toInt()
135 val b = octets[1].toInt()
136 if (a == 10) return true
137 if (a == 192 && b == 168) return true
138 if (a == 172 && b in 16..31) return true
139 if (a == 127) return true
140 if (a == 169 && b == 254) return true
145class ServerEditViewModel(
146 private val container: AppContainer,
147 private val profileId: String?,
148 private val sourceId: String? = null,
151 private val _state = MutableStateFlow(ServerEditState(id = profileId))
152 val state: StateFlow<ServerEditState> = _state.asStateFlow()
155 viewModelScope.launch {
156 if (profileId != null) {
157 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
158 container.profileStore.profiles
159 .first { list -> list.any { p -> p.id == profileId } }
160 .firstOrNull { it.id == profileId }
162 if (existing != null) {
163 // FIX 21(a): normalize (trim) the dirty-snapshot fields the same way
164 // save() stores them, so a stored value with surrounding whitespace
165 // doesn't make the freshly-opened editor spuriously dirty (isDirty
166 // compares field.trim() against these).
167 val init = InitialProfile(
168 label = existing.label.trim(),
169 baseUrl = existing.baseUrl.trim(),
170 username = existing.username.orEmpty().trim(),
171 password = existing.password.orEmpty().trim(),
172 requireHttps = existing.requireHttps,
173 certPin = existing.certPin.orEmpty().trim(),
175 _state.value = ServerEditState(
177 label = existing.label,
178 baseUrl = existing.baseUrl,
179 username = existing.username.orEmpty(),
180 password = existing.password.orEmpty(),
181 requireHttps = existing.requireHttps,
182 certPin = existing.certPin.orEmpty(),
184 authFieldsVisible = existing.hasAuth,
189 _state.value = ServerEditState(
192 error = container.string(R.string.error_load_timeout),
196 // Duplicate: seed the form from an existing profile but as a NEW profile
197 // (id stays null so save() generates a fresh one). Appends " (copy)" to the
198 // label so the user can tell the duplicate from the original at a glance.
199 if (sourceId != null) {
200 val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
201 container.profileStore.profiles
202 .first { list -> list.any { p -> p.id == sourceId } }
203 .firstOrNull { it.id == sourceId }
205 if (source != null) {
206 val dupLabel = if (source.label.isBlank()) source.baseUrl + " (copy)" else "${source.label} (copy)"
207 _state.value = ServerEditState(
210 baseUrl = source.baseUrl,
211 username = source.username.orEmpty(),
212 password = source.password.orEmpty(),
213 requireHttps = source.requireHttps,
214 certPin = source.certPin.orEmpty(),
216 authFieldsVisible = source.hasAuth,
217 // Snapshot the seeded (copy) values so an untouched duplicate isn't
218 // immediately dirty — an empty initial would make backing out of a
219 // freshly opened duplicate always trigger the discard-changes dialog.
220 initial = InitialProfile(
221 label = dupLabel.trim(),
222 baseUrl = source.baseUrl.trim(),
223 username = source.username.orEmpty().trim(),
224 password = source.password.orEmpty().trim(),
225 requireHttps = source.requireHttps,
226 certPin = source.certPin.orEmpty().trim(),
231 // Source not found — fall through to a blank new-profile form.
233 // New-profile form: seed an initial snapshot of empty values so isDirty
234 // becomes true the moment the user types anything.
238 initial = InitialProfile("", "", "", ""),
244 fun update(transform: (ServerEditState) -> ServerEditState) {
245 // Clear both the inline credential note and the bottom-of-form error when any field
246 // changes: once the user starts editing (e.g. correcting a rejected password), a stale
247 // "Credentials rejected" / test-failure message no longer describes the current input.
248 _state.update { transform(it).copy(credentialsResult = null, error = null) }
252 * Populate the form from a pasted/decoded QR payload (a JSON blob the Share-as-QR flow
253 * produces) or an `opencode://server/...` link. Returns true when [text] was recognized and
254 * applied so the caller can surface a "fields filled" confirmation. Lets a user transfer a
255 * server config by pasting instead of scanning an image.
257 fun applyQrPayload(text: String): Boolean {
258 val qr = soy.iko.opencode.ui.components.parseQrPayload(text.trim()) ?: return false
261 baseUrl = qr.baseUrl,
262 username = qr.username.orEmpty(),
263 password = qr.password.orEmpty(),
264 requireHttps = qr.requireHttps,
265 certPin = qr.certPin.orEmpty(),
266 credentialsResult = null,
274 * Primary action: save the profile and connect to it. When the server needs auth and the
275 * user hasn't supplied credentials yet, a connectivity probe reveals the username/password
276 * fields instead of connecting with none — folding the former separate "Check connectivity"
277 * step into this single action so the common case is one tap.
279 fun connect(onDone: () -> Unit) {
281 if (!s.canSave || s.saving) return
282 // Credentials already known (an authed profile is loaded, or the user typed some): skip
283 // the probe and go straight to save + connect. A wrong password then surfaces as a
284 // connect error rather than silently re-opening the auth fields.
285 if (s.authFieldsVisible || s.username.isNotBlank() || s.password.isNotBlank()) {
286 saveInternal(connectAfter = true, onDone = onDone)
289 _state.update { it.copy(saving = true, error = null, credentialsResult = null) }
290 // FIX 21(b)/FIX 11: probe the trimmed URL save() would store, and remember it so a stale
291 // result is dropped if the user edited the base URL away while the probe was in flight.
292 val probedUrl = s.baseUrl.trim()
293 val probedRequireHttps = s.requireHttps
294 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
295 viewModelScope.launch {
296 val result = runCatchingCancellable { container.probeServer(probedUrl, probedRequireHttps, probedCertPin) }
297 result.onSuccess { pr ->
298 // Stale result (URL edited away mid-probe): clear the spinner and stop, else
299 // `saving` latches true and blocks re-connecting.
300 if (_state.value.baseUrl.trim() != probedUrl) {
301 _state.update { it.copy(saving = false) }
305 is ProbeResult.NeedsAuth ->
306 _state.update { it.copy(saving = false, authFieldsVisible = true, error = null) }
307 is ProbeResult.Unreachable ->
308 _state.update { it.copy(saving = false, error = pr.error) }
309 is ProbeResult.Reachable -> {
310 // Reachable without auth: clear the probe-owned saving flag and hand off
311 // to the normal save+connect path (saveInternal re-sets saving itself).
312 _state.update { it.copy(saving = false) }
313 saveInternal(connectAfter = true, onDone = onDone)
318 if (it.baseUrl.trim() != probedUrl) it.copy(saving = false)
319 else it.copy(saving = false, error = container.friendlyError(e))
326 * Probe the server *with* the entered credentials to validate them before saving.
327 * Only meaningful once auth fields are visible (the server requires auth); calling
328 * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
329 * so the UI can show success/failure without dismissing the form.
331 fun testCredentials() {
333 if (!s.canSave || s.testingCredentials) return
334 if (s.username.isBlank() && s.password.isBlank()) return
335 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
336 // FIX 21(b): test the trimmed URL/credentials that save() would actually store.
337 // FIX 11: remember the probed URL so a stale result is dropped if the user
338 // edited the base URL away while the test was in flight.
339 val probedUrl = s.baseUrl.trim()
340 val probedUser = s.username.trim()
341 val probedPass = s.password.trim()
342 val probedRequireHttps = s.requireHttps
343 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
344 viewModelScope.launch {
345 // Bound the probe so a hung/unresponsive server doesn't leave the "Testing…"
346 // spinner spinning indefinitely. A timeout surfaces as a failure (the probe
347 // didn't confirm credentials), not a silent hang.
348 val result = runCatchingCancellable {
349 withTimeoutOrNull(NetworkConfig.testCredentialsTimeoutMs) {
350 container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
353 result.onSuccess { ok ->
355 // Stale result: clear the spinner but drop the verdict (see probe()).
356 if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
358 testingCredentials = false,
359 credentialsResult = ok,
360 error = if (ok == true) null else container.string(R.string.credentials_rejected),
365 if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingCredentials = false)
367 testingCredentials = false,
368 credentialsResult = false,
369 error = container.friendlyError(e),
376 /** Secondary action: persist the profile without connecting (e.g. setting up offline). */
377 fun save(onDone: () -> Unit) {
378 saveInternal(connectAfter = false, onDone = onDone)
381 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
383 if (!s.canSave || s.saving) return
384 _state.update { it.copy(saving = true) }
385 viewModelScope.launch {
386 val result = runCatchingCancellable {
387 val existingLastUsed = if (s.id != null) {
388 withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
389 container.profileStore.profiles.first()
390 .firstOrNull { it.id == s.id }?.lastUsed
393 val saved = ServerProfile(
394 id = s.id ?: UUID.randomUUID().toString(),
395 label = s.label.trim(),
396 baseUrl = s.baseUrl.trim(),
397 username = s.username.trim().takeIf { it.isNotBlank() },
398 password = s.password.trim().takeIf { it.isNotEmpty() },
399 lastUsed = existingLastUsed,
400 requireHttps = s.requireHttps,
401 certPin = s.certPin.trim().takeIf { it.isNotBlank() },
403 container.profileStore.save(saved)
404 // FIX 3: persist the (possibly freshly-generated) id and the normalized
405 // values back into state right after the save succeeds — before the
406 // connect/ping that may throw. Otherwise a retry after a connect failure
407 // would see id == null again, mint a new UUID and create a DUPLICATE
408 // profile instead of upserting the same row. Also refresh the dirty
409 // snapshot so the just-saved values no longer read as unsaved changes.
413 initial = InitialProfile(
415 baseUrl = saved.baseUrl,
416 username = saved.username.orEmpty(),
417 password = saved.password.orEmpty(),
418 requireHttps = saved.requireHttps,
419 certPin = saved.certPin.orEmpty(),
424 container.connect(saved)
425 // Await the ping and treat a failure as a failed save: without this, a server
426 // that accepts the connect but is half-up (wrong path, unresponsive /info) would
427 // navigate to the session list as if everything succeeded, leaving the user
428 // stranded on an empty screen with no error.
429 val pingOk = runCatchingCancellable { container.activeConnection.value?.api?.ping() }.isSuccess
431 throw java.io.IOException(container.string(R.string.error_ping_failed))
435 result.onSuccess { onDone() }
436 .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list