Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions NotificationIcon.NET/LinuxNotifyIcon.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ private struct Tray
{
[MarshalAs(UnmanagedType.LPUTF8Str)]
public string iconPath;
// tooltip and double_click_cb are in the shared native struct layout but unused on Linux
[MarshalAs(UnmanagedType.LPUTF8Str)]
public string? tooltip;
public IntPtr doubleClickCb;
public IntPtr menus;
}

Expand Down Expand Up @@ -106,6 +110,8 @@ protected override IHeapAlloc AllocateTray(nint menuItemsPtr)
Tray tray = new()
{
iconPath = IconPath,
tooltip = null, // Not supported on Linux/AppIndicator
doubleClickCb = IntPtr.Zero,
menus = menuItemsPtr
};
return HeapAlloc<Tray>.Copy(tray);
Expand Down
1 change: 1 addition & 0 deletions NotificationIcon.NET/NotificationIcon.NET.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<RuntimeIdentifiers>win-x64;linux-x64;</RuntimeIdentifiers>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<DocumentationFile>$(MSBuildProjectDirectory)/bin/$(Configuration)/$(TargetFramework)/NotificationIcon.NET.xml</DocumentationFile>
<LangVersion>12</LangVersion>
</PropertyGroup>

<PropertyGroup Condition="$(Configuration.Contains(Debug))">
Expand Down
79 changes: 74 additions & 5 deletions NotificationIcon.NET/NotifyIcon.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public abstract class NotifyIcon : IDisposable
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void MenuItemCallback(IntPtr menu);

[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void TrayCallback(IntPtr tray);

/// <summary>
/// A path to an icon on the file system. For Windows, this should be an ICO file. For Unix, this should be a PNG.
/// </summary>
Expand All @@ -43,6 +46,35 @@ public string IconPath
}
private string _iconPath;

/// <summary>
/// The tooltip text shown when hovering over the tray icon. Null means no tooltip.
/// On Linux this property is silently ignored (AppIndicator does not support tooltips).
/// </summary>
public string? Tooltip
{
get => _tooltip;
set
{
if (!string.Equals(_tooltip, value))
{
_tooltip = value;
AllocateNewTray(true);
}
}
}
private string? _tooltip;

/// <summary>
/// Returns true if there are any subscribers to the <see cref="DoubleClick"/> event.
/// </summary>
protected bool HasDoubleClickSubscribers => DoubleClick != null;

/// <summary>
/// Raised when the user double-clicks the tray icon.
/// On Linux this event is never raised (AppIndicator does not support click events).
/// </summary>
public event EventHandler<TrayIconClickEventArgs>? DoubleClick;

/// <summary>
/// The currently displayed menu items.
/// </summary>
Expand All @@ -59,6 +91,7 @@ public IReadOnlyList<MenuItem> MenuItems
private IReadOnlyList<MenuItem> _menuItems;

private readonly List<MenuItemCallback> _callbacks;
private TrayCallback? _doubleClickCallback;
private IHeapAlloc trayHandle;
private IHeapAlloc menuItemsHandle;
private Exception? trayLoopException;
Expand All @@ -69,15 +102,28 @@ public IReadOnlyList<MenuItem> MenuItems
/// </summary>
/// <param name="iconPath">A path to an icon on the file system. For Windows, this should be an ICO file. For Unix, this should be a PNG.</param>
/// <param name="menuItems">The menu items to display to the user when clicking the icon.</param>
/// <param name="tooltip">Optional tooltip shown when hovering over the icon. Ignored on Linux.</param>
/// <param name="onDoubleClick">Optional action invoked on double-click. Ignored on Linux.</param>
/// <exception cref="PlatformNotSupportedException"></exception>
/// <exception cref="InvalidOperationException"></exception>
public static NotifyIcon Create(string iconPath, IReadOnlyList<MenuItem> menuItems)
public static NotifyIcon Create(
string iconPath,
IReadOnlyList<MenuItem> menuItems,
string? tooltip = null,
Action<NotifyIcon>? onDoubleClick = null)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that the Create method should accept a delegate. Users can simply subscribe to the DoubleClick event themselves using the returned NotifyIcon instance.

{
NotifyIcon icon;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return new WindowsNotifyIcon(iconPath, menuItems);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
return new LinuxNotifyIcon(iconPath, menuItems);
throw new PlatformNotSupportedException();
icon = new WindowsNotifyIcon(iconPath, menuItems);
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
icon = new LinuxNotifyIcon(iconPath, menuItems);
else
throw new PlatformNotSupportedException();

icon.Tooltip = tooltip;
if (onDoubleClick != null)
icon.DoubleClick += (s, e) => onDoubleClick(icon);
return icon;
}

/// <summary>
Expand Down Expand Up @@ -268,6 +314,28 @@ protected nint CreateNativeClickCallback(MenuItem menuItem)
return Marshal.GetFunctionPointerForDelegate(callback);
}

/// <summary>
/// Creates (or returns cached) a native callback for double-click events.
/// </summary>
protected nint GetOrCreateNativeDoubleClickCallback()
{
if (_doubleClickCallback == null)
{
_doubleClickCallback = trayPtr =>
{
try
{
DoubleClick?.Invoke(this, new TrayIconClickEventArgs(this));
}
catch (Exception ex)
{
trayLoopException = ex;
}
};
}
return Marshal.GetFunctionPointerForDelegate(_doubleClickCallback);
}

/// <summary>
/// Shows this icon in the notification area and reacts to user events.
/// Keeps blocking the thread as long as this icon is shown.
Expand Down Expand Up @@ -297,6 +365,7 @@ public virtual void Dispose()
trayHandle.Dispose();
menuItemsHandle.Dispose();
_callbacks.Clear();
_doubleClickCallback = null;
disposed = true;
}
}
Expand Down
20 changes: 20 additions & 0 deletions NotificationIcon.NET/TrayIconClickEventArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;

namespace NotificationIcon.NET
{
/// <summary>
/// Event arguments for tray icon click events (e.g. double-click).
/// </summary>
public class TrayIconClickEventArgs : EventArgs
{
/// <summary>
/// The <see cref="NotifyIcon"/> that was clicked.
/// </summary>
public NotifyIcon Icon { get; }

public TrayIconClickEventArgs(NotifyIcon icon)
{
Icon = icon;
}
}
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing newline at end of file

7 changes: 6 additions & 1 deletion NotificationIcon.NET/WindowsNotifyIcon.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ private struct Tray
{
[MarshalAs(UnmanagedType.LPWStr)]
public string iconPath;
[MarshalAs(UnmanagedType.LPWStr)]
public string? tooltip;
public IntPtr doubleClickCb;
public IntPtr menus;
}

Expand Down Expand Up @@ -106,8 +109,10 @@ protected override IHeapAlloc AllocateTray(nint menuItemsPtr)
Tray tray = new()
{
iconPath = IconPath,
tooltip = Tooltip,
doubleClickCb = GetOrCreateNativeDoubleClickCallback(),
menus = menuItemsPtr
};
return HeapAlloc<Tray>.Copy(tray);
}
}
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing newline at end of file

Binary file not shown.
6 changes: 3 additions & 3 deletions native/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.0.0)

cmake_minimum_required(VERSION 3.5.0)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any specific reason to raise the minimum?

project(notification_icon VERSION 2.0.0)

add_library(notification_icon SHARED "src/main.c")
if(UNIX AND NOT APPLE)
find_package(PkgConfig REQUIRED)
Expand Down
3 changes: 3 additions & 0 deletions native/src/tray.h
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/*
MIT License

Copyright (c) 2026 Roman Anderson
Copyright (c) 2023 Ori Almagor
Copyright (c) 2017 Serge Zaitsev

Expand Down Expand Up @@ -29,6 +30,8 @@ struct tray_menu;

struct tray {
char* icon;
char* tooltip;
void (*double_click_cb)(struct tray*);
struct tray_menu* menu;
};

Expand Down
35 changes: 28 additions & 7 deletions native/src/tray_windows.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ SOFTWARE.
#define UNICODE

#include <windows.h>
#include <windowsx.h>
#include <shellapi.h>

#define EXPORT __declspec(dllexport)
Expand All @@ -40,6 +41,8 @@ static HWND hwnd;
static HMENU hmenu = NULL;
static LONG isTrayLoopRunning = 0;

static struct tray* _current_tray = NULL;

static LRESULT CALLBACK _tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) {
switch (msg) {
case WM_CLOSE:
Expand All @@ -49,11 +52,18 @@ static LRESULT CALLBACK _tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARA
PostQuitMessage(0);
return 0;
case WM_TRAY_CALLBACK_MESSAGE:
if (lparam == WM_LBUTTONUP || lparam == WM_RBUTTONUP) {
POINT p;
if (!GetCursorPos(&p)) {
break;
{
UINT mouseEvent = LOWORD(lparam);
if (mouseEvent == WM_LBUTTONDBLCLK) {
if (_current_tray != NULL && _current_tray->double_click_cb != NULL) {
_current_tray->double_click_cb(_current_tray);
}
return 0;
}
if (mouseEvent == WM_RBUTTONUP) {
POINT p;
p.x = GET_X_LPARAM(wparam);
p.y = GET_Y_LPARAM(wparam);
SetForegroundWindow(hwnd);
if (hmenu == NULL) {
break;
Expand All @@ -69,6 +79,7 @@ static LRESULT CALLBACK _tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARA
return 0;
}
break;
}
case WM_COMMAND:
{
UINT menuItemId = (UINT)wparam;
Expand Down Expand Up @@ -127,6 +138,7 @@ static HMENU _tray_menu(struct tray_menu* m, UINT* id) {
}

EXPORT void tray_update(struct tray* tray) {
_current_tray = tray;
HMENU prevmenu = hmenu;
UINT id = ID_TRAY_FIRST;
hmenu = _tray_menu(tray->menu, &id);
Expand All @@ -137,6 +149,12 @@ EXPORT void tray_update(struct tray* tray) {
DestroyIcon(nid.hIcon);
}
nid.hIcon = icon;
nid.uFlags |= NIF_TIP;
if (tray->tooltip != NULL) {
wcscpy_s(nid.szTip, sizeof(nid.szTip) / sizeof(WCHAR), (LPCWSTR)tray->tooltip);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use _TRUNCATE to prevent crashes if the tooltip is too long, and _countof instead of calculating manually

Suggested change
wcscpy_s(nid.szTip, sizeof(nid.szTip) / sizeof(WCHAR), (LPCWSTR)tray->tooltip);
wcsncpy_s(nid.szTip, _countof(nid.szTip), (LPCWSTR)tray->tooltip, _TRUNCATE);

} else {
nid.szTip[0] = L'\0';
}
Shell_NotifyIcon(NIM_MODIFY, &nid);

if (prevmenu != NULL) {
Expand Down Expand Up @@ -166,10 +184,14 @@ EXPORT INT32 tray_init(struct tray* tray) {
nid.cbSize = sizeof(NOTIFYICONDATA);
nid.hWnd = hwnd;
nid.uID = 0;
nid.uFlags = NIF_ICON | NIF_MESSAGE;
nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
nid.uCallbackMessage = WM_TRAY_CALLBACK_MESSAGE;
Shell_NotifyIcon(NIM_ADD, &nid);

// Enable version 4 to receive WM_LBUTTONDBLCLK and extended cursor position in wparam
nid.uVersion = NOTIFYICON_VERSION_4;
Shell_NotifyIcon(NIM_SETVERSION, &nid);

tray_update(tray);
return 0;
}
Expand Down Expand Up @@ -217,5 +239,4 @@ EXPORT void tray_exit() {
while (tray_loop(1) == 0)
{ }
}
}

}