//! 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 keep watching a fresh peer for that exit. The check is a /// network call the node makes at startup, and it takes as long as the /// network takes: on 2026-09-06 a fresh 0.2.132 took more than the two /// seconds this used to wait, exited after the supervisor had already gone, /// and the layer read "node absent" with only node.log saying why. const UPDATE_EXIT_WATCH: Duration = Duration::from_secs(90); 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}")) } /// The peer's 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. /// /// Watches the peer that `start_node_inner` just put in `freenet_node`, from /// its own thread, polling under the lock rather than owning the child — the /// child belongs to the state, and the Stop button takes it. An update is /// applied and the peer restarted while the lock is held, so nothing reads /// the dead child as "stopped" and starts a second peer in the meantime. Any /// other exit is logged and the slot emptied; an emptied slot ends the watch. fn watch_for_update(app: tauri::AppHandle, bin: PathBuf, dirs: NodeDirs, log: PathBuf) { std::thread::spawn(move || { let deadline = Instant::now() + UPDATE_EXIT_WATCH; while Instant::now() < deadline { std::thread::sleep(Duration::from_millis(500)); let state = app.state::(); let mut guard = state.freenet_node.lock().unwrap(); match guard.as_mut().map(|c| c.try_wait()) { Some(Ok(None)) => continue, Some(Ok(Some(status))) if status.code() == Some(EXIT_UPDATE_NEEDED) => { log::warn!("freenet node: exit 42 — a newer release is out; applying it"); match apply_update(&bin).and_then(|()| spawn_node(&bin, &dirs, &log)) { Ok(child) => { log::info!("freenet node: updated and restarted (pid {})", child.id()); *guard = Some(child); } Err(e) => { log::warn!("freenet node: could not apply the update: {e}"); *guard = None; } } return; } Some(Ok(Some(status))) => { log::warn!("freenet node: the peer exited ({status}) — see {}", log.display()); *guard = None; return; } Some(Err(_)) | None => return, } } }); } /// 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), put it in `freenet_node`, and leave the /// supervisor watching it. async fn start_node_inner(app: &tauri::AppHandle) -> Result<(), String> { let handle = app.clone(); tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { 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_node(&bin, &dirs, &first_log)?; let mut deadline = Instant::now() + FIRST_BOOT_WAIT; let mut updated = false; while !config.is_file() && Instant::now() < deadline { if let Ok(Some(status)) = first.try_wait() { // The first boot is the one launch the watcher cannot // cover — its child is never in the state — so the update // exit is handled here, once, with the clock restarted. if status.code() == Some(EXIT_UPDATE_NEEDED) && !updated { log::warn!("freenet node: exit 42 on first boot — a newer release is out; applying it"); apply_update(&bin)?; updated = true; first = spawn_node(&bin, &dirs, &first_log)?; deadline = Instant::now() + FIRST_BOOT_WAIT; continue; } 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 log = dirs.base.join("node.log"); let child = spawn_node(&bin, &dirs, &log)?; log::info!("freenet node started (pid {}) from {}", child.id(), bin.display()); *handle.state::().freenet_node.lock().unwrap() = Some(child); watch_for_update(handle.clone(), bin, dirs, log); Ok(()) }) .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; } if let Err(e) = start_node_inner(&app).await { 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); } start_node_inner(&app).await?; 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) }