Files
SoulStream/src-tauri/src/freenet.rs
T

417 lines
15 KiB
Rust

//! 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::<AppState>();
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<FreenetStatus, String> {
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<FreenetStatus, String> {
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<freenet_adapter::AdapterStatus, String> {
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<String>,
pub version: Option<String>,
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<NodeDirs, String> {
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<PathBuf> {
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<String> {
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<Child, String> {
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<Child, String> {
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<NodeInfo, String> {
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<NodeInfo, String> {
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<Child, String> {
let handle = app.clone();
tauri::async_runtime::spawn_blocking(move || -> Result<Child, 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_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::<AppState>();
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<NodeInfo, String> {
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<NodeInfo, String> {
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)
}