Files
SoulStream/src-tauri/src/freenet.rs
T
KeelrLeek 594a33a302 freenet node: the update exit is watched from a thread for as long as the check takes, not for two seconds inside the start call
The supervisor that applies the node's update-wanted exit (code 42)
watched a fresh child for two seconds and then let go. The exit is the
result of a network check the node makes at startup, and it takes as long
as the network takes: on a fresh install today a pinned 0.2.132 exited
several seconds after launch, after the supervisor had already returned,
and the layer read "node absent" with only node.log saying why — the
exact failure the supervisor was written against, back again.

`start_node_inner` now puts the child in `freenet_node` itself and leaves
`watch_for_update` polling it from its own thread for 90 s, 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 meanwhile; any other exit is logged and the slot
emptied; an emptied slot ends the watch. The first boot is the one launch
the watcher cannot cover (its child never reaches the state), so that loop
handles code 42 itself, once, with its clock restarted.

Seen live on this machine: the other-exit branch caught a spawn that died
on a busy port (code 43) within half a second and cleared the slot; a peer
killed by hand and started again from the app was connected two seconds
later. The exit-42 branch itself is not reproducible here any more — the
binary is on the latest release — and stands on the same code path.
2026-09-06 08:17:55 -05:00

454 lines
17 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 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<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}"))
}
/// 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::<AppState>();
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<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), 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::<AppState>().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::<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;
}
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<NodeInfo, String> {
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<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)
}