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:
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 {