417 lines
15 KiB
Rust
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)
|
|
}
|