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) }
88 * Return [input] with an `https://` scheme prepended when it has no scheme, mirroring
89 * [HttpClientFactory.normalizeBaseUrl]. Lets the user type a bare host ("example.com",
90 * "192.168.1.10:4096") without manually prefixing a protocol; we always assume HTTPS.
91 * Pass through unchanged when [input] already has a scheme.
93fun normalizeForSave(input: String): String {
94 val trimmed = input.trim()
95 val lower = trimmed.lowercase()
96 return if (lower.startsWith("http://") || lower.startsWith("https://")) {
103fun isValidUrl(url: String): Boolean {
104 val trimmed = url.trim()
105 if (trimmed.isBlank()) return false
106 // Accept any URL OkHttp can parse, *or* a schemeless bare host that we can prefix with
107 // https:// ourselves. This lets the user type "example.com" or "192.168.1.10:4096"
108 // without a protocol: canSave flips true and Save/Connect prepend https:// via
109 // [normalizeForSave]. A non-http scheme like "ftp://…" is rejected explicitly so we
110 // don't accidentally accept "ftp://example.com" by prepending https:// (which would
111 // parse as host="ftp", path="//example.com"). Truly invalid input ("not a url") still
112 // fails here so canSave stays false.
113 if (trimmed.toHttpUrlOrNull() != null) return true
114 if ("://" in trimmed) return false
115 return "https://$trimmed".toHttpUrlOrNull() != null
118/** Derive a default display label from a base URL: the host plus port when non-default.
119 * Used by the add-server flow which has no label field — the label is auto-derived from
120 * the hostname so the server list can show a readable name without extra user input.
121 * Default ports (80 for http, 443 for https) are omitted; an explicit non-default port
122 * is included so two servers on the same host but different ports are distinguishable. */
123fun hostFromBaseUrl(url: String): String {
124 val normalized = normalizeForSave(url)
125 val httpUrl = normalized.toHttpUrlOrNull() ?: return url.trim()
126 val isDefaultPort = httpUrl.port == 80 || httpUrl.port == 443 || httpUrl.port == -1
127 return if (!isDefaultPort) "${httpUrl.host}:${httpUrl.port}" else httpUrl.host
130class ServerEditViewModel(
131 private val container: AppContainer,
134 private val _state = MutableStateFlow(ServerEditState())
135 val state: StateFlow<ServerEditState> = _state.asStateFlow()
138 // Add-only form: seed an initial snapshot of empty values so isDirty becomes true the
139 // moment the user types anything. No existing-profile load, no duplicate seeding, no
140 // delete — editing an existing server lives on the Server Settings screen.
141 viewModelScope.launch {
145 initial = InitialProfile("", "", "", ""),
151 fun update(transform: (ServerEditState) -> ServerEditState) {
152 // Clear both the inline credential note and the bottom-of-form error when any field
153 // changes: once the user starts editing (e.g. correcting a rejected password), a stale
154 // "Credentials rejected" / test-failure message no longer describes the current input.
155 _state.update { transform(it).copy(credentialsResult = null, error = null) }
159 * Primary action: save the profile and connect to it. When the server needs auth and the
160 * user hasn't supplied credentials yet, a connectivity probe reveals the username/password
161 * fields instead of connecting with none — folding the former separate "Check connectivity"
162 * step into this single action so the common case is one tap.
164 fun connect(onDone: () -> Unit) {
166 if (!s.canSave || s.saving) return
167 // Credentials already known (an authed profile is loaded, or the user typed some): skip
168 // the probe and go straight to save + connect. A wrong password then surfaces as a
169 // connect error rather than silently re-opening the auth fields.
170 if (s.authFieldsVisible || s.username.isNotBlank() || s.password.isNotBlank()) {
171 saveInternal(connectAfter = true, onDone = onDone)
174 _state.update { it.copy(saving = true, error = null, credentialsResult = null) }
175 // FIX 21(b)/FIX 11: probe the trimmed URL save() would store, and remember it so a stale
176 // result is dropped if the user edited the base URL away while the probe was in flight.
177 // normalizeForSave applies the same scheme prefix save() would persist, so a bare host
178 // typed without http:// is probed at the URL it'll actually be saved as.
179 val probedUrl = normalizeForSave(s.baseUrl)
180 val probedRequireHttps = s.requireHttps
181 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
182 viewModelScope.launch {
183 val result = runCatchingCancellable { container.probeServer(probedUrl, probedRequireHttps, probedCertPin) }
184 result.onSuccess { pr ->
185 // Stale result (URL edited away mid-probe): clear the spinner and stop, else
186 // `saving` latches true and blocks re-connecting.
187 if (normalizeForSave(_state.value.baseUrl) != probedUrl) {
188 _state.update { it.copy(saving = false) }
192 is ProbeResult.NeedsAuth ->
193 _state.update { it.copy(saving = false, authFieldsVisible = true, error = null) }
194 is ProbeResult.Unreachable ->
195 _state.update { it.copy(saving = false, error = pr.error) }
196 is ProbeResult.Reachable -> {
197 // Reachable without auth: clear the probe-owned saving flag and hand off
198 // to the normal save+connect path (saveInternal re-sets saving itself).
199 _state.update { it.copy(saving = false) }
200 saveInternal(connectAfter = true, onDone = onDone)
205 if (normalizeForSave(it.baseUrl) != probedUrl) it.copy(saving = false)
206 else it.copy(saving = false, error = container.friendlyError(e))
213 * Probe the server *with* the entered credentials to validate them before saving.
214 * Only meaningful once auth fields are visible (the server requires auth); calling
215 * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
216 * so the UI can show success/failure without dismissing the form.
218 fun testCredentials() {
220 if (!s.canSave || s.testingCredentials) return
221 if (s.username.isBlank() && s.password.isBlank()) return
222 _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
223 // FIX 21(b): test the trimmed URL/credentials that save() would actually store.
224 // FIX 11: remember the probed URL so a stale result is dropped if the user
225 // edited the base URL away while the test was in flight.
226 val probedUrl = normalizeForSave(s.baseUrl)
227 val probedUser = s.username.trim()
228 val probedPass = s.password.trim()
229 val probedRequireHttps = s.requireHttps
230 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
231 viewModelScope.launch {
232 // Bound the probe so a hung/unresponsive server doesn't leave the "Testing…"
233 // spinner spinning indefinitely. A timeout surfaces as a failure (the probe
234 // didn't confirm credentials), not a silent hang.
235 val result = runCatchingCancellable {
236 withTimeoutOrNull(NetworkConfig.testCredentialsTimeoutMs) {
237 container.probeWithCredentials(probedUrl, probedUser, probedPass, probedRequireHttps, probedCertPin)
240 result.onSuccess { ok ->
242 // Stale result: clear the spinner but drop the verdict (see probe()).
243 if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingCredentials = false)
245 testingCredentials = false,
246 credentialsResult = ok,
247 error = if (ok == true) null else container.string(R.string.credentials_rejected),
252 if (normalizeForSave(it.baseUrl) != probedUrl) return@update it.copy(testingCredentials = false)
254 testingCredentials = false,
255 credentialsResult = false,
256 error = container.friendlyError(e),
263 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
265 if (!s.canSave || s.saving) return
266 _state.update { it.copy(saving = true) }
267 // Prepend https:// to a bare host so the saved profile is immediately connectable
268 // and re-opening the editor doesn't re-show an "add scheme" hint. The user can still
269 // opt into cleartext by typing http:// explicitly.
270 val savedBaseUrl = normalizeForSave(s.baseUrl)
271 viewModelScope.launch {
272 val result = runCatchingCancellable {
273 // Duplicate detection: warn when a different profile with the same URL
274 // and label already exists, so the user doesn't silently create a second
275 // entry identical to an existing one. The check excludes the profile
276 // being edited (by id) so re-saving an existing profile doesn't trigger.
277 val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
278 container.profileStore.profiles.first()
280 // The add-server form has no label field, so derive a default label from the
281 // hostname (e.g. "192.168.1.10:4096") rather than saving an empty label. The
282 // user can rename it later from Server Settings.
283 val savedLabel = s.label.trim().ifBlank { hostFromBaseUrl(savedBaseUrl) }
284 val duplicate = existing.any { it.id != s.id && it.baseUrl == savedBaseUrl && it.label == savedLabel }
286 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
288 // Preserve the existing profile's lastUsed so re-saving doesn't reset its
289 // sort order. When the timeout fired (existing is empty) and this is an
290 // edit of an existing profile (s.id != null), we can't know the prior
291 // lastUsed — fall back to the current wall-clock time rather than 0 (which
292 // would silently drop the user's most-recently-used server to the bottom of
293 // the server list). A fresh save bumping lastUsed to "now" is the same
294 // behavior connectLocked applies on a real connect, so this is consistent.
295 val existingLastUsed = if (s.id != null) {
296 existing.firstOrNull { it.id == s.id }?.lastUsed ?: System.currentTimeMillis()
298 val saved = ServerProfile(
299 id = s.id ?: UUID.randomUUID().toString(),
301 baseUrl = savedBaseUrl,
302 username = s.username.trim().takeIf { it.isNotBlank() },
303 password = s.password.trim().takeIf { it.isNotEmpty() },
304 lastUsed = existingLastUsed,
305 requireHttps = s.requireHttps,
306 certPin = s.certPin.trim().takeIf { it.isNotBlank() },
308 container.profileStore.save(saved)
309 // FIX 3: persist the (possibly freshly-generated) id and the normalized
310 // values back into state right after the save succeeds — before the
311 // connect/ping that may throw. Otherwise a retry after a connect failure
312 // would see id == null again, mint a new UUID and create a DUPLICATE
313 // profile instead of upserting the same row. Also refresh the dirty
314 // snapshot so the just-saved values no longer read as unsaved changes.
315 // The base URL is updated to the schemed form too, so a freshly-saved
316 // bare-host entry doesn't read as dirty against its own saved value.
321 baseUrl = saved.baseUrl,
322 initial = InitialProfile(
324 baseUrl = saved.baseUrl,
325 username = saved.username.orEmpty(),
326 password = saved.password.orEmpty(),
327 requireHttps = saved.requireHttps,
328 certPin = saved.certPin.orEmpty(),
333 container.connect(saved)
334 // Await the ping and treat a failure as a failed save: without this, a server
335 // that accepts the connect but is half-up (wrong path, unresponsive /info) would
336 // navigate to the session list as if everything succeeded, leaving the user
337 // stranded on an empty screen with no error.
338 // A null activeConnection must NOT count as a successful ping: `null?.api?.ping()`
339 // evaluates to null without throwing, so runCatchingCancellable would wrap it as
340 // Result.success(null) and .isSuccess would be true — masking a connection that
341 // dropped between connect() and the ping and navigating the user to an empty
342 // session list with no error.
343 val pingOk = container.activeConnection.value?.let {
344 runCatchingCancellable { it.api.ping() }.isSuccess
347 throw java.io.IOException(container.string(R.string.error_ping_failed))
351 result.onSuccess { onDone() }
352 .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