use clap::{Parser, ArgAction};
use clap_version_flag::colorful_version;
use sendgrowl::{GntpClient, NotificationType, NotifyOptions, Resource};
use std::{env, path::PathBuf};
#[derive(Parser, Debug)]
#[command(
name = "sendgrowl",
version = env!("CARGO_PKG_VERSION"),
disable_version_flag = true,
about = "Send Growl notifications to multiple hosts safely on Windows"
)]
struct Args {
#[arg(short='V', long, action=ArgAction::SetTrue)]
version: bool,
app_name: String,
event_name: String,
title: String,
text: String,
#[arg(short='H', long="host", value_name="HOST[:PORT]", num_args=1..)]
host: Vec<String>,
#[arg(short='P', long, default_value="23053")]
port: u16,
#[arg(short='i', long)]
icon: Option<PathBuf>,
#[arg(short='s', long)]
sticky: bool,
#[arg(short='p', long, default_value="0")]
priority: i8,
#[arg(short='v', long, action=ArgAction::Count)]
verbose: u8,
#[arg(short='r', long, default_value="0")]
retry: u8,
#[arg(long, default_value="2000")]
retry_delay: u64,
#[arg(long, default_value="dataurl", value_parser=parse_icon_mode)]
icon_mode: sendgrowl::IconMode,
}
fn parse_icon_mode(s: &str) -> Result<sendgrowl::IconMode, String> {
match s.to_lowercase().as_str() {
"binary" => Ok(sendgrowl::IconMode::Binary),
"fileurl" => Ok(sendgrowl::IconMode::FileUrl),
"dataurl" => Ok(sendgrowl::IconMode::DataUrl),
"httpurl" => Ok(sendgrowl::IconMode::HttpUrl),
"auto" => Ok(sendgrowl::IconMode::Auto),
_ => Err(format!("Invalid icon mode: {}. Use: binary, fileurl, dataurl, httpurl, auto", s)),
}
}
fn load_icon(path: &Option<PathBuf>, verbose: u8) -> Option<Resource> {
let pathbuf = if let Some(p) = path {
p.clone()
} else {
let exe_dir = env::current_exe().ok()?.parent()?.to_path_buf();
exe_dir.join("growl.png")
};
match Resource::from_file(&pathbuf) {
Ok(icon) => {
if verbose > 0 {
println!("✓ Icon loaded: {}", pathbuf.display());
}
Some(icon)
}
Err(e) => {
eprintln!("⚠ Icon ignored: {}", e);
None
}
}
}
fn send_growl(
host: &str,
port: u16,
app_name: &str,
event_name: &str,
title: &str,
text: &str,
icon: Option<Resource>,
sticky: bool,
priority: i8,
verbose: u8,
icon_mode: sendgrowl::IconMode,
retry_count: u8,
retry_delay_ms: u64,
) -> Result<(), Box<dyn std::error::Error>> {
let host = if cfg!(windows) && (host == "127.0.0.1" || host == "::1") {
if verbose > 0 {
println!("⚠ '127.0.0.1' converted to 'localhost' for Windows Growl");
}
"localhost".to_string()
} else {
host.to_string()
};
let mut last_error: Option<Box<dyn std::error::Error>> = None;
let max_attempts = retry_count + 1;
for attempt in 1..=max_attempts {
if attempt > 1 {
if verbose > 0 {
println!("⚠ Retry attempt {}/{} after {}ms delay...",
attempt - 1, retry_count, retry_delay_ms);
}
std::thread::sleep(std::time::Duration::from_millis(retry_delay_ms));
}
let mut client = GntpClient::new(app_name)
.with_host(&host)
.with_port(port)
.with_icon_mode(icon_mode.clone())
.with_debug(verbose > 1);
let mut notification = NotificationType::new(event_name)
.with_display_name(event_name)
.with_enabled(true);
if let Some(ref icon) = icon {
notification = notification.with_icon(icon.clone());
if verbose > 0 && attempt == 1 {
println!("✓ Icon attached to notification type");
}
}
if verbose > 0 && attempt == 1 {
println!("Registering with Growl on {}:{}...", host, port);
}
match client.register(vec![notification]) {
Ok(_) => {
let options = NotifyOptions::new()
.with_sticky(sticky)
.with_priority(priority);
match client.notify_with_options(event_name, title, text, options) {
Ok(_) => {
if verbose > 0 {
if attempt > 1 {
println!("✓ Notification sent to {}:{} (succeeded on retry {})",
host, port, attempt - 1);
} else {
println!("✓ Notification sent to {}:{}", host, port);
}
}
return Ok(());
}
Err(e) => {
last_error = Some(Box::new(e));
if verbose > 0 && attempt < max_attempts {
println!("⚠ Notify failed on {}:{}, will retry...", host, port);
}
}
}
}
Err(e) => {
last_error = Some(Box::new(e));
if verbose > 0 && attempt < max_attempts {
println!("⚠ Registration failed on {}:{}, will retry...", host, port);
}
}
}
}
Err(last_error.unwrap_or_else(|| "Unknown error".into()))
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let os_args: Vec<String> = std::env::args().collect();
if os_args.len() == 2 && (os_args[1] == "-V" || os_args[1] == "--version") {
let version = colorful_version!();
version.print_and_exit();
}
let args = Args::parse();
if args.version {
colorful_version!().print_and_exit();
}
let icon = load_icon(&args.icon, args.verbose);
let hosts = if args.host.is_empty() {
vec![format!("localhost:{}", args.port)]
} else {
args.host.iter().map(|h| {
if h.contains(':') {
h.clone()
} else {
format!("{}:{}", h, args.port)
}
}).collect()
};
for h in hosts {
let mut split = h.split(':');
let host = split.next().unwrap();
let port = split.next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(args.port);
if let Err(e) = send_growl(
host,
port,
&args.app_name,
&args.event_name,
&args.title,
&args.text,
icon.clone(),
args.sticky,
args.priority,
args.verbose,
args.icon_mode.clone(),
args.retry,
args.retry_delay,
) {
eprintln!("❌ Failed to send to {}:{} - {}", host, port, e);
}
}
Ok(())
}