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
356 changes: 279 additions & 77 deletions app/src/main/kotlin/app/getarcane/android/core/ArcaneClientManager.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package app.getarcane.android.core

import kotlinx.coroutines.CancellationException

internal data class SavedAuthState(
val serverUrl: String?,
val activeEnvironmentId: String?,
val activeEnvironmentName: String?,
val credentialOrigin: String? = null,
)

/**
* Restores the persisted server and session without exposing the login screen before validation.
*
* Android dependencies stay in [ArcaneClientManager]; keeping the transition coordinator here
* makes startup failure, invalid-session, and cancellation behavior deterministic and testable.
*/
internal suspend fun restoreAuthenticationSession(
loadSavedState: suspend () -> SavedAuthState,
applySavedState: (SavedAuthState) -> Unit,
openSavedServer: (String) -> Unit,
validateSavedSession: suspend () -> Unit,
refreshLoginMethods: suspend () -> Unit,
updateStatus: (AuthStatus) -> Unit,
) {
try {
val savedState = loadSavedState()
applySavedState(savedState)

val savedServer = savedState.serverUrl
if (savedServer.isNullOrBlank()) {
updateStatus(AuthStatus.SETUP)
return
}

openSavedServer(savedServer)
updateStatus(AuthStatus.AUTHENTICATING)

try {
validateSavedSession()
updateStatus(AuthStatus.AUTHENTICATED)
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
updateStatus(AuthStatus.LOGIN)
try {
refreshLoginMethods()
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
// Login remains available even when optional OIDC discovery fails.
}
}
} catch (e: CancellationException) {
updateStatus(AuthStatus.SETUP)
throw e
} catch (_: Throwable) {
updateStatus(AuthStatus.SETUP)
}
}
29 changes: 28 additions & 1 deletion app/src/main/kotlin/app/getarcane/android/core/Prefs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,45 @@ class Prefs(context: Context) {
val accentHex: Flow<String?> = store.data.map { it[ACCENT_HEX] }
val activeEnvId: Flow<String?> = store.data.map { it[ACTIVE_ENV_ID] }
val activeEnvName: Flow<String?> = store.data.map { it[ACTIVE_ENV_NAME] }
val credentialOrigin: Flow<String?> = store.data.map { it[CREDENTIAL_ORIGIN] }

suspend fun setServerUrl(value: String) = store.edit { it[SERVER_URL] = value }.let {}
suspend fun setServerUrl(value: String) = store.edit {
if (it[SERVER_URL] != value) {
it.remove(ACTIVE_ENV_ID)
it.remove(ACTIVE_ENV_NAME)
}
it[SERVER_URL] = value
}.let {}
suspend fun setAccentHex(value: String) = store.edit { it[ACCENT_HEX] = value }.let {}
suspend fun setActiveEnv(id: String, name: String) = store.edit {
it[ACTIVE_ENV_ID] = id
it[ACTIVE_ENV_NAME] = name
}.let {}

suspend fun setCredentialOrigin(origin: String) = store.edit {
it[CREDENTIAL_ORIGIN] = origin
}.let {}

suspend fun clearCredentialOrigin(origin: String) = store.edit {
if (it[CREDENTIAL_ORIGIN] == origin) it.remove(CREDENTIAL_ORIGIN)
}.let {}

suspend fun clearServerState(expectedServerUrl: String, expectedCredentialOrigin: String?) = store.edit {
if (it[SERVER_URL] == expectedServerUrl) {
it.remove(SERVER_URL)
it.remove(ACTIVE_ENV_ID)
it.remove(ACTIVE_ENV_NAME)
}
if (expectedCredentialOrigin != null && it[CREDENTIAL_ORIGIN] == expectedCredentialOrigin) {
it.remove(CREDENTIAL_ORIGIN)
}
}.let {}

companion object {
private val SERVER_URL = stringPreferencesKey("server_url")
private val ACCENT_HEX = stringPreferencesKey("accent_hex")
private val ACTIVE_ENV_ID = stringPreferencesKey("active_env_id")
private val ACTIVE_ENV_NAME = stringPreferencesKey("active_env_name")
private val CREDENTIAL_ORIGIN = stringPreferencesKey("credential_origin")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package app.getarcane.android.core

import app.getarcane.sdk.auth.TokenPair
import app.getarcane.sdk.auth.TokenStore
import kotlinx.coroutines.CancellationException

/**
* Binds SDK token persistence to one canonical server origin.
*
* The legacy store is consulted only during an explicitly allowed upgrade migration. A separate
* credential-origin binding prevents an origin-scoped token from becoming active merely because a
* user enters another server URL.
*/
internal class ServerBoundTokenStore(
private val origin: String,
private val originStore: TokenStore,
private val legacyStore: TokenStore,
private val allowsLegacyMigration: Boolean,
private val credentialOrigin: suspend () -> String?,
private val bindCredentialOrigin: suspend (String) -> Unit,
private val unbindCredentialOrigin: suspend (String) -> Unit,
) : TokenStore {
override suspend fun loadTokens(): TokenPair? {
val boundOrigin = credentialOrigin()
val canUseOrigin = boundOrigin == origin || (boundOrigin == null && allowsLegacyMigration)
if (!canUseOrigin) return null

originStore.loadTokens()?.let { tokens ->
if (boundOrigin == null) bindCredentialOrigin(origin)
return tokens
}
if (!allowsLegacyMigration) return null

val legacyTokens = legacyStore.loadTokens() ?: return null
originStore.saveTokens(legacyTokens)
legacyStore.clearTokens()
bindCredentialOrigin(origin)
return legacyTokens
}

override suspend fun saveTokens(tokens: TokenPair) {
originStore.saveTokens(tokens)
bindCredentialOrigin(origin)
}

override suspend fun clearTokens() {
var firstFailure: Throwable? = null
for (store in listOf(originStore, legacyStore)) {
try {
store.clearTokens()
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
if (firstFailure == null) firstFailure = failure
}
}
unbindCredentialOrigin(origin)
firstFailure?.let { throw it }
}
}
38 changes: 38 additions & 0 deletions app/src/main/kotlin/app/getarcane/android/core/ServerIdentity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package app.getarcane.android.core

import java.net.URI
import java.security.MessageDigest

internal data class ServerIdentity(
val normalizedUrl: String,
val canonicalOrigin: String,
val tokenAccount: String,
)

internal object ServerIdentities {
fun from(rawUrl: String): ServerIdentity? {
val normalizedUrl = ServerUrl.normalize(rawUrl) ?: return null
val uri = URI(normalizedUrl)
val scheme = uri.scheme ?: return null
val host = uri.host ?: return null
val port = uri.port.takeIf { it >= 0 } ?: when (scheme) {
"http" -> 80
"https" -> 443
else -> return null
}
val authorityHost = if (host.contains(':') && !host.startsWith('[')) "[$host]" else host
val path = uri.rawPath.orEmpty().let { if (it == "/") "" else it }
val canonicalOrigin = "$scheme://$authorityHost:$port$path"

return ServerIdentity(
normalizedUrl = normalizedUrl,
canonicalOrigin = canonicalOrigin,
tokenAccount = "server.${canonicalOrigin.sha256()}",
)
}
}

private fun String.sha256(): String =
MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
.joinToString(separator = "") { byte -> "%02x".format(byte) }
23 changes: 18 additions & 5 deletions app/src/main/kotlin/app/getarcane/android/core/ServerUrl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,29 @@ object ServerUrl {
return null
}

val scheme = uri.scheme?.takeIf { it.isNotBlank() } ?: return null
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
val scheme = uri.scheme
?.lowercase(Locale.US)
?.takeIf { it == "http" || it == "https" }
?: return null
if (uri.rawUserInfo != null) return null
val host = uri.host
?.lowercase(Locale.US)
?.trimEnd('.')
?.takeIf { it.isNotBlank() }
?: return null
val port = when {
scheme == "http" && uri.port == 80 -> -1
scheme == "https" && uri.port == 443 -> -1
else -> uri.port
}
val normalizedPath = normalizePath(uri.rawPath)

return try {
URI(
scheme.lowercase(Locale.US),
uri.rawUserInfo,
scheme,
null,
host,
uri.port,
port,
normalizedPath.ifEmpty { null },
null,
null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.getarcane.android.core.LocalArcaneManager
import app.getarcane.android.ui.components.ContentUnavailable

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PortDetailScreen(portId: String, onBack: () -> Unit) {
val port = PortStore.get(portId)
val manager = LocalArcaneManager.current
val port = PortStore.get(manager.serverSessionIdentity, portId)
val title = port?.containerName ?: "Port"

Scaffold(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,22 @@ fun PortListScreen(onOpen: (String) -> Unit) {
val manager = LocalArcaneManager.current
val client = manager.client
val envId = manager.activeEnvironmentId
val serverIdentity = manager.serverSessionIdentity

var state by remember { mutableStateOf<Loadable<List<PortMapping>>>(Loadable.Loading) }
var search by remember { mutableStateOf("") }
var refreshKey by remember { mutableStateOf(0) }
var refreshing by remember { mutableStateOf(false) }

LaunchedEffect(envId.rawValue, refreshKey) {
LaunchedEffect(serverIdentity, envId.rawValue, refreshKey) {
if (client == null) return@LaunchedEffect
if (state !is Loadable.Success) state = Loadable.Loading
state = try {
val ports = client.ports.list(
envId = envId,
query = SearchPaginationSort(start = 0, limit = 500)
).data
PortStore.put(ports)
PortStore.put(serverIdentity, ports)
Loadable.Success(ports)
} catch (e: Throwable) {
Loadable.Error(friendlyErrorMessage(e))
Expand Down Expand Up @@ -300,10 +301,19 @@ internal fun protocolTint(protocol: String): Color = when (protocol.lowercase())
* list -> detail navigation within the Ports tab.
*/
internal object PortStore {
private var serverIdentity: String = ""
private var byId: Map<String, PortMapping> = emptyMap()
fun put(ports: List<PortMapping>) {

fun put(serverIdentity: String, ports: List<PortMapping>) {
this.serverIdentity = serverIdentity
byId = ports.associateBy { it.id }
}

fun get(id: String): PortMapping? = byId[id]
fun get(serverIdentity: String, id: String): PortMapping? =
if (this.serverIdentity == serverIdentity) byId[id] else null

fun clear() {
serverIdentity = ""
byId = emptyMap()
}
}
Loading
Loading