#[allow(unused_imports)]
use std::sync::mpsc;
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)]
pub enum TrayAction {
OpenDashboard,
Restart,
Stop,
Start,
CheckUpdate,
ViewLogs,
Quit,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum WrapperStatus {
Running,
Updating,
Stopped,
UpToDate,
UpdatedRestarting,
}
const MENU_ID_OPEN_DASHBOARD: &str = "freenet.tray.open_dashboard";
#[allow(dead_code)]
const MENU_ID_STATUS: &str = "freenet.tray.status";
#[allow(dead_code)]
const MENU_ID_VERSION: &str = "freenet.tray.version";
const MENU_ID_STOP: &str = "freenet.tray.stop";
const MENU_ID_START: &str = "freenet.tray.start";
const MENU_ID_RESTART: &str = "freenet.tray.restart";
const MENU_ID_CHECK_UPDATE: &str = "freenet.tray.check_update";
const MENU_ID_VIEW_LOGS: &str = "freenet.tray.view_logs";
const MENU_ID_LAUNCH_AT_LOGIN: &str = "freenet.tray.launch_at_login";
const MENU_ID_QUIT: &str = "freenet.tray.quit";
#[derive(Debug, PartialEq, Eq)]
#[allow(dead_code)]
enum MenuDispatch {
OpenDashboard,
ToggleLaunchAtLogin,
Action(TrayAction),
Quit,
Unknown,
}
#[allow(dead_code)]
fn dispatch_menu_event(event_id: &str) -> MenuDispatch {
match event_id {
MENU_ID_OPEN_DASHBOARD => MenuDispatch::OpenDashboard,
MENU_ID_STOP => MenuDispatch::Action(TrayAction::Stop),
MENU_ID_START => MenuDispatch::Action(TrayAction::Start),
MENU_ID_RESTART => MenuDispatch::Action(TrayAction::Restart),
MENU_ID_CHECK_UPDATE => MenuDispatch::Action(TrayAction::CheckUpdate),
MENU_ID_VIEW_LOGS => MenuDispatch::Action(TrayAction::ViewLogs),
MENU_ID_LAUNCH_AT_LOGIN => MenuDispatch::ToggleLaunchAtLogin,
MENU_ID_QUIT => MenuDispatch::Quit,
_ => MenuDispatch::Unknown,
}
}
#[derive(Debug, PartialEq, Eq)]
#[allow(dead_code)]
struct MenuState {
status_text: &'static str,
stop_enabled: bool,
start_enabled: bool,
restart_enabled: bool,
is_terminal: bool,
}
#[allow(dead_code)]
fn compute_menu_state(status: &WrapperStatus) -> MenuState {
let status_text = match status {
WrapperStatus::Running => "Running",
WrapperStatus::Updating => "Checking for updates...",
WrapperStatus::Stopped => "Stopped",
WrapperStatus::UpToDate => "Up to date",
WrapperStatus::UpdatedRestarting => "Updated! Restarting...",
};
let is_running = matches!(
status,
WrapperStatus::Running | WrapperStatus::UpToDate | WrapperStatus::Updating
);
MenuState {
status_text,
stop_enabled: is_running,
start_enabled: !is_running,
restart_enabled: is_running,
is_terminal: matches!(status, WrapperStatus::UpdatedRestarting),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dispatch_maps_known_menu_items_to_their_actions() {
assert_eq!(
dispatch_menu_event(MENU_ID_OPEN_DASHBOARD),
MenuDispatch::OpenDashboard
);
assert_eq!(
dispatch_menu_event(MENU_ID_STOP),
MenuDispatch::Action(TrayAction::Stop)
);
assert_eq!(
dispatch_menu_event(MENU_ID_START),
MenuDispatch::Action(TrayAction::Start)
);
assert_eq!(
dispatch_menu_event(MENU_ID_RESTART),
MenuDispatch::Action(TrayAction::Restart)
);
assert_eq!(
dispatch_menu_event(MENU_ID_CHECK_UPDATE),
MenuDispatch::Action(TrayAction::CheckUpdate)
);
assert_eq!(
dispatch_menu_event(MENU_ID_VIEW_LOGS),
MenuDispatch::Action(TrayAction::ViewLogs)
);
assert_eq!(
dispatch_menu_event(MENU_ID_LAUNCH_AT_LOGIN),
MenuDispatch::ToggleLaunchAtLogin
);
assert_eq!(dispatch_menu_event(MENU_ID_QUIT), MenuDispatch::Quit);
}
#[test]
fn dispatch_returns_unknown_for_foreign_ids() {
assert_eq!(dispatch_menu_event(""), MenuDispatch::Unknown);
assert_eq!(dispatch_menu_event("stop"), MenuDispatch::Unknown);
assert_eq!(
dispatch_menu_event("freenet.tray.nonexistent"),
MenuDispatch::Unknown
);
}
#[test]
fn menu_state_enables_stop_while_running() {
for status in [
WrapperStatus::Running,
WrapperStatus::UpToDate,
WrapperStatus::Updating,
] {
let s = compute_menu_state(&status);
assert!(s.stop_enabled, "Stop should be enabled for {status:?}");
assert!(!s.start_enabled, "Start should be disabled for {status:?}");
assert!(
s.restart_enabled,
"Restart should be enabled for {status:?}"
);
assert!(!s.is_terminal, "{status:?} is not terminal");
}
}
#[test]
fn menu_state_enables_start_while_stopped() {
let s = compute_menu_state(&WrapperStatus::Stopped);
assert!(!s.stop_enabled);
assert!(s.start_enabled);
assert!(!s.restart_enabled);
assert!(!s.is_terminal);
assert_eq!(s.status_text, "Stopped");
}
#[test]
fn menu_state_flags_terminal_on_updated_restarting() {
let s = compute_menu_state(&WrapperStatus::UpdatedRestarting);
assert!(s.is_terminal);
assert_eq!(s.status_text, "Updated! Restarting...");
}
#[test]
fn menu_state_status_texts_are_distinct() {
let texts: Vec<&'static str> = [
WrapperStatus::Running,
WrapperStatus::Updating,
WrapperStatus::Stopped,
WrapperStatus::UpToDate,
WrapperStatus::UpdatedRestarting,
]
.iter()
.map(|s| compute_menu_state(s).status_text)
.collect();
let mut sorted = texts.clone();
sorted.sort();
sorted.dedup();
assert_eq!(
texts.len(),
sorted.len(),
"each WrapperStatus should map to a distinct status_text"
);
}
#[test]
fn open_log_file_spawn_must_null_all_three_standard_handles() {
let src = include_str!("tray.rs");
let (_, after_fn_start) = src
.split_once("pub fn open_log_file() {")
.expect("open_log_file definition not found");
let body = after_fn_start
.split_once("\n#[cfg(test)]")
.map(|(b, _)| b)
.unwrap_or(after_fn_start);
for handle in ["stdin", "stdout", "stderr"] {
let pattern = format!(".{handle}(std::process::Stdio::null())");
assert!(
body.contains(&pattern),
"open_log_file must call `{}` — without it, Windows \
autostart fails `Command::spawn()` with os error 6 \
(handle invalid after FreeConsole), and View Logs \
silently does nothing. See #3933.",
pattern
);
}
}
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
mod platform {
use super::*;
use muda::{CheckMenuItem, Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem};
use std::sync::mpsc as std_mpsc;
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
const DASHBOARD_URL: &str = super::super::service::DASHBOARD_URL;
fn build_icon() -> Result<Icon, tray_icon::BadIcon> {
let rgba = include_bytes!("assets/freenet_256x256.rgba").to_vec();
Icon::from_rgba(rgba, 256, 256)
}
fn open_url(url: &str) {
super::super::open_url_in_browser(url);
}
fn menu_item(id: &'static str, text: &str, enabled: bool) -> MenuItem {
MenuItem::with_id(MenuId::new(id), text, enabled, None)
}
struct TrayState {
_tray: TrayIcon,
version: String,
status_item: MenuItem,
stop_item: MenuItem,
start_item: MenuItem,
restart_item: MenuItem,
#[cfg(target_os = "macos")]
launch_at_login_item: CheckMenuItem,
}
impl TrayState {
fn new(version: String) -> Result<Self, String> {
let menu = Menu::new();
let app_header = menu_item(MENU_ID_VERSION, &format!("Freenet {version}"), false);
let open_dashboard = menu_item(MENU_ID_OPEN_DASHBOARD, "Open Dashboard", true);
let status_item = menu_item(MENU_ID_STATUS, "Status: Starting...", false);
let stop_item = menu_item(MENU_ID_STOP, "Stop", true);
let start_item = menu_item(MENU_ID_START, "Start", false);
let restart_item = menu_item(MENU_ID_RESTART, "Restart", true);
let check_update = menu_item(MENU_ID_CHECK_UPDATE, "Check for Updates", true);
let view_logs = menu_item(MENU_ID_VIEW_LOGS, "View Logs", true);
#[cfg(target_os = "macos")]
let launch_at_login_item = CheckMenuItem::with_id(
MenuId::new(MENU_ID_LAUNCH_AT_LOGIN),
"Launch at Login",
true,
super::super::service::is_launch_at_login_enabled(),
None,
);
let quit_item = menu_item(MENU_ID_QUIT, "Quit", true);
menu.append(&app_header).ok();
menu.append(&PredefinedMenuItem::separator()).ok();
menu.append(&open_dashboard).ok();
menu.append(&PredefinedMenuItem::separator()).ok();
menu.append(&status_item).ok();
menu.append(&PredefinedMenuItem::separator()).ok();
menu.append(&stop_item).ok();
menu.append(&start_item).ok();
menu.append(&restart_item).ok();
menu.append(&check_update).ok();
menu.append(&view_logs).ok();
#[cfg(target_os = "macos")]
menu.append(&launch_at_login_item).ok();
menu.append(&PredefinedMenuItem::separator()).ok();
menu.append(&quit_item).ok();
let icon = build_icon().map_err(|e| format!("Failed to build tray icon: {e}"))?;
let tray = TrayIconBuilder::new()
.with_menu(Box::new(menu))
.with_tooltip(format!("Freenet {version} - Starting..."))
.with_icon(icon)
.build()
.map_err(|e| format!("Failed to create tray icon: {e}"))?;
Ok(Self {
_tray: tray,
version,
status_item,
stop_item,
start_item,
restart_item,
#[cfg(target_os = "macos")]
launch_at_login_item,
})
}
#[cfg(target_os = "macos")]
fn refresh_launch_at_login_state(&self) {
self.launch_at_login_item
.set_checked(super::super::service::is_launch_at_login_enabled());
}
fn apply_status(&self, status: &WrapperStatus) -> bool {
let s = compute_menu_state(status);
self.status_item
.set_text(format!("Status: {}", s.status_text));
self._tray
.set_tooltip(Some(format!(
"Freenet {} - {}",
self.version, s.status_text
)))
.ok();
self.stop_item.set_enabled(s.stop_enabled);
self.start_item.set_enabled(s.start_enabled);
self.restart_item.set_enabled(s.restart_enabled);
s.is_terminal
}
}
#[cfg(target_os = "windows")]
fn pump_win32_messages() -> bool {
use winapi::um::winuser::{
DispatchMessageW, PM_REMOVE, PeekMessageW, TranslateMessage, WM_QUIT,
};
unsafe {
let mut msg = std::mem::zeroed();
while PeekMessageW(&mut msg, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0 {
if msg.message == WM_QUIT {
return true;
}
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
false
}
#[cfg(target_os = "windows")]
fn run_event_loop(
state: TrayState,
action_tx: std_mpsc::Sender<TrayAction>,
status_rx: std_mpsc::Receiver<WrapperStatus>,
_cleanup_done_rx: std_mpsc::Receiver<()>,
) {
let menu_rx = MenuEvent::receiver();
loop {
if pump_win32_messages() {
action_tx.send(TrayAction::Quit).ok();
break;
}
if let Ok(event) = menu_rx.try_recv() {
match dispatch_menu_event(event.id.as_ref()) {
MenuDispatch::OpenDashboard => open_url(DASHBOARD_URL),
MenuDispatch::ToggleLaunchAtLogin => {
}
MenuDispatch::Action(action) => {
action_tx.send(action).ok();
}
MenuDispatch::Quit => {
action_tx.send(TrayAction::Quit).ok();
break;
}
MenuDispatch::Unknown => {}
}
}
if let Ok(status) = status_rx.try_recv() {
let is_terminal = state.apply_status(&status);
if is_terminal {
std::thread::sleep(std::time::Duration::from_secs(1));
action_tx.send(TrayAction::Quit).ok();
break;
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
#[cfg(target_os = "macos")]
fn run_event_loop(
state: TrayState,
action_tx: std_mpsc::Sender<TrayAction>,
status_rx: std_mpsc::Receiver<WrapperStatus>,
cleanup_done_rx: std_mpsc::Receiver<()>,
) {
use std::time::{Duration, Instant};
use tao::event::Event;
use tao::event_loop::{ControlFlow, EventLoopBuilder};
let event_loop = EventLoopBuilder::<WrapperStatus>::with_user_event().build();
let proxy = event_loop.create_proxy();
std::thread::spawn(move || {
while let Ok(status) = status_rx.recv() {
let is_terminal = matches!(&status, WrapperStatus::UpdatedRestarting);
if proxy.send_event(status).is_err() {
break;
}
if is_terminal {
break;
}
}
});
let menu_rx = MenuEvent::receiver();
event_loop.run(move |event, _window, control_flow| {
*control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(100));
while let Ok(menu_event) = menu_rx.try_recv() {
match dispatch_menu_event(menu_event.id.as_ref()) {
MenuDispatch::OpenDashboard => open_url(DASHBOARD_URL),
MenuDispatch::ToggleLaunchAtLogin => {
let outcome = super::super::service::toggle_launch_at_login_outcome(
super::super::service::is_launch_at_login_enabled(),
);
let result = match outcome {
super::super::service::ToggleLaunchAtLoginOutcome::Enable => {
match std::env::current_exe() {
Ok(p) => super::super::service::enable_launch_at_login(&p),
Err(e) => Err(e),
}
}
super::super::service::ToggleLaunchAtLoginOutcome::Disable => {
super::super::service::disable_launch_at_login()
}
};
if let Err(e) = result {
tracing::warn!("Failed to toggle Launch at Login: {}", e);
}
state.refresh_launch_at_login_state();
}
MenuDispatch::Action(action) => {
action_tx.send(action).ok();
}
MenuDispatch::Quit => {
action_tx.send(TrayAction::Quit).ok();
cleanup_done_rx.recv().ok();
*control_flow = ControlFlow::Exit;
return;
}
MenuDispatch::Unknown => {}
}
}
if let Event::UserEvent(status) = event {
let is_terminal = state.apply_status(&status);
if is_terminal {
std::thread::sleep(Duration::from_secs(1));
action_tx.send(TrayAction::Quit).ok();
cleanup_done_rx.recv().ok();
*control_flow = ControlFlow::Exit;
}
}
});
}
pub fn run_tray_event_loop(
action_tx: std_mpsc::Sender<TrayAction>,
status_rx: std_mpsc::Receiver<WrapperStatus>,
cleanup_done_rx: std_mpsc::Receiver<()>,
version: &str,
) {
let state = match TrayState::new(version.to_string()) {
Ok(s) => s,
Err(e) => {
eprintln!("{e}. Running without tray.");
return;
}
};
run_event_loop(state, action_tx, status_rx, cleanup_done_rx);
}
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
mod platform {
use super::*;
use std::sync::mpsc as std_mpsc;
pub fn run_tray_event_loop(
_action_tx: std_mpsc::Sender<TrayAction>,
_status_rx: std_mpsc::Receiver<WrapperStatus>,
_cleanup_done_rx: std_mpsc::Receiver<()>,
_version: &str,
) {
}
}
#[allow(unused_imports, dead_code)]
pub use platform::run_tray_event_loop;
#[allow(dead_code)]
pub fn open_log_file() {
use freenet::tracing::tracer::get_log_dir;
let Some(log_dir) = get_log_dir() else {
eprintln!("Could not determine log directory");
return;
};
let latest = super::service::find_latest_log_file(&log_dir, "freenet");
match latest {
Some(path) => {
#[cfg(target_os = "windows")]
let mut cmd = std::process::Command::new("notepad");
#[cfg(target_os = "macos")]
let mut cmd = std::process::Command::new("open");
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let mut cmd = std::process::Command::new("xdg-open");
cmd.arg(&path)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
drop(cmd.spawn());
}
None => {
eprintln!("No log files found in {}", log_dir.display());
}
}
}