diff --git a/crates/core/src/identity.rs b/crates/core/src/identity.rs new file mode 100644 index 0000000..bdf71b0 --- /dev/null +++ b/crates/core/src/identity.rs @@ -0,0 +1,392 @@ +//! Multi-identity management: create, list, switch, delete identities. +//! Each identity gets its own data directory with identity.key, itsgoin.db, and blobs/. +//! One identity is active at a time — hot-swap via Node teardown/rebuild. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; + +use crate::node::Node; +use crate::types::NodeId; + +/// Metadata about a stored identity. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdentityInfo { + pub node_id: NodeId, + pub node_id_hex: String, + pub display_name: String, + pub created_at: u64, + pub last_used_at: u64, +} + +/// Per-identity metadata stored in meta.json. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct IdentityMeta { + display_name: String, + created_at: u64, + last_used_at: u64, +} + +/// Manages multiple identities on a single device. +/// One identity is active at a time. Switching tears down the current Node +/// and builds a new one from the target identity's data directory. +pub struct IdentityManager { + base_dir: PathBuf, + active_node: Option>, + active_id: Option, + bind_addr: Option, + profile: crate::types::DeviceProfile, +} + +impl IdentityManager { + /// Create a new IdentityManager rooted at the given base directory. + /// Performs legacy layout migration if needed (flat itsgoin-data/ → per-identity subdirs). + pub async fn open( + base_dir: impl AsRef, + bind_addr: Option, + profile: crate::types::DeviceProfile, + ) -> anyhow::Result { + let base_dir = base_dir.as_ref().to_path_buf(); + std::fs::create_dir_all(&base_dir)?; + + let mut mgr = Self { + base_dir: base_dir.clone(), + active_node: None, + active_id: None, + bind_addr, + profile, + }; + + // Migrate legacy flat layout if needed + mgr.migrate_legacy_layout()?; + + // Ensure identities/ directory exists + let identities_dir = base_dir.join("identities"); + std::fs::create_dir_all(&identities_dir)?; + + // Auto-start the active identity (if any) + if let Some(active_id) = mgr.read_active_identity()? { + let id_dir = mgr.identity_dir(&active_id); + if id_dir.exists() { + info!(identity = hex::encode(active_id), "Starting active identity"); + let node = Arc::new( + Node::open_with_bind(&id_dir, bind_addr, profile).await? + ); + mgr.active_id = Some(active_id); + mgr.active_node = Some(node); + mgr.update_last_used(&active_id)?; + } else { + warn!(identity = hex::encode(active_id), "Active identity directory missing"); + } + } else { + // No active identity — check if there's exactly one, auto-select it + let identities = mgr.list_identities()?; + if identities.len() == 1 { + let id = identities[0].node_id; + let id_dir = mgr.identity_dir(&id); + info!(identity = hex::encode(id), "Auto-selecting only available identity"); + let node = Arc::new( + Node::open_with_bind(&id_dir, bind_addr, profile).await? + ); + mgr.active_id = Some(id); + mgr.active_node = Some(node); + mgr.write_active_identity(&id)?; + mgr.update_last_used(&id)?; + } + } + + Ok(mgr) + } + + /// Get the currently active Node, if any. + pub fn active_node(&self) -> Option<&Arc> { + self.active_node.as_ref() + } + + /// Get the currently active Node, or error. + pub fn node(&self) -> anyhow::Result<&Arc> { + self.active_node.as_ref() + .ok_or_else(|| anyhow::anyhow!("No active identity")) + } + + /// Get the active identity's NodeId. + pub fn active_id(&self) -> Option { + self.active_id + } + + // --- Identity CRUD --- + + /// List all available identities. + pub fn list_identities(&self) -> anyhow::Result> { + let identities_dir = self.base_dir.join("identities"); + if !identities_dir.exists() { + return Ok(vec![]); + } + + let mut result = Vec::new(); + for entry in std::fs::read_dir(&identities_dir)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let dir_name = entry.file_name().to_string_lossy().to_string(); + let key_path = entry.path().join("identity.key"); + if !key_path.exists() { + continue; + } + + // Derive node_id from key + let key_bytes = std::fs::read(&key_path)?; + let seed: [u8; 32] = key_bytes.try_into() + .map_err(|_| anyhow::anyhow!("invalid key in {}", dir_name))?; + let secret_key = iroh::SecretKey::from_bytes(&seed); + let node_id: NodeId = *secret_key.public().as_bytes(); + let node_id_hex = hex::encode(node_id); + + // Read metadata + let meta_path = entry.path().join("meta.json"); + let meta = if meta_path.exists() { + serde_json::from_str::( + &std::fs::read_to_string(&meta_path)? + ).unwrap_or(IdentityMeta { + display_name: node_id_hex[..12].to_string(), + created_at: 0, + last_used_at: 0, + }) + } else { + IdentityMeta { + display_name: node_id_hex[..12].to_string(), + created_at: 0, + last_used_at: 0, + } + }; + + result.push(IdentityInfo { + node_id, + node_id_hex, + display_name: meta.display_name, + created_at: meta.created_at, + last_used_at: meta.last_used_at, + }); + } + + // Sort by last_used descending + result.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at)); + Ok(result) + } + + /// Create a new identity with the given display name. + /// Returns the new NodeId. + pub fn create_identity(&self, name: &str) -> anyhow::Result { + let key = iroh::SecretKey::generate(&mut rand::rng()); + let seed = key.to_bytes(); + let node_id: NodeId = *key.public().as_bytes(); + let node_id_hex = hex::encode(node_id); + + let id_dir = self.base_dir.join("identities").join(&node_id_hex); + std::fs::create_dir_all(&id_dir)?; + + // Write identity key + std::fs::write(id_dir.join("identity.key"), seed)?; + + // Write metadata + let now = now_ms(); + let meta = IdentityMeta { + display_name: name.to_string(), + created_at: now, + last_used_at: now, + }; + std::fs::write( + id_dir.join("meta.json"), + serde_json::to_string_pretty(&meta)?, + )?; + + info!(identity = node_id_hex, name, "Created new identity"); + Ok(node_id) + } + + /// Import an identity from a hex-encoded secret key. + pub fn import_identity_from_key(&self, key_hex: &str, name: &str) -> anyhow::Result { + let seed_bytes = hex::decode(key_hex)?; + let seed: [u8; 32] = seed_bytes.try_into() + .map_err(|_| anyhow::anyhow!("key must be exactly 32 bytes (64 hex chars)"))?; + + let secret_key = iroh::SecretKey::from_bytes(&seed); + let node_id: NodeId = *secret_key.public().as_bytes(); + let node_id_hex = hex::encode(node_id); + + let id_dir = self.base_dir.join("identities").join(&node_id_hex); + if id_dir.exists() { + anyhow::bail!("Identity {} already exists", &node_id_hex[..12]); + } + + std::fs::create_dir_all(&id_dir)?; + std::fs::write(id_dir.join("identity.key"), seed)?; + + let now = now_ms(); + let meta = IdentityMeta { + display_name: name.to_string(), + created_at: now, + last_used_at: now, + }; + std::fs::write( + id_dir.join("meta.json"), + serde_json::to_string_pretty(&meta)?, + )?; + + info!(identity = node_id_hex, name, "Imported identity from key"); + Ok(node_id) + } + + /// Switch to a different identity. Shuts down the current Node and starts a new one. + pub async fn switch_identity(&mut self, target_id: &NodeId) -> anyhow::Result> { + let target_hex = hex::encode(target_id); + let id_dir = self.identity_dir(target_id); + if !id_dir.exists() { + anyhow::bail!("Identity {} not found", &target_hex[..12]); + } + + // Shutdown current node if running + if let Some(ref node) = self.active_node { + info!(old = hex::encode(node.node_id), new = target_hex, "Switching identity"); + // Node::shutdown would go here once implemented + // For now, dropping the Arc triggers cleanup when last reference is released + } + self.active_node = None; + self.active_id = None; + + // Start new node + let node = Arc::new( + Node::open_with_bind(&id_dir, self.bind_addr, self.profile).await? + ); + let node_arc = Arc::clone(&node); + self.active_node = Some(node); + self.active_id = Some(*target_id); + self.write_active_identity(target_id)?; + self.update_last_used(target_id)?; + + info!(identity = target_hex, "Identity switch complete"); + Ok(node_arc) + } + + /// Delete an identity. Refuses to delete the active identity. + pub fn delete_identity(&self, target_id: &NodeId) -> anyhow::Result<()> { + if self.active_id.as_ref() == Some(target_id) { + anyhow::bail!("Cannot delete the active identity — switch to another first"); + } + + let id_dir = self.identity_dir(target_id); + if !id_dir.exists() { + anyhow::bail!("Identity not found"); + } + + std::fs::remove_dir_all(&id_dir)?; + info!(identity = hex::encode(target_id), "Deleted identity"); + Ok(()) + } + + // --- Internal helpers --- + + fn identity_dir(&self, node_id: &NodeId) -> PathBuf { + self.base_dir.join("identities").join(hex::encode(node_id)) + } + + fn read_active_identity(&self) -> anyhow::Result> { + let path = self.base_dir.join("active_identity"); + if !path.exists() { + return Ok(None); + } + let hex_str = std::fs::read_to_string(&path)?.trim().to_string(); + if hex_str.is_empty() { + return Ok(None); + } + let bytes = hex::decode(&hex_str)?; + let id: NodeId = bytes.try_into() + .map_err(|_| anyhow::anyhow!("invalid active_identity file"))?; + Ok(Some(id)) + } + + fn write_active_identity(&self, node_id: &NodeId) -> anyhow::Result<()> { + let path = self.base_dir.join("active_identity"); + std::fs::write(&path, hex::encode(node_id))?; + Ok(()) + } + + fn update_last_used(&self, node_id: &NodeId) -> anyhow::Result<()> { + let meta_path = self.identity_dir(node_id).join("meta.json"); + if meta_path.exists() { + let mut meta: IdentityMeta = serde_json::from_str( + &std::fs::read_to_string(&meta_path)? + )?; + meta.last_used_at = now_ms(); + std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?; + } + Ok(()) + } + + /// Migrate from flat itsgoin-data/ layout to per-identity subdirectories. + /// Only runs if identity.key exists in the base_dir (legacy layout). + fn migrate_legacy_layout(&self) -> anyhow::Result<()> { + let legacy_key = self.base_dir.join("identity.key"); + if !legacy_key.exists() { + return Ok(()); // Already migrated or fresh install + } + + info!("Migrating legacy flat layout to per-identity directories"); + + // Read the key to determine node_id + let key_bytes = std::fs::read(&legacy_key)?; + let seed: [u8; 32] = key_bytes.try_into() + .map_err(|_| anyhow::anyhow!("invalid legacy identity.key"))?; + let secret_key = iroh::SecretKey::from_bytes(&seed); + let node_id: NodeId = *secret_key.public().as_bytes(); + let node_id_hex = hex::encode(node_id); + + let id_dir = self.base_dir.join("identities").join(&node_id_hex); + std::fs::create_dir_all(&id_dir)?; + + // Move files + let files_to_move = ["identity.key", "itsgoin.db", "itsgoin.db-wal", "itsgoin.db-shm", "bootstrap.json"]; + for name in &files_to_move { + let src = self.base_dir.join(name); + if src.exists() { + let dst = id_dir.join(name); + std::fs::rename(&src, &dst)?; + } + } + + // Move blobs directory + let blobs_src = self.base_dir.join("blobs"); + if blobs_src.exists() { + let blobs_dst = id_dir.join("blobs"); + std::fs::rename(&blobs_src, &blobs_dst)?; + } + + // Write metadata + let now = now_ms(); + let meta = IdentityMeta { + display_name: node_id_hex[..12].to_string(), + created_at: now, + last_used_at: now, + }; + std::fs::write( + id_dir.join("meta.json"), + serde_json::to_string_pretty(&meta)?, + )?; + + // Write active identity + self.write_active_identity(&node_id)?; + + info!(identity = node_id_hex, "Legacy migration complete"); + Ok(()) + } +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0205d36..a7648bb 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,6 +4,7 @@ pub mod connection; pub mod content; pub mod crypto; pub mod http; +pub mod identity; pub mod network; pub mod node; pub mod protocol; diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 21c246a..a4d862a 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -4,10 +4,27 @@ use serde::{Deserialize, Serialize}; use tauri::{Manager, State}; use tracing::info; +use itsgoin_core::identity::IdentityManager; use itsgoin_core::node::Node; use itsgoin_core::types::{NodeId, PeerSlotKind, Post, PostId, PostVisibility, VisibilityIntent}; -type AppState = Arc; +/// The active Node, swappable on identity switch. +type AppNode = Arc>>; +/// The identity manager for multi-identity operations. +type AppIdentity = Arc>; + +/// Helper: get a clone of the active Node from the RwLock. +async fn get_node(state: &State<'_, AppNode>) -> Arc { + state.read().await.clone() +} + +/// Helper: same as get_node but also returns a reference-like wrapper for &Node usage. +macro_rules! node { + ($state:expr) => {{ + let _n = get_node($state).await; + _n + }}; +} // --- DTOs for the frontend (all IDs as hex strings) --- @@ -312,8 +329,8 @@ fn parse_node_id(hex_str: &str) -> Result { // --- Tauri commands --- #[tauri::command] -async fn get_node_info(state: State<'_, AppState>) -> Result { - let node = state.inner(); +async fn get_node_info(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; let node_id_hex = hex::encode(node.node_id); let addr = node.endpoint_addr(); // Prefer external address (UPnP, public IPv6, observed) over local bind address @@ -348,10 +365,10 @@ async fn get_node_info(state: State<'_, AppState>) -> Result, + state: State<'_, AppNode>, name: String, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let profile = node .set_profile(name, String::new()) .await @@ -366,11 +383,11 @@ async fn set_display_name( #[tauri::command] async fn set_profile( - state: State<'_, AppState>, + state: State<'_, AppNode>, name: String, bio: String, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let profile = node .set_profile(name, bio) .await @@ -385,13 +402,13 @@ async fn set_profile( #[tauri::command] async fn create_post( - state: State<'_, AppState>, + state: State<'_, AppNode>, content: String, visibility: Option, circle_name: Option, recipient_hex: Option, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let intent = match visibility.as_deref() { Some("friends") => VisibilityIntent::Friends, Some("circle") => { @@ -409,20 +426,20 @@ async fn create_post( .create_post_with_visibility(content, intent, vec![]) .await .map_err(|e| e.to_string())?; - let decrypted = decrypt_just_created(node, &post, &vis).await; - Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), node).await) + let decrypted = decrypt_just_created(&node, &post, &vis).await; + Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).await) } #[tauri::command] async fn create_post_with_files( - state: State<'_, AppState>, + state: State<'_, AppNode>, content: String, visibility: Option, circle_name: Option, recipient_hex: Option, files: Vec<(String, String)>, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let intent = match visibility.as_deref() { Some("friends") => VisibilityIntent::Friends, Some("circle") => { @@ -452,8 +469,8 @@ async fn create_post_with_files( .create_post_with_visibility(content, intent, attachment_data) .await .map_err(|e| e.to_string())?; - let decrypted = decrypt_just_created(node, &post, &vis).await; - Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), node).await) + let decrypted = decrypt_just_created(&node, &post, &vis).await; + Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).await) } /// Return the filesystem path of a blob if it exists locally (for streaming video/media). @@ -461,11 +478,11 @@ async fn create_post_with_files( /// via the asset protocol — the frontend must use IPC-based get_blob instead. #[tauri::command] async fn get_blob_path( - state: State<'_, AppState>, + state: State<'_, AppNode>, cid_hex: String, post_id_hex: Option, ) -> Result, String> { - let node = state.inner(); + let node = get_node(&state).await; let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?; let cid: [u8; 32] = cid_bytes .try_into() @@ -551,18 +568,18 @@ async fn resolve_blob_data( /// Save a blob to the Downloads folder and open it with the system handler. #[tauri::command] async fn save_and_open_blob( - state: State<'_, AppState>, + state: State<'_, AppNode>, cid_hex: String, post_id_hex: Option, filename: String, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?; let cid: [u8; 32] = cid_bytes .try_into() .map_err(|_| "CID must be 32 bytes".to_string())?; - let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?; + let data = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?; let safe_name = sanitize_download_filename(&filename); // Save to Downloads @@ -581,18 +598,18 @@ async fn save_and_open_blob( /// Save a blob to Downloads without opening it. #[tauri::command] async fn save_blob( - state: State<'_, AppState>, + state: State<'_, AppNode>, cid_hex: String, post_id_hex: Option, filename: String, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?; let cid: [u8; 32] = cid_bytes .try_into() .map_err(|_| "CID must be 32 bytes".to_string())?; - let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?; + let data = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?; let safe_name = sanitize_download_filename(&filename); let downloads = dirs::download_dir() @@ -606,46 +623,46 @@ async fn save_blob( #[tauri::command] async fn get_blob( - state: State<'_, AppState>, + state: State<'_, AppNode>, cid_hex: String, post_id_hex: Option, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?; let cid: [u8; 32] = cid_bytes .try_into() .map_err(|_| "CID must be 32 bytes".to_string())?; - let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?; + let data = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?; use base64::Engine; Ok(base64::engine::general_purpose::STANDARD.encode(&data)) } #[tauri::command] -async fn get_feed(state: State<'_, AppState>) -> Result, String> { - let node = state.inner(); +async fn get_feed(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; let posts = node.get_feed().await.map_err(|e| e.to_string())?; let mut dtos = Vec::with_capacity(posts.len()); for (id, p, vis, decrypted) in &posts { - dtos.push(post_to_dto(id, p, vis, decrypted.as_deref(), node).await); + dtos.push(post_to_dto(id, p, vis, decrypted.as_deref(), &node).await); } Ok(dtos) } #[tauri::command] -async fn get_all_posts(state: State<'_, AppState>) -> Result, String> { - let node = state.inner(); +async fn get_all_posts(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; let posts = node.get_all_posts().await.map_err(|e| e.to_string())?; let mut dtos = Vec::with_capacity(posts.len()); for (id, p, vis, decrypted) in &posts { - dtos.push(post_to_dto(id, p, vis, decrypted.as_deref(), node).await); + dtos.push(post_to_dto(id, p, vis, decrypted.as_deref(), &node).await); } Ok(dtos) } #[tauri::command] -async fn get_stats(state: State<'_, AppState>) -> Result { - let node = state.inner(); +async fn get_stats(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; let stats = node.stats().await.map_err(|e| e.to_string())?; Ok(StatsDto { post_count: stats.post_count, @@ -656,10 +673,10 @@ async fn get_stats(state: State<'_, AppState>) -> Result { #[tauri::command] async fn connect_peer( - state: State<'_, AppState>, + state: State<'_, AppNode>, connect_string: String, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let (nid, addr) = itsgoin_core::parse_connect_string(&connect_string).map_err(|e| e.to_string())?; // Store peer with addresses @@ -683,12 +700,12 @@ async fn connect_peer( } #[tauri::command] -async fn follow_node(state: State<'_, AppState>, node_id_hex: String) -> Result<(), String> { - let node = state.inner(); +async fn follow_node(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> { + let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; node.follow(&nid).await.map_err(|e| e.to_string())?; // Auto-sync: pull posts from the followed peer in the background (15s timeout) - let node_clone = state.inner().clone(); + let node_clone = get_node(&state).await; tokio::spawn(async move { match tokio::time::timeout( std::time::Duration::from_secs(15), @@ -703,15 +720,15 @@ async fn follow_node(state: State<'_, AppState>, node_id_hex: String) -> Result< } #[tauri::command] -async fn unfollow_node(state: State<'_, AppState>, node_id_hex: String) -> Result<(), String> { - let node = state.inner(); +async fn unfollow_node(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> { + let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; node.unfollow(&nid).await.map_err(|e| e.to_string()) } #[tauri::command] -async fn list_follows(state: State<'_, AppState>) -> Result, String> { - let node = state.inner(); +async fn list_follows(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; let follows = node.list_follows().await.map_err(|e| e.to_string())?; let mut dtos = Vec::with_capacity(follows.len()); for nid in &follows { @@ -751,8 +768,8 @@ async fn list_follows(state: State<'_, AppState>) -> Result, String } #[tauri::command] -async fn list_peers(state: State<'_, AppState>) -> Result, String> { - let node = state.inner(); +async fn list_peers(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; let records = node .list_peer_records() .await @@ -831,8 +848,8 @@ async fn list_peers(state: State<'_, AppState>) -> Result, String> } #[tauri::command] -async fn suggested_peers(state: State<'_, AppState>) -> Result, String> { - let node = state.inner(); +async fn suggested_peers(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; let records = node .list_peer_records() .await @@ -868,8 +885,8 @@ async fn suggested_peers(state: State<'_, AppState>) -> Result) -> Result, String> { - let node = state.inner(); +async fn list_circles(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; let circles = node.list_circles().await.map_err(|e| e.to_string())?; Ok(circles .into_iter() @@ -882,8 +899,8 @@ async fn list_circles(state: State<'_, AppState>) -> Result, Stri } #[tauri::command] -async fn create_circle(state: State<'_, AppState>, name: String) -> Result { - let node = state.inner(); +async fn create_circle(state: State<'_, AppNode>, name: String) -> Result { + let node = get_node(&state).await; node.create_circle(name.clone()) .await .map_err(|e| e.to_string())?; @@ -898,18 +915,18 @@ async fn create_circle(state: State<'_, AppState>, name: String) -> Result, name: String) -> Result<(), String> { - let node = state.inner(); +async fn delete_circle(state: State<'_, AppNode>, name: String) -> Result<(), String> { + let node = get_node(&state).await; node.delete_circle(name).await.map_err(|e| e.to_string()) } #[tauri::command] async fn add_circle_member( - state: State<'_, AppState>, + state: State<'_, AppNode>, circle_name: String, node_id_hex: String, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; node.add_to_circle(circle_name, nid) .await @@ -918,11 +935,11 @@ async fn add_circle_member( #[tauri::command] async fn remove_circle_member( - state: State<'_, AppState>, + state: State<'_, AppNode>, circle_name: String, node_id_hex: String, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; node.remove_from_circle(circle_name, nid) .await @@ -930,20 +947,20 @@ async fn remove_circle_member( } #[tauri::command] -async fn delete_post(state: State<'_, AppState>, post_id_hex: String) -> Result<(), String> { - let node = state.inner(); +async fn delete_post(state: State<'_, AppNode>, post_id_hex: String) -> Result<(), String> { + let node = get_node(&state).await; let post_id = parse_node_id(&post_id_hex)?; node.delete_post(&post_id).await.map_err(|e| e.to_string()) } #[tauri::command] async fn revoke_circle_access( - state: State<'_, AppState>, + state: State<'_, AppNode>, circle_name: String, node_id_hex: String, mode: Option, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; let rev_mode = match mode.as_deref() { Some("reencrypt") => itsgoin_core::types::RevocationMode::ReEncrypt, @@ -955,8 +972,8 @@ async fn revoke_circle_access( } #[tauri::command] -async fn get_redundancy_info(state: State<'_, AppState>) -> Result { - let node = state.inner(); +async fn get_redundancy_info(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; let (total, zero, one, two_plus) = node .get_redundancy_summary() .await @@ -971,10 +988,10 @@ async fn get_redundancy_info(state: State<'_, AppState>) -> Result, + state: State<'_, AppNode>, anchors: Vec, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let anchor_ids: Vec = anchors .iter() .map(|h| parse_node_id(h)) @@ -992,8 +1009,8 @@ async fn set_anchors( } #[tauri::command] -async fn list_anchor_peers(state: State<'_, AppState>) -> Result, String> { - let node = state.inner(); +async fn list_anchor_peers(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; let storage = node.storage.get().await; let records = storage.list_anchor_peers().map_err(|e| e.to_string())?; drop(storage); @@ -1024,8 +1041,8 @@ struct KnownAnchorDto { } #[tauri::command] -async fn list_known_anchors(state: State<'_, AppState>) -> Result, String> { - let node = state.inner(); +async fn list_known_anchors(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; let storage = node.storage.get().await; let anchors = storage.list_known_anchors().map_err(|e| e.to_string())?; drop(storage); @@ -1053,8 +1070,8 @@ struct AudienceDto { } #[tauri::command] -async fn list_audience(state: State<'_, AppState>) -> Result, String> { - let node = state.inner(); +async fn list_audience(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; let records = node .list_audience( itsgoin_core::types::AudienceDirection::Inbound, @@ -1087,8 +1104,8 @@ async fn list_audience(state: State<'_, AppState>) -> Result, S } #[tauri::command] -async fn list_audience_outbound(state: State<'_, AppState>) -> Result, String> { - let node = state.inner(); +async fn list_audience_outbound(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; let records = node .list_audience( itsgoin_core::types::AudienceDirection::Outbound, @@ -1118,30 +1135,30 @@ async fn list_audience_outbound(state: State<'_, AppState>) -> Result, + state: State<'_, AppNode>, node_id_hex: String, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; node.request_audience(&nid).await.map_err(|e| e.to_string()) } #[tauri::command] async fn approve_audience( - state: State<'_, AppState>, + state: State<'_, AppNode>, node_id_hex: String, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; node.approve_audience(&nid).await.map_err(|e| e.to_string()) } #[tauri::command] async fn remove_audience( - state: State<'_, AppState>, + state: State<'_, AppNode>, node_id_hex: String, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; node.remove_audience(&nid).await.map_err(|e| e.to_string()) } @@ -1167,8 +1184,8 @@ struct ConnectionDto { } #[tauri::command] -async fn list_connections(state: State<'_, AppState>) -> Result, String> { - let node = state.inner(); +async fn list_connections(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; let conns = node.list_connections().await; let mut dtos = Vec::with_capacity(conns.len()); for (nid, slot_kind, connected_at) in conns { @@ -1185,10 +1202,10 @@ async fn list_connections(state: State<'_, AppState>) -> Result, + state: State<'_, AppNode>, node_id_hex: String, ) -> Result, String> { - let node = state.inner(); + let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; match node.worm_lookup(&nid).await.map_err(|e| e.to_string())? { Some(wr) => { @@ -1224,8 +1241,8 @@ struct SocialRouteDto { } #[tauri::command] -async fn list_social_routes(state: State<'_, AppState>) -> Result, String> { - let node = state.inner(); +async fn list_social_routes(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; let routes = node.list_social_routes().await.map_err(|e| e.to_string())?; let mut dtos = Vec::with_capacity(routes.len()); for r in &routes { @@ -1249,20 +1266,20 @@ async fn list_social_routes(state: State<'_, AppState>) -> Result) -> Result { - let node = state.inner(); +async fn export_identity(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; node.export_identity_hex().map_err(|e| e.to_string()) } #[tauri::command] async fn set_circle_profile( - state: State<'_, AppState>, + state: State<'_, AppNode>, circle_name: String, display_name: String, bio: String, avatar_cid: Option, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let avatar = match avatar_cid { Some(hex) => { let bytes = hex::decode(&hex).map_err(|e| e.to_string())?; @@ -1285,10 +1302,10 @@ async fn set_circle_profile( #[tauri::command] async fn get_circle_profile( - state: State<'_, AppState>, + state: State<'_, AppNode>, circle_name: String, ) -> Result, String> { - let node = state.inner(); + let node = get_node(&state).await; let cp = node .get_circle_profile(&circle_name) .await @@ -1306,10 +1323,10 @@ async fn get_circle_profile( #[tauri::command] async fn delete_circle_profile( - state: State<'_, AppState>, + state: State<'_, AppNode>, circle_name: String, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; node.delete_circle_profile(circle_name) .await .map_err(|e| e.to_string()) @@ -1317,10 +1334,10 @@ async fn delete_circle_profile( #[tauri::command] async fn set_public_visible( - state: State<'_, AppState>, + state: State<'_, AppNode>, visible: bool, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; node.set_public_visible(visible) .await .map_err(|e| e.to_string()) @@ -1328,10 +1345,10 @@ async fn set_public_visible( #[tauri::command] async fn resolve_display( - state: State<'_, AppState>, + state: State<'_, AppNode>, node_id_hex: String, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; let (display_name, bio, avatar_cid) = node .resolve_display_name(&nid) @@ -1346,9 +1363,9 @@ async fn resolve_display( #[tauri::command] async fn get_public_visible( - state: State<'_, AppState>, + state: State<'_, AppNode>, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; node.get_public_visible() .await .map_err(|e| e.to_string()) @@ -1376,8 +1393,8 @@ async fn send_notification(title: String, body: String) -> Result<(), String> { } #[tauri::command] -async fn get_cache_stats(state: State<'_, AppState>) -> Result { - let node = state.inner(); +async fn get_cache_stats(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; let (used, max, count) = node.get_cache_stats().await.map_err(|e| e.to_string())?; Ok(CacheStatsDto { used_bytes: used, @@ -1387,35 +1404,35 @@ async fn get_cache_stats(state: State<'_, AppState>) -> Result, key: String) -> Result, String> { - let node = state.inner(); +async fn get_setting(state: State<'_, AppNode>, key: String) -> Result, String> { + let node = get_node(&state).await; node.get_setting(&key).await.map_err(|e| e.to_string()) } #[tauri::command] -async fn set_setting(state: State<'_, AppState>, key: String, value: String) -> Result<(), String> { - let node = state.inner(); +async fn set_setting(state: State<'_, AppNode>, key: String, value: String) -> Result<(), String> { + let node = get_node(&state).await; node.set_setting(&key, &value).await.map_err(|e| e.to_string()) } #[tauri::command] async fn mark_post_seen( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, react_count: u32, comment_count: u32, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; node.set_seen_engagement(&pid, react_count, comment_count).await.map_err(|e| e.to_string()) } #[tauri::command] async fn mark_conversation_read( - state: State<'_, AppState>, + state: State<'_, AppNode>, partner_id: String, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; let nid = parse_node_id(&partner_id)?; let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1426,10 +1443,10 @@ async fn mark_conversation_read( #[tauri::command] async fn get_seen_engagement( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let (rc, cc) = node.get_seen_engagement(&pid).await.map_err(|e| e.to_string())?; Ok(serde_json::json!({ @@ -1440,10 +1457,10 @@ async fn get_seen_engagement( #[tauri::command] async fn get_badge_counts( - state: State<'_, AppState>, + state: State<'_, AppNode>, last_feed_view_ms: u64, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let storage = node.storage.get().await; // Feed badge: count non-DM posts from others newer than last_feed_view_ms @@ -1528,31 +1545,31 @@ async fn get_badge_counts( #[tauri::command] async fn get_last_read_message( - state: State<'_, AppState>, + state: State<'_, AppNode>, partner_id_hex: String, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let nid = parse_node_id(&partner_id_hex)?; node.get_last_read_message(&nid).await.map_err(|e| e.to_string()) } #[tauri::command] -async fn generate_share_link(state: State<'_, AppState>, post_id_hex: String) -> Result, String> { - let node = state.inner(); +async fn generate_share_link(state: State<'_, AppNode>, post_id_hex: String) -> Result, String> { + let node = get_node(&state).await; let pid = parse_node_id(&post_id_hex)?; node.generate_share_link(&pid).await.map_err(|e| e.to_string()) } #[tauri::command] -async fn sync_all(state: State<'_, AppState>) -> Result { - let node = state.inner(); +async fn sync_all(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; node.sync_all().await.map_err(|e| e.to_string())?; Ok("Sync complete".to_string()) } #[tauri::command] -async fn sync_from_peer(state: State<'_, AppState>, node_id_hex: String) -> Result { - let node = state.inner(); +async fn sync_from_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result { + let node = get_node(&state).await; let bytes = hex::decode(&node_id_hex).map_err(|e| e.to_string())?; let nid: [u8; 32] = bytes.try_into().map_err(|_| "Invalid node ID length")?; node.sync_with(nid).await.map_err(|e| e.to_string())?; @@ -1574,8 +1591,8 @@ struct NetworkSummaryDto { } #[tauri::command] -async fn get_network_summary(state: State<'_, AppState>) -> Result { - let node = state.inner(); +async fn get_network_summary(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; let conns = node.list_connections().await; let mut preferred = 0usize; let mut local = 0usize; @@ -1627,8 +1644,8 @@ struct ActivityLogDto { } #[tauri::command] -async fn get_activity_log(state: State<'_, AppState>) -> Result { - let node = state.inner(); +async fn get_activity_log(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; let events = node.get_activity_log(200); let (rebalance_last, anchor_last) = node.timer_state(); let dto_events: Vec = events.into_iter().map(|e| { @@ -1650,16 +1667,16 @@ async fn get_activity_log(state: State<'_, AppState>) -> Result) -> Result { - let node = state.inner(); +async fn trigger_rebalance(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; node.network.rebalance().await.map_err(|e| e.to_string())?; let conns = node.list_connections().await; Ok(format!("Rebalance complete — {} connections", conns.len())) } #[tauri::command] -async fn request_referrals(state: State<'_, AppState>) -> Result { - let node = state.inner(); +async fn request_referrals(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; let node_id = node.node_id; // Try known_anchors table first (populated by anchor register cycle), // fall back to anchor peers from the peers table (is_anchor = true) @@ -1731,8 +1748,8 @@ async fn request_referrals(state: State<'_, AppState>) -> Result } #[tauri::command] -async fn reset_data(state: State<'_, AppState>) -> Result { - let node = state.inner(); +async fn reset_data(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; let sentinel = node.data_dir.join(".reset"); std::fs::write(&sentinel, b"reset").map_err(|e| e.to_string())?; Ok("Reset scheduled. Restart the app to apply.".to_string()) @@ -1742,12 +1759,12 @@ async fn reset_data(state: State<'_, AppState>) -> Result { #[tauri::command] async fn react_to_post( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, emoji: String, private: bool, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let reaction = node.react_to_post(pid, emoji, private).await.map_err(|e| e.to_string())?; Ok(ReactionDto { @@ -1761,21 +1778,21 @@ async fn react_to_post( #[tauri::command] async fn remove_reaction( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, emoji: String, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; node.remove_reaction(pid, emoji).await.map_err(|e| e.to_string()) } #[tauri::command] async fn get_reactions( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, ) -> Result, String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let reactions = node.get_reactions(pid).await.map_err(|e| e.to_string())?; Ok(reactions.into_iter().map(|r| ReactionDto { @@ -1789,10 +1806,10 @@ async fn get_reactions( #[tauri::command] async fn get_reaction_counts( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, ) -> Result, String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let counts = node.get_reaction_counts(pid).await.map_err(|e| e.to_string())?; Ok(counts.into_iter().map(|(emoji, count, reacted_by_me)| { @@ -1802,11 +1819,11 @@ async fn get_reaction_counts( #[tauri::command] async fn comment_on_post( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, content: String, ) -> Result { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let comment = node.comment_on_post(pid, content).await.map_err(|e| e.to_string())?; let author_name = match node.resolve_display_name(&comment.author).await { @@ -1824,33 +1841,33 @@ async fn comment_on_post( #[tauri::command] async fn edit_comment( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, timestamp_ms: u64, new_content: String, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; node.edit_comment(pid, timestamp_ms, new_content).await.map_err(|e| e.to_string()) } #[tauri::command] async fn delete_comment( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, timestamp_ms: u64, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; node.delete_comment(pid, timestamp_ms).await.map_err(|e| e.to_string()) } #[tauri::command] async fn get_comments( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, ) -> Result, String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let comments = node.get_comments(pid).await.map_err(|e| e.to_string())?; let mut dtos = Vec::new(); @@ -1872,12 +1889,12 @@ async fn get_comments( #[tauri::command] async fn set_comment_policy( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, allow_comments: String, allow_reacts: String, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let comment_perm = match allow_comments.as_str() { "audience_only" => itsgoin_core::types::CommentPermission::AudienceOnly, @@ -1900,10 +1917,10 @@ async fn set_comment_policy( #[tauri::command] async fn get_comment_policy( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, ) -> Result, String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let policy = node.get_comment_policy(pid).await.map_err(|e| e.to_string())?; Ok(policy.map(|p| CommentPolicyDto { @@ -1916,10 +1933,10 @@ async fn get_comment_policy( #[tauri::command] async fn get_comment_thread( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, ) -> Result, String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let comments = node.get_comment_thread(pid).await.map_err(|e| e.to_string())?; let mut dtos = Vec::new(); @@ -1941,12 +1958,12 @@ async fn get_comment_thread( #[tauri::command] async fn write_message_receipt( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, receipt_state: String, emoji: Option, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let state_val = itsgoin_core::types::ReceiptState::from_str_label(&receipt_state); node.write_receipt_slot(pid, state_val, emoji).await.map_err(|e| e.to_string()) @@ -1954,21 +1971,21 @@ async fn write_message_receipt( #[tauri::command] async fn write_message_comment( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, content: String, ) -> Result<(), String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; node.write_comment_slot(pid, content).await.map_err(|e| e.to_string()) } #[tauri::command] async fn get_message_receipts( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, ) -> Result, String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let slots = node.read_receipt_slots(pid).await.map_err(|e| e.to_string())?; Ok(slots.into_iter().map(|s| ReceiptSlotDto { @@ -1982,10 +1999,10 @@ async fn get_message_receipts( #[tauri::command] async fn get_message_comments( - state: State<'_, AppState>, + state: State<'_, AppNode>, post_id: String, ) -> Result, String> { - let node = state.inner(); + let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let slots = node.read_comment_slots(pid).await.map_err(|e| e.to_string())?; let mut dtos = Vec::new(); @@ -2068,47 +2085,58 @@ pub fn run() { let _ = std::fs::remove_file(&sentinel); } - info!(data_dir = %data_dir.display(), "Opening node"); + info!(data_dir = %data_dir.display(), "Opening node via IdentityManager"); let is_mobile = cfg!(target_os = "android") || cfg!(target_os = "ios"); - let node = tauri::async_runtime::block_on(async { - let n = if is_mobile { - Node::open_mobile(&data_dir).await + let profile = if is_mobile { + itsgoin_core::types::DeviceProfile::Mobile + } else { + itsgoin_core::types::DeviceProfile::Desktop + }; + let (node, identity_mgr) = tauri::async_runtime::block_on(async { + let mgr = IdentityManager::open(&data_dir, None, profile).await?; + + // If no identity exists yet, create one + let n = if let Some(node) = mgr.active_node() { + Arc::clone(node) } else { - Node::open(&data_dir).await - }?; - let n = Arc::new(n); + // No identities at all — this is a fresh install after migration + // The IdentityManager should have either migrated or the user needs to create one + anyhow::bail!("No identity available — create one first") + }; - // Start background networking (v2: mesh connections) - // Must be inside block_on so tokio::spawn has a runtime context + // Start background networking n.start_accept_loop(); - n.start_pull_cycle(300); // 5 min pull cycle - n.start_diff_cycle(120); // 2 min routing diff - n.start_rebalance_cycle(600); // 10 min rebalance - n.start_growth_loop(); // reactive mesh growth - n.start_recovery_loop(); // reactive anchor reconnect on mesh loss - n.start_social_checkin_cycle(3600); // 1 hour social checkin - n.start_anchor_register_cycle(600); // 10 min anchor register - n.start_upnp_renewal_cycle(); // UPnP lease renewal (if mapped) - n.start_upnp_tcp_renewal_cycle(); // UPnP TCP lease renewal (for HTTP serving) - n.start_http_server(); // HTTP post delivery (if publicly reachable) - n.start_bootstrap_connectivity_check(); // 24h isolation check - n.start_replication_cycle(600); // 10 min active replication + n.start_pull_cycle(300); + n.start_diff_cycle(120); + n.start_rebalance_cycle(600); + n.start_growth_loop(); + n.start_recovery_loop(); + n.start_social_checkin_cycle(3600); + n.start_anchor_register_cycle(600); + n.start_upnp_renewal_cycle(); + n.start_upnp_tcp_renewal_cycle(); + n.start_http_server(); + n.start_bootstrap_connectivity_check(); + n.start_replication_cycle(600); - // Start blob eviction cycle (every 5 min) let cache_max_bytes: u64 = { let storage = n.storage.get().await; storage.get_setting("cache_size_bytes") .ok() .flatten() .and_then(|s| s.parse().ok()) - .unwrap_or(1_073_741_824u64) // default 1 GB + .unwrap_or(1_073_741_824u64) }; Node::start_eviction_cycle(Arc::clone(&n), 300, cache_max_bytes); - Ok::<_, anyhow::Error>(n) + Ok::<_, anyhow::Error>((n, mgr)) })?; - app.manage(node); + // Manage both the swappable Node and the IdentityManager + let app_node: AppNode = Arc::new(tokio::sync::RwLock::new(node)); + let app_identity: AppIdentity = Arc::new(tokio::sync::Mutex::new(identity_mgr)); + app.manage(app_node); + app.manage(app_identity); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -2195,9 +2223,10 @@ pub fn run() { if let tauri::RunEvent::Resumed = event { // App resumed from background (mobile sleep/wake) — // probe connections and recover dead ones immediately - if let Some(node) = app_handle.try_state::() { - let node = node.inner().clone(); + if let Some(app_node) = app_handle.try_state::() { + let app_node = app_node.inner().clone(); tauri::async_runtime::spawn(async move { + let node = app_node.read().await.clone(); let removed = node.network.wake_health_check().await; if removed > 0 { tracing::info!(removed, "Wake health check: removed dead connections");