use std::io::{Read, Write};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
use std::collections::HashSet;
const GNTP_VERSION: &str = "1.0";
const DEFAULT_PORT: u16 = 23053;
const CRLF: &str = "\r\n";
#[derive(Debug)]
pub enum GntpError {
ConnectionError(String),
IoError(String),
ProtocolError(String),
}
impl std::fmt::Display for GntpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GntpError::ConnectionError(msg) => write!(f, "Connection error: {}", msg),
GntpError::IoError(msg) => write!(f, "I/O error: {}", msg),
GntpError::ProtocolError(msg) => write!(f, "Protocol error: {}", msg),
}
}
}
impl std::error::Error for GntpError {}
impl From<std::io::Error> for GntpError {
fn from(err: std::io::Error) -> Self {
GntpError::IoError(err.to_string())
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum IconMode {
Binary,
FileUrl,
DataUrl,
HttpUrl,
Auto,
}
#[derive(Clone)]
pub struct Resource {
pub identifier: String,
pub data: Vec<u8>,
pub source_path: Option<PathBuf>,
pub mime_type: String,
}
impl Resource {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, GntpError> {
let path = path.as_ref();
let data = std::fs::read(&path).map_err(|e| {
GntpError::IoError(format!("Failed to read file {}: {}", path.display(), e))
})?;
let mime_type = guess_mime_type(path);
let identifier = uuid::Uuid::new_v4().to_string();
Ok(Resource {
identifier,
data,
source_path: Some(path.to_path_buf()),
mime_type,
})
}
pub fn from_pathbuf(path: PathBuf) -> Result<Self, GntpError> {
Self::from_file(path)
}
pub fn from_bytes(data: Vec<u8>, mime_type: &str) -> Self {
Resource {
identifier: uuid::Uuid::new_v4().to_string(),
data,
source_path: None,
mime_type: mime_type.to_string(),
}
}
fn get_reference(&self, mode: &IconMode) -> String {
match mode {
IconMode::Binary => {
format!("x-growl-resource://{}", self.identifier)
}
IconMode::FileUrl => {
if let Some(ref path) = self.source_path {
let path_str = path.to_string_lossy().replace('\\', "/");
format!("file:///{}", path_str)
} else {
self.to_data_url()
}
}
IconMode::HttpUrl => {
if let Some(ref path) = self.source_path {
path.to_string_lossy().to_string()
} else {
self.to_data_url()
}
}
IconMode::DataUrl | IconMode::Auto => {
self.to_data_url()
}
}
}
fn to_data_url(&self) -> String {
const MAX_DATA_URL_SIZE: usize = 500_000;
let encoded = base64_encode(&self.data);
if encoded.len() > MAX_DATA_URL_SIZE {
eprintln!("Warning: Icon size ({} bytes) may be too large for some GNTP servers", encoded.len());
}
format!("data:{};base64,{}", self.mime_type, encoded)
}
}
fn guess_mime_type(path: &Path) -> String {
match path.extension().and_then(|s| s.to_str()) {
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("bmp") => "image/bmp",
Some("ico") => "image/x-icon",
Some("svg") => "image/svg+xml",
Some("webp") => "image/webp",
_ => "application/octet-stream",
}.to_string()
}
fn base64_encode(data: &[u8]) -> String {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut result = String::with_capacity((data.len() + 2) / 3 * 4);
let mut i = 0;
while i < data.len() {
let b1 = data[i];
let b2 = if i + 1 < data.len() { data[i + 1] } else { 0 };
let b3 = if i + 2 < data.len() { data[i + 2] } else { 0 };
let c1 = CHARSET[(b1 >> 2) as usize] as char;
let c2 = CHARSET[(((b1 & 0x03) << 4) | (b2 >> 4)) as usize] as char;
let c3 = if i + 1 < data.len() {
CHARSET[(((b2 & 0x0F) << 2) | (b3 >> 6)) as usize] as char
} else {
'='
};
let c4 = if i + 2 < data.len() {
CHARSET[(b3 & 0x3F) as usize] as char
} else {
'='
};
result.push(c1);
result.push(c2);
result.push(c3);
result.push(c4);
i += 3;
}
result
}
#[derive(Clone)]
pub struct NotificationType {
pub name: String,
pub display_name: Option<String>,
pub enabled: bool,
pub icon: Option<Resource>,
}
impl NotificationType {
pub fn new(name: &str) -> Self {
NotificationType {
name: name.to_string(),
display_name: None,
enabled: true,
icon: None,
}
}
pub fn with_display_name(mut self, display_name: &str) -> Self {
self.display_name = Some(display_name.to_string());
self
}
pub fn with_icon(mut self, icon: Resource) -> Self {
self.icon = Some(icon);
self
}
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
}
pub struct GntpClient {
pub host: String,
pub port: u16,
pub application_name: String,
pub application_icon: Option<Resource>,
#[allow(dead_code)]
pub password: Option<String>,
registered: bool,
pub debug: bool,
pub icon_mode: IconMode,
}
impl GntpClient {
pub fn new(application_name: &str) -> Self {
GntpClient {
host: "localhost".to_string(),
port: DEFAULT_PORT,
application_name: application_name.to_string(),
application_icon: None,
password: None,
registered: false,
debug: false,
icon_mode: IconMode::DataUrl, }
}
pub fn with_host(mut self, host: &str) -> Self {
self.host = host.to_string();
self
}
pub fn with_port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn with_icon(mut self, icon: Resource) -> Self {
self.application_icon = Some(icon);
self
}
#[allow(dead_code)]
pub fn with_password(mut self, password: &str) -> Self {
self.password = Some(password.to_string());
self
}
pub fn with_debug(mut self, debug: bool) -> Self {
self.debug = debug;
self
}
pub fn with_icon_mode(mut self, mode: IconMode) -> Self {
self.icon_mode = mode;
self
}
pub fn register(&mut self, notifications: Vec<NotificationType>) -> Result<String, GntpError> {
let mut packet = String::new();
let mut resources = Vec::new();
let mut seen_identifiers = HashSet::new();
packet.push_str(&format!("GNTP/{} REGISTER NONE{}", GNTP_VERSION, CRLF));
packet.push_str(&format!("Application-Name: {}{}", self.application_name, CRLF));
if let Some(ref icon) = self.application_icon {
let icon_ref = icon.get_reference(&self.icon_mode);
packet.push_str(&format!("Application-Icon: {}{}", icon_ref, CRLF));
if self.icon_mode == IconMode::Binary {
if seen_identifiers.insert(icon.identifier.clone()) {
resources.push(icon.clone());
}
}
}
packet.push_str(&format!("Notifications-Count: {}{}", notifications.len(), CRLF));
packet.push_str(CRLF);
for notif in ¬ifications {
packet.push_str(&format!("Notification-Name: {}{}", notif.name, CRLF));
if let Some(ref display) = notif.display_name {
packet.push_str(&format!("Notification-Display-Name: {}{}", display, CRLF));
}
packet.push_str(&format!("Notification-Enabled: {}{}",
if notif.enabled { "True" } else { "False" }, CRLF));
if let Some(ref icon) = notif.icon {
let icon_ref = icon.get_reference(&self.icon_mode);
packet.push_str(&format!("Notification-Icon: {}{}", icon_ref, CRLF));
if self.icon_mode == IconMode::Binary {
if seen_identifiers.insert(icon.identifier.clone()) {
resources.push(icon.clone());
}
}
}
packet.push_str(CRLF);
}
if self.icon_mode == IconMode::Binary {
for resource in &resources {
packet.push_str(&format!("Identifier: {}{}", resource.identifier, CRLF));
packet.push_str(&format!("Length: {}{}", resource.data.len(), CRLF));
packet.push_str(CRLF);
}
}
if self.debug {
println!("\n=== REGISTER PACKET (Mode: {:?}) ===", self.icon_mode);
println!("{}", packet);
println!("Resources: {}", resources.len());
println!("======================================\n");
}
let response = if self.icon_mode == IconMode::Binary {
self.send_packet_with_resources(&packet, &resources)?
} else {
self.send_packet(&packet)?
};
self.registered = true;
Ok(response)
}
pub fn notify(&self,
notification_name: &str,
title: &str,
text: &str) -> Result<String, GntpError> {
self.notify_with_options(notification_name, title, text,
NotifyOptions::default())
}
pub fn notify_with_options(&self,
notification_name: &str,
title: &str,
text: &str,
options: NotifyOptions) -> Result<String, GntpError> {
if !self.registered {
return Err(GntpError::ProtocolError(
"Must call register() before notify()".to_string()
));
}
let mut packet = String::new();
let mut resources = Vec::new();
packet.push_str(&format!("GNTP/{} NOTIFY NONE{}", GNTP_VERSION, CRLF));
packet.push_str(&format!("Application-Name: {}{}", self.application_name, CRLF));
packet.push_str(&format!("Notification-Name: {}{}", notification_name, CRLF));
packet.push_str(&format!("Notification-Title: {}{}", title, CRLF));
packet.push_str(&format!("Notification-Text: {}{}", text, CRLF));
if options.sticky {
packet.push_str(&format!("Notification-Sticky: True{}", CRLF));
}
if options.priority != 0 {
packet.push_str(&format!("Notification-Priority: {}{}", options.priority, CRLF));
}
if let Some(ref icon) = options.icon {
let icon_ref = icon.get_reference(&self.icon_mode);
packet.push_str(&format!("Notification-Icon: {}{}", icon_ref, CRLF));
if self.icon_mode == IconMode::Binary {
resources.push(icon.clone());
}
}
packet.push_str(CRLF);
if self.icon_mode == IconMode::Binary {
for resource in &resources {
packet.push_str(&format!("Identifier: {}{}", resource.identifier, CRLF));
packet.push_str(&format!("Length: {}{}", resource.data.len(), CRLF));
packet.push_str(CRLF);
}
}
if self.debug {
println!("\n=== NOTIFY PACKET (Mode: {:?}) ===", self.icon_mode);
println!("{}", packet);
println!("====================================\n");
}
if self.icon_mode == IconMode::Binary {
self.send_packet_with_resources(&packet, &resources)
} else {
self.send_packet(&packet)
}
}
fn send_packet(&self, packet: &str) -> Result<String, GntpError> {
let address = format!("{}:{}", self.host, self.port);
if self.debug {
println!("Connecting to {}...", address);
}
let mut stream = TcpStream::connect(&address)
.map_err(|e| GntpError::ConnectionError(format!("Failed to connect to {}: {}",
address, e)))?;
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
stream.set_write_timeout(Some(Duration::from_secs(10)))?;
stream.write_all(packet.as_bytes())?;
stream.flush()?;
if self.debug {
println!("Packet sent, waiting for response...");
}
let mut response = String::new();
match stream.read_to_string(&mut response) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset
|| e.kind() == std::io::ErrorKind::UnexpectedEof => {
if self.debug {
println!("Connection closed (OK)");
}
return Ok(String::new());
}
Err(e) => return Err(e.into()),
}
if response.contains("-ERROR") {
return Err(GntpError::ProtocolError(format!("Server error: {}", response)));
}
Ok(response)
}
fn send_packet_with_resources(&self, packet: &str, resources: &[Resource]) -> Result<String, GntpError> {
let address = format!("{}:{}", self.host, self.port);
let mut stream = TcpStream::connect(&address)
.map_err(|e| GntpError::ConnectionError(format!("Failed to connect to {}: {}",
address, e)))?;
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
stream.set_write_timeout(Some(Duration::from_secs(10)))?;
stream.write_all(packet.as_bytes())?;
for resource in resources {
stream.write_all(&resource.data)?;
stream.write_all(CRLF.as_bytes())?;
}
stream.write_all(CRLF.as_bytes())?;
stream.flush()?;
let mut response = String::new();
match stream.read_to_string(&mut response) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset
|| e.kind() == std::io::ErrorKind::UnexpectedEof => {
return Ok(String::new());
}
Err(e) => return Err(e.into()),
}
if response.contains("-ERROR") {
return Err(GntpError::ProtocolError(format!("Server error: {}", response)));
}
Ok(response)
}
}
#[derive(Default)]
pub struct NotifyOptions {
pub sticky: bool,
pub priority: i8,
pub icon: Option<Resource>,
}
impl NotifyOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_sticky(mut self, sticky: bool) -> Self {
self.sticky = sticky;
self
}
pub fn with_priority(mut self, priority: i8) -> Self {
self.priority = priority.max(-2).min(2);
self
}
pub fn with_icon(mut self, icon: Resource) -> Self {
self.icon = Some(icon);
self
}
}