commit cd6b276b28c5d1789674fa9139781583c61abaf7
parent 8fad1d6ac79bed152cc5b02b32b587558f095081
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 23:43:50 +0000
application: project root lifecycle from native state
- derive explicit root routes from immutable native snapshots
- consume ordered changes without recreating the Rust reducer
- route account intents through typed gateway commands
- keep generated recovery and removal as exclusive native flows
Diffstat:
4 files changed, 64 insertions(+), 80 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt
@@ -6,10 +6,20 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.radroots.studio.ffi.AppSnapshotDto
+import org.radroots.studio.ffi.AppLifecycleDto
import org.radroots.studio.ffi.StudioException
+enum class StudioRoute {
+ BOOTING,
+ ACCOUNTS,
+ ACTIVE_ACCOUNT,
+ FATAL,
+ CLOSED,
+}
+
data class StudioStoreState(
val snapshot: AppSnapshotDto,
+ val route: StudioRoute = snapshot.toStudioRoute(),
val importDraft: String = "",
val generatedKeyBackup: GeneratedKeyBackup? = null,
val pendingRemovalPublicKeyHex: String? = null,
@@ -37,9 +47,9 @@ class StudioAppStore(
init {
launchCommand {
- val registered = gateway.subscribe { snapshot ->
+ val registered = gateway.subscribeChanges { change ->
scope.launch {
- if (!closed) acceptSnapshot(snapshot)
+ if (!closed) acceptSnapshot(change.snapshot)
}
}
if (closed) {
@@ -74,7 +84,7 @@ class StudioAppStore(
fun acknowledgeGeneratedKeyBackup() {
val recovery = pendingGeneratedRecovery ?: return
pendingGeneratedRecovery = null
- runCommand {
+ runSnapshotCommand {
try {
recovery.acknowledge().also {
generatedRecovery.acknowledge()
@@ -90,33 +100,19 @@ class StudioAppStore(
if (command?.isActive == true) return
val input = mutableState.value.importDraft.encodeToByteArray()
mutableState.value = mutableState.value.copy(importDraft = "")
- runCommand {
- try {
- gateway.importSecretKey(input)
- } finally {
- input.fill(0)
- }
- }
+ runTypedCommand(StudioCommand.ImportAccount(input))
}
fun selectAccount(publicKeyHex: String) {
- runCommand { gateway.selectAccount(publicKeyHex) }
+ runTypedCommand(StudioCommand.SelectAccount(publicKeyHex))
}
fun activateAccount(publicKeyHex: String) {
- runCommand {
- gateway.activateAccount(publicKeyHex).also {
- mutableState.value = mutableState.value.copy(accountChooserVisible = false)
- }
- }
+ runTypedCommand(StudioCommand.ActivateAccount(publicKeyHex), hideChooser = true)
}
fun signOut() {
- runCommand {
- gateway.signOut().also {
- mutableState.value = mutableState.value.copy(accountChooserVisible = false)
- }
- }
+ runTypedCommand(StudioCommand.SignOut, hideChooser = true)
}
fun showAccountChooser() {
@@ -128,7 +124,7 @@ class StudioAppStore(
}
fun refreshActiveProfile() {
- runCommand { gateway.refreshActiveProfile() }
+ runTypedCommand(StudioCommand.RefreshProfile)
}
fun requestAccountRemoval(publicKeyHex: String) {
@@ -159,7 +155,7 @@ class StudioAppStore(
fun confirmAccountRemoval() {
val ticket = pendingRemoval ?: return
pendingRemoval = null
- runCommand {
+ runSnapshotCommand {
try {
gateway.confirmAccountRemoval(ticket).also {
mutableState.value = mutableState.value.copy(pendingRemovalPublicKeyHex = null)
@@ -175,10 +171,26 @@ class StudioAppStore(
mutableState.value = mutableState.value.copy(problem = null)
}
- private fun runCommand(operation: suspend () -> AppSnapshotDto) {
+ private fun runSnapshotCommand(operation: suspend () -> AppSnapshotDto) {
launchCommand { acceptSnapshot(operation()) }
}
+ private fun runTypedCommand(command: StudioCommand, hideChooser: Boolean = false) {
+ launchCommand {
+ when (val result = gateway.execute(command)) {
+ is StudioCommandResult.Accepted -> {
+ acceptSnapshot(result.receipt.snapshot)
+ if (hideChooser) {
+ mutableState.value = mutableState.value.copy(accountChooserVisible = false)
+ }
+ }
+ is StudioCommandResult.Rejected -> {
+ mutableState.value = mutableState.value.copy(problem = result.failure.safeMessage)
+ }
+ }
+ }
+ }
+
private fun launchCommand(operation: suspend () -> Unit) {
if (closed || command?.isActive == true) return
mutableState.value = mutableState.value.copy(busy = true, problem = null)
@@ -191,7 +203,10 @@ class StudioAppStore(
private fun acceptSnapshot(snapshot: AppSnapshotDto) {
if (snapshot.revision >= mutableState.value.snapshot.revision) {
- mutableState.value = mutableState.value.copy(snapshot = snapshot)
+ mutableState.value = mutableState.value.copy(
+ snapshot = snapshot,
+ route = snapshot.toStudioRoute(),
+ )
}
}
@@ -209,5 +224,13 @@ class StudioAppStore(
subscription?.close()
generatedRecovery.close()
gateway.close()
+ mutableState.value = mutableState.value.copy(route = StudioRoute.CLOSED, busy = false)
}
}
+
+private fun AppSnapshotDto.toStudioRoute(): StudioRoute = when {
+ lifecycle == AppLifecycleDto.BOOTING -> StudioRoute.BOOTING
+ lifecycle == AppLifecycleDto.FATAL -> StudioRoute.FATAL
+ activeAccount != null -> StudioRoute.ACTIVE_ACCOUNT
+ else -> StudioRoute.ACCOUNTS
+}
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt
@@ -64,8 +64,6 @@ sealed interface StudioCommandResult {
interface StudioCoreGateway : AutoCloseable {
fun snapshot(): AppSnapshotDto
- suspend fun subscribe(onSnapshot: (AppSnapshotDto) -> Unit): AutoCloseable
-
suspend fun subscribeChanges(onChange: (StudioChange) -> Unit): AutoCloseable
suspend fun execute(command: StudioCommand): StudioCommandResult
@@ -74,16 +72,6 @@ interface StudioCoreGateway : AutoCloseable {
suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket
- suspend fun importSecretKey(secretKey: ByteArray): AppSnapshotDto
-
- suspend fun selectAccount(publicKeyHex: String): AppSnapshotDto
-
- suspend fun activateAccount(publicKeyHex: String): AppSnapshotDto
-
- suspend fun signOut(): AppSnapshotDto
-
- suspend fun refreshActiveProfile(): AppSnapshotDto
-
suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket
suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto
@@ -95,10 +83,6 @@ class NativeStudioCoreGateway(
private val nextRequest = AtomicLong(1)
override fun snapshot(): AppSnapshotDto = core.snapshot()
- override suspend fun subscribe(onSnapshot: (AppSnapshotDto) -> Unit): AutoCloseable {
- return subscribeChanges { change -> onSnapshot(change.snapshot) }
- }
-
override suspend fun subscribeChanges(onChange: (StudioChange) -> Unit): AutoCloseable {
val subscription = core.subscribeChangesV2(
object : StudioChangeObserver {
@@ -137,23 +121,6 @@ class NativeStudioCoreGateway(
override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket =
NativeGeneratedRecoveryTicket(core, core.beginGeneratedAccountV2())
- override suspend fun importSecretKey(secretKey: ByteArray): AppSnapshotDto =
- try {
- core.importAccountV2(requestContext(), secretKey).snapshot
- } finally {
- secretKey.fill(0)
- }
-
- override suspend fun selectAccount(publicKeyHex: String): AppSnapshotDto =
- core.selectAccount(publicKeyHex)
-
- override suspend fun activateAccount(publicKeyHex: String): AppSnapshotDto =
- core.activateAccount(publicKeyHex)
-
- override suspend fun signOut(): AppSnapshotDto = core.signOut()
-
- override suspend fun refreshActiveProfile(): AppSnapshotDto = core.refreshActiveProfile()
-
override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket =
NativeRemovalTicket(core.requestAccountRemoval(publicKeyHex))
diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt
@@ -75,17 +75,10 @@ private class ApplicationGateway : StudioCoreGateway {
var closed = false
override fun snapshot() = applicationSnapshot(0UL)
- override suspend fun subscribe(onSnapshot: (org.radroots.studio.ffi.AppSnapshotDto) -> Unit) =
- AutoCloseable {}
override suspend fun subscribeChanges(onChange: (StudioChange) -> Unit) = AutoCloseable {}
override suspend fun execute(command: StudioCommand): StudioCommandResult = error("unused")
override suspend fun bootstrap() = applicationSnapshot(1UL)
override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = error("unused")
- override suspend fun importSecretKey(secretKey: ByteArray) = error("unused")
- override suspend fun selectAccount(publicKeyHex: String) = error("unused")
- override suspend fun activateAccount(publicKeyHex: String) = error("unused")
- override suspend fun signOut() = error("unused")
- override suspend fun refreshActiveProfile() = error("unused")
override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = error("unused")
override suspend fun confirmAccountRemoval(ticket: RemovalTicket) = error("unused")
diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt
@@ -132,16 +132,25 @@ private class FakeStudioCoreGateway(
override fun snapshot(): AppSnapshotDto = current
- override suspend fun subscribe(onSnapshot: (AppSnapshotDto) -> Unit): AutoCloseable {
- observer = onSnapshot
+ override suspend fun subscribeChanges(onChange: (StudioChange) -> Unit): AutoCloseable {
+ observer = { snapshot -> onChange(StudioChange(snapshot, null)) }
return AutoCloseable { subscriptionClosed = true }
}
- override suspend fun subscribeChanges(onChange: (StudioChange) -> Unit): AutoCloseable =
- subscribe { snapshot -> onChange(StudioChange(snapshot, null)) }
-
- override suspend fun execute(command: StudioCommand): StudioCommandResult =
- error("unused")
+ override suspend fun execute(command: StudioCommand): StudioCommandResult {
+ when (command) {
+ is StudioCommand.ImportAccount -> {
+ lastImportBuffer = command.bytes
+ importedSecrets += command.bytes.decodeToString()
+ command.bytes.fill(0)
+ }
+ StudioCommand.SignOut -> signOutCalls += 1
+ else -> Unit
+ }
+ return StudioCommandResult.Accepted(
+ StudioCommandReceipt("fake-request", current.revision, current),
+ )
+ }
fun emit(snapshot: AppSnapshotDto) {
current = snapshot
@@ -157,14 +166,6 @@ private class FakeStudioCoreGateway(
committed(current)
}
- override suspend fun importSecretKey(secretKey: ByteArray): AppSnapshotDto = current.also {
- lastImportBuffer = secretKey
- importedSecrets += secretKey.decodeToString()
- }
- override suspend fun selectAccount(publicKeyHex: String): AppSnapshotDto = current
- override suspend fun activateAccount(publicKeyHex: String): AppSnapshotDto = current
- override suspend fun signOut(): AppSnapshotDto = current.also { signOutCalls += 1 }
- override suspend fun refreshActiveProfile(): AppSnapshotDto = current
override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket =
FakeRemovalTicket().also { lastRemovalTicket = it }