Skip to main content

windows_erg/desktop/
tray.rs

1use windows::Win32::Foundation::{
2    ERROR_CLASS_ALREADY_EXISTS, GetLastError, HWND, LPARAM, LRESULT, WPARAM,
3};
4use windows::Win32::System::LibraryLoader::GetModuleHandleW;
5use windows::Win32::UI::Shell::{
6    NIF_ICON, NIF_INFO, NIF_TIP, NIIF_ERROR, NIIF_INFO, NIIF_NONE, NIIF_WARNING, NIM_ADD,
7    NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, Shell_NotifyIconW,
8};
9use windows::Win32::UI::WindowsAndMessaging::{
10    CreateWindowExW, DefWindowProcW, DestroyWindow, HICON, HWND_MESSAGE, IDI_APPLICATION,
11    LoadIconW, RegisterClassW, WINDOW_EX_STYLE, WINDOW_STYLE, WNDCLASSW,
12};
13use windows::core::PCWSTR;
14
15use crate::error::{DesktopError, DesktopOperationError, Error, InvalidParameterError, Result};
16use crate::utils::to_utf16_nul;
17
18use super::types::{BalloonIcon, TrayIconId, WindowHandle};
19
20const DEFAULT_TRAY_WINDOW_CLASS_NAME: &str = "windows_erg_tray_window";
21const DEFAULT_TRAY_WINDOW_NAME: &str = "windows_erg_tray_window";
22
23/// Balloon notification payload for a tray icon.
24#[derive(Debug, Clone)]
25pub struct TrayNotification {
26    /// Notification title.
27    pub title: String,
28    /// Notification body text.
29    pub body: String,
30    /// Notification icon style.
31    pub icon: BalloonIcon,
32}
33
34impl TrayNotification {
35    /// Create a notification payload.
36    pub fn new(title: impl Into<String>, body: impl Into<String>, icon: BalloonIcon) -> Self {
37        Self {
38            title: title.into(),
39            body: body.into(),
40            icon,
41        }
42    }
43}
44
45/// Notification area icon with RAII lifecycle management.
46#[derive(Debug)]
47pub struct TrayIcon {
48    hwnd: HWND,
49    id: TrayIconId,
50    icon_added: bool,
51    owns_window: bool,
52}
53
54impl TrayIcon {
55    /// Create a tray icon with an internally managed hidden message window.
56    ///
57    /// This is a convenience wrapper around [`TrayIconBuilder`], using
58    /// default internal class and window names.
59    pub fn new(id: TrayIconId, tooltip: &str) -> Result<Self> {
60        TrayIconBuilder::new(id, tooltip).create()
61    }
62
63    /// Create a tray icon builder.
64    ///
65    /// Use this when you need to customize the internal message window class name
66    /// and/or window name used for tray icon ownership.
67    ///
68    /// # Example
69    ///
70    /// ```no_run
71    /// use windows_erg::desktop::{TrayIcon, TrayIconId};
72    ///
73    /// # fn main() -> windows_erg::Result<()> {
74    /// let _tray = TrayIcon::builder(TrayIconId::new(1), "demo")
75    ///     .window_class_name("my_app_tray_window_class")
76    ///     .window_name("my_app_tray_window")
77    ///     .create()?;
78    /// # Ok(())
79    /// # }
80    /// ```
81    pub fn builder(id: TrayIconId, tooltip: impl Into<String>) -> TrayIconBuilder {
82        TrayIconBuilder::new(id, tooltip)
83    }
84
85    /// Create a tray icon bound to an existing window handle.
86    pub fn from_window(owner: WindowHandle, id: TrayIconId, tooltip: &str) -> Result<Self> {
87        let mut icon = TrayIcon {
88            hwnd: owner.into(),
89            id,
90            icon_added: false,
91            owns_window: false,
92        };
93
94        icon.add_icon(tooltip)?;
95        Ok(icon)
96    }
97
98    /// Show a tray balloon notification.
99    pub fn show_notification(&self, notification: &TrayNotification) -> Result<()> {
100        let mut data = self.base_notify_data();
101        data.uFlags = NIF_INFO;
102        copy_text_to_fixed(&notification.body, &mut data.szInfo);
103        copy_text_to_fixed(&notification.title, &mut data.szInfoTitle);
104        data.dwInfoFlags = match notification.icon {
105            BalloonIcon::None => NIIF_NONE,
106            BalloonIcon::Info => NIIF_INFO,
107            BalloonIcon::Warning => NIIF_WARNING,
108            BalloonIcon::Error => NIIF_ERROR,
109        };
110
111        let ok = unsafe { Shell_NotifyIconW(NIM_MODIFY, &data) }.as_bool();
112        if ok {
113            return Ok(());
114        }
115
116        let code = unsafe { GetLastError().0 as i32 };
117        Err(Error::Desktop(DesktopError::OperationFailed(
118            DesktopOperationError::with_code("Shell_NotifyIconW", "NIM_MODIFY notification", code),
119        )))
120    }
121
122    /// Update tray icon tooltip text.
123    pub fn update_tooltip(&self, tooltip: &str) -> Result<()> {
124        let mut data = self.base_notify_data();
125        data.uFlags = NIF_TIP;
126        copy_text_to_fixed(tooltip, &mut data.szTip);
127
128        let ok = unsafe { Shell_NotifyIconW(NIM_MODIFY, &data) }.as_bool();
129        if ok {
130            return Ok(());
131        }
132
133        let code = unsafe { GetLastError().0 as i32 };
134        Err(Error::Desktop(DesktopError::OperationFailed(
135            DesktopOperationError::with_code("Shell_NotifyIconW", "NIM_MODIFY tooltip", code),
136        )))
137    }
138
139    /// Explicitly remove the tray icon.
140    pub fn remove(&mut self) -> Result<()> {
141        if !self.icon_added {
142            return Ok(());
143        }
144
145        let data = self.base_notify_data();
146        let ok = unsafe { Shell_NotifyIconW(NIM_DELETE, &data) }.as_bool();
147        if !ok {
148            let code = unsafe { GetLastError().0 as i32 };
149            return Err(Error::Desktop(DesktopError::OperationFailed(
150                DesktopOperationError::with_code("Shell_NotifyIconW", "NIM_DELETE", code),
151            )));
152        }
153
154        self.icon_added = false;
155        Ok(())
156    }
157
158    fn add_icon(&mut self, tooltip: &str) -> Result<()> {
159        let mut data = self.base_notify_data();
160        data.uFlags = NIF_ICON | NIF_TIP;
161        data.hIcon = load_default_icon();
162        copy_text_to_fixed(tooltip, &mut data.szTip);
163
164        let ok = unsafe { Shell_NotifyIconW(NIM_ADD, &data) }.as_bool();
165        if !ok {
166            let code = unsafe { GetLastError().0 as i32 };
167            return Err(Error::Desktop(DesktopError::OperationFailed(
168                DesktopOperationError::with_code("Shell_NotifyIconW", "NIM_ADD", code),
169            )));
170        }
171
172        self.icon_added = true;
173        Ok(())
174    }
175
176    fn base_notify_data(&self) -> NOTIFYICONDATAW {
177        NOTIFYICONDATAW {
178            cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
179            hWnd: self.hwnd,
180            uID: self.id.as_u32(),
181            ..Default::default()
182        }
183    }
184}
185
186/// Builder for creating tray icons with custom internal window names.
187#[derive(Debug, Clone)]
188pub struct TrayIconBuilder {
189    id: TrayIconId,
190    tooltip: String,
191    owner: Option<WindowHandle>,
192    window_class_name: String,
193    window_name: String,
194}
195
196impl TrayIconBuilder {
197    /// Create a new tray icon builder with default class and window names.
198    pub fn new(id: TrayIconId, tooltip: impl Into<String>) -> Self {
199        Self {
200            id,
201            tooltip: tooltip.into(),
202            owner: None,
203            window_class_name: DEFAULT_TRAY_WINDOW_CLASS_NAME.to_string(),
204            window_name: DEFAULT_TRAY_WINDOW_NAME.to_string(),
205        }
206    }
207
208    /// Bind the tray icon to an existing owner window.
209    ///
210    /// When set, custom class and window names are ignored because this builder
211    /// does not create an internal message-only window.
212    pub fn owner_window(mut self, owner: WindowHandle) -> Self {
213        self.owner = Some(owner);
214        self
215    }
216
217    /// Set the class name used when creating an internal message-only tray window.
218    pub fn window_class_name(mut self, name: impl Into<String>) -> Self {
219        self.window_class_name = name.into();
220        self
221    }
222
223    /// Set the window name used when creating an internal message-only tray window.
224    pub fn window_name(mut self, name: impl Into<String>) -> Self {
225        self.window_name = name.into();
226        self
227    }
228
229    /// Create the tray icon instance.
230    pub fn create(self) -> Result<TrayIcon> {
231        validate_window_name("window_class_name", &self.window_class_name)?;
232        validate_window_name("window_name", &self.window_name)?;
233
234        let hwnd = match self.owner {
235            Some(owner) => owner.into(),
236            None => create_message_window(&self.window_class_name, &self.window_name)?,
237        };
238
239        let mut icon = TrayIcon {
240            hwnd,
241            id: self.id,
242            icon_added: false,
243            owns_window: self.owner.is_none(),
244        };
245
246        icon.add_icon(&self.tooltip)?;
247        Ok(icon)
248    }
249}
250
251impl Drop for TrayIcon {
252    fn drop(&mut self) {
253        let _ = self.remove();
254
255        if self.owns_window && !self.hwnd.0.is_null() {
256            unsafe {
257                let _ = DestroyWindow(self.hwnd);
258            }
259        }
260    }
261}
262
263fn create_message_window(class_name: &str, window_name: &str) -> Result<HWND> {
264    let instance = unsafe { GetModuleHandleW(None) }.map_err(|e| {
265        Error::Desktop(DesktopError::OperationFailed(
266            DesktopOperationError::with_code("GetModuleHandleW", "tray window class", e.code().0),
267        ))
268    })?;
269
270    let class_name_wide = to_utf16_nul(class_name);
271    let window_name_wide = to_utf16_nul(window_name);
272
273    let wnd_class = WNDCLASSW {
274        lpfnWndProc: Some(tray_window_proc),
275        hInstance: instance.into(),
276        lpszClassName: PCWSTR(class_name_wide.as_ptr()),
277        ..Default::default()
278    };
279
280    let class_atom = unsafe { RegisterClassW(&wnd_class) };
281    if class_atom == 0 {
282        let code = unsafe { GetLastError() };
283        if code != ERROR_CLASS_ALREADY_EXISTS {
284            return Err(Error::Desktop(DesktopError::OperationFailed(
285                DesktopOperationError::with_code(
286                    "RegisterClassW",
287                    class_name.to_string(),
288                    code.0 as i32,
289                ),
290            )));
291        }
292    }
293
294    let hwnd = unsafe {
295        CreateWindowExW(
296            WINDOW_EX_STYLE(0),
297            PCWSTR(class_name_wide.as_ptr()),
298            PCWSTR(window_name_wide.as_ptr()),
299            WINDOW_STYLE(0),
300            0,
301            0,
302            0,
303            0,
304            HWND_MESSAGE,
305            None,
306            instance,
307            None,
308        )
309    }
310    .map_err(|e| {
311        Error::Desktop(DesktopError::OperationFailed(
312            DesktopOperationError::with_code("CreateWindowExW", "tray message window", e.code().0),
313        ))
314    })?;
315
316    Ok(hwnd)
317}
318
319fn load_default_icon() -> HICON {
320    unsafe { LoadIconW(None, IDI_APPLICATION).unwrap_or_default() }
321}
322
323unsafe extern "system" fn tray_window_proc(
324    hwnd: HWND,
325    msg: u32,
326    wparam: WPARAM,
327    lparam: LPARAM,
328) -> LRESULT {
329    unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
330}
331
332fn copy_text_to_fixed<const N: usize>(text: &str, destination: &mut [u16; N]) {
333    destination.fill(0);
334    let mut encoded = text.encode_utf16();
335
336    for slot in destination.iter_mut().take(N.saturating_sub(1)) {
337        if let Some(ch) = encoded.next() {
338            *slot = ch;
339        } else {
340            break;
341        }
342    }
343}
344
345fn validate_window_name(field: &'static str, value: &str) -> Result<()> {
346    if value.is_empty() {
347        return Err(Error::InvalidParameter(InvalidParameterError::new(
348            field,
349            "value cannot be empty",
350        )));
351    }
352
353    if value.contains('\0') {
354        return Err(Error::InvalidParameter(InvalidParameterError::new(
355            field,
356            "value cannot contain NUL characters",
357        )));
358    }
359
360    Ok(())
361}