use anyhow::{Context, Result};
use clap::Subcommand;
use directories::ProjectDirs;
use freenet::config::ConfigPaths;
use freenet::tracing::tracer::get_log_dir;
use std::path::Path;
use std::sync::Arc;
use super::report::ReportCommand;
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn tail_with_rotation(log_dir: &Path, base_name: &str) -> Result<()> {
use std::time::Duration;
let mut current_log = find_latest_log_file(log_dir, base_name).ok_or_else(|| {
anyhow::anyhow!(
"No log files found in: {}\nMake sure the service has been installed and started.",
log_dir.display()
)
})?;
println!("Following logs from: {}", current_log.display());
println!("Press Ctrl+C to stop.\n");
loop {
let mut child = std::process::Command::new("tail")
.arg("-f")
.arg(¤t_log)
.spawn()
.context("Failed to spawn tail")?;
loop {
match child.try_wait() {
Ok(Some(status)) => {
std::process::exit(status.code().unwrap_or(1));
}
Ok(None) => {
}
Err(e) => {
drop(child.kill());
drop(child.wait());
anyhow::bail!("Error waiting on tail process: {e}");
}
}
std::thread::sleep(Duration::from_secs(5));
if let Some(newer_log) = find_latest_log_file(log_dir, base_name) {
if newer_log != current_log {
println!("\n--- Log rotated to: {} ---\n", newer_log.display());
drop(child.kill());
drop(child.wait());
current_log = newer_log;
break; }
}
}
}
}
pub(super) fn find_latest_log_file(log_dir: &Path, base_name: &str) -> Option<std::path::PathBuf> {
use std::fs;
let mut candidates: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new();
let static_file = log_dir.join(format!("{base_name}.log"));
if static_file.exists() {
if let Ok(metadata) = fs::metadata(&static_file) {
if metadata.len() > 0 {
if let Ok(modified) = metadata.modified() {
candidates.push((static_file, modified));
}
}
}
}
if let Ok(entries) = fs::read_dir(log_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(&format!("{base_name}."))
&& name_str.ends_with(".log")
&& name_str.len() > format!("{base_name}..log").len()
{
if let Ok(metadata) = entry.metadata() {
if let Ok(modified) = metadata.modified() {
candidates.push((entry.path(), modified));
}
}
}
}
}
candidates
.into_iter()
.max_by_key(|(_, modified)| *modified)
.map(|(path, _)| path)
}
#[derive(Subcommand, Debug, Clone)]
pub enum ServiceCommand {
Install {
#[arg(long)]
system: bool,
},
Uninstall {
#[arg(long)]
system: bool,
#[arg(long, conflicts_with = "keep_data")]
purge: bool,
#[arg(long, conflicts_with = "purge")]
keep_data: bool,
},
Status {
#[arg(long)]
system: bool,
},
Start {
#[arg(long)]
system: bool,
},
Stop {
#[arg(long)]
system: bool,
},
Restart {
#[arg(long)]
system: bool,
},
Logs {
#[arg(long)]
err: bool,
},
Report(ReportCommand),
#[command(hide = true)]
RunWrapper,
}
impl ServiceCommand {
pub fn run(
&self,
version: &str,
git_commit: &str,
git_dirty: &str,
build_timestamp: &str,
config_dirs: Arc<ConfigPaths>,
) -> Result<()> {
match self {
ServiceCommand::Install { system } => install_service(*system),
ServiceCommand::Uninstall {
system,
purge,
keep_data,
} => uninstall_service(*system, *purge, *keep_data),
ServiceCommand::Status { system } => service_status(*system),
ServiceCommand::Start { system } => start_service(*system),
ServiceCommand::Stop { system } => stop_service(*system),
ServiceCommand::Restart { system } => restart_service(*system),
ServiceCommand::Logs { err } => service_logs(*err),
ServiceCommand::Report(cmd) => {
cmd.run(version, git_commit, git_dirty, build_timestamp, config_dirs)
}
ServiceCommand::RunWrapper => run_wrapper(version),
}
}
}
const WRAPPER_EXIT_UPDATE_NEEDED: i32 = 42;
const WRAPPER_EXIT_ALREADY_RUNNING: i32 = 43;
const SENTINEL_RESTART: i32 = -1;
const SENTINEL_STOP: i32 = -2;
const WRAPPER_INITIAL_BACKOFF_SECS: u64 = 10;
const WRAPPER_MAX_BACKOFF_SECS: u64 = 300;
const WRAPPER_MAX_PORT_CONFLICT_KILLS: u32 = 3;
const WRAPPER_MAX_CONSECUTIVE_FAILURES: u32 = 50;
#[allow(dead_code)] pub(crate) const DASHBOARD_URL: &str = "http://127.0.0.1:7509/";
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn open_url_in_browser(url: &str) {
super::open_url_in_browser(url);
}
#[derive(Debug, Clone)]
struct WrapperState {
backoff_secs: u64,
consecutive_failures: u32,
port_conflict_kills: u32,
}
impl WrapperState {
fn new() -> Self {
Self {
backoff_secs: WRAPPER_INITIAL_BACKOFF_SECS,
consecutive_failures: 0,
port_conflict_kills: 0,
}
}
}
#[derive(Debug, PartialEq)]
enum WrapperAction {
Update,
Exit,
KillAndRetry,
BackoffAndRelaunch { secs: u64 },
}
fn next_wrapper_action(
state: &mut WrapperState,
exit_code: i32,
is_port_conflict: bool,
update_succeeded: Option<bool>, ) -> WrapperAction {
match exit_code {
code if code == WRAPPER_EXIT_UPDATE_NEEDED => {
match update_succeeded {
Some(true) => {
state.consecutive_failures = 0;
state.port_conflict_kills = 0;
state.backoff_secs = WRAPPER_INITIAL_BACKOFF_SECS;
WrapperAction::Update
}
Some(false) => {
state.consecutive_failures += 1;
let secs = state.backoff_secs;
state.backoff_secs = (state.backoff_secs * 2).min(WRAPPER_MAX_BACKOFF_SECS);
WrapperAction::BackoffAndRelaunch { secs }
}
None => WrapperAction::Update, }
}
code if code == WRAPPER_EXIT_ALREADY_RUNNING => WrapperAction::Exit,
0 => WrapperAction::Exit,
_ => {
if is_port_conflict {
state.port_conflict_kills += 1;
if state.port_conflict_kills <= WRAPPER_MAX_PORT_CONFLICT_KILLS {
state.backoff_secs = WRAPPER_INITIAL_BACKOFF_SECS;
return WrapperAction::KillAndRetry;
}
}
state.consecutive_failures += 1;
state.port_conflict_kills = 0;
let secs = state.backoff_secs;
state.backoff_secs = (state.backoff_secs * 2).min(WRAPPER_MAX_BACKOFF_SECS);
WrapperAction::BackoffAndRelaunch { secs }
}
}
}
fn jitter_secs(secs: u64) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
std::time::SystemTime::now().hash(&mut hasher);
let hash = hasher.finish();
let factor = 0.8 + (hash % 1000) as f64 / 2500.0;
(secs as f64 * factor) as u64
}
#[allow(dead_code)] enum BackoffInterrupt {
Completed,
Quit,
Relaunch,
CheckUpdate,
}
fn sleep_with_jitter_interruptible(
secs: u64,
#[cfg(any(target_os = "windows", target_os = "macos"))] action_rx: Option<
&std::sync::mpsc::Receiver<super::tray::TrayAction>,
>,
#[cfg(not(any(target_os = "windows", target_os = "macos")))] _action_rx: Option<
&std::sync::mpsc::Receiver<super::tray::TrayAction>,
>,
) -> BackoffInterrupt {
let jittered = jitter_secs(secs).max(1);
for _ in 0..jittered {
std::thread::sleep(std::time::Duration::from_secs(1));
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some(rx) = action_rx {
if let Ok(action) = rx.try_recv() {
match action {
super::tray::TrayAction::Quit => return BackoffInterrupt::Quit,
super::tray::TrayAction::ViewLogs => super::tray::open_log_file(),
super::tray::TrayAction::OpenDashboard => {
open_url_in_browser(DASHBOARD_URL);
}
super::tray::TrayAction::Start | super::tray::TrayAction::Restart => {
return BackoffInterrupt::Relaunch;
}
super::tray::TrayAction::CheckUpdate => {
return BackoffInterrupt::CheckUpdate;
}
super::tray::TrayAction::Stop => {} }
}
}
}
BackoffInterrupt::Completed
}
fn run_wrapper(version: &str) -> Result<()> {
#[cfg(target_os = "windows")]
unsafe {
winapi::um::wincon::FreeConsole();
}
use freenet::tracing::tracer::get_log_dir;
use std::sync::mpsc;
let log_dir = get_log_dir().unwrap_or_else(|| {
let fallback = dirs::data_local_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("freenet")
.join("logs");
eprintln!(
"Warning: could not determine log directory, using {}",
fallback.display()
);
fallback
});
std::fs::create_dir_all(&log_dir)
.with_context(|| format!("Failed to create log directory: {}", log_dir.display()))?;
kill_stale_freenet_processes(&log_dir);
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
use super::tray::{TrayAction, WrapperStatus};
let (action_tx, action_rx) = mpsc::channel::<TrayAction>();
let (status_tx, status_rx) = mpsc::channel::<WrapperStatus>();
let version_owned = version.to_string();
let log_dir_clone = log_dir.clone();
let loop_handle = std::thread::spawn(move || {
run_wrapper_loop(&log_dir_clone, Some((&action_rx, &status_tx)))
});
super::tray::run_tray_event_loop(action_tx, status_rx, &version_owned);
match loop_handle.join() {
Ok(result) => result,
Err(_) => anyhow::bail!("Wrapper loop thread panicked"),
}
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let _ = version;
run_wrapper_loop(
&log_dir,
None::<(
&mpsc::Receiver<super::tray::TrayAction>,
&mpsc::Sender<super::tray::WrapperStatus>,
)>,
)
}
}
fn spawn_update_command(exe_path: &Path) -> std::io::Result<std::process::ExitStatus> {
let mut cmd = std::process::Command::new(exe_path);
cmd.args(["update", "--quiet"]);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd.status()
}
fn spawn_new_wrapper(exe_path: &Path, log_dir: &Path) -> bool {
let mut cmd = std::process::Command::new(exe_path);
cmd.args(["service", "run-wrapper"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
match cmd.spawn() {
Ok(_) => {
log_wrapper_event(log_dir, "New wrapper process spawned");
true
}
Err(e) => {
log_wrapper_event(
log_dir,
&format!("Failed to spawn new wrapper: {e}. Continuing with current wrapper."),
);
false
}
}
}
const NETWORK_READINESS_TIMEOUT_SECS: u64 = 60;
const NETWORK_READINESS_CHECK_INTERVAL_SECS: u64 = 2;
const NETWORK_PROBE_ADDR: &str = "freenet.org:443";
fn wait_for_network_ready(
log_dir: &Path,
#[cfg(any(target_os = "windows", target_os = "macos"))] action_rx: Option<
&std::sync::mpsc::Receiver<super::tray::TrayAction>,
>,
#[cfg(not(any(target_os = "windows", target_os = "macos")))] _action_rx: Option<
&std::sync::mpsc::Receiver<super::tray::TrayAction>,
>,
) -> bool {
wait_for_network_ready_inner(
log_dir,
NETWORK_PROBE_ADDR,
#[cfg(any(target_os = "windows", target_os = "macos"))]
action_rx,
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
_action_rx,
)
}
fn wait_for_network_ready_inner(
log_dir: &Path,
probe_addr: &str,
#[cfg(any(target_os = "windows", target_os = "macos"))] action_rx: Option<
&std::sync::mpsc::Receiver<super::tray::TrayAction>,
>,
#[cfg(not(any(target_os = "windows", target_os = "macos")))] _action_rx: Option<
&std::sync::mpsc::Receiver<super::tray::TrayAction>,
>,
) -> bool {
use std::net::ToSocketAddrs;
if probe_addr.to_socket_addrs().is_ok() {
return true;
}
log_wrapper_event(
log_dir,
"Network not ready yet, waiting for connectivity...",
);
let max_checks = NETWORK_READINESS_TIMEOUT_SECS / NETWORK_READINESS_CHECK_INTERVAL_SECS;
for _ in 0..max_checks {
let jittered = jitter_secs(NETWORK_READINESS_CHECK_INTERVAL_SECS);
std::thread::sleep(std::time::Duration::from_secs(jittered.max(1)));
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some(rx) = action_rx {
if let Ok(super::tray::TrayAction::Quit) = rx.try_recv() {
return false;
}
}
if probe_addr.to_socket_addrs().is_ok() {
log_wrapper_event(log_dir, "Network is ready");
return true;
}
}
log_wrapper_event(
log_dir,
"Network readiness timeout — starting node anyway (it will retry internally)",
);
true
}
fn run_wrapper_loop(
log_dir: &Path,
#[cfg(any(target_os = "windows", target_os = "macos"))] tray: Option<(
&std::sync::mpsc::Receiver<super::tray::TrayAction>,
&std::sync::mpsc::Sender<super::tray::WrapperStatus>,
)>,
#[cfg(not(any(target_os = "windows", target_os = "macos")))] _tray: Option<(
&std::sync::mpsc::Receiver<super::tray::TrayAction>,
&std::sync::mpsc::Sender<super::tray::WrapperStatus>,
)>,
) -> Result<()> {
#[cfg(any(target_os = "windows", target_os = "macos"))]
use super::tray::WrapperStatus;
let exe_path = std::env::current_exe().context("Failed to get current executable")?;
let mut state = WrapperState::new();
#[cfg(any(target_os = "windows", target_os = "macos"))]
if !wait_for_network_ready(log_dir, tray.map(|(rx, _)| rx)) {
return Ok(()); }
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
wait_for_network_ready(log_dir, None);
loop {
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((_, status_tx)) = tray {
status_tx.send(WrapperStatus::Running).ok();
}
let stderr_path = log_dir.join("freenet.error.log.last");
let stderr_file = std::fs::File::create(&stderr_path).ok();
let mut cmd = std::process::Command::new(&exe_path);
cmd.arg("network");
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::null());
}
if let Some(stderr_file) = stderr_file {
cmd.stderr(stderr_file);
} else {
#[cfg(target_os = "windows")]
cmd.stderr(std::process::Stdio::null());
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
log_wrapper_event(log_dir, &format!("Failed to spawn freenet network: {e}"));
return Err(e).context("Failed to spawn freenet network");
}
};
let exit_code = loop {
match child.try_wait() {
Ok(Some(status)) => break status.code().unwrap_or(1),
Ok(None) => {} Err(e) => {
log_wrapper_event(log_dir, &format!("Error waiting for child: {e}"));
break 1;
}
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((action_rx, status_tx)) = tray {
if let Ok(action) = action_rx.try_recv() {
match action {
super::tray::TrayAction::Quit => {
drop(child.kill());
drop(child.wait());
return Ok(());
}
super::tray::TrayAction::Restart => {
log_wrapper_event(log_dir, "Restart requested via tray");
drop(child.kill());
drop(child.wait());
break SENTINEL_RESTART;
}
super::tray::TrayAction::Stop => {
log_wrapper_event(log_dir, "Stop requested via tray");
drop(child.kill());
drop(child.wait());
break SENTINEL_STOP;
}
super::tray::TrayAction::Start => {
}
super::tray::TrayAction::ViewLogs => super::tray::open_log_file(),
super::tray::TrayAction::CheckUpdate => {
status_tx.send(WrapperStatus::Updating).ok();
let result = spawn_update_command(&exe_path);
match result {
Ok(s) if s.success() => {
log_wrapper_event(
log_dir,
"Update installed via tray, restarting wrapper...",
);
status_tx.send(WrapperStatus::UpdatedRestarting).ok();
drop(child.kill());
drop(child.wait());
if spawn_new_wrapper(&exe_path, log_dir) {
return Ok(());
}
break SENTINEL_RESTART;
}
Ok(_) => {
log_wrapper_event(log_dir, "No update available");
status_tx.send(WrapperStatus::UpToDate).ok();
}
Err(e) => {
log_wrapper_event(
log_dir,
&format!("Update check failed: {e}"),
);
status_tx.send(WrapperStatus::Running).ok();
}
}
}
super::tray::TrayAction::OpenDashboard => {
open_url_in_browser(DASHBOARD_URL);
}
}
}
}
std::thread::sleep(std::time::Duration::from_millis(250));
};
if exit_code == SENTINEL_RESTART {
continue;
}
if exit_code == SENTINEL_STOP {
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((action_rx, status_tx)) = tray {
status_tx.send(WrapperStatus::Stopped).ok();
loop {
if let Ok(action) = action_rx.try_recv() {
match action {
super::tray::TrayAction::Start | super::tray::TrayAction::Restart => {
log_wrapper_event(log_dir, "Start requested via tray");
break; }
super::tray::TrayAction::Quit => {
return Ok(());
}
super::tray::TrayAction::ViewLogs => {
super::tray::open_log_file();
}
super::tray::TrayAction::CheckUpdate => {
status_tx.send(WrapperStatus::Updating).ok();
let result = spawn_update_command(&exe_path);
match result {
Ok(s) if s.success() => {
log_wrapper_event(
log_dir,
"Update installed while stopped, restarting wrapper...",
);
status_tx.send(WrapperStatus::UpdatedRestarting).ok();
if spawn_new_wrapper(&exe_path, log_dir) {
return Ok(());
}
break;
}
Ok(_) => {
log_wrapper_event(log_dir, "No update available");
status_tx.send(WrapperStatus::UpToDate).ok();
}
Err(e) => {
log_wrapper_event(
log_dir,
&format!("Update check failed: {e}"),
);
}
}
}
super::tray::TrayAction::OpenDashboard
| super::tray::TrayAction::Stop => {}
}
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
continue;
}
let is_port_conflict = stderr_path
.exists()
.then(|| std::fs::read_to_string(&stderr_path).unwrap_or_default())
.map(|s| s.contains("already in use"))
.unwrap_or(false);
let update_succeeded = if exit_code == WRAPPER_EXIT_UPDATE_NEEDED {
log_wrapper_event(log_dir, "Update needed, running freenet update...");
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((_, status_tx)) = tray {
status_tx.send(WrapperStatus::Updating).ok();
}
let ok = spawn_update_command(&exe_path)
.map(|s| s.success())
.unwrap_or(false);
if ok {
log_wrapper_event(log_dir, "Update successful, restarting...");
} else {
log_wrapper_event(log_dir, "Update failed");
}
Some(ok)
} else {
None
};
let action = next_wrapper_action(&mut state, exit_code, is_port_conflict, update_succeeded);
match action {
WrapperAction::Update => {
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((_, status_tx)) = tray {
status_tx.send(WrapperStatus::UpdatedRestarting).ok();
std::thread::sleep(std::time::Duration::from_secs(2));
}
if spawn_new_wrapper(&exe_path, log_dir) {
return Ok(());
}
}
WrapperAction::Exit => {
let reason = if exit_code == WRAPPER_EXIT_ALREADY_RUNNING {
"Another instance is already running, exiting cleanly"
} else if exit_code == 0 {
"Normal shutdown"
} else {
"Exiting"
};
log_wrapper_event(log_dir, reason);
return Ok(());
}
WrapperAction::KillAndRetry => {
log_wrapper_event(
log_dir,
&format!(
"Port conflict detected (attempt {}/{WRAPPER_MAX_PORT_CONFLICT_KILLS}) — killing stale process and retrying...",
state.port_conflict_kills,
),
);
kill_stale_freenet_processes(log_dir);
std::thread::sleep(std::time::Duration::from_secs(2));
}
WrapperAction::BackoffAndRelaunch { secs } => {
if state.consecutive_failures >= WRAPPER_MAX_CONSECUTIVE_FAILURES {
log_wrapper_event(
log_dir,
&format!(
"Giving up after {} consecutive failures. \
Run 'freenet network' manually to diagnose.",
state.consecutive_failures,
),
);
return Ok(());
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((_, status_tx)) = tray {
status_tx.send(WrapperStatus::Stopped).ok();
}
log_wrapper_event(
log_dir,
&format!("Exited with code {exit_code}, restarting after {secs}s backoff..."),
);
loop {
#[cfg(any(target_os = "windows", target_os = "macos"))]
let interrupt = sleep_with_jitter_interruptible(secs, tray.map(|(rx, _)| rx));
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let interrupt = sleep_with_jitter_interruptible(secs, None);
match interrupt {
BackoffInterrupt::Quit => return Ok(()),
BackoffInterrupt::Relaunch => {
log_wrapper_event(log_dir, "Relaunch requested during backoff");
state.consecutive_failures = 0;
break; }
BackoffInterrupt::CheckUpdate => {
log_wrapper_event(log_dir, "Update check requested during backoff");
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((_, status_tx)) = tray {
status_tx.send(WrapperStatus::Updating).ok();
let result = spawn_update_command(&exe_path);
match result {
Ok(s) if s.success() => {
log_wrapper_event(
log_dir,
"Update installed during backoff, restarting wrapper...",
);
status_tx.send(WrapperStatus::UpdatedRestarting).ok();
if spawn_new_wrapper(&exe_path, log_dir) {
return Ok(());
}
state.consecutive_failures = 0;
break;
}
Ok(_) => {
log_wrapper_event(log_dir, "No update available");
status_tx.send(WrapperStatus::UpToDate).ok();
}
Err(e) => {
log_wrapper_event(
log_dir,
&format!("Update check failed: {e}"),
);
status_tx.send(WrapperStatus::Stopped).ok();
}
}
}
continue;
}
BackoffInterrupt::Completed => break, }
}
}
}
}
}
const WRAPPER_LOG_RETENTION_DAYS: usize = 7;
fn log_wrapper_event(log_dir: &Path, message: &str) {
use std::io::Write;
let now = chrono::Local::now();
let date_str = now.format("%Y-%m-%d");
let log_path = log_dir.join(format!("freenet-wrapper.{date_str}.log"));
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
let timestamp = now.format("%H:%M:%S");
drop(writeln!(file, "{timestamp}: {message}"));
}
eprintln!("{message}");
cleanup_old_wrapper_logs(log_dir);
}
fn cleanup_old_wrapper_logs(log_dir: &Path) {
let entries = match std::fs::read_dir(log_dir) {
Ok(e) => e,
Err(_) => return,
};
let mut wrapper_logs: Vec<std::path::PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("freenet-wrapper.") && n.ends_with(".log"))
.unwrap_or(false)
})
.collect();
if wrapper_logs.len() <= WRAPPER_LOG_RETENTION_DAYS {
return;
}
wrapper_logs.sort();
let to_remove = wrapper_logs.len() - WRAPPER_LOG_RETENTION_DAYS;
for path in &wrapper_logs[..to_remove] {
drop(std::fs::remove_file(path));
}
}
fn kill_stale_freenet_processes(log_dir: &Path) {
#[cfg(unix)]
{
let uid = std::process::Command::new("id")
.arg("-u")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default();
let uid = uid.trim();
let status = std::process::Command::new("pkill")
.args(["-f", "-u", uid, "freenet network"])
.status();
if let Ok(s) = status {
if s.success() {
log_wrapper_event(
log_dir,
"Killed stale freenet network process(es) on startup",
);
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
}
#[cfg(target_os = "windows")]
{
let output = std::process::Command::new("wmic")
.args([
"process",
"where",
"name='freenet.exe' and commandline like '%network%'",
"get",
"processid",
"/format:list",
])
.output();
if let Ok(output) = output {
let stdout = String::from_utf8_lossy(&output.stdout);
let mut killed = false;
for line in stdout.lines() {
if let Some(pid_str) = line.strip_prefix("ProcessId=") {
let pid = pid_str.trim();
if !pid.is_empty() {
drop(
std::process::Command::new("taskkill")
.args(["/F", "/PID", pid])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status(),
);
killed = true;
}
}
}
if killed {
log_wrapper_event(
log_dir,
"Killed stale freenet network process(es) on startup",
);
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
}
}
pub fn should_purge(purge: bool, keep_data: bool) -> Result<bool> {
if purge {
return Ok(true);
}
if keep_data {
return Ok(false);
}
use std::io::{self, BufRead, IsTerminal, Write};
if io::stdin().is_terminal() {
print!("Also remove all Freenet data, config, and logs? [y/N] ");
io::stdout().flush()?;
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
let answer = line.trim().to_ascii_lowercase();
Ok(answer == "y" || answer == "yes")
} else {
println!(
"Non-interactive mode: keeping data. Use --purge to also remove data, config, and logs."
);
Ok(false)
}
}
fn remove_if_exists(label: &str, path: &Path) -> Result<()> {
if path.exists() {
println!("Removing {label}: {}", path.display());
std::fs::remove_dir_all(path)
.with_context(|| format!("Failed to remove {label} directory: {}", path.display()))?;
}
Ok(())
}
pub fn purge_data(system_mode: bool) -> Result<()> {
purge_data_dirs(system_mode)
}
fn purge_data_dirs(#[allow(unused_variables)] system_mode: bool) -> Result<()> {
#[cfg(target_os = "linux")]
let home_override: Option<std::path::PathBuf> = if system_mode {
std::env::var("SUDO_USER")
.ok()
.map(|u| home_dir_for_user(&u))
} else {
None
};
#[cfg(not(target_os = "linux"))]
let home_override: Option<std::path::PathBuf> = None;
if let Some(ref home) = home_override {
remove_if_exists("data", &home.join(".local/share/Freenet"))?;
remove_if_exists("config", &home.join(".config/Freenet"))?;
remove_if_exists("cache", &home.join(".cache/Freenet"))?;
remove_if_exists("cache", &home.join(".cache/freenet"))?;
remove_if_exists("logs", &home.join(".local/state/freenet"))?;
} else {
match ProjectDirs::from("", "The Freenet Project Inc", "Freenet") {
Some(ref dirs) => {
let data_dir = dirs.data_local_dir();
remove_if_exists("data", data_dir)?;
let old_roaming = dirs.data_dir();
if old_roaming != data_dir {
remove_if_exists("data (legacy roaming)", old_roaming)?;
}
let config_dir = dirs.config_dir();
if config_dir != data_dir && config_dir != old_roaming {
remove_if_exists("config", config_dir)?;
}
remove_if_exists("cache", dirs.cache_dir())?;
}
None => {
eprintln!(
"Warning: Could not determine Freenet directories. Data and config may not have been removed."
);
}
}
if let Some(ref dirs) = ProjectDirs::from("", "The Freenet Project Inc", "freenet") {
let cache_dir = dirs.cache_dir();
if cache_dir.exists() {
remove_if_exists("cache", cache_dir)?;
}
}
if let Some(log_dir) = get_log_dir() {
remove_if_exists("logs", &log_dir)?;
}
}
Ok(())
}
#[cfg(target_os = "linux")]
const SYSTEM_SERVICE_PATH: &str = "/etc/systemd/system/freenet.service";
#[cfg(target_os = "linux")]
fn has_system_service() -> bool {
Path::new(SYSTEM_SERVICE_PATH).exists()
}
#[cfg(target_os = "linux")]
fn has_user_service() -> bool {
dirs::home_dir()
.map(|h| h.join(".config/systemd/user/freenet.service").exists())
.unwrap_or(false)
}
#[cfg(target_os = "linux")]
fn chown_to_user(path: &Path, username: &str) {
let _status = std::process::Command::new("chown")
.args(["-R", username, &path.display().to_string()])
.status();
}
#[cfg(target_os = "linux")]
fn home_dir_for_user(username: &str) -> std::path::PathBuf {
if let Ok(output) = std::process::Command::new("getent")
.args(["passwd", username])
.output()
{
if output.status.success() {
let line = String::from_utf8_lossy(&output.stdout);
if let Some(home) = line.split(':').nth(5) {
let home = home.trim();
if !home.is_empty() {
return std::path::PathBuf::from(home);
}
}
}
}
std::path::PathBuf::from(format!("/home/{username}"))
}
#[cfg(target_os = "linux")]
fn use_system_mode(system_flag: bool) -> bool {
system_flag || (has_system_service() && !has_user_service())
}
#[cfg(target_os = "linux")]
fn systemctl(system_mode: bool, args: &[&str]) -> Result<std::process::ExitStatus> {
let mut cmd = std::process::Command::new("systemctl");
if !system_mode {
cmd.arg("--user");
}
cmd.args(args);
let status = cmd.status().context("Failed to run systemctl")?;
Ok(status)
}
#[cfg(target_os = "linux")]
fn systemctl_with_hint(system_mode: bool, args: &[&str], action: &str) -> Result<()> {
let status = systemctl(system_mode, args)?;
if status.success() {
return Ok(());
}
if system_mode {
anyhow::bail!("Failed to {action}");
}
let hint = std::process::Command::new("systemctl")
.args(["--user", "daemon-reload"])
.stderr(std::process::Stdio::piped())
.output()
.ok()
.and_then(|out| {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("bus")
|| stderr.contains("XDG_RUNTIME_DIR")
|| stderr.contains("Failed to connect")
{
Some(
"\n\nHint: User systemd session not available (common in containers/LXC).\n\
Try: sudo freenet service install --system",
)
} else {
None
}
})
.unwrap_or("");
anyhow::bail!("Failed to {action}{hint}");
}
#[cfg(target_os = "linux")]
fn install_service(system: bool) -> Result<()> {
if system {
install_system_service()
} else {
install_user_service()
}
}
#[cfg(target_os = "linux")]
fn install_user_service() -> Result<()> {
use std::fs;
let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
let home_dir = dirs::home_dir().context("Failed to get home directory")?;
let service_dir = home_dir.join(".config/systemd/user");
fs::create_dir_all(&service_dir).context("Failed to create systemd user directory")?;
let log_dir = home_dir.join(".local/state/freenet");
fs::create_dir_all(&log_dir).context("Failed to create log directory")?;
let service_content = generate_user_service_file(&exe_path, &log_dir);
let service_path = service_dir.join("freenet.service");
fs::write(&service_path, service_content).context("Failed to write service file")?;
systemctl_with_hint(false, &["daemon-reload"], "reload systemd daemon")?;
systemctl_with_hint(false, &["enable", "freenet"], "enable service")?;
println!("Freenet user service installed successfully.");
println!();
println!("To start the service now:");
println!(" freenet service start");
println!();
println!("The service will start automatically on login.");
println!("Logs will be written to: {}", log_dir.display());
Ok(())
}
#[cfg(target_os = "linux")]
fn install_system_service() -> Result<()> {
use std::fs;
let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
let username = std::env::var("SUDO_USER")
.or_else(|_| std::env::var("USER"))
.or_else(|_| std::env::var("LOGNAME"))
.context(
"Could not determine username. Set the USER environment variable \
or run with sudo (which sets SUDO_USER).",
)?;
if username == "root" {
anyhow::bail!(
"Refusing to install system service running as root.\n\
Run with sudo from a non-root user account so SUDO_USER is set,\n\
or set the USER environment variable to the desired service user."
);
}
let home_dir = home_dir_for_user(&username);
let log_dir = home_dir.join(".local/state/freenet");
fs::create_dir_all(&log_dir).context("Failed to create log directory")?;
chown_to_user(&log_dir, &username);
let service_content = generate_system_service_file(&exe_path, &log_dir, &username, &home_dir);
fs::write(SYSTEM_SERVICE_PATH, &service_content).with_context(|| {
format!(
"Failed to write service file to {SYSTEM_SERVICE_PATH}. \
Are you running as root? Try: sudo freenet service install --system"
)
})?;
let status = systemctl(true, &["daemon-reload"])?;
if !status.success() {
anyhow::bail!("Failed to reload systemd daemon");
}
let status = systemctl(true, &["enable", "freenet"])?;
if !status.success() {
anyhow::bail!("Failed to enable service");
}
println!("Freenet system service installed successfully.");
println!(" Service runs as user: {username}");
println!();
println!("To start the service now:");
println!(" sudo freenet service start --system");
println!();
println!("The service will start automatically on boot.");
println!("Logs will be written to: {}", log_dir.display());
Ok(())
}
#[cfg(target_os = "linux")]
pub fn generate_user_service_file(binary_path: &Path, log_dir: &Path) -> String {
format!(
r#"[Unit]
Description=Freenet Node
Documentation=https://freenet.org
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart={binary} network
Restart=always
# Wait 10 seconds before restart to avoid rapid restart loops
RestartSec=10
# Stop restart loop after 5 failures in 2 minutes (e.g., port conflict with
# a stale process). Without this, systemd restarts indefinitely.
# SuccessExitStatus=42 ensures auto-update exits don't count as failures.
StartLimitBurst=5
StartLimitIntervalSec=120
# Allow 15 seconds for graceful shutdown before SIGKILL
# The node handles SIGTERM to properly close peer connections
TimeoutStopSec=15
# Auto-update: if peer exits with code 42 (version mismatch with gateway),
# run update before systemd restarts the service. The '-' prefix means
# ExecStopPost failure won't affect service restart.
ExecStopPost=-/bin/sh -c '[ "$EXIT_STATUS" = "42" ] && {binary} update --quiet || true'
# Treat exit code 42 as success so it doesn't count against StartLimitBurst.
# Without this, rapid update cycles (exit 42 → ExecStopPost → restart) can
# exhaust the burst limit and permanently kill the service.
SuccessExitStatus=42 43
# Exit code 43 = another instance is already running on the port.
# Do NOT restart — the existing instance is healthy.
RestartPreventExitStatus=43
# Logging - write to files for systems without active user journald
# (headless servers, systems without lingering enabled, etc.)
StandardOutput=append:{log_dir}/freenet.log
StandardError=append:{log_dir}/freenet.error.log
SyslogIdentifier=freenet
# Resource limits to prevent runaway resource consumption
# File descriptors needed for network connections
LimitNOFILE=65536
# Memory limit (2GB soft limit for user service)
MemoryMax=2G
# CPU quota (200% = 2 cores max)
CPUQuota=200%
[Install]
WantedBy=default.target
"#,
binary = binary_path.display(),
log_dir = log_dir.display()
)
}
#[cfg(target_os = "linux")]
pub fn generate_system_service_file(
binary_path: &Path,
log_dir: &Path,
username: &str,
home_dir: &Path,
) -> String {
format!(
r#"[Unit]
Description=Freenet Node
Documentation=https://freenet.org
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={username}
Environment=HOME={home}
ExecStart={binary} network
Restart=always
# Wait 10 seconds before restart to avoid rapid restart loops
RestartSec=10
# Stop restart loop after 5 failures in 2 minutes (e.g., port conflict with
# a stale process). Without this, systemd restarts indefinitely.
# SuccessExitStatus=42 ensures auto-update exits don't count as failures.
StartLimitBurst=5
StartLimitIntervalSec=120
# Allow 15 seconds for graceful shutdown before SIGKILL
# The node handles SIGTERM to properly close peer connections
TimeoutStopSec=15
# Auto-update: if peer exits with code 42 (version mismatch with gateway),
# run update before systemd restarts the service. The '-' prefix means
# ExecStopPost failure won't affect service restart.
ExecStopPost=-/bin/sh -c '[ "$EXIT_STATUS" = "42" ] && {binary} update --quiet || true'
# Treat exit code 42 as success so it doesn't count against StartLimitBurst.
# Without this, rapid update cycles (exit 42 → ExecStopPost → restart) can
# exhaust the burst limit and permanently kill the service.
SuccessExitStatus=42 43
# Exit code 43 = another instance is already running on the port.
# Do NOT restart — the existing instance is healthy.
RestartPreventExitStatus=43
# Logging - write to files for systems without active user journald
StandardOutput=append:{log_dir}/freenet.log
StandardError=append:{log_dir}/freenet.error.log
SyslogIdentifier=freenet
# Resource limits to prevent runaway resource consumption
# File descriptors needed for network connections
LimitNOFILE=65536
# Memory limit (2GB soft limit)
MemoryMax=2G
# CPU quota (200% = 2 cores max)
CPUQuota=200%
[Install]
WantedBy=multi-user.target
"#,
binary = binary_path.display(),
log_dir = log_dir.display(),
username = username,
home = home_dir.display()
)
}
#[cfg(target_os = "linux")]
pub fn stop_and_remove_service(system: bool) -> Result<bool> {
use std::fs;
let system_mode = use_system_mode(system);
let service_path = if system_mode {
std::path::PathBuf::from(SYSTEM_SERVICE_PATH)
} else {
dirs::home_dir()
.context("Failed to get home directory")?
.join(".config/systemd/user/freenet.service")
};
if !service_path.exists() {
return Ok(false);
}
let _stop = systemctl(system_mode, &["stop", "freenet"]);
let _disable = systemctl(system_mode, &["disable", "freenet"]);
fs::remove_file(&service_path).context("Failed to remove service file")?;
drop(systemctl(system_mode, &["daemon-reload"]));
Ok(true)
}
#[cfg(target_os = "linux")]
fn uninstall_service(system: bool, purge: bool, keep_data: bool) -> Result<()> {
stop_and_remove_service(system)?;
println!("Freenet service uninstalled.");
if should_purge(purge, keep_data)? {
let system_mode = use_system_mode(system);
purge_data_dirs(system_mode)?;
println!("All Freenet data, config, and logs removed.");
}
Ok(())
}
#[cfg(target_os = "linux")]
fn service_status(system: bool) -> Result<()> {
let system_mode = use_system_mode(system);
let status = systemctl(system_mode, &["status", "freenet"])?;
std::process::exit(status.code().unwrap_or(1));
}
#[cfg(target_os = "linux")]
fn start_service(system: bool) -> Result<()> {
let system_mode = use_system_mode(system);
systemctl_with_hint(system_mode, &["start", "freenet"], "start service")?;
println!("Freenet service started.");
println!("Open http://127.0.0.1:7509/ in your browser to view your Freenet dashboard.");
Ok(())
}
#[cfg(target_os = "linux")]
fn stop_service(system: bool) -> Result<()> {
let system_mode = use_system_mode(system);
systemctl_with_hint(system_mode, &["stop", "freenet"], "stop service")?;
println!("Freenet service stopped.");
Ok(())
}
#[cfg(target_os = "linux")]
fn restart_service(system: bool) -> Result<()> {
let system_mode = use_system_mode(system);
systemctl_with_hint(system_mode, &["restart", "freenet"], "restart service")?;
println!("Freenet service restarted.");
println!("Open http://127.0.0.1:7509/ in your browser to view your Freenet dashboard.");
Ok(())
}
#[cfg(target_os = "linux")]
fn service_logs(error_only: bool) -> Result<()> {
let log_dir = dirs::home_dir()
.context("Failed to get home directory")?
.join(".local/state/freenet");
let base_name = if error_only {
"freenet.error"
} else {
"freenet"
};
tail_with_rotation(&log_dir, base_name)
}
#[cfg(target_os = "macos")]
fn install_service(system: bool) -> Result<()> {
if system {
anyhow::bail!(
"The --system flag is only supported on Linux.\n\
On macOS, use the default user agent: freenet service install"
);
}
install_macos_service()
}
#[cfg(target_os = "macos")]
fn install_macos_service() -> Result<()> {
use std::fs;
use std::os::unix::fs::PermissionsExt;
let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
let home_dir = dirs::home_dir().context("Failed to get home directory")?;
let launch_agents_dir = home_dir.join("Library/LaunchAgents");
fs::create_dir_all(&launch_agents_dir).context("Failed to create LaunchAgents directory")?;
let log_dir = home_dir.join("Library/Logs/freenet");
fs::create_dir_all(&log_dir).context("Failed to create log directory")?;
let wrapper_dir = home_dir.join(".local/bin");
fs::create_dir_all(&wrapper_dir).context("Failed to create wrapper directory")?;
let wrapper_path = wrapper_dir.join("freenet-service-wrapper.sh");
let wrapper_content = generate_wrapper_script(&exe_path);
fs::write(&wrapper_path, wrapper_content).context("Failed to write wrapper script")?;
fs::set_permissions(&wrapper_path, fs::Permissions::from_mode(0o755))
.context("Failed to make wrapper script executable")?;
let plist_content = generate_plist(&wrapper_path, &log_dir);
let plist_path = launch_agents_dir.join("org.freenet.node.plist");
fs::write(&plist_path, plist_content).context("Failed to write plist file")?;
println!("Freenet service installed successfully.");
println!();
println!("To start the service now:");
println!(" freenet service start");
println!();
println!("The service will start automatically on login.");
println!("Logs will be written to: {}", log_dir.display());
Ok(())
}
#[cfg(target_os = "macos")]
pub fn generate_wrapper_script(binary_path: &Path) -> String {
format!(
r#"#!/bin/bash
# Freenet service wrapper for auto-update support.
# This wrapper monitors exit code 42 (update needed) and runs update before restart.
# Includes exponential backoff to prevent rapid restart loops on repeated failures.
# On startup, kills any stale 'freenet network' processes to avoid port conflicts.
BACKOFF=10 # Initial backoff in seconds
MAX_BACKOFF=300 # Maximum backoff (5 minutes)
CONSECUTIVE_FAILURES=0
PORT_CONFLICT_KILLS=0
MAX_PORT_CONFLICT_KILLS=3 # Give up after this many kill attempts
LOG="$HOME/Library/Logs/freenet/freenet.log"
# Kill any stale freenet network processes before starting.
# This handles the case where a previous launch daemon restart left a child
# process still holding the port (e.g. port 7509).
# Scoped to the current user to avoid killing processes owned by other users.
if pkill -f -u "$(id -u)" "freenet network" 2>/dev/null; then
echo "$(date): Killed stale freenet network process(es) on startup" >> "$LOG"
sleep 2
fi
while true; do
"{binary}" network 2>"$HOME/Library/Logs/freenet/freenet.error.log.last"
EXIT_CODE=$?
if [ $EXIT_CODE -eq 42 ]; then
echo "$(date): Update needed, running freenet update..." >> "$LOG"
if "{binary}" update --quiet; then
echo "$(date): Update successful, restarting..." >> "$LOG"
CONSECUTIVE_FAILURES=0
PORT_CONFLICT_KILLS=0
BACKOFF=10
sleep 2
else
CONSECUTIVE_FAILURES=$((CONSECUTIVE_FAILURES + 1))
echo "$(date): Update failed (attempt $CONSECUTIVE_FAILURES), backing off $BACKOFF seconds..." >> "$LOG"
sleep $BACKOFF
BACKOFF=$((BACKOFF * 2))
[ $BACKOFF -gt $MAX_BACKOFF ] && BACKOFF=$MAX_BACKOFF
fi
continue
elif [ $EXIT_CODE -eq 43 ]; then
echo "$(date): Another instance is already running, exiting cleanly" >> "$LOG"
exit 0
elif [ $EXIT_CODE -eq 0 ]; then
echo "$(date): Normal shutdown" >> "$LOG"
exit 0
else
# Check if this looks like a port-already-in-use failure.
if grep -q "already in use" "$HOME/Library/Logs/freenet/freenet.error.log.last" 2>/dev/null; then
PORT_CONFLICT_KILLS=$((PORT_CONFLICT_KILLS + 1))
if [ $PORT_CONFLICT_KILLS -le $MAX_PORT_CONFLICT_KILLS ]; then
echo "$(date): Port conflict detected (attempt $PORT_CONFLICT_KILLS/$MAX_PORT_CONFLICT_KILLS) — killing stale freenet process and retrying..." >> "$LOG"
pkill -f -u "$(id -u)" "freenet network" 2>/dev/null || true
sleep 2
BACKOFF=10
continue
else
echo "$(date): Port conflict persists after $MAX_PORT_CONFLICT_KILLS kill attempts. Manual intervention may be required ('pkill freenet'). Backing off..." >> "$LOG"
fi
fi
CONSECUTIVE_FAILURES=$((CONSECUTIVE_FAILURES + 1))
PORT_CONFLICT_KILLS=0
echo "$(date): Exited with code $EXIT_CODE, restarting after backoff..." >> "$LOG"
sleep $BACKOFF
BACKOFF=$((BACKOFF * 2))
[ $BACKOFF -gt $MAX_BACKOFF ] && BACKOFF=$MAX_BACKOFF
fi
done
"#,
binary = binary_path.display()
)
}
#[cfg(target_os = "macos")]
fn generate_plist(wrapper_path: &Path, log_dir: &Path) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>org.freenet.node</string>
<key>ProgramArguments</key>
<array>
<string>{wrapper}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>{log_dir}/freenet.log</string>
<key>StandardErrorPath</key>
<string>{log_dir}/freenet.error.log</string>
<key>SoftResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>65536</integer>
</dict>
<key>HardResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>65536</integer>
</dict>
</dict>
</plist>
"#,
wrapper = wrapper_path.display(),
log_dir = log_dir.display()
)
}
#[cfg(target_os = "macos")]
fn check_no_system_flag(system: bool) -> Result<()> {
if system {
anyhow::bail!(
"The --system flag is only supported on Linux.\n\
On macOS, use the default user agent commands without --system."
);
}
Ok(())
}
#[cfg(target_os = "macos")]
pub fn stop_and_remove_service(_system: bool) -> Result<bool> {
use std::fs;
let plist_path = dirs::home_dir()
.context("Failed to get home directory")?
.join("Library/LaunchAgents/org.freenet.node.plist");
if !plist_path.exists() {
return Ok(false);
}
if let Some(plist_path_str) = plist_path.to_str() {
let unload_status = std::process::Command::new("launchctl")
.args(["unload", plist_path_str])
.status();
if let Err(e) = unload_status {
eprintln!("Warning: Failed to unload service: {}", e);
}
}
fs::remove_file(&plist_path).context("Failed to remove plist file")?;
Ok(true)
}
#[cfg(target_os = "macos")]
fn uninstall_service(system: bool, purge: bool, keep_data: bool) -> Result<()> {
check_no_system_flag(system)?;
stop_and_remove_service(system)?;
println!("Freenet service uninstalled.");
if should_purge(purge, keep_data)? {
purge_data_dirs(false)?;
println!("All Freenet data, config, and logs removed.");
}
Ok(())
}
#[cfg(target_os = "macos")]
fn service_status(system: bool) -> Result<()> {
check_no_system_flag(system)?;
let output = std::process::Command::new("launchctl")
.args(["list", "org.freenet.node"])
.output()
.context("Failed to check service status")?;
if output.status.success() {
println!("Freenet service is running.");
if !output.stdout.is_empty() {
println!("{}", String::from_utf8_lossy(&output.stdout));
}
} else {
println!("Freenet service is not running.");
std::process::exit(3); }
Ok(())
}
#[cfg(target_os = "macos")]
fn start_service(system: bool) -> Result<()> {
check_no_system_flag(system)?;
let plist_path = dirs::home_dir()
.context("Failed to get home directory")?
.join("Library/LaunchAgents/org.freenet.node.plist");
if !plist_path.exists() {
anyhow::bail!("Service not installed. Run 'freenet service install' first.");
}
let plist_path_str = plist_path
.to_str()
.context("Plist path contains invalid UTF-8")?;
let status = std::process::Command::new("launchctl")
.args(["load", plist_path_str])
.status()
.context("Failed to start service")?;
if status.success() {
println!("Freenet service started.");
println!("Open http://127.0.0.1:7509/ in your browser to view your Freenet dashboard.");
} else {
anyhow::bail!("Failed to start service");
}
Ok(())
}
#[cfg(target_os = "macos")]
fn stop_service(system: bool) -> Result<()> {
check_no_system_flag(system)?;
let plist_path = dirs::home_dir()
.context("Failed to get home directory")?
.join("Library/LaunchAgents/org.freenet.node.plist");
let plist_path_str = plist_path
.to_str()
.context("Plist path contains invalid UTF-8")?;
let status = std::process::Command::new("launchctl")
.args(["unload", plist_path_str])
.status()
.context("Failed to stop service")?;
if status.success() {
println!("Freenet service stopped.");
} else {
anyhow::bail!("Failed to stop service");
}
Ok(())
}
#[cfg(target_os = "macos")]
fn restart_service(system: bool) -> Result<()> {
check_no_system_flag(system)?;
stop_service(false)?;
start_service(false)
}
#[cfg(target_os = "macos")]
fn service_logs(error_only: bool) -> Result<()> {
let log_dir = dirs::home_dir()
.context("Failed to get home directory")?
.join("Library/Logs/freenet");
let base_name = if error_only {
"freenet.error"
} else {
"freenet"
};
tail_with_rotation(&log_dir, base_name)
}
#[cfg(target_os = "windows")]
fn install_service(system: bool) -> Result<()> {
if system {
anyhow::bail!(
"The --system flag is only supported on Linux.\n\
On Windows, use the default scheduled task: freenet service install"
);
}
let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
let exe_path_str = exe_path
.to_str()
.context("Executable path contains invalid UTF-8")?;
let run_command = format!("\"{}\" service run-wrapper", exe_path_str);
let hkcu = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER);
let (run_key, _) = hkcu
.create_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run")
.context("Failed to open registry Run key")?;
run_key
.set_value("Freenet", &run_command)
.context("Failed to write Freenet registry entry")?;
drop(
std::process::Command::new("schtasks")
.args(["/delete", "/tn", "Freenet", "/f"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status(),
);
println!("Freenet autostart registered successfully.");
println!();
println!("To start Freenet now:");
println!(" freenet service start");
println!();
println!("Freenet will start automatically when you log in.");
println!("A system tray icon will appear with status and controls.");
Ok(())
}
#[cfg(target_os = "windows")]
fn check_no_system_flag_windows(system: bool) -> Result<()> {
if system {
anyhow::bail!(
"The --system flag is only supported on Linux.\n\
On Windows, use the default service commands without --system."
);
}
Ok(())
}
#[cfg(target_os = "windows")]
pub fn stop_and_remove_service(_system: bool) -> Result<bool> {
let hkcu = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER);
let run_key = hkcu
.open_subkey_with_flags(
r"Software\Microsoft\Windows\CurrentVersion\Run",
winreg::enums::KEY_READ | winreg::enums::KEY_WRITE,
)
.context("Failed to open registry Run key")?;
let had_registry = run_key.delete_value("Freenet").is_ok();
let our_pid = std::process::id().to_string();
drop(
std::process::Command::new("taskkill")
.args([
"/f",
"/im",
"freenet.exe",
"/fi",
&format!("PID ne {}", our_pid),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status(),
);
let had_task = std::process::Command::new("schtasks")
.args(["/query", "/tn", "Freenet"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if had_task {
drop(
std::process::Command::new("schtasks")
.args(["/delete", "/tn", "Freenet", "/f"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status(),
);
}
Ok(had_registry || had_task)
}
#[cfg(target_os = "windows")]
fn uninstall_service(system: bool, purge: bool, keep_data: bool) -> Result<()> {
check_no_system_flag_windows(system)?;
stop_and_remove_service(system)?;
println!("Freenet autostart uninstalled.");
if should_purge(purge, keep_data)? {
purge_data_dirs(false)?;
println!("All Freenet data, config, and logs removed.");
}
Ok(())
}
#[cfg(target_os = "windows")]
fn service_status(system: bool) -> Result<()> {
check_no_system_flag_windows(system)?;
let hkcu = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER);
let registered = hkcu
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run")
.ok()
.and_then(|k| k.get_value::<String, _>("Freenet").ok())
.is_some();
if registered {
println!("Freenet autostart is registered.");
let running = std::process::Command::new("tasklist")
.args(["/fi", "imagename eq freenet.exe", "/fo", "csv", "/nh"])
.output()
.map(|o| {
let stdout = String::from_utf8_lossy(&o.stdout);
stdout.contains("freenet.exe")
})
.unwrap_or(false);
if running {
println!("Freenet is currently running.");
} else {
println!("Freenet is not currently running.");
}
} else {
println!("Freenet autostart is not registered.");
std::process::exit(3);
}
Ok(())
}
#[cfg(target_os = "windows")]
fn start_service(system: bool) -> Result<()> {
check_no_system_flag_windows(system)?;
let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
std::process::Command::new(&exe_path)
.args(["service", "run-wrapper"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn()
.context("Failed to start Freenet")?;
println!("Freenet started.");
println!("Open http://127.0.0.1:7509/ in your browser to view your Freenet dashboard.");
Ok(())
}
#[cfg(target_os = "windows")]
fn stop_service(system: bool) -> Result<()> {
check_no_system_flag_windows(system)?;
let our_pid = std::process::id().to_string();
let status = std::process::Command::new("taskkill")
.args([
"/f",
"/im",
"freenet.exe",
"/fi",
&format!("PID ne {}", our_pid),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("Failed to stop Freenet")?;
if status.success() {
println!("Freenet stopped.");
} else {
anyhow::bail!("Failed to stop Freenet. It may not be running.");
}
Ok(())
}
#[cfg(target_os = "windows")]
fn restart_service(system: bool) -> Result<()> {
check_no_system_flag_windows(system)?;
drop(stop_service(false));
std::thread::sleep(std::time::Duration::from_secs(2));
start_service(false)
}
#[cfg(target_os = "windows")]
fn service_logs(error_only: bool) -> Result<()> {
use freenet::tracing::tracer::get_log_dir;
use std::time::Duration;
let log_dir = get_log_dir().context(
"Could not determine log directory. \
Ensure Freenet has been run at least once via 'freenet service run-wrapper'.",
)?;
let base_name = if error_only {
"freenet.error"
} else {
"freenet"
};
let wrapper_log = find_latest_log_file(&log_dir, "freenet-wrapper");
let mut current_log = match find_latest_log_file(&log_dir, base_name) {
Some(log_path) => {
println!("Log file: {}", log_path.display());
if let Some(ref wl) = wrapper_log {
println!("Wrapper log: {}", wl.display());
}
log_path
}
None => {
if let Some(ref wl) = wrapper_log {
println!("No node logs found, showing wrapper log:");
let status = std::process::Command::new("powershell")
.args([
"-Command",
&format!("Get-Content -Path '{}' -Tail 50 -Wait", wl.display()),
])
.status()
.context("Failed to open wrapper log")?;
std::process::exit(status.code().unwrap_or(1));
} else {
anyhow::bail!(
"No log files found in {}.\n\
Ensure Freenet has been run at least once.",
log_dir.display()
);
}
}
};
println!("Press Ctrl+C to stop.\n");
loop {
let mut child = std::process::Command::new("powershell")
.args([
"-Command",
&format!(
"Get-Content -Path '{}' -Tail 50 -Wait",
current_log.display()
),
])
.spawn()
.context("Failed to spawn PowerShell for log tailing")?;
loop {
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
drop(
std::process::Command::new("notepad")
.arg(¤t_log)
.spawn(),
);
}
std::process::exit(status.code().unwrap_or(1));
}
Ok(None) => {}
Err(e) => {
drop(child.kill());
drop(child.wait());
anyhow::bail!("Error waiting on PowerShell process: {e}");
}
}
std::thread::sleep(Duration::from_secs(5));
if let Some(newer_log) = find_latest_log_file(&log_dir, base_name) {
if newer_log != current_log {
println!("\n--- Log rotated to: {} ---\n", newer_log.display());
drop(child.kill());
drop(child.wait());
current_log = newer_log;
break;
}
}
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn install_service(_system: bool) -> Result<()> {
anyhow::bail!("Service installation is not supported on this platform")
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
pub fn stop_and_remove_service(_system: bool) -> Result<bool> {
Ok(false)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn uninstall_service(_system: bool, _purge: bool, _keep_data: bool) -> Result<()> {
anyhow::bail!("Service management is not supported on this platform")
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn service_status(_system: bool) -> Result<()> {
anyhow::bail!("Service management is not supported on this platform")
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn start_service(_system: bool) -> Result<()> {
anyhow::bail!("Service management is not supported on this platform")
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn stop_service(_system: bool) -> Result<()> {
anyhow::bail!("Service management is not supported on this platform")
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn restart_service(_system: bool) -> Result<()> {
anyhow::bail!("Service management is not supported on this platform")
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn service_logs(_error_only: bool) -> Result<()> {
anyhow::bail!("Service management is not supported on this platform")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
#[cfg(target_os = "linux")]
fn test_systemd_user_service_file_generation() {
let binary_path = PathBuf::from("/usr/local/bin/freenet");
let log_dir = PathBuf::from("/home/test/.local/state/freenet");
let service_content = generate_user_service_file(&binary_path, &log_dir);
assert!(service_content.contains("[Unit]"));
assert!(service_content.contains("[Service]"));
assert!(service_content.contains("[Install]"));
assert!(service_content.contains("/usr/local/bin/freenet network"));
assert!(service_content.contains("/home/test/.local/state/freenet/freenet.log"));
assert!(service_content.contains("/home/test/.local/state/freenet/freenet.error.log"));
assert!(service_content.contains("LimitNOFILE=65536"));
assert!(service_content.contains("MemoryMax=2G"));
assert!(service_content.contains("CPUQuota=200%"));
assert!(service_content.contains("Restart=always"));
assert!(service_content.contains("RestartSec=10"));
assert!(service_content.contains("StartLimitBurst=5"));
assert!(service_content.contains("StartLimitIntervalSec=120"));
assert!(service_content.contains("ExecStopPost="));
assert!(service_content.contains("SuccessExitStatus=42 43"));
assert!(service_content.contains("RestartPreventExitStatus=43"));
assert!(service_content.contains("TimeoutStopSec=15"));
assert!(service_content.contains("WantedBy=default.target"));
assert!(!service_content.contains("User="));
}
#[test]
#[cfg(target_os = "linux")]
fn test_systemd_system_service_file_generation() {
let binary_path = PathBuf::from("/home/test/.local/bin/freenet");
let log_dir = PathBuf::from("/home/test/.local/state/freenet");
let username = "testuser";
let home_dir = PathBuf::from("/home/test");
let service_content =
generate_system_service_file(&binary_path, &log_dir, username, &home_dir);
assert!(service_content.contains("[Unit]"));
assert!(service_content.contains("[Service]"));
assert!(service_content.contains("[Install]"));
assert!(service_content.contains("User=testuser"));
assert!(service_content.contains("Environment=HOME=/home/test"));
assert!(service_content.contains("WantedBy=multi-user.target"));
assert!(service_content.contains("Restart=always"));
assert!(service_content.contains("StartLimitBurst=5"));
assert!(service_content.contains("StartLimitIntervalSec=120"));
assert!(service_content.contains("LimitNOFILE=65536"));
assert!(service_content.contains("ExecStopPost="));
assert!(service_content.contains("SuccessExitStatus=42 43"));
assert!(service_content.contains("RestartPreventExitStatus=43"));
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos_wrapper_script_generation() {
let binary_path = PathBuf::from("/usr/local/bin/freenet");
let script = generate_wrapper_script(&binary_path);
assert!(
script.contains("pkill -f -u \"$(id -u)\" \"freenet network\""),
"wrapper must kill stale processes on startup, scoped to current user"
);
assert!(
script.contains("sleep 2"),
"wrapper must wait after startup kill to let the OS release the port"
);
assert!(
script.contains("freenet.error.log.last"),
"wrapper must capture stderr to a scratch file for port-conflict detection"
);
assert!(
script.contains("already in use"),
"wrapper must detect port-already-in-use errors from stderr"
);
assert!(
script.contains("PORT_CONFLICT_KILLS"),
"wrapper must track port-conflict kill attempts"
);
assert!(
script.contains("MAX_PORT_CONFLICT_KILLS"),
"wrapper must cap port-conflict kill attempts to prevent infinite loops"
);
assert!(
script.contains("\"/usr/local/bin/freenet\" network"),
"wrapper must invoke the correct binary"
);
assert!(
script.contains("exit 0"),
"wrapper must exit cleanly on normal shutdown"
);
assert!(
script.contains("EXIT_CODE -eq 42"),
"wrapper must handle exit code 42 for auto-update"
);
}
#[test]
#[cfg(target_os = "macos")]
fn test_launchd_plist_generation() {
let binary_path = PathBuf::from("/usr/local/bin/freenet");
let log_dir = PathBuf::from("/Users/test/Library/Logs/freenet");
let plist_content = generate_plist(&binary_path, &log_dir);
assert!(plist_content.contains("<?xml version=\"1.0\""));
assert!(plist_content.contains("<plist version=\"1.0\">"));
assert!(plist_content.contains("</plist>"));
assert!(plist_content.contains("<string>org.freenet.node</string>"));
assert!(plist_content.contains("/usr/local/bin/freenet"));
assert!(plist_content.contains("/Users/test/Library/Logs/freenet/freenet.log"));
assert!(plist_content.contains("/Users/test/Library/Logs/freenet/freenet.error.log"));
assert!(plist_content.contains("<key>NumberOfFiles</key>"));
assert!(plist_content.contains("<integer>65536</integer>"));
assert!(plist_content.contains("<key>RunAtLoad</key>"));
assert!(plist_content.contains("<true/>"));
}
#[test]
fn test_should_purge_with_purge_flag() {
assert!(should_purge(true, false).unwrap());
}
#[test]
fn test_should_purge_with_keep_data_flag() {
assert!(!should_purge(false, true).unwrap());
}
#[test]
fn test_should_purge_no_flags_non_tty() {
assert!(!should_purge(false, false).unwrap());
}
#[test]
fn test_wrapper_exit_42_update_success_resets_state() {
let mut state = WrapperState::new();
state.consecutive_failures = 3;
state.backoff_secs = 80;
let action = next_wrapper_action(&mut state, 42, false, Some(true));
assert_eq!(action, WrapperAction::Update);
assert_eq!(state.consecutive_failures, 0);
assert_eq!(state.backoff_secs, WRAPPER_INITIAL_BACKOFF_SECS);
assert_eq!(state.port_conflict_kills, 0);
}
#[test]
fn test_wrapper_exit_42_update_failure_backs_off() {
let mut state = WrapperState::new();
assert_eq!(state.backoff_secs, 10);
let action = next_wrapper_action(&mut state, 42, false, Some(false));
assert_eq!(action, WrapperAction::BackoffAndRelaunch { secs: 10 });
assert_eq!(state.consecutive_failures, 1);
assert_eq!(state.backoff_secs, 20);
let action = next_wrapper_action(&mut state, 42, false, Some(false));
assert_eq!(action, WrapperAction::BackoffAndRelaunch { secs: 20 });
assert_eq!(state.backoff_secs, 40);
}
#[test]
fn test_wrapper_exit_43_exits_immediately() {
let mut state = WrapperState::new();
let action = next_wrapper_action(&mut state, 43, false, None);
assert_eq!(action, WrapperAction::Exit);
}
#[test]
fn test_wrapper_exit_0_exits_cleanly() {
let mut state = WrapperState::new();
let action = next_wrapper_action(&mut state, 0, false, None);
assert_eq!(action, WrapperAction::Exit);
}
#[test]
fn test_wrapper_crash_exponential_backoff_with_cap() {
let mut state = WrapperState::new();
let action = next_wrapper_action(&mut state, 1, false, None);
assert_eq!(action, WrapperAction::BackoffAndRelaunch { secs: 10 });
assert_eq!(state.backoff_secs, 20);
let action = next_wrapper_action(&mut state, 1, false, None);
assert_eq!(action, WrapperAction::BackoffAndRelaunch { secs: 20 });
assert_eq!(state.backoff_secs, 40);
let action = next_wrapper_action(&mut state, 1, false, None);
assert_eq!(action, WrapperAction::BackoffAndRelaunch { secs: 40 });
assert_eq!(state.backoff_secs, 80);
let action = next_wrapper_action(&mut state, 1, false, None);
assert_eq!(action, WrapperAction::BackoffAndRelaunch { secs: 80 });
assert_eq!(state.backoff_secs, 160);
let action = next_wrapper_action(&mut state, 1, false, None);
assert_eq!(action, WrapperAction::BackoffAndRelaunch { secs: 160 });
assert_eq!(state.backoff_secs, 300);
let action = next_wrapper_action(&mut state, 1, false, None);
assert_eq!(action, WrapperAction::BackoffAndRelaunch { secs: 300 });
assert_eq!(state.backoff_secs, 300);
assert_eq!(state.consecutive_failures, 6);
}
#[test]
fn test_wrapper_port_conflict_kill_and_retry() {
let mut state = WrapperState::new();
let action = next_wrapper_action(&mut state, 1, true, None);
assert_eq!(action, WrapperAction::KillAndRetry);
assert_eq!(state.port_conflict_kills, 1);
assert_eq!(state.backoff_secs, WRAPPER_INITIAL_BACKOFF_SECS);
let action = next_wrapper_action(&mut state, 1, true, None);
assert_eq!(action, WrapperAction::KillAndRetry);
assert_eq!(state.port_conflict_kills, 2);
let action = next_wrapper_action(&mut state, 1, true, None);
assert_eq!(action, WrapperAction::KillAndRetry);
assert_eq!(state.port_conflict_kills, 3);
let action = next_wrapper_action(&mut state, 1, true, None);
assert_eq!(action, WrapperAction::BackoffAndRelaunch { secs: 10 });
assert_eq!(state.port_conflict_kills, 0); assert_eq!(state.consecutive_failures, 1);
}
#[test]
fn test_wrapper_port_conflict_resets_backoff() {
let mut state = WrapperState::new();
state.backoff_secs = 160;
let action = next_wrapper_action(&mut state, 1, true, None);
assert_eq!(action, WrapperAction::KillAndRetry);
assert_eq!(state.backoff_secs, WRAPPER_INITIAL_BACKOFF_SECS);
}
#[test]
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn test_backoff_sleep_handles_tray_actions() {
use super::super::tray::TrayAction;
use std::sync::mpsc;
let send_and_check = |action: TrayAction| -> BackoffInterrupt {
let (tx, rx) = mpsc::channel();
tx.send(action).unwrap();
sleep_with_jitter_interruptible(1, Some(&rx))
};
assert!(matches!(
send_and_check(TrayAction::Start),
BackoffInterrupt::Relaunch
));
assert!(matches!(
send_and_check(TrayAction::Restart),
BackoffInterrupt::Relaunch
));
assert!(matches!(
send_and_check(TrayAction::CheckUpdate),
BackoffInterrupt::CheckUpdate
));
assert!(matches!(
send_and_check(TrayAction::Quit),
BackoffInterrupt::Quit
));
let (_tx, rx) = mpsc::channel::<TrayAction>();
assert!(matches!(
sleep_with_jitter_interruptible(1, Some(&rx)),
BackoffInterrupt::Completed
));
}
#[test]
fn test_config_paths_build_succeeds_without_network() {
let tmp = tempfile::tempdir().unwrap();
let args = freenet::config::ConfigPathsArgs {
config_dir: Some(tmp.path().join("config")),
data_dir: Some(tmp.path().join("data")),
..Default::default()
};
let result = args.build(None);
assert!(
result.is_ok(),
"ConfigPathsArgs::build should succeed without network access"
);
}
#[test]
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn test_network_ready_quit_during_wait() {
use std::sync::mpsc;
let tmp = tempfile::tempdir().unwrap();
let (tx, rx) = mpsc::channel::<super::super::tray::TrayAction>();
tx.send(super::super::tray::TrayAction::Quit).unwrap();
let result = wait_for_network_ready_inner(tmp.path(), "nonexistent.invalid:1", Some(&rx));
assert!(
!result,
"Should return false when Quit is received during wait"
);
}
#[test]
fn test_find_latest_log_file_picks_newest() {
use std::fs;
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("freenet.2025-01-01.log");
let new = tmp.path().join("freenet.2025-12-31.log");
let unrelated = tmp.path().join("other.log");
fs::write(&old, "old").unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
let mut f = fs::File::create(&new).unwrap();
f.write_all(b"new").unwrap();
fs::write(&unrelated, "unrelated").unwrap();
let result = find_latest_log_file(tmp.path(), "freenet");
assert_eq!(result, Some(new));
}
#[test]
fn test_find_latest_log_file_skips_empty_static_file() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("freenet.log"), "").unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
let rotated = tmp.path().join("freenet.2025-12-31.log");
fs::write(&rotated, "content").unwrap();
let result = find_latest_log_file(tmp.path(), "freenet");
assert_eq!(result, Some(rotated));
}
#[test]
fn test_find_latest_log_file_no_matching_files() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(find_latest_log_file(tmp.path(), "freenet"), None);
std::fs::write(tmp.path().join("other.log"), "data").unwrap();
assert_eq!(find_latest_log_file(tmp.path(), "freenet"), None);
}
#[test]
fn test_find_latest_log_file_static_wins_over_older_rotated() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let rotated = tmp.path().join("freenet.2025-01-01.log");
fs::write(&rotated, "old").unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
let static_file = tmp.path().join("freenet.log");
fs::write(&static_file, "newer").unwrap();
let result = find_latest_log_file(tmp.path(), "freenet");
assert_eq!(result, Some(static_file));
}
#[test]
fn test_find_latest_log_file_detects_rotation() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let hour14 = tmp.path().join("freenet.2025-12-31-14.log");
fs::write(&hour14, "hour 14 data").unwrap();
let result = find_latest_log_file(tmp.path(), "freenet");
assert_eq!(result, Some(hour14.clone()));
std::thread::sleep(std::time::Duration::from_millis(50));
let hour15 = tmp.path().join("freenet.2025-12-31-15.log");
fs::write(&hour15, "hour 15 data").unwrap();
let result = find_latest_log_file(tmp.path(), "freenet");
assert_eq!(result, Some(hour15));
}
}