1pub 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#[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 #[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 if std::env::var("COMMUNITAS_NO_AUTONET").is_err() {
96 let _span = tracing::info_span!("auto_networking").entered();
97
98 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 #[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 let id_words =
142 generate_id_words().map_err(|e| UiServiceInitError::AppInit(e.to_string()))?;
143
144 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 #[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 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 pub fn storage(&self) -> &UiStorage {
223 &self.storage
224 }
225
226 pub fn auth(&self) -> Arc<AuthController> {
228 self.auth.clone()
229 }
230
231 pub fn navigation(&self) -> Arc<NavigationStore> {
233 self.navigation.clone()
234 }
235
236 pub fn directory(&self) -> Arc<DirectoryService> {
238 self.directory.clone()
239 }
240
241 pub fn messaging(&self) -> Arc<MessagingService> {
243 self.messaging.clone()
244 }
245
246 pub fn presence(&self) -> Arc<PresenceService> {
248 self.presence.clone()
249 }
250
251 pub fn kanban(&self) -> Arc<KanbanService> {
253 self.kanban.clone()
254 }
255
256 pub fn canvas(&self) -> Arc<CanvasService> {
258 self.canvas.clone()
259 }
260
261 pub fn drive(&self) -> Arc<DriveService> {
263 self.drive.clone()
264 }
265
266 pub fn audit(&self) -> Arc<AuditService> {
268 self.audit.clone()
269 }
270
271 pub fn app(&self) -> Arc<CommunitasApp> {
273 self.app.clone()
274 }
275
276 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 pub async fn stop_networking(&self) -> Result<(), UiServiceInitError> {
306 tracing::info!("Networking stop requested (daemon runs independently)");
308 Ok(())
309 }
310}
311
312#[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 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 => {} 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 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 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 let key = EntityNavigationKey::new("channel", "ch1");
478 services
479 .navigation()
480 .record_entity(key.clone())
481 .await
482 .unwrap();
483
484 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 #[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 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 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 let mut rx = services1.navigation().subscribe();
543
544 services2
546 .navigation()
547 .record_contact("alice".to_string())
548 .await
549 .unwrap();
550
551 rx.changed().await.unwrap();
553 let snap = rx.borrow().clone();
554 assert_eq!(snap.recent_contacts, vec!["alice".to_string()]);
555 }
556}