app

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

commit dbbbdd063afd60a9f13efdff0096dd36e7b25a34
parent 7e44352ff89100d6752d11a7efc47d1420df7c69
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 08:33:48 +0000

runtime: reconcile launcher prerequisites for products

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/launchers/desktop/src/window.rs | 57+++++++++++++++++++++++++++++++++++----------------------
2 files changed, 217 insertions(+), 40 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -4,8 +4,9 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedAccountsPaths}; use radroots_app_models::{ ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, - FarmId, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, - SettingsAccountProjection, SettingsPreference, SettingsSection, TodayAgendaProjection, + FarmId, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection, + SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, + TodayAgendaProjection, }; use radroots_app_sqlite::{ APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget, @@ -68,11 +69,14 @@ impl DesktopAppRuntime { .selected_section } - pub fn select_settings_section(&self, section: SettingsSection) -> bool { - let changed = self - .lock_state_mut() + pub fn sync_settings_section(&self, section: SettingsSection) -> bool { + self.lock_state_mut() .state_store - .apply_in_memory(AppStateCommand::select_settings_section(section)); + .apply_in_memory(AppStateCommand::select_settings_section(section)) + } + + pub fn select_settings_section(&self, section: SettingsSection) -> bool { + let changed = self.sync_settings_section(section); if changed { let _ = self.record_activity(AppActivityKind::SettingsSectionSelected { section }); @@ -81,6 +85,28 @@ impl DesktopAppRuntime { changed } + pub fn select_home(&self) -> bool { + let mut state = self.lock_state_mut(); + let selected_section = match state.state_store.startup_gate() { + AppStartupGate::Farmer => ShellSection::Farmer(FarmerSection::Today), + AppStartupGate::Blocked | AppStartupGate::SetupRequired | AppStartupGate::Personal => { + ShellSection::Home + } + }; + + state + .state_store + .apply_in_memory(AppStateCommand::SelectSection(selected_section)) + } + + pub fn select_farmer_section(&self, section: FarmerSection) -> bool { + self.lock_state_mut() + .state_store + .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( + section, + ))) + } + pub fn set_settings_preference(&self, preference: SettingsPreference, enabled: bool) -> bool { let changed = self.lock_state_mut().state_store.apply_in_memory( AppStateCommand::SetSettingsPreference { @@ -169,13 +195,19 @@ impl DesktopAppRuntime { self.record_activity(AppActivityKind::SettingsOpened { section }) } - #[allow(dead_code)] - pub fn activity_context(&self, limit: Option<usize>) -> Option<AppActivityContext> { - self.lock_state().sqlite_store.as_ref().and_then(|store| { - store - .load_activity_context(limit.unwrap_or(APP_ACTIVITY_CONTEXT_LIMIT)) - .ok() - }) + pub fn activity_context( + &self, + limit: Option<usize>, + ) -> Result<AppActivityContext, DesktopAppRuntimeActivityContextError> { + let state = self.lock_state(); + let store = state + .sqlite_store + .as_ref() + .ok_or(DesktopAppRuntimeActivityContextError::RuntimeUnavailable)?; + + store + .load_activity_context(limit.unwrap_or(APP_ACTIVITY_CONTEXT_LIMIT)) + .map_err(DesktopAppRuntimeActivityContextError::from) } fn from_state(state: DesktopAppRuntimeState) -> Self { @@ -220,6 +252,14 @@ pub struct DesktopAppRuntimeSummary { pub startup_issue: Option<String>, } +#[derive(Debug, Error)] +pub enum DesktopAppRuntimeActivityContextError { + #[error("desktop runtime activity context is unavailable while the runtime is degraded")] + RuntimeUnavailable, + #[error(transparent)] + Sqlite(#[from] AppSqliteError), +} + #[derive(Clone, Debug, Default)] struct DesktopSelectedAccountContext { farm_setup_projection: FarmSetupProjection, @@ -591,8 +631,9 @@ mod tests { use radroots_app_models::{ AccountSurfaceActivationProjection, ActiveSurface, AppActivityKind, AppStartupGate, FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, - FarmerActivationProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, - ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, + FarmerActivationProjection, FarmerSection, SelectedSurfaceProjection, SettingsPreference, + SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, + TodaySummary, }; use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget}; use radroots_app_state::{ @@ -608,8 +649,8 @@ mod tests { use crate::accounts::DesktopLocalIdentityImportRequest; use super::{ - APP_DATABASE_FILE_NAME, DesktopAppRuntime, DesktopAppRuntimeCommandError, - DesktopAppRuntimeState, + APP_DATABASE_FILE_NAME, DesktopAppRuntime, DesktopAppRuntimeActivityContextError, + DesktopAppRuntimeCommandError, DesktopAppRuntimeState, }; #[test] @@ -671,7 +712,7 @@ mod tests { }); let cloned_runtime = runtime.clone(); - assert!(runtime.select_settings_section(SettingsSection::About)); + assert!(runtime.sync_settings_section(SettingsSection::About)); assert!(cloned_runtime.set_settings_preference(SettingsPreference::LaunchAtLogin, true)); let summary = runtime.summary(); @@ -804,6 +845,7 @@ mod tests { }); assert!(runtime.record_home_opened()); + assert!(runtime.sync_settings_section(SettingsSection::About)); assert!(runtime.record_settings_opened(SettingsSection::About)); assert!(runtime.select_settings_section(SettingsSection::Settings)); assert!(runtime.set_settings_preference(SettingsPreference::LaunchAtLogin, true)); @@ -836,6 +878,128 @@ mod tests { } #[test] + fn activity_context_distinguishes_empty_history_from_runtime_unavailable() { + let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { + state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory state store should load"), + default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + shared_accounts_paths: None, + accounts_manager: None, + sqlite_store: Some( + AppSqliteStore::open(DatabaseTarget::InMemory) + .expect("in-memory sqlite store should open"), + ), + startup_issue: None, + }); + + let empty_context = runtime + .activity_context(Some(8)) + .expect("empty activity history should still load"); + assert!(empty_context.recent_events.is_empty()); + + let degraded = DesktopAppRuntime::from_state(DesktopAppRuntimeState::degraded( + super::DesktopAppRuntimeBootstrapError::State(AppStateStoreError::Repository( + AppStateRepositoryError::load("state unavailable"), + )), + )); + + assert!(matches!( + degraded.activity_context(Some(8)), + Err(DesktopAppRuntimeActivityContextError::RuntimeUnavailable) + )); + } + + #[test] + fn activity_context_surfaces_store_load_failure() { + let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { + state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory state store should load"), + default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + shared_accounts_paths: None, + accounts_manager: None, + sqlite_store: Some( + AppSqliteStore::open(DatabaseTarget::InMemory) + .expect("in-memory sqlite store should open"), + ), + startup_issue: None, + }); + + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute_batch("DROP TABLE activity_events") + .expect("activity table should drop"); + + assert!(matches!( + runtime.activity_context(Some(8)), + Err(DesktopAppRuntimeActivityContextError::Sqlite(_)) + )); + } + + #[test] + fn selecting_farmer_section_requires_farmer_identity_gate() { + let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { + state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory state store should load"), + default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + shared_accounts_paths: None, + accounts_manager: None, + sqlite_store: Some( + AppSqliteStore::open(DatabaseTarget::InMemory) + .expect("in-memory sqlite store should open"), + ), + startup_issue: None, + }); + + assert!(!runtime.select_farmer_section(FarmerSection::Products)); + assert_eq!( + runtime.summary().shell_projection.selected_section, + ShellSection::Home + ); + } + + #[test] + fn runtime_routes_between_farmer_home_and_products_through_explicit_methods() { + let runtime = memory_runtime(); + + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); + let account_id = runtime + .summary() + .settings_account_projection + .selected_account + .as_ref() + .expect("selected account") + .account + .account_id + .clone(); + save_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer, true); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); + + assert!(runtime.select_farmer_section(FarmerSection::Products)); + assert_eq!( + runtime.summary().shell_projection.selected_section, + ShellSection::Farmer(FarmerSection::Products) + ); + + assert!(runtime.select_home()); + assert_eq!( + runtime.summary().shell_projection.selected_section, + ShellSection::Farmer(FarmerSection::Today) + ); + } + + #[test] fn runtime_account_commands_refresh_identity_projection() { let runtime = memory_runtime(); diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -28,6 +28,9 @@ use std::time::Duration; use crate::runtime::{DesktopAppRuntime, DesktopAppRuntimeSummary}; +const HOME_WINDOW_MIN_WIDTH_PX: f32 = 1080.0; +const HOME_WINDOW_MIN_HEIGHT_PX: f32 = 720.0; + pub fn home_titlebar_options() -> gpui::TitlebarOptions { gpui::TitlebarOptions { title: None, @@ -77,26 +80,29 @@ pub fn home_stage(summary: &DesktopAppRuntimeSummary) -> HomeStage { } pub fn home_window_options(cx: &mut App) -> WindowOptions { - let bounds = Bounds::centered( - None, - size( - px(APP_UI_THEME.windows.home_min_width_px), - px(APP_UI_THEME.windows.home_min_height_px), - ), - cx, - ); + let (launch_width_px, launch_height_px) = home_window_launch_size_px(); + let (minimum_width_px, minimum_height_px) = home_window_minimum_size_px(); + let bounds = Bounds::centered(None, size(px(launch_width_px), px(launch_height_px)), cx); WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), - window_min_size: Some(size( - px(APP_UI_THEME.windows.home_min_width_px), - px(APP_UI_THEME.windows.home_min_height_px), - )), + window_min_size: Some(size(px(minimum_width_px), px(minimum_height_px))), titlebar: Some(home_titlebar_options()), ..Default::default() } } +fn home_window_launch_size_px() -> (f32, f32) { + ( + APP_UI_THEME.windows.home_min_width_px, + APP_UI_THEME.windows.home_min_height_px, + ) +} + +fn home_window_minimum_size_px() -> (f32, f32) { + (HOME_WINDOW_MIN_WIDTH_PX, HOME_WINDOW_MIN_HEIGHT_PX) +} + pub fn settings_window_options(cx: &mut App) -> WindowOptions { let bounds = Bounds::centered( None, @@ -135,6 +141,7 @@ pub fn open_settings_window( runtime: DesktopAppRuntime, initial_view: SettingsPanelViewKey, ) -> gpui::Entity<Root> { + let _ = runtime.sync_settings_section(initial_view); let _ = runtime.record_settings_opened(initial_view); let view = cx.new(|_| SettingsWindowView::new(runtime, initial_view)); cx.new(|cx| Root::new(view, window, cx)) @@ -564,24 +571,24 @@ impl LoggedInHomeView { pub struct SettingsWindowView { runtime: DesktopAppRuntime, - selected_view: SettingsPanelViewKey, } impl SettingsWindowView { pub fn new(runtime: DesktopAppRuntime, initial_view: SettingsPanelViewKey) -> Self { - Self { - runtime, - selected_view: initial_view, - } + let _ = initial_view; + Self { runtime } } fn select_view(&mut self, view: SettingsPanelViewKey, cx: &mut Context<Self>) { - if self.selected_view != view { - self.selected_view = view; + if self.runtime.select_settings_section(view) { cx.notify(); } } + fn selected_view(&self) -> SettingsPanelViewKey { + self.runtime.selected_settings_section() + } + fn navigation_button( &mut self, view: SettingsPanelViewKey, @@ -594,7 +601,7 @@ impl SettingsWindowView { app_shared_text(settings_panel_label_key(view)), navigation_icon, ), - self.selected_view == view, + self.selected_view() == view, cx.listener(move |this, _, _, cx| this.select_view(view, cx)), cx, ) @@ -1055,7 +1062,7 @@ impl SettingsWindowView { } fn settings_panel_content(&mut self, cx: &mut Context<Self>) -> AnyElement { - match self.selected_view { + match self.selected_view() { SettingsPanelViewKey::Account => self.account_panel(cx).into_any_element(), SettingsPanelViewKey::Settings => self.settings_panel(cx).into_any_element(), SettingsPanelViewKey::About => self.about_panel().into_any_element(), @@ -2316,7 +2323,7 @@ fn home_farm_order_method_label_key(method: FarmOrderMethod) -> AppTextKey { mod tests { use super::{ AppTextKey, FarmerHomeFarmState, farm_setup_onboarding_card_spec, farmer_home_farm_state, - home_saved_farm, + home_saved_farm, home_window_launch_size_px, home_window_minimum_size_px, }; use crate::runtime::DesktopAppRuntimeSummary; use radroots_app_models::SettingsAccountProjection; @@ -2355,6 +2362,12 @@ mod tests { } #[test] + fn home_window_launch_frame_and_minimum_size_are_split() { + assert_eq!(home_window_launch_size_px(), (1284.0, 795.0)); + assert_eq!(home_window_minimum_size_px(), (1080.0, 720.0)); + } + + #[test] fn farmer_home_farm_state_distinguishes_no_farm_incomplete_and_configured() { let farm_id = FarmId::new(); let incomplete_farm = FarmSummary {