diff --git a/README.md b/README.md index f7e51c9..6ed1ff9 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ Arcane Mobile is the official Android companion for [Arcane](https://github.com/getarcaneapp/arcane). It connects to any Arcane manager or agent and lets you browse and operate your Docker environments — containers, images, volumes, networks, and Compose projects — from your phone. +The parity target is the core mobile companion outcome, not every Arcane web administration feature. +Optional expansion and advanced administration remain explicitly tracked in issues +[#62](https://github.com/getarcaneapp/android/issues/62)–[#65](https://github.com/getarcaneapp/android/issues/65). + ## Documentation For setup instructions, configuration details, and development guides, visit the **[official documentation site](https://getarcane.app/docs)**. diff --git a/app/src/main/kotlin/app/getarcane/android/core/AccessSurfaceReachability.kt b/app/src/main/kotlin/app/getarcane/android/core/AccessSurfaceReachability.kt new file mode 100644 index 0000000..2240940 --- /dev/null +++ b/app/src/main/kotlin/app/getarcane/android/core/AccessSurfaceReachability.kt @@ -0,0 +1,27 @@ +package app.getarcane.android.core + +import app.getarcane.android.nav.AppTab +import app.getarcane.sdk.models.role.Permission +import app.getarcane.sdk.models.role.PermissionsManifest +import app.getarcane.sdk.models.user.User +import app.getarcane.sdk.models.user.hasPermission +import app.getarcane.sdk.models.user.isGlobalAdmin + +internal fun canAccessTab( + tab: AppTab, + user: User, + supportsV2: Boolean, + manifest: PermissionsManifest?, + environmentId: String, + allowLegacyFallback: Boolean = true, +): Boolean { + if (tab.requiresV2 && !supportsV2) return false + if (supportsV2 && manifest?.accessSurfaces?.isNotEmpty() == true && tab.accessSurfaceIds.isNotEmpty()) { + return tab.accessSurfaceIds.any { manifest.canAccessSurface(it, user, environmentId) } + } + if (supportsV2 && tab.accessSurfaceIds.isNotEmpty() && !allowLegacyFallback) return false + if (tab == AppTab.Variables && user.permissionsByEnv != null) { + return user.hasPermission(Permission.Variables.READ) + } + return !tab.requiresAdmin || user.isGlobalAdmin +} diff --git a/app/src/main/kotlin/app/getarcane/android/core/ArcaneClientManager.kt b/app/src/main/kotlin/app/getarcane/android/core/ArcaneClientManager.kt index 33f5914..cb8e97d 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/ArcaneClientManager.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/ArcaneClientManager.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import app.getarcane.android.nav.MainTabSelectionStore import app.getarcane.android.nav.ArcaneShortcutPublisher +import app.getarcane.android.nav.AppTab import app.getarcane.android.BuildConfig import app.getarcane.android.widget.FleetStatusWidgetUpdater import app.getarcane.sdk.ArcaneClient @@ -25,6 +26,7 @@ import app.getarcane.sdk.models.auth.MFAChallenge import app.getarcane.sdk.models.auth.OidcStatusInfo import app.getarcane.sdk.models.auth.PasskeySummary import app.getarcane.sdk.models.auth.StepUpGrant +import app.getarcane.sdk.models.role.PermissionsManifest import app.getarcane.sdk.models.user.User import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.okhttp.OkHttp @@ -121,13 +123,17 @@ class ArcaneClientManager(context: Context) { var currentUser by mutableStateOf(null); private set internal var offlineReadSessionActive by mutableStateOf(false); private set var capabilities by mutableStateOf(ServerCapabilities.UNKNOWN); private set + var permissionsManifest by mutableStateOf(null); private set + private var allowLegacyAccessSurfaceFallback by mutableStateOf(true) var supportsPost26MobileFeatures by mutableStateOf(false); private set var supportsProjectWorkspaceContract by mutableStateOf(false); private set var supportsContainerReliabilityActions by mutableStateOf(false); private set + var supportsContainerManagementWorkflows by mutableStateOf(false); private set var isLoading by mutableStateOf(false); private set var errorMessage by mutableStateOf(null); private set private var authenticationMethods by mutableStateOf(AuthenticationMethodAvailability()) val oidc: OidcStatusInfo? get() = authenticationMethods.oidcStatus + val localAuthState: AuthenticationMethodState get() = authenticationMethods.localState val oidcLoginState: AuthenticationMethodState get() = authenticationMethods.oidcState val passkeyLoginState: AuthenticationMethodState get() = authenticationMethods.passkeyLoginState val passkeyBridgeState: AuthenticationMethodState get() = authenticationMethods.passkeyBridgeState @@ -210,11 +216,14 @@ class ArcaneClientManager(context: Context) { val restoredUser = c.auth.me() val detectedCapabilities = c.serverCapabilities() val mobileFeatures = detectMobileFeatures(c) + val manifestResult = loadPermissionsManifest(c, detectedCapabilities) currentUser = restoredUser capabilities = detectedCapabilities + applyPermissionsManifest(manifestResult) supportsPost26MobileFeatures = mobileFeatures.post26 supportsProjectWorkspaceContract = mobileFeatures.projectWorkspace supportsContainerReliabilityActions = mobileFeatures.containerReliabilityActions + supportsContainerManagementWorkflows = mobileFeatures.containerManagement } }, refreshLoginMethods = ::refreshLoginMethods, @@ -394,11 +403,14 @@ class ArcaneClientManager(context: Context) { val user = activeClient.auth.me() val detectedCapabilities = activeClient.serverCapabilities() val mobileFeatures = detectMobileFeatures(activeClient) + val manifestResult = loadPermissionsManifest(activeClient, detectedCapabilities) currentUser = user capabilities = detectedCapabilities + applyPermissionsManifest(manifestResult) supportsPost26MobileFeatures = mobileFeatures.post26 supportsProjectWorkspaceContract = mobileFeatures.projectWorkspace supportsContainerReliabilityActions = mobileFeatures.containerReliabilityActions + supportsContainerManagementWorkflows = mobileFeatures.containerManagement offlineReadScopeOverride = null offlineReadSessionActive = false validateResilientSession() @@ -439,6 +451,8 @@ class ArcaneClientManager(context: Context) { offlineReadScopeOverride = saved.cacheScope offlineReadSessionActive = true currentUser = saved.asReadOnlyUser() + permissionsManifest = null + allowLegacyAccessSurfaceFallback = true capabilities = ServerCapabilities( ServerCapabilities.Mode.entries.firstOrNull { it.name == saved.capabilityMode } ?: return false, @@ -526,9 +540,12 @@ class ArcaneClientManager(context: Context) { resetEnvironment() currentUser = null capabilities = ServerCapabilities.UNKNOWN + permissionsManifest = null + allowLegacyAccessSurfaceFallback = true supportsPost26MobileFeatures = false supportsProjectWorkspaceContract = false supportsContainerReliabilityActions = false + supportsContainerManagementWorkflows = false pendingMfa = null authenticationMethods = authenticationMethods.beginAll() cookieJar.clear() @@ -805,13 +822,16 @@ class ArcaneClientManager(context: Context) { is AuthenticationResult.Authenticated -> { val detectedCapabilities = c.serverCapabilities() val mobileFeatures = detectMobileFeatures(c) + val manifestResult = loadPermissionsManifest(c, detectedCapabilities) if (!isCurrentClient(generation, c)) return pendingMfa = null currentUser = result.response.user capabilities = detectedCapabilities + applyPermissionsManifest(manifestResult) supportsPost26MobileFeatures = mobileFeatures.post26 supportsProjectWorkspaceContract = mobileFeatures.projectWorkspace supportsContainerReliabilityActions = mobileFeatures.containerReliabilityActions + supportsContainerManagementWorkflows = mobileFeatures.containerManagement authStatus = AuthStatus.AUTHENTICATED refreshLoginMethods() operationStore?.onAuthenticated() @@ -842,9 +862,12 @@ class ArcaneClientManager(context: Context) { cookieJar.clear() currentUser = null capabilities = ServerCapabilities.UNKNOWN + permissionsManifest = null + allowLegacyAccessSurfaceFallback = true supportsPost26MobileFeatures = false supportsProjectWorkspaceContract = false supportsContainerReliabilityActions = false + supportsContainerManagementWorkflows = false pendingMfa = null refreshLoginMethods() } @@ -913,9 +936,12 @@ class ArcaneClientManager(context: Context) { serverUrl = "" currentUser = null capabilities = ServerCapabilities.UNKNOWN + permissionsManifest = null + allowLegacyAccessSurfaceFallback = true supportsPost26MobileFeatures = false supportsProjectWorkspaceContract = false supportsContainerReliabilityActions = false + supportsContainerManagementWorkflows = false pendingMfa = null authenticationMethods = authenticationMethods.beginAll() isLoading = false @@ -1000,12 +1026,15 @@ class ArcaneClientManager(context: Context) { val response = c.auth.login(session.username, session.password) val detectedCapabilities = c.serverCapabilities() val mobileFeatures = detectMobileFeatures(c) + val manifestResult = loadPermissionsManifest(c, detectedCapabilities) if (!isCurrentClient(generation, c)) return@launch currentUser = response.user capabilities = detectedCapabilities + applyPermissionsManifest(manifestResult) supportsPost26MobileFeatures = mobileFeatures.post26 supportsProjectWorkspaceContract = mobileFeatures.projectWorkspace supportsContainerReliabilityActions = mobileFeatures.containerReliabilityActions + supportsContainerManagementWorkflows = mobileFeatures.containerManagement demoEndsAt = session.endsAtMillis authStatus = AuthStatus.AUTHENTICATED refreshLoginMethods() @@ -1048,9 +1077,12 @@ class ArcaneClientManager(context: Context) { clientGeneration++ currentUser = null capabilities = ServerCapabilities.UNKNOWN + permissionsManifest = null + allowLegacyAccessSurfaceFallback = true supportsPost26MobileFeatures = false supportsProjectWorkspaceContract = false supportsContainerReliabilityActions = false + supportsContainerManagementWorkflows = false pendingMfa = null authenticationMethods = authenticationMethods.beginAll() demoEndsAt = null @@ -1088,15 +1120,92 @@ class ArcaneClientManager(context: Context) { fun setActiveEnvironment(id: EnvironmentId, name: String) { activeEnvironmentId = id activeEnvironmentName = name - scope.launch { prefs.setActiveEnv(id.rawValue, name) } + scope.launch { + prefs.setActiveEnv(id.rawValue, name) + refreshAuthorization() + } + } + + internal fun canAccess(tab: AppTab): Boolean { + val user = currentUser ?: return false + return canAccessTab( + tab = tab, + user = user, + supportsV2 = capabilities.mode == ServerCapabilities.Mode.RBAC, + manifest = permissionsManifest, + environmentId = activeEnvironmentId.rawValue, + allowLegacyFallback = allowLegacyAccessSurfaceFallback, + ) + } + + internal fun canAccessSurface(surfaceId: String, environmentId: String = activeEnvironmentId.rawValue): Boolean { + val user = currentUser ?: return false + val manifest = permissionsManifest + if (capabilities.mode != ServerCapabilities.Mode.RBAC) return true + if (manifest?.accessSurfaces?.isNotEmpty() == true) { + return manifest.canAccessSurface(surfaceId, user, environmentId) + } + return allowLegacyAccessSurfaceFallback + } + + /** Refreshes user permissions and server-owned reachability without changing the session. */ + internal suspend fun refreshAuthorization() { + val c = client ?: return + val generation = clientGeneration + if (authStatus != AuthStatus.AUTHENTICATED) return + try { + val user = c.auth.me() + val manifestResult = loadPermissionsManifest(c, capabilities) + if (!isCurrentClient(generation, c)) return + currentUser = user + if (!manifestResult.failed) applyPermissionsManifest(manifestResult) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + // Keep the last authoritative authorization snapshot. Requests remain server-enforced. + } } + private suspend fun loadPermissionsManifest( + c: ArcaneClient, + serverCapabilities: ServerCapabilities, + ): PermissionsManifestLoadResult { + if (serverCapabilities.mode != ServerCapabilities.Mode.RBAC) { + return PermissionsManifestLoadResult(legacyFallback = true) + } + return try { + val manifest = c.roles.availablePermissions() + PermissionsManifestLoadResult( + manifest = manifest, + legacyFallback = manifest.accessSurfaces.isEmpty(), + ) + } catch (error: CancellationException) { + throw error + } catch (_: ArcaneError.NotFound) { + PermissionsManifestLoadResult(legacyFallback = true) + } catch (_: Throwable) { + PermissionsManifestLoadResult(failed = true) + } + } + + private fun applyPermissionsManifest(result: PermissionsManifestLoadResult) { + permissionsManifest = result.manifest + allowLegacyAccessSurfaceFallback = result.legacyFallback + } + + private data class PermissionsManifestLoadResult( + val manifest: PermissionsManifest? = null, + val legacyFallback: Boolean = false, + val failed: Boolean = false, + ) + private suspend fun detectMobileFeatures(client: ArcaneClient): MobileFeatureSupport = try { val version = client.version.appVersion() MobileFeatureSupport( post26 = version.supportsPost26MobileFeatures, projectWorkspace = version.supportsProjectWorkspaceContract, containerReliabilityActions = version.supportsContainerReliabilityActions(), + containerManagement = version.supportsContainerManagementWorkflows(), ) } catch (e: CancellationException) { throw e @@ -1108,6 +1217,7 @@ class ArcaneClientManager(context: Context) { val post26: Boolean = false, val projectWorkspace: Boolean = false, val containerReliabilityActions: Boolean = false, + val containerManagement: Boolean = false, ) private suspend fun refreshLoginMethods() { @@ -1116,11 +1226,20 @@ class ArcaneClientManager(context: Context) { if (!isCurrentClient(clientGeneration, c)) return authenticationMethods = authenticationMethods.beginAll() val oidcProbeGeneration = authenticationMethods.oidcProbeGeneration + val localProbeGeneration = authenticationMethods.localProbeGeneration val passkeyProbeGeneration = authenticationMethods.passkeyProbeGeneration // These probes publish independently. A failure in either optional method cannot hide the // other method or the password fallback. coroutineScope { + launch { + val result = probeLocalAuthAvailability { + c.settings.getPublicSettings().associate { it.key to it.value } + } + if (isCurrentClient(clientGeneration, c)) { + authenticationMethods = authenticationMethods.applyLocal(localProbeGeneration, result) + } + } launch { val result = probeOidcAvailability( loadPublicSettings = { diff --git a/app/src/main/kotlin/app/getarcane/android/core/AuthenticationMethodAvailability.kt b/app/src/main/kotlin/app/getarcane/android/core/AuthenticationMethodAvailability.kt index da197da..02bc8e7 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/AuthenticationMethodAvailability.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/AuthenticationMethodAvailability.kt @@ -22,22 +22,35 @@ internal data class PasskeyAvailabilityResult( * same client, including a probe that was in flight while logout started. */ internal data class AuthenticationMethodAvailability( + val localProbeGeneration: Long = 0, val oidcProbeGeneration: Long = 0, val passkeyProbeGeneration: Long = 0, + val localState: AuthenticationMethodState = AuthenticationMethodState.LOADING, val oidcState: AuthenticationMethodState = AuthenticationMethodState.LOADING, val oidcStatus: OidcStatusInfo? = null, val passkeyLoginState: AuthenticationMethodState = AuthenticationMethodState.LOADING, val passkeyBridgeState: AuthenticationMethodState = AuthenticationMethodState.LOADING, ) { fun beginAll(): AuthenticationMethodAvailability = copy( + localProbeGeneration = localProbeGeneration + 1, oidcProbeGeneration = oidcProbeGeneration + 1, passkeyProbeGeneration = passkeyProbeGeneration + 1, + localState = AuthenticationMethodState.LOADING, oidcState = AuthenticationMethodState.LOADING, oidcStatus = null, passkeyLoginState = AuthenticationMethodState.LOADING, passkeyBridgeState = AuthenticationMethodState.LOADING, ) + fun applyLocal( + probeGeneration: Long, + state: AuthenticationMethodState, + ): AuthenticationMethodAvailability = if (probeGeneration == localProbeGeneration) { + copy(localState = state) + } else { + this + } + fun beginPasskeyBridgeProbe(): AuthenticationMethodAvailability = copy( passkeyProbeGeneration = passkeyProbeGeneration + 1, passkeyLoginState = AuthenticationMethodState.LOADING, @@ -80,21 +93,46 @@ internal data class LoginActionVisibility( val showOidc: Boolean, val showOidcDisclosure: Boolean, val showPassword: Boolean, + val checking: Boolean, + val noMethodsAvailable: Boolean, ) internal fun loginActionVisibility( availability: AuthenticationMethodAvailability, showPasswordForm: Boolean, ): LoginActionVisibility { + val checking = availability.localState == AuthenticationMethodState.LOADING || + availability.oidcState == AuthenticationMethodState.LOADING || + availability.passkeyLoginState == AuthenticationMethodState.LOADING + val localAvailable = availability.localState == AuthenticationMethodState.AVAILABLE val oidcAvailable = availability.oidcState == AuthenticationMethodState.AVAILABLE + val passkeyAvailable = availability.passkeyLoginState == AuthenticationMethodState.AVAILABLE return LoginActionVisibility( - showPasskey = availability.passkeyLoginState == AuthenticationMethodState.AVAILABLE, - showOidc = oidcAvailable && !showPasswordForm, - showOidcDisclosure = oidcAvailable, - showPassword = !oidcAvailable || showPasswordForm, + showPasskey = !checking && passkeyAvailable, + showOidc = !checking && oidcAvailable && (!localAvailable || !showPasswordForm), + showOidcDisclosure = !checking && oidcAvailable && localAvailable, + showPassword = !checking && localAvailable && (!oidcAvailable || showPasswordForm), + checking = checking, + noMethodsAvailable = !checking && !localAvailable && !oidcAvailable && !passkeyAvailable, ) } +/** Missing means an older server where local authentication remains the compatible default. */ +internal suspend fun probeLocalAuthAvailability( + loadPublicSettings: suspend () -> Map, +): AuthenticationMethodState = try { + val configured = loadPublicSettings()["authLocalEnabled"]?.trim() + if (configured?.equals("false", ignoreCase = true) == true) { + AuthenticationMethodState.UNAVAILABLE + } else { + AuthenticationMethodState.AVAILABLE + } +} catch (e: CancellationException) { + throw e +} catch (_: Throwable) { + AuthenticationMethodState.ERROR +} + /** * OIDC is offered only when the public setting explicitly enables it. For environment-managed * OIDC, the status endpoint must additionally confirm that the required environment configuration diff --git a/app/src/main/kotlin/app/getarcane/android/core/ServerFeatureSupport.kt b/app/src/main/kotlin/app/getarcane/android/core/ServerFeatureSupport.kt index 02e9cb6..16eb636 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/ServerFeatureSupport.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/ServerFeatureSupport.kt @@ -4,6 +4,13 @@ import app.getarcane.sdk.models.version.VersionInfo /** Container pause/unpause/kill were added by Arcane 2.2.0. */ internal fun VersionInfo.supportsContainerReliabilityActions(): Boolean { + return supportsAtLeast(2, 2) +} + +/** Standalone edit/commit/Compose conversion shipped with Arcane 2.10. */ +internal fun VersionInfo.supportsContainerManagementWorkflows(): Boolean = supportsAtLeast(2, 10) + +private fun VersionInfo.supportsAtLeast(requiredMajor: Int, requiredMinor: Int): Boolean { if (!isSemverVersion) return false val components = currentVersion .trim() @@ -13,6 +20,6 @@ internal fun VersionInfo.supportsContainerReliabilityActions(): Boolean { .split('.') if (components.size != 3) return false val version = components.map { it.toIntOrNull()?.takeIf { value -> value >= 0 } ?: return false } - return version[0] > 2 || - (version[0] == 2 && (version[1] > 2 || version[1] == 2)) + return version[0] > requiredMajor || + (version[0] == requiredMajor && version[1] >= requiredMinor) } diff --git a/app/src/main/kotlin/app/getarcane/android/nav/AdaptiveNavigation.kt b/app/src/main/kotlin/app/getarcane/android/nav/AdaptiveNavigation.kt index add5707..c18c81a 100644 --- a/app/src/main/kotlin/app/getarcane/android/nav/AdaptiveNavigation.kt +++ b/app/src/main/kotlin/app/getarcane/android/nav/AdaptiveNavigation.kt @@ -21,12 +21,14 @@ internal object AdaptiveNavigation { isAdmin: Boolean, supportsV2: Boolean, canReadVariables: Boolean, - ): List = - AppTab.entries.filter { tab -> + canAccess: ((AppTab) -> Boolean)? = null, + ): List = AppTab.entries.filter { tab -> + canAccess?.invoke(tab) ?: ( (!tab.requiresAdmin || isAdmin) && (!tab.requiresV2 || supportsV2) && (tab != AppTab.Variables || canReadVariables) - } + ) + } /** Non-pinnable destinations retain the complete nested flows already owned by Settings. */ fun usesSettingsHost(tab: AppTab): Boolean = !tab.canPinToBottomBar diff --git a/app/src/main/kotlin/app/getarcane/android/nav/AppTab.kt b/app/src/main/kotlin/app/getarcane/android/nav/AppTab.kt index 9f2d176..564a41e 100644 --- a/app/src/main/kotlin/app/getarcane/android/nav/AppTab.kt +++ b/app/src/main/kotlin/app/getarcane/android/nav/AppTab.kt @@ -60,36 +60,37 @@ enum class AppTab( val requiresAdmin: Boolean = false, val requiresV2: Boolean = false, val isEnvironmentScoped: Boolean = false, + val accessSurfaceIds: List = emptyList(), ) { - Dashboard("dashboard", "Dashboard", "Dashboard", R.string.nav_dashboard, R.string.nav_dashboard, Icons.Filled.SpaceDashboard, ArcaneBlue, TabSection.Management, isEnvironmentScoped = true), - Projects("projects", "Projects", "Projects", R.string.nav_projects, R.string.nav_projects, Icons.Filled.FolderSpecial, ArcaneBlue, TabSection.Management, isEnvironmentScoped = true), - ContainerRegistries("containerRegistries", "Container Registries", "Registries", R.string.nav_container_registries, R.string.nav_container_registries_short, Icons.Filled.Cloud, ArcanePurple, TabSection.Management, requiresAdmin = true), - TemplateRegistries("templateRegistries", "Template Registries", "Templates", R.string.nav_template_registries, R.string.nav_template_registries_short, Icons.Filled.Layers, ArcaneIndigo, TabSection.Management, requiresAdmin = true), - GitRepositories("gitRepositories", "Git Repositories", "Git Repos", R.string.nav_git_repositories, R.string.nav_git_repositories_short, Icons.Filled.Source, ArcaneIndigo, TabSection.Management, requiresAdmin = true), - GitOps("gitOps", "GitOps", "GitOps", R.string.nav_gitops, R.string.nav_gitops, Icons.Filled.Sync, ArcaneIndigo, TabSection.Management, requiresAdmin = true, isEnvironmentScoped = true), + Dashboard("dashboard", "Dashboard", "Dashboard", R.string.nav_dashboard, R.string.nav_dashboard, Icons.Filled.SpaceDashboard, ArcaneBlue, TabSection.Management, isEnvironmentScoped = true, accessSurfaceIds = listOf("route.dashboard")), + Projects("projects", "Projects", "Projects", R.string.nav_projects, R.string.nav_projects, Icons.Filled.FolderSpecial, ArcaneBlue, TabSection.Management, isEnvironmentScoped = true, accessSurfaceIds = listOf("route.projects")), + ContainerRegistries("containerRegistries", "Container Registries", "Registries", R.string.nav_container_registries, R.string.nav_container_registries_short, Icons.Filled.Cloud, ArcanePurple, TabSection.Management, requiresAdmin = true, accessSurfaceIds = listOf("customize.category.registries")), + TemplateRegistries("templateRegistries", "Template Registries", "Templates", R.string.nav_template_registries, R.string.nav_template_registries_short, Icons.Filled.Layers, ArcaneIndigo, TabSection.Management, requiresAdmin = true, accessSurfaceIds = listOf("customize.category.templates")), + GitRepositories("gitRepositories", "Git Repositories", "Git Repos", R.string.nav_git_repositories, R.string.nav_git_repositories_short, Icons.Filled.Source, ArcaneIndigo, TabSection.Management, requiresAdmin = true, accessSurfaceIds = listOf("customize.category.git-repositories")), + GitOps("gitOps", "GitOps", "GitOps", R.string.nav_gitops, R.string.nav_gitops, Icons.Filled.Sync, ArcaneIndigo, TabSection.Management, requiresAdmin = true, isEnvironmentScoped = true, accessSurfaceIds = listOf("route.environments.gitops")), - Containers("containers", "Containers", "Containers", R.string.nav_containers, R.string.nav_containers, Icons.Filled.Inventory2, ArcaneBlue, TabSection.Resources, isEnvironmentScoped = true), - Images("images", "Images", "Images", R.string.nav_images, R.string.nav_images, Icons.Filled.Layers, ArcaneBlue, TabSection.Resources, isEnvironmentScoped = true), - Builds("builds", "Builds", "Builds", R.string.nav_builds, R.string.nav_builds, Icons.Filled.Build, ArcaneOrange, TabSection.Resources, requiresAdmin = true), - Updates("updates", "Updates", "Updates", R.string.nav_updates, R.string.nav_updates, Icons.Filled.Autorenew, ArcaneGreen, TabSection.Resources), - Networks("networks", "Networks", "Networks", R.string.nav_networks, R.string.nav_networks, Icons.Filled.Lan, ArcaneTeal, TabSection.Resources, isEnvironmentScoped = true), - Ports("ports", "Ports", "Ports", R.string.nav_ports, R.string.nav_ports, Icons.Filled.SettingsEthernet, ArcaneCyan, TabSection.Resources, isEnvironmentScoped = true), - Volumes("volumes", "Volumes", "Volumes", R.string.nav_volumes, R.string.nav_volumes, Icons.Filled.Storage, ArcaneOrange, TabSection.Resources, isEnvironmentScoped = true), - Jobs("jobs", "Jobs", "Jobs", R.string.nav_jobs, R.string.nav_jobs, Icons.Filled.Schedule, ArcanePink, TabSection.Resources, requiresAdmin = true, isEnvironmentScoped = true), - Activities("activities", "Activities", "Activity", R.string.nav_activities, R.string.nav_activities_short, Icons.Filled.History, ArcaneOrange, TabSection.Resources, requiresV2 = true), + Containers("containers", "Containers", "Containers", R.string.nav_containers, R.string.nav_containers, Icons.Filled.Inventory2, ArcaneBlue, TabSection.Resources, isEnvironmentScoped = true, accessSurfaceIds = listOf("route.containers")), + Images("images", "Images", "Images", R.string.nav_images, R.string.nav_images, Icons.Filled.Layers, ArcaneBlue, TabSection.Resources, isEnvironmentScoped = true, accessSurfaceIds = listOf("route.images")), + Builds("builds", "Builds", "Builds", R.string.nav_builds, R.string.nav_builds, Icons.Filled.Build, ArcaneOrange, TabSection.Resources, requiresAdmin = true, accessSurfaceIds = listOf("settings.category.build")), + Updates("updates", "Updates", "Updates", R.string.nav_updates, R.string.nav_updates, Icons.Filled.Autorenew, ArcaneGreen, TabSection.Resources, accessSurfaceIds = listOf("route.updates")), + Networks("networks", "Networks", "Networks", R.string.nav_networks, R.string.nav_networks, Icons.Filled.Lan, ArcaneTeal, TabSection.Resources, isEnvironmentScoped = true, accessSurfaceIds = listOf("route.networks")), + Ports("ports", "Ports", "Ports", R.string.nav_ports, R.string.nav_ports, Icons.Filled.SettingsEthernet, ArcaneCyan, TabSection.Resources, isEnvironmentScoped = true, accessSurfaceIds = listOf("route.ports")), + Volumes("volumes", "Volumes", "Volumes", R.string.nav_volumes, R.string.nav_volumes, Icons.Filled.Storage, ArcaneOrange, TabSection.Resources, isEnvironmentScoped = true, accessSurfaceIds = listOf("route.volumes")), + Jobs("jobs", "Jobs", "Jobs", R.string.nav_jobs, R.string.nav_jobs, Icons.Filled.Schedule, ArcanePink, TabSection.Resources, requiresAdmin = true, isEnvironmentScoped = true, accessSurfaceIds = listOf("settings.category.jobschedule")), + Activities("activities", "Activities", "Activity", R.string.nav_activities, R.string.nav_activities_short, Icons.Filled.History, ArcaneOrange, TabSection.Resources, requiresV2 = true, accessSurfaceIds = listOf("route.activities")), - Swarm("swarm", "Swarm", "Swarm", R.string.nav_swarm_section, R.string.nav_swarm_section, Icons.Filled.Hub, ArcaneMint, TabSection.Swarm, requiresAdmin = true), + Swarm("swarm", "Swarm", "Swarm", R.string.nav_swarm_section, R.string.nav_swarm_section, Icons.Filled.Hub, ArcaneMint, TabSection.Swarm, requiresAdmin = true, accessSurfaceIds = listOf("route.swarm")), - Events("events", "Events", "Events", R.string.nav_events, R.string.nav_events, Icons.Filled.History, ArcaneRed, TabSection.Administration), - Variables("variables", "Global Variables", "Variables", R.string.nav_variables, R.string.nav_variables_short, Icons.Filled.Sync, ArcaneTeal, TabSection.Administration, requiresV2 = true), - Users("users", "Users", "Users", R.string.nav_users, R.string.nav_users, Icons.Filled.Groups, ArcaneBlue, TabSection.Administration, requiresAdmin = true), - ApiKeys("apiKeys", "API Keys", "API Keys", R.string.nav_api_keys, R.string.nav_api_keys, Icons.Filled.VpnKey, ArcaneYellow, TabSection.Administration, requiresAdmin = true), - Notifications("notifications", "Notifications", "Notifications", R.string.nav_notifications, R.string.nav_notifications, Icons.Filled.Notifications, ArcaneRed, TabSection.Administration, requiresAdmin = true), - Webhooks("webhooks", "Webhooks", "Webhooks", R.string.nav_webhooks, R.string.nav_webhooks, Icons.Filled.Webhook, ArcaneGreen, TabSection.Administration, requiresAdmin = true), - Authentication("authentication", "Authentication", "Auth", R.string.nav_authentication, R.string.nav_authentication_short, Icons.Filled.Lock, ArcaneBlue, TabSection.Administration, requiresAdmin = true), - Roles("roles", "Roles", "Roles", R.string.nav_roles, R.string.nav_roles, Icons.Filled.AdminPanelSettings, ArcanePurple, TabSection.Administration, requiresAdmin = true, requiresV2 = true), - OidcRoleMappings("oidcRoleMappings", "OIDC Role Mappings", "OIDC Roles", R.string.nav_oidc_role_mappings, R.string.nav_oidc_role_mappings_short, Icons.Filled.Groups, ArcaneIndigo, TabSection.Administration, requiresAdmin = true, requiresV2 = true), - SystemSettings("systemSettings", "System Settings", "System", R.string.nav_system_settings, R.string.nav_system_settings_short, Icons.Filled.Dns, ArcaneGray, TabSection.Administration, requiresAdmin = true), + Events("events", "Events", "Events", R.string.nav_events, R.string.nav_events, Icons.Filled.History, ArcaneRed, TabSection.Administration, accessSurfaceIds = listOf("route.events")), + Variables("variables", "Global Variables", "Variables", R.string.nav_variables, R.string.nav_variables_short, Icons.Filled.Sync, ArcaneTeal, TabSection.Administration, requiresV2 = true, accessSurfaceIds = listOf("customize.category.variables")), + Users("users", "Users", "Users", R.string.nav_users, R.string.nav_users, Icons.Filled.Groups, ArcaneBlue, TabSection.Administration, requiresAdmin = true, accessSurfaceIds = listOf("settings.category.users")), + ApiKeys("apiKeys", "API Keys", "API Keys", R.string.nav_api_keys, R.string.nav_api_keys, Icons.Filled.VpnKey, ArcaneYellow, TabSection.Administration, requiresAdmin = true, accessSurfaceIds = listOf("settings.category.apikeys")), + Notifications("notifications", "Notifications", "Notifications", R.string.nav_notifications, R.string.nav_notifications, Icons.Filled.Notifications, ArcaneRed, TabSection.Administration, requiresAdmin = true, accessSurfaceIds = listOf("settings.category.notifications")), + Webhooks("webhooks", "Webhooks", "Webhooks", R.string.nav_webhooks, R.string.nav_webhooks, Icons.Filled.Webhook, ArcaneGreen, TabSection.Administration, requiresAdmin = true, accessSurfaceIds = listOf("settings.category.webhooks")), + Authentication("authentication", "Authentication", "Auth", R.string.nav_authentication, R.string.nav_authentication_short, Icons.Filled.Lock, ArcaneBlue, TabSection.Administration, requiresAdmin = true, accessSurfaceIds = listOf("settings.category.authentication")), + Roles("roles", "Roles", "Roles", R.string.nav_roles, R.string.nav_roles, Icons.Filled.AdminPanelSettings, ArcanePurple, TabSection.Administration, requiresAdmin = true, requiresV2 = true, accessSurfaceIds = listOf("settings.category.roles")), + OidcRoleMappings("oidcRoleMappings", "OIDC Role Mappings", "OIDC Roles", R.string.nav_oidc_role_mappings, R.string.nav_oidc_role_mappings_short, Icons.Filled.Groups, ArcaneIndigo, TabSection.Administration, requiresAdmin = true, requiresV2 = true, accessSurfaceIds = listOf("route.oidc-role-mappings")), + SystemSettings("systemSettings", "System Settings", "System", R.string.nav_system_settings, R.string.nav_system_settings_short, Icons.Filled.Dns, ArcaneGray, TabSection.Administration, requiresAdmin = true, accessSurfaceIds = listOf("settings.category.build", "settings.category.timeouts")), ; val canPinToBottomBar: Boolean diff --git a/app/src/main/kotlin/app/getarcane/android/nav/AuthenticatedRoute.kt b/app/src/main/kotlin/app/getarcane/android/nav/AuthenticatedRoute.kt index f64ae83..34db07e 100644 --- a/app/src/main/kotlin/app/getarcane/android/nav/AuthenticatedRoute.kt +++ b/app/src/main/kotlin/app/getarcane/android/nav/AuthenticatedRoute.kt @@ -204,6 +204,10 @@ suspend fun resolveAuthenticatedRoute( } val environmentId = route.environmentId ?: manager.activeEnvironmentId.rawValue + val accessSurfaceId = route.destination.accessSurfaceId + if (accessSurfaceId != null && !manager.canAccessSurface(accessSurfaceId, environmentId)) { + return AuthenticatedRouteResolution.Rejected("You no longer have permission to open this destination.") + } val permission = route.destination.requiredPermission if (permission != null && user.permissionsByEnv != null && !user.hasPermission(permission, environmentId)) { return AuthenticatedRouteResolution.Rejected("You no longer have permission to open this destination.") @@ -277,4 +281,20 @@ private val RouteDestination.requiredPermission: String? -> null } +private val RouteDestination.accessSurfaceId: String? + get() = when (this) { + RouteDestination.DASHBOARD -> "route.dashboard" + RouteDestination.CONTAINERS -> "route.containers" + RouteDestination.CONTAINER -> "route.containers.detail" + RouteDestination.PROJECTS -> "route.projects" + RouteDestination.PROJECT -> "route.projects.detail" + RouteDestination.ENVIRONMENT -> "route.environments.detail" + RouteDestination.ACTIVITIES, + RouteDestination.ACTIVITY, + -> "route.activities" + RouteDestination.OPERATIONS, + RouteDestination.OPERATION, + -> null + } + private val ACTIVITY_DESTINATIONS = setOf(RouteDestination.ACTIVITIES, RouteDestination.ACTIVITY) diff --git a/app/src/main/kotlin/app/getarcane/android/nav/MainTabSelection.kt b/app/src/main/kotlin/app/getarcane/android/nav/MainTabSelection.kt index 030912a..652fde2 100644 --- a/app/src/main/kotlin/app/getarcane/android/nav/MainTabSelection.kt +++ b/app/src/main/kotlin/app/getarcane/android/nav/MainTabSelection.kt @@ -9,11 +9,13 @@ internal object MainTabSelection { visibleTabs: List, isAdmin: Boolean, supportsV2: Boolean, + canAccess: ((AppTab) -> Boolean)? = null, ): String = normalize( selectedTabId = storedTabId ?: AppTab.Dashboard.id, visibleTabs = visibleTabs, isAdmin = isAdmin, supportsV2 = supportsV2, + canAccess = canAccess, ) fun normalize( @@ -21,7 +23,8 @@ internal object MainTabSelection { visibleTabs: List, isAdmin: Boolean, supportsV2: Boolean, - ): String = if (isSelectable(selectedTabId, isAdmin, supportsV2)) { + canAccess: ((AppTab) -> Boolean)? = null, + ): String = if (isSelectable(selectedTabId, isAdmin, supportsV2, canAccess)) { selectedTabId } else { visibleTabs.firstOrNull()?.id ?: AppTab.Dashboard.id @@ -47,9 +50,11 @@ internal object MainTabSelection { tabId: String, isAdmin: Boolean, supportsV2: Boolean, + canAccess: ((AppTab) -> Boolean)?, ): Boolean { if (tabId == SETTINGS_ID) return true val tab = AppTab.byId(tabId) ?: return false - return (!tab.requiresAdmin || isAdmin) && (!tab.requiresV2 || supportsV2) + return canAccess?.invoke(tab) + ?: ((!tab.requiresAdmin || isAdmin) && (!tab.requiresV2 || supportsV2)) } } diff --git a/app/src/main/kotlin/app/getarcane/android/nav/MainTabView.kt b/app/src/main/kotlin/app/getarcane/android/nav/MainTabView.kt index ed5297d..09f1edb 100644 --- a/app/src/main/kotlin/app/getarcane/android/nav/MainTabView.kt +++ b/app/src/main/kotlin/app/getarcane/android/nav/MainTabView.kt @@ -91,9 +91,9 @@ fun MainTabView() { val isAdmin = manager.currentUser?.isGlobalAdmin ?: false val supportsV2 = manager.capabilities.mode == ServerCapabilities.Mode.RBAC - val visible = tabsStore.visibleTabs(isAdmin, supportsV2) + val visible = tabsStore.visibleTabs(isAdmin, supportsV2, manager::canAccess) val canReadVariables = manager.currentUser?.hasPermission(Permission.Variables.READ) == true - val available = AdaptiveNavigation.availableTabs(isAdmin, supportsV2, canReadVariables) + val available = AdaptiveNavigation.availableTabs(isAdmin, supportsV2, canReadVariables, manager::canAccess) var selected by rememberSaveable { mutableStateOf(null) } val popToRootSignals = remember { mutableStateMapOf() } @@ -219,6 +219,7 @@ fun MainTabView() { visibleTabs = visible, isAdmin = isAdmin, supportsV2 = supportsV2, + canAccess = manager::canAccess, ) } @@ -228,6 +229,7 @@ fun MainTabView() { visibleTabs = visible, isAdmin = isAdmin, supportsV2 = supportsV2, + canAccess = manager::canAccess, ) if (selected != null && currentSelected != normalizedSelection) { selected = normalizedSelection @@ -277,6 +279,7 @@ fun MainTabView() { fun selectOrPopToRoot(tabId: String) { val selectedTab = AppTab.byId(tabId) + if (selectedTab != null && !manager.canAccess(selectedTab)) return if (tabId == AppTab.Dashboard.id && (latestNormalizedSelection != AppTab.Dashboard.id || dashboardOpenTarget != null) ) { @@ -318,17 +321,21 @@ fun MainTabView() { TabContent( normalizedSelection, popToRootSignal = popToRootSignal, - onSelectTab = { selected = it }, + onSelectTab = ::selectOrPopToRoot, dashboardOpenTarget = dashboardOpenTarget, onOpenContainer = { id -> + if (!manager.canAccess(AppTab.Containers)) return@TabContent externalRouteBackTabId = null dashboardOpenTarget = DashboardOpenTarget.Container(id = id) }, onOpenProject = { id -> + if (!manager.canAccess(AppTab.Projects)) return@TabContent externalRouteBackTabId = null dashboardOpenTarget = DashboardOpenTarget.Project(id = id) + selected = AppTab.Dashboard.id }, onOpenVolume = { name -> + if (!manager.canAccess(AppTab.Volumes)) return@TabContent externalRouteBackTabId = null dashboardOpenTarget = DashboardOpenTarget.Volume(id = name) }, @@ -349,14 +356,17 @@ fun MainTabView() { imagesInitialDestination = ImagesInitialDestination.List }, onOpenImageVulnerabilities = { id, name -> + if (!manager.canAccess(AppTab.Images)) return@TabContent externalRouteBackTabId = null dashboardOpenTarget = DashboardOpenTarget.ImageVulnerabilities(id = id, name = name) }, onOpenImageUpdates = { + if (!manager.canAccess(AppTab.Updates)) return@TabContent externalRouteBackTabId = null dashboardOpenTarget = DashboardOpenTarget.ImageUpdates }, onOpenApiKeys = { + if (!manager.canAccess(AppTab.ApiKeys)) return@TabContent dashboardOpenTarget = null externalRouteBackTabId = null selected = AppTab.ApiKeys.id @@ -440,6 +450,7 @@ private fun TabContent( ContainersScreen( dashboardContainerId = target.id, onDashboardBack = onDashboardBack, + onOpenProject = onOpenProject, ) } is DashboardOpenTarget.Project -> key(target) { @@ -490,6 +501,7 @@ private fun TabContent( initialContainerId = containerRouteResourceId, initialRequestId = containerRouteRequestId, onInitialDetailHandled = onContainerRouteHandled, + onOpenProject = onOpenProject, nav = requireNotNull(nav), ) } @@ -529,7 +541,7 @@ private fun TabContent( AppTab.ApiKeys.id -> ApiKeysScreen() AppTab.Notifications.id -> NotificationSettingsScreen(onOpenProvider = {}) AppTab.Webhooks.id -> WebhooksScreen() - AppTab.SystemSettings.id -> SystemSettingsScreen(onOpenCategory = {}, onUpgrade = {}) + AppTab.SystemSettings.id -> SystemSettingsScreen(onOpenCategory = { _, _, _ -> }, onUpgrade = { _, _ -> }) AppTab.Authentication.id -> AuthenticationSettingsScreen() AppTab.Builds.id -> BuildSettingsScreen() AppTab.Roles.id -> RolesScreen(onOpenRole = {}, onCreateRole = {}) diff --git a/app/src/main/kotlin/app/getarcane/android/nav/NavTabsStore.kt b/app/src/main/kotlin/app/getarcane/android/nav/NavTabsStore.kt index d426253..cfb9caf 100644 --- a/app/src/main/kotlin/app/getarcane/android/nav/NavTabsStore.kt +++ b/app/src/main/kotlin/app/getarcane/android/nav/NavTabsStore.kt @@ -37,13 +37,17 @@ class NavTabsStore(context: Context) { } /** The [SLOTS] tabs to show, filtered by current availability and padded with defaults. */ - fun visibleTabs(isAdmin: Boolean, supportsV2: Boolean): List { + fun visibleTabs( + isAdmin: Boolean, + supportsV2: Boolean, + canAccess: ((AppTab) -> Boolean)? = null, + ): List { val normalized = normalizedPinnedBottomTabs(pinned) if (normalized != pinned) { pinned = normalized persist(normalized) } - return visibleBottomTabs(normalized, isAdmin, supportsV2) + return visibleBottomTabs(normalized, isAdmin, supportsV2, canAccess) } fun swap(slot: Int, replacement: AppTab) { @@ -126,8 +130,9 @@ internal fun visibleBottomTabs( pinned: List, isAdmin: Boolean, supportsV2: Boolean, + canAccess: ((AppTab) -> Boolean)? = null, ): List { - fun allowed(tab: AppTab) = tab.isAvailableForBottomBar(isAdmin, supportsV2) + fun allowed(tab: AppTab) = canAccess?.invoke(tab) ?: tab.isAvailableForBottomBar(isAdmin, supportsV2) val result = pinned .filter(::allowed) .toMutableList() @@ -137,5 +142,10 @@ internal fun visibleBottomTabs( if (fallback !in result && allowed(fallback)) result.add(fallback) } + for (fallback in AppTab.entries) { + if (result.size >= NavTabsStore.SLOTS) break + if (fallback !in result && allowed(fallback)) result.add(fallback) + } + return result.take(NavTabsStore.SLOTS) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/auth/LoginScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/auth/LoginScreen.kt index 48d46fc..bc2a94e 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/auth/LoginScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/auth/LoginScreen.kt @@ -417,6 +417,22 @@ private fun Actions( onClick = onPasskeySignIn, ) } + if (actionVisibility.checking) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(10.dp)) + Text( + stringResource(R.string.auth_checking_sign_in_methods), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else if (actionVisibility.noMethodsAvailable) { + InfoBanner(stringResource(R.string.auth_no_sign_in_methods)) + } if (actionVisibility.showOidc) { PrimaryButton( text = stringResource( @@ -705,7 +721,7 @@ private fun Modifier.autofill( } @Composable -private fun InfoBanner(message: String, onDismiss: () -> Unit) { +private fun InfoBanner(message: String, onDismiss: (() -> Unit)? = null) { Surface( shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), @@ -718,13 +734,15 @@ private fun InfoBanner(message: String, onDismiss: () -> Unit) { ) { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) Text(message, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) - IconButton(onClick = onDismiss) { - Icon( - Icons.Filled.Close, - stringResource(R.string.a11y_dismiss), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) + if (onDismiss != null) { + IconButton(onClick = onDismiss) { + Icon( + Icons.Filled.Close, + stringResource(R.string.a11y_dismiss), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } } } } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerActionPolicy.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerActionPolicy.kt index 4ab058c..3f0a899 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerActionPolicy.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerActionPolicy.kt @@ -2,6 +2,9 @@ package app.getarcane.android.ui.screens.containers import androidx.annotation.StringRes import app.getarcane.android.R +import app.getarcane.sdk.models.role.Permission +import app.getarcane.sdk.models.user.User +import app.getarcane.sdk.models.user.hasPermission internal enum class ContainerDetailAction( val permission: String, @@ -50,3 +53,28 @@ internal fun availableContainerActions( } } } + +internal data class ContainerWorkflowAccess( + val canCreate: Boolean, + val canEdit: Boolean, + val canCommit: Boolean, + val canCompose: Boolean, +) + +internal fun containerWorkflowAccess( + user: User?, + environmentId: String, + offline: Boolean, + supportsManagement: Boolean, + canCreateProjectSurface: Boolean, +): ContainerWorkflowAccess { + if (offline || user == null) return ContainerWorkflowAccess(false, false, false, false) + return ContainerWorkflowAccess( + canCreate = supportsManagement && user.hasPermission(Permission.Containers.CREATE, environmentId), + canEdit = supportsManagement && user.hasPermission(Permission.Containers.EDIT, environmentId), + canCommit = supportsManagement && user.hasPermission(Permission.Images.COMMIT, environmentId), + canCompose = supportsManagement && canCreateProjectSurface && + user.hasPermission(Permission.Containers.READ, environmentId) && + user.hasPermission(Permission.Projects.CREATE, environmentId), + ) +} diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerConfigurationScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerConfigurationScreen.kt new file mode 100644 index 0000000..70e395e --- /dev/null +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerConfigurationScreen.kt @@ -0,0 +1,396 @@ +package app.getarcane.android.ui.screens.containers + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.getarcane.android.core.LocalArcaneManager +import app.getarcane.android.core.ReadResource +import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.ui.components.ProtectSensitiveWindow +import app.getarcane.android.ui.screens.settings.FormErrorRow +import app.getarcane.android.ui.screens.settings.LabeledPicker +import app.getarcane.android.ui.screens.settings.LabeledToggle +import app.getarcane.android.ui.screens.settings.SettingsSectionHeader +import app.getarcane.sdk.models.container.ContainerCreate +import app.getarcane.sdk.models.container.ContainerEdit +import app.getarcane.sdk.models.container.ContainerEditConfig +import app.getarcane.sdk.models.container.ContainerRestartPolicyCreate +import app.getarcane.sdk.models.container.EndpointSettingsCreate +import app.getarcane.sdk.models.container.HostConfigCreate +import app.getarcane.sdk.models.container.HostConfigEdit +import app.getarcane.sdk.models.container.NetworkingConfigCreate +import app.getarcane.sdk.models.container.PortBindingCreate +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +internal enum class ContainerConfigurationMode { CREATE, EDIT } + +internal data class ContainerConfigurationDraft( + val name: String = "", + val image: String = "", + val command: String = "", + val entrypoint: String = "", + val workingDirectory: String = "", + val user: String = "", + val environment: String = "", + val labels: String = "", + val binds: String = "", + val ports: String = "", + val networks: String = "", + val networkSettings: Map = emptyMap(), + val restartPolicy: String = "no", + val privileged: Boolean = false, + val autoRemove: Boolean = false, + val readOnlyRootFilesystem: Boolean = false, + val memoryMb: String = "0", + val cpus: String = "0", +) + +internal data class ContainerConfigurationPayload( + val create: ContainerCreate? = null, + val edit: ContainerEdit? = null, + val errors: List = emptyList(), +) + +internal fun buildContainerConfigurationPayload( + mode: ContainerConfigurationMode, + draft: ContainerConfigurationDraft, +): ContainerConfigurationPayload { + val errors = mutableListOf() + if (mode == ContainerConfigurationMode.CREATE && draft.name.isBlank()) errors += "Name is required." + if (draft.image.isBlank()) errors += "Image is required." + val labels = parseAssignments(draft.labels, "label", errors) + val portBindings = parsePortBindings(draft.ports, errors) + val memoryMb = draft.memoryMb.ifBlank { "0" }.toLongOrNull() + if (memoryMb == null || memoryMb < 0 || memoryMb > Long.MAX_VALUE / (1024L * 1024L)) { + errors += "Memory must be a non-negative whole number within the supported range." + } + val cpus = draft.cpus.ifBlank { "0" }.toDoubleOrNull() + if (cpus == null || !cpus.isFinite() || cpus < 0 || cpus > Long.MAX_VALUE / 1_000_000_000.0) { + errors += "CPUs must be a non-negative number within the supported range." + } + if (errors.isNotEmpty()) return ContainerConfigurationPayload(errors = errors) + + val commands = draft.command.nonBlankLines() + val entrypoint = draft.entrypoint.nonBlankLines() + val environment = draft.environment.nonBlankLines() + val binds = draft.binds.nonBlankLines() + val networkNames = draft.networks.nonBlankLines().distinct() + val networking = NetworkingConfigCreate( + endpointsConfig = networkNames.associateWith { draft.networkSettings[it] ?: EndpointSettingsCreate() }, + ) + val restart = ContainerRestartPolicyCreate(name = draft.restartPolicy) + val memory = requireNotNull(memoryMb) * 1024L * 1024L + val nanoCpus = (requireNotNull(cpus) * 1_000_000_000L).toLong() + + return when (mode) { + ContainerConfigurationMode.CREATE -> ContainerConfigurationPayload( + create = ContainerCreate( + name = draft.name.trim(), + image = draft.image.trim(), + command = commands, + entrypoint = entrypoint, + workingDir = draft.workingDirectory, + user = draft.user, + environment = environment, + labels = labels, + hostConfig = HostConfigCreate( + binds = binds, + portBindings = portBindings, + restartPolicy = restart, + privileged = draft.privileged, + autoRemove = draft.autoRemove, + readonlyRootfs = draft.readOnlyRootFilesystem, + memory = memory, + nanoCpus = nanoCpus, + ), + networkingConfig = networking, + ), + ) + ContainerConfigurationMode.EDIT -> ContainerConfigurationPayload( + edit = ContainerEdit( + image = draft.image.trim(), + workingDir = draft.workingDirectory, + user = draft.user, + command = commands, + entrypoint = entrypoint, + environment = environment, + labels = labels, + hostConfig = HostConfigEdit( + binds = binds, + portBindings = portBindings, + restartPolicy = restart, + privileged = draft.privileged, + autoRemove = draft.autoRemove, + readonlyRootfs = draft.readOnlyRootFilesystem, + memory = memory, + nanoCpus = nanoCpus, + ), + networkingConfig = networking, + ), + ) + } +} + +private fun String.nonBlankLines(): List = lines().map(String::trim).filter(String::isNotEmpty) + +private fun parseAssignments(raw: String, kind: String, errors: MutableList): Map = + buildMap { + raw.nonBlankLines().forEachIndexed { index, line -> + val key = line.substringBefore('=', missingDelimiterValue = "").trim() + if (key.isBlank() || '=' !in line) { + errors += "Invalid $kind on line ${index + 1}; use NAME=VALUE." + } else { + put(key, line.substringAfter('=')) + } + } + } + +private fun parsePortBindings(raw: String, errors: MutableList): Map> = + buildMap { + raw.nonBlankLines().forEachIndexed { index, line -> + val containerPort = line.substringBefore('=', missingDelimiterValue = "").trim() + val host = line.substringAfter('=', missingDelimiterValue = "").trim() + if (containerPort.isBlank() || host.isBlank()) { + errors += "Invalid port on line ${index + 1}; use 80/tcp=8080 or 80/tcp=127.0.0.1|8080." + } else { + val normalized = if ('/' in containerPort) containerPort else "$containerPort/tcp" + val parts = host.split('|', limit = 2) + put( + normalized, + listOf( + PortBindingCreate( + hostIp = parts.takeIf { it.size == 2 }?.first()?.ifBlank { null }, + hostPort = parts.last(), + ), + ), + ) + } + } + } + +private fun ContainerEditConfig.toDraft(): ContainerConfigurationDraft = ContainerConfigurationDraft( + name = name, + image = image, + command = command.orEmpty().joinToString("\n"), + entrypoint = entrypoint.orEmpty().joinToString("\n"), + workingDirectory = workingDir.orEmpty(), + user = user.orEmpty(), + environment = environment.orEmpty().joinToString("\n"), + labels = labels.orEmpty().entries.joinToString("\n") { "${it.key}=${it.value}" }, + binds = hostConfig.binds.orEmpty().joinToString("\n"), + ports = hostConfig.portBindings.orEmpty().flatMap { (containerPort, bindings) -> + bindings.map { binding -> + "$containerPort=${binding.hostIp?.takeIf(String::isNotBlank)?.let { "$it|" }.orEmpty()}${binding.hostPort.orEmpty()}" + } + }.joinToString("\n"), + networks = networks.orEmpty().keys.joinToString("\n"), + networkSettings = networks.orEmpty(), + restartPolicy = hostConfig.restartPolicy?.name ?: "no", + privileged = hostConfig.privileged == true, + autoRemove = hostConfig.autoRemove == true, + readOnlyRootFilesystem = hostConfig.readonlyRootfs == true, + memoryMb = ((hostConfig.memory ?: 0L) / (1024L * 1024L)).toString(), + cpus = ((hostConfig.nanoCpus ?: 0L) / 1_000_000_000.0).toString(), +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ContainerConfigurationScreen( + mode: ContainerConfigurationMode, + containerId: String? = null, + onBack: () -> Unit, + onSaved: (String) -> Unit, +) { + val manager = LocalArcaneManager.current + val client = manager.client + val environmentId = manager.activeEnvironmentId + val environmentName = manager.activeEnvironmentName + val session = manager.authenticatedClientScope() + val scope = rememberCoroutineScope() + var draft by remember(containerId, environmentId.rawValue) { mutableStateOf(ContainerConfigurationDraft()) } + var loading by remember { mutableStateOf(mode == ContainerConfigurationMode.EDIT) } + var saving by remember { mutableStateOf(false) } + var editDisabledReason by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var pendingPayload by remember { mutableStateOf(null) } + ProtectSensitiveWindow() + + LaunchedEffect(client, containerId, environmentId.rawValue) { + if (mode != ContainerConfigurationMode.EDIT) return@LaunchedEffect + val id = containerId ?: return@LaunchedEffect + val captured = session ?: return@LaunchedEffect + loading = true + try { + val config = captured.client.containers.editConfig(environmentId, id) + if (!manager.isCurrent(captured)) return@LaunchedEffect + if (config.editDisabled == true || config.isCompose == true) { + editDisabledReason = "This container is managed by Compose${config.composeProject?.let { " project $it" }.orEmpty()}. Edit the project instead." + } + draft = config.toDraft() + } catch (exception: CancellationException) { + throw exception + } catch (exception: Throwable) { + error = friendlyErrorMessage(exception) + } finally { + loading = false + } + } + + fun submit(edit: ContainerEdit? = null) { + val captured = session ?: return + scope.launch { + saving = true + error = null + try { + val id = when (mode) { + ContainerConfigurationMode.CREATE -> { + val payload = buildContainerConfigurationPayload(mode, draft) + if (payload.errors.isNotEmpty()) { + error = payload.errors.joinToString("\n") + return@launch + } + captured.client.containers.create(environmentId, requireNotNull(payload.create)).id + } + ContainerConfigurationMode.EDIT -> captured.client.containers.edit( + environmentId, + requireNotNull(containerId), + requireNotNull(edit), + ).id + } + if (!manager.isCurrent(captured)) return@launch + manager.invalidateReadCache(environmentId, ReadResource.CONTAINERS) + onSaved(id) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Throwable) { + if (manager.isCurrent(captured)) error = friendlyErrorMessage(exception) + } finally { + if (manager.isCurrent(captured)) saving = false + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (mode == ContainerConfigurationMode.CREATE) "Create Container" else "Edit Container") }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, + ) + }, + ) { padding -> + if (loading) { + Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { CircularProgressIndicator() } + return@Scaffold + } + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + editDisabledReason?.let { FormErrorRow(it) } + error?.let { FormErrorRow(it) } + Text( + "Target: $environmentName", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + SettingsSectionHeader("Identity & image") + FormField("Name", draft.name, mode == ContainerConfigurationMode.CREATE) { draft = draft.copy(name = it) } + FormField("Image", draft.image) { draft = draft.copy(image = it) } + FormField("Working directory", draft.workingDirectory) { draft = draft.copy(workingDirectory = it) } + FormField("User", draft.user) { draft = draft.copy(user = it) } + SettingsSectionHeader("Process") + MultiLineField("Command · one argument per line", draft.command) { draft = draft.copy(command = it) } + MultiLineField("Entrypoint · one argument per line", draft.entrypoint) { draft = draft.copy(entrypoint = it) } + SettingsSectionHeader("Environment & labels") + MultiLineField("Environment · NAME=VALUE", draft.environment) { draft = draft.copy(environment = it) } + MultiLineField("Labels · NAME=VALUE", draft.labels) { draft = draft.copy(labels = it) } + SettingsSectionHeader("Host") + MultiLineField("Binds · source:target[:mode]", draft.binds) { draft = draft.copy(binds = it) } + MultiLineField("Ports · 80/tcp=8080", draft.ports) { draft = draft.copy(ports = it) } + MultiLineField("Networks · one name per line", draft.networks) { draft = draft.copy(networks = it) } + LabeledPicker("Restart policy", draft.restartPolicy, listOf("no", "always", "unless-stopped", "on-failure"), { it }, { draft = draft.copy(restartPolicy = it) }) + LabeledToggle("Privileged", draft.privileged, { draft = draft.copy(privileged = it) }) + LabeledToggle("Auto remove", draft.autoRemove, { draft = draft.copy(autoRemove = it) }) + LabeledToggle("Read-only root filesystem", draft.readOnlyRootFilesystem, { draft = draft.copy(readOnlyRootFilesystem = it) }) + FormField("Memory (MB)", draft.memoryMb) { draft = draft.copy(memoryMb = it) } + FormField("CPUs", draft.cpus) { draft = draft.copy(cpus = it) } + Button( + onClick = { + val payload = buildContainerConfigurationPayload(mode, draft) + if (payload.errors.isNotEmpty()) error = payload.errors.joinToString("\n") + else if (mode == ContainerConfigurationMode.EDIT) pendingPayload = payload.edit + else submit() + }, + enabled = !saving && editDisabledReason == null, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) { + if (saving) CircularProgressIndicator() else Text(if (mode == ContainerConfigurationMode.CREATE) "Create Container" else "Review & Recreate") + } + } + } + + pendingPayload?.let { payload -> + AlertDialog( + onDismissRequest = { pendingPayload = null }, + title = { Text("Recreate ${draft.name}?") }, + text = { Text("Arcane will stop and replace this container in $environmentName. Its ID may change and the workload may be briefly unavailable. Unedited configuration remains preserved by the server.") }, + confirmButton = { TextButton(onClick = { pendingPayload = null; submit(payload) }) { Text("Recreate") } }, + dismissButton = { TextButton(onClick = { pendingPayload = null }) { Text("Cancel") } }, + ) + } +} + +@Composable +private fun FormField(label: String, value: String, enabled: Boolean = true, onChange: (String) -> Unit) { + OutlinedTextField( + value = value, + onValueChange = onChange, + enabled = enabled, + label = { Text(label) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + ) +} + +@Composable +private fun MultiLineField(label: String, value: String, onChange: (String) -> Unit) { + OutlinedTextField( + value = value, + onValueChange = onChange, + label = { Text(label) }, + minLines = 3, + maxLines = 8, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + ) +} diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerDerivedWorkflowsScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerDerivedWorkflowsScreen.kt new file mode 100644 index 0000000..c0c4ac5 --- /dev/null +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerDerivedWorkflowsScreen.kt @@ -0,0 +1,206 @@ +package app.getarcane.android.ui.screens.containers + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.getarcane.android.core.LocalArcaneManager +import app.getarcane.android.core.ReadResource +import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.ui.screens.settings.FormErrorRow +import app.getarcane.android.ui.screens.settings.LabeledToggle +import app.getarcane.sdk.models.container.ContainerCommitRequest +import app.getarcane.sdk.models.container.ContainerGenerateComposeRequest +import app.getarcane.sdk.models.project.CreateProject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ContainerCommitScreen(containerId: String, onBack: () -> Unit) { + val manager = LocalArcaneManager.current + val session = manager.authenticatedClientScope() + val environmentId = manager.activeEnvironmentId + val scope = rememberCoroutineScope() + var repository by remember { mutableStateOf("") } + var tag by remember { mutableStateOf("latest") } + var comment by remember { mutableStateOf("") } + var author by remember { mutableStateOf("") } + var noPause by remember { mutableStateOf(false) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var result by remember { mutableStateOf(null) } + + fun commit() { + if (repository.isBlank()) { + error = "Repository is required so the committed image can be found later." + return + } + val captured = session ?: return + scope.launch { + busy = true + error = null + try { + val committed = captured.client.containers.commit( + environmentId, + containerId, + ContainerCommitRequest( + repository = repository.trim(), + tag = tag.trim().ifBlank { null }, + comment = comment.ifBlank { null }, + author = author.ifBlank { null }, + noPause = noPause, + ), + ) + if (!manager.isCurrent(captured)) return@launch + result = committed.id + manager.invalidateReadCache(environmentId, ReadResource.IMAGES) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Throwable) { + if (manager.isCurrent(captured)) error = friendlyErrorMessage(exception) + } finally { + if (manager.isCurrent(captured)) busy = false + } + } + } + + Scaffold( + topBar = { TopAppBar(title = { Text("Commit Container") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }) }, + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Create an image from this container in ${manager.activeEnvironmentName}. Arcane pauses the container during commit unless you opt out.") + OutlinedTextField(repository, { repository = it }, label = { Text("Repository") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(tag, { tag = it }, label = { Text("Tag") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(comment, { comment = it }, label = { Text("Comment") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(author, { author = it }, label = { Text("Author") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + LabeledToggle("Do not pause container", noPause, { noPause = it }) + error?.let { FormErrorRow(it) } + result?.let { Text("Created image $it") } + Button(onClick = ::commit, enabled = !busy && result == null, modifier = Modifier.fillMaxWidth()) { + if (busy) CircularProgressIndicator() else Text("Commit to Image") + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ContainerComposeScreen( + containerId: String, + onBack: () -> Unit, + onProjectCreated: (String) -> Unit, +) { + val manager = LocalArcaneManager.current + val session = manager.authenticatedClientScope() + val environmentId = manager.activeEnvironmentId + val scope = rememberCoroutineScope() + var composeContent by remember { mutableStateOf(null) } + var projectName by remember(containerId) { mutableStateOf("container-${containerId.take(8)}") } + var loading by remember { mutableStateOf(true) } + var creating by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + LaunchedEffect(session, environmentId.rawValue, containerId) { + val captured = session ?: return@LaunchedEffect + loading = true + try { + val generated = captured.client.containers.generateCompose( + environmentId, + ContainerGenerateComposeRequest(listOf(containerId)), + ) + if (manager.isCurrent(captured)) composeContent = generated.composeContent + } catch (exception: CancellationException) { + throw exception + } catch (exception: Throwable) { + if (manager.isCurrent(captured)) error = friendlyErrorMessage(exception) + } finally { + if (manager.isCurrent(captured)) loading = false + } + } + + fun createProject() { + val captured = session ?: return + val content = composeContent ?: return + if (projectName.isBlank()) { + error = "Project name is required." + return + } + scope.launch { + creating = true + error = null + try { + val project = captured.client.projects.create( + environmentId, + CreateProject(name = projectName.trim(), composeContent = content), + useWorkspaceContract = manager.supportsProjectWorkspaceContract, + ) + if (!manager.isCurrent(captured)) return@launch + manager.invalidateReadCache(environmentId, ReadResource.PROJECTS, ReadResource.CONTAINERS) + onProjectCreated(project.id) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Throwable) { + if (manager.isCurrent(captured)) error = friendlyErrorMessage(exception) + } finally { + if (manager.isCurrent(captured)) creating = false + } + } + } + + Scaffold( + topBar = { TopAppBar(title = { Text("Convert to Compose") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }) }, + ) { padding -> + if (loading) { + Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { CircularProgressIndicator() } + return@Scaffold + } + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Review the server-generated Compose configuration, then create a project in ${manager.activeEnvironmentName}.") + OutlinedTextField(projectName, { projectName = it }, label = { Text("Project name") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField( + value = composeContent.orEmpty(), + onValueChange = {}, + readOnly = true, + label = { Text("Generated compose.yaml") }, + minLines = 12, + modifier = Modifier.fillMaxWidth(), + ) + error?.let { FormErrorRow(it) } + Button(onClick = ::createProject, enabled = !creating && composeContent != null, modifier = Modifier.fillMaxWidth()) { + if (creating) CircularProgressIndicator() else Text("Create & Open Project") + } + } + } +} diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerDetailScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerDetailScreen.kt index 1bb4ee1..c5cc0b0 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerDetailScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerDetailScreen.kt @@ -30,6 +30,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Source import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Inventory2 import androidx.compose.material.icons.filled.MoreVert @@ -129,6 +132,9 @@ fun ContainerDetailScreen( onLogs: (String) -> Unit, onTerminal: (String) -> Unit, onInspect: (String) -> Unit, + onEdit: ((String) -> Unit)? = null, + onCommit: ((String) -> Unit)? = null, + onCompose: ((String) -> Unit)? = null, ) { val manager = LocalArcaneManager.current val operationStore = LocalOperationStore.current @@ -272,11 +278,35 @@ fun ContainerDetailScreen( Icon(Icons.Filled.Terminal, "Terminal") } } - if (availableActions.any { it in setOf(ContainerDetailAction.Kill, ContainerDetailAction.Delete) }) Box { + if (onEdit != null || onCommit != null || onCompose != null || + availableActions.any { it in setOf(ContainerDetailAction.Kill, ContainerDetailAction.Delete) } + ) Box { IconButton(onClick = { overflowOpen = true }, enabled = !busy) { Icon(Icons.Filled.MoreVert, "More") } DropdownMenu(expanded = overflowOpen, onDismissRequest = { overflowOpen = false }) { + if (onEdit != null) { + DropdownMenuItem( + text = { Text("Edit & Recreate") }, + onClick = { overflowOpen = false; onEdit(id) }, + leadingIcon = { Icon(Icons.Filled.Edit, null) }, + ) + } + if (onCommit != null) { + DropdownMenuItem( + text = { Text("Commit to Image") }, + onClick = { overflowOpen = false; onCommit(id) }, + leadingIcon = { Icon(Icons.Filled.Save, null) }, + ) + } + if (onCompose != null) { + DropdownMenuItem( + text = { Text("Convert to Compose") }, + onClick = { overflowOpen = false; onCompose(id) }, + leadingIcon = { Icon(Icons.Filled.Source, null) }, + ) + } + if (onEdit != null || onCommit != null || onCompose != null) HorizontalDivider() if (ContainerDetailAction.Kill in availableActions) { DropdownMenuItem( text = { Text("Force Kill", color = ArcaneRed) }, diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerListScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerListScreen.kt index 9a47cce..67b3c13 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerListScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerListScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.Inventory2 +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PushPin @@ -76,6 +77,8 @@ import app.getarcane.android.ui.theme.ArcaneGreen import app.getarcane.android.ui.theme.StatusRunning import app.getarcane.android.ui.theme.StatusUnknown import app.getarcane.sdk.models.container.ContainerSummary +import app.getarcane.sdk.models.role.Permission +import app.getarcane.sdk.models.user.hasPermission import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.serialization.builtins.ListSerializer @@ -89,7 +92,7 @@ private val ContainerStateFilter.label: String @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ContainerListScreen(onOpen: (String) -> Unit) { +fun ContainerListScreen(onOpen: (String) -> Unit, onCreate: (() -> Unit)? = null) { val manager = LocalArcaneManager.current val pinned = LocalPinnedStore.current val client = manager.client @@ -173,6 +176,9 @@ fun ContainerListScreen(onOpen: (String) -> Unit) { TopAppBar( title = { Text("Containers") }, actions = { + if (onCreate != null) { + IconButton(onClick = onCreate) { Icon(Icons.Filled.Add, "Create container") } + } Box { IconButton(onClick = { menuOpen = true }) { Icon(Icons.Filled.MoreVert, "Options") } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { @@ -284,6 +290,10 @@ private fun ContainerRow( val manager = LocalArcaneManager.current val envId = manager.activeEnvironmentId val scope = rememberCoroutineScope() + val user = manager.currentUser + val canStart = !manager.offlineReadSessionActive && user?.hasPermission(Permission.Containers.START, envId.rawValue) == true + val canStop = !manager.offlineReadSessionActive && user?.hasPermission(Permission.Containers.STOP, envId.rawValue) == true + val canRestart = !manager.offlineReadSessionActive && user?.hasPermission(Permission.Containers.RESTART, envId.rawValue) == true var menu by remember { mutableStateOf(false) } fun act(block: suspend () -> Unit) { @@ -333,10 +343,13 @@ private fun ContainerRow( onClick = { menu = false; onTogglePin() }, leadingIcon = { Icon(Icons.Filled.PushPin, null) }, ) - if (container.isRunning) { + if (container.isRunning && canStop) { DropdownMenuItem(text = { Text("Stop") }, onClick = { menu = false; act { manager.client!!.containers.stop(envId = envId, id = container.id) } }, leadingIcon = { Icon(Icons.Filled.Stop, null) }) + } + if (container.isRunning && canRestart) { DropdownMenuItem(text = { Text("Restart") }, onClick = { menu = false; act { manager.client!!.containers.restart(envId = envId, id = container.id) } }, leadingIcon = { Icon(Icons.Filled.Refresh, null) }) - } else { + } + if (!container.isRunning && canStart) { DropdownMenuItem(text = { Text("Start") }, onClick = { menu = false; act { manager.client!!.containers.start(envId = envId, id = container.id) } }, leadingIcon = { Icon(Icons.Filled.PlayArrow, null) }) } } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainersScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainersScreen.kt index 88a4c37..df1f341 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainersScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainersScreen.kt @@ -3,13 +3,22 @@ package app.getarcane.android.ui.screens.containers import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import app.getarcane.android.core.LocalArcaneManager +import app.getarcane.android.core.supportsContainerManagementWorkflows import app.getarcane.android.nav.popToRootOrReplace import app.getarcane.android.ui.components.AdaptiveListDetailLayout import app.getarcane.android.ui.components.ListDetailPlaceholder +import app.getarcane.sdk.EnvironmentId +import kotlinx.coroutines.CancellationException /** * Containers tab with its own nested back stack (list -> detail -> {logs, terminal, inspect}). @@ -23,8 +32,52 @@ fun ContainersScreen( initialContainerId: String? = null, initialRequestId: Long = 0, onInitialDetailHandled: (Long) -> Unit = {}, + onOpenProject: (String) -> Unit = {}, nav: NavHostController = rememberNavController(), ) { + val manager = LocalArcaneManager.current + val environmentId = manager.activeEnvironmentId.rawValue + val user = manager.currentUser + var supportsManagementForEnvironment by remember(environmentId) { + mutableStateOf(manager.activeEnvironmentId == EnvironmentId.LOCAL_DOCKER && manager.supportsContainerManagementWorkflows) + } + LaunchedEffect(manager.client, environmentId, manager.supportsContainerManagementWorkflows) { + supportsManagementForEnvironment = when { + manager.activeEnvironmentId == EnvironmentId.LOCAL_DOCKER -> manager.supportsContainerManagementWorkflows + manager.client == null -> false + else -> try { + manager.client!!.version.environmentVersion(manager.activeEnvironmentId).supportsContainerManagementWorkflows() + } catch (exception: CancellationException) { + throw exception + } catch (_: Throwable) { + false + } + } + } + val workflowAccess = containerWorkflowAccess( + user = user, + environmentId = environmentId, + offline = manager.offlineReadSessionActive, + supportsManagement = supportsManagementForEnvironment, + canCreateProjectSurface = manager.canAccessSurface("route.projects.new", environmentId), + ) + val canCreate = workflowAccess.canCreate + val canEdit = workflowAccess.canEdit + val canCommit = workflowAccess.canCommit + val canCompose = workflowAccess.canCompose + val currentEntry by nav.currentBackStackEntryAsState() + + LaunchedEffect(currentEntry?.destination?.route, canCreate, canEdit, canCommit, canCompose) { + val route = currentEntry?.destination?.route.orEmpty() + val allowed = when { + route == "create" -> canCreate + route.startsWith("edit/") -> canEdit + route.startsWith("commit/") -> canCommit + route.startsWith("compose/") -> canCompose + else -> true + } + if (!allowed) nav.popBackStack() + } fun openDetail(id: String) { nav.navigate("detail/$id") { popUpTo(nav.graph.startDestinationId) { inclusive = false } @@ -44,7 +97,7 @@ fun ContainersScreen( } val listPane: @Composable () -> Unit = { - ContainerListScreen(onOpen = ::openDetail) + ContainerListScreen(onOpen = ::openDetail, onCreate = if (canCreate) ({ nav.navigate("create") }) else null) } AdaptiveListDetailLayout( listPane = listPane, @@ -62,6 +115,9 @@ fun ContainersScreen( onLogs = { childId -> nav.navigate("logs/$childId") }, onTerminal = { childId -> nav.navigate("terminal/$childId") }, onInspect = { childId -> nav.navigate("inspect/$childId") }, + onEdit = if (canEdit) ({ childId -> nav.navigate("edit/$childId") }) else null, + onCommit = if (canCommit) ({ childId -> nav.navigate("commit/$childId") }) else null, + onCompose = if (canCompose) ({ childId -> nav.navigate("compose/$childId") }) else null, ) } composable("detail/{id}") { entry -> @@ -72,6 +128,37 @@ fun ContainersScreen( onLogs = { childId -> nav.navigate("logs/$childId") }, onTerminal = { childId -> nav.navigate("terminal/$childId") }, onInspect = { childId -> nav.navigate("inspect/$childId") }, + onEdit = if (canEdit) ({ childId -> nav.navigate("edit/$childId") }) else null, + onCommit = if (canCommit) ({ childId -> nav.navigate("commit/$childId") }) else null, + onCompose = if (canCompose) ({ childId -> nav.navigate("compose/$childId") }) else null, + ) + } + composable("create") { + ContainerConfigurationScreen( + mode = ContainerConfigurationMode.CREATE, + onBack = { nav.popBackStack() }, + onSaved = ::openDetail, + ) + } + composable("edit/{id}") { entry -> + ContainerConfigurationScreen( + mode = ContainerConfigurationMode.EDIT, + containerId = entry.arguments?.getString("id").orEmpty(), + onBack = { nav.popBackStack() }, + onSaved = ::openDetail, + ) + } + composable("commit/{id}") { entry -> + ContainerCommitScreen( + containerId = entry.arguments?.getString("id").orEmpty(), + onBack = { nav.popBackStack() }, + ) + } + composable("compose/{id}") { entry -> + ContainerComposeScreen( + containerId = entry.arguments?.getString("id").orEmpty(), + onBack = { nav.popBackStack() }, + onProjectCreated = onOpenProject, ) } composable("logs/{id}") { entry -> diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/SettingsScreen.kt index a4f81e3..6baacc3 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/SettingsScreen.kt @@ -91,16 +91,19 @@ internal object SettingsRoutes { const val AUTHENTICATION = "authentication" const val BUILDS = "builds" const val SYSTEM = "system" - const val SYSTEM_CATEGORY = "system/{categoryId}" + const val SYSTEM_CATEGORY = "system/{categoryId}/{environmentId}" const val CONTAINER_REGISTRIES = "container-registries" const val TEMPLATE_REGISTRIES = "template-registries" - const val UPGRADE = "upgrade" + const val UPGRADE = "upgrade/{environmentId}" + + fun systemCategory(categoryId: String, environmentId: String): String = + "system/$categoryId/$environmentId" + + fun upgrade(environmentId: String): String = "upgrade/$environmentId" } internal fun settingsRouteAccessOwner(route: String?): AppTab? { - AppTab.byId(route.orEmpty())?.let { tab -> - if (tab.requiresAdmin || tab.requiresV2) return tab - } + AppTab.byId(route.orEmpty())?.let { return it } return when (route) { SettingsRoutes.USER_DETAIL, SettingsRoutes.USER_ROLE_ASSIGNMENTS -> AppTab.Users @@ -119,9 +122,11 @@ internal fun shouldResetUnauthorizedSettingsRoute( supportsV2: Boolean, supportsPost26: Boolean, canReadVariables: Boolean = true, + canAccessOwner: ((AppTab) -> Boolean)? = null, ): Boolean { if (route == SettingsRoutes.ACCOUNT_SECURITY) return !supportsPost26 val owner = settingsRouteAccessOwner(route) ?: return false + canAccessOwner?.let { return !it(owner) } return (owner.requiresAdmin && !isAdmin) || (owner.requiresV2 && !supportsV2) || (owner == AppTab.Variables && !canReadVariables) @@ -129,8 +134,6 @@ internal fun shouldResetUnauthorizedSettingsRoute( internal fun isEnvironmentScopedSettingsDetail(route: String?): Boolean = route in setOf( SettingsRoutes.NOTIFICATION_PROVIDER, - SettingsRoutes.SYSTEM_CATEGORY, - SettingsRoutes.UPGRADE, ) sealed interface SettingsInitialDestination { @@ -160,13 +163,23 @@ fun SettingsScreen( var navigationEnvironmentId by remember { mutableStateOf(environmentId) } nav.PopToRootOnSignal(popToRootSignal, rootRoute = SettingsRoutes.ROOT) - LaunchedEffect(currentRoute, isAdmin, supportsV2, supportsPost26, canReadVariables) { + LaunchedEffect( + currentRoute, + isAdmin, + supportsV2, + supportsPost26, + canReadVariables, + manager.currentUser, + manager.permissionsManifest, + environmentId, + ) { if (shouldResetUnauthorizedSettingsRoute( currentRoute, isAdmin, supportsV2, supportsPost26, canReadVariables, + manager::canAccess, ) ) { nav.popBackStack(SettingsRoutes.ROOT, inclusive = false) @@ -183,7 +196,7 @@ fun SettingsScreen( LaunchedEffect(initialDestination) { when (initialDestination) { SettingsInitialDestination.Root -> Unit - SettingsInitialDestination.Upgrade -> nav.navigate(SettingsRoutes.UPGRADE) { + SettingsInitialDestination.Upgrade -> nav.navigate(SettingsRoutes.upgrade(manager.activeEnvironmentId.rawValue)) { popUpTo(SettingsRoutes.ROOT) launchSingleTop = true } @@ -256,10 +269,21 @@ fun SettingsScreen( composable(SettingsRoutes.SYSTEM_CATEGORY) { entry -> SettingsCategoryScreen( categoryId = entry.arguments?.getString("categoryId").orEmpty(), + environmentId = app.getarcane.sdk.EnvironmentId( + entry.arguments?.getString("environmentId").orEmpty(), + ), + environmentName = entry.arguments?.getString("environmentId").orEmpty(), + onBack = { nav.popBackStack() }, + ) + } + composable(SettingsRoutes.UPGRADE) { entry -> + val environmentId = entry.arguments?.getString("environmentId").orEmpty() + SystemUpgradeScreen( onBack = { nav.popBackStack() }, + environmentId = app.getarcane.sdk.EnvironmentId(environmentId), + environmentName = environmentId, ) } - composable(SettingsRoutes.UPGRADE) { SystemUpgradeScreen(onBack = { nav.popBackStack() }) } } } @@ -278,9 +302,7 @@ private fun SettingsRoot(nav: NavHostController) { AppTab.entries.filter { tab -> tab.section == section && tab !in pinnedTabs && - (isAdmin || !tab.requiresAdmin) && - (supportsV2 || !tab.requiresV2) && - (tab != AppTab.Variables || canReadVariables) + manager.canAccess(tab) } Scaffold( @@ -334,6 +356,13 @@ private fun SettingsRoot(nav: NavHostController) { @Composable private fun SettingsTabDestination(tab: AppTab, nav: NavHostController) { + val manager = LocalArcaneManager.current + if (!manager.canAccess(tab)) { + LaunchedEffect(tab, manager.currentUser, manager.permissionsManifest, manager.activeEnvironmentId) { + nav.popBackStack(SettingsRoutes.ROOT, inclusive = false) + } + return + } when (tab) { AppTab.Dashboard -> DashboardScreen() AppTab.Containers -> ContainersScreen() @@ -346,7 +375,6 @@ private fun SettingsTabDestination(tab: AppTab, nav: NavHostController) { AppTab.Activities -> ActivitiesTab() AppTab.Events -> EventsScreen() AppTab.Variables -> { - val manager = LocalArcaneManager.current VariablesScreen( client = manager.client, currentUser = manager.currentUser, @@ -364,8 +392,12 @@ private fun SettingsTabDestination(tab: AppTab, nav: NavHostController) { AppTab.Notifications -> NotificationSettingsScreen(onOpenProvider = { provider -> nav.navigate("notifications/${provider.wire}") }) AppTab.Webhooks -> WebhooksScreen() AppTab.SystemSettings -> SystemSettingsScreen( - onOpenCategory = { id -> nav.navigate("system/$id") }, - onUpgrade = { nav.navigate(SettingsRoutes.UPGRADE) }, + onOpenCategory = { id, environmentId, _ -> + nav.navigate(SettingsRoutes.systemCategory(id, environmentId.rawValue)) + }, + onUpgrade = { environmentId, _ -> + nav.navigate(SettingsRoutes.upgrade(environmentId.rawValue)) + }, ) AppTab.Authentication -> AuthenticationSettingsScreen(onBack = { nav.popBackStack() }) AppTab.Builds -> BuildSettingsScreen(onBack = { nav.popBackStack() }) diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/system/SettingsCategoryScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/system/SettingsCategoryScreen.kt index 7ab0031..825577e 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/system/SettingsCategoryScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/system/SettingsCategoryScreen.kt @@ -45,18 +45,28 @@ import app.getarcane.android.ui.screens.settings.FormErrorRow import app.getarcane.android.ui.screens.settings.FormSuccessRow import app.getarcane.android.ui.screens.settings.LabeledPicker import app.getarcane.android.ui.screens.settings.LabeledToggle +import app.getarcane.android.ui.screens.settings.SettingsSectionHeader +import app.getarcane.sdk.EnvironmentId +import app.getarcane.sdk.models.role.Permission +import app.getarcane.sdk.models.user.hasPermission +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** Generic per-category settings editor. Port of iOS `SettingsCategoryView`. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsCategoryScreen(categoryId: String, onBack: () -> Unit) { - val category = remember(categoryId) { systemSettingsCategories.first { it.id == categoryId } } +fun SettingsCategoryScreen( + categoryId: String, + environmentId: EnvironmentId, + environmentName: String, + onBack: () -> Unit, +) { + val category = remember(categoryId) { systemSettingsCategories.firstOrNull { it.id == categoryId } } val manager = LocalArcaneManager.current val client = manager.client - val envId = manager.activeEnvironmentId val scope = rememberCoroutineScope() + val canWrite = manager.currentUser?.hasPermission(Permission.Settings.WRITE) == true val settings = remember { mutableStateMapOf() } var originalSettings by remember { mutableStateOf>(emptyMap()) } @@ -65,14 +75,16 @@ fun SettingsCategoryScreen(categoryId: String, onBack: () -> Unit) { var error by remember { mutableStateOf(null) } var savedMessage by remember { mutableStateOf(null) } - LaunchedEffect(categoryId, envId.rawValue) { + LaunchedEffect(categoryId, environmentId.rawValue) { if (client == null) return@LaunchedEffect loading = true try { - val dtos = client.settings.getSettings(envId) + val dtos = client.settings.getSettings(environmentId) val map = dtos.associate { it.key to it.value } settings.clear(); settings.putAll(map) originalSettings = map + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { error = friendlyErrorMessage(e) } finally { @@ -80,21 +92,32 @@ fun SettingsCategoryScreen(categoryId: String, onBack: () -> Unit) { } } - val hasChanges = category.fields.any { settings[it.key] != originalSettings[it.key] } + val fields = category?.fields.orEmpty() + val hasChanges = fields.any { settings[it.key] != originalSettings[it.key] } fun save() { val c = client ?: return + val captured = manager.authenticatedClientScope() ?: return scope.launch { saving = true; error = null; savedMessage = null - val changed = category.fields + val changed = fields .mapNotNull { f -> (settings[f.key] ?: "").let { v -> if (v != originalSettings[f.key]) f.key to v else null } } .toMap() if (changed.isEmpty()) { saving = false; return@launch } + val validationErrors = validateSettingChanges(fields, changed) + if (validationErrors.isNotEmpty()) { + error = validationErrors.joinToString("\n") + saving = false + return@launch + } try { - c.settings.updateSettings(updateSettingsFrom(changed), envId) + c.settings.updateSettings(updateSettingsFrom(changed), environmentId) + if (!manager.isCurrent(captured)) return@launch originalSettings = originalSettings.toMutableMap().apply { putAll(changed) } savedMessage = "Settings saved" launch { delay(3000); savedMessage = null } + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { error = friendlyErrorMessage(e) } finally { @@ -106,7 +129,7 @@ fun SettingsCategoryScreen(categoryId: String, onBack: () -> Unit) { Scaffold( topBar = { TopAppBar( - title = { Text(category.title) }, + title = { Text(category?.title ?: "Unsupported settings") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") @@ -126,12 +149,27 @@ fun SettingsCategoryScreen(categoryId: String, onBack: () -> Unit) { .verticalScroll(rememberScrollState()) .padding(bottom = 24.dp), ) { - category.fields.forEach { field -> SettingRow(field, settings) } + if (category == null) { + FormErrorRow("This settings category is not supported by this app version.") + return@Column + } + Text( + "Editing $environmentName without changing the app's active environment.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + category.sections.forEach { section -> + SettingsSectionHeader(section.title) + section.fields.filter { field -> + field.visibleWhen?.let { settings[it.key] == it.value } ?: true + }.forEach { field -> SettingRow(field, settings) } + } if (hasChanges) { Button( onClick = { save() }, - enabled = !saving, + enabled = !saving && canWrite, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), ) { if (saving) { @@ -142,7 +180,7 @@ fun SettingsCategoryScreen(categoryId: String, onBack: () -> Unit) { } } OutlinedButton( - onClick = { category.fields.forEach { settings[it.key] = originalSettings[it.key] ?: "" } }, + onClick = { fields.forEach { settings[it.key] = originalSettings[it.key] ?: "" } }, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), ) { Text("Discard Changes") } } @@ -188,6 +226,52 @@ private fun SettingRow(field: SettingFieldDef, settings: androidx.compose.runtim modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), ) } + is SettingFieldType.Cron -> { + OutlinedTextField( + value = value, + onValueChange = { settings[field.key] = it }, + label = { Text(field.label) }, + supportingText = { Text("Cron expression") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), + ) + } + is SettingFieldType.TextArea -> { + OutlinedTextField( + value = value, + onValueChange = { settings[field.key] = it }, + label = { Text(field.label) }, + supportingText = field.description?.let { description -> ({ Text(description) }) }, + minLines = 3, + maxLines = 10, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), + ) + } + } +} + +internal fun validateSettingChanges( + fields: List, + changed: Map, +): List = buildList { + fields.forEach { field -> + val value = changed[field.key] ?: return@forEach + when (val type = field.type) { + is SettingFieldType.Number -> { + val number = value.toIntOrNull() + if (number == null) { + add("${field.label} must be a whole number.") + } else if ((field.minValue != null && number < field.minValue) || + (field.maxValue != null && number > field.maxValue) + ) { + add("${field.label} must be between ${field.minValue ?: "the minimum"} and ${field.maxValue ?: "the maximum"}.") + } + } + is SettingFieldType.Select -> if (value !in type.options) { + add("${field.label} has an unsupported value.") + } + else -> Unit + } } } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/system/SystemSettingsScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/system/SystemSettingsScreen.kt index e29406b..167c28b 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/system/SystemSettingsScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/system/SystemSettingsScreen.kt @@ -12,14 +12,12 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowCircleUp -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Inventory2 -import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Security -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -38,196 +36,262 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.friendlyErrorMessage +import app.getarcane.android.core.loadCompleteEnvironments +import app.getarcane.android.ui.screens.settings.LabeledPicker import app.getarcane.android.ui.screens.settings.SettingsSectionFooter import app.getarcane.android.ui.screens.settings.SettingsSectionHeader +import kotlinx.coroutines.CancellationException +import app.getarcane.sdk.EnvironmentId +import app.getarcane.sdk.models.environment.Environment -/** Field types in a settings category. Mirrors iOS `SettingFieldType`. */ sealed interface SettingFieldType { data object Text : SettingFieldType data object Number : SettingFieldType data object Boolean : SettingFieldType data object Password : SettingFieldType + data object Cron : SettingFieldType + data object TextArea : SettingFieldType data class Select(val options: List) : SettingFieldType } -/** A single setting field definition. Mirrors iOS `SettingFieldDef`. */ -data class SettingFieldDef(val key: String, val label: String, val type: SettingFieldType) +data class SettingFieldCondition(val key: String, val value: String) + +data class SettingFieldDef( + val key: String, + val label: String, + val type: SettingFieldType, + val description: String? = null, + val minValue: Int? = null, + val maxValue: Int? = null, + val visibleWhen: SettingFieldCondition? = null, +) + +data class SettingsSectionDef(val id: String, val title: String, val fields: List) -/** A settings category. Mirrors iOS `SettingsCategoryDef`. */ data class SettingsCategoryDef( val id: String, val title: String, val icon: ImageVector, val summary: String, - val fields: List, -) + val sections: List, +) { + val fields: List get() = sections.flatMap(SettingsSectionDef::fields) +} + +private fun field(key: String, label: String, type: SettingFieldType, min: Int? = null, max: Int? = null) = + SettingFieldDef(key, label, type, minValue = min, maxValue = max) -/** Categories + fields, ported 1:1 from iOS `systemSettingsCategories`. */ val systemSettingsCategories: List = listOf( SettingsCategoryDef( - "general", "General", Icons.Filled.Settings, "Server URL, gravatar, default shell", - listOf( - SettingFieldDef("baseServerUrl", "Base Server URL", SettingFieldType.Text), - SettingFieldDef("diskUsagePath", "Disk Usage Path", SettingFieldType.Text), - SettingFieldDef("enableGravatar", "Enable Gravatar", SettingFieldType.Boolean), - SettingFieldDef("defaultShell", "Default Shell", SettingFieldType.Text), - SettingFieldDef("autoInjectEnv", "Auto-Inject .env", SettingFieldType.Boolean), - SettingFieldDef("defaultDeployPullPolicy", "Default Pull Policy", SettingFieldType.Select(listOf("always", "missing", "never", "build"))), - ), - ), - SettingsCategoryDef( - "docker", "Docker Daemon", Icons.Filled.Inventory2, "Docker host and project directories", - listOf( - SettingFieldDef("dockerHost", "Docker Host", SettingFieldType.Text), - SettingFieldDef("projectsDirectory", "Projects Directory", SettingFieldType.Text), - SettingFieldDef("swarmStackSourcesDirectory", "Swarm Stack Sources", SettingFieldType.Text), - SettingFieldDef("followProjectSymlinks", "Follow Project Symlinks", SettingFieldType.Boolean), - SettingFieldDef("dockerPruneMode", "Prune Mode", SettingFieldType.Select(listOf("all", "dangling"))), - ), - ), - SettingsCategoryDef( - "auto-update", "Auto-Update", Icons.Filled.Sync, "Automatic image updates and polling", - listOf( - SettingFieldDef("autoUpdate", "Enabled", SettingFieldType.Boolean), - SettingFieldDef("autoUpdateExcludedContainers", "Excluded Containers", SettingFieldType.Text), - SettingFieldDef("pollingEnabled", "Polling Enabled", SettingFieldType.Boolean), - ), - ), - SettingsCategoryDef( - "auto-heal", "Auto-Heal", Icons.Filled.Favorite, "Restart unhealthy containers automatically", + "storage-limits", "Storage & Limits", Icons.Filled.Storage, + "Directories, storage paths, sync, and upload limits", listOf( - SettingFieldDef("autoHealEnabled", "Enabled", SettingFieldType.Boolean), - SettingFieldDef("autoHealMaxRestarts", "Max Restarts", SettingFieldType.Number), - SettingFieldDef("autoHealRestartWindow", "Restart Window (min)", SettingFieldType.Number), - SettingFieldDef("autoHealExcludedContainers", "Excluded Containers", SettingFieldType.Text), + SettingsSectionDef("directories", "Directories & Storage Paths", listOf( + field("projectsDirectory", "Projects Directory", SettingFieldType.Text), + field("templatesDirectory", "Templates Directory", SettingFieldType.Text), + field("swarmStackSourcesDirectory", "Swarm Stack Sources", SettingFieldType.Text), + field("diskUsagePath", "Disk Usage Path", SettingFieldType.Text), + field("followProjectSymlinks", "Follow Project Symlinks", SettingFieldType.Boolean), + )), + SettingsSectionDef("limits", "Sync & Upload Limits", listOf( + field("maxImageUploadSize", "Max Image Upload (MB)", SettingFieldType.Number), + field("gitSyncMaxFiles", "Git Sync Max Files", SettingFieldType.Number), + field("gitSyncMaxTotalSizeMb", "Git Sync Total Size (MB)", SettingFieldType.Number), + field("gitSyncMaxBinarySizeMb", "Git Sync Binary Size (MB)", SettingFieldType.Number), + )), ), ), SettingsCategoryDef( - "prune", "Scheduled Pruning", Icons.Filled.Delete, "Automatically clean up unused resources", + "docker", "Docker Settings", Icons.Filled.Inventory2, + "Shell, deployment defaults, and resource pruning", listOf( - SettingFieldDef("scheduledPruneEnabled", "Enabled", SettingFieldType.Boolean), - SettingFieldDef("scheduledPruneContainers", "Prune Containers", SettingFieldType.Boolean), - SettingFieldDef("scheduledPruneImages", "Prune Images", SettingFieldType.Boolean), - SettingFieldDef("scheduledPruneVolumes", "Prune Volumes", SettingFieldType.Boolean), - SettingFieldDef("scheduledPruneNetworks", "Prune Networks", SettingFieldType.Boolean), - SettingFieldDef("scheduledPruneBuildCache", "Prune Build Cache", SettingFieldType.Boolean), - SettingFieldDef("pruneContainerMode", "Container Mode", SettingFieldType.Select(listOf("none", "stopped", "olderThan"))), - SettingFieldDef("pruneContainerUntil", "Container Until", SettingFieldType.Text), - SettingFieldDef("pruneImageMode", "Image Mode", SettingFieldType.Select(listOf("none", "dangling", "all", "olderThan"))), - SettingFieldDef("pruneImageUntil", "Image Until", SettingFieldType.Text), - SettingFieldDef("pruneVolumeMode", "Volume Mode", SettingFieldType.Select(listOf("none", "anonymous", "all"))), - SettingFieldDef("pruneNetworkMode", "Network Mode", SettingFieldType.Select(listOf("none", "unused", "olderThan"))), - SettingFieldDef("pruneNetworkUntil", "Network Until", SettingFieldType.Text), - SettingFieldDef("pruneBuildCacheMode", "Build Cache Mode", SettingFieldType.Select(listOf("none", "unused", "all", "olderThan"))), - SettingFieldDef("pruneBuildCacheUntil", "Build Cache Until", SettingFieldType.Text), + SettingsSectionDef("configuration", "Configuration", listOf( + field("baseServerUrl", "Base Server URL", SettingFieldType.Text), + field("defaultShell", "Default Shell", SettingFieldType.Text), + field("defaultDeployPullPolicy", "Default Pull Policy", SettingFieldType.Select(listOf("missing", "always", "never"))), + field("autoInjectEnv", "Auto-Inject .env", SettingFieldType.Boolean), + )), + SettingsSectionDef("prune", "Prune Options", listOf( + field("pruneContainerMode", "Prune Containers", SettingFieldType.Select(listOf("none", "stopped", "olderThan"))), + SettingFieldDef("pruneContainerUntil", "Container Age Filter", SettingFieldType.Text, visibleWhen = SettingFieldCondition("pruneContainerMode", "olderThan")), + field("pruneImageMode", "Prune Images", SettingFieldType.Select(listOf("none", "dangling", "all", "olderThan"))), + SettingFieldDef("pruneImageUntil", "Image Age Filter", SettingFieldType.Text, visibleWhen = SettingFieldCondition("pruneImageMode", "olderThan")), + field("pruneVolumeMode", "Prune Volumes", SettingFieldType.Select(listOf("none", "anonymous", "all"))), + field("pruneNetworkMode", "Prune Networks", SettingFieldType.Select(listOf("none", "unused", "olderThan"))), + SettingFieldDef("pruneNetworkUntil", "Network Age Filter", SettingFieldType.Text, visibleWhen = SettingFieldCondition("pruneNetworkMode", "olderThan")), + field("pruneBuildCacheMode", "Prune Build Cache", SettingFieldType.Select(listOf("none", "unused", "all", "olderThan"))), + SettingFieldDef("pruneBuildCacheUntil", "Build Cache Age Filter", SettingFieldType.Text, visibleWhen = SettingFieldCondition("pruneBuildCacheMode", "olderThan")), + )), ), ), SettingsCategoryDef( - "vulnerability", "Vulnerability Scanning", Icons.Filled.Security, "Trivy scanner configuration", - listOf( - SettingFieldDef("vulnerabilityScanEnabled", "Enabled", SettingFieldType.Boolean), - SettingFieldDef("trivyImage", "Trivy Image", SettingFieldType.Text), - SettingFieldDef("trivyNetwork", "Network", SettingFieldType.Text), - SettingFieldDef("trivySecurityOpts", "Security Options", SettingFieldType.Text), - SettingFieldDef("trivyPrivileged", "Privileged Mode", SettingFieldType.Boolean), - SettingFieldDef("trivyResourceLimitsEnabled", "Resource Limits", SettingFieldType.Boolean), - SettingFieldDef("trivyCpuLimit", "CPU Limit", SettingFieldType.Text), - SettingFieldDef("trivyMemoryLimitMb", "Memory Limit (MB)", SettingFieldType.Number), - SettingFieldDef("trivyConcurrentScanContainers", "Concurrent Scans", SettingFieldType.Number), - SettingFieldDef("trivyPreserveCacheOnVolumePrune", "Preserve Cache", SettingFieldType.Boolean), - ), + "security", "Security", Icons.Filled.Security, "Trivy vulnerability scanner configuration", + listOf(SettingsSectionDef("vulnerability", "Vulnerability Scanning", listOf( + field("trivyImage", "Trivy Image", SettingFieldType.Text), + SettingFieldDef("trivyNetwork", "Trivy Network", SettingFieldType.Text, description = "Empty inherits Arcane's network; built-in and custom Docker networks are accepted."), + field("trivySecurityOpts", "Security Options", SettingFieldType.TextArea), + field("trivyPrivileged", "Privileged Mode", SettingFieldType.Boolean), + field("trivyResourceLimitsEnabled", "Resource Limits", SettingFieldType.Boolean), + field("trivyCpuLimit", "CPU Limit", SettingFieldType.Text), + field("trivyMemoryLimitMb", "Memory Limit (MB)", SettingFieldType.Number), + field("trivyConcurrentScanContainers", "Concurrent Scans", SettingFieldType.Number, min = 1), + field("trivyPreserveCacheOnVolumePrune", "Preserve Cache", SettingFieldType.Boolean), + field("trivyConfig", "Trivy Config (YAML)", SettingFieldType.TextArea), + field("trivyIgnore", ".trivyignore", SettingFieldType.TextArea), + ))), ), SettingsCategoryDef( - "timeouts", "Timeouts", Icons.Filled.Schedule, "Operation timeouts in seconds", + "automations", "Automations", Icons.Filled.Schedule, + "Updates, monitoring, maintenance, and scheduled scans", listOf( - SettingFieldDef("dockerApiTimeout", "Docker API (s)", SettingFieldType.Number), - SettingFieldDef("dockerImagePullTimeout", "Image Pull (s)", SettingFieldType.Number), - SettingFieldDef("trivyScanTimeout", "Trivy Scan (s)", SettingFieldType.Number), - SettingFieldDef("gitOperationTimeout", "Git Operation (s)", SettingFieldType.Number), - SettingFieldDef("httpClientTimeout", "HTTP Client (s)", SettingFieldType.Number), - SettingFieldDef("registryTimeout", "Registry (s)", SettingFieldType.Number), - SettingFieldDef("proxyRequestTimeout", "Proxy Request (s)", SettingFieldType.Number), - SettingFieldDef("buildTimeout", "Build (s)", SettingFieldType.Number), + SettingsSectionDef("updates", "Updates", listOf( + field("pollingEnabled", "Image Polling", SettingFieldType.Boolean), + field("pollingInterval", "Polling Interval", SettingFieldType.Cron), + field("autoUpdate", "Auto-Update", SettingFieldType.Boolean), + field("autoUpdateInterval", "Update Interval", SettingFieldType.Cron), + field("autoUpdateExcludedContainers", "Excluded Containers", SettingFieldType.TextArea), + )), + SettingsSectionDef("monitoring", "Monitoring", listOf( + field("autoHealEnabled", "Auto-Heal", SettingFieldType.Boolean), + field("autoHealInterval", "Check Interval", SettingFieldType.Cron), + field("autoHealMaxRestarts", "Max Restarts", SettingFieldType.Number), + field("autoHealRestartWindow", "Restart Window (min)", SettingFieldType.Number), + field("autoHealExcludedContainers", "Excluded Containers", SettingFieldType.TextArea), + )), + SettingsSectionDef("maintenance", "Maintenance", listOf( + field("scheduledPruneEnabled", "Scheduled Pruning", SettingFieldType.Boolean), + field("scheduledPruneInterval", "Prune Interval", SettingFieldType.Cron), + field("environmentHealthInterval", "Environment Health Check", SettingFieldType.Cron), + field("dockerClientRefreshInterval", "Docker Client Refresh", SettingFieldType.Cron), + field("eventCleanupInterval", "Event Cleanup", SettingFieldType.Cron), + field("expiredSessionsCleanupInterval", "Expired Sessions Cleanup", SettingFieldType.Cron), + )), + SettingsSectionDef("security", "Security", listOf( + field("vulnerabilityScanEnabled", "Vulnerability Scanning", SettingFieldType.Boolean), + field("vulnerabilityScanInterval", "Scan Interval", SettingFieldType.Cron), + )), ), ), SettingsCategoryDef( - "git-sync", "Git Sync Limits", Icons.Filled.Sync, "Repository sync size and file limits", - listOf( - SettingFieldDef("gitSyncMaxFiles", "Max Files", SettingFieldType.Number), - SettingFieldDef("gitSyncMaxTotalSizeMb", "Max Total Size (MB)", SettingFieldType.Number), - SettingFieldDef("gitSyncMaxBinarySizeMb", "Max Binary Size (MB)", SettingFieldType.Number), - ), + "activity", "Activity", Icons.Filled.History, "Activity Center history retention", + listOf(SettingsSectionDef("history", "Activity History", listOf( + field("activityHistoryRetentionDays", "Retention (days)", SettingFieldType.Number, 0, 3650), + field("activityHistoryMaxEntries", "Max Entries", SettingFieldType.Number, 0, 100000), + ))), ), SettingsCategoryDef( - "misc", "Miscellaneous", Icons.Filled.MoreHoriz, "Additional settings", + "timeouts", "Timeouts", Icons.Filled.Schedule, "Docker, Git, and network operation timeouts", listOf( - SettingFieldDef("maxImageUploadSize", "Max Image Upload (MB)", SettingFieldType.Number), + SettingsSectionDef("docker", "Docker Operations", listOf( + field("dockerApiTimeout", "Docker API (s)", SettingFieldType.Number, 1, 3600), + field("dockerImagePullTimeout", "Image Pull (s)", SettingFieldType.Number, 30, 7200), + field("trivyScanTimeout", "Trivy Scan (s)", SettingFieldType.Number, 60, 14400), + )), + SettingsSectionDef("git", "Git Operations", listOf( + field("gitOperationTimeout", "Git Operation (s)", SettingFieldType.Number, 30, 3600), + )), + SettingsSectionDef("network", "Network Operations", listOf( + field("httpClientTimeout", "HTTP Client (s)", SettingFieldType.Number, 5, 300), + field("registryTimeout", "Registry (s)", SettingFieldType.Number, 5, 300), + field("proxyRequestTimeout", "Proxy Request (s)", SettingFieldType.Number, 10, 600), + )), ), ), ) -/** System settings hub: category list + Maintenance (Upgrade) for admins. Port of iOS `SystemSettingsView`. */ +private data class SettingsTarget(val id: EnvironmentId, val name: String) + @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SystemSettingsScreen(onOpenCategory: (categoryId: String) -> Unit, onUpgrade: () -> Unit) { +fun SystemSettingsScreen( + onOpenCategory: (categoryId: String, environmentId: EnvironmentId, environmentName: String) -> Unit, + onUpgrade: (environmentId: EnvironmentId, environmentName: String) -> Unit, +) { val manager = LocalArcaneManager.current val session = manager.authenticatedClientScope() - val user = manager.currentUser - val environmentId = manager.activeEnvironmentId - var upgradeAvailability by remember(session, environmentId.rawValue) { + var target by remember(manager.serverSessionIdentity, manager.currentUser?.id) { + mutableStateOf(SettingsTarget(manager.activeEnvironmentId, manager.activeEnvironmentName)) + } + var targets by remember { mutableStateOf(listOf(target)) } + var targetsLoading by remember { mutableStateOf(false) } + var upgradeAvailability by remember(session, target.id.rawValue) { mutableStateOf(UpgradeAvailability.Loading) } - LaunchedEffect(session, user, environmentId.rawValue) { + LaunchedEffect(session) { val captured = session ?: return@LaunchedEffect - val capturedUser = user ?: return@LaunchedEffect - upgradeAvailability = UpgradeAvailability.Loading - val resolved = resolveUpgradeAvailability( - user = capturedUser, - environmentId = environmentId.rawValue, - loadVersion = { captured.client.version.environmentVersion(environmentId) }, - checkUpgrade = { captured.client.system.checkUpgrade(environmentId) }, + targetsLoading = true + try { + val environments = loadCompleteEnvironments { captured.client.environments.list(it) } + if (manager.isCurrent(captured)) { + targets = environments.map(Environment::toSettingsTarget).ifEmpty { listOf(target) } + targets.firstOrNull { it.id == target.id }?.let { target = it } + } + } catch (exception: CancellationException) { + throw exception + } catch (_: Throwable) { + if (targets.none { it.id == target.id }) targets = listOf(target) + targets + } finally { + targetsLoading = false + } + } + + LaunchedEffect(session, target.id.rawValue) { + val captured = session ?: return@LaunchedEffect + val user = manager.currentUser ?: return@LaunchedEffect + upgradeAvailability = resolveUpgradeAvailability( + user = user, + environmentId = target.id.rawValue, + loadVersion = { captured.client.version.environmentVersion(target.id) }, + checkUpgrade = { captured.client.system.checkUpgrade(target.id) }, errorMessage = ::friendlyErrorMessage, ) - if (manager.isCurrent(captured)) upgradeAvailability = resolved } - Scaffold(topBar = { TopAppBar(title = { Text("System Settings") }) }) { padding -> + Scaffold(topBar = { TopAppBar(title = { Text("Environment Settings") }) }) { padding -> LazyColumn(Modifier.fillMaxSize().padding(padding)) { - items(systemSettingsCategories, key = { it.id }) { category -> - CategoryRow(category, onClick = { onOpenCategory(category.id) }) + item(key = "target") { + LabeledPicker( + label = "Settings Environment", + selected = target, + options = targets, + optionLabel = SettingsTarget::name, + onSelect = { target = it }, + enabled = !targetsLoading && targets.size > 1, + ) + SettingsSectionFooter("This selects which environment to edit without changing the environment used elsewhere in the app.") } - item(key = "settings-footer") { - SettingsSectionFooter("Settings apply to the active environment: ${manager.activeEnvironmentName}") + item(key = "configuration-header") { SettingsSectionHeader("Configuration") } + items(systemSettingsCategories.take(4), key = { it.id }) { category -> + CategoryRow(category) { onOpenCategory(category.id, target.id, target.name) } } - item(key = "maint-header") { SettingsSectionHeader("Maintenance") } - if (upgradeAvailability.canUpgrade) { - item(key = "upgrade") { - CategoryRowRaw( - icon = Icons.Filled.ArrowCircleUp, - title = "Upgrade Arcane", - summary = "Update to the latest Arcane release", - onClick = onUpgrade, - ) - } - } else { - item(key = "upgrade-status") { - CategoryRowRaw( - icon = Icons.Filled.ArrowCircleUp, - title = "Upgrade status", - summary = upgradeAvailability.summary(), - onClick = null, - ) + item(key = "services-header") { SettingsSectionHeader("Services") } + items(systemSettingsCategories.drop(4), key = { it.id }) { category -> + CategoryRow(category) { onOpenCategory(category.id, target.id, target.name) } + } + item(key = "maintenance-header") { SettingsSectionHeader("Maintenance") } + item(key = "upgrade") { + CategoryRowRaw( + icon = Icons.Filled.ArrowCircleUp, + title = if (upgradeAvailability.canUpgrade) "Upgrade Arcane" else "Upgrade status", + summary = if (upgradeAvailability.canUpgrade) "Update ${target.name} to the latest Arcane release" else upgradeAvailability.summary(), + onClick = if (upgradeAvailability.canUpgrade) ({ onUpgrade(target.id, target.name) }) else null, + ) + } + if (targetsLoading) item(key = "target-loading") { + Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.Center) { + CircularProgressIndicator() } } } } } +private fun Environment.toSettingsTarget(): SettingsTarget = SettingsTarget(EnvironmentId(id), name ?: id) + @Composable -private fun CategoryRow(category: SettingsCategoryDef, onClick: () -> Unit) { +private fun CategoryRow(category: SettingsCategoryDef, onClick: () -> Unit) = CategoryRowRaw(category.icon, category.title, category.summary, onClick) -} @Composable private fun CategoryRowRaw(icon: ImageVector, title: String, summary: String, onClick: (() -> Unit)?) { @@ -238,10 +302,10 @@ private fun CategoryRowRaw(icon: ImageVector, title: String, summary: String, on verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.width(28.dp)) + Icon(icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.width(28.dp)) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(title, style = MaterialTheme.typography.bodyLarge) - Text(summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + Text(summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be166ad..8d6c115 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,8 @@ Sign in Cancel passkey request Change server + Checking available sign-in methods… + This server has no supported sign-in method enabled. Check its authentication configuration or change server. Two-factor authentication Use a registered passkey or one of your recovery codes. Continue with passkey diff --git a/app/src/test/java/app/getarcane/android/core/AccessSurfaceReachabilityTest.kt b/app/src/test/java/app/getarcane/android/core/AccessSurfaceReachabilityTest.kt new file mode 100644 index 0000000..0a8e087 --- /dev/null +++ b/app/src/test/java/app/getarcane/android/core/AccessSurfaceReachabilityTest.kt @@ -0,0 +1,93 @@ +package app.getarcane.android.core + +import app.getarcane.android.nav.AppTab +import app.getarcane.sdk.models.role.AccessSurface +import app.getarcane.sdk.models.role.AccessSurfaceAccessMode +import app.getarcane.sdk.models.role.AccessSurfaceKind +import app.getarcane.sdk.models.role.AccessSurfaceMatchMode +import app.getarcane.sdk.models.role.AccessSurfaceScopeMode +import app.getarcane.sdk.models.role.PermissionsManifest +import app.getarcane.sdk.models.user.User +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AccessSurfaceReachabilityTest { + private val manifest = PermissionsManifest( + accessSurfaces = listOf( + AccessSurface( + id = "route.containers", + kind = AccessSurfaceKind.ROUTE, + accessMode = AccessSurfaceAccessMode.PERMISSIONS, + matchMode = AccessSurfaceMatchMode.ANY_OF, + scopeMode = AccessSurfaceScopeMode.SELECTED_ENVIRONMENT_PLUS_GLOBAL, + permissions = listOf("containers:list"), + ), + AccessSurface( + id = "settings.category.users", + kind = AccessSurfaceKind.SETTINGS_CATEGORY, + accessMode = AccessSurfaceAccessMode.PERMISSIONS, + matchMode = AccessSurfaceMatchMode.ANY_OF, + scopeMode = AccessSurfaceScopeMode.GLOBAL_ONLY, + permissions = listOf("users:list"), + ), + ), + ) + + @Test + fun `manifest replaces broad admin reachability and follows selected environment`() { + val user = User( + id = "u1", + username = "restricted", + permissionsByEnv = mapOf("edge-a" to listOf("containers:list")), + ) + + assertTrue(canAccessTab(AppTab.Containers, user, true, manifest, "edge-a")) + assertFalse(canAccessTab(AppTab.Containers, user, true, manifest, "edge-b")) + assertFalse(canAccessTab(AppTab.Users, user, true, manifest, "edge-a")) + } + + @Test + fun `older servers retain admin fallback`() { + val legacyAdmin = User(id = "admin", username = "admin", roles = listOf("admin")) + val legacyUser = User(id = "viewer", username = "viewer") + + assertTrue(canAccessTab(AppTab.Users, legacyAdmin, false, null, "0")) + assertFalse(canAccessTab(AppTab.Users, legacyUser, false, null, "0")) + assertTrue(canAccessTab(AppTab.Containers, legacyUser, false, null, "0")) + } + + @Test + fun `unknown future surfaces fail closed without breaking unrelated fallback`() { + val user = User(id = "u1", username = "user", permissionsByEnv = emptyMap()) + val unknownOnly = PermissionsManifest( + accessSurfaces = listOf( + AccessSurface( + id = "route.containers", + kind = AccessSurfaceKind("future"), + accessMode = AccessSurfaceAccessMode("future"), + matchMode = AccessSurfaceMatchMode("future"), + scopeMode = AccessSurfaceScopeMode("future"), + ), + ), + ) + assertFalse(canAccessTab(AppTab.Containers, user, true, unknownOnly, "0")) + assertTrue(canAccessTab(AppTab.Events, user, true, PermissionsManifest(), "0")) + } + + @Test + fun `manifest load failures fail closed instead of using legacy fallback`() { + val admin = User(id = "admin", username = "admin", roles = listOf("admin")) + + assertFalse( + canAccessTab( + tab = AppTab.Containers, + user = admin, + supportsV2 = true, + manifest = null, + environmentId = "0", + allowLegacyFallback = false, + ), + ) + } +} diff --git a/app/src/test/java/app/getarcane/android/core/AuthenticationMethodAvailabilityTest.kt b/app/src/test/java/app/getarcane/android/core/AuthenticationMethodAvailabilityTest.kt index 1e62a38..d0b4957 100644 --- a/app/src/test/java/app/getarcane/android/core/AuthenticationMethodAvailabilityTest.kt +++ b/app/src/test/java/app/getarcane/android/core/AuthenticationMethodAvailabilityTest.kt @@ -13,6 +13,22 @@ import org.junit.Assert.assertTrue import org.junit.Test class AuthenticationMethodAvailabilityTest { + @Test + fun `local auth follows explicit setting and preserves older-server default`() = runBlocking { + assertEquals( + AuthenticationMethodState.UNAVAILABLE, + probeLocalAuthAvailability { mapOf("authLocalEnabled" to "false") }, + ) + assertEquals( + AuthenticationMethodState.AVAILABLE, + probeLocalAuthAvailability { mapOf("oidcEnabled" to "false") }, + ) + assertEquals( + AuthenticationMethodState.ERROR, + probeLocalAuthAvailability { throw ArcaneError.Transport("offline") }, + ) + } + @Test fun `bridge available and legacy availability true shows passkey`() = runBlocking { val result = probePasskeyAvailability( @@ -102,6 +118,10 @@ class AuthenticationMethodAvailabilityTest { ) var availability = AuthenticationMethodAvailability().beginAll() + availability = availability.applyLocal( + availability.localProbeGeneration, + AuthenticationMethodState.AVAILABLE, + ) availability = availability.applyPasskey( availability.passkeyProbeGeneration, PasskeyAvailabilityResult( @@ -136,6 +156,10 @@ class AuthenticationMethodAvailabilityTest { loadStatus = { error("must not run") }, ) var availability = AuthenticationMethodAvailability().beginAll() + availability = availability.applyLocal( + availability.localProbeGeneration, + AuthenticationMethodState.AVAILABLE, + ) availability = availability.applyPasskey( availability.passkeyProbeGeneration, PasskeyAvailabilityResult( @@ -158,6 +182,10 @@ class AuthenticationMethodAvailabilityTest { var availability = AuthenticationMethodAvailability().beginAll() val oldOidcGeneration = availability.oidcProbeGeneration val oldPasskeyGeneration = availability.passkeyProbeGeneration + availability = availability.applyLocal( + availability.localProbeGeneration, + AuthenticationMethodState.AVAILABLE, + ) availability = availability.applyOidc( oldOidcGeneration, OidcAvailabilityResult(AuthenticationMethodState.AVAILABLE, oidcStatus()), @@ -184,6 +212,7 @@ class AuthenticationMethodAvailabilityTest { ) assertEquals(AuthenticationMethodState.LOADING, availability.oidcState) + assertEquals(AuthenticationMethodState.LOADING, availability.localState) assertEquals(AuthenticationMethodState.LOADING, availability.passkeyLoginState) assertEquals(AuthenticationMethodState.LOADING, availability.passkeyBridgeState) assertNull(availability.oidcStatus) @@ -199,16 +228,28 @@ class AuthenticationMethodAvailabilityTest { assertFalse(visibility.showPasskey) assertFalse(visibility.showOidc) assertFalse(visibility.showOidcDisclosure) - assertTrue(visibility.showPassword) + assertFalse(visibility.showPassword) + assertTrue(visibility.checking) } @Test fun `OIDC availability preserves password fallback disclosure`() { var availability = AuthenticationMethodAvailability().beginAll() + availability = availability.applyLocal( + availability.localProbeGeneration, + AuthenticationMethodState.AVAILABLE, + ) availability = availability.applyOidc( availability.oidcProbeGeneration, OidcAvailabilityResult(AuthenticationMethodState.AVAILABLE, oidcStatus()), ) + availability = availability.applyPasskey( + availability.passkeyProbeGeneration, + PasskeyAvailabilityResult( + AuthenticationMethodState.UNAVAILABLE, + AuthenticationMethodState.UNAVAILABLE, + ), + ) val providerPrimary = loginActionVisibility(availability, showPasswordForm = false) val passwordFallback = loginActionVisibility(availability, showPasswordForm = true) @@ -221,6 +262,44 @@ class AuthenticationMethodAvailabilityTest { assertTrue(passwordFallback.showOidcDisclosure) } + @Test + fun `disabled local auth never exposes password or OIDC fallback disclosure`() { + var availability = AuthenticationMethodAvailability().beginAll() + availability = availability.applyLocal( + availability.localProbeGeneration, + AuthenticationMethodState.UNAVAILABLE, + ) + availability = availability.applyOidc( + availability.oidcProbeGeneration, + OidcAvailabilityResult(AuthenticationMethodState.AVAILABLE, oidcStatus()), + ) + availability = availability.applyPasskey( + availability.passkeyProbeGeneration, + PasskeyAvailabilityResult( + AuthenticationMethodState.UNAVAILABLE, + AuthenticationMethodState.AVAILABLE, + ), + ) + + val visibility = loginActionVisibility(availability, showPasswordForm = true) + assertTrue(visibility.showOidc) + assertFalse(visibility.showOidcDisclosure) + assertFalse(visibility.showPassword) + } + + @Test + fun `terminal unavailable states show configuration message`() { + val availability = AuthenticationMethodAvailability( + localState = AuthenticationMethodState.UNAVAILABLE, + oidcState = AuthenticationMethodState.UNAVAILABLE, + passkeyLoginState = AuthenticationMethodState.ERROR, + passkeyBridgeState = AuthenticationMethodState.ERROR, + ) + val visibility = loginActionVisibility(availability, showPasswordForm = false) + assertTrue(visibility.noMethodsAvailable) + assertFalse(visibility.checking) + } + private fun oidcStatus( envForced: Boolean = false, envConfigured: Boolean = false, diff --git a/app/src/test/java/app/getarcane/android/core/ServerFeatureSupportTest.kt b/app/src/test/java/app/getarcane/android/core/ServerFeatureSupportTest.kt index 0e647f0..159317f 100644 --- a/app/src/test/java/app/getarcane/android/core/ServerFeatureSupportTest.kt +++ b/app/src/test/java/app/getarcane/android/core/ServerFeatureSupportTest.kt @@ -20,6 +20,14 @@ class ServerFeatureSupportTest { assertFalse(version("1.99.0", semver = true).supportsContainerReliabilityActions()) } + @Test + fun standaloneManagementGateStartsAtArcane2100() { + assertFalse(version("2.9.9").supportsContainerManagementWorkflows()) + assertTrue(version("2.10.0").supportsContainerManagementWorkflows()) + assertTrue(version("v2.12.0").supportsContainerManagementWorkflows()) + assertFalse(version("dev", semver = false).supportsContainerManagementWorkflows()) + } + private fun version(value: String, semver: Boolean = true) = VersionInfo( currentVersion = value, revision = "revision", diff --git a/app/src/test/java/app/getarcane/android/nav/AdaptiveNavigationTest.kt b/app/src/test/java/app/getarcane/android/nav/AdaptiveNavigationTest.kt index 0a36426..477cda3 100644 --- a/app/src/test/java/app/getarcane/android/nav/AdaptiveNavigationTest.kt +++ b/app/src/test/java/app/getarcane/android/nav/AdaptiveNavigationTest.kt @@ -45,4 +45,16 @@ class AdaptiveNavigationTest { assertFalse(AdaptiveNavigation.usesSettingsHost(AppTab.Containers)) assertFalse(AdaptiveNavigation.usesSettingsHost(AppTab.Networks)) } + + @Test + fun serverPolicyCanReachFormerlyAdminOnlyDestination() { + val available = AdaptiveNavigation.availableTabs( + isAdmin = false, + supportsV2 = true, + canReadVariables = false, + canAccess = { it == AppTab.Users }, + ) + + assertEquals(listOf(AppTab.Users), available) + } } diff --git a/app/src/test/java/app/getarcane/android/nav/MainTabSelectionTest.kt b/app/src/test/java/app/getarcane/android/nav/MainTabSelectionTest.kt index 0f2f4f1..20c66f2 100644 --- a/app/src/test/java/app/getarcane/android/nav/MainTabSelectionTest.kt +++ b/app/src/test/java/app/getarcane/android/nav/MainTabSelectionTest.kt @@ -193,6 +193,34 @@ class MainTabSelectionTest { ) } + @Test + fun fallsBackWhenSelectedTabSurfaceIsNoLongerReachable() { + assertEquals( + AppTab.Dashboard.id, + MainTabSelection.normalize( + selectedTabId = AppTab.Images.id, + visibleTabs = defaultVisibleTabs, + isAdmin = false, + supportsV2 = true, + canAccess = { it != AppTab.Images }, + ), + ) + } + + @Test + fun manifestPolicyOverridesLegacyAdminFlag() { + assertEquals( + AppTab.Users.id, + MainTabSelection.normalize( + selectedTabId = AppTab.Users.id, + visibleTabs = defaultVisibleTabs, + isAdmin = false, + supportsV2 = true, + canAccess = { it == AppTab.Users }, + ), + ) + } + @Test fun keepsHiddenAdminTabWhenUserIsAllowed() { assertEquals( diff --git a/app/src/test/java/app/getarcane/android/ui/screens/containers/ContainerConfigurationTest.kt b/app/src/test/java/app/getarcane/android/ui/screens/containers/ContainerConfigurationTest.kt new file mode 100644 index 0000000..ba994a8 --- /dev/null +++ b/app/src/test/java/app/getarcane/android/ui/screens/containers/ContainerConfigurationTest.kt @@ -0,0 +1,71 @@ +package app.getarcane.android.ui.screens.containers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContainerConfigurationTest { + private val draft = ContainerConfigurationDraft( + name = "worker", + image = "busybox:1.37", + command = "sh\n-c\necho ready", + environment = "TOKEN=secret\nMODE=test", + labels = "team=mobile", + binds = "data:/data", + ports = "80/tcp=127.0.0.1|8080", + networks = "bridge", + restartPolicy = "unless-stopped", + memoryMb = "128", + cpus = "0.5", + ) + + @Test + fun createPayloadMapsSupportedConfiguration() { + val payload = buildContainerConfigurationPayload(ContainerConfigurationMode.CREATE, draft) + assertTrue(payload.errors.isEmpty()) + val create = requireNotNull(payload.create) + assertEquals("worker", create.name) + assertEquals(listOf("TOKEN=secret", "MODE=test"), create.environment) + assertEquals("8080", create.hostConfig?.portBindings?.get("80/tcp")?.single()?.hostPort) + assertEquals(128L * 1024L * 1024L, create.hostConfig?.memory) + assertEquals(500_000_000L, create.hostConfig?.nanoCpus) + } + + @Test + fun editPayloadUsesRecreateContractWithoutRenameOrUnownedSections() { + val edit = requireNotNull( + buildContainerConfigurationPayload(ContainerConfigurationMode.EDIT, draft).edit, + ) + assertNull(edit.name) + assertNull(edit.healthcheck) + assertNull(edit.credentials) + assertNull(edit.hostConfig?.mounts) + assertNull(edit.hostConfig?.capAdd) + } + + @Test + fun invalidConfigurationFailsBeforeNetworkMutation() { + val payload = buildContainerConfigurationPayload( + ContainerConfigurationMode.CREATE, + draft.copy(name = "", image = "", labels = "broken", ports = "broken", cpus = "many"), + ) + assertFalse(payload.errors.isEmpty()) + assertNull(payload.create) + assertTrue(payload.errors.any { "Name" in it }) + assertTrue(payload.errors.any { "port" in it }) + } + + @Test + fun numericOverflowFailsBeforeNetworkMutation() { + val payload = buildContainerConfigurationPayload( + ContainerConfigurationMode.CREATE, + draft.copy(memoryMb = Long.MAX_VALUE.toString(), cpus = "Infinity"), + ) + + assertNull(payload.create) + assertTrue(payload.errors.any { "Memory" in it }) + assertTrue(payload.errors.any { "CPUs" in it }) + } +} diff --git a/app/src/test/java/app/getarcane/android/ui/screens/containers/ContainerWorkflowAccessTest.kt b/app/src/test/java/app/getarcane/android/ui/screens/containers/ContainerWorkflowAccessTest.kt new file mode 100644 index 0000000..a5e5e5a --- /dev/null +++ b/app/src/test/java/app/getarcane/android/ui/screens/containers/ContainerWorkflowAccessTest.kt @@ -0,0 +1,48 @@ +package app.getarcane.android.ui.screens.containers + +import app.getarcane.sdk.models.user.User +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContainerWorkflowAccessTest { + @Test + fun accessTracksSelectedEnvironmentAndAuthorizationLoss() { + val user = User( + id = "u1", + username = "operator", + permissionsByEnv = mapOf( + "edge-a" to listOf( + "containers:create", + "containers:edit", + "containers:read", + "images:commit", + "projects:create", + ), + "edge-b" to listOf("containers:read"), + ), + ) + val allowed = containerWorkflowAccess(user, "edge-a", false, true, true) + val switched = containerWorkflowAccess(user, "edge-b", false, true, true) + val refreshed = containerWorkflowAccess( + user.copy(permissionsByEnv = mapOf("edge-a" to listOf("containers:read"))), + "edge-a", + false, + true, + true, + ) + + assertTrue(allowed.canCreate && allowed.canEdit && allowed.canCommit && allowed.canCompose) + assertFalse(switched.canCreate || switched.canEdit || switched.canCommit || switched.canCompose) + assertFalse(refreshed.canCreate || refreshed.canEdit || refreshed.canCommit || refreshed.canCompose) + } + + @Test + fun oldServerOfflineAndHiddenProjectSurfaceFailClosedForMutations() { + val admin = User(id = "admin", username = "admin", roles = listOf("admin")) + val oldServer = containerWorkflowAccess(admin, "0", false, false, true) + assertFalse(oldServer.canCreate || oldServer.canEdit || oldServer.canCommit || oldServer.canCompose) + assertFalse(containerWorkflowAccess(admin, "0", true, true, true).canCreate) + assertFalse(containerWorkflowAccess(admin, "0", false, true, false).canCompose) + } +} diff --git a/app/src/test/java/app/getarcane/android/ui/screens/settings/SettingsRouteSafetyTest.kt b/app/src/test/java/app/getarcane/android/ui/screens/settings/SettingsRouteSafetyTest.kt index 52b6f30..040269f 100644 --- a/app/src/test/java/app/getarcane/android/ui/screens/settings/SettingsRouteSafetyTest.kt +++ b/app/src/test/java/app/getarcane/android/ui/screens/settings/SettingsRouteSafetyTest.kt @@ -99,8 +99,8 @@ class SettingsRouteSafetyTest { @Test fun onlyEnvironmentBoundDetailsResetWhenEnvironmentChanges() { assertTrue(isEnvironmentScopedSettingsDetail(SettingsRoutes.NOTIFICATION_PROVIDER)) - assertTrue(isEnvironmentScopedSettingsDetail(SettingsRoutes.SYSTEM_CATEGORY)) - assertTrue(isEnvironmentScopedSettingsDetail(SettingsRoutes.UPGRADE)) + assertFalse(isEnvironmentScopedSettingsDetail(SettingsRoutes.SYSTEM_CATEGORY)) + assertFalse(isEnvironmentScopedSettingsDetail(SettingsRoutes.UPGRADE)) assertFalse(isEnvironmentScopedSettingsDetail(SettingsRoutes.USER_DETAIL)) assertFalse(isEnvironmentScopedSettingsDetail(SettingsRoutes.ROLE_DETAIL)) assertFalse(isEnvironmentScopedSettingsDetail(AppTab.Notifications.id)) diff --git a/app/src/test/java/app/getarcane/android/ui/screens/settings/system/SystemSettingsContractTest.kt b/app/src/test/java/app/getarcane/android/ui/screens/settings/system/SystemSettingsContractTest.kt new file mode 100644 index 0000000..194ddb9 --- /dev/null +++ b/app/src/test/java/app/getarcane/android/ui/screens/settings/system/SystemSettingsContractTest.kt @@ -0,0 +1,54 @@ +package app.getarcane.android.ui.screens.settings.system + +import app.getarcane.sdk.serialization.ArcaneJson +import kotlinx.serialization.encodeToString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SystemSettingsContractTest { + @Test + fun currentCategoryInventoryUsesAcceptedPullPoliciesAndConditionalFields() { + val fields = systemSettingsCategories.flatMap(SettingsCategoryDef::fields) + val pullPolicy = fields.single { it.key == "defaultDeployPullPolicy" }.type as SettingFieldType.Select + assertEquals(listOf("missing", "always", "never"), pullPolicy.options) + assertFalse("build" in pullPolicy.options) + assertEquals( + SettingFieldCondition("pruneImageMode", "olderThan"), + fields.single { it.key == "pruneImageUntil" }.visibleWhen, + ) + assertNull(fields.find { it.key == "scheduledPruneContainers" }) + assertTrue(fields.any { it.key == "templatesDirectory" }) + assertTrue(fields.any { it.key == "trivyConfig" }) + } + + @Test + fun validationChecksRangesAndSelectValues() { + val fields = systemSettingsCategories.flatMap(SettingsCategoryDef::fields) + val errors = validateSettingChanges( + fields, + mapOf( + "defaultDeployPullPolicy" to "build", + "activityHistoryRetentionDays" to "3651", + "trivyConcurrentScanContainers" to "zero", + ), + ) + assertEquals(3, errors.size) + } + + @Test + fun updateBuilderPreservesUnknownLoadedFieldsBySendingOnlyKnownChanges() { + val update = updateSettingsFrom( + mapOf( + "templatesDirectory" to "/srv/templates", + "unknownFutureSetting" to "preserve-on-server", + ), + ) + val encoded = ArcaneJson.default.encodeToString(update) + assertTrue("templatesDirectory" in encoded) + assertFalse("unknownFutureSetting" in encoded) + assertFalse("projectsDirectory" in encoded) + } +} diff --git a/docs/ios-android-gap-analysis.md b/docs/ios-android-gap-analysis.md index 68d8cc6..90d5c7a 100644 --- a/docs/ios-android-gap-analysis.md +++ b/docs/ios-android-gap-analysis.md @@ -1,6 +1,6 @@ # iOS-to-Android gap analysis -Last reviewed: 2026-09-11 +Last reviewed: 2026-09-17 This document compares Arcane's iOS application with the Android application to guide Android product planning. It is a source-analysis snapshot, not a promise that Android will reproduce every @@ -12,15 +12,20 @@ The analysis is pinned to these product revisions: | Component | Revision | Notes | | --- | --- | --- | -| iOS | [`6088fcc0ef04dc906ce74e9129dffa96894a6da5`](https://github.com/getarcaneapp/ios/tree/6088fcc0ef04dc906ce74e9129dffa96894a6da5) | Current `origin/main`; mobile behavior authority for this refresh | -| iOS resolved Swift SDK | [`facc40e20e32b7d6600b004fd744a214bbd2a166`](https://github.com/getarcaneapp/libarcane-swift/tree/facc40e20e32b7d6600b004fd744a214bbd2a166) | Current `origin/main`; compared for account, passkey/MFA, variables, and upgrade contracts | -| Android | [`75fde394f61ee8838f201f25b42a3e83af146513`](https://github.com/getarcaneapp/android/tree/75fde394f61ee8838f201f25b42a3e83af146513) | Current `origin/main` and Image Insights branch base | -| Kotlin SDK | [`275e7a533bd5f68063d3e275012041d0f846e251`](https://github.com/getarcaneapp/libarcane-kotlin/tree/275e7a533bd5f68063d3e275012041d0f846e251) | Image History PR #9 head; based on current `origin/main` `b21faefd091de53fa30e6b5b910c66f49ec8076c` | -| Arcane | [`5df09ed475c4bac4ab6f54e1b9bdde1ac1c55105`](https://github.com/getarcaneapp/arcane/tree/5df09ed475c4bac4ab6f54e1b9bdde1ac1c55105) | Current `origin/main` wire-contract authority; live compatibility exercised on 2.10.2 tag `670ee2b` | - -This refresh revalidated Image Insights against current iOS, both SDKs, Arcane handlers/types, -Android `origin/main`, and the canonical backlog. It also reconciles the completed Projects -Workspace and Accounts and Administration batches. +| iOS | [`8d13fdb5cd61a62b1d666e9e982a2670d86086c3`](https://github.com/getarcaneapp/ios/tree/8d13fdb5cd61a62b1d666e9e982a2670d86086c3) | Current `origin/main`; mobile outcome authority | +| iOS resolved Swift SDK | [`9d1c931f664158a20f0a8295ddf957afb2ee3390`](https://github.com/getarcaneapp/libarcane-swift/tree/9d1c931f664158a20f0a8295ddf957afb2ee3390) | Current `origin/main`; contract comparison authority | +| Android | [`b775eb982d0e9df387ce0226020c31a9e90acd2d`](https://github.com/getarcaneapp/android/tree/b775eb982d0e9df387ce0226020c31a9e90acd2d) | Current `origin/main` and base of the handoff branch | +| Kotlin SDK | [`5546f35ba5dcc3ca0b378ddd1101a1d97f690c03`](https://github.com/getarcaneapp/libarcane-kotlin/tree/5546f35ba5dcc3ca0b378ddd1101a1d97f690c03) | Current `origin/main`; merge of handoff SDK PR #12 (feature head `614e5da9eaa808ed9401a72c5e7171709736234c`) | +| Arcane | [`f5d6f37dba1c9cab72271d6e9d183cbe1f7fd79e`](https://github.com/getarcaneapp/arcane/tree/f5d6f37dba1c9cab72271d6e9d183cbe1f7fd79e) | Current `origin/main` wire-contract authority; live compatibility exercised on 2.10.2 | + +This refresh closes the bounded companion-app handoff scope: truthful authentication availability, +server-described access reachability, current system-settings contracts, and the highest-value +standalone-container mutations. It does **not** claim complete Arcane administration-console parity. +Deferred expansion is tracked in Android issues +[#62](https://github.com/getarcaneapp/android/issues/62), +[#63](https://github.com/getarcaneapp/android/issues/63), +[#64](https://github.com/getarcaneapp/android/issues/64), and +[#65](https://github.com/getarcaneapp/android/issues/65). Parity status also reflects the locally validated PAR-001 session-restoration hardening and PAR-002 server-session scoping layered on the pinned Android base. Their exact implementation and validation @@ -60,29 +65,24 @@ authentication settings, system settings, builds, and upgrades are all represent also has live streams for important operational views and a larger JVM unit-test body plus working CI than the iOS repository. -The largest remaining difference is depth and continuity, not the count of resource screens. iOS +The largest remaining difference is optional administration depth and continuity, not the count of resource screens. iOS still has disk-backed stale-while-revalidate caching, adaptive tablet navigation, persistent deployment progress, several native entry points, activity-start feedback, and interactive network topology. Android now covers profile, project-file workspace, deploy options, template discovery, registry identity, passkeys/MFA, scoped global variables, rich log continuity, container lifecycle actions, and Image Insights through typed SDK contracts. -The most urgent Android work is smaller than those strategic gaps. PAR-002 closes the change-server -state and credential-scoping defect; PAR-005 closes the unreachable admin-navigation paths; and -PAR-006 gives App Settings deliberate Android/project links plus Android-owned, version-filtered -release notes. PAR-009 now persists Light/Dark/Auto and accent choices at the app theme root, while -PAR-007 deliberately excludes that server-adjacent preference file from backup. The old -recommendation to expose a separate -environment list is no longer a parity blocker: iOS 0.7.0 deliberately makes the dashboard its -single fleet destination, which is compatible with Android's dashboard-plus-detail outcome. +The final handoff batch makes authentication choices truthful, consumes the server's permission +manifest for destination/action reachability, reconciles System Settings with current wire types, +and adds standalone-container create, edit/recreate, commit, and Compose-to-project workflows. The +remaining items are explicit maintenance/product expansion, not a reason to represent the core +companion application as feature-complete with the full web administration console. The recommended sequence is: -1. Add persistent long-running operation state and resilient cached reads. -2. Add Android-native equivalents for adaptive navigation, widgets, shortcuts, deep links, and - ongoing-operation notifications. -3. Consider optional product expansion such as multi-server profiles only after the operational - foundation is reliable. +1. Maintain the SDK-first contract boundary and the server-driven permission surface mapping. +2. Finish release-candidate runtime evidence, especially current-server and physical-device lanes. +3. Evaluate deferred expansion only through issues #62–#65 rather than reopening a blanket parity goal. ## Detailed capability matrix @@ -92,9 +92,10 @@ The recommended sequence is: | --- | --- | --- | --- | | Application architecture | SwiftUI state machine with setup, authentication, login, and authenticated states; service/store ownership around the SDK. | Single `ComponentActivity`, Compose auth router, central `ArcaneClientManager`, and screen-local stores. See `app/src/main/kotlin/app/getarcane/android/MainActivity.kt`, `ui/ArcaneApp.kt`, and `core/ArcaneClientManager.kt`. | **Parity** for the core application lifecycle. Preserve one client/auth owner. | | Configurable primary tabs | Four user-swappable resource tabs plus Settings, with tab state and independent navigation. | Four user-swappable resource tabs plus fixed Settings; selected tabs persist through `nav/NavTabsStore.kt`. | **Parity** in basic configuration. | -| Adaptive large-screen navigation | Compact navigation and an optional regular-width sidebar/drawer. | Bottom navigation only; no tablet-adaptive rail/sidebar strategy was found. | **Android gap.** Add a `NavigationSuiteScaffold`-style adaptive shell or equivalent after navigation defects are fixed. | -| Per-tab navigation continuity | Independent navigation stacks, environment-aware rebuild, deep-link restoration. | Android rebuilds the selected tab's content and loses that tab's nested stack when switching tabs. | **Android gap.** Preserve independent stacks across tab switches; separately validate configuration-change and process-recreation restoration. | -| Deep links and external entry points | Deep links can select tab/environment/container/project; quick actions and widgets use them. | OIDC callback handling exists, but no comparable authenticated resource deep-link system was found. | **Android gap.** Define stable internal routes before widgets and shortcuts. | +| Adaptive large-screen navigation | Compact navigation and an optional regular-width sidebar/drawer. | Android uses bottom navigation below 600 dp, a rail/More sheet at medium width, and a grouped permanent drawer when expanded. | **Android-native parity.** | +| Per-tab navigation continuity | Independent navigation stacks, environment-aware rebuild, deep-link restoration. | Hoisted per-tab navigation owners preserve nested state, reset environment-scoped stacks on environment change, and normalize inaccessible restored selections. | **Parity** for the supported stacks. | +| Deep links and external entry points | Deep links can select tab/environment/container/project; quick actions and widgets use them. | Versioned authenticated routes, static/dynamic shortcuts, operation notifications, and the fleet widget share server/environment/resource validation. | **Android-native parity.** Permission loss and stale routes fail safely. | +| Authorization reachability | Server permission/access-surface metadata controls routes and actions. | Android loads the authoritative manifest per signed-in server, evaluates known surfaces defensively, filters navigation and Settings rows, and rejects stale restored, deep-linked, shortcut, and dashboard-card destinations after permission loss. | **Parity for described surfaces.** Old manifests retain the legacy admin/permission fallback; unknown future surfaces fail closed. | | Release notes | Version-aware release notes display automatically when appropriate. | A manually reachable What's New surface shows Android-owned notes at or below the installed version and identifies only an exact installed-version match. Android does not automatically present new-version notes. | **Partial.** The release data and version boundary are safe; add automatic presentation only from the exact installed-version mapping. | | Appearance | Accent, sidebar preference, alternate icons, material compatibility, motion-aware polish. | Accent and Light/Dark/Auto persist through app-owned preferences and drive the application theme root. They intentionally reset to defaults after reinstall/restore because server and environment identity shares the same protected preference file. | **Partial.** Core theme persistence is complete; iOS retains additional icon, sidebar, and motion-aware appearance options. | | Localization | English-only; future-language intent is visible. | Most user-visible text is hard-coded; only minimal string resources exist. | **Shared gap**, with higher Android remediation cost. New work should use resources without coupling a feature to a full rewrite. | @@ -105,12 +106,12 @@ The recommended sequence is: | Capability | iOS baseline | Android baseline | Status and action | | --- | --- | --- | --- | | Server setup | Server URL setup with DNS/bootstrap retry and local-server allowances. | URL normalization and server setup exist. | **Parity** for the primary outcome; compare error recovery during live testing. | -| Password authentication | Password login, secure persisted credentials/tokens, session restore, and logout. | Password login, encrypted token storage, restoration, and logout via `ArcaneClientManager`. | **Parity.** | -| OIDC | Uses `ASWebAuthenticationSession` and public provider information. | Current and legacy OIDC callback/deep-link handling. | **Parity** at the product level; device-test provider variants. | -| Passkeys and MFA | Passkey sign-in, passkey enrollment/rename/delete, password or passkey step-up, MFA policy, and recovery on supported Arcane servers. | Typed Kotlin contracts and a server-origin mobile bridge integrate Credential Manager with capability-gated login, account enrollment/rename/delete, step-up, MFA policy, and recovery. | **Parity** for supported-server outcomes. API 30 verified capability/status, password step-up, and safe browser cancellation; a completing WebAuthn provider remains a release-environment integration check. | +| Password authentication | Password login, secure persisted credentials/tokens, session restore, and logout. | Password login is shown only when public `authLocalEnabled` is not explicitly false; omission remains the older-server compatibility default. Method probes are independent and actions stay hidden until all probes settle. | **Android-native parity with an intentional truthfulness improvement.** API 30 verified enabled/disabled UI and restoration without a login-action flash. | +| OIDC | Uses `ASWebAuthenticationSession` and public provider information. | Current and legacy OIDC callback/deep-link handling; the action requires enabled, complete server configuration and never changes local/passkey state. | **Parity** for disabled-state behavior; an enabled disposable provider was unavailable in this handoff lane. | +| Passkeys and MFA | Passkey sign-in, passkey enrollment/rename/delete, password or passkey step-up, MFA policy, and recovery on supported Arcane servers. Current iOS exposes the login button unconditionally. | Typed Kotlin contracts and a server-origin bridge integrate Credential Manager with capability-gated login and account/MFA flows. Login additionally requires server availability; `NotFound` alone falls back to the validated bridge. | **Intentional Android deviation.** Unusable methods are hidden. API 30 verified live `false`; the 404 and error branches are deterministic-test evidence. Recovery-code access remains independent. | | Demo mode | Demo provisioning and session behavior. | Demo provisioning, heartbeat, and countdown. | **Parity**, with Android exposing explicit heartbeat/countdown behavior. | | User profile | View/update display name and email, change password, avatar/Gravatar handling, sign out, and change server. | A signed-in Account route provides the same outcomes, remains distinct from administrator user management, and refreshes shared current-user state after mutation. | **Parity.** Validation, password policy failures, re-login, sign-out, and change-server passed live API 30 testing. | -| Multiple server profiles | No complete multi-profile manager was identified; change-server flow exists. | One active server is persisted. PAR-002 canonicalizes its origin, scopes tokens and process caches, rotates client/session ownership, and durably clears the saved server and credential binding before exposing setup. | **Shared profile gap; single-server switching is hardened.** Keep multi-profile work deferred until cache, operation, and route identity are equally scoped. | +| Multiple server profiles | Current iOS can save and switch server profiles; it does not simultaneously aggregate independent servers. | Android persists one active server, with credentials, routes, operations, and process state fenced to that server/account identity. | **Deferred Android product expansion.** PAR-502 and issue #65 cover saved profile switching; simultaneous multi-server aggregation remains out of scope. | | Biometric application lock | No core capability identified. | No core capability identified. | **Shared gap**, not required for iOS parity. Consider separately if threat modeling supports it. | ### Dashboard and environment management @@ -121,16 +122,16 @@ The recommended sequence is: | Live dashboard updates | v2 stream with legacy fallback and bounded concurrent stats streams. | Dashboard streaming with reconnect behavior and resource statistics streams. | **Partial.** Validate fallback/version behavior and connection limits under many environments. | | Environment selection | Active environment selection and environment-aware navigation. | Active environment selection, detail/test, persistence, and client rebuild. | **Parity** for selection. | | Environment management | The dashboard is the single fleet destination and opens environment details/actions; it does not claim full CRUD. | Dashboard cards open environment details and selection; a separate list/detail/test surface exists but is not a primary route or full CRUD. | **Parity** for the current read/select/detail outcome. Exposing the extra Android list is a product choice, not a parity prerequisite. | -| Fleet pagination | Environment-backed views load the complete relevant fleet. | The SDK environment list defaults to 20. `DashboardScreen`, `UpdatesScreen`, `AllEnvironmentsImageUpdatesScreen`, and `EnvironmentListScreen` call it without pagination, silently omitting environments above 20. | **Android correctness defect.** Implement explicit paging or a deliberate complete-fleet query and test fleets of 0, 20, 21, and multiple pages. | +| Fleet pagination | Environment-backed views load the complete relevant fleet. | Shared complete-list pagination is used by Dashboard, Updates, Activity, environment administration, role assignment, variables, and the new System Settings target picker. | **Parity.** Boundary and repeated-page tests prevent silent truncation. | | Offline dashboard snapshot | Disk cache and last-known server snapshots support stale display. | No disk-backed response cache/database was found. | **Android gap.** See the resilience section. | ### Containers | Capability | iOS baseline | Android baseline | Status and action | | --- | --- | --- | --- | -| Inventory and filtering | List/search/filter, selection, bulk deletion, and prune. | List/search/filter, pin, resource actions, and prune. | **Partial.** Confirm bulk selection/delete parity. | -| Lifecycle actions | Start, stop, restart, pause, unpause, redeploy, rename, and delete as applicable. | Start, stop, restart, unpause, redeploy, rename, and delete are visible across list/detail flows. Pause and kill are absent from the UI even though the pinned Kotlin SDK exposes them. | **Android UI gap.** Add pause and kill with current-state gating and explicit confirmation appropriate to their impact. | -| Detail depth | Configuration, health, ports, environment, labels, mounts, and networks. | Overview, live stats, logs, inspect/copy, and operational actions. | **Partial.** Compare detail fields on real containers and fill high-value metadata gaps. | +| Inventory and filtering | List/search/filter, selection, bulk deletion, and prune. | List/search/filter, pin, permission-specific actions, create, and prune. | **Core parity;** bulk-selection presentation is optional expansion under issue #62. | +| Lifecycle and configuration | Start, stop, restart, pause, unpause, redeploy, delete, create, edit/recreate, commit to image, and generate Compose where supported. Older rename references do not correspond to a current authoritative route. | Android exposes typed, permission/surface/version-gated lifecycle actions plus standalone create, edit/recreate, commit, and Compose generation/create-project on Arcane 2.10 or newer. Recreate confirmation names the container and environment; Compose-owned containers are excluded. Rename is deliberately absent. | **Core companion parity.** Live server validation covered create, recreate, commit, Compose generation/project creation, validation/authorization failure, and cleanup. Full UI cancellation/current-server lanes remain release-candidate evidence. | +| Detail depth | Configuration, health, ports, environment, labels, mounts, and networks. | Overview, live stats, logs, inspect/copy, operational actions, and an editable supported-configuration subset. Fields Android does not edit remain omitted so the server preserves them. | **Partial by design.** Advanced writable volume/image operations remain in issue #63. | | Statistics | Live CPU, memory, network, and I/O presentation. | Live statistics and charts. | **Parity.** | | Logs | Search/filter, pause, timestamps, retention, ANSI rendering, copy/share/export. | Live logs and ANSI handling are present, but the iOS-level copy/share/export workflow was not identified. | **Partial.** Add select/copy/share/export and verify cancellation/reconnect behavior. | | Terminal | Interactive terminal with special keys, copy, and clear. | Interactive terminal exists. | **Partial to parity.** Device-test IME, lifecycle, special-key, and reconnect behavior. | @@ -196,7 +197,7 @@ release. Do not add application-local HTTP calls or duplicate DTOs. | Global variables | v2 global variables support create/edit/delete, secret values, all/specific-environment scoping, sync status, and explicit sync. | Typed Kotlin contracts back permission-gated list/search/create/edit/delete/sync flows with all/selected scope and protected secret handling. | **Parity.** Live coverage included partial sync across 26 environments and an unauthorized account. | | Template discovery | Search, source filtering, metadata, preview, remote download, deploy, and registry management. | Search/source filters, metadata, preview, remote import, variable resolution, failure recovery, and deployment are implemented. | **Parity.** Completed and live-tested in PAR-114. | | Container registry names | Registries expose a user-facing identity in addition to URL and credentials. | Current Arcane has no independent `name` field; Android derives a stable provider/description/URL identity and preserves encrypted credentials on update. | **Parity for the actual wire contract.** Completed and live-tested in PAR-115. | -| Authentication/system/build/upgrade | Server authentication settings, system information/settings, builds, and upgrade. | Authentication, system, build, and upgrade surfaces include typed per-environment capability gating. | **Parity** in broad coverage. | +| Authentication/system/build/upgrade | Server authentication settings, system information/settings, builds, and upgrade. | System Settings use current keys, string wire types, conditions/ranges, and `missing`/`always`/`never` pull policy. Selecting a target environment does not mutate the app-wide active environment. Partial updates omit unedited/unknown fields and tolerate field-set drift. | **Contract-correct core coverage.** Disposable Arcane 2.10.2 passed a read/write/restore round trip; Build Settings remains its existing separate surface. | | Admin/config destinations as swappable tabs | Administration/configuration destinations are not bottom-tab replacement choices. | Android centralizes bottom-tab eligibility and excludes Users, Notifications, System, Roles, and other configuration destinations under merged PR #5. Their drill-down flows remain available through Settings. | **Parity.** Verify Settings-owned drill-down behavior rather than unsupported primary-admin-tab behavior. | | Documentation/support links | iOS repository links are appropriate to the app. | App Settings centralizes deliberate Android source/issues, Arcane documentation/privacy, and Discord support destinations. | **Parity.** Focused mapping tests prevent regression to iOS repository links. | @@ -248,7 +249,7 @@ features” project; each needs a clear user scenario and data-security review. | Capability | iOS baseline | Android baseline | Status and action | | --- | --- | --- | --- | -| Unit tests | 29 XCTest methods across six files, still focused mainly on utilities and post-0.6 pagination/security regressions. | 44 JVM test files and 261 test methods, with useful coverage of authentication restoration, server credential/cache scoping, navigation, dashboard/updater logic, URL handling, ANSI parsing, container completeness, and backup policy. | **Android strength.** | +| Unit tests | Current source contains 33 Swift test files and 129 test methods. | Current source contains 71 JVM test files and 412 test methods, including authentication restoration/availability, server credential/cache scoping, navigation and access-surface policy, dashboard/updater logic, container contracts, URL handling, ANSI parsing, complete-list loading, and backup policy. | **Android strength.** | | UI/instrumented tests | No meaningful UI test suite identified. | Only the template/example instrumentation test was identified. | **Shared gap.** Add a small navigation/auth/destructive-confirmation suite before attempting broad UI automation. | | Integration/network contract tests | No broad suite identified. | No broad end-to-end contract suite identified. | **Shared gap.** SDK serialization/service tests should carry most wire-contract coverage. | | CI | No repository CI workflow was identified in the inspected iOS baseline. | CI uses JDK 21/API 35 and runs unit tests/build, with optional signed tag release support. | **Android strength.** | diff --git a/docs/ios-parity-task-list.md b/docs/ios-parity-task-list.md index 060c593..9c1baa9 100644 --- a/docs/ios-parity-task-list.md +++ b/docs/ios-parity-task-list.md @@ -1,6 +1,6 @@ # Android iOS-parity task list -Last updated: 2026-09-11 +Last updated: 2026-09-17 This is the working backlog for bringing Arcane Android to product-outcome parity with iOS. It turns the findings in [the pinned gap analysis](ios-android-gap-analysis.md) into issue-sized work; @@ -11,12 +11,13 @@ this canonical backlog through local validation and a review-ready pull request. The source comparison is pinned to: -- iOS `6088fcc0ef04dc906ce74e9129dffa96894a6da5` -- libarcane-swift `facc40e20e32b7d6600b004fd744a214bbd2a166` -- Android `75fde394f61ee8838f201f25b42a3e83af146513` (Image Insights branch base) -- libarcane-kotlin `275e7a533bd5f68063d3e275012041d0f846e251` (Image History PR #9) -- Arcane `5df09ed475c4bac4ab6f54e1b9bdde1ac1c55105` (live compatibility target: - 2.10.2 tag `670ee2b34ea7b0fb2917643229b6ce9070ee9742`) +- iOS `8d13fdb5cd61a62b1d666e9e982a2670d86086c3` +- libarcane-swift `9d1c931f664158a20f0a8295ddf957afb2ee3390` +- Android `b775eb982d0e9df387ce0226020c31a9e90acd2d` (`origin/main` handoff base) +- libarcane-kotlin `5546f35ba5dcc3ca0b378ddd1101a1d97f690c03` (`origin/main` merge of handoff + SDK PR #12; feature head `614e5da9eaa808ed9401a72c5e7171709736234c`) +- Arcane `f5d6f37dba1c9cab72271d6e9d183cbe1f7fd79e` (current contract source; live older-server + compatibility target: 2.10.2) Revalidate conclusions against current source before starting an item. Record the Android, Kotlin SDK, and Arcane server revisions in the resulting issue or pull request. @@ -29,12 +30,44 @@ removed the Arcane Assistant. ## Recommended starting queue -The P0 correctness foundation through **PAR-101** and PAR-501's multi-environment validation are -complete. Continue with the remaining P1 workflow slices. - -The Projects Workspace, Accounts and Administration, Container and Activity Reliability, and Image -Insights batches are complete; the last is on its review branches. Continue with the remaining -Ready items according to priority and dependencies. +The bounded **close parity and maintenance handoff** batch is the final active parity batch. It +targets truthful login choices, server-described reachability, contract-correct System Settings, +and useful standalone-container mutations. It does not target complete administration-console +parity. Further product expansion belongs to issues #62–#65. + +### Close parity and maintenance handoff + +- [x] Authentication methods are independent, loading-safe, server truthful, and preserve the + missing-field/404 compatibility cases. Android intentionally hides unusable Passkey login even + though current iOS exposes it unconditionally. +- [x] Kotlin models current permission presets/access surfaces and evaluates only known surface + semantics; Android gates navigation, Settings rows, restored/deep-linked/shortcut routes, + dashboard callbacks, and relevant create/edit actions. +- [x] System Settings use current keys/types/options/conditions/ranges, preserve partial-update + behavior, and choose a target environment without changing the global active environment. +- [x] Supported standalone-container create, edit/recreate, commit, and Compose-to-project workflows + use typed SDK contracts. Container rename remains absent because current Arcane has no route. +- [x] SDK unit/release assembly and Android unit/debug assembly/lint gates are required before + publication; clean remote-SDK resolution uses merged SDK revision + `5546f35ba5dcc3ca0b378ddd1101a1d97f690c03`. +- [ ] Release-candidate runtime completion remains separate: an enabled disposable OIDC provider, + a provider-completed WebAuthn ceremony, current-Arcane live instance, multi-environment access + changes, and full UI cancellation/failure exercises were not completed in this handoff lane. + +API 30 plus disposable Arcane 2.10.2 verified local auth enabled/disabled, OIDC disabled, passkey +availability false, hidden actions while probes settle, admin/restricted roles, permission loss plus +process recreation, stale deep-link rejection, and the dashboard-card reachability regression found +during testing. Server/API validation covered a System Settings read/write/restore round trip and +container create, edit/recreate, commit, Compose generation, project creation, validation failure, +authorization failure, and cleanup. The compatibility 404 and unknown/old manifest paths remain +deterministic-test evidence rather than mislabeled live evidence. + +Deferred scope is intentionally consolidated rather than split back into parity microtasks: + +- [#62](https://github.com/getarcaneapp/android/issues/62) — optional Android-native/product expansion; +- [#63](https://github.com/getarcaneapp/android/issues/63) — advanced volume and image workflows; +- [#64](https://github.com/getarcaneapp/android/issues/64) — backups, S3, federated, and API-key administration; +- [#65](https://github.com/getarcaneapp/android/issues/65) — saved server profiles and diagnostics. ## Status legend @@ -66,7 +99,9 @@ resilience/native continuity, and **P3** maturity or optional expansion. Depende ## Definition of parity and done -Parity means Android provides the same useful outcome as iOS through Android-native conventions. +For this handoff, parity means Android provides the same useful **core companion-app** outcome as iOS +through Android-native conventions. It does not mean reproducing the complete Arcane web +administration console or every optional iOS integration. It does not mean copying Apple APIs or presentation. Examples include an ongoing notification instead of a Live Activity, Glance instead of WidgetKit, and Android shortcuts/deep links instead of App Intents. @@ -1490,13 +1525,15 @@ The standard checks are: - **Status:** Deferred - **Priority:** P3 - **Dependencies:** PAR-002, PAR-202, PAR-301, PAR-305 -- **Scope:** Specify profiles only after single-server credential, cache, operation, and route scoping - are correct. +- **Scope:** Current iOS now provides saved server profiles and explicit switching, so saved-profile + switching is an iOS outcome Android does not yet implement. Specify it only after single-server + credential, cache, operation, and route scoping remain correct. Simultaneous cross-server fleet + aggregation is a separate product problem and remains deferred. Track both boundaries in issue #65. - **Acceptance criteria:** - [ ] The design covers credentials, cookies, caches, snapshots, operations, routes, active selection, deletion, migration, and concurrent server behavior. - [ ] Switching cannot leak data or actions between servers/users. - - [ ] Product scope distinguishes saved profiles from simultaneous fleet aggregation. + - [ ] Product scope implements saved-profile switching without implying simultaneous fleet aggregation. - [ ] Implementation is split into reviewable persistence, client ownership, and UI tasks. - [ ] **PAR-503 — Evaluate an Android AI assistant** diff --git a/docs/release-readiness.md b/docs/release-readiness.md index ecd21db..40b50e1 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -18,11 +18,13 @@ operator's responsibility. commit and validates both the matching sibling composite build and the public remote fallback. An F-Droid recipe must materialize the exact SDK commit as a sibling `srclib`; a moving `main` branch is never a release pin. -- The current preparation comparison pins are Android - `90b67366638c21c30b2c748347a57bd8f184d491`, iOS - `8d13fdb5cd61a62b1d666e9e982a2670d86086c3`, libarcane-kotlin - `b29695d547b78389ed7230b35cd133f7046b4b52`, and Arcane current source - `194e7ae87f0803bc2b85ed3a9a107fd432993ac4` (`v2.12.0-5-g194e7ae8`). +- The current handoff comparison pins are Android `origin/main` + `b775eb982d0e9df387ce0226020c31a9e90acd2d`, iOS + `8d13fdb5cd61a62b1d666e9e982a2670d86086c3`, libarcane-swift + `9d1c931f664158a20f0a8295ddf957afb2ee3390`, libarcane-kotlin `origin/main` + `5546f35ba5dcc3ca0b378ddd1101a1d97f690c03` (merge of PR #12, feature head + `614e5da9eaa808ed9401a72c5e7171709736234c`), and Arcane current source + `f5d6f37dba1c9cab72271d6e9d183cbe1f7fd79e`. ## Candidate checklist @@ -200,10 +202,61 @@ sufficient; no SDK source change or SDK PR was required. screenshot is retained. F-Droid remains blocked by the SDK license and proprietary credentials dependency documented in `fdroid-release-preparation.md`. +## Close-parity handoff record (2026-09-17) + +This batch deliberately closes a **core companion-app** scope rather than claiming complete iOS or +Arcane administration-console parity. + +- Authentication login actions remain hidden until independent local/OIDC/passkey checks finish. + Explicit `authLocalEnabled=false` hides credentials; missing older-server fields retain password + compatibility. Passkey requires a validated bridge plus server availability, with only typed 404 + falling back to the bridge. This is an intentional truthfulness deviation from current iOS. +- The current permission manifest supplies presets and access surfaces through libarcane-kotlin. + Android gates navigation, Settings rows, nested/restored/external routes, shortcuts, dashboard + callbacks, and relevant mutations. Missing old-server surfaces use the prior permission/admin + fallback; transient manifest failures retain the last snapshot or fail closed, and unknown future + surface semantics do not grant access. +- System Settings match current Arcane keys and string wire types, exclude pull policy `build`, apply + field conditions/ranges, tolerate missing/new fields, and target an environment without changing + global selection. +- Standalone containers support create, supported configuration edit/recreate, commit to image, and + Compose generation followed by project creation/open on Arcane 2.10 or newer. Every workflow keeps its environment ID, + permission/surface/version gate, cancellation owner, and disruption confirmation. Rename is not + exposed because current Arcane has no route. + +The disposable Arcane 2.10.2/API 30 lane verified password enabled/disabled, OIDC disabled, passkey +false, probe loading/restoration, administrator and restricted roles, permission loss across process +recreation, stale deep-link rejection, and the dashboard-card reachability guard. Direct typed API +coverage verified System Settings read/write/restore and container create, recreate, commit, Compose +generation, project creation, validation/authorization failure, and cleanup. It does **not** claim an +enabled OIDC provider, completed WebAuthn ceremony, live current-Arcane instance, multi-environment +permission transition, or the complete UI cancellation/failure matrix; those remain candidate +evidence and must not be marked green from source/unit tests alone. + +### Maintenance guide + +- Treat current iOS source as mobile outcome authority and Arcane handlers/types as wire authority; + web presentation is supporting contract evidence. Record all compared revisions. +- Keep `ArcaneClientManager` as the only app client/auth/environment owner. Put REST, DTO, auth, + WebSocket, and NDJSON work in libarcane-kotlin first; never add app-local API duplicates. +- For SDK changes run `./gradlew :arcane-core:test :arcane-android:assembleRelease`, then point + Android at the pushed SDK revision and run `./gradlew :app:testDebugUnitTest :app:assembleDebug`, + `./gradlew :app:lintDebug`, and `git diff --check`. Validate once from a clean checkout without a + sibling SDK. +- Use only disposable servers/data for mutations, record old/current server versions separately, + label emulator/device versus API-only evidence, and clean only resources created by the task. +- Commit, feature-branch push, PR creation, merge, tagging, signing, and release publication remain + separate authorization boundaries. This handoff authorizes no merge or release. + ## Known limitations and exclusions -- Multi-server profiles (PAR-502), an Android AI assistant (PAR-503), and speculative Swarm workflow - work (PAR-504) are intentionally deferred and are not beta promises. +- PAR-502 now distinguishes iOS-style saved profile switching from simultaneous cross-server + aggregation; both remain deferred under + [#65](https://github.com/getarcaneapp/android/issues/65). Optional product/native expansion is + [#62](https://github.com/getarcaneapp/android/issues/62), advanced volume/image work is + [#63](https://github.com/getarcaneapp/android/issues/63), and backups/S3/federated/API-key breadth + is [#64](https://github.com/getarcaneapp/android/issues/64). An Android AI assistant (PAR-503) and + speculative Swarm workflow (PAR-504) are not beta promises. - The app supports one configured Arcane server identity at a time, with multiple environments owned by that server. Older servers expose explicit unsupported states for newer capabilities. - Localization is incremental; the first resource-backed slice is authentication, navigation,