app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit f0e823fad95bb5b4cd8adb6eb4896a57872e7800
parent c551ff5c6c674bf806ab49c0fde3c935f2191e2a
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 00:12:02 +0000

runtime: finalize removal and startup safety

- derive credential deletion impact from the local binding
- retain cascade-backed account-owned data cleanup
- convert missing or invalid icon resources into safe UI failure
- cover binding-aware impact and resource decoding failure

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/desktop/Main.kt | 36+++++++++++++++++++++---------------
Aapp/desktop/src/test/kotlin/org/radroots/studio/desktop/MainTest.kt | 13+++++++++++++
Mcore/crates/application/src/app_core.rs | 45+++++++++++++++++++++++++++++++++++++++------
3 files changed, 73 insertions(+), 21 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/desktop/Main.kt b/app/desktop/src/main/kotlin/org/radroots/studio/desktop/Main.kt @@ -9,6 +9,7 @@ import java.awt.Dimension import java.awt.Taskbar import javax.imageio.ImageIO import org.radroots.studio.application.RadrootsApplication +import org.radroots.studio.accounts.ui.StartupFailureScreen private const val ApplicationName = "Radroots" private const val InitialWindowWidth = 1284 @@ -21,9 +22,7 @@ private val isMacOs: Boolean = .startsWith("Mac", ignoreCase = true) fun main() { - if (isMacOs) { - configureMacOsApplication() - } + val nativeStartupProblem = if (isMacOs) configureMacOsApplication() else null application { Window( @@ -47,26 +46,33 @@ fun main() { onDispose { } } - RadrootsApplication() + if (nativeStartupProblem == null) { + RadrootsApplication() + } else { + StartupFailureScreen(nativeStartupProblem) + } } } } -private fun configureMacOsApplication() { +private fun configureMacOsApplication(): String? { System.setProperty("apple.awt.application.name", ApplicationName) System.setProperty("apple.awt.application.appearance", "system") - if (!Taskbar.isTaskbarSupported()) return + if (!Taskbar.isTaskbarSupported()) return null val taskbar = Taskbar.getTaskbar() - if (!taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) return + if (!taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) return null - val resource = checkNotNull( - Thread.currentThread().contextClassLoader.getResource("icons/radroots.png"), - ) { - "Missing runtime app icon" - } - taskbar.iconImage = checkNotNull(ImageIO.read(resource)) { - "Invalid runtime app icon" - } + val icon = loadRuntimeIcon { + Thread.currentThread().contextClassLoader.getResourceAsStream("icons/radroots.png") + } ?: return "The application icon resource is unavailable." + return runCatching { taskbar.iconImage = icon } + .fold( + onSuccess = { null }, + onFailure = { "The application icon could not be configured." }, + ) } + +internal fun loadRuntimeIcon(openResource: () -> java.io.InputStream?): java.awt.Image? = + runCatching { openResource()?.use(ImageIO::read) }.getOrNull() diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/desktop/MainTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/desktop/MainTest.kt @@ -0,0 +1,13 @@ +package org.radroots.studio.desktop + +import java.io.ByteArrayInputStream +import kotlin.test.Test +import kotlin.test.assertNull + +class MainTest { + @Test + fun missingOrInvalidRuntimeIconFailsSafely() { + assertNull(loadRuntimeIcon { null }) + assertNull(loadRuntimeIcon { ByteArrayInputStream("not an image".encodeToByteArray()) }) + } +} diff --git a/core/crates/application/src/app_core.rs b/core/crates/application/src/app_core.rs @@ -141,15 +141,17 @@ impl AppCore { now: UnixTimestamp, ) -> Result<RemovalConfirmationToken, SafeError> { let mut state = self.lock_state(); - if !state + let Some(account) = state .state_machine .snapshot() .accounts() .iter() - .any(|account| account.public_key() == public_key) - { + .find(|account| account.public_key() == public_key) + else { return Err(account_not_found()); - } + }; + let deletes_local_credential = account.signer().availability() + != radroots_studio_domain::BindingAvailability::CredentialMissing; let id = state.next_removal_token; state.next_removal_token = id.checked_add(1).ok_or_else(invalid_application_state)?; let revision = state.state_machine.snapshot().revision(); @@ -160,7 +162,7 @@ impl AppCore { ) .ok_or_else(invalid_application_state)?; let impact = RemovalImpact { - deletes_local_credential: true, + deletes_local_credential, signs_out: state .state_machine .snapshot() @@ -241,7 +243,12 @@ const fn account_not_found() -> SafeError { #[cfg(test)] mod tests { - use crate::{AppCore, AppLifecycle, RelayConfiguration}; + use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + PublicKey, UnixTimestamp, + }; + + use crate::{AppCore, AppLifecycle, RelayConfiguration, StateTransition}; #[test] fn bootstrap_is_idempotent_and_advances_only_once() { @@ -264,4 +271,30 @@ mod tests { assert_eq!(first.snapshot().revision().value(), 1); assert_eq!(second.snapshot().revision().value(), 0); } + + #[test] + fn removal_impact_matches_missing_local_binding() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let public_key = PublicKey::from_bytes([9; 32]); + let account = AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + None, + ) + .expect("account"); + core.apply_transition(StateTransition::BootstrapRegistry { + accounts: vec![account], + selected: Some(public_key), + }) + .expect("registry"); + + let removal = core + .issue_removal_token(public_key, UnixTimestamp::from_seconds(2).expect("time")) + .expect("removal"); + + assert!(!removal.impact().deletes_local_credential()); + assert!(!removal.impact().signs_out()); + } }