//! The app's Freenet surface — deliberately tiny. //! //! The webview gets STATUS ONLY: whether the experimental layer is enabled, //! and what the adapter sees (absent / occupied / incompatible / connected). //! The privileged node API stays entirely inside the Rust boundary //! (crates/freenet-adapter); no command here moves contract state, keys, or //! node operations across the bridge. Future Community services call the //! adapter crate directly from Rust. //! //! Off by default. `freenet_set_enabled(true)` is the explicit opt-in and //! persists to config.json; the kill switch is the same call with `false` //! and takes effect immediately (drops the connection, ends every retry). use serde::Serialize; use tauri::{Emitter, Manager}; use crate::config; use crate::state::AppState; #[derive(Serialize)] pub struct FreenetStatus { pub enabled: bool, pub ws_port: u16, pub status: freenet_adapter::AdapterStatus, /// Whether remote profile avatars may be fetched and decoded at all. pub load_remote_images: bool, } /// Wire the adapter at startup: apply config (port + opt-in) and forward /// adapter events to the webview as `freenet:status` — status frames only. pub fn init(app: &tauri::AppHandle) { let cfg = config::load(app); let state = app.state::(); let adapter = state.freenet.clone(); adapter.set_ws_port(cfg.freenet_ws_port.unwrap_or(freenet_adapter::DEFAULT_WS_PORT)); let handle = app.clone(); let mut events = adapter.subscribe(); tauri::async_runtime::spawn(async move { loop { match events.recv().await { Ok(freenet_adapter::AdapterEvent::Status(s)) => { let _ = handle.emit("freenet:status", &s); } // Contract updates carry ids/lengths only, and nothing in // the UI consumes them yet — swallow rather than emit noise. Ok(freenet_adapter::AdapterEvent::ContractUpdate { .. }) => {} Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } }); if cfg.freenet_enabled.unwrap_or(false) { adapter.enable(); // The peer the app set up follows the switch (see ensure_node). tauri::async_runtime::spawn(ensure_node(app.clone())); } } #[tauri::command] pub fn freenet_status(app: tauri::AppHandle, state: tauri::State<'_, AppState>) -> FreenetStatus { FreenetStatus { enabled: state.freenet.is_enabled(), ws_port: state.freenet.ws_port(), status: state.freenet.status(), load_remote_images: config::load(&app).freenet_load_remote_images.unwrap_or(true), } } /// The reader-side policy gate for avatars (profile.rs checks it BEFORE any /// fetch). Off degrades every remote avatar to the identicon. #[tauri::command] pub fn freenet_set_load_remote_images( app: tauri::AppHandle, state: tauri::State<'_, AppState>, on: bool, ) -> Result { let mut cfg = config::load(&app); cfg.freenet_load_remote_images = Some(on); config::save(&app, &cfg); Ok(freenet_status(app, state)) } /// The explicit opt-in / kill switch. Persisted, then applied immediately. #[tauri::command] pub fn freenet_set_enabled( app: tauri::AppHandle, state: tauri::State<'_, AppState>, enabled: bool, ) -> Result { let mut cfg = config::load(&app); cfg.freenet_enabled = Some(enabled); config::save(&app, &cfg); if enabled { state.freenet.enable(); tauri::async_runtime::spawn(ensure_node(app.clone())); } else { state.freenet.disable(); } Ok(freenet_status(app, state)) } /// One-shot detection — works while disabled, touches no adapter state. #[tauri::command] pub async fn freenet_detect( state: tauri::State<'_, AppState>, ) -> Result { Ok(state.freenet.detect().await) } // ---- the local peer ----------------------------------------------------------- // Starting the installed node from the app, the way the research did it by // hand (research/freenet-f0, -f2): the peer writes its config.toml on first // boot with telemetry ON (the alpha default, even in local mode), so a first // start runs it only until that file exists, stops it, pins // `telemetry-enabled = false`, and only then launches the real session. The // peer is a detached background process — it outlives the app on purpose // (the design's "desktop background peer") and stops with its own button. use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; #[derive(Debug, Serialize)] pub struct NodeInfo { /// Where the peer binary was found, if anywhere. pub binary: Option, pub version: Option, pub config_dir: String, pub data_dir: String, pub config_exists: bool, pub telemetry_off: bool, /// True while a node THIS app launched is alive. pub started_by_app: bool, } const TELEMETRY_ON: &str = "telemetry-enabled = true"; const TELEMETRY_OFF: &str = "telemetry-enabled = false"; const FIRST_BOOT_WAIT: Duration = Duration::from_secs(20); /// What the node returns when it has spotted a newer release and wants one. const EXIT_UPDATE_NEEDED: i32 = 42; /// How long to watch a fresh child for that exit. The check runs at startup, /// before any network work, so this only has to outlast process spawn. const UPDATE_EXIT_WAIT: Duration = Duration::from_secs(2); struct NodeDirs { base: PathBuf, config: PathBuf, data: PathBuf, /// The client API port this peer must listen on — the same one the /// adapter dials. Left implicit, a second instance's peer takes the /// default and quietly binds the first instance's port. ws_port: u16, } fn node_dirs(app: &tauri::AppHandle) -> Result { let base = crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("freenet"); let ws_port = config::load(app).freenet_ws_port.unwrap_or(freenet_adapter::DEFAULT_WS_PORT); Ok(NodeDirs { config: base.join("config"), data: base.join("data"), base, ws_port }) } /// No console window for child processes on Windows. fn quiet(mut c: Command) -> Command { #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } c } fn find_binary(app: &tauri::AppHandle) -> Option { let name = if cfg!(windows) { "freenet.exe" } else { "freenet" }; if let Ok(home) = app.path().home_dir() { let p = home.join(".cargo").join("bin").join(name); if p.is_file() { return Some(p); } } let on_path = quiet(Command::new("freenet")) .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false); on_path.then(|| PathBuf::from("freenet")) } fn version_of(bin: &Path) -> Option { let out = quiet(Command::new(bin)).arg("--version").output().ok()?; let text = String::from_utf8_lossy(&out.stdout); let line = text.lines().next()?.trim(); let v = line .trim_start_matches("Freenet version:") .trim() .trim_end_matches("()") .trim(); (!v.is_empty()).then(|| v.to_string()) } fn telemetry_off(config: &Path) -> bool { std::fs::read_to_string(config) .map(|s| s.contains(TELEMETRY_OFF)) .unwrap_or(false) } fn pin_telemetry_off(config: &Path) -> Result<(), String> { let s = std::fs::read_to_string(config).map_err(|e| format!("read config.toml: {e}"))?; if s.contains(TELEMETRY_OFF) { return Ok(()); } if !s.contains(TELEMETRY_ON) { return Err("config.toml has no telemetry-enabled line to pin".into()); } std::fs::write(config, s.replace(TELEMETRY_ON, TELEMETRY_OFF)) .map_err(|e| format!("write config.toml: {e}")) } fn spawn_node(bin: &Path, dirs: &NodeDirs, log: &Path) -> Result { let out = std::fs::File::create(log).map_err(|e| format!("node log: {e}"))?; let err = out.try_clone().map_err(|e| e.to_string())?; quiet(Command::new(bin)) .arg("network") .arg("--config-dir") .arg(&dirs.config) .arg("--data-dir") .arg(&dirs.data) .arg("--ws-api-port") .arg(dirs.ws_port.to_string()) .stdin(Stdio::null()) .stdout(Stdio::from(out)) .stderr(Stdio::from(err)) .spawn() .map_err(|e| format!("start the peer: {e}")) } /// Spawn the peer and be its supervisor. /// /// A node that spots a newer release exits with code 42 and waits for someone /// to apply it; Freenet ships that someone as a system service. We cannot use /// one: `freenet service install` takes no `--config-dir`/`--data-dir`, so its /// node would run on the default folders — a different peer with none of this /// app's identity — and fight ours for port 7509. The app owns the node, so /// the app is the supervisor. /// /// Without this, an update simply ends the layer: 0.2.133 landed on /// 2026-09-03 and the pinned 0.2.132 exited at every launch, leaving the node /// permanently absent with only a line in node.log to say why. One update, one /// restart, no recursion — if the fresh binary asks again we let it be, rather /// than loop. fn spawn_supervised(bin: &Path, dirs: &NodeDirs, log: &Path) -> Result { let mut child = spawn_node(bin, dirs, log)?; let deadline = Instant::now() + UPDATE_EXIT_WAIT; while Instant::now() < deadline { match child.try_wait() { Ok(Some(status)) if status.code() == Some(EXIT_UPDATE_NEEDED) => { log::warn!("freenet node: exit 42 — a newer release is out; applying it"); apply_update(bin)?; let child = spawn_node(bin, dirs, log)?; log::info!("freenet node: updated and restarted (pid {})", child.id()); return Ok(child); } // Any other early exit is the caller's problem to report, and a // live child is the normal case. Ok(Some(_)) | Err(_) => break, Ok(None) => std::thread::sleep(Duration::from_millis(100)), } } Ok(child) } /// Run the node's own updater. Blocking — this whole path is already on a /// blocking task, and the download takes as long as it takes. fn apply_update(bin: &Path) -> Result<(), String> { let out = quiet(Command::new(bin)) .arg("update") .output() .map_err(|e| format!("could not run freenet update: {e}"))?; if !out.status.success() { return Err(format!( "freenet update failed: {}", String::from_utf8_lossy(&out.stderr).trim() )); } Ok(()) } fn node_info(app: &tauri::AppHandle, state: &AppState) -> Result { let dirs = node_dirs(app)?; let bin = find_binary(app); let config = dirs.config.join("config.toml"); let started_by_app = { let mut guard = state.freenet_node.lock().unwrap(); match guard.as_mut().map(|c| c.try_wait()) { Some(Ok(None)) => true, _ => { *guard = None; false } } }; Ok(NodeInfo { version: bin.as_deref().and_then(version_of), binary: bin.map(|p| p.display().to_string()), config_dir: dirs.config.display().to_string(), data_dir: dirs.data.display().to_string(), config_exists: config.is_file(), telemetry_off: telemetry_off(&config), started_by_app, }) } #[tauri::command] pub fn freenet_node_info( app: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { node_info(&app, &state) } /// Start the peer under the app's folder (the first-boot recipe when its /// config does not exist yet). Returns the running child. async fn start_node_inner(app: &tauri::AppHandle) -> Result { let handle = app.clone(); tauri::async_runtime::spawn_blocking(move || -> Result { let bin = find_binary(&handle) .ok_or("no Freenet peer found on this machine — install it (cargo install freenet) and try again")?; let dirs = node_dirs(&handle)?; std::fs::create_dir_all(&dirs.config).map_err(|e| e.to_string())?; std::fs::create_dir_all(&dirs.data).map_err(|e| e.to_string())?; let config = dirs.config.join("config.toml"); if !config.is_file() { let first_log = dirs.base.join("first-boot.log"); let mut first = spawn_supervised(&bin, &dirs, &first_log)?; let deadline = Instant::now() + FIRST_BOOT_WAIT; while !config.is_file() && Instant::now() < deadline { if let Ok(Some(status)) = first.try_wait() { return Err(format!( "the peer exited during its first boot ({status}) — see {}", first_log.display() )); } std::thread::sleep(Duration::from_millis(100)); } let _ = first.kill(); let _ = first.wait(); if !config.is_file() { return Err("the peer wrote no config.toml within 20s".into()); } log::info!("freenet node: first boot wrote {}", config.display()); } pin_telemetry_off(&config)?; let child = spawn_supervised(&bin, &dirs, &dirs.base.join("node.log"))?; log::info!("freenet node started (pid {}) from {}", child.id(), bin.display()); Ok(child) }) .await .map_err(|e| e.to_string())? } /// A peer the app set up follows the switch: whenever the layer is on and /// the peer is absent, start it — at launch, and when the switch goes on. /// First-time setup stays explicit (the Start button), so a user who never /// asked for a peer never gets one; after that it is simply kept running. /// (A dev-harness restart takes the child down with the app — observed /// 2026-09-02 — and a reboot does the same; this is what brings it back.) pub async fn ensure_node(app: tauri::AppHandle) { let state = app.state::(); if !state.freenet.is_enabled() { return; } let Ok(dirs) = node_dirs(&app) else { return }; if !dirs.config.join("config.toml").is_file() { return; } if state.freenet_node.lock().unwrap().is_some() { return; } if !matches!(state.freenet.detect().await, freenet_adapter::AdapterStatus::NodeAbsent) { return; } match start_node_inner(&app).await { Ok(child) => *state.freenet_node.lock().unwrap() = Some(child), Err(e) => log::warn!("freenet node: could not start the peer: {e}"), } } #[tauri::command] pub async fn freenet_node_start( app: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { if node_info(&app, &state)?.started_by_app { return node_info(&app, &state); } let child = start_node_inner(&app).await?; *state.freenet_node.lock().unwrap() = Some(child); node_info(&app, &state) } /// Stops only a node this app started; a hand-started peer is left alone. #[tauri::command] pub async fn freenet_node_stop( app: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { let child = state.freenet_node.lock().unwrap().take(); if let Some(mut c) = child { let _ = c.kill(); let _ = c.wait(); log::info!("freenet node stopped"); } node_info(&app, &state) }