use tauri::{AppHandle, Listener, Runtime};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::desktop::ipc::{socket_path, IpcEvent};
use crate::desktop::ipc_server::IpcServer;
use crate::manager::manager_loop;
use crate::models::PluginEvent;
#[cfg(test)]
use crate::models::StopReason;
use crate::service_trait::BackgroundService;
#[derive(Debug)]
struct ParsedArgs {
service_label: String,
validate_install: bool,
}
fn parse_args(args: &[String]) -> Result<ParsedArgs, String> {
let mut label = None;
let mut validate = false;
let mut iter = args.iter().skip(1); while let Some(arg) = iter.next() {
if arg == "--service-label" {
let value = iter
.next()
.ok_or_else(|| "--service-label requires a value".to_string())?;
if value.is_empty() {
return Err("--service-label value must not be empty".to_string());
}
label = Some(value.clone());
} else if arg == "--validate-service-install" {
validate = true;
}
}
Ok(ParsedArgs {
service_label: label.ok_or_else(|| {
"--service-label is required. Usage: <binary> --service-label <label>".to_string()
})?,
validate_install: validate,
})
}
pub fn headless_main<F, R>(factory: F, app: AppHandle<R>)
where
F: Fn() -> Box<dyn BackgroundService<R>> + Send + Sync + 'static,
R: Runtime,
{
let args: Vec<String> = std::env::args().collect();
let parsed = parse_args(&args).unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
if parsed.validate_install {
println!("ok");
std::process::exit(0);
}
let label = parsed.service_label;
tauri::async_runtime::block_on(async move {
let (cmd_tx, cmd_rx) = mpsc::channel(16);
tauri::async_runtime::spawn(manager_loop(
cmd_rx,
Box::new(factory),
0.0,
0.0,
0.0,
0.0,
false,
false,
None,
));
let path = match socket_path(&label) {
Ok(p) => p,
Err(e) => {
eprintln!("error: invalid service label: {e}");
return;
}
};
let app_for_events = app.clone();
let server = match IpcServer::bind(path, cmd_tx, app) {
Ok(s) => s,
Err(e) => {
eprintln!("error: failed to bind IPC socket: {e}");
return;
}
};
let event_tx = server.event_sender();
let _listener = app_for_events.listen("background-service://event", move |event| {
if let Ok(plugin_event) = serde_json::from_str::<PluginEvent>(event.payload()) {
let ipc_event = match plugin_event {
PluginEvent::Started => IpcEvent::Started,
PluginEvent::Stopped { reason } => IpcEvent::Stopped { reason },
PluginEvent::Error { message } => IpcEvent::Error { message },
};
if event_tx.send(ipc_event).is_err() {
log::warn!("headless event relay: broadcast channel closed during shutdown");
}
}
});
let shutdown = CancellationToken::new();
#[cfg(unix)]
{
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler");
tokio::select! {
_ = server.run(shutdown.clone()) => {}
_ = tokio::signal::ctrl_c() => {
shutdown.cancel();
}
_ = sigterm.recv() => {
shutdown.cancel();
}
}
}
#[cfg(not(unix))]
{
tokio::select! {
_ = server.run(shutdown.clone()) => {}
_ = tokio::signal::ctrl_c() => {
shutdown.cancel();
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_args_extracts_service_label() {
let args = vec![
"my-app-headless".to_string(),
"--service-label".to_string(),
"com.example.svc".to_string(),
];
let parsed = parse_args(&args).unwrap();
assert_eq!(parsed.service_label, "com.example.svc");
assert!(!parsed.validate_install);
}
#[test]
fn parse_args_extracts_label_with_other_args() {
let args = vec![
"my-app-headless".to_string(),
"--verbose".to_string(),
"--service-label".to_string(),
"com.example.svc".to_string(),
"--other".to_string(),
];
let parsed = parse_args(&args).unwrap();
assert_eq!(parsed.service_label, "com.example.svc");
}
#[test]
fn parse_args_rejects_missing_label() {
let args = vec!["my-app-headless".to_string()];
let result = parse_args(&args);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("--service-label"),
"Error should mention --service-label: {err}"
);
}
#[test]
fn parse_args_rejects_label_without_value() {
let args = vec!["my-app-headless".to_string(), "--service-label".to_string()];
let result = parse_args(&args);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("value"),
"Error should mention missing value: {err}"
);
}
#[test]
fn parse_args_rejects_empty_label() {
let args = vec![
"my-app-headless".to_string(),
"--service-label".to_string(),
"".to_string(),
];
let result = parse_args(&args);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("empty"),
"Error should mention empty value: {err}"
);
}
#[test]
fn parse_args_detects_validate_flag() {
let args = vec![
"my-app-headless".to_string(),
"--service-label".to_string(),
"com.example.svc".to_string(),
"--validate-service-install".to_string(),
];
let parsed = parse_args(&args).unwrap();
assert!(parsed.validate_install);
assert_eq!(parsed.service_label, "com.example.svc");
}
#[test]
fn parse_args_validate_flag_absent() {
let args = vec![
"my-app-headless".to_string(),
"--service-label".to_string(),
"com.example.svc".to_string(),
];
let parsed = parse_args(&args).unwrap();
assert!(!parsed.validate_install);
}
#[test]
fn parse_args_both_flags_in_mixed_order() {
let args = vec![
"my-app-headless".to_string(),
"--validate-service-install".to_string(),
"--service-label".to_string(),
"com.example.svc".to_string(),
];
let parsed = parse_args(&args).unwrap();
assert_eq!(parsed.service_label, "com.example.svc");
assert!(parsed.validate_install);
}
#[test]
fn plugin_event_maps_to_ipc_event_started() {
let plugin_event = PluginEvent::Started;
let json = serde_json::to_string(&plugin_event).unwrap();
let parsed: PluginEvent = serde_json::from_str(&json).unwrap();
let ipc_event: IpcEvent = match parsed {
PluginEvent::Started => IpcEvent::Started,
PluginEvent::Stopped { reason } => IpcEvent::Stopped { reason },
PluginEvent::Error { message } => IpcEvent::Error { message },
};
assert!(matches!(ipc_event, IpcEvent::Started));
}
#[test]
fn plugin_event_maps_to_ipc_event_stopped() {
let plugin_event = PluginEvent::Stopped {
reason: StopReason::TaskCompleted,
};
let json = serde_json::to_string(&plugin_event).unwrap();
let parsed: PluginEvent = serde_json::from_str(&json).unwrap();
let ipc_event: IpcEvent = match parsed {
PluginEvent::Started => IpcEvent::Started,
PluginEvent::Stopped { reason } => IpcEvent::Stopped { reason },
PluginEvent::Error { message } => IpcEvent::Error { message },
};
match ipc_event {
IpcEvent::Stopped { reason } => assert_eq!(reason, StopReason::TaskCompleted),
other => panic!("Expected Stopped, got {other:?}"),
}
}
#[test]
fn plugin_event_maps_to_ipc_event_error() {
let plugin_event = PluginEvent::Error {
message: "init failed".into(),
};
let json = serde_json::to_string(&plugin_event).unwrap();
let parsed: PluginEvent = serde_json::from_str(&json).unwrap();
let ipc_event: IpcEvent = match parsed {
PluginEvent::Started => IpcEvent::Started,
PluginEvent::Stopped { reason } => IpcEvent::Stopped { reason },
PluginEvent::Error { message } => IpcEvent::Error { message },
};
match ipc_event {
IpcEvent::Error { message } => assert_eq!(message, "init failed"),
other => panic!("Expected Error, got {other:?}"),
}
}
#[test]
fn event_sender_broadcasts_mapped_events() {
use tokio::sync::broadcast;
let (tx, _) = broadcast::channel::<IpcEvent>(32);
let mut rx = tx.subscribe();
let plugin_event = PluginEvent::Error {
message: "test error".into(),
};
let ipc_event = match plugin_event {
PluginEvent::Started => IpcEvent::Started,
PluginEvent::Stopped { reason } => IpcEvent::Stopped { reason },
PluginEvent::Error { message } => IpcEvent::Error { message },
};
let _ = tx.send(ipc_event);
let received = rx.try_recv().unwrap();
match received {
IpcEvent::Error { message } => assert_eq!(message, "test error"),
other => panic!("Expected Error event, got {other:?}"),
}
}
}