Skip to main content

communitas_ui_service/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Shared Rust service layer consumed by all Communitas UI surfaces (Dioxus + MCP).
4
5pub mod audit;
6pub mod auth;
7pub mod canvas;
8pub mod canvas_convert;
9pub mod contacts;
10pub mod directory;
11pub mod drive;
12pub mod kanban;
13pub mod messaging;
14pub mod messaging_convert;
15pub mod navigation;
16pub mod presence;
17pub mod storage;
18pub mod update;
19pub mod util;
20
21use std::sync::Arc;
22
23use audit::AuditService;
24use auth::AuthController;
25use canvas::CanvasService;
26use communitas_core::app::CommunitasApp;
27use communitas_core::generate_id_words;
28use directory::DirectoryService;
29use drive::DriveService;
30use kanban::KanbanService;
31use messaging::MessagingService;
32use navigation::NavigationStore;
33use presence::PresenceService;
34use storage::UiStorage;
35use thiserror::Error;
36
37/// Aggregates shared UI services for convenient dependency injection.
38#[derive(Clone)]
39pub struct UiServices {
40    storage: UiStorage,
41    app: Arc<CommunitasApp>,
42    auth: Arc<AuthController>,
43    navigation: Arc<NavigationStore>,
44    directory: Arc<DirectoryService>,
45    messaging: Arc<MessagingService>,
46    presence: Arc<PresenceService>,
47    kanban: Arc<KanbanService>,
48    canvas: Arc<CanvasService>,
49    drive: Arc<DriveService>,
50    audit: Arc<AuditService>,
51}
52
53impl UiServices {
54    /// Bootstrap UiServices with auto-discovered storage and a generated identity (async).
55    ///
56    /// This is the recommended method for applications running in an async context.
57    /// Use this when you already have a Tokio runtime (e.g., `#[tokio::main]`).
58    ///
59    /// # Errors
60    /// Returns an error if storage discovery fails or the app cannot be initialized.
61    #[tracing::instrument(name = "bootstrap_async", skip_all)]
62    pub async fn bootstrap_async() -> Result<Self, UiServiceInitError> {
63        let start = std::time::Instant::now();
64
65        let storage = {
66            let _span = tracing::info_span!("storage_discovery").entered();
67            UiStorage::discover()?
68        };
69        let storage_path = storage.root_string()?;
70
71        let id_words = {
72            let _span = tracing::info_span!("identity_generation").entered();
73            generate_id_words().map_err(|e| UiServiceInitError::AppInit(e.to_string()))?
74        };
75
76        let app = {
77            let _span = tracing::info_span!("core_app_init").entered();
78            tracing::info!("Creating CommunitasApp");
79            CommunitasApp::new(
80                id_words,
81                "Bootstrap User".to_string(),
82                "Desktop".to_string(),
83                storage_path,
84            )
85            .await
86            .map_err(|e| UiServiceInitError::AppInit(e.to_string()))?
87        };
88
89        let services = {
90            let _span = tracing::info_span!("services_init").entered();
91            Self::new(storage, Arc::new(app))?
92        };
93
94        // Auto-start networking unless COMMUNITAS_NO_AUTONET is set
95        if std::env::var("COMMUNITAS_NO_AUTONET").is_err() {
96            let _span = tracing::info_span!("auto_networking").entered();
97
98            // Check for preferred port from environment
99            let preferred_port = std::env::var("COMMUNITAS_PORT")
100                .ok()
101                .and_then(|p| p.parse::<u16>().ok());
102
103            if let Some(port) = preferred_port {
104                tracing::info!(port, "Auto-starting networking on specified port...");
105            } else {
106                tracing::info!("Auto-starting networking on random port...");
107            }
108
109            if let Err(e) = services.start_networking(preferred_port).await {
110                tracing::warn!(error = %e, "Failed to auto-start networking (non-fatal)");
111            }
112        }
113
114        let elapsed = start.elapsed();
115        tracing::info!(
116            elapsed_ms = elapsed.as_millis(),
117            "Bootstrap complete in {:.1}ms",
118            elapsed.as_secs_f64() * 1000.0
119        );
120
121        Ok(services)
122    }
123
124    /// Bootstrap UiServices with auto-discovered storage and a generated identity.
125    ///
126    /// This is a convenience method for applications that need to start without
127    /// prior authentication. A temporary identity is created, and the user can
128    /// log in or create a new identity through the auth service later.
129    ///
130    /// **Note**: Prefer `bootstrap_async()` when running in an async context to
131    /// avoid creating a nested Tokio runtime.
132    ///
133    /// # Errors
134    /// Returns an error if storage discovery fails or the app cannot be initialized.
135    #[tracing::instrument(name = "bootstrap_sync", skip_all)]
136    pub fn bootstrap() -> Result<Self, UiServiceInitError> {
137        let storage = UiStorage::discover()?;
138        let storage_path = storage.root_string()?;
139
140        // Generate a bootstrap identity
141        let id_words =
142            generate_id_words().map_err(|e| UiServiceInitError::AppInit(e.to_string()))?;
143
144        // Create the app using a blocking runtime since bootstrap is called from sync context
145        let rt = tokio::runtime::Runtime::new()
146            .map_err(|e| UiServiceInitError::AppInit(format!("failed to create runtime: {e}")))?;
147
148        let app = rt
149            .block_on(async {
150                CommunitasApp::new(
151                    id_words,
152                    "Bootstrap User".to_string(),
153                    "Desktop".to_string(),
154                    storage_path,
155                )
156                .await
157            })
158            .map_err(|e| UiServiceInitError::AppInit(e.to_string()))?;
159
160        Self::new(storage, Arc::new(app))
161    }
162
163    /// Create services using the provided storage configuration and core app.
164    ///
165    /// # Errors
166    /// Returns [`UiServiceInitError`] if the auth controller or navigation store
167    /// cannot be initialized from the provided storage.
168    #[tracing::instrument(name = "services_new", skip_all)]
169    pub fn new(storage: UiStorage, app: Arc<CommunitasApp>) -> Result<Self, UiServiceInitError> {
170        let auth = {
171            let _span = tracing::debug_span!("auth_init").entered();
172            Arc::new(AuthController::new(storage.clone())?)
173        };
174        let navigation = {
175            let _span = tracing::debug_span!("navigation_init").entered();
176            Arc::new(NavigationStore::new(storage.clone())?)
177        };
178        let directory = Arc::new(DirectoryService::new(auth.clone()));
179        let storage_arc = Arc::new(storage.clone());
180        // Create presence first so messaging can use it for DM thread presence
181        let presence = Arc::new(PresenceService::new(auth.clone(), directory.clone()));
182        let messaging = {
183            let _span = tracing::debug_span!("messaging_init").entered();
184            Arc::new(MessagingService::new(
185                auth.clone(),
186                app.clone(),
187                storage_arc,
188                presence.subscribe(),
189            ))
190        };
191        let kanban = {
192            let _span = tracing::debug_span!("kanban_init").entered();
193            Arc::new(KanbanService::new(
194                auth.clone(),
195                app.clone(),
196                directory.clone(),
197            ))
198        };
199        let canvas = Arc::new(CanvasService::new(auth.clone(), app.clone()));
200        let drive = Arc::new(DriveService::with_storage(
201            auth.clone(),
202            app.clone(),
203            Some(Arc::new(storage.clone())),
204        ));
205        let audit = Arc::new(AuditService::new(storage.root().join("audit_logs")));
206        Ok(Self {
207            storage,
208            app,
209            auth,
210            navigation,
211            directory,
212            messaging,
213            presence,
214            kanban,
215            canvas,
216            drive,
217            audit,
218        })
219    }
220
221    /// Access the storage configuration.
222    pub fn storage(&self) -> &UiStorage {
223        &self.storage
224    }
225
226    /// Authentication/session controller.
227    pub fn auth(&self) -> Arc<AuthController> {
228        self.auth.clone()
229    }
230
231    /// Navigation preferences/state controller.
232    pub fn navigation(&self) -> Arc<NavigationStore> {
233        self.navigation.clone()
234    }
235
236    /// Directory service for entities and contacts.
237    pub fn directory(&self) -> Arc<DirectoryService> {
238        self.directory.clone()
239    }
240
241    /// Messaging threads and messages service.
242    pub fn messaging(&self) -> Arc<MessagingService> {
243        self.messaging.clone()
244    }
245
246    /// Presence status tracking for contacts.
247    pub fn presence(&self) -> Arc<PresenceService> {
248        self.presence.clone()
249    }
250
251    /// Kanban boards and cards service.
252    pub fn kanban(&self) -> Arc<KanbanService> {
253        self.kanban.clone()
254    }
255
256    /// Canvas drawing and visual surfaces service.
257    pub fn canvas(&self) -> Arc<CanvasService> {
258        self.canvas.clone()
259    }
260
261    /// Drive and virtual disk service.
262    pub fn drive(&self) -> Arc<DriveService> {
263        self.drive.clone()
264    }
265
266    /// Security audit log service.
267    pub fn audit(&self) -> Arc<AuditService> {
268        self.audit.clone()
269    }
270
271    /// Access the underlying Communitas core application.
272    pub fn app(&self) -> Arc<CommunitasApp> {
273        self.app.clone()
274    }
275
276    /// Start networking and connect to bootstrap nodes.
277    ///
278    /// This connects the app to the P2P network. Networking is automatically
279    /// started during bootstrap unless `COMMUNITAS_NO_AUTONET` is set.
280    ///
281    /// # Arguments
282    /// * `preferred_port` - Optional port to bind to. If None, a random port is used.
283    ///
284    /// # Errors
285    /// Returns an error if networking is already active or fails to start.
286    pub async fn start_networking(
287        &self,
288        _preferred_port: Option<u16>,
289    ) -> Result<(), UiServiceInitError> {
290        use communitas_core::command::Command;
291
292        self.app
293            .execute(Command::EnsureDaemon)
294            .await
295            .map_err(|e| UiServiceInitError::AppInit(format!("networking start failed: {e}")))?;
296
297        tracing::info!("Networking started successfully");
298        Ok(())
299    }
300
301    /// Stop networking and disconnect from the P2P network.
302    ///
303    /// # Errors
304    /// Returns an error if networking fails to stop.
305    pub async fn stop_networking(&self) -> Result<(), UiServiceInitError> {
306        // Daemon lifecycle is managed independently; stop is a no-op.
307        tracing::info!("Networking stop requested (daemon runs independently)");
308        Ok(())
309    }
310}
311
312/// Errors that can occur when initializing the shared service layer.
313#[derive(Debug, Error)]
314pub enum UiServiceInitError {
315    #[error("storage initialization failed: {0}")]
316    Storage(#[from] storage::StorageError),
317    #[error("auth controller failed: {0}")]
318    Auth(#[from] auth::AuthError),
319    #[error("navigation controller failed: {0}")]
320    Navigation(#[from] navigation::NavigationError),
321    #[error("directory controller failed: {0}")]
322    Directory(#[from] directory::DirectoryError),
323    #[error("app initialization failed: {0}")]
324    AppInit(String),
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::auth::{AuthService, AuthStateSnapshot};
331    use crate::navigation::{EntityNavigationKey, NavigationService};
332    use tempfile::TempDir;
333
334    async fn make_services(temp: &TempDir) -> UiServices {
335        let storage = UiStorage::from_path(temp.path()).unwrap();
336        let app = Arc::new(
337            CommunitasApp::new(
338                "ocean-forest-moon-star".to_string(),
339                "TestUser".to_string(),
340                "TestDevice".to_string(),
341                temp.path()
342                    .join("app_storage")
343                    .to_string_lossy()
344                    .to_string(),
345            )
346            .await
347            .unwrap(),
348        );
349        UiServices::new(storage, app).unwrap()
350    }
351
352    #[tokio::test]
353    async fn ui_services_constructs_all_components() {
354        let temp = TempDir::new().unwrap();
355        let services = make_services(&temp).await;
356
357        // All services should be accessible
358        let _ = services.storage();
359        let _ = services.auth();
360        let _ = services.navigation();
361        let _ = services.directory();
362        let _ = services.messaging();
363        let _ = services.presence();
364        let _ = services.kanban();
365        let _ = services.canvas();
366        let _ = services.drive();
367        let _ = services.audit();
368    }
369
370    #[tokio::test]
371    async fn presence_starts_empty() {
372        let temp = TempDir::new().unwrap();
373        let services = make_services(&temp).await;
374
375        let snap = services.presence().current_snapshot();
376        assert!(snap.statuses.is_empty());
377        assert!(snap.last_seen.is_empty());
378    }
379
380    #[tokio::test]
381    async fn services_share_storage_path() {
382        let temp = TempDir::new().unwrap();
383        let services = make_services(&temp).await;
384
385        let storage_root = services.storage().root();
386        assert_eq!(storage_root, temp.path());
387    }
388
389    #[tokio::test]
390    async fn auth_starts_logged_out() {
391        let temp = TempDir::new().unwrap();
392        let services = make_services(&temp).await;
393
394        let rx = services.auth().subscribe();
395        match &*rx.borrow() {
396            AuthStateSnapshot::LoggedOut => {} // expected
397            other => panic!("expected LoggedOut, got {other:?}"),
398        }
399    }
400
401    #[tokio::test]
402    async fn directory_starts_with_empty_snapshot() {
403        let temp = TempDir::new().unwrap();
404        let services = make_services(&temp).await;
405
406        // Use async snapshot() instead of current_snapshot() to avoid blocking_read in async context
407        let snap = services.directory().snapshot().await;
408        assert!(snap.identity.is_none());
409        assert!(snap.entities.is_empty());
410        assert!(snap.contacts.is_empty());
411    }
412
413    #[tokio::test]
414    async fn navigation_starts_with_empty_recents() {
415        let temp = TempDir::new().unwrap();
416        let services = make_services(&temp).await;
417
418        // Use async snapshot() instead of current_snapshot() to avoid blocking_read in async context
419        let snap = services.navigation().snapshot().await;
420        assert!(snap.recent_entities.is_empty());
421        assert!(snap.recent_contacts.is_empty());
422        assert!(snap.starred_entities.is_empty());
423        assert!(snap.starred_contacts.is_empty());
424    }
425
426    #[tokio::test]
427    async fn messaging_starts_with_empty_threads() {
428        let temp = TempDir::new().unwrap();
429        let services = make_services(&temp).await;
430
431        let snap = services.messaging().current_snapshot();
432        assert!(snap.threads.is_empty());
433        assert!(!snap.loading);
434    }
435
436    #[tokio::test]
437    async fn kanban_starts_with_empty_boards() {
438        let temp = TempDir::new().unwrap();
439        let services = make_services(&temp).await;
440
441        let snap = services.kanban().current_snapshot();
442        assert!(snap.boards.is_empty());
443        assert!(!snap.loading);
444    }
445
446    #[tokio::test]
447    async fn canvas_starts_with_empty_scene() {
448        let temp = TempDir::new().unwrap();
449        let services = make_services(&temp).await;
450
451        let snap = services.canvas().current_snapshot();
452        assert!(snap.elements.is_empty());
453        assert!(snap.selected_ids.is_empty());
454        assert!(!snap.loading);
455    }
456
457    #[tokio::test]
458    async fn drive_starts_with_empty_snapshot() {
459        let temp = TempDir::new().unwrap();
460        let services = make_services(&temp).await;
461
462        let snap = services.drive().current_snapshot();
463        assert!(snap.uploads.is_empty());
464        assert!(snap.downloads.is_empty());
465        assert!(snap.current_directory.is_empty());
466        assert!(!snap.loading);
467    }
468
469    #[tokio::test]
470    async fn navigation_updates_propagate_to_subscribers() {
471        let temp = TempDir::new().unwrap();
472        let services = make_services(&temp).await;
473
474        let mut rx = services.navigation().subscribe();
475
476        // Record an entity
477        let key = EntityNavigationKey::new("channel", "ch1");
478        services
479            .navigation()
480            .record_entity(key.clone())
481            .await
482            .unwrap();
483
484        // Wait for update
485        rx.changed().await.unwrap();
486
487        let snap = rx.borrow().clone();
488        assert_eq!(snap.recent_entities.len(), 1);
489        assert_eq!(snap.recent_entities[0], key);
490    }
491
492    // Uses a thread with larger stack (8MB) to avoid stack overflow from
493    // large async state machine in CommunitasApp + DirectoryService::refresh_all chain
494    #[test]
495    fn directory_refresh_fails_without_auth() {
496        std::thread::Builder::new()
497            .stack_size(8 * 1024 * 1024)
498            .spawn(|| {
499                let rt = tokio::runtime::Runtime::new().unwrap();
500                rt.block_on(async {
501                    let temp = TempDir::new().unwrap();
502                    let services = make_services(&temp).await;
503
504                    // Not authenticated, so refresh should fail
505                    let result = services.directory().refresh_all().await;
506                    assert!(result.is_err());
507                });
508            })
509            .unwrap()
510            .join()
511            .unwrap();
512    }
513
514    #[tokio::test]
515    async fn services_clone_shares_underlying_arcs() {
516        let temp = TempDir::new().unwrap();
517        let services1 = make_services(&temp).await;
518        let services2 = services1.clone();
519
520        // Both should point to the same underlying Arc
521        assert!(Arc::ptr_eq(&services1.auth(), &services2.auth()));
522        assert!(Arc::ptr_eq(
523            &services1.navigation(),
524            &services2.navigation()
525        ));
526        assert!(Arc::ptr_eq(&services1.directory(), &services2.directory()));
527        assert!(Arc::ptr_eq(&services1.messaging(), &services2.messaging()));
528        assert!(Arc::ptr_eq(&services1.presence(), &services2.presence()));
529        assert!(Arc::ptr_eq(&services1.kanban(), &services2.kanban()));
530        assert!(Arc::ptr_eq(&services1.canvas(), &services2.canvas()));
531        assert!(Arc::ptr_eq(&services1.drive(), &services2.drive()));
532        assert!(Arc::ptr_eq(&services1.audit(), &services2.audit()));
533    }
534
535    #[tokio::test]
536    async fn cloned_services_share_state_updates() {
537        let temp = TempDir::new().unwrap();
538        let services1 = make_services(&temp).await;
539        let services2 = services1.clone();
540
541        // Subscribe via services1
542        let mut rx = services1.navigation().subscribe();
543
544        // Record via services2
545        services2
546            .navigation()
547            .record_contact("alice".to_string())
548            .await
549            .unwrap();
550
551        // Should see update via services1's subscription
552        rx.changed().await.unwrap();
553        let snap = rx.borrow().clone();
554        assert_eq!(snap.recent_contacts, vec!["alice".to_string()]);
555    }
556}