Add support for jump lists

This commit is contained in:
Pat Hartl 2026-06-14 15:00:42 -05:00
parent c33f0df70a
commit 8d412ede18
15 changed files with 1778 additions and 0 deletions

View file

@ -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

View file

@ -22,6 +22,7 @@
#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#import <UserNotifications/UserNotifications.h>
#import <objc/runtime.h>
#include <stdatomic.h>
#include <stdlib.h>
#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 <NSApplicationDelegate>
@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<NSString*>* idArr = [NSMutableArray arrayWithCapacity:(count > 0 ? count : 0)];
NSMutableArray<NSString*>* 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);
}

View file

@ -0,0 +1,23 @@
namespace Notify.NET.Abstractions
{
/// <summary>
/// Receives activation events when the user clicks an entry in the application's jump list,
/// launcher shortcut menu or Dock menu.
/// </summary>
public interface IJumpListHandler
{
/// <summary>
/// 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.
/// </summary>
/// <param name="taskId">The <see cref="JumpListTask.Id"/> of the task that was clicked.</param>
void OnTaskActivated(string taskId);
}
}

View file

@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
namespace Notify.NET.Abstractions
{
/// <summary>
/// Manages the application's jump list (Windows), launcher shortcut menu (Linux
/// <c>.desktop</c> Actions) or Dock menu (macOS), with a bundled live-callback layer so a
/// clicked task is delivered to the already-running instance via
/// <see cref="IJumpListHandler.OnTaskActivated"/>.
///
/// <para><b>Activation model.</b> Jump-list and <c>.desktop</c> 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:</para>
/// <list type="number">
/// <item><description>
/// A clicked task on Windows/Linux relaunches the app with a hidden activation argument.
/// </description></item>
/// <item><description>
/// Call <see cref="TryHandleActivation"/> at the very start of <c>Main</c>. 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 <c>true</c> — the caller should exit
/// immediately without showing any UI.
/// </description></item>
/// <item><description>
/// Otherwise the method returns <c>false</c> and the app continues normal startup. The first
/// call to <see cref="SetTasks"/> 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.
/// </description></item>
/// </list>
///
/// <para>Nothing is registered and no listener, mutex or pipe is created until
/// <see cref="SetTasks"/> (or <see cref="SetHandler"/>) is first called, so applications that do
/// not use jump lists incur no overhead.</para>
///
/// <para>Capability notes:</para>
/// <list type="bullet">
/// <item><description>
/// <b>Windows</b> — uses the shell <c>ICustomDestinationList</c> "user tasks" (Windows 7+).
/// Requires the same AppUserModelId used for notifications so the list attaches to the
/// correct taskbar button.
/// </description></item>
/// <item><description>
/// <b>Linux</b> — writes <c>Actions</c> into the application's <c>.desktop</c> file (honoured
/// by GNOME, KDE, Unity and others). Requires <c>NotificationOptions.DesktopFileId</c>; if no
/// installed <c>.desktop</c> file is found, a minimal one is created under
/// <c>~/.local/share/applications</c>.
/// </description></item>
/// <item><description>
/// <b>macOS</b> — 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.
/// </description></item>
/// </list>
/// When jump lists are not available on the current platform, <see cref="IsSupported"/> is
/// <c>false</c> and all methods are silent no-ops (<see cref="TryHandleActivation"/> returns
/// <c>false</c>).
/// </summary>
public interface IJumpListService : IDisposable
{
/// <summary>
/// Whether jump lists / launcher actions / Dock-menu items are available on this platform.
/// When <c>false</c>, all other methods are silent no-ops.
/// </summary>
bool IsSupported { get; }
/// <summary>
/// Registers the handler that receives <see cref="IJumpListHandler.OnTaskActivated"/> 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 <c>null</c>
/// to detach the current handler.
/// </summary>
void SetHandler(IJumpListHandler? handler);
/// <summary>
/// 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 <see cref="ClearTasks"/>.
/// </summary>
void SetTasks(IEnumerable<JumpListTask> tasks);
/// <summary>Removes all jump-list tasks registered by this application.</summary>
void ClearTasks();
/// <summary>
/// Inspects the process command-line arguments for a jump-list activation. Call this once, as
/// early as possible in <c>Main</c>, before any UI is shown.
/// </summary>
/// <param name="args">The arguments passed to <c>Main</c>.</param>
/// <returns>
/// <c>true</c> 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
/// <c>false</c> (continue normal startup — the activation, if any, will be replayed to the
/// handler once this instance becomes primary).
/// </returns>
bool TryHandleActivation(string[] args);
}
}

View file

@ -0,0 +1,81 @@
using System;
namespace Notify.NET.Abstractions
{
/// <summary>
/// A single entry in an application's jump list (Windows), launcher shortcut menu
/// (Linux <c>.desktop</c> 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 <see cref="Id"/> back to <see cref="IJumpListHandler.OnTaskActivated"/> in the
/// already-running instance (see <see cref="IJumpListService"/> for the model).
/// </summary>
public sealed class JumpListTask
{
/// <summary>
/// A stable, machine-readable identifier for this task (e.g. <c>"open-library"</c>).
/// It is passed back to <see cref="IJumpListHandler.OnTaskActivated"/> 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,
/// <c>-</c> and <c>_</c>.
/// </summary>
public string Id { get; }
/// <summary>The human-readable label shown in the menu (e.g. <c>"Open Library"</c>).</summary>
public string Title { get; }
/// <summary>
/// Optional tooltip/description. Shown on Windows jump-list tasks on hover.
/// Ignored on Linux and macOS.
/// </summary>
public string? Description { get; }
/// <summary>
/// Optional path to an icon. On Windows this is a path to an <c>.ico</c>, <c>.exe</c> or
/// <c>.dll</c> file whose icon at <see cref="IconIndex"/> is shown next to the task.
/// On Linux it is an icon name (per the freedesktop icon theme) or absolute path written
/// into the <c>.desktop</c> Action. Ignored on macOS (Dock menus do not show item icons).
/// </summary>
public string? IconPath { get; }
/// <summary>
/// The zero-based index of the icon to use within <see cref="IconPath"/> when it refers to
/// a multi-icon file (e.g. an <c>.exe</c>/<c>.dll</c>). Windows only; defaults to 0.
/// </summary>
public int IconIndex { get; }
/// <param name="id">A stable, whitespace-free identifier passed to the handler when invoked.</param>
/// <param name="title">The label shown in the menu.</param>
/// <param name="description">Optional Windows-only tooltip.</param>
/// <param name="iconPath">Optional icon file (Windows) or icon name/path (Linux).</param>
/// <param name="iconIndex">Icon index within <paramref name="iconPath"/> (Windows only).</param>
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;
}
}
}

View file

@ -122,6 +122,63 @@ namespace Notify.NET.Extensions
return new NullTaskbarProgressService();
}
/// <summary>
/// Registers <see cref="IJumpListService"/> as a singleton, using the
/// platform-appropriate backend:
/// <list type="bullet">
/// <item><description>Windows → <see cref="WindowsJumpListService"/> (ICustomDestinationList user tasks)</description></item>
/// <item><description>Linux → <see cref="LinuxJumpListService"/> (freedesktop.org Desktop Actions)</description></item>
/// <item><description>macOS → <see cref="MacOSJumpListService"/> (Dock menu)</description></item>
/// <item><description>Other → <see cref="NullJumpListService"/> (<see cref="IJumpListService.IsSupported"/> = false)</description></item>
/// </list>
///
/// On Windows and Linux a clicked task relaunches the executable with
/// <c>--notify-jumplist &lt;id&gt;</c>; 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.
/// </summary>
/// <param name="services">The service collection to add to.</param>
/// <param name="configure">Optional delegate to configure <see cref="NotificationOptions"/>.</param>
public static IServiceCollection AddJumpList(
this IServiceCollection services,
Action<NotificationOptions>? configure = null)
{
var options = new NotificationOptions();
configure?.Invoke(options);
services.AddSingleton<IJumpListService>(_ => CreateJumpListServiceCore(options));
return services;
}
/// <summary>
/// Creates the platform-appropriate <see cref="IJumpListService"/> directly
/// (without a DI container), for use in simple console applications.
/// </summary>
public static IJumpListService CreateJumpListService(
Action<NotificationOptions>? 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();
}
}
/// <summary>
@ -161,6 +218,14 @@ namespace Notify.NET.Extensions
/// the process name is used. Ignored on Windows and macOS.
/// </summary>
public string? DesktopFileId { get; set; }
/// <summary>
/// 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
/// <c>dotnet</c> 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).
/// </summary>
public string? ExecutablePath { get; set; }
}
/// <summary>
@ -194,4 +259,18 @@ namespace Notify.NET.Extensions
public void SetWindow(IntPtr windowHandle) { }
public void Dispose() { }
}
/// <summary>
/// No-op implementation used when the current platform has no supported jump-list backend.
/// <see cref="IsSupported"/> is always false and every method is a silent no-op.
/// </summary>
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<JumpListTask> tasks) { }
public void ClearTasks() { }
public void Dispose() { }
}
}

View file

@ -0,0 +1,72 @@
using System;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
namespace Notify.NET.Platform
{
/// <summary>
/// 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
/// <see cref="ActivationFlag"/> followed by the task id, e.g.
/// <c>myapp --notify-jumplist open-library</c>. The bundled single-instance layer parses this,
/// forwards the id to the primary instance and exits.
/// </summary>
internal static class JumpListActivation
{
/// <summary>The command-line flag that precedes a jump-list task id on relaunch.</summary>
internal const string ActivationFlag = "--notify-jumplist";
/// <summary>
/// Extracts the task id from a jump-list activation command line, or <c>null</c> if these
/// arguments are not a jump-list activation.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// Best-effort absolute path to the current process executable, used as the relaunch target.
/// </summary>
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;
}
}
}

View file

@ -0,0 +1,113 @@
using System;
using System.Threading;
using Notify.NET.Abstractions;
namespace Notify.NET.Platform
{
/// <summary>
/// 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 <see cref="EnsureListening"/> or a
/// non-null <see cref="SetHandler"/> is first called.
/// </summary>
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;
}
/// <summary>
/// Handles a possible jump-list activation command line. Returns <c>true</c> if the activation
/// was forwarded to an already-running primary instance (caller should exit); otherwise
/// <c>false</c> (the activation, if any, is captured for cold-start replay).
/// </summary>
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;
}
/// <summary>Sets (or clears) the handler and starts listening when a handler is attached.</summary>
internal void SetHandler(IJumpListHandler? handler)
{
if (_disposed) return;
lock (_gate)
{
_handler = handler;
if (handler != null) EnsureListening_NoLock();
}
}
/// <summary>
/// Becomes the primary instance (if elected) and begins listening for forwarded activations.
/// Called by the service the first time tasks are registered.
/// </summary>
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;
}
}
}
}

View file

@ -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
{
/// <summary>
/// Writes freedesktop.org "Desktop Actions" (launcher shortcut entries) into an application's
/// <c>.desktop</c> file. Desktop Actions appear in the right-click menu of the launcher/taskbar
/// icon on GNOME, KDE, Unity and other environments; each action's <c>Exec</c> relaunches the
/// executable with a jump-list activation argument.
///
/// The file's <c>Actions</c> key and all <c>[Desktop Action *]</c> groups are owned and managed
/// by this writer — existing ones are replaced on each write. If no installed <c>.desktop</c>
/// file exists for the application, a minimal one is created under
/// <c>$XDG_DATA_HOME/applications</c> (default <c>~/.local/share/applications</c>).
/// </summary>
internal static class DesktopFileWriter
{
/// <summary>Registers the supplied tasks as Desktop Actions, creating/merging the file.</summary>
internal static void WriteActions(
string desktopFileId,
string appName,
string executablePath,
IReadOnlyList<JumpListTask> tasks)
{
string path = ResolveUserDesktopPath(desktopFileId);
string? source = FindExistingDesktopPath(desktopFileId) ?? (File.Exists(path) ? path : null);
List<Section> sections = source != null
? ParseSections(File.ReadAllLines(source))
: CreateMinimal(appName, executablePath);
ApplyActions(sections, executablePath, tasks);
WriteFile(path, sections);
}
/// <summary>Removes the <c>Actions</c> key and all action groups managed by this writer.</summary>
internal static void RemoveActions(string desktopFileId)
{
string path = ResolveUserDesktopPath(desktopFileId);
if (!File.Exists(path)) return;
List<Section> sections = ParseSections(File.ReadAllLines(path));
ApplyActions(sections, executablePath: null, tasks: Array.Empty<JumpListTask>());
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");
}
/// <summary>The user-writable path we always write to.</summary>
internal static string ResolveUserDesktopPath(string desktopFileId)
{
string id = StripSuffix(desktopFileId);
return Path.Combine(DataHome(), "applications", id + ".desktop");
}
/// <summary>
/// Looks for an existing installed <c>.desktop</c> file (user dir first, then the system
/// <c>XDG_DATA_DIRS</c>) to use as the merge source. Returns null if none exists.
/// </summary>
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<string> Lines = new List<string>(); // 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<Section> ParseSections(string[] lines)
{
var sections = new List<Section>();
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<Section> 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<Section> { entry };
}
// ------------------------------------------------------------------
// Action application
// ------------------------------------------------------------------
private static void ApplyActions(
List<Section> sections, string? executablePath, IReadOnlyList<JumpListTask> 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);
}
}
/// <summary>Quotes an executable path for a Desktop Entry <c>Exec</c> value if needed.</summary>
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<Section> 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());
}
}
}

View file

@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using Notify.NET.Abstractions;
using Notify.NET.Platform;
namespace Notify.NET.Platform.Linux
{
/// <summary>
/// <see cref="IJumpListService"/> implementation that registers launcher shortcut tasks as
/// freedesktop.org Desktop Actions in the application's <c>.desktop</c> file (see
/// <see cref="DesktopFileWriter"/>), honoured by GNOME, KDE, Unity and others.
///
/// Clicking an action relaunches the executable with <c>--notify-jumplist &lt;id&gt;</c>; the bundled
/// <see cref="JumpListActivationRouter"/> forwards the id to the running primary instance so
/// <see cref="IJumpListHandler.OnTaskActivated"/> fires live (or replays it on a cold start).
/// </summary>
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;
/// <inheritdoc/>
public bool IsSupported => true;
/// <param name="appName">Human-readable application name, used if a new .desktop file is created.</param>
/// <param name="desktopFileId">
/// The application's <c>.desktop</c> file id (with or without the ".desktop" suffix). Identifies
/// which launcher entry the actions are written into and keys the single-instance channel.
/// </param>
/// <param name="executablePath">
/// Absolute command used to relaunch the app for an action's <c>Exec</c>. 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).
/// </param>
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));
}
/// <inheritdoc/>
public bool TryHandleActivation(string[] args)
{
if (_disposed) return false;
return _router.TryHandleActivation(args);
}
/// <inheritdoc/>
public void SetHandler(IJumpListHandler? handler)
{
if (_disposed) return;
_router.SetHandler(handler);
}
/// <inheritdoc/>
public void SetTasks(IEnumerable<JumpListTask> tasks)
{
if (_disposed) return;
if (tasks == null) throw new ArgumentNullException(nameof(tasks));
var list = new List<JumpListTask>(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.
}
}
/// <inheritdoc/>
public void ClearTasks()
{
if (_disposed) return;
try { DesktopFileWriter.RemoveActions(_desktopFileId); }
catch (Exception) { /* best effort */ }
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_router.Dispose();
}
}
}

View file

@ -0,0 +1,45 @@
using System;
using System.Runtime.InteropServices;
namespace Notify.NET.Platform.MacOS
{
/// <summary>
/// P/Invoke declarations for the Dock-menu ("jump list") entry points exported by
/// <c>libMacNotifyWrapper.dylib</c> (see <c>MacNotifyWrapper.h</c>).
///
/// Unlike the Windows/Linux jump lists, the macOS Dock menu fires a live in-process
/// callback (<see cref="DockMenuCallback"/>) — 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 <see cref="UnmanagedType.LPStr"/>
/// marshalling is a faithful round-trip. Every function uses the C calling convention (cdecl).
/// </summary>
internal static class MacJumpListNative
{
internal const string LibName = "MacNotifyWrapper";
/// <summary>
/// Fired on the main thread when the user clicks a Dock-menu item; <paramref name="taskId"/>
/// is the id supplied to <see cref="MNW_SetDockMenu"/> for that item.
/// </summary>
[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();
}
}

View file

@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Notify.NET.Abstractions;
namespace Notify.NET.Platform.MacOS
{
/// <summary>
/// <see cref="IJumpListService"/> implementation backed by the application's macOS Dock menu
/// (shown on right-click / click-and-hold of the Dock icon), provided through the native
/// <c>libMacNotifyWrapper.dylib</c> (<c>MNW_SetDockMenu</c> and friends).
///
/// Unlike the Windows and Linux services there is no relaunch and no single-instance
/// forwarding: clicking a Dock-menu item fires <see cref="IJumpListHandler.OnTaskActivated"/>
/// live in the running process. Consequently <see cref="TryHandleActivation"/> 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.
/// </summary>
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;
/// <inheritdoc/>
public bool IsSupported { get; }
static MacOSJumpListService()
{
_staticCallback = OnDockItemActivated;
}
public MacOSJumpListService()
{
try
{
MacOSNativeLibraryLoader.EnsureLoaded();
IsSupported = MacJumpListNative.MNW_IsSupported();
}
catch (DllNotFoundException)
{
IsSupported = false;
}
}
// ------------------------------------------------------------------
// IJumpListService
// ------------------------------------------------------------------
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
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);
}
/// <inheritdoc/>
public void SetTasks(IEnumerable<JumpListTask> tasks)
{
if (_disposed || !IsSupported) return;
if (tasks == null) throw new ArgumentNullException(nameof(tasks));
var list = new List<JumpListTask>(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);
}
/// <inheritdoc/>
public void ClearTasks()
{
if (_disposed || !IsSupported) return;
MacJumpListNative.MNW_ClearDockMenu();
}
// ------------------------------------------------------------------
// IDisposable
// ------------------------------------------------------------------
/// <inheritdoc/>
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 */ }
}
}
}

View file

@ -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
{
/// <summary>
/// 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 <see cref="Mutex"/> to elect the
/// primary instance.
///
/// The primary instance (the first to call <see cref="EnsureListening"/>) runs a background loop
/// that accepts connections and invokes a callback with each received task id. Any instance can
/// statically <see cref="TryForward"/> a task id to the primary; if no primary is listening the
/// call returns <c>false</c> 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.
/// </summary>
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;
/// <summary>Whether this process won the election and is the listening primary instance.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
internal void EnsureListening(Action<string> onActivated)
{
if (_disposed || !_isPrimary || _listenTask != null) return;
_cts = new CancellationTokenSource();
_listenTask = Task.Run(() => AcceptLoopAsync(onActivated, _cts.Token));
}
private async Task AcceptLoopAsync(Action<string> 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<string> 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();
}
/// <summary>
/// Attempts to deliver <paramref name="taskId"/> to a primary instance listening on
/// <paramref name="pipeName"/>. Returns <c>true</c> if a primary accepted the connection and
/// the id was written; <c>false</c> if no primary is listening (a cold start).
/// </summary>
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();
}
}
}

View file

@ -0,0 +1,183 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace Notify.NET.Platform.Windows
{
/// <summary>
/// COM interop declarations for building a Windows 7+ jump list via the shell
/// <c>ICustomDestinationList</c> "user tasks" API. No native wrapper DLL is required — every
/// coclass used here is an in-box shell object, mirroring <see cref="TaskbarListNative"/>.
///
/// A user task is an <c>IShellLink</c> (a shortcut) that relaunches the application's executable
/// with arguments; its display label is set via the <c>System.Title</c> (<c>PKEY_Title</c>)
/// property on the link's <c>IPropertyStore</c>.
/// </summary>
internal static class CustomDestinationListNative
{
// VT_LPWSTR — the only PROPVARIANT type we produce (for the task title).
private const ushort VT_LPWSTR = 31;
/// <summary><c>System.Title</c> — the label shown for a jump-list user task.</summary>
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;
}
/// <summary>
/// A deliberately minimal PROPVARIANT large enough for the simple inline value we set
/// (<c>VT_LPWSTR</c>). The trailing padding makes the managed size match the native
/// <c>PROPVARIANT</c> (16 bytes on x86, 24 on x64), which is all that <c>SetValue</c> and
/// <c>PropVariantClear</c> require here.
/// </summary>
[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);
/// <summary>
/// Sets a string property on a link's property store and commits it. Used to assign the
/// task's display title (<see cref="PKEY_Title"/>), which is required for the task to appear.
/// </summary>
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);
}
}
}
}

View file

@ -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
{
/// <summary>
/// <see cref="IJumpListService"/> implementation backed by the shell
/// <c>ICustomDestinationList</c> "user tasks" API (Windows 7+). No native wrapper DLL is required.
///
/// Each task is an <c>IShellLink</c> that relaunches the host executable with
/// <c>--notify-jumplist &lt;id&gt;</c>; the bundled <see cref="SingleInstanceChannel"/> forwards the
/// id to the already-running primary instance so <see cref="IJumpListHandler.OnTaskActivated"/>
/// 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 <see cref="WindowsTaskbarProgressService"/>.
/// </summary>
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<Action>? _workQueue;
private ManualResetEventSlim? _staReady;
private volatile bool _disposed;
/// <inheritdoc/>
public bool IsSupported { get; }
/// <param name="appUserModelId">
/// The same AppUserModelId used for notifications, so the jump list attaches to the correct
/// taskbar button.
/// </param>
/// <param name="executablePath">
/// Absolute path to the executable to relaunch when a task is clicked. When null, the current
/// process executable is used.
/// </param>
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
// ------------------------------------------------------------------
/// <inheritdoc/>
public bool TryHandleActivation(string[] args)
{
if (!IsSupported || _disposed) return false;
return _router.TryHandleActivation(args);
}
/// <inheritdoc/>
public void SetHandler(IJumpListHandler? handler)
{
if (!IsSupported || _disposed) return;
_router.SetHandler(handler);
}
/// <inheritdoc/>
public void SetTasks(IEnumerable<JumpListTask> tasks)
{
if (!IsSupported || _disposed) return;
if (tasks == null) throw new ArgumentNullException(nameof(tasks));
var list = new List<JumpListTask>(tasks);
_router.EnsureListening();
EnqueueOnSta(() => BuildList(list));
}
/// <inheritdoc/>
public void ClearTasks()
{
if (!IsSupported || _disposed) return;
EnqueueOnSta(DeleteList);
}
// ------------------------------------------------------------------
// IDisposable
// ------------------------------------------------------------------
/// <inheritdoc/>
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<Action>();
_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<JumpListTask> 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 */ }
}
}
}
}