app

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

app.rs (13061B)


      1 use gpui::{Application, rgb};
      2 use gpui_component::{Theme, ThemeMode};
      3 use radroots_app_core::{
      4     APP_PROJECTION_SOURCE, AppBuildIdentity, AppRuntimeConfig, AppRuntimeConfigError,
      5     AppRuntimeSnapshot, bootstrap_logging, install_panic_hook, launch_startup_event,
      6 };
      7 use radroots_app_i18n::select_locale_from_host;
      8 use radroots_app_ui::APP_UI_THEME;
      9 use thiserror::Error;
     10 use tracing::{error, info};
     11 
     12 use crate::menus::install_native_app_menu;
     13 use crate::runtime::{DesktopAppRuntime, DesktopAppRuntimeSummary};
     14 use crate::window::{home_window_options, open_home_window};
     15 
     16 #[derive(Debug, Error)]
     17 pub enum AppLaunchError {
     18     #[error(transparent)]
     19     RuntimeConfig(#[from] AppRuntimeConfigError),
     20     #[error(transparent)]
     21     Logging(#[from] radroots_app_core::AppLoggingError),
     22 }
     23 
     24 pub fn launch() -> Result<(), AppLaunchError> {
     25     let build = build_identity();
     26     let runtime_config = AppRuntimeConfig::from_env()?;
     27     let snapshot = AppRuntimeSnapshot::capture_for_mode(build, runtime_config.runtime_mode);
     28     bootstrap_logging(&snapshot, runtime_config.local_log_root.as_path())?;
     29     install_panic_hook();
     30 
     31     let runtime =
     32         DesktopAppRuntime::bootstrap(runtime_config.nostr_relay_urls.clone(), snapshot.clone());
     33     if let Err(error) = runtime.sync_on_app_launch() {
     34         error!(
     35             target: "sync",
     36             event = "sync.launch_attempt_failed",
     37             error = %error,
     38             "failed to execute launch sync attempt"
     39         );
     40     }
     41     let runtime_summary = runtime.summary();
     42     emit_runtime_events(&snapshot, &runtime_summary);
     43 
     44     let app = Application::new().with_assets(gpui_component_assets::Assets);
     45 
     46     app.run(move |cx| {
     47         gpui_component::init(cx);
     48         Theme::change(ThemeMode::Light, None, cx);
     49         Theme::global_mut(cx).ring = rgb(APP_UI_THEME.components.app_input_text.border).into();
     50         select_locale_from_host(&snapshot.host.host_locale);
     51         install_native_app_menu(runtime.clone(), cx);
     52 
     53         cx.on_window_closed(|cx| {
     54             if cx.windows().is_empty() {
     55                 cx.quit();
     56             }
     57         })
     58         .detach();
     59 
     60         let snapshot = snapshot.clone();
     61         let runtime = runtime.clone();
     62         let mut primary_window_options = home_window_options(cx);
     63         primary_window_options.app_id = Some(snapshot.host.app_identifier.clone());
     64         cx.spawn(async move |cx| {
     65             let open_result = cx.open_window(primary_window_options, |window, cx| {
     66                 window.activate_window();
     67                 open_home_window(window, cx, runtime.clone())
     68             });
     69 
     70             if let Err(error) = open_result {
     71                 error!(
     72                     target: "window",
     73                     event = "window.primary_open_failed",
     74                     error = %error,
     75                     "failed to open primary window"
     76                 );
     77                 let _ = cx.update(|cx| cx.quit());
     78                 return;
     79             }
     80 
     81             info!(
     82                 target: "window",
     83                 event = "window.primary_opened",
     84                 app_id = %snapshot.host.app_identifier,
     85                 "primary window opened"
     86             );
     87 
     88             if let Err(error) = cx.update(|cx| cx.activate(true)) {
     89                 error!(
     90                     target: "window",
     91                     event = "window.app_activation_failed",
     92                     error = %error,
     93                     "failed to activate app"
     94                 );
     95             }
     96         })
     97         .detach();
     98     });
     99 
    100     Ok(())
    101 }
    102 
    103 fn build_identity() -> AppBuildIdentity {
    104     AppBuildIdentity {
    105         package_name: env!("CARGO_PKG_NAME").to_owned(),
    106         package_version: env!("CARGO_PKG_VERSION").to_owned(),
    107         build_profile: option_env!("PROFILE").unwrap_or("debug").to_owned(),
    108         target_triple: option_env!("TARGET").unwrap_or("unknown-target").to_owned(),
    109         projection_source: APP_PROJECTION_SOURCE.to_owned(),
    110         git_commit: option_env!("RADROOTS_GIT_COMMIT").map(str::to_owned),
    111     }
    112 }
    113 
    114 fn emit_launch_event(snapshot: &AppRuntimeSnapshot) {
    115     let launch_event = launch_startup_event(snapshot);
    116     info!(
    117         target: "bootstrap",
    118         event = launch_event.name,
    119         home_screen = %launch_event.metadata.home_screen,
    120         core_package = %launch_event.metadata.core_package,
    121         host_surface = %launch_event.metadata.host_surface,
    122         runtime_mode = %launch_event.metadata.runtime_mode,
    123         "{}",
    124         launch_event.message
    125     );
    126 }
    127 
    128 fn emit_runtime_events(snapshot: &AppRuntimeSnapshot, runtime: &DesktopAppRuntimeSummary) {
    129     emit_launch_event(snapshot);
    130 
    131     if let Some(startup_issue) = runtime.startup_issue.as_deref() {
    132         emit_degraded_runtime_event(startup_issue);
    133     }
    134 }
    135 
    136 fn emit_degraded_runtime_event(startup_issue: &str) {
    137     error!(
    138         target: "runtime",
    139         event = "runtime.degraded",
    140         startup_issue = %startup_issue,
    141         "desktop runtime degraded"
    142     );
    143 }
    144 
    145 #[cfg(test)]
    146 mod tests {
    147     use std::sync::{Arc, Mutex};
    148 
    149     use radroots_app_core::{
    150         APP_PROJECTION_SOURCE, AppBuildIdentity, AppRuntimeCapture, AppRuntimeMode,
    151         AppRuntimeSnapshot,
    152     };
    153     use radroots_app_state::{AppShellProjection, FarmWorkspaceReadinessProjection, HomeRoute};
    154     use radroots_app_view::{
    155         AppStartupGate, LoggedOutStartupProjection, SettingsAccountProjection,
    156         TodayAgendaProjection,
    157     };
    158     use tracing::{
    159         Event, Level, Subscriber,
    160         field::{Field, Visit},
    161     };
    162     use tracing_subscriber::{Layer, layer::Context, prelude::*, registry::LookupSpan};
    163 
    164     use crate::runtime::DesktopAppRuntimeSummary;
    165 
    166     use super::emit_runtime_events;
    167     use crate::window::{HomeStage, PrimaryWindowTarget, home_stage, primary_window_target};
    168 
    169     #[derive(Clone, Debug, Eq, PartialEq)]
    170     struct CapturedEvent {
    171         level: Level,
    172         target: String,
    173         event: Option<String>,
    174         message: Option<String>,
    175         startup_issue: Option<String>,
    176     }
    177 
    178     #[derive(Default)]
    179     struct EventFieldVisitor {
    180         event: Option<String>,
    181         message: Option<String>,
    182         startup_issue: Option<String>,
    183     }
    184 
    185     struct CaptureLayer {
    186         events: Arc<Mutex<Vec<CapturedEvent>>>,
    187     }
    188 
    189     impl EventFieldVisitor {
    190         fn record_value(&mut self, field: &Field, value: String) {
    191             match field.name() {
    192                 "event" => self.event = Some(value),
    193                 "message" => self.message = Some(value),
    194                 "startup_issue" => self.startup_issue = Some(value),
    195                 _ => {}
    196             }
    197         }
    198     }
    199 
    200     impl Visit for EventFieldVisitor {
    201         fn record_str(&mut self, field: &Field, value: &str) {
    202             self.record_value(field, value.to_owned());
    203         }
    204 
    205         fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
    206             self.record_value(field, format!("{value:?}").trim_matches('"').to_owned());
    207         }
    208     }
    209 
    210     impl<S> Layer<S> for CaptureLayer
    211     where
    212         S: Subscriber + for<'lookup> LookupSpan<'lookup>,
    213     {
    214         fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
    215             let mut visitor = EventFieldVisitor::default();
    216             event.record(&mut visitor);
    217             self.events
    218                 .lock()
    219                 .expect("capture lock")
    220                 .push(CapturedEvent {
    221                     level: *event.metadata().level(),
    222                     target: event.metadata().target().to_owned(),
    223                     event: visitor.event,
    224                     message: visitor.message,
    225                     startup_issue: visitor.startup_issue,
    226                 });
    227         }
    228     }
    229 
    230     fn test_snapshot() -> AppRuntimeSnapshot {
    231         AppRuntimeSnapshot::from_capture(
    232             AppBuildIdentity {
    233                 package_name: "radroots_app".to_owned(),
    234                 package_version: "0.1.0".to_owned(),
    235                 build_profile: "debug".to_owned(),
    236                 target_triple: "aarch64-apple-darwin".to_owned(),
    237                 projection_source: APP_PROJECTION_SOURCE.to_owned(),
    238                 git_commit: None,
    239             },
    240             AppRuntimeMode::LocalhostDev,
    241             AppRuntimeCapture {
    242                 host_locale: "en_US.UTF-8".to_owned(),
    243                 operating_system: "macos".to_owned(),
    244                 run_id: "app-localhost-dev-20260418T000000Z-deadbeefcafefeed".to_owned(),
    245             },
    246         )
    247     }
    248 
    249     fn summary_with_gate(
    250         startup_gate: AppStartupGate,
    251         home_route: HomeRoute,
    252         startup_issue: Option<&str>,
    253     ) -> DesktopAppRuntimeSummary {
    254         DesktopAppRuntimeSummary {
    255             shell_projection: AppShellProjection::default(),
    256             settings_account_projection: SettingsAccountProjection::default(),
    257             startup_gate,
    258             home_route,
    259             personal_projection: Default::default(),
    260             farm_setup_projection: Default::default(),
    261             farm_rules_projection: Default::default(),
    262             farm_readiness_projection: FarmWorkspaceReadinessProjection::default(),
    263             today_projection: TodayAgendaProjection::default(),
    264             products_projection: Default::default(),
    265             orders_projection: Default::default(),
    266             pack_day_projection: Default::default(),
    267             reminder_log: Default::default(),
    268             runtime_metadata: crate::runtime::DesktopAppRuntimeMetadataSummary::default(),
    269             logged_out_startup: LoggedOutStartupProjection::default(),
    270             sync_status: crate::runtime::DesktopAppSyncStatusSummary::default(),
    271             startup_issue: startup_issue.map(str::to_owned),
    272             sdk_status: None,
    273         }
    274     }
    275 
    276     #[test]
    277     fn degraded_runtime_emits_launch_and_degraded_events() {
    278         let events = Arc::new(Mutex::new(Vec::new()));
    279         let subscriber = tracing_subscriber::registry().with(CaptureLayer {
    280             events: Arc::clone(&events),
    281         });
    282         let summary = summary_with_gate(
    283             AppStartupGate::SetupRequired,
    284             HomeRoute::SetupRequired,
    285             Some("desktop runtime roots require HOME for macos"),
    286         );
    287 
    288         tracing::subscriber::with_default(subscriber, || {
    289             emit_runtime_events(&test_snapshot(), &summary);
    290         });
    291 
    292         let events = events.lock().expect("events lock");
    293         assert_eq!(events.len(), 2);
    294         assert_eq!(events[0].event.as_deref(), Some("runtime.launch"));
    295         assert_eq!(events[0].target, "bootstrap");
    296         assert_eq!(events[1].event.as_deref(), Some("runtime.degraded"));
    297         assert_eq!(events[1].level, Level::ERROR);
    298         assert_eq!(events[1].target, "runtime");
    299         assert_eq!(
    300             events[1].startup_issue.as_deref(),
    301             Some("desktop runtime roots require HOME for macos")
    302         );
    303         assert_eq!(
    304             events[1].message.as_deref(),
    305             Some("desktop runtime degraded")
    306         );
    307     }
    308 
    309     #[test]
    310     fn blocked_and_setup_runtime_target_the_home_window() {
    311         let blocked = summary_with_gate(AppStartupGate::Blocked, HomeRoute::Blocked, None);
    312         let setup = summary_with_gate(
    313             AppStartupGate::SetupRequired,
    314             HomeRoute::SetupRequired,
    315             None,
    316         );
    317 
    318         assert_eq!(primary_window_target(&blocked), PrimaryWindowTarget::Home);
    319         assert_eq!(primary_window_target(&setup), PrimaryWindowTarget::Home);
    320     }
    321 
    322     #[test]
    323     fn ready_runtime_targets_the_home_window() {
    324         let personal = summary_with_gate(AppStartupGate::Personal, HomeRoute::Personal, None);
    325         let farmer =
    326             summary_with_gate(AppStartupGate::Farmer, HomeRoute::FarmSetupOnboarding, None);
    327 
    328         assert_eq!(primary_window_target(&personal), PrimaryWindowTarget::Home);
    329         assert_eq!(primary_window_target(&farmer), PrimaryWindowTarget::Home);
    330     }
    331 
    332     #[test]
    333     fn degraded_runtime_targets_the_home_window() {
    334         let degraded = summary_with_gate(
    335             AppStartupGate::Personal,
    336             HomeRoute::Personal,
    337             Some("runtime unavailable"),
    338         );
    339 
    340         assert_eq!(primary_window_target(&degraded), PrimaryWindowTarget::Home);
    341     }
    342 
    343     #[test]
    344     fn home_stage_tracks_setup_personal_and_farmer_states() {
    345         let setup = summary_with_gate(
    346             AppStartupGate::SetupRequired,
    347             HomeRoute::SetupRequired,
    348             None,
    349         );
    350         let personal = summary_with_gate(AppStartupGate::Personal, HomeRoute::Personal, None);
    351         let farmer =
    352             summary_with_gate(AppStartupGate::Farmer, HomeRoute::FarmSetupOnboarding, None);
    353         let blocked = summary_with_gate(
    354             AppStartupGate::Farmer,
    355             HomeRoute::FarmSetupOnboarding,
    356             Some("runtime unavailable"),
    357         );
    358 
    359         assert_eq!(home_stage(&setup), HomeStage::Setup);
    360         assert_eq!(home_stage(&personal), HomeStage::BuyerWorkspace);
    361         assert_eq!(home_stage(&farmer), HomeStage::FarmerWorkspace);
    362         assert_eq!(home_stage(&blocked), HomeStage::Setup);
    363     }
    364 }