From 8d412ede18a4996183dbfc69a0416f5a1a715626 Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Sun, 14 Jun 2026 15:00:42 -0500 Subject: [PATCH] Add support for jump lists --- native/MacNotifyWrapper/MacNotifyWrapper.h | 35 +++ native/MacNotifyWrapper/MacNotifyWrapper.m | 152 ++++++++++ .../Abstractions/IJumpListHandler.cs | 23 ++ .../Abstractions/IJumpListService.cs | 100 +++++++ src/Notify.NET/Abstractions/JumpListTask.cs | 81 ++++++ .../Extensions/ServiceCollectionExtensions.cs | 79 ++++++ src/Notify.NET/Platform/JumpListActivation.cs | 72 +++++ .../Platform/JumpListActivationRouter.cs | 113 ++++++++ .../Platform/Linux/DesktopFileWriter.cs | 224 +++++++++++++++ .../Platform/Linux/LinuxJumpListService.cs | 99 +++++++ .../Platform/MacOS/MacJumpListNative.cs | 45 +++ .../Platform/MacOS/MacOSJumpListService.cs | 142 ++++++++++ .../Platform/SingleInstanceChannel.cs | 166 +++++++++++ .../Windows/CustomDestinationListNative.cs | 183 ++++++++++++ .../Windows/WindowsJumpListService.cs | 264 ++++++++++++++++++ 15 files changed, 1778 insertions(+) create mode 100644 src/Notify.NET/Abstractions/IJumpListHandler.cs create mode 100644 src/Notify.NET/Abstractions/IJumpListService.cs create mode 100644 src/Notify.NET/Abstractions/JumpListTask.cs create mode 100644 src/Notify.NET/Platform/JumpListActivation.cs create mode 100644 src/Notify.NET/Platform/JumpListActivationRouter.cs create mode 100644 src/Notify.NET/Platform/Linux/DesktopFileWriter.cs create mode 100644 src/Notify.NET/Platform/Linux/LinuxJumpListService.cs create mode 100644 src/Notify.NET/Platform/MacOS/MacJumpListNative.cs create mode 100644 src/Notify.NET/Platform/MacOS/MacOSJumpListService.cs create mode 100644 src/Notify.NET/Platform/SingleInstanceChannel.cs create mode 100644 src/Notify.NET/Platform/Windows/CustomDestinationListNative.cs create mode 100644 src/Notify.NET/Platform/Windows/WindowsJumpListService.cs diff --git a/native/MacNotifyWrapper/MacNotifyWrapper.h b/native/MacNotifyWrapper/MacNotifyWrapper.h index be3d464..9e56616 100644 --- a/native/MacNotifyWrapper/MacNotifyWrapper.h +++ b/native/MacNotifyWrapper/MacNotifyWrapper.h @@ -146,6 +146,41 @@ MACNOTIFYAPI bool MNW_HideNotification(int64_t notifId); */ MACNOTIFYAPI void MNW_SetTaskbarProgress(int state, double fraction); +/* ------------------------------------------------------------------------- + * Dock menu (jump-list equivalent) + * + * Adds custom items to the application's Dock menu (shown on right-click / click-and-hold of + * the Dock icon). Unlike Windows jump lists / Linux .desktop actions, Dock-menu items fire a + * live in-process callback — there is no relaunch. + * + * Like the Dock-tile progress API these are only effective for a regular (bundled) GUI + * application with a running main loop; a bare console process has no Dock menu and the calls + * are harmless no-ops. The wrapper provides the menu via the application delegate's + * -applicationDockMenu:, installing its own delegate if the app has none, or adding the method + * to the existing delegate's class if it does not already implement it. + * ------------------------------------------------------------------------- */ + +/** Fired on the main thread when the user clicks a Dock-menu item. taskId is UTF-8. */ +typedef void (*MNW_DockMenuCallback)(const char* taskId); + +/** Registers the callback invoked when a Dock-menu item is clicked. Pass NULL to clear it. */ +MACNOTIFYAPI void MNW_SetDockMenuHandler(MNW_DockMenuCallback callback); + +/** + * Replaces the custom Dock-menu items. + * + * @param ids Array of `count` UTF-8 task ids (passed back to the callback when clicked). + * @param titles Array of `count` UTF-8 item labels, parallel to `ids`. + * @param count Number of items (0 clears the menu). + * + * The arrays are copied before this function returns; the caller may free them afterwards. + * Work is dispatched onto the main thread because AppKit menus are main-thread-only. + */ +MACNOTIFYAPI void MNW_SetDockMenu(const char** ids, const char** titles, int count); + +/** Removes all custom Dock-menu items. Equivalent to MNW_SetDockMenu(NULL, NULL, 0). */ +MACNOTIFYAPI void MNW_ClearDockMenu(void); + #ifdef __cplusplus } #endif diff --git a/native/MacNotifyWrapper/MacNotifyWrapper.m b/native/MacNotifyWrapper/MacNotifyWrapper.m index a7498d2..371e10b 100644 --- a/native/MacNotifyWrapper/MacNotifyWrapper.m +++ b/native/MacNotifyWrapper/MacNotifyWrapper.m @@ -22,6 +22,7 @@ #import #import #import +#import #include #include #include "MacNotifyWrapper.h" @@ -508,3 +509,154 @@ void MNW_SetTaskbarProgress(int state, double fraction) [tile display]; }); } + +/* ------------------------------------------------------------------------- + * Dock menu (jump-list equivalent) + * + * Custom Dock-menu items are supplied to AppKit through the application + * delegate's -applicationDockMenu:. Unlike Windows/Linux this fires a live + * in-process callback — there is no relaunch. + * + * All AppKit objects below are touched only on the main thread (inside the + * dispatched blocks); the C callback pointer is read/written under g_dockLock. + * ------------------------------------------------------------------------- */ + +/* Built/replaced on the main thread; read by -applicationDockMenu: on the main thread. */ +static NSMenu* g_dockMenu = nil; +/* Guards g_dockCb only (the menu is confined to the main thread). */ +static NSLock* g_dockLock = nil; +static MNW_DockMenuCallback g_dockCb = NULL; + +/* Target object for the menu items; routes -onItem: to the managed callback. */ +@interface MNWDockTarget : NSObject +- (void)onItem:(id)sender; +@end + +@implementation MNWDockTarget +- (void)onItem:(id)sender +{ + NSString* taskId = nil; + if ([sender respondsToSelector:@selector(representedObject)]) + taskId = [sender representedObject]; + if (![taskId isKindOfClass:[NSString class]]) return; + + [g_dockLock lock]; + MNW_DockMenuCallback cb = g_dockCb; + [g_dockLock unlock]; + + if (cb) cb([taskId UTF8String]); +} +@end + +/* A minimal delegate used only when the host application has no delegate of its own. */ +@interface MNWDockDelegate : NSObject +@end + +@implementation MNWDockDelegate +- (NSMenu*)applicationDockMenu:(NSApplication*)sender +{ + (void)sender; + return g_dockMenu; +} +@end + +static MNWDockTarget* g_dockTarget = nil; +static MNWDockDelegate* g_dockDelegate = nil; + +/* + * Implementation injected into a pre-existing delegate's class (via class_addMethod) + * when that delegate does not already implement -applicationDockMenu:. + */ +static NSMenu* mnw_dock_menu_imp(id self, SEL _cmd, NSApplication* sender) +{ + (void)self; (void)_cmd; (void)sender; + return g_dockMenu; +} + +/* + * Ensures AppKit will call back into us for the Dock menu. Must run on the main thread. + * - If the app has no delegate, install ours. + * - If it has one that already implements -applicationDockMenu:, leave it alone + * (we cannot compose with the app's own menu without overriding it). + * - Otherwise add -applicationDockMenu: to the existing delegate's class. + */ +static void EnsureDockDelegate(void) +{ + NSApplication* app = [NSApplication sharedApplication]; + id existing = [app delegate]; + + if (existing == nil) { + if (!g_dockDelegate) g_dockDelegate = [[MNWDockDelegate alloc] init]; + [app setDelegate:g_dockDelegate]; + return; + } + + if ([existing respondsToSelector:@selector(applicationDockMenu:)]) + return; /* The host already provides a Dock menu; do not clobber it. */ + + class_addMethod(object_getClass(existing), + @selector(applicationDockMenu:), + (IMP)mnw_dock_menu_imp, + "@@:@"); +} + +static void EnsureDockGlobals(void) +{ + static dispatch_once_t once; + dispatch_once(&once, ^{ + g_dockLock = [[NSLock alloc] init]; + g_dockTarget = [[MNWDockTarget alloc] init]; + }); +} + +void MNW_SetDockMenuHandler(MNW_DockMenuCallback callback) +{ + EnsureDockGlobals(); + [g_dockLock lock]; + g_dockCb = callback; + [g_dockLock unlock]; +} + +void MNW_SetDockMenu(const char** ids, const char** titles, int count) +{ + EnsureDockGlobals(); + + /* Copy the C strings into NSStrings synchronously; the caller may free the + * arrays as soon as this function returns. */ + NSMutableArray* idArr = [NSMutableArray arrayWithCapacity:(count > 0 ? count : 0)]; + NSMutableArray* titleArr = [NSMutableArray arrayWithCapacity:(count > 0 ? count : 0)]; + for (int i = 0; i < count; i++) { + const char* idC = ids ? ids[i] : NULL; + const char* titleC = titles ? titles[i] : NULL; + if (!idC || !titleC) continue; + [idArr addObject:[NSString stringWithUTF8String:idC]]; + [titleArr addObject:[NSString stringWithUTF8String:titleC]]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + if (idArr.count == 0) { + g_dockMenu = nil; + EnsureDockDelegate(); + return; + } + + NSMenu* menu = [[NSMenu alloc] init]; + for (NSUInteger i = 0; i < idArr.count; i++) { + NSMenuItem* item = [[NSMenuItem alloc] + initWithTitle:titleArr[i] + action:@selector(onItem:) + keyEquivalent:@""]; + item.target = g_dockTarget; + item.representedObject = idArr[i]; + [menu addItem:item]; + } + + g_dockMenu = menu; + EnsureDockDelegate(); + }); +} + +void MNW_ClearDockMenu(void) +{ + MNW_SetDockMenu(NULL, NULL, 0); +} diff --git a/src/Notify.NET/Abstractions/IJumpListHandler.cs b/src/Notify.NET/Abstractions/IJumpListHandler.cs new file mode 100644 index 0000000..3e85340 --- /dev/null +++ b/src/Notify.NET/Abstractions/IJumpListHandler.cs @@ -0,0 +1,23 @@ +namespace Notify.NET.Abstractions +{ + /// + /// Receives activation events when the user clicks an entry in the application's jump list, + /// launcher shortcut menu or Dock menu. + /// + public interface IJumpListHandler + { + /// + /// Called when the user invokes a jump-list task. + /// + /// On Windows and Linux the click relaunches the executable, and the bundled single-instance + /// layer forwards the activation to the already-running primary instance, where this method + /// is invoked. On a cold start (no primary instance was running) the activation is replayed + /// once a handler has been registered. On macOS the Dock-menu click invokes this method + /// directly, in-process. + /// + /// This callback may be invoked on a background thread; marshal to the UI thread if required. + /// + /// The of the task that was clicked. + void OnTaskActivated(string taskId); + } +} diff --git a/src/Notify.NET/Abstractions/IJumpListService.cs b/src/Notify.NET/Abstractions/IJumpListService.cs new file mode 100644 index 0000000..5ff9696 --- /dev/null +++ b/src/Notify.NET/Abstractions/IJumpListService.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; + +namespace Notify.NET.Abstractions +{ + /// + /// Manages the application's jump list (Windows), launcher shortcut menu (Linux + /// .desktop Actions) or Dock menu (macOS), with a bundled live-callback layer so a + /// clicked task is delivered to the already-running instance via + /// . + /// + /// Activation model. Jump-list and .desktop tasks fundamentally relaunch + /// the executable; macOS Dock menus fire in-process. To present a single, uniform live-callback + /// API across all three, this service bundles a single-instance channel: + /// + /// + /// A clicked task on Windows/Linux relaunches the app with a hidden activation argument. + /// + /// + /// Call at the very start of Main. If this launch is + /// such a relaunch and a primary instance is already running, the activation is forwarded to + /// it over a named-pipe channel and the method returns true — the caller should exit + /// immediately without showing any UI. + /// + /// + /// Otherwise the method returns false and the app continues normal startup. The first + /// call to registers the OS jump list and (lazily) starts the + /// single-instance listener, making this process the primary instance. If this launch was a + /// cold-start activation (no primary was running), the pending task is replayed to the + /// handler once one is set. + /// + /// + /// + /// Nothing is registered and no listener, mutex or pipe is created until + /// (or ) is first called, so applications that do + /// not use jump lists incur no overhead. + /// + /// Capability notes: + /// + /// + /// Windows — uses the shell ICustomDestinationList "user tasks" (Windows 7+). + /// Requires the same AppUserModelId used for notifications so the list attaches to the + /// correct taskbar button. + /// + /// + /// Linux — writes Actions into the application's .desktop file (honoured + /// by GNOME, KDE, Unity and others). Requires NotificationOptions.DesktopFileId; if no + /// installed .desktop file is found, a minimal one is created under + /// ~/.local/share/applications. + /// + /// + /// macOS — adds items to the Dock menu via the application delegate. Only effective for + /// a bundled GUI application with a running main loop; a bare console process has no Dock + /// menu. No relaunch or forwarding is involved. + /// + /// + /// When jump lists are not available on the current platform, is + /// false and all methods are silent no-ops ( returns + /// false). + /// + public interface IJumpListService : IDisposable + { + /// + /// Whether jump lists / launcher actions / Dock-menu items are available on this platform. + /// When false, all other methods are silent no-ops. + /// + bool IsSupported { get; } + + /// + /// Registers the handler that receives events. + /// Calling this with a non-null handler also starts the single-instance listener (if not + /// already started) and replays any activation captured during a cold start. Pass null + /// to detach the current handler. + /// + void SetHandler(IJumpListHandler? handler); + + /// + /// Replaces the application's jump-list tasks with the supplied set. The first call also + /// starts the single-instance listener, making this process the primary instance. An empty + /// sequence is equivalent to . + /// + void SetTasks(IEnumerable tasks); + + /// Removes all jump-list tasks registered by this application. + void ClearTasks(); + + /// + /// Inspects the process command-line arguments for a jump-list activation. Call this once, as + /// early as possible in Main, before any UI is shown. + /// + /// The arguments passed to Main. + /// + /// true if this launch was a jump-list activation that has been forwarded to an + /// already-running primary instance and the caller should exit immediately; otherwise + /// false (continue normal startup — the activation, if any, will be replayed to the + /// handler once this instance becomes primary). + /// + bool TryHandleActivation(string[] args); + } +} diff --git a/src/Notify.NET/Abstractions/JumpListTask.cs b/src/Notify.NET/Abstractions/JumpListTask.cs new file mode 100644 index 0000000..f6e5557 --- /dev/null +++ b/src/Notify.NET/Abstractions/JumpListTask.cs @@ -0,0 +1,81 @@ +using System; + +namespace Notify.NET.Abstractions +{ + /// + /// A single entry in an application's jump list (Windows), launcher shortcut menu + /// (Linux .desktop Actions) or Dock menu (macOS). + /// + /// A task represents an action the user can trigger by right-clicking the application's + /// taskbar/launcher/Dock icon. When clicked, the bundled live-callback layer routes the + /// task's back to in the + /// already-running instance (see for the model). + /// + public sealed class JumpListTask + { + /// + /// A stable, machine-readable identifier for this task (e.g. "open-library"). + /// It is passed back to when the task is + /// invoked, and is embedded in the relaunch command line on Windows/Linux, so it must + /// not contain whitespace or characters that need shell quoting. Use letters, digits, + /// - and _. + /// + public string Id { get; } + + /// The human-readable label shown in the menu (e.g. "Open Library"). + public string Title { get; } + + /// + /// Optional tooltip/description. Shown on Windows jump-list tasks on hover. + /// Ignored on Linux and macOS. + /// + public string? Description { get; } + + /// + /// Optional path to an icon. On Windows this is a path to an .ico, .exe or + /// .dll file whose icon at is shown next to the task. + /// On Linux it is an icon name (per the freedesktop icon theme) or absolute path written + /// into the .desktop Action. Ignored on macOS (Dock menus do not show item icons). + /// + public string? IconPath { get; } + + /// + /// The zero-based index of the icon to use within when it refers to + /// a multi-icon file (e.g. an .exe/.dll). Windows only; defaults to 0. + /// + public int IconIndex { get; } + + /// A stable, whitespace-free identifier passed to the handler when invoked. + /// The label shown in the menu. + /// Optional Windows-only tooltip. + /// Optional icon file (Windows) or icon name/path (Linux). + /// Icon index within (Windows only). + public JumpListTask( + string id, + string title, + string? description = null, + string? iconPath = null, + int iconIndex = 0) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("Task id must not be empty.", nameof(id)); + if (HasWhitespace(id)) + throw new ArgumentException("Task id must not contain whitespace.", nameof(id)); + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("Task title must not be empty.", nameof(title)); + + Id = id; + Title = title; + Description = description; + IconPath = iconPath; + IconIndex = iconIndex; + } + + private static bool HasWhitespace(string s) + { + foreach (char c in s) + if (char.IsWhiteSpace(c)) return true; + return false; + } + } +} diff --git a/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs b/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs index 710bea6..bd26429 100644 --- a/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs +++ b/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs @@ -122,6 +122,63 @@ namespace Notify.NET.Extensions return new NullTaskbarProgressService(); } + + /// + /// Registers as a singleton, using the + /// platform-appropriate backend: + /// + /// Windows → (ICustomDestinationList user tasks) + /// Linux → (freedesktop.org Desktop Actions) + /// macOS → (Dock menu) + /// Other → ( = false) + /// + /// + /// On Windows and Linux a clicked task relaunches the executable with + /// --notify-jumplist <id>; the bundled single-instance layer forwards the id to the + /// running primary instance so the handler fires live. The IPC listener and OS registration + /// are created lazily — only when tasks or a handler are actually configured. + /// + /// The service collection to add to. + /// Optional delegate to configure . + public static IServiceCollection AddJumpList( + this IServiceCollection services, + Action? configure = null) + { + var options = new NotificationOptions(); + configure?.Invoke(options); + + services.AddSingleton(_ => CreateJumpListServiceCore(options)); + return services; + } + + /// + /// Creates the platform-appropriate directly + /// (without a DI container), for use in simple console applications. + /// + public static IJumpListService CreateJumpListService( + Action? configure = null) + { + var opts = new NotificationOptions(); + configure?.Invoke(opts); + return CreateJumpListServiceCore(opts); + } + + private static IJumpListService CreateJumpListServiceCore(NotificationOptions opts) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return new WindowsJumpListService(opts.AppUserModelId, opts.ExecutablePath); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return new LinuxJumpListService( + opts.AppName, + opts.DesktopFileId ?? System.Diagnostics.Process.GetCurrentProcess().ProcessName, + opts.ExecutablePath); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return new MacOSJumpListService(); + + return new NullJumpListService(); + } } /// @@ -161,6 +218,14 @@ namespace Notify.NET.Extensions /// the process name is used. Ignored on Windows and macOS. /// public string? DesktopFileId { get; set; } + + /// + /// Absolute path to the executable a jump-list task relaunches when clicked (Windows and + /// Linux only). When null, the current process executable is used. For framework-dependent + /// dotnet apps the auto-detected path may be the shared host rather than your app, so + /// pass an explicit path in that case. Ignored on macOS (the Dock menu fires live, no relaunch). + /// + public string? ExecutablePath { get; set; } } /// @@ -194,4 +259,18 @@ namespace Notify.NET.Extensions public void SetWindow(IntPtr windowHandle) { } public void Dispose() { } } + + /// + /// No-op implementation used when the current platform has no supported jump-list backend. + /// is always false and every method is a silent no-op. + /// + internal sealed class NullJumpListService : IJumpListService + { + public bool IsSupported => false; + public bool TryHandleActivation(string[] args) => false; + public void SetHandler(IJumpListHandler? handler) { } + public void SetTasks(System.Collections.Generic.IEnumerable tasks) { } + public void ClearTasks() { } + public void Dispose() { } + } } diff --git a/src/Notify.NET/Platform/JumpListActivation.cs b/src/Notify.NET/Platform/JumpListActivation.cs new file mode 100644 index 0000000..fdc11cf --- /dev/null +++ b/src/Notify.NET/Platform/JumpListActivation.cs @@ -0,0 +1,72 @@ +using System; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +namespace Notify.NET.Platform +{ + /// + /// Shared helpers for the jump-list relaunch protocol used on Windows and Linux. + /// + /// When the user clicks a jump-list/launcher task the OS relaunches the executable with + /// followed by the task id, e.g. + /// myapp --notify-jumplist open-library. The bundled single-instance layer parses this, + /// forwards the id to the primary instance and exits. + /// + internal static class JumpListActivation + { + /// The command-line flag that precedes a jump-list task id on relaunch. + internal const string ActivationFlag = "--notify-jumplist"; + + /// + /// Extracts the task id from a jump-list activation command line, or null if these + /// arguments are not a jump-list activation. + /// + internal static string? TryParseTaskId(string[]? args) + { + if (args == null) return null; + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], ActivationFlag, StringComparison.Ordinal)) + { + string id = args[i + 1]; + return string.IsNullOrWhiteSpace(id) ? null : id; + } + } + return null; + } + + /// + /// Builds a stable channel name (named pipe on Windows, Unix-domain socket name on Linux) + /// for the single-instance listener. Derived from a caller-supplied key (the AppUserModelId + /// on Windows or the .desktop id on Linux) so that all instances of the same application — + /// and only that application — rendezvous on the same channel. + /// + internal static string ChannelName(string key) + { + // Hash the key so the channel name is fixed-length and free of path-hostile characters. + using var sha = SHA256.Create(); + byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(key ?? string.Empty)); + var sb = new StringBuilder("notifynet-jl-", 29); + for (int i = 0; i < 8; i++) sb.Append(hash[i].ToString("x2")); + return sb.ToString(); + } + + /// + /// Best-effort absolute path to the current process executable, used as the relaunch target. + /// + internal static string CurrentExecutablePath() + { + try + { + string? path = Process.GetCurrentProcess().MainModule?.FileName; + if (!string.IsNullOrEmpty(path)) return path!; + } + catch + { + /* MainModule can throw for some hosts; fall through. */ + } + return AppContext.BaseDirectory; + } + } +} diff --git a/src/Notify.NET/Platform/JumpListActivationRouter.cs b/src/Notify.NET/Platform/JumpListActivationRouter.cs new file mode 100644 index 0000000..b4e6e4d --- /dev/null +++ b/src/Notify.NET/Platform/JumpListActivationRouter.cs @@ -0,0 +1,113 @@ +using System; +using System.Threading; +using Notify.NET.Abstractions; + +namespace Notify.NET.Platform +{ + /// + /// Encapsulates the single-instance activation logic shared by the Windows and Linux jump-list + /// services: forwarding a clicked task to the primary instance, listening for forwarded + /// activations, and replaying a cold-start activation once a handler is registered. + /// + /// The owning service supplies only the platform-specific channel key and consumes the routed + /// task ids via the handler it sets. Nothing is created until or a + /// non-null is first called. + /// + internal sealed class JumpListActivationRouter : IDisposable + { + private readonly string _channelName; + private readonly object _gate = new object(); + + private SingleInstanceChannel? _channel; + private IJumpListHandler? _handler; + private string? _pending; + private bool _disposed; + + internal JumpListActivationRouter(string channelName) + { + _channelName = channelName; + } + + /// + /// Handles a possible jump-list activation command line. Returns true if the activation + /// was forwarded to an already-running primary instance (caller should exit); otherwise + /// false (the activation, if any, is captured for cold-start replay). + /// + internal bool TryHandleActivation(string[] args) + { + if (_disposed) return false; + + string? taskId = JumpListActivation.TryParseTaskId(args); + if (taskId == null) return false; + + if (SingleInstanceChannel.TryForward(_channelName, taskId)) + return true; + + lock (_gate) _pending = taskId; + return false; + } + + /// Sets (or clears) the handler and starts listening when a handler is attached. + internal void SetHandler(IJumpListHandler? handler) + { + if (_disposed) return; + lock (_gate) + { + _handler = handler; + if (handler != null) EnsureListening_NoLock(); + } + } + + /// + /// Becomes the primary instance (if elected) and begins listening for forwarded activations. + /// Called by the service the first time tasks are registered. + /// + internal void EnsureListening() + { + if (_disposed) return; + lock (_gate) EnsureListening_NoLock(); + } + + private void EnsureListening_NoLock() + { + _channel ??= new SingleInstanceChannel(_channelName); + _channel.EnsureListening(OnForwardedActivation); + ReplayPending_NoLock(); + } + + private void ReplayPending_NoLock() + { + if (_handler == null || _pending == null) return; + if (_channel == null || !_channel.IsPrimary) return; + + string id = _pending; + _pending = null; + IJumpListHandler handler = _handler; + ThreadPool.QueueUserWorkItem(_ => SafeInvoke(handler, id)); + } + + private void OnForwardedActivation(string taskId) + { + IJumpListHandler? handler; + lock (_gate) handler = _handler; + if (handler != null) SafeInvoke(handler, taskId); + } + + private static void SafeInvoke(IJumpListHandler handler, string taskId) + { + try { handler.OnTaskActivated(taskId); } + catch { /* a handler exception must never crash the listener */ } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + _disposed = true; + _channel?.Dispose(); + _channel = null; + } + } + } +} diff --git a/src/Notify.NET/Platform/Linux/DesktopFileWriter.cs b/src/Notify.NET/Platform/Linux/DesktopFileWriter.cs new file mode 100644 index 0000000..9cf1a31 --- /dev/null +++ b/src/Notify.NET/Platform/Linux/DesktopFileWriter.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Notify.NET.Abstractions; +using Notify.NET.Platform; + +namespace Notify.NET.Platform.Linux +{ + /// + /// Writes freedesktop.org "Desktop Actions" (launcher shortcut entries) into an application's + /// .desktop file. Desktop Actions appear in the right-click menu of the launcher/taskbar + /// icon on GNOME, KDE, Unity and other environments; each action's Exec relaunches the + /// executable with a jump-list activation argument. + /// + /// The file's Actions key and all [Desktop Action *] groups are owned and managed + /// by this writer — existing ones are replaced on each write. If no installed .desktop + /// file exists for the application, a minimal one is created under + /// $XDG_DATA_HOME/applications (default ~/.local/share/applications). + /// + internal static class DesktopFileWriter + { + /// Registers the supplied tasks as Desktop Actions, creating/merging the file. + internal static void WriteActions( + string desktopFileId, + string appName, + string executablePath, + IReadOnlyList tasks) + { + string path = ResolveUserDesktopPath(desktopFileId); + string? source = FindExistingDesktopPath(desktopFileId) ?? (File.Exists(path) ? path : null); + + List
sections = source != null + ? ParseSections(File.ReadAllLines(source)) + : CreateMinimal(appName, executablePath); + + ApplyActions(sections, executablePath, tasks); + WriteFile(path, sections); + } + + /// Removes the Actions key and all action groups managed by this writer. + internal static void RemoveActions(string desktopFileId) + { + string path = ResolveUserDesktopPath(desktopFileId); + if (!File.Exists(path)) return; + + List
sections = ParseSections(File.ReadAllLines(path)); + ApplyActions(sections, executablePath: null, tasks: Array.Empty()); + WriteFile(path, sections); + } + + // ------------------------------------------------------------------ + // Path resolution + // ------------------------------------------------------------------ + + private static string StripSuffix(string id) => + id.EndsWith(".desktop", StringComparison.Ordinal) ? id.Substring(0, id.Length - 8) : id; + + private static string DataHome() + { + string? xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + if (!string.IsNullOrEmpty(xdg)) return xdg!; + string home = Environment.GetEnvironmentVariable("HOME") ?? "~"; + return Path.Combine(home, ".local", "share"); + } + + /// The user-writable path we always write to. + internal static string ResolveUserDesktopPath(string desktopFileId) + { + string id = StripSuffix(desktopFileId); + return Path.Combine(DataHome(), "applications", id + ".desktop"); + } + + /// + /// Looks for an existing installed .desktop file (user dir first, then the system + /// XDG_DATA_DIRS) to use as the merge source. Returns null if none exists. + /// + private static string? FindExistingDesktopPath(string desktopFileId) + { + string id = StripSuffix(desktopFileId); + string fileName = id + ".desktop"; + + string userPath = Path.Combine(DataHome(), "applications", fileName); + if (File.Exists(userPath)) return userPath; + + string dataDirs = Environment.GetEnvironmentVariable("XDG_DATA_DIRS") + ?? "/usr/local/share:/usr/share"; + foreach (string dir in dataDirs.Split(':')) + { + if (string.IsNullOrEmpty(dir)) continue; + string candidate = Path.Combine(dir, "applications", fileName); + if (File.Exists(candidate)) return candidate; + } + return null; + } + + // ------------------------------------------------------------------ + // Section model + parsing + // ------------------------------------------------------------------ + + private sealed class Section + { + public string Header = ""; // e.g. "[Desktop Entry]" + public readonly List Lines = new List(); // body lines (excluding header) + + public bool IsHeader(string name) => + Header.Equals("[" + name + "]", StringComparison.Ordinal); + + public bool IsDesktopActionGroup => + Header.StartsWith("[Desktop Action ", StringComparison.Ordinal); + } + + private static List
ParseSections(string[] lines) + { + var sections = new List
(); + Section? current = null; + // Preserve any leading comments/blank lines before the first group as a headerless section. + var preamble = new Section { Header = "" }; + + foreach (string line in lines) + { + string trimmed = line.TrimStart(); + if (trimmed.StartsWith("[", StringComparison.Ordinal) && trimmed.EndsWith("]", StringComparison.Ordinal)) + { + current = new Section { Header = trimmed }; + sections.Add(current); + } + else if (current != null) + { + current.Lines.Add(line); + } + else + { + preamble.Lines.Add(line); + } + } + + if (preamble.Lines.Count > 0) + sections.Insert(0, preamble); + return sections; + } + + private static List
CreateMinimal(string appName, string executablePath) + { + var entry = new Section { Header = "[Desktop Entry]" }; + entry.Lines.Add("Type=Application"); + entry.Lines.Add("Name=" + appName); + entry.Lines.Add("Exec=" + QuoteExec(executablePath)); + entry.Lines.Add("Terminal=false"); + return new List
{ entry }; + } + + // ------------------------------------------------------------------ + // Action application + // ------------------------------------------------------------------ + + private static void ApplyActions( + List
sections, string? executablePath, IReadOnlyList tasks) + { + // 1. Drop all existing Desktop Action groups (we own them). + sections.RemoveAll(s => s.IsDesktopActionGroup); + + // 2. Find (or create) the [Desktop Entry] group and reset its Actions key. + Section? entry = sections.Find(s => s.IsHeader("Desktop Entry")); + if (entry == null) + { + entry = new Section { Header = "[Desktop Entry]" }; + sections.Insert(0, entry); + } + entry.Lines.RemoveAll(l => l.TrimStart().StartsWith("Actions=", StringComparison.Ordinal)); + + if (tasks.Count == 0) return; // RemoveActions path: leave no Actions key, no groups. + + var ids = new StringBuilder(); + foreach (JumpListTask t in tasks) ids.Append(t.Id).Append(';'); + entry.Lines.Add("Actions=" + ids); + + // 3. Append a group per task. + foreach (JumpListTask t in tasks) + { + var group = new Section { Header = "[Desktop Action " + t.Id + "]" }; + group.Lines.Add("Name=" + t.Title); + group.Lines.Add("Exec=" + QuoteExec(executablePath!) + + " " + JumpListActivation.ActivationFlag + " " + t.Id); + if (!string.IsNullOrEmpty(t.IconPath)) + group.Lines.Add("Icon=" + t.IconPath); + sections.Add(group); + } + } + + /// Quotes an executable path for a Desktop Entry Exec value if needed. + private static string QuoteExec(string exec) + { + if (exec.IndexOf(' ') < 0) return exec; + // Desktop spec uses double quotes; escape embedded backslashes and quotes. + return "\"" + exec.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + } + + // ------------------------------------------------------------------ + // Writing + // ------------------------------------------------------------------ + + private static void WriteFile(string path, List
sections) + { + string? dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir!); + + var sb = new StringBuilder(); + bool first = true; + foreach (Section s in sections) + { + if (!string.IsNullOrEmpty(s.Header)) + { + if (!first) sb.AppendLine(); + sb.AppendLine(s.Header); + } + foreach (string line in s.Lines) sb.AppendLine(line); + first = false; + } + + File.WriteAllText(path, sb.ToString()); + } + } +} diff --git a/src/Notify.NET/Platform/Linux/LinuxJumpListService.cs b/src/Notify.NET/Platform/Linux/LinuxJumpListService.cs new file mode 100644 index 0000000..8d9eabb --- /dev/null +++ b/src/Notify.NET/Platform/Linux/LinuxJumpListService.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using Notify.NET.Abstractions; +using Notify.NET.Platform; + +namespace Notify.NET.Platform.Linux +{ + /// + /// implementation that registers launcher shortcut tasks as + /// freedesktop.org Desktop Actions in the application's .desktop file (see + /// ), honoured by GNOME, KDE, Unity and others. + /// + /// Clicking an action relaunches the executable with --notify-jumplist <id>; the bundled + /// forwards the id to the running primary instance so + /// fires live (or replays it on a cold start). + /// + public sealed class LinuxJumpListService : IJumpListService + { + private readonly string _appName; + private readonly string _desktopFileId; + private readonly string _executablePath; + private readonly JumpListActivationRouter _router; + private volatile bool _disposed; + + /// + public bool IsSupported => true; + + /// Human-readable application name, used if a new .desktop file is created. + /// + /// The application's .desktop file id (with or without the ".desktop" suffix). Identifies + /// which launcher entry the actions are written into and keys the single-instance channel. + /// + /// + /// Absolute command used to relaunch the app for an action's Exec. When null, the + /// current process executable is used (note: for framework-dependent dotnet apps this may be + /// the host; pass an explicit path for those). + /// + public LinuxJumpListService(string appName, string desktopFileId, string? executablePath = null) + { + _appName = appName ?? throw new ArgumentNullException(nameof(appName)); + _desktopFileId = desktopFileId ?? throw new ArgumentNullException(nameof(desktopFileId)); + _executablePath = executablePath ?? JumpListActivation.CurrentExecutablePath(); + _router = new JumpListActivationRouter(JumpListActivation.ChannelName(_desktopFileId)); + } + + /// + public bool TryHandleActivation(string[] args) + { + if (_disposed) return false; + return _router.TryHandleActivation(args); + } + + /// + public void SetHandler(IJumpListHandler? handler) + { + if (_disposed) return; + _router.SetHandler(handler); + } + + /// + public void SetTasks(IEnumerable tasks) + { + if (_disposed) return; + if (tasks == null) throw new ArgumentNullException(nameof(tasks)); + + var list = new List(tasks); + _router.EnsureListening(); + + try + { + if (list.Count == 0) + DesktopFileWriter.RemoveActions(_desktopFileId); + else + DesktopFileWriter.WriteActions(_desktopFileId, _appName, _executablePath, list); + } + catch (Exception) + { + // Writing the .desktop file is best-effort; a read-only or absent home directory + // must not bring the application down. + } + } + + /// + public void ClearTasks() + { + if (_disposed) return; + try { DesktopFileWriter.RemoveActions(_desktopFileId); } + catch (Exception) { /* best effort */ } + } + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _router.Dispose(); + } + } +} diff --git a/src/Notify.NET/Platform/MacOS/MacJumpListNative.cs b/src/Notify.NET/Platform/MacOS/MacJumpListNative.cs new file mode 100644 index 0000000..db7d323 --- /dev/null +++ b/src/Notify.NET/Platform/MacOS/MacJumpListNative.cs @@ -0,0 +1,45 @@ +using System; +using System.Runtime.InteropServices; + +namespace Notify.NET.Platform.MacOS +{ + /// + /// P/Invoke declarations for the Dock-menu ("jump list") entry points exported by + /// libMacNotifyWrapper.dylib (see MacNotifyWrapper.h). + /// + /// Unlike the Windows/Linux jump lists, the macOS Dock menu fires a live in-process + /// callback () — there is no relaunch. The entry points are + /// only effective for a regular bundled GUI application with a running main loop; a bare + /// console process has no Dock menu and the calls are harmless no-ops. + /// + /// All strings are UTF-8; on macOS the ANSI code page is UTF-8 so + /// marshalling is a faithful round-trip. Every function uses the C calling convention (cdecl). + /// + internal static class MacJumpListNative + { + internal const string LibName = "MacNotifyWrapper"; + + /// + /// Fired on the main thread when the user clicks a Dock-menu item; + /// is the id supplied to for that item. + /// + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + internal delegate void DockMenuCallback([MarshalAs(UnmanagedType.LPStr)] string taskId); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + internal static extern bool MNW_IsSupported(); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern void MNW_SetDockMenuHandler(DockMenuCallback? callback); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern void MNW_SetDockMenu( + [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPStr)] string[]? ids, + [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPStr)] string[]? titles, + int count); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern void MNW_ClearDockMenu(); + } +} diff --git a/src/Notify.NET/Platform/MacOS/MacOSJumpListService.cs b/src/Notify.NET/Platform/MacOS/MacOSJumpListService.cs new file mode 100644 index 0000000..6d40966 --- /dev/null +++ b/src/Notify.NET/Platform/MacOS/MacOSJumpListService.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Notify.NET.Abstractions; + +namespace Notify.NET.Platform.MacOS +{ + /// + /// implementation backed by the application's macOS Dock menu + /// (shown on right-click / click-and-hold of the Dock icon), provided through the native + /// libMacNotifyWrapper.dylib (MNW_SetDockMenu and friends). + /// + /// Unlike the Windows and Linux services there is no relaunch and no single-instance + /// forwarding: clicking a Dock-menu item fires + /// live in the running process. Consequently never matches + /// — there is no activation command line on macOS. + /// + /// The Dock menu is only effective for a regular bundled GUI application with a running main + /// loop; a bare console process has no Dock menu and the native calls are harmless no-ops. + /// + public sealed class MacOSJumpListService : IJumpListService + { + // A single static delegate kept alive for the whole process so the native side always has + // a valid function pointer to invoke (mirrors MacNotifyCallbackBridge). + private static readonly MacJumpListNative.DockMenuCallback _staticCallback; + + private static IJumpListHandler? _handler; + private static readonly object _handlerGate = new object(); + + private volatile bool _disposed; + + /// + public bool IsSupported { get; } + + static MacOSJumpListService() + { + _staticCallback = OnDockItemActivated; + } + + public MacOSJumpListService() + { + try + { + MacOSNativeLibraryLoader.EnsureLoaded(); + IsSupported = MacJumpListNative.MNW_IsSupported(); + } + catch (DllNotFoundException) + { + IsSupported = false; + } + } + + // ------------------------------------------------------------------ + // IJumpListService + // ------------------------------------------------------------------ + + /// + public bool TryHandleActivation(string[] args) + { + // macOS dock-menu clicks are delivered live in-process; there is no relaunch with an + // activation command line to handle. + return false; + } + + /// + public void SetHandler(IJumpListHandler? handler) + { + if (_disposed || !IsSupported) return; + + lock (_handlerGate) _handler = handler; + + // Register (or clear) the native callback only when a handler is actually attached, + // honouring the "don't register unless used" requirement. + MacJumpListNative.MNW_SetDockMenuHandler(handler != null ? _staticCallback : null); + } + + /// + public void SetTasks(IEnumerable tasks) + { + if (_disposed || !IsSupported) return; + if (tasks == null) throw new ArgumentNullException(nameof(tasks)); + + var list = new List(tasks); + if (list.Count == 0) + { + MacJumpListNative.MNW_ClearDockMenu(); + return; + } + + var ids = new string[list.Count]; + var titles = new string[list.Count]; + for (int i = 0; i < list.Count; i++) + { + ids[i] = list[i].Id; + titles[i] = list[i].Title; + } + + MacJumpListNative.MNW_SetDockMenu(ids, titles, list.Count); + } + + /// + public void ClearTasks() + { + if (_disposed || !IsSupported) return; + MacJumpListNative.MNW_ClearDockMenu(); + } + + // ------------------------------------------------------------------ + // IDisposable + // ------------------------------------------------------------------ + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + if (!IsSupported) return; + try + { + MacJumpListNative.MNW_ClearDockMenu(); + MacJumpListNative.MNW_SetDockMenuHandler(null); + } + catch { /* best effort */ } + + lock (_handlerGate) _handler = null; + } + + // ------------------------------------------------------------------ + // Native callback routing — invoked on the main thread from AppKit + // ------------------------------------------------------------------ + + private static void OnDockItemActivated(string taskId) + { + IJumpListHandler? handler; + lock (_handlerGate) handler = _handler; + + try { handler?.OnTaskActivated(taskId); } + catch { /* a handler exception must never propagate into native code */ } + } + } +} diff --git a/src/Notify.NET/Platform/SingleInstanceChannel.cs b/src/Notify.NET/Platform/SingleInstanceChannel.cs new file mode 100644 index 0000000..9811912 --- /dev/null +++ b/src/Notify.NET/Platform/SingleInstanceChannel.cs @@ -0,0 +1,166 @@ +using System; +using System.IO; +using System.IO.Pipes; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Notify.NET.Platform +{ + /// + /// A minimal single-instance forwarding channel shared by the Windows and Linux jump-list + /// services. Implemented with a named pipe (which the .NET runtime maps to a named pipe on + /// Windows and a Unix-domain socket on Linux), plus a named to elect the + /// primary instance. + /// + /// The primary instance (the first to call ) runs a background loop + /// that accepts connections and invokes a callback with each received task id. Any instance can + /// statically a task id to the primary; if no primary is listening the + /// call returns false and the caller treats it as a cold start. + /// + /// Nothing here is created until a jump-list service actually needs it, honouring the + /// "no overhead unless used" contract. + /// + internal sealed class SingleInstanceChannel : IDisposable + { + private readonly string _pipeName; + private readonly Mutex _mutex; + private readonly bool _isPrimary; + + private CancellationTokenSource? _cts; + private Task? _listenTask; + private volatile bool _disposed; + + /// Whether this process won the election and is the listening primary instance. + internal bool IsPrimary => _isPrimary; + + internal SingleInstanceChannel(string channelName) + { + _pipeName = channelName; + // initiallyOwned: true means we try to take ownership; createdNew tells us whether this + // call created the kernel object, which we use as the primary-election signal. + _mutex = new Mutex(initiallyOwned: true, name: channelName + "-mtx", out bool createdNew); + _isPrimary = createdNew; + } + + /// + /// Starts the background accept loop if this process is the primary instance and the loop is + /// not already running. Safe to call repeatedly. No-op for non-primary instances. + /// + internal void EnsureListening(Action onActivated) + { + if (_disposed || !_isPrimary || _listenTask != null) return; + + _cts = new CancellationTokenSource(); + _listenTask = Task.Run(() => AcceptLoopAsync(onActivated, _cts.Token)); + } + + private async Task AcceptLoopAsync(Action onActivated, CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + using var server = new NamedPipeServerStream( + _pipeName, + PipeDirection.In, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + + await server.WaitForConnectionAsync(token).ConfigureAwait(false); + + string taskId = await ReadAllAsync(server, token).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(taskId)) + { + try { onActivated(taskId.Trim()); } + catch { /* never let a handler exception kill the accept loop */ } + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception) + { + // Transient pipe error — pause briefly so we don't spin on a persistent failure. + try { await Task.Delay(50, token).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + } + } + } + + private static async Task ReadAllAsync(Stream stream, CancellationToken token) + { + var buffer = new byte[256]; + var sb = new StringBuilder(); + int read; + while ((read = await stream.ReadAsync(buffer, 0, buffer.Length, token).ConfigureAwait(false)) > 0) + sb.Append(Encoding.UTF8.GetString(buffer, 0, read)); + return sb.ToString(); + } + + /// + /// Attempts to deliver to a primary instance listening on + /// . Returns true if a primary accepted the connection and + /// the id was written; false if no primary is listening (a cold start). + /// + internal static bool TryForward(string pipeName, string taskId, int timeoutMs = 400) + { + try + { + using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.Out); + client.Connect(timeoutMs); + byte[] payload = Encoding.UTF8.GetBytes(taskId); + client.Write(payload, 0, payload.Length); + client.Flush(); + return true; + } + catch (TimeoutException) + { + return false; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + try { _cts?.Cancel(); } catch { /* best effort */ } + + try + { + // Unblock a pending WaitForConnectionAsync by briefly connecting to our own pipe. + if (_isPrimary && _listenTask != null) + { + try + { + using var unblock = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out); + unblock.Connect(100); + } + catch { /* listener may already be gone */ } + _listenTask.Wait(TimeSpan.FromSeconds(2)); + } + } + catch { /* best effort */ } + + _cts?.Dispose(); + + try + { + if (_isPrimary) _mutex.ReleaseMutex(); + } + catch { /* not owned / already released */ } + _mutex.Dispose(); + } + } +} diff --git a/src/Notify.NET/Platform/Windows/CustomDestinationListNative.cs b/src/Notify.NET/Platform/Windows/CustomDestinationListNative.cs new file mode 100644 index 0000000..9b8337e --- /dev/null +++ b/src/Notify.NET/Platform/Windows/CustomDestinationListNative.cs @@ -0,0 +1,183 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace Notify.NET.Platform.Windows +{ + /// + /// COM interop declarations for building a Windows 7+ jump list via the shell + /// ICustomDestinationList "user tasks" API. No native wrapper DLL is required — every + /// coclass used here is an in-box shell object, mirroring . + /// + /// A user task is an IShellLink (a shortcut) that relaunches the application's executable + /// with arguments; its display label is set via the System.Title (PKEY_Title) + /// property on the link's IPropertyStore. + /// + internal static class CustomDestinationListNative + { + // VT_LPWSTR — the only PROPVARIANT type we produce (for the task title). + private const ushort VT_LPWSTR = 31; + + /// System.Title — the label shown for a jump-list user task. + internal static readonly PROPERTYKEY PKEY_Title = new PROPERTYKEY + { + fmtid = new Guid("F29F85E0-4FF9-1068-AB91-08002B27B3D9"), + pid = 2 + }; + + // IID for IObjectArray, passed to ICustomDestinationList.BeginList. + internal static Guid IID_IObjectArray = new Guid("92CA9DCD-5622-4bba-A805-5E9F541BD8C9"); + + // ------------------------------------------------------------------ + // Structs + // ------------------------------------------------------------------ + + [StructLayout(LayoutKind.Sequential)] + internal struct PROPERTYKEY + { + public Guid fmtid; + public uint pid; + } + + /// + /// A deliberately minimal PROPVARIANT large enough for the simple inline value we set + /// (VT_LPWSTR). The trailing padding makes the managed size match the native + /// PROPVARIANT (16 bytes on x86, 24 on x64), which is all that SetValue and + /// PropVariantClear require here. + /// + [StructLayout(LayoutKind.Sequential)] + internal struct PROPVARIANT + { + public ushort vt; + public ushort wReserved1; + public ushort wReserved2; + public ushort wReserved3; + public IntPtr p; + public int p2; + } + + // ------------------------------------------------------------------ + // Coclasses + // ------------------------------------------------------------------ + + [ComImport, Guid("77f10cf0-3db5-4966-b520-b7c54fd35ed6"), ClassInterface(ClassInterfaceType.None)] + internal class CDestinationList { } + + [ComImport, Guid("2d3468c1-36a7-43b6-ac24-d3f02fd9607a"), ClassInterface(ClassInterfaceType.None)] + internal class CEnumerableObjectCollection { } + + [ComImport, Guid("00021401-0000-0000-C000-000000000046"), ClassInterface(ClassInterfaceType.None)] + internal class CShellLink { } + + // ------------------------------------------------------------------ + // Interfaces + // ------------------------------------------------------------------ + + [ComImport, Guid("92CA9DCD-5622-4bba-A805-5E9F541BD8C9"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IObjectArray + { + void GetCount(out uint cObjects); + void GetAt(uint uiIndex, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out object ppv); + } + + [ComImport, Guid("5632b1a4-e38a-400a-928a-d4cd63230295"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IObjectCollection + { + // ---- IObjectArray ---- + void GetCount(out uint cObjects); + void GetAt(uint uiIndex, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out object ppv); + // ---- IObjectCollection ---- + void AddObject([MarshalAs(UnmanagedType.Interface)] object punk); + void AddFromArray([MarshalAs(UnmanagedType.Interface)] IObjectArray poaSource); + void RemoveObjectAt(uint uiIndex); + void Clear(); + } + + [ComImport, Guid("6332debf-87b5-4670-90c0-5e57b408a49e"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface ICustomDestinationList + { + void SetAppID([MarshalAs(UnmanagedType.LPWStr)] string pszAppID); + void BeginList(out uint pcMaxSlots, ref Guid riid, + [MarshalAs(UnmanagedType.Interface)] out object ppv); + void AppendCategory([MarshalAs(UnmanagedType.LPWStr)] string pszCategory, + [MarshalAs(UnmanagedType.Interface)] IObjectArray poa); + void AppendKnownCategory(int category); + void AddUserTasks([MarshalAs(UnmanagedType.Interface)] IObjectArray poa); + void CommitList(); + void GetRemovedDestinations(ref Guid riid, + [MarshalAs(UnmanagedType.Interface)] out object ppv); + void DeleteList([MarshalAs(UnmanagedType.LPWStr)] string pszAppID); + void AbortList(); + } + + [ComImport, Guid("000214F9-0000-0000-C000-000000000046"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IShellLinkW + { + void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cch, + IntPtr pfd, uint fFlags); + void GetIDList(out IntPtr ppidl); + void SetIDList(IntPtr pidl); + void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cch); + void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName); + void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cch); + void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); + void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cch); + void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); + void GetHotkey(out short pwHotkey); + void SetHotkey(short wHotkey); + void GetShowCmd(out int piShowCmd); + void SetShowCmd(int iShowCmd); + void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, + int cch, out int piIcon); + void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon); + void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, uint dwReserved); + void Resolve(IntPtr hwnd, uint fFlags); + void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); + } + + [ComImport, Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IPropertyStore + { + void GetCount(out uint cProps); + void GetAt(uint iProp, out PROPERTYKEY pkey); + void GetValue(ref PROPERTYKEY key, out PROPVARIANT pv); + void SetValue(ref PROPERTYKEY key, ref PROPVARIANT pv); + void Commit(); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + [DllImport("ole32.dll")] + private static extern int PropVariantClear(ref PROPVARIANT pvar); + + /// + /// Sets a string property on a link's property store and commits it. Used to assign the + /// task's display title (), which is required for the task to appear. + /// + internal static void SetStringValue(IPropertyStore store, PROPERTYKEY key, string value) + { + var pv = new PROPVARIANT + { + vt = VT_LPWSTR, + p = Marshal.StringToCoTaskMemUni(value) + }; + try + { + store.SetValue(ref key, ref pv); + store.Commit(); + } + finally + { + // Frees the CoTaskMem string we allocated for `p`. + PropVariantClear(ref pv); + } + } + } +} diff --git a/src/Notify.NET/Platform/Windows/WindowsJumpListService.cs b/src/Notify.NET/Platform/Windows/WindowsJumpListService.cs new file mode 100644 index 0000000..0f2ae3c --- /dev/null +++ b/src/Notify.NET/Platform/Windows/WindowsJumpListService.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using Notify.NET.Abstractions; +using Notify.NET.Platform; + +namespace Notify.NET.Platform.Windows +{ + /// + /// implementation backed by the shell + /// ICustomDestinationList "user tasks" API (Windows 7+). No native wrapper DLL is required. + /// + /// Each task is an IShellLink that relaunches the host executable with + /// --notify-jumplist <id>; the bundled forwards the + /// id to the already-running primary instance so + /// fires live. + /// + /// Threading model: + /// The destination-list COM objects are apartment-threaded, so all COM work runs on a dedicated + /// STA thread (created lazily on first use), mirroring . + /// + public sealed class WindowsJumpListService : IJumpListService + { + private readonly string _appUserModelId; + private readonly string _executablePath; + private readonly JumpListActivationRouter _router; + private readonly object _gate = new object(); + + private Thread? _staThread; + private BlockingCollection? _workQueue; + private ManualResetEventSlim? _staReady; + private volatile bool _disposed; + + /// + public bool IsSupported { get; } + + /// + /// The same AppUserModelId used for notifications, so the jump list attaches to the correct + /// taskbar button. + /// + /// + /// Absolute path to the executable to relaunch when a task is clicked. When null, the current + /// process executable is used. + /// + public WindowsJumpListService(string appUserModelId, string? executablePath = null) + { + _appUserModelId = appUserModelId ?? throw new ArgumentNullException(nameof(appUserModelId)); + _executablePath = executablePath ?? JumpListActivation.CurrentExecutablePath(); + _router = new JumpListActivationRouter(JumpListActivation.ChannelName(_appUserModelId)); + + // Jump lists require Windows 7+. The shell coclasses are present from Win7 onward; + // treat the platform as supported and degrade gracefully if COM creation fails. + IsSupported = true; + } + + // ------------------------------------------------------------------ + // IJumpListService + // ------------------------------------------------------------------ + + /// + public bool TryHandleActivation(string[] args) + { + if (!IsSupported || _disposed) return false; + return _router.TryHandleActivation(args); + } + + /// + public void SetHandler(IJumpListHandler? handler) + { + if (!IsSupported || _disposed) return; + _router.SetHandler(handler); + } + + /// + public void SetTasks(IEnumerable tasks) + { + if (!IsSupported || _disposed) return; + if (tasks == null) throw new ArgumentNullException(nameof(tasks)); + + var list = new List(tasks); + _router.EnsureListening(); + EnqueueOnSta(() => BuildList(list)); + } + + /// + public void ClearTasks() + { + if (!IsSupported || _disposed) return; + EnqueueOnSta(DeleteList); + } + + // ------------------------------------------------------------------ + // IDisposable + // ------------------------------------------------------------------ + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _workQueue?.CompleteAdding(); + if (_staThread != null && _staThread.IsAlive) + _staThread.Join(TimeSpan.FromSeconds(5)); + + _workQueue?.Dispose(); + _staReady?.Dispose(); + _router.Dispose(); + } + + // ------------------------------------------------------------------ + // STA worker + // ------------------------------------------------------------------ + + private void EnqueueOnSta(Action action) + { + EnsureStaThread(); + try { _workQueue!.Add(action); } + catch (InvalidOperationException) { /* queue completed — disposed */ } + } + + private void EnsureStaThread() + { + if (_staThread != null) return; + lock (_gate) + { + if (_staThread != null) return; + + _workQueue = new BlockingCollection(); + _staReady = new ManualResetEventSlim(false); + _staThread = new Thread(StaThreadProc) + { + Name = "Notify.NET JumpList STA", + IsBackground = true + }; + _staThread.SetApartmentState(ApartmentState.STA); + _staThread.Start(); + _staReady.Wait(); + } + } + + private void StaThreadProc() + { + _staReady!.Set(); + try + { + foreach (Action work in _workQueue!.GetConsumingEnumerable()) + { + try { work(); } + catch { /* a single failed list build must not stop the worker */ } + } + } + catch (InvalidOperationException) { /* queue completed */ } + } + + // ------------------------------------------------------------------ + // Jump-list construction (runs on the STA thread) + // ------------------------------------------------------------------ + + private void BuildList(List tasks) + { + if (tasks.Count == 0) { DeleteList(); return; } + + CustomDestinationListNative.ICustomDestinationList? list = null; + CustomDestinationListNative.IObjectCollection? collection = null; + object? removed = null; + try + { + list = (CustomDestinationListNative.ICustomDestinationList) + new CustomDestinationListNative.CDestinationList(); + list.SetAppID(_appUserModelId); + + Guid riid = CustomDestinationListNative.IID_IObjectArray; + list.BeginList(out _, ref riid, out removed); + + collection = (CustomDestinationListNative.IObjectCollection) + new CustomDestinationListNative.CEnumerableObjectCollection(); + + foreach (JumpListTask task in tasks) + { + object? link = CreateTaskLink(task); + if (link != null) collection.AddObject(link); + } + + list.AddUserTasks((CustomDestinationListNative.IObjectArray)collection); + list.CommitList(); + } + catch (Exception) + { + // Abort a half-built list so the previous one is preserved. + try { list?.AbortList(); } catch { /* best effort */ } + } + finally + { + ReleaseCom(removed); + ReleaseCom(collection); + ReleaseCom(list); + } + } + + private object? CreateTaskLink(JumpListTask task) + { + CustomDestinationListNative.IShellLinkW? link = null; + try + { + link = (CustomDestinationListNative.IShellLinkW) + new CustomDestinationListNative.CShellLink(); + + link.SetPath(_executablePath); + link.SetArguments($"{JumpListActivation.ActivationFlag} {task.Id}"); + + string? workingDir = Path.GetDirectoryName(_executablePath); + if (!string.IsNullOrEmpty(workingDir)) + link.SetWorkingDirectory(workingDir); + + if (!string.IsNullOrEmpty(task.Description)) + link.SetDescription(task.Description); + + // Icon: explicit override, else the host executable's own icon. + if (!string.IsNullOrEmpty(task.IconPath)) + link.SetIconLocation(task.IconPath, task.IconIndex); + else + link.SetIconLocation(_executablePath, 0); + + // The title is mandatory for a user task to be shown. + var store = (CustomDestinationListNative.IPropertyStore)link; + CustomDestinationListNative.SetStringValue( + store, CustomDestinationListNative.PKEY_Title, task.Title); + + return link; + } + catch (Exception) + { + ReleaseCom(link); + return null; + } + } + + private void DeleteList() + { + CustomDestinationListNative.ICustomDestinationList? list = null; + try + { + list = (CustomDestinationListNative.ICustomDestinationList) + new CustomDestinationListNative.CDestinationList(); + list.DeleteList(_appUserModelId); + } + catch (Exception) { /* nothing to delete or shell unavailable */ } + finally { ReleaseCom(list); } + } + + private static void ReleaseCom(object? comObject) + { + if (comObject != null && Marshal.IsComObject(comObject)) + { + try { Marshal.FinalReleaseComObject(comObject); } + catch { /* best effort */ } + } + } + } +}