commit 4544abe63f25ee09fc4c779d4b4023fe86a19f9c
parent 1432018c9315751b20ff11b067521fcaa7d5264d
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 20:22:59 +0000
fix(runtime): render safe native startup failures
- catch native core construction failures at the Compose root
- display approved FFI messages without exposing internal exceptions
- preserve one-time application initialization across recomposition
- verify generic startup failures redact their original detail
Diffstat:
3 files changed, 43 insertions(+), 1 deletion(-)
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt
@@ -47,6 +47,21 @@ data class StudioUiActions(
)
@Composable
+fun StartupFailureScreen(problem: String) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(WindowBackgroundColor)
+ .padding(24.dp)
+ .testTag("startup-failure"),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ BasicText("radroots")
+ BasicText(problem, Modifier.testTag("startup-problem"))
+ }
+}
+
+@Composable
fun StudioScreen(
model: StudioUiModel,
actions: StudioUiActions,
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt
@@ -9,8 +9,10 @@ import java.awt.datatransfer.StringSelection
import kotlinx.coroutines.CoroutineScope
import org.radroots.studio.accounts.ui.StudioScreen
import org.radroots.studio.accounts.ui.StudioUiActions
+import org.radroots.studio.accounts.ui.StartupFailureScreen
import org.radroots.studio.accounts.ui.toUiModel
import org.radroots.studio.ffi.StudioAppCore
+import org.radroots.studio.ffi.StudioException
internal typealias StudioStoreFactory = (CoroutineScope) -> StudioAppStore
@@ -19,7 +21,15 @@ fun RadrootsApplication(
storeFactory: StudioStoreFactory = ::createStudioAppStore,
) {
val scope = rememberCoroutineScope()
- val store = remember { storeFactory(scope) }
+ val storeResult = remember { runCatching { storeFactory(scope) } }
+ val store = storeResult.getOrNull()
+ if (store == null) {
+ val error = storeResult.exceptionOrNull()
+ val message = (error as? StudioException.Failure)?.safeMessage
+ ?: "The application could not start."
+ StartupFailureScreen(message)
+ return
+ }
DisposableEffect(store) {
onDispose(store::close)
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
@@ -9,6 +9,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -52,6 +54,21 @@ class RadrootsApplicationTest {
assertEquals(1, factoryCalls)
assertEquals(true, gateway?.closed)
}
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun applicationRendersSafeStartupFailureWithoutLeakingInternalMessage() =
+ runComposeUiTest {
+ setContent {
+ RadrootsApplication {
+ error("sensitive internal startup detail")
+ }
+ }
+
+ onNodeWithTag("startup-failure").assertIsDisplayed()
+ onNodeWithText("The application could not start.").assertIsDisplayed()
+ onAllNodesWithText("sensitive internal startup detail").assertCountEquals(0)
+ }
}
private class ApplicationGateway : StudioCoreGateway {