diff --git a/Cargo.lock b/Cargo.lock index ae10be2..5689dce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "itsgoin-cli" -version = "0.7.3" +version = "0.7.0" dependencies = [ "anyhow", "hex", @@ -2744,7 +2744,7 @@ dependencies = [ [[package]] name = "itsgoin-core" -version = "0.7.3" +version = "0.7.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -2753,10 +2753,8 @@ dependencies = [ "curve25519-dalek", "ed25519-dalek", "hex", + "igd-next", "iroh", - "jni", - "ndk-context", - "portmapper", "rand 0.9.2", "rusqlite", "serde", @@ -2769,7 +2767,7 @@ dependencies = [ [[package]] name = "itsgoin-desktop" -version = "0.7.3" +version = "0.7.0" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 2e53c35..8515386 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-cli" -version = "0.7.3" +version = "0.7.0" edition = "2021" [[bin]] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 71a4f85..67d7750 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-core" -version = "0.7.3" +version = "0.7.0" edition = "2021" [dependencies] @@ -19,11 +19,7 @@ ed25519-dalek = { version = "=3.0.0-pre.1", features = ["rand_core", "zeroize"] chacha20poly1305 = "0.10" base64 = "0.22" zip = { version = "2", default-features = false, features = ["deflate"] } -portmapper = "0.14" - -[target.'cfg(target_os = "android")'.dependencies] -jni = "0.21" -ndk-context = "0.1" +igd-next = { version = "0.16", features = ["tokio"] } [dev-dependencies] tempfile = "3" diff --git a/crates/core/src/android_wifi.rs b/crates/core/src/android_wifi.rs deleted file mode 100644 index bee1dcb..0000000 --- a/crates/core/src/android_wifi.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! Android-only helpers for WiFi detection and MulticastLock acquisition. -//! -//! SSDP discovery (UPnP) requires receiving multicast UDP on 239.255.255.250:1900. -//! Android filters incoming multicast unless a WifiManager.MulticastLock is held. -//! These helpers acquire the lock for the duration of an SSDP attempt. -//! -//! Cellular networks (CGNAT) almost never expose UPnP/PCP gateways, so we gate -//! UPnP attempts on "is on WiFi" to avoid wasting a 3s discovery timeout on -//! every cellular startup. - -#![cfg(target_os = "android")] - -use jni::objects::{JObject, JString, JValue}; -use jni::JavaVM; -use tracing::{debug, warn}; - -/// Returns true if the active network is WiFi (or Ethernet). -/// Returns false on cellular, no-network, or any error. -pub fn is_on_wifi() -> bool { - match check_wifi_inner() { - Ok(v) => v, - Err(e) => { - debug!("Android WiFi check failed: {}", e); - false - } - } -} - -fn check_wifi_inner() -> Result { - let ctx = ndk_context::android_context(); - if ctx.vm().is_null() { - return Err("ndk_context: null JavaVM (not initialized?)".into()); - } - if ctx.context().is_null() { - return Err("ndk_context: null activity context".into()); - } - let vm = unsafe { JavaVM::from_raw(ctx.vm() as *mut _) } - .map_err(|e| format!("JavaVM init: {:?}", e))?; - let mut env = vm - .attach_current_thread() - .map_err(|e| format!("attach_current_thread: {:?}", e))?; - let activity = unsafe { JObject::from_raw(ctx.context() as *mut _) }; - - // service = activity.getSystemService(Context.CONNECTIVITY_SERVICE) - let svc_name = env - .new_string("connectivity") - .map_err(|e| format!("new_string: {:?}", e))?; - let svc = env - .call_method( - &activity, - "getSystemService", - "(Ljava/lang/String;)Ljava/lang/Object;", - &[JValue::Object(&svc_name)], - ) - .map_err(|e| format!("getSystemService: {:?}", e))? - .l() - .map_err(|e| format!("getSystemService cast: {:?}", e))?; - - // network = service.getActiveNetwork() - let network = env - .call_method(&svc, "getActiveNetwork", "()Landroid/net/Network;", &[]) - .map_err(|e| format!("getActiveNetwork: {:?}", e))? - .l() - .map_err(|e| format!("getActiveNetwork cast: {:?}", e))?; - - if network.is_null() { - return Ok(false); - } - - // caps = service.getNetworkCapabilities(network) - let caps = env - .call_method( - &svc, - "getNetworkCapabilities", - "(Landroid/net/Network;)Landroid/net/NetworkCapabilities;", - &[JValue::Object(&network)], - ) - .map_err(|e| format!("getNetworkCapabilities: {:?}", e))? - .l() - .map_err(|e| format!("getNetworkCapabilities cast: {:?}", e))?; - - if caps.is_null() { - return Ok(false); - } - - // NetworkCapabilities.TRANSPORT_WIFI = 1, TRANSPORT_ETHERNET = 3 - let has_wifi = env - .call_method(&caps, "hasTransport", "(I)Z", &[JValue::Int(1)]) - .map_err(|e| format!("hasTransport(WIFI): {:?}", e))? - .z() - .map_err(|e| format!("hasTransport(WIFI) cast: {:?}", e))?; - let has_eth = env - .call_method(&caps, "hasTransport", "(I)Z", &[JValue::Int(3)]) - .map_err(|e| format!("hasTransport(ETH): {:?}", e))? - .z() - .map_err(|e| format!("hasTransport(ETH) cast: {:?}", e))?; - - Ok(has_wifi || has_eth) -} - -/// RAII guard that holds an Android WifiManager.MulticastLock for its lifetime. -/// Release happens on Drop. -pub struct MulticastLockGuard { - vm: JavaVM, - lock: jni::objects::GlobalRef, -} - -impl MulticastLockGuard { - pub fn acquire(tag: &str) -> Option { - match Self::acquire_inner(tag) { - Ok(g) => Some(g), - Err(e) => { - debug!("MulticastLock acquire failed: {}", e); - None - } - } - } - - fn acquire_inner(tag: &str) -> Result { - let ctx = ndk_context::android_context(); - if ctx.vm().is_null() { - return Err("ndk_context: null JavaVM (not initialized?)".into()); - } - if ctx.context().is_null() { - return Err("ndk_context: null activity context".into()); - } - let vm = unsafe { JavaVM::from_raw(ctx.vm() as *mut _) } - .map_err(|e| format!("JavaVM init: {:?}", e))?; - let mut env = vm - .attach_current_thread() - .map_err(|e| format!("attach_current_thread: {:?}", e))?; - let activity = unsafe { JObject::from_raw(ctx.context() as *mut _) }; - - // wifi = activity.getApplicationContext().getSystemService(Context.WIFI_SERVICE) - // Application context is important: WifiManager from Activity context can leak the activity. - let app_ctx = env - .call_method( - &activity, - "getApplicationContext", - "()Landroid/content/Context;", - &[], - ) - .map_err(|e| format!("getApplicationContext: {:?}", e))? - .l() - .map_err(|e| format!("getApplicationContext cast: {:?}", e))?; - - let svc_name: JString = env - .new_string("wifi") - .map_err(|e| format!("new_string: {:?}", e))?; - let wifi = env - .call_method( - &app_ctx, - "getSystemService", - "(Ljava/lang/String;)Ljava/lang/Object;", - &[JValue::Object(&svc_name)], - ) - .map_err(|e| format!("getSystemService(wifi): {:?}", e))? - .l() - .map_err(|e| format!("getSystemService(wifi) cast: {:?}", e))?; - - if wifi.is_null() { - return Err("WifiManager is null".into()); - } - - // lock = wifi.createMulticastLock(tag) - let tag_str = env - .new_string(tag) - .map_err(|e| format!("new_string(tag): {:?}", e))?; - let lock = env - .call_method( - &wifi, - "createMulticastLock", - "(Ljava/lang/String;)Landroid/net/wifi/WifiManager$MulticastLock;", - &[JValue::Object(&tag_str)], - ) - .map_err(|e| format!("createMulticastLock: {:?}", e))? - .l() - .map_err(|e| format!("createMulticastLock cast: {:?}", e))?; - - // lock.setReferenceCounted(false) - let _ = env.call_method( - &lock, - "setReferenceCounted", - "(Z)V", - &[JValue::Bool(0)], - ); - - // lock.acquire() - env.call_method(&lock, "acquire", "()V", &[]) - .map_err(|e| format!("acquire: {:?}", e))?; - - let global = env - .new_global_ref(&lock) - .map_err(|e| format!("new_global_ref: {:?}", e))?; - - drop(env); - Ok(MulticastLockGuard { vm, lock: global }) - } -} - -impl Drop for MulticastLockGuard { - fn drop(&mut self) { - let env = match self.vm.attach_current_thread() { - Ok(e) => e, - Err(e) => { - warn!("MulticastLock release: attach failed: {:?}", e); - return; - } - }; - let mut env = env; - if let Err(e) = env.call_method(&self.lock, "release", "()V", &[]) { - warn!("MulticastLock release failed: {:?}", e); - } - } -} - -/// Stop the Android `NodeService` foreground service. Called from the -/// in-app close button so the network process actually exits rather -/// than continuing to run as a foreground service after the Activity -/// closes (foreground services are kept alive across Activity exit by -/// design). -/// -/// Errors are logged but not propagated — best-effort cleanup before -/// `AppHandle::exit(0)` finishes the Activity. -pub fn stop_node_service() { - if let Err(e) = stop_node_service_inner() { - warn!("stop_node_service failed (will exit anyway): {}", e); - } -} - -fn stop_node_service_inner() -> Result<(), String> { - let ctx = ndk_context::android_context(); - if ctx.vm().is_null() { - return Err("ndk_context: null JavaVM".into()); - } - if ctx.context().is_null() { - return Err("ndk_context: null activity context".into()); - } - let vm = unsafe { JavaVM::from_raw(ctx.vm() as *mut _) } - .map_err(|e| format!("JavaVM init: {:?}", e))?; - let mut env = vm - .attach_current_thread() - .map_err(|e| format!("attach_current_thread: {:?}", e))?; - let activity = unsafe { JObject::from_raw(ctx.context() as *mut _) }; - - // NodeService.stopFromNative(activity) - env.call_static_method( - "com/itsgoin/app/NodeService", - "stopFromNative", - "(Landroid/content/Context;)V", - &[JValue::Object(&activity)], - ) - .map_err(|e| format!("stopFromNative: {:?}", e))?; - - Ok(()) -} diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 07b6633..9a022a5 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -145,22 +145,14 @@ pub(crate) async fn hole_punch_parallel( None } -// EDM port scanner — DISABLED in v0.7.3 (see hole_punch_with_scanning). -// Constants and helpers preserved as the refactor target for a raw-UDP -// scanner that bypasses iroh's path-store accumulation. - /// Timeout for each individual scan connect attempt (200ms → ~20 in-flight at 100/sec) -#[allow(dead_code)] const SCAN_CONNECT_TIMEOUT_MS: u64 = 200; /// Scan rate: one attempt every 10ms = 100 ports/sec -#[allow(dead_code)] const SCAN_INTERVAL_MS: u64 = 10; /// How often to punch peer's anchor-observed address during scanning (seconds). /// Each punch checks if the peer has opened a firewall port matching our actual port. -#[allow(dead_code)] const SCAN_PUNCH_INTERVAL_SECS: u64 = 2; /// Maximum scan duration (seconds) — accept the cost for otherwise-impossible connections -#[allow(dead_code)] const SCAN_MAX_DURATION_SECS: u64 = 300; // 5 minutes /// Global cap on concurrent port-scan hole punches. Each scanner fires @@ -172,63 +164,11 @@ const SCAN_MAX_DURATION_SECS: u64 = 300; // 5 minutes /// at proxy timeouts. A permit is acquired before the scanning loop /// starts and held until the scanner returns; extra callers fall back /// to the cheaper `hole_punch_parallel`. -#[allow(dead_code)] fn scanner_semaphore() -> &'static tokio::sync::Semaphore { static SEM: std::sync::OnceLock = std::sync::OnceLock::new(); SEM.get_or_init(|| tokio::sync::Semaphore::new(1)) } -/// Hole punch orchestrator. -/// -/// **v0.7.3:** the EDM port scanner is DISABLED. We do Step 1 (quick punch to -/// the anchor-observed address) → Step 2 (parallel punch over the 30s window -/// to all known addresses). No port scan. -/// -/// **Why disabled:** iroh's `Endpoint` accumulates every `endpoint.connect()` -/// target into a per-endpoint paths set and probes them all in the background -/// under QUIC NAT-traversal. A 100-probes/sec / 5-min scan inserts ~30,000 -/// paths; iroh then probes all of them. Observed at 22MB/s outbound from a -/// single client. Disabled until we replace per-probe `endpoint.connect()` -/// with a raw `socket.send_to()` on the endpoint's bound UDP socket — see -/// `edm_port_scan_disabled_v0_7_3` for the preserved scanner logic to -/// refactor against. -/// -/// Original docstring is preserved on `edm_port_scan_disabled_v0_7_3`. -pub(crate) async fn hole_punch_with_scanning( - endpoint: &iroh::Endpoint, - target: &NodeId, - addresses: &[String], - _our_profile: crate::types::NatProfile, - _peer_profile: crate::types::NatProfile, -) -> Option { - if let Some(conn) = hole_punch_single(endpoint, target, addresses).await { - return Some(conn); - } - hole_punch_parallel(endpoint, target, addresses).await -} - -/// **DISABLED in v0.7.3** — kept as the refactor target for a safe replacement. -/// -/// **Why disabled:** iroh's `Endpoint` accumulates every `endpoint.connect()` -/// target into a per-endpoint paths set and probes them all in the background -/// under QUIC NAT-traversal. A 100-probes/sec / 5-min scan inserts ~30,000 -/// paths; iroh then probes all of them. Observed at 22MB/s outbound from a -/// single client (DoS-grade). -/// -/// **Refactor target:** replace `endpoint.connect()` in the per-probe path -/// with a raw `socket.send_to(...)` on the endpoint's bound UDP socket. The -/// probe still opens a NAT mapping on our side; we just don't ask iroh to -/// manage the path. The every-2s punch retains `endpoint.connect()` so the -/// real handshake completes when the peer's punch arrives. -/// -/// Logic worth preserving below: role-based scanner/puncher split, -/// `PortWalkIter`, `scanner_semaphore`, `found_tx`/`found_rx` channel -/// pattern, deadline + `tokio::select!` orchestration. -/// -/// --- -/// -/// Original docstring: -/// /// Advanced hole punch with port scanning fallback for EDM/port-restricted NAT. /// /// **Role-based behavior** (each side calls this independently): @@ -243,8 +183,7 @@ pub(crate) async fn hole_punch_with_scanning( /// NAT mapping alive and checks if the peer's scan has opened their firewall for us. /// /// For both-EDM pairs: both sides scan + punch simultaneously. -#[allow(dead_code)] -async fn edm_port_scan_disabled_v0_7_3( +pub(crate) async fn hole_punch_with_scanning( endpoint: &iroh::Endpoint, target: &NodeId, addresses: &[String], @@ -450,17 +389,12 @@ async fn edm_port_scan_disabled_v0_7_3( /// Iterator that walks outward from a base port: base, base+1, base-1, base+2, base-2, ... /// Skips ports outside [1, 65535]. -/// -/// Used by `edm_port_scan_disabled_v0_7_3` — preserved for the future -/// raw-UDP scanner refactor. -#[allow(dead_code)] struct PortWalkIter { base: u16, offset: u32, tried_plus: bool, // within current offset, have we tried base+offset? } -#[allow(dead_code)] impl PortWalkIter { fn new(base: u16) -> Self { Self { base, offset: 0, tried_plus: false } @@ -692,11 +626,6 @@ pub struct ConnectionManager { active_relay_pipes: Arc, /// Max concurrent relay pipes max_relay_pipes: usize, - /// User opt-in for session relay. Gates both serving as a relay - /// (`can_accept_relay_pipe`) and using a peer as a relay on hole-punch - /// failure (auto-fallback in node.rs). Default false — relay is opt-in. - /// Loaded from the `relay.session_relay_enabled` setting at startup. - session_relay_enabled: Arc, /// Device profile (for resource limits) #[allow(dead_code)] device_profile: DeviceProfile, @@ -778,13 +707,11 @@ impl ConnectionManager { bind_addr: Option, nat_type: crate::types::NatType, nat_mapping: crate::types::NatMapping, - session_relay_enabled: bool, ) -> Self { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; - let session_relay_enabled = Arc::new(AtomicBool::new(session_relay_enabled)); Self { connections: HashMap::new(), endpoint, @@ -805,7 +732,6 @@ impl ConnectionManager { seen_intros: HashMap::new(), active_relay_pipes: Arc::new(AtomicU64::new(0)), max_relay_pipes: profile.max_relay_pipes(), - session_relay_enabled, device_profile: profile, unreachable_peers: HashMap::new(), referral_list: HashMap::new(), @@ -3705,28 +3631,11 @@ impl ConnectionManager { &self.active_relay_pipes } - /// Check if we can accept more relay pipes. Gated on user opt-in - /// (`relay.session_relay_enabled`) — returns false if the user has - /// not enabled serving as a session relay, regardless of capacity. + /// Check if we can accept more relay pipes. pub fn can_accept_relay_pipe(&self) -> bool { - if !self.session_relay_enabled.load(Ordering::Relaxed) { - return false; - } self.active_relay_pipes.load(Ordering::Relaxed) < self.max_relay_pipes as u64 } - /// Whether the user has opted in to session relay (both serving and using). - pub fn is_session_relay_enabled(&self) -> bool { - self.session_relay_enabled.load(Ordering::Relaxed) - } - - /// Update the session-relay opt-in flag. The caller is responsible for - /// persisting the setting to storage; this only updates the in-memory - /// flag that gates the relay accept and auto-use paths. - pub fn set_session_relay_enabled(&self, enabled: bool) { - self.session_relay_enabled.store(enabled, Ordering::Relaxed); - } - /// Get our node ID. pub fn our_node_id(&self) -> &NodeId { &self.our_node_id @@ -6672,13 +6581,6 @@ pub enum ConnCommand { CanAcceptRelayPipe { reply: oneshot::Sender, }, - IsSessionRelayEnabled { - reply: oneshot::Sender, - }, - SetSessionRelayEnabled { - enabled: bool, - reply: oneshot::Sender<()>, - }, BuildAnchorAdvertisedAddr { reply: oneshot::Sender>, }, @@ -7076,18 +6978,6 @@ impl ConnHandle { rx.await.unwrap_or(false) } - pub async fn is_session_relay_enabled(&self) -> bool { - let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::IsSessionRelayEnabled { reply: tx }).await; - rx.await.unwrap_or(false) - } - - pub async fn set_session_relay_enabled(&self, enabled: bool) { - let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::SetSessionRelayEnabled { enabled, reply: tx }).await; - let _ = rx.await; - } - pub async fn build_anchor_advertised_addr(&self) -> Option { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::BuildAnchorAdvertisedAddr { reply: tx }).await; @@ -8012,15 +7902,6 @@ impl ConnectionActor { let cm = self.cm.lock().await; let _ = reply.send(cm.can_accept_relay_pipe()); } - ConnCommand::IsSessionRelayEnabled { reply } => { - let cm = self.cm.lock().await; - let _ = reply.send(cm.is_session_relay_enabled()); - } - ConnCommand::SetSessionRelayEnabled { enabled, reply } => { - let cm = self.cm.lock().await; - cm.set_session_relay_enabled(enabled); - let _ = reply.send(()); - } ConnCommand::BuildAnchorAdvertisedAddr { reply } => { let cm = self.cm.lock().await; let _ = reply.send(cm.build_anchor_advertised_addr()); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3c679a0..a4e6d7f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,6 +1,4 @@ pub mod activity; -#[cfg(target_os = "android")] -pub mod android_wifi; pub mod blob; pub mod connection; pub mod content; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 6466eff..282a576 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -35,12 +35,10 @@ pub struct Network { /// Growth loop signal sender (set by start_growth_loop) growth_tx: tokio::sync::Mutex>>, activity_log: Arc>, - /// UDP port mapping (UPnP/NAT-PMP/PCP) for the QUIC socket. - /// Holding this keeps the auto-renewing portmapper service alive. - upnp_mapping: Option, - /// TCP port mapping for HTTP serving, if available. Holding it keeps - /// the lease renewed by the portmapper background service. - tcp_mapping: Option, + /// UPnP mapping result (None if no mapping or on mobile) + upnp_mapping: Option, + /// Whether UPnP TCP mapping succeeded (for HTTP serving) + has_upnp_tcp: bool, /// Whether this node has a public IPv6 address has_public_v6: bool, /// Stable bind address (from --bind flag), passed to ConnectionManager for anchor advertised address @@ -122,18 +120,12 @@ impl Network { let our_node_id = *endpoint.id().as_bytes(); - // Best-effort UDP port mapping (UPnP/NAT-PMP/PCP via portmapper). - // Skip if --bind was explicit (operator chose a fixed external port). - // The portmapper service auto-renews internally; we hold the - // PortMapping handle for the lifetime of the network. On Android the - // upnp module gates on active-network-is-WiFi and acquires a - // MulticastLock around UPnP/SSDP discovery for the lifetime of the - // mapping. iOS works for PCP and NAT-PMP without entitlement. + // Best-effort UPnP port mapping (desktop only, skip if --bind was used) let is_mobile = cfg!(target_os = "android") || cfg!(target_os = "ios"); - let upnp_mapping = if bind_addr.is_none() { + let upnp_mapping = if !is_mobile && bind_addr.is_none() { let bound_port = endpoint.bound_sockets().first() .map(|s| s.port()).unwrap_or(0); - crate::upnp::PortMapping::try_udp(bound_port).await + crate::upnp::try_upnp_mapping(bound_port).await } else { None }; @@ -223,14 +215,6 @@ impl Network { } let upnp_external_addr = upnp_mapping.as_ref().map(|m| m.external_addr); - let session_relay_enabled = { - let s = storage.get().await; - s.get_setting("relay.session_relay_enabled") - .ok() - .flatten() - .map(|v| v == "true") - .unwrap_or(false) - }; let conn_mgr = ConnectionManager::new( endpoint.clone(), Arc::clone(&storage), @@ -244,26 +228,18 @@ impl Network { bind_addr, nat_type, nat_mapping, - session_relay_enabled, ); let conn_mgr = Arc::new(Mutex::new(conn_mgr)); // Spawn actor wrapping the same Arc> (Phase 1: additive) let conn_handle = ConnectionActor::spawn_with_arc(Arc::clone(&conn_mgr)).await; - // TCP port mapping for HTTP post delivery. Run on every platform — - // mobile devices with permissive NAT (UPnP/PCP-TCP working) can serve - // HTTP for direct browser fetches. We attempt independently of the - // UDP mapping result because the protocols are separate calls in - // portmapper. - let tcp_mapping = if bind_addr.is_none() { - let bound_port = endpoint.bound_sockets().first() - .map(|s| s.port()).unwrap_or(0); - crate::upnp::PortMapping::try_tcp(bound_port).await + // TCP UPnP mapping for HTTP post delivery (only if UDP UPnP succeeded) + let has_upnp_tcp = if let Some(ref mapping) = upnp_mapping { + crate::upnp::try_upnp_tcp_mapping(mapping.local_port, mapping.external_addr.port()).await } else { - None + false }; - let has_upnp_tcp = tcp_mapping.is_some(); info!( node_id = %endpoint.id(), @@ -283,79 +259,6 @@ impl Network { info!(role = %device_role, "CDN replication role determined"); conn_handle.set_device_role(device_role); - // Anchor reachability watcher: tracks the UDP port mapping over time - // and adjusts anchor mode without restart. - // - Mapping lost (None) for >5 min → clear anchor mode (don't keep - // advertising an unreachable address). Replaces the old - // "3 consecutive UPnP renewal failures" logic. - // - Mapping restored (None → Some) → re-evaluate auto-anchor. If - // this node qualifies (desktop with the new mapping in hand), set - // anchor back on. Mobile never auto-anchors regardless. - // The watcher exits when the watch channel closes (i.e., when the - // PortMapping is dropped at network shutdown). - if let Some(ref mapping) = upnp_mapping { - let mut rx = mapping.watch_external(); - let is_anchor_w = Arc::clone(&is_anchor); - let alog_w = Arc::clone(&activity_log); - tokio::spawn(async move { - let mut down_since: Option = None; - let threshold = std::time::Duration::from_secs(300); - loop { - if rx.changed().await.is_err() { - // Channel closed — mapping was dropped, watcher done. - return; - } - let current = *rx.borrow_and_update(); - match current { - Some(addr) => { - // Mapping restored. If we'd lost anchor due to a - // previous drop, restore it now. Skip on mobile - // (cellular IPs look public but aren't anchorable). - let recovered = down_since.is_some(); - down_since = None; - if recovered && !is_mobile && !is_anchor_w.load(Ordering::Relaxed) { - is_anchor_w.store(true, Ordering::Relaxed); - if let Ok(mut log) = alog_w.try_lock() { - log.log( - ActivityLevel::Info, - ActivityCategory::Connection, - format!( - "Port mapping restored ({}), anchor mode re-enabled", - addr - ), - None, - ); - } - info!(external = %addr, "Port mapping restored — anchor mode re-enabled"); - } - } - None => { - let now = std::time::Instant::now(); - let t = *down_since.get_or_insert(now); - if now.duration_since(t) > threshold - && is_anchor_w.load(Ordering::Relaxed) - { - is_anchor_w.store(false, Ordering::Relaxed); - if let Ok(mut log) = alog_w.try_lock() { - log.log( - ActivityLevel::Warn, - ActivityCategory::Connection, - "Port mapping lost >5min, anchor mode cleared" - .into(), - None, - ); - } - warn!( - "Port mapping lost for >5min — anchor mode cleared" - ); - // Don't return — keep watching for recovery so we can re-anchor. - } - } - } - } - }); - } - Ok(Self { endpoint, storage, @@ -366,7 +269,7 @@ impl Network { growth_tx: tokio::sync::Mutex::new(None), activity_log, upnp_mapping, - tcp_mapping, + has_upnp_tcp, has_public_v6, bind_addr, device_role, @@ -430,8 +333,8 @@ impl Network { addrs } - /// Get the active UDP port mapping (UPnP/NAT-PMP/PCP), if any. - pub fn upnp_mapping(&self) -> Option<&crate::upnp::PortMapping> { + /// Get the UPnP mapping, if one was successfully acquired. + pub fn upnp_mapping(&self) -> Option<&crate::upnp::UpnpMapping> { self.upnp_mapping.as_ref() } @@ -456,7 +359,7 @@ impl Network { /// Whether this node can serve HTTP (has TCP reachability). pub fn is_http_capable(&self) -> bool { - self.tcp_mapping.is_some() || self.has_public_v6 || self.bind_addr.is_some() + self.has_upnp_tcp || self.has_public_v6 || self.bind_addr.is_some() } /// Get the port to bind the HTTP TCP listener on (same as QUIC). @@ -470,9 +373,9 @@ impl Network { } } - /// Whether the TCP port mapping for HTTP serving is active. + /// Whether UPnP TCP mapping is active. pub fn has_upnp_tcp(&self) -> bool { - self.tcp_mapping.is_some() + self.has_upnp_tcp } /// Whether this node has a public IPv6 address. @@ -480,19 +383,15 @@ impl Network { self.has_public_v6 } - /// Whether this node has a port mapping (UDP or TCP) from any of - /// UPnP-IGD / NAT-PMP / PCP. + /// Whether this node has UPnP mapping (UDP or TCP). pub fn has_upnp(&self) -> bool { - self.upnp_mapping.is_some() || self.tcp_mapping.is_some() + self.upnp_mapping.is_some() || self.has_upnp_tcp } /// Get external HTTP address string for InitialExchange advertisement. pub fn http_addr(&self) -> Option { - // Prefer an explicit TCP mapping (UPnP/PCP/NAT-PMP) — that's the address - // a remote browser can reach for HTTP serving. The UDP mapping is for - // QUIC, not HTTP. - if let Some(ref mapping) = self.tcp_mapping { + if let Some(ref mapping) = self.upnp_mapping { return Some(mapping.external_addr.to_string()); } if let Some(bind) = self.bind_addr { @@ -2253,20 +2152,18 @@ impl Network { pub async fn shutdown(self) -> anyhow::Result<()> { // Remove UPnP port mapping before closing endpoint - // Dropping the PortMapping triggers the portmapper Client's - // AbortOnDropHandle and stops the renewal task. We don't explicitly - // release here because `self` doesn't move; the field will drop - // naturally when the Network goes out of scope. + if let Some(ref mapping) = self.upnp_mapping { + crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await; + } self.endpoint.close().await; Ok(()) } /// Shutdown via Arc reference — closes the endpoint, causing all background tasks to exit. pub async fn shutdown_ref(&self) { - // Dropping the PortMapping triggers the portmapper Client's - // AbortOnDropHandle and stops the renewal task. We don't explicitly - // release here because `self` doesn't move; the field will drop - // naturally when the Network goes out of scope. + if let Some(ref mapping) = self.upnp_mapping { + crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await; + } self.endpoint.close().await; } diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index ede6096..7c90407 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -92,175 +92,6 @@ async fn ensure_initial_v_me( generate_and_store_initial_v_me(&s, persona_id, now_ms) } -/// Probe a list of anchors with batched parallelism, returning the first -/// successful NodeId. Remaining probes continue in background tasks after -/// first success and naturally register additional mesh connections. -/// -/// **Parameters fixed in v0.7.3:** -/// - 3 anchors in flight at a time -/// - 2-second stagger between batch dispatches -/// - 10s per-anchor connect timeout -/// - Failed probes to anchors with `last_seen_ms` older than 3 days -/// auto-delete from `known_anchors` (self-healing pruning) -/// -/// Returns `None` only when every probe completed without success. -async fn probe_anchors_batched( - anchors: Vec<(NodeId, Vec)>, - network: Arc, - storage: Arc, - self_node_id: NodeId, - label: &'static str, -) -> Option { - use std::sync::atomic::{AtomicUsize, Ordering}; - - const BATCH_SIZE: usize = 3; - const BATCH_STAGGER_SECS: u64 = 2; - const PER_ANCHOR_TIMEOUT_SECS: u64 = 10; - const STALE_THRESHOLD_MS: u64 = 3 * 86_400 * 1000; - - let total = anchors.len(); - if total == 0 { - return None; - } - - let (success_tx, success_rx) = tokio::sync::oneshot::channel::(); - let success_tx = Arc::new(tokio::sync::Mutex::new(Some(success_tx))); - let completed = Arc::new(AtomicUsize::new(0)); - let all_done = Arc::new(tokio::sync::Notify::new()); - - // Dispatcher: spawns per-anchor tasks in batches of BATCH_SIZE, - // sleeping BATCH_STAGGER_SECS between batches. The per-anchor tasks - // continue running after the dispatcher exits. - let dispatcher = { - let network = Arc::clone(&network); - let storage = Arc::clone(&storage); - let success_tx = Arc::clone(&success_tx); - let completed = Arc::clone(&completed); - let all_done = Arc::clone(&all_done); - tokio::spawn(async move { - let mut iter = anchors.into_iter(); - loop { - let batch: Vec<_> = (&mut iter).take(BATCH_SIZE).collect(); - if batch.is_empty() { - break; - } - let more = iter.size_hint().0 > 0; - for (nid, addrs) in batch { - let network = Arc::clone(&network); - let storage = Arc::clone(&storage); - let success_tx = Arc::clone(&success_tx); - let completed = Arc::clone(&completed); - let all_done = Arc::clone(&all_done); - tokio::spawn(async move { - let result = probe_one_anchor(&network, &storage, nid, addrs, self_node_id, label).await; - if let Some(nid) = result { - let mut guard = success_tx.lock().await; - if let Some(sender) = guard.take() { - let _ = sender.send(nid); - } - } - let prev = completed.fetch_add(1, Ordering::SeqCst); - if prev + 1 == total { - all_done.notify_one(); - } - }); - } - if more { - tokio::time::sleep(std::time::Duration::from_secs(BATCH_STAGGER_SECS)).await; - } - } - }) - }; - - // Race: first success vs all probes complete unsuccessfully. - let result = tokio::select! { - Ok(nid) = success_rx => Some(nid), - _ = all_done.notified() => None, - }; - - // Detach the dispatcher; in-flight per-anchor tasks continue. - drop(dispatcher); - - let _ = BATCH_STAGGER_SECS; // silence unused-const if compiler is picky - let _ = PER_ANCHOR_TIMEOUT_SECS; - let _ = STALE_THRESHOLD_MS; - result -} - -async fn probe_one_anchor( - network: &crate::network::Network, - storage: &Arc, - nid: NodeId, - addrs: Vec, - self_node_id: NodeId, - label: &'static str, -) -> Option { - const PER_ANCHOR_TIMEOUT_SECS: u64 = 10; - const STALE_THRESHOLD_MS: u64 = 3 * 86_400 * 1000; - - if nid == self_node_id || network.is_peer_connected_or_session(&nid).await { - return None; - } - let endpoint_id = match iroh::EndpointId::from_bytes(&nid) { - Ok(eid) => eid, - Err(_) => return None, - }; - let mut addr = iroh::EndpointAddr::from(endpoint_id); - for sa in &addrs { - addr = addr.with_ip_addr(*sa); - } - info!(peer = hex::encode(&nid), label, "Trying anchor"); - let result = tokio::time::timeout( - std::time::Duration::from_secs(PER_ANCHOR_TIMEOUT_SECS), - network.connect_to_anchor(nid, addr), - ).await; - - match result { - Ok(Ok(())) => { - info!(peer = hex::encode(&nid), label, "Connected to anchor"); - Some(nid) - } - Ok(Err(e)) => { - debug!(error = %e, peer = hex::encode(&nid), label, "Anchor connect failed"); - maybe_prune_stale_anchor(storage, &nid, STALE_THRESHOLD_MS).await; - None - } - Err(_) => { - debug!(peer = hex::encode(&nid), label, "Anchor connect timed out"); - maybe_prune_stale_anchor(storage, &nid, STALE_THRESHOLD_MS).await; - None - } - } -} - -/// If the anchor's last successful contact was more than `threshold_ms` -/// ago, delete it from `known_anchors`. Future startups won't waste a -/// probe slot on it. Anchors that were recently successful are preserved -/// even when they fail a single probe (likely transient). -async fn maybe_prune_stale_anchor( - storage: &Arc, - nid: &NodeId, - threshold_ms: u64, -) { - let s = storage.get().await; - let last_seen_ms = match s.get_known_anchor_last_seen(nid) { - Ok(Some(ms)) => ms, - _ => return, - }; - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - if now_ms > last_seen_ms && now_ms - last_seen_ms > threshold_ms { - let _ = s.delete_known_anchor(nid); - debug!( - peer = hex::encode(nid), - age_ms = now_ms - last_seen_ms, - "Pruned stale anchor (>3 days since last success + failed probe)" - ); - } -} - impl Node { /// Create or open a node in the given data directory (Desktop profile) pub async fn open(data_dir: impl AsRef) -> anyhow::Result { @@ -441,11 +272,6 @@ impl Node { /// Bootstrap: connect to anchors, pull initial data, NAT probe, referrals. /// Can be called during open_with_bind (blocking startup) or deferred to background. - /// - /// v0.7.3: anchor probing is batched (3 in flight, 2s stagger between batches, - /// 10s per-anchor timeout, first success unblocks downstream, remaining probes - /// continue in background and naturally fill peer connections). Failed probes - /// to anchors >3 days stale auto-prune from `known_anchors`. pub async fn run_bootstrap(&self, data_dir: &Path) -> anyhow::Result<()> { let storage = &self.storage; let network = &self.network; @@ -653,28 +479,57 @@ impl Node { let (discovered, bootstrap_known): (Vec<_>, Vec<_>) = known.into_iter() .partition(|(nid, _)| !bootstrap_anchor_ids.contains(nid)); - // Phase 1: probe discovered (non-bootstrap) anchors in batches. - // First success returns immediately; remaining probes continue in - // background. Failed probes to anchors >3 days stale auto-prune. - let mut connected_anchor = probe_anchors_batched( - discovered.clone(), - network.clone(), - Arc::clone(storage), - node_id, - "discovered", - ).await; + // Phase 1: Try discovered (non-bootstrap) anchors first + let mut connected_anchor = None; + for (anchor_nid, anchor_addrs) in &discovered { + if *anchor_nid == node_id || network.is_peer_connected_or_session(anchor_nid).await { + continue; + } + let endpoint_id = match iroh::EndpointId::from_bytes(anchor_nid) { + Ok(eid) => eid, + Err(_) => continue, + }; + let mut addr = iroh::EndpointAddr::from(endpoint_id); + for sa in anchor_addrs { + addr = addr.with_ip_addr(*sa); + } + info!(peer = hex::encode(anchor_nid), "Trying discovered anchor"); + match tokio::time::timeout(std::time::Duration::from_secs(10), network.connect_to_anchor(*anchor_nid, addr)).await { + Ok(Ok(())) => { + info!(peer = hex::encode(anchor_nid), "Connected to discovered anchor"); + connected_anchor = Some(*anchor_nid); + break; + } + Ok(Err(e)) => debug!(error = %e, peer = hex::encode(anchor_nid), "Discovered anchor: connect failed"), + Err(_) => debug!(peer = hex::encode(anchor_nid), "Discovered anchor: connect timed out"), + } + } - // Phase 2: bootstrap anchors as fallback — only fires if every - // Phase 1 entry failed. Preserves the load-distribution intent - // (don't smash the central anchor when discovered anchors work). + // Phase 2: Fall back to bootstrap anchors only if no discovered anchor worked if connected_anchor.is_none() { - connected_anchor = probe_anchors_batched( - bootstrap_known.clone(), - network.clone(), - Arc::clone(storage), - node_id, - "bootstrap", - ).await; + for (anchor_nid, anchor_addrs) in &bootstrap_known { + if *anchor_nid == node_id || network.is_peer_connected_or_session(anchor_nid).await { + continue; + } + let endpoint_id = match iroh::EndpointId::from_bytes(anchor_nid) { + Ok(eid) => eid, + Err(_) => continue, + }; + let mut addr = iroh::EndpointAddr::from(endpoint_id); + for sa in anchor_addrs { + addr = addr.with_ip_addr(*sa); + } + info!(peer = hex::encode(anchor_nid), "Trying bootstrap anchor (fallback)"); + match tokio::time::timeout(std::time::Duration::from_secs(10), network.connect_to_anchor(*anchor_nid, addr)).await { + Ok(Ok(())) => { + info!(peer = hex::encode(anchor_nid), "Connected to bootstrap anchor"); + connected_anchor = Some(*anchor_nid); + break; + } + Ok(Err(e)) => debug!(error = %e, peer = hex::encode(anchor_nid), "Bootstrap anchor: connect failed"), + Err(_) => debug!(peer = hex::encode(anchor_nid), "Bootstrap anchor: connect timed out"), + } + } } // Phase 3: NAT probe + referrals from whichever anchor we connected to @@ -1938,20 +1793,6 @@ impl Node { } } } - // Keep posting_identities.display_name in sync with the - // profile post so the Personas list and any UI reading - // PostingIdentity sees the current name (not the original - // empty/auto-gen one). The upsert preserves the persona's - // secret_seed / created_at; only display_name changes. - if let Ok(Some(existing)) = storage.get_posting_identity(&posting_id) { - let updated = crate::types::PostingIdentity { - node_id: existing.node_id, - secret_seed: existing.secret_seed, - display_name: display_name.clone(), - created_at: existing.created_at, - }; - let _ = storage.upsert_posting_identity(&updated); - } } // Propagate via neighbor-manifest header diffs like any other post. @@ -3587,12 +3428,7 @@ impl Node { pub async fn get_redundancy_summary(&self) -> anyhow::Result<(usize, usize, usize, usize)> { let storage = self.storage.get().await; - // Posts are authored by posting identities (personas), not the - // network NodeId. Use every persona on this device so the - // summary counts all of my posts across personas. - let author_ids: Vec = storage.list_posting_identities()? - .into_iter().map(|p| p.node_id).collect(); - storage.get_redundancy_summary(&author_ids, 3_600_000) + storage.get_redundancy_summary(&self.node_id, 3_600_000) } // ---- Networking ---- @@ -3837,14 +3673,8 @@ impl Node { } } - // Step 7: Session relay fallback — only if BOTH the introducer - // signaled relay availability AND this node has opted in to - // using session relay (`relay.session_relay_enabled`). Default - // is opt-out: hole-punch failure does NOT silently fall back - // to byte-relaying through a third party. - if !self.network.conn_handle().is_session_relay_enabled().await { - debug!(target = hex::encode(peer_id), "Session relay opt-out — skipping relay fallback"); - } else if let (Some(intro_id), Some(relay_peer)) = (last_intro_id, last_relay_peer) { + // Step 7: Session relay fallback — if intro was accepted but hole punch failed + if let (Some(intro_id), Some(relay_peer)) = (last_intro_id, last_relay_peer) { if last_relay_available { info!( target = hex::encode(peer_id), @@ -4728,16 +4558,44 @@ impl Node { }) } - /// No-op since v0.7.2: the `portmapper::Client` held inside the active - /// `PortMapping` auto-renews internally in its own background service. - /// Retained for API compatibility with callers in CLI and Tauri until - /// they're cleaned up. Returns `None`. - /// - /// TODO(v0.7.x): wire a watcher on `mapping.watch_external()` to clear - /// anchor mode if the external address stays `None` for more than ~5min - /// (parity with the old "3 renewal failures" behavior). + /// Start UPnP lease renewal cycle. Renews every lease_secs/2. + /// On 3 consecutive failures: clears is_anchor and logs a warning. pub fn start_upnp_renewal_cycle(&self) -> Option> { - None + let mapping = self.network.upnp_mapping()?; + let local_port = mapping.local_port; + let external_port = mapping.external_addr.port(); + let interval_secs = (mapping.lease_secs / 2) as u64; + let network = Arc::clone(&self.network); + let alog = Arc::clone(&self.activity_log); + + Some(tokio::spawn(async move { + let mut interval = + tokio::time::interval(std::time::Duration::from_secs(interval_secs)); + let mut consecutive_failures: u32 = 0; + loop { + interval.tick().await; + if crate::upnp::renew_upnp_mapping(local_port, external_port).await { + consecutive_failures = 0; + debug!("UPnP: lease renewed (port {})", external_port); + } else { + consecutive_failures += 1; + warn!("UPnP: renewal failed ({}/3)", consecutive_failures); + if consecutive_failures >= 3 { + network.clear_anchor(); + if let Ok(mut log) = alog.try_lock() { + log.log( + ActivityLevel::Warn, + ActivityCategory::Connection, + "UPnP lease lost after 3 renewal failures, auto-anchor disabled".into(), + None, + ); + } + warn!("UPnP: 3 consecutive renewal failures, auto-anchor disabled"); + return; // stop the cycle + } + } + } + })) } // --- HTTP Post Delivery --- @@ -4792,21 +4650,34 @@ impl Node { }) } - /// No-op since v0.7.2 — the TCP `portmapper::Client` auto-renews internally. + /// Start UPnP TCP lease renewal cycle alongside the UDP renewal. pub fn start_upnp_tcp_renewal_cycle(&self) -> Option> { - None + if !self.network.has_upnp_tcp() { + return None; + } + let mapping = self.network.upnp_mapping()?; + let local_port = mapping.local_port; + let external_port = mapping.external_addr.port(); + let interval_secs = (mapping.lease_secs / 2) as u64; + + Some(tokio::spawn(async move { + let mut interval = + tokio::time::interval(std::time::Duration::from_secs(interval_secs)); + loop { + interval.tick().await; + if !crate::upnp::renew_upnp_tcp_mapping(local_port, external_port).await { + warn!("UPnP: TCP lease renewal failed"); + // Don't stop the cycle — TCP is best-effort + } + } + })) } /// Generate a share link URL for a public post. /// Returns None if post is not public or not found. - /// - /// URL Phase 1 (v0.7.2): the link contains only the post ID — no author - /// hex, no node addresses. The receiving anchor (itsgoin.net) does the - /// holder lookup itself and serves via redirect or QUIC-proxy fallback. - /// Older URLs with `/{post_hex}/{author_hex}` continue to work — the - /// web handler parses the author hex as optional. pub async fn generate_share_link(&self, post_id: &PostId) -> anyhow::Result> { - let (_post, visibility) = { + // Look up the post to verify it's public and get the author + let (post, visibility) = { let store = self.storage.get().await; match store.get_post_with_visibility(post_id)? { Some(pv) => pv, @@ -4819,7 +4690,8 @@ impl Node { } let post_hex = hex::encode(post_id); - Ok(Some(format!("https://itsgoin.net/p/{}", post_hex))) + let author_hex = hex::encode(post.author); + Ok(Some(format!("https://itsgoin.net/p/{}/{}", post_hex, author_hex))) } // --- Engagement API --- diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 9cd95cb..cee97e8 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -2248,33 +2248,6 @@ impl Storage { Ok(result) } - /// Get the last successful contact time (ms since epoch) for a known anchor. - /// Returns None if the anchor isn't in the table. - pub fn get_known_anchor_last_seen(&self, node_id: &NodeId) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT last_seen_ms FROM known_anchors WHERE node_id = ?1", - )?; - let mut rows = stmt.query(params![node_id.as_slice()])?; - if let Some(row) = rows.next()? { - let ms: i64 = row.get(0)?; - Ok(Some(ms as u64)) - } else { - Ok(None) - } - } - - /// Remove a known anchor entry. Used by the bootstrap connect path - /// when a stale anchor (>3 days since last successful contact) fails - /// to connect — self-healing pruning so future startups don't re-try - /// long-dead entries. - pub fn delete_known_anchor(&self, node_id: &NodeId) -> anyhow::Result<()> { - self.conn.execute( - "DELETE FROM known_anchors WHERE node_id = ?1", - params![node_id.as_slice()], - )?; - Ok(()) - } - /// Prune known anchors to keep at most `max` entries (by highest success_count). pub fn prune_known_anchors(&self, max: usize) -> anyhow::Result { let count: i64 = self.conn.query_row( @@ -3156,30 +3129,17 @@ impl Storage { /// Get a summary of redundancy across all our authored posts. /// Returns (total, zero_replicas, one_replica, two_plus_replicas). - /// Redundancy summary across every post authored by ANY of the - /// device's posting identities (personas). Pre-v0.6.0 this matched - /// on the device's network NodeId, but the network/posting-ID - /// split moved authorship to the posting identity — so the old - /// query returned 0 of my posts and the UI showed 0 redundancy - /// for new posts. pub fn get_redundancy_summary( &self, - author_ids: &[NodeId], + our_node_id: &NodeId, staleness_ms: u64, ) -> anyhow::Result<(usize, usize, usize, usize)> { - if author_ids.is_empty() { - return Ok((0, 0, 0, 0)); - } let cutoff = now_ms() - staleness_ms as i64; - let placeholders: Vec<&str> = (0..author_ids.len()).map(|_| "?").collect(); - let sql = format!( - "SELECT p.id FROM posts p WHERE p.author IN ({})", - placeholders.join(","), - ); - let mut stmt = self.conn.prepare(&sql)?; - let params_iter = rusqlite::params_from_iter(author_ids.iter().map(|n| n.to_vec())); + let mut stmt = self.conn.prepare( + "SELECT p.id FROM posts p WHERE p.author = ?1", + )?; let post_ids: Vec = { - let mut rows = stmt.query(params_iter)?; + let mut rows = stmt.query(params![our_node_id.as_slice()])?; let mut ids = Vec::new(); while let Some(row) = rows.next()? { ids.push(blob_to_postid(row.get(0)?)?); diff --git a/crates/core/src/upnp.rs b/crates/core/src/upnp.rs index 643a985..df4f445 100644 --- a/crates/core/src/upnp.rs +++ b/crates/core/src/upnp.rs @@ -1,181 +1,243 @@ -//! NAT port mapping via UPnP-IGD, NAT-PMP, and PCP. -//! -//! Wraps the `portmapper` crate (also used internally by iroh) which runs -//! all three protocols in parallel and auto-renews the lease in a background -//! task. This module is named `upnp` for historical reasons — by v0.7.2 it -//! covers more than UPnP. -//! -//! ## Protocols -//! - **UPnP-IGD** — long-standing consumer-router default. Discovery uses -//! SSDP multicast on 239.255.255.250:1900. Behavior on Hold harbor routers -//! varies; many ship with UPnP disabled by default. -//! - **NAT-PMP** (RFC 6886) — Apple lineage; widespread on routers that -//! ever shipped Bonjour. Unicast to the gateway on UDP/5351. -//! - **PCP** (RFC 6887) — modern IETF-track successor to NAT-PMP. Unicast -//! on UDP/5351. Supports both IPv4 NAT mapping and IPv6 firewall pinholes -//! (the latter via `AddPinhole`-shaped requests). Increasingly common in -//! modern routers. -//! -//! All three are attempted in parallel by portmapper; the first one to -//! respond wins. PCP responses arrive sub-second when present; SSDP wakes -//! up in 1–3s. -//! -//! ## Per-platform contract -//! | Platform | UPnP-IGD | NAT-PMP | PCP | TCP for HTTP | -//! |---|---|---|---|---| -//! | Linux/macOS/Windows | ✓ | ✓ | ✓ | ✓ | -//! | Android (WiFi/Ethernet) | ✓ (with MulticastLock) | ✓ | ✓ | ✓ | -//! | Android (cellular) | ✗ (skipped early) | ✗ | ✗ | ✗ | -//! | iOS | ✗ (without `com.apple.developer.networking.multicast` entitlement) | ✓ | ✓ | ✓ | -//! -//! ## Android specifics -//! `try_udp` / `try_tcp` first check `crate::android_wifi::is_on_wifi()`. -//! If not on WiFi/Ethernet (cellular), returns `None` immediately — cellular -//! networks almost never expose any of these protocols, and a discovery -//! timeout would waste ~3s every startup. -//! -//! When on WiFi, a `WifiManager.MulticastLock` is acquired and held for the -//! lifetime of the resulting `PortMapping`. Without the lock Android filters -//! the SSDP multicast responses; PCP/NAT-PMP work without it but UPnP-IGD -//! would never complete. The lock is released on Drop. -//! -//! ## iOS specifics -//! Until Apple grants the multicast entitlement, UPnP-IGD will silently fail -//! (no SSDP responses delivered to the socket). The unicast protocols PCP -//! and NAT-PMP succeed without any entitlement and cover most modern home -//! routers, so iOS gets reasonable coverage today. -//! -//! ## Renewal model -//! Auto-renewal happens inside `portmapper::Client`'s internal task — there -//! is **no** external renewal cycle to schedule. The `PortMapping` value -//! owns the client; while it's held, the mapping stays alive. Dropping it -//! aborts the renewal task (via `AbortOnDropHandle`) and stops keeping the -//! mapping alive at the gateway. `release(&self)` triggers an explicit -//! deactivation message before drop for cleaner shutdown. -//! -//! ## Anchor reachability watcher (bidirectional) -//! `Network::start` spawns a task that observes the UDP mapping's -//! `watch_external()` channel and adjusts anchor mode at runtime: -//! - **Mapping lost** for >5 min → clear `is_anchor`. The node stops -//! advertising itself as an anchor at the now-stale external address. -//! - **Mapping restored** (None → Some) → re-evaluate auto-anchor. On -//! non-mobile devices the anchor flag is set back on so the node -//! re-joins the anchor set without a restart. -//! -//! Network roams (e.g., leaving a UPnP-capable WiFi and joining a new one) -//! self-heal. Mobile devices never auto-anchor regardless — cellular IPs -//! look public but sit behind CGNAT. +//! Best-effort UPnP port mapping for NAT traversal. +//! Skipped entirely on mobile platforms where UPnP is unsupported. use std::net::SocketAddr; -use std::num::NonZeroU16; -use std::time::Duration; -use tracing::{debug, info, warn}; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +use tracing::{info, debug}; -/// An active port mapping. While this value is held, the underlying -/// `portmapper::Client` keeps the lease alive in a background task. -/// Dropping it releases the mapping. -pub struct PortMapping { +/// Result of a successful UPnP port mapping. +pub struct UpnpMapping { pub external_addr: SocketAddr, + pub lease_secs: u32, pub local_port: u16, - #[allow(dead_code)] // service kept alive by holding the client - client: portmapper::Client, - #[cfg(target_os = "android")] - #[allow(dead_code)] // lock kept alive for the lifetime of the mapping - mcast_lock: Option, } -impl PortMapping { - /// Best-effort UDP port mapping for the given local QUIC port. - /// On Android, requires an active WiFi/Ethernet connection — returns - /// `None` immediately on cellular. Waits up to 3 seconds for any of the - /// three protocols (PCP / NAT-PMP / UPnP-IGD) to return a mapping. - pub async fn try_udp(local_port: u16) -> Option { - Self::try_for_protocol(local_port, portmapper::Protocol::Udp).await - } +/// Best-effort UPnP port mapping. +/// 3s gateway discovery timeout, 1800s (30 min) lease, UDP protocol. +/// Returns None on any failure (no router, unsupported, timeout, port conflict). +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub async fn try_upnp_mapping(local_port: u16) -> Option { + use igd_next::SearchOptions; - /// Best-effort TCP port mapping for HTTP serving on the given local port. - /// Same platform gating as UDP. Used to make this node HTTP-reachable - /// for browser-shaped fetches of public posts. - pub async fn try_tcp(local_port: u16) -> Option { - Self::try_for_protocol(local_port, portmapper::Protocol::Tcp).await - } + let search_opts = SearchOptions { + timeout: Some(std::time::Duration::from_secs(3)), + ..Default::default() + }; - async fn try_for_protocol(local_port: u16, protocol: portmapper::Protocol) -> Option { - let port = NonZeroU16::new(local_port)?; - - #[cfg(target_os = "android")] - { - if !crate::android_wifi::is_on_wifi() { - debug!("Port mapping: skipping (not on WiFi/Ethernet)"); - return None; - } + let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { + Ok(gw) => gw, + Err(e) => { + debug!("UPnP gateway discovery failed (expected behind non-UPnP router): {}", e); + return None; } + }; - #[cfg(target_os = "android")] - let mcast_lock = crate::android_wifi::MulticastLockGuard::acquire("itsgoin-ssdp"); + let external_ip = match gateway.get_external_ip().await { + Ok(ip) => ip, + Err(e) => { + debug!("UPnP: could not get external IP: {}", e); + return None; + } + }; - let config = portmapper::Config { - enable_upnp: true, - enable_pcp: true, - enable_nat_pmp: true, - protocol, - }; - let client = portmapper::Client::new(config); - client.update_local_port(port); + // Local address for the mapping — bind to all interfaces + let local_addr = SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), local_port); + let lease_secs: u32 = 1800; // 30 minutes - // Wait up to 3 seconds for any protocol to produce a mapping. - let mut watch = client.watch_external_address(); - let external = match tokio::time::timeout(Duration::from_secs(3), async { - loop { - if let Some(addr) = *watch.borrow_and_update() { - return Some(addr); - } - if watch.changed().await.is_err() { + // Try mapping the same external port first + let result = gateway.add_port( + igd_next::PortMappingProtocol::UDP, + local_port, + local_addr, + lease_secs, + "itsgoin", + ).await; + + let external_port = match result { + Ok(()) => local_port, + Err(_) => { + // Port taken — try any available port + match gateway.add_any_port( + igd_next::PortMappingProtocol::UDP, + local_addr, + lease_secs, + "itsgoin", + ).await { + Ok(port) => port, + Err(e) => { + debug!("UPnP: port mapping failed: {}", e); return None; } } - }) - .await - { - Ok(Some(addr)) => addr, - _ => { - debug!( - protocol = ?protocol, - local_port, - "Port mapping: no protocol responded within 3s (no UPnP/PCP/NAT-PMP gateway?)" - ); - return None; - } - }; + } + }; - info!( - external = %external, - local_port, - protocol = ?protocol, - "Port mapping established" - ); + let external_addr = SocketAddr::new(external_ip, external_port); + info!("UPnP: mapped {}:{} → :{}", external_ip, external_port, local_port); - Some(PortMapping { - external_addr: SocketAddr::V4(external), - local_port, - client, - #[cfg(target_os = "android")] - mcast_lock, - }) - } + Some(UpnpMapping { + external_addr, + lease_secs, + local_port, + }) +} - /// Watch for changes to the external address. Useful when the underlying - /// network changes (e.g., mobile WiFi roam) — the mapping may move to a - /// new public IP/port. - pub fn watch_external(&self) -> tokio::sync::watch::Receiver> { - self.client.watch_external_address() - } +#[cfg(any(target_os = "android", target_os = "ios"))] +pub async fn try_upnp_mapping(_local_port: u16) -> Option { + None +} - /// Explicitly request the portmapper service to release the gateway - /// mapping. The actual cleanup completes when the `PortMapping` is - /// dropped (the internal service handle is abort-on-drop). Allows - /// callers to signal "release now" before the value goes out of scope. - pub fn release(&self) { - self.client.deactivate(); +/// Renew an existing UPnP lease. Returns true on success. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub async fn renew_upnp_mapping(local_port: u16, external_port: u16) -> bool { + use igd_next::SearchOptions; + + let search_opts = SearchOptions { + timeout: Some(std::time::Duration::from_secs(3)), + ..Default::default() + }; + + let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { + Ok(gw) => gw, + Err(_) => return false, + }; + + let local_addr = SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), local_port); + gateway.add_port( + igd_next::PortMappingProtocol::UDP, + external_port, + local_addr, + 1800, + "itsgoin", + ).await.is_ok() +} + +#[cfg(any(target_os = "android", target_os = "ios"))] +pub async fn renew_upnp_mapping(_local_port: u16, _external_port: u16) -> bool { + false +} + +/// Remove UPnP mapping on shutdown. Best-effort, errors are silently ignored. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub async fn remove_upnp_mapping(external_port: u16) { + use igd_next::SearchOptions; + + let search_opts = SearchOptions { + timeout: Some(std::time::Duration::from_secs(3)), + ..Default::default() + }; + + if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await { + let _ = gateway.remove_port(igd_next::PortMappingProtocol::UDP, external_port).await; + info!("UPnP: removed port mapping for external port {}", external_port); } } + +#[cfg(any(target_os = "android", target_os = "ios"))] +pub async fn remove_upnp_mapping(_external_port: u16) {} + +// --- TCP port mapping (for HTTP post delivery) --- + +/// Best-effort UPnP TCP port mapping on the same port as QUIC UDP. +/// Returns true on success. Reuses the already-discovered gateway. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub async fn try_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool { + use igd_next::SearchOptions; + + let search_opts = SearchOptions { + timeout: Some(std::time::Duration::from_secs(3)), + ..Default::default() + }; + + let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { + Ok(gw) => gw, + Err(_) => return false, + }; + + let local_addr = SocketAddr::new( + std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), + local_port, + ); + + match gateway + .add_port( + igd_next::PortMappingProtocol::TCP, + external_port, + local_addr, + 1800, + "itsgoin-http", + ) + .await + { + Ok(()) => { + info!("UPnP: TCP port {} mapped for HTTP serving", external_port); + true + } + Err(e) => { + debug!("UPnP: TCP port mapping failed (non-fatal): {}", e); + false + } + } +} + +#[cfg(any(target_os = "android", target_os = "ios"))] +pub async fn try_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool { + false +} + +/// Renew an existing UPnP TCP lease. Returns true on success. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub async fn renew_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool { + use igd_next::SearchOptions; + + let search_opts = SearchOptions { + timeout: Some(std::time::Duration::from_secs(3)), + ..Default::default() + }; + + let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { + Ok(gw) => gw, + Err(_) => return false, + }; + + let local_addr = SocketAddr::new( + std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), + local_port, + ); + gateway + .add_port( + igd_next::PortMappingProtocol::TCP, + external_port, + local_addr, + 1800, + "itsgoin-http", + ) + .await + .is_ok() +} + +#[cfg(any(target_os = "android", target_os = "ios"))] +pub async fn renew_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool { + false +} + +/// Remove UPnP TCP mapping on shutdown. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub async fn remove_upnp_tcp_mapping(external_port: u16) { + use igd_next::SearchOptions; + + let search_opts = SearchOptions { + timeout: Some(std::time::Duration::from_secs(3)), + ..Default::default() + }; + + if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await { + let _ = gateway + .remove_port(igd_next::PortMappingProtocol::TCP, external_port) + .await; + info!("UPnP: removed TCP port mapping for port {}", external_port); + } +} + +#[cfg(any(target_os = "android", target_os = "ios"))] +pub async fn remove_upnp_tcp_mapping(_external_port: u16) {} diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index 7e9e5a2..d7bc90c 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-desktop" -version = "0.7.3" +version = "0.7.0" edition = "2021" [lib] diff --git a/crates/tauri-app/gen/android/app/src/main/java/com/itsgoin/app/NodeService.kt b/crates/tauri-app/gen/android/app/src/main/java/com/itsgoin/app/NodeService.kt index 0112936..4a5aaf0 100644 --- a/crates/tauri-app/gen/android/app/src/main/java/com/itsgoin/app/NodeService.kt +++ b/crates/tauri-app/gen/android/app/src/main/java/com/itsgoin/app/NodeService.kt @@ -5,7 +5,6 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service -import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build @@ -17,17 +16,6 @@ class NodeService : Service() { companion object { const val CHANNEL_ID = "itsgoin_node" const val NOTIFICATION_ID = 1 - - // Called via JNI from Rust when the user taps the in-app close - // button. Foreground services survive Activity exit by design - // (keeps connections alive when backgrounded). When the user - // explicitly wants to stop networking, we need to stop the - // service in addition to ending the Activity. - @JvmStatic - fun stopFromNative(context: Context) { - val intent = Intent(context, NodeService::class.java) - context.stopService(intent) - } } private var wakeLock: PowerManager.WakeLock? = null diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 5d9dfdd..6246241 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1142,18 +1142,6 @@ async fn list_vouches_given(state: State<'_, AppNode>) -> Result) -> Result, String> { let node = get_node(&state).await; @@ -1710,25 +1698,6 @@ async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Resul storage.set_setting("ui_update_channel", &channel).map_err(|e| e.to_string()) } -#[tauri::command] -async fn get_session_relay_enabled(state: State<'_, AppNode>) -> Result { - let node = get_node(&state).await; - Ok(node.network.conn_handle().is_session_relay_enabled().await) -} - -#[tauri::command] -async fn set_session_relay_enabled(state: State<'_, AppNode>, enabled: bool) -> Result<(), String> { - let node = get_node(&state).await; - { - let storage = node.storage.get().await; - storage - .set_setting("relay.session_relay_enabled", if enabled { "true" } else { "false" }) - .map_err(|e| e.to_string())?; - } - node.network.conn_handle().set_session_relay_enabled(enabled).await; - Ok(()) -} - /// Open a URL in the user's default system browser. /// Desktop: spawns the platform opener (xdg-open / open / cmd start). /// Only https:// URLs are accepted to avoid being a generic command exec. @@ -3411,9 +3380,6 @@ pub fn run() { import_as_new_identity, import_as_personas_cmd, import_merge_with_key, - exit_app, - get_session_relay_enabled, - set_session_relay_enabled, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index 8728350..f3aaa93 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "itsgoin", - "version": "0.7.3", + "version": "0.7.0", "identifier": "com.itsgoin.app", "build": { "frontendDist": "../../frontend", diff --git a/deploy.sh b/deploy.sh index f89cdca..c780395 100755 --- a/deploy.sh +++ b/deploy.sh @@ -21,20 +21,23 @@ KS_ALIAS="itsgoin" VERSION=$(grep '"version"' crates/tauri-app/tauri.conf.json | head -1 | sed 's/.*"\([0-9.]*\)".*/\1/') echo "=== Deploying v${VERSION} ===" -# Builds run SERIALLY — parallel cargo invocations write to the same -# target/ directory, which causes intermittent failures (linuxdeploy -# blowing up mid-AppImage was the v0.7.0 release symptom). The extra -# wall time vs. the parallel version is small because cargo's -# incremental cache deduplicates the shared core crate compilation. - -echo "=== Building AppImage (includes GStreamer patch) ===" -./build-appimage.sh - -echo "=== Building APK ===" -cargo tauri android build --apk - +# Build CLI echo "=== Building CLI ===" -cargo build -p itsgoin-cli --release +cargo build -p itsgoin-cli --release & +CLI_PID=$! + +# Build APK +echo "=== Building APK ===" +cargo tauri android build --apk & +APK_PID=$! + +# Build AppImage (includes GStreamer patch) +echo "=== Building AppImage ===" +./build-appimage.sh +wait $CLI_PID +echo "=== CLI build complete ===" +wait $APK_PID +echo "=== APK build complete ===" # Sign APK echo "=== Signing APK ===" diff --git a/frontend/app.js b/frontend/app.js index 7db32cb..1840b8e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -449,12 +449,6 @@ $('#popover-overlay').addEventListener('click', (e) => { if (e.target === $('#popover-overlay')) closePopover(); }); -$('#close-app-btn').addEventListener('click', async () => { - if (confirm('Close ItsGoin?\n\nStops all network connections to save battery. Reopen the app any time to resume.')) { - try { await invoke('exit_app'); } catch (_) {} - } -}); - function relativeTime(timestampMs) { const now = Date.now(); const diff = now - timestampMs; @@ -1693,13 +1687,12 @@ async function openBioModal(nodeId, preloadedName) { ${bio ? `

${escapeHtml(bio)}

` : '

No bio.

'}
- ${(following && isVouched) - ? `` - : (following - ? ` - ` - : ` - `)} + ${following + ? `` + : ``} + ${isVouched + ? `` + : ``} ${isIgnored ? `` @@ -1756,34 +1749,16 @@ async function openBioModal(nodeId, preloadedName) { } catch (e) { toast('Error: ' + e); } finally { vouch.disabled = false; } }; - // Friend = follow + vouch in one click. Default action per v0.7.x UX. - const friend = document.getElementById('bio-friend'); - if (friend) friend.onclick = async () => { - friend.disabled = true; - try { - await invoke('follow_node', { nodeIdHex: nodeId }); - await invoke('vouch_for_peer', { nodeIdHex: nodeId }); - toast(`Friended ${name}`); - close(); - loadFollows(); - loadFeed(true); - } catch (e) { toast('Error: ' + e); } - finally { friend.disabled = false; } - }; - // Unfriend = revoke vouch + unfollow. Rotation cost is real; confirm. - const unfriend = document.getElementById('bio-unfriend'); - if (unfriend) unfriend.onclick = async () => { - if (!confirm(`Unfriend ${name}? This revokes your vouch (rotates your vouch key — they keep access to existing posts but not future ones) AND unfollows them.`)) return; - unfriend.disabled = true; + const revokeVouch = document.getElementById('bio-revoke-vouch'); + if (revokeVouch) revokeVouch.onclick = async () => { + if (!confirm(`Revoke vouch for ${name}? This rotates your vouch key — they keep access to existing posts but not future ones.`)) return; + revokeVouch.disabled = true; try { await invoke('revoke_vouch_for_peer', { nodeIdHex: nodeId }); - await invoke('unfollow_node', { nodeIdHex: nodeId }); - toast(`Unfriended ${name}`); + toast('Revoked and rotated'); close(); - loadFollows(); - loadFeed(true); } catch (e) { toast('Error: ' + e); } - finally { unfriend.disabled = false; } + finally { revokeVouch.disabled = false; } }; } catch (e) { bodyEl.innerHTML = `

Error: ${e}

`; @@ -2668,7 +2643,7 @@ async function doPost() { try { const vis = visibilitySelect.value; const params = { content: content || '' }; - if (vis !== 'public' && vis !== 'fof_closed') { + if (vis !== 'public') { params.visibility = vis; } if (vis === 'circle') { @@ -2692,22 +2667,14 @@ async function doPost() { const reactPerm = document.getElementById('react-perm-select').value; let result; - if (vis === 'fof_closed') { - // Visibility = Extended Friends (FoF). Body + comments are - // encrypted under the FoF gating CEK. Mode 1. + if (commentPerm === 'friends_of_friends') { + // FoF Layer 2: body is still public (Mode 2) but the post + // carries a fof_gating block built from the author's + // keyring. Routed through a dedicated command because the + // gating block is signed at publish time (can't be added + // via SetPolicy after the fact). if (selectedFiles.length > 0 || params.postingIdHex) { - toast('FoF (Extended Friends) posts with attachments or non-default persona not yet supported.'); - postBtn.disabled = false; - return; - } - const created = await invoke('create_post_fof_closed', { - content: params.content, - }); - result = { id: created.postId }; - } else if (vis === 'public' && commentPerm === 'friends_of_friends') { - // Public body, FoF-gated comments. Mode 2. - if (selectedFiles.length > 0 || params.postingIdHex) { - toast('FoF-comment posts with attachments or non-default persona not yet supported.'); + toast('FoF posts with attachments or non-default persona not yet supported.'); postBtn.disabled = false; return; } @@ -2715,6 +2682,20 @@ async function doPost() { content: params.content, }); result = { id: created.postId }; + } else if (commentPerm === 'fof_closed') { + // FoF Layer 3 / Mode 1: body itself encrypted under the + // gating CEK. Non-FoF observers see only ciphertext; + // FoF readers unlock + decrypt on render via + // read_fof_closed_body. + if (selectedFiles.length > 0 || params.postingIdHex) { + toast('FoFClosed posts with attachments or non-default persona not yet supported.'); + postBtn.disabled = false; + return; + } + const created = await invoke('create_post_fof_closed', { + content: params.content, + }); + result = { id: created.postId }; } else if (selectedFiles.length > 0) { // Convert ArrayBuffers to base64 strings const files = selectedFiles.map(f => { @@ -2747,7 +2728,7 @@ async function doPost() { selectedFiles = []; renderAttachmentPreview(); updateCharCount(); - visibilitySelect.value = 'fof_closed'; + visibilitySelect.value = 'public'; updateVisibilityUI(); toast('Posted!'); loadFeed(true); @@ -3001,11 +2982,6 @@ async function loadCircleProfiles() { function updateVisibilityUI() { const vis = visibilitySelect.value; circleSelect.classList.toggle('hidden', vis !== 'circle'); - // Hide the comment-permission picker for FoF (Extended Friends) — the - // visibility already implies comments-restricted-to-FoF. Show it - // again when audience is public / friends / circle. - const commentPerm = document.getElementById('comment-perm-select'); - if (commentPerm) commentPerm.classList.toggle('hidden', vis === 'fof_closed'); } async function loadCircleOptions() { @@ -3023,9 +2999,6 @@ visibilitySelect.addEventListener('change', () => { updateVisibilityUI(); if (visibilitySelect.value === 'circle') loadCircleOptions(); }); -// Run once on load so the comment-perm picker is hidden for the -// default FoF visibility (matches the dropdown's `selected` option). -updateVisibilityUI(); // --- Circles management --- async function loadCircles() { @@ -3549,11 +3522,11 @@ if (exportKeyBtn) exportKeyBtn.addEventListener('click', async () => { const key = await invoke('export_identity'); try { await navigator.clipboard.writeText(key); - toast('Device address key copied to clipboard. KEEP IT SECRET!'); + toast('Identity key copied to clipboard. KEEP IT SECRET!'); } catch (clipErr) { // Clipboard API may fail in some webview contexts — show the key instead console.error('Clipboard write failed:', clipErr); - prompt('Copy your device address key (KEEP IT SECRET!):', key); + prompt('Copy your identity key (KEEP IT SECRET!):', key); } } catch (e) { console.error('export_identity failed:', e); @@ -3592,16 +3565,14 @@ document.querySelectorAll('.text-size-opt').forEach(btn => { }); }); -// --- Device address management (formerly "Identity") --- -// The underlying Tauri commands keep their `identity` names for now — -// only the user-facing labels rename. Backend rename can follow. +// --- Identity management --- async function loadIdentities() { const list = $('#identities-list'); if (!list) return; try { const identities = await invoke('list_identities'); if (identities.length === 0) { - list.innerHTML = '

No device addresses

'; + list.innerHTML = '

No identities

'; return; } list.innerHTML = identities.map(id => { @@ -3624,7 +3595,7 @@ async function loadIdentities() { btn.textContent = 'Switching...'; try { await invoke('switch_identity', { nodeIdHex: btn.dataset.id }); - toast('Device address switched — reloading...'); + toast('Identity switched — reloading...'); setTimeout(() => location.reload(), 1000); } catch (e) { toast('Error: ' + e); btn.disabled = false; btn.textContent = 'Switch'; } }); @@ -3633,10 +3604,10 @@ async function loadIdentities() { // Wire delete buttons list.querySelectorAll('.delete-id-btn').forEach(btn => { btn.addEventListener('click', async () => { - if (!confirm('Delete this device address? This cannot be undone.')) return; + if (!confirm('Delete this identity? This cannot be undone.')) return; try { await invoke('delete_identity', { nodeIdHex: btn.dataset.id }); - toast('Device address deleted'); + toast('Identity deleted'); loadIdentities(); } catch (e) { toast('Error: ' + e); } }); @@ -3652,9 +3623,8 @@ $('#create-identity-btn').addEventListener('click', () => { overlay.style.cursor = 'default'; overlay.innerHTML = `
-

New Device Address

-

A new QUIC network endpoint for this device. Your personas are unaffected.

- +

New Identity

+
@@ -3663,10 +3633,10 @@ $('#create-identity-btn').addEventListener('click', () => { document.body.appendChild(overlay); overlay.querySelector('#new-id-create').addEventListener('click', async () => { const name = overlay.querySelector('#new-id-name').value.trim(); - if (!name) { toast('Label is required'); return; } + if (!name) { toast('Name is required'); return; } try { const nodeId = await invoke('create_identity', { name }); - toast(`Device address created: ${nodeId.substring(0, 12)}`); + toast(`Identity created: ${nodeId.substring(0, 12)}`); overlay.remove(); loadIdentities(); } catch (e) { toast('Error: ' + e); } @@ -3681,10 +3651,10 @@ $('#import-identity-btn').addEventListener('click', () => { overlay.style.cursor = 'default'; overlay.innerHTML = `
-

Import Device Address Key

-

Paste a 64-character hex key from a previous device-address export. This is NOT how you move your personas — use Export/Import personas above for that.

- - +

Import Identity

+

Paste the 64-character hex key from an identity export.

+ +
@@ -3697,7 +3667,7 @@ $('#import-identity-btn').addEventListener('click', () => { if (keyHex.length !== 64) { toast('Key must be 64 hex characters'); return; } try { const nodeId = await invoke('import_identity_key', { keyHex, name }); - toast(`Device address imported: ${nodeId.substring(0, 12)}`); + toast(`Identity imported: ${nodeId.substring(0, 12)}`); overlay.remove(); loadIdentities(); } catch (e) { toast('Error: ' + e); } @@ -4071,14 +4041,14 @@ $('#export-btn').addEventListener('click', () => { overlay.className = 'image-lightbox'; overlay.style.cursor = 'default'; overlay.innerHTML = ` -
-

Export your personas

-

Save your personas + (optionally) your posts to a ZIP file so you can import them on another device.

+
+

Export Data

+

Choose what to include in the export ZIP.

- - - - + + + +
@@ -4148,8 +4118,8 @@ $('#import-btn').addEventListener('click', () => { overlay.style.cursor = 'default'; overlay.innerHTML = `
-

Import from another device

-

Select an ItsGoin export ZIP. Default action restores the exported personas onto this device so you can post as them.

+

Import Data

+

Select an ItsGoin export ZIP file.

@@ -4251,20 +4221,6 @@ $('#import-btn').addEventListener('click', () => { overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); }); -(async () => { - const toggle = $('#session-relay-toggle'); - if (!toggle) return; - try { toggle.checked = await invoke('get_session_relay_enabled'); } catch (_) {} - toggle.addEventListener('change', async () => { - try { - await invoke('set_session_relay_enabled', { enabled: toggle.checked }); - } catch (e) { - toggle.checked = !toggle.checked; - alert('Failed to update session relay setting: ' + e); - } - }); -})(); - $('#notifications-btn').addEventListener('click', async () => { // Load current settings const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on'; @@ -4429,7 +4385,7 @@ async function init() {

How would you like to get started?

- +
`; document.body.appendChild(chooser); diff --git a/frontend/index.html b/frontend/index.html index 7848b1a..e415fd8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -25,7 +25,6 @@
-