diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 604589c..90a6442 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,9 @@ permissions: jobs: build-windows: name: Build WinToastWrapper (${{ matrix.rid }}) - runs-on: windows-latest + # Pinned to windows-2022 because the project uses the v143 (VS 2022) toolset. + # windows-latest moved to a newer Visual Studio that no longer ships v143. + runs-on: windows-2022 strategy: fail-fast: false matrix: diff --git a/README.md b/README.md index 1600a4f..241f18d 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,268 @@ cleanup on macOS). --- +## Taskbar progress + +`ITaskbarProgressService` drives the progress indicator on the application's taskbar button +(Windows), launcher entry (Linux) or Dock tile (macOS) — the same green/red bar Windows +Explorer shows during a file copy. Use it to surface the progress of a long-running +operation without a custom UI. + +| Platform | Backend | Requirement | +|----------|---------|-------------| +| Windows | `ITaskbarList3` | A top-level window handle (defaults to the console window) | +| Linux | Unity LauncherEntry D-Bus API (KDE Plasma, Unity, Dash-to-Dock, Plank, Latte) | A `.desktop` file whose id is supplied via `DesktopFileId` | +| macOS | `NSProgressIndicator` drawn on the Dock tile | A bundled GUI app that owns a Dock tile | + +If the indicator is unavailable on the current platform, `IsSupported` is `false` and all +methods are silent no-ops. + +### Creating the service + +```csharp +// Direct (no DI container) +using var progress = ServiceCollectionExtensions.CreateTaskbarProgressService(opts => +{ + opts.DesktopFileId = "com.example.MyApp"; // Linux: the app's .desktop file id +}); + +// With Microsoft.Extensions.DependencyInjection +services.AddTaskbarProgress(opts => +{ + opts.DesktopFileId = "com.example.MyApp"; +}); +``` + +### Reporting progress + +```csharp +if (!progress.IsSupported) + return; + +// Determinate progress, by fraction (0.0–1.0, clamped)… +progress.SetProgress(0.25); + +// …or by completed / total counts. +for (ulong i = 0; i <= total; i++) +{ + DoWork(i); + progress.SetProgress(i, total); // total must be greater than zero +} + +// Clear the indicator when finished. +progress.SetState(TaskbarProgressState.None); +``` + +Calling either `SetProgress` overload switches the indicator to the `Normal` state, unless +it is currently in the `Error` or `Paused` state (those are preserved so a paused/failed +operation keeps its colour while its value updates). + +### States + +```csharp +progress.SetState(TaskbarProgressState.Indeterminate); // work of unknown length +progress.SetState(TaskbarProgressState.Paused); // operation paused +progress.SetState(TaskbarProgressState.Error); // operation failed +progress.SetState(TaskbarProgressState.None); // clear the indicator +``` + +| State | Windows | Linux | macOS | +|-------|---------|-------|-------| +| `None` | No bar | No bar | No bar | +| `Indeterminate` | Pulsing marquee bar | Falls back to a 0% bar | Animated bar | +| `Normal` | Green bar | Bar at the current value | Bar at the current value | +| `Paused` | Yellow bar | Same as `Normal` | Same as `Normal` | +| `Error` | Red bar | Launcher entry flagged "urgent" | Same as `Normal` | + +### Targeting a window (Windows) + +By default the Windows backend targets the console window (`GetConsoleWindow()`). For a +WPF/WinForms app, point it at your main window's HWND so the bar appears on the right +taskbar button. This is a no-op on Linux and macOS. + +```csharp +// WPF +var hwnd = new System.Windows.Interop.WindowInteropHelper(mainWindow).Handle; +progress.SetWindow(hwnd); + +// WinForms +progress.SetWindow(form.Handle); + +// Revert to the console window +progress.SetWindow(IntPtr.Zero); +``` + +### ITaskbarProgressService interface + +```csharp +public interface ITaskbarProgressService : IDisposable +{ + // False if a progress indicator is unavailable on this platform. + bool IsSupported { get; } + + // Sets the visual state without changing the value (None clears it). + void SetState(TaskbarProgressState state); + + // Sets the value and switches to Normal (Error/Paused are preserved). + void SetProgress(ulong completed, ulong total); // total must be > 0 + void SetProgress(double fraction); // 0.0–1.0, clamped + + // Windows only: target a specific top-level window (Zero reverts to the console window). + void SetWindow(IntPtr windowHandle); +} +``` + +--- + +## Jump lists + +A *jump list* is the menu of quick action shortcuts attached to an application's taskbar +button (Windows), launcher icon (Linux) or Dock icon (macOS). Notify.NET exposes this +through `IJumpListService`, which presents a single, uniform live-callback API across all +three platforms: when the user clicks a task, your already-running process receives an +`IJumpListHandler.OnTaskActivated(taskId)` call. + +| Platform | Backend | Activation model | +|----------|---------|------------------| +| Windows | Shell `ICustomDestinationList` "user tasks" (Windows 7+) | Relaunch + single-instance forwarding | +| Linux | freedesktop.org Desktop Actions in the app's `.desktop` file (GNOME, KDE, Unity, …) | Relaunch + single-instance forwarding | +| macOS | Dock menu via the application delegate (bundled GUI app only) | Live, in-process — no relaunch | + +On Windows and Linux a clicked task fundamentally relaunches the executable with a hidden +`--notify-jumplist ` argument. Notify.NET bundles a single-instance channel (a named +mutex plus a named pipe) that forwards the id to the running primary instance, so the +handler always fires live — uniform with macOS's natively-live Dock menu. + +Nothing is registered and no mutex, pipe or OS entry is created until you call `SetTasks` +or `SetHandler`, so applications that do not use jump lists incur zero overhead. + +### Creating the service + +```csharp +// Direct (no DI container) +using var jumpList = ServiceCollectionExtensions.CreateJumpListService(opts => +{ + opts.AppName = "My App"; + opts.AppUserModelId = "MyCompany.MyApp"; // Windows: must match the notification AUMI + opts.DesktopFileId = "com.example.MyApp"; // Linux: the app's .desktop file id +}); + +// With Microsoft.Extensions.DependencyInjection +services.AddJumpList(opts => +{ + opts.AppName = "My App"; + opts.AppUserModelId = "MyCompany.MyApp"; + opts.DesktopFileId = "com.example.MyApp"; +}); +``` + +`CreateJumpListService` / `AddJumpList` select the correct backend for the current OS. +On unsupported platforms they return a no-op service where `IsSupported` is `false`. + +### Wiring up activation + +On Windows and Linux, call `TryHandleActivation` at the very top of `Main`, before any UI +is shown. If this launch is a forwarded jump-list click, it returns `true` and the process +should exit immediately. Then attach a handler and register the tasks — the first call to +`SetTasks` / `SetHandler` makes this process the primary instance and starts the listener. + +```csharp +public static int Main(string[] args) +{ + using var jumpList = ServiceCollectionExtensions.CreateJumpListService(opts => + { + opts.AppName = "My App"; + opts.AppUserModelId = "MyCompany.MyApp"; + opts.DesktopFileId = "com.example.MyApp"; + }); + + // Forward a jump-list click to the already-running instance, then exit. + if (jumpList.TryHandleActivation(args)) + return 0; + + jumpList.SetHandler(new MyJumpListHandler()); + jumpList.SetTasks(new[] + { + new JumpListTask("new-doc", "New Document"), + new JumpListTask("open-last","Open Last File", description: "Reopen the most recent file"), + new JumpListTask("settings", "Settings", iconPath: @"C:\Apps\MyApp\settings.ico"), + }); + + RunApplication(); // your normal startup / message loop + return 0; +} + +public sealed class MyJumpListHandler : IJumpListHandler +{ + public void OnTaskActivated(string taskId) + { + // Fired on a background thread — marshal to your UI thread before touching UI. + switch (taskId) + { + case "new-doc": CreateDocument(); break; + case "open-last": OpenLastFile(); break; + case "settings": ShowSettings(); break; + } + } +} +``` + +If the app was launched cold by a jump-list click (no primary instance was running), the +activation is captured and replayed to the handler once one is set. + +### JumpListTask + +```csharp +new JumpListTask( + id: "open-last", // stable id passed back to OnTaskActivated (no whitespace) + title: "Open Last File", // label shown in the menu + description: "Reopen the most recent file", // tooltip (Windows); optional + iconPath: @"C:\Apps\MyApp\recent.ico", // optional; defaults to the host exe icon + iconIndex: 0); // icon index within iconPath (Windows) +``` + +### Managing tasks + +```csharp +jumpList.SetTasks(tasks); // replace the current task set (empty sequence == ClearTasks) +jumpList.ClearTasks(); // remove all tasks registered by this app +jumpList.SetHandler(null); // detach the handler +``` + +### Options + +| Option | Purpose | +|--------|---------| +| `AppName` | Human-readable name; used if a minimal Linux `.desktop` file must be created. | +| `AppUserModelId` | Windows — must match the AUMI used for notifications so the list attaches to the right taskbar button. | +| `DesktopFileId` | Linux — the app's `.desktop` file id (with or without the `.desktop` suffix). Defaults to the process name. | +| `ExecutablePath` | Windows/Linux — absolute path to relaunch on click. When null, the current process executable is used; pass an explicit path for framework-dependent `dotnet` apps where the auto-detected path may be the shared host. Ignored on macOS. | + +### IJumpListService interface + +```csharp +public interface IJumpListService : IDisposable +{ + // False if jump lists are unavailable on this platform; all methods become no-ops. + bool IsSupported { get; } + + // Registers the handler for OnTaskActivated events (also starts the listener). + void SetHandler(IJumpListHandler? handler); + + // Replaces the application's jump-list tasks (empty sequence clears them). + void SetTasks(IEnumerable tasks); + + // Removes all tasks registered by this application. + void ClearTasks(); + + // Call once at the start of Main. Returns true if the launch was a forwarded + // activation and the caller should exit immediately. + bool TryHandleActivation(string[] args); +} +``` + +--- + ## Platform notes ### Windows @@ -290,6 +552,14 @@ cleanup on macOS). published alongside the executable. - Toast callbacks are delivered on a WinRT thread-pool thread, not the STA thread. The library handles this internally. +- Jump lists use the shell `ICustomDestinationList` "user tasks" API (Windows 7+) — pure + managed COM interop, no native DLL required. The jump list attaches to the taskbar button + matching `AppUserModelId`, so it must be the same id used for notifications. The COM work + runs on a dedicated STA thread the library creates lazily on first use. +- Taskbar progress uses `ITaskbarList3` and needs a top-level window handle. It defaults to + the console window (`GetConsoleWindow()`); call `SetWindow` with your WPF/WinForms main + window HWND to move the bar onto that taskbar button. The COM work runs on its own lazily + created STA thread. ### Linux @@ -314,6 +584,18 @@ is present. Image support via `gdk-pixbuf` requires `libgdk-pixbuf-2.0` to be installed, which is typically included as a dependency of `libnotify4`. +Taskbar progress uses the Unity LauncherEntry D-Bus API, honoured by KDE Plasma, Unity, +Dash-to-Dock, Plank and Latte. It requires the app to ship (or have created) a `.desktop` +file whose id is supplied via `DesktopFileId`; the launcher matches the entry by that id. +Desktop environments without LauncherEntry support simply show no bar. + +Jump lists are written as `Actions` into the application's `.desktop` file. If no installed +`.desktop` file is found for `DesktopFileId`, a minimal one is created under +`$XDG_DATA_HOME/applications` (default `~/.local/share/applications`). Writing the file is +best-effort — a read-only or absent home directory will not crash the application. Each +action's `Exec` relaunches the executable with the activation argument, which the bundled +single-instance layer forwards to the running primary instance. + ### macOS - Requires macOS 10.14 (Mojave) or later. @@ -335,6 +617,18 @@ typically included as a dependency of `libnotify4`. `OnDismissed` callback is not fired after the user activates a notification or clicks a button (unlike Windows, where WinToastLib always fires the dismissed event after any interaction). +- Taskbar progress draws an `NSProgressIndicator` along the bottom of the **Dock tile**. + This is only visible for a regular bundled GUI application that owns a Dock tile and has a + running main loop; a bare console process has none, so the calls are harmless no-ops. The + Dock cannot tint the bar, so `Paused` and `Error` render the same as `Normal`. +- Jump-list tasks appear in the **Dock menu** (right-click / click-and-hold of the Dock + icon) and fire `OnTaskActivated` live in-process — there is no relaunch, so + `TryHandleActivation` always returns `false` on macOS. This is 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 supplies 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 without clobbering a Dock menu the app + already provides. --- diff --git a/native/MacNotifyWrapper/MacNotifyWrapper.h b/native/MacNotifyWrapper/MacNotifyWrapper.h index ec59e44..9e56616 100644 --- a/native/MacNotifyWrapper/MacNotifyWrapper.h +++ b/native/MacNotifyWrapper/MacNotifyWrapper.h @@ -54,6 +54,15 @@ typedef void (*MNW_FailedCallback) (int64_t notifId); #define MNW_INTERRUPTION_TIME_SENSITIVE 2 #define MNW_INTERRUPTION_CRITICAL 3 +/* ------------------------------------------------------------------------- + * Dock-tile progress states (passed to MNW_SetTaskbarProgress) + * ------------------------------------------------------------------------- */ +#define MNW_PROGRESS_NONE 0 /* Clear the progress bar */ +#define MNW_PROGRESS_INDETERMINATE 1 /* Animated bar with no specific value */ +#define MNW_PROGRESS_NORMAL 2 /* Determinate bar at `fraction` */ +#define MNW_PROGRESS_PAUSED 3 /* Same visual as NORMAL (Dock cannot tint) */ +#define MNW_PROGRESS_ERROR 4 /* Same visual as NORMAL (Dock cannot tint) */ + /* ------------------------------------------------------------------------- * Handler — bundle of four callback function pointers, copied by value. * Any pointer may be NULL to opt out of that event. @@ -122,6 +131,56 @@ MACNOTIFYAPI int64_t MNW_ShowNotification( */ MACNOTIFYAPI bool MNW_HideNotification(int64_t notifId); +/** + * Sets the Dock-tile progress indicator. + * + * @param state One of MNW_PROGRESS_*. + * @param fraction Progress in the range 0.0–1.0 (used only when state is + * MNW_PROGRESS_NORMAL / PAUSED / ERROR; ignored otherwise). + * + * The work is dispatched asynchronously onto the main thread because AppKit/Dock + * APIs are main-thread-only. It is therefore only effective for a regular GUI + * application whose main run loop is running and which owns a Dock tile; a bare + * console process has no Dock tile and the call is a harmless no-op. + * Safe to call before MNW_Initialize (it does not depend on notification state). + */ +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 8fa09e9..371e10b 100644 --- a/native/MacNotifyWrapper/MacNotifyWrapper.m +++ b/native/MacNotifyWrapper/MacNotifyWrapper.m @@ -20,7 +20,9 @@ #define MACNOTIFYWRAPPER_EXPORTS #import +#import #import +#import #include #include #include "MacNotifyWrapper.h" @@ -442,3 +444,219 @@ bool MNW_HideNotification(int64_t notifId) return true; } + +/* ------------------------------------------------------------------------- + * Dock-tile progress + * + * AppKit Dock APIs are main-thread-only, so all work is dispatched onto the + * main queue. The custom content view draws the application icon with an + * NSProgressIndicator overlaid along the bottom edge. + * ------------------------------------------------------------------------- */ + +/* Accessed only on the main thread (inside the dispatched block). */ +static NSImageView* g_dockImageView = nil; +static NSProgressIndicator* g_dockProgress = nil; + +static void EnsureDockViews(NSDockTile* tile) +{ + if (g_dockImageView) return; + + NSImageView* iconView = [[NSImageView alloc] + initWithFrame:NSMakeRect(0, 0, tile.size.width, tile.size.height)]; + iconView.image = [NSApp applicationIconImage]; + + NSProgressIndicator* bar = [[NSProgressIndicator alloc] + initWithFrame:NSMakeRect(0.0, 0.0, tile.size.width, 12.0)]; + bar.style = NSProgressIndicatorStyleBar; + bar.indeterminate = NO; + bar.minValue = 0.0; + bar.maxValue = 1.0; + [iconView addSubview:bar]; + + tile.contentView = iconView; + g_dockImageView = iconView; + g_dockProgress = bar; +} + +void MNW_SetTaskbarProgress(int state, double fraction) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + NSApplication* app = [NSApplication sharedApplication]; + NSDockTile* tile = [app dockTile]; + + if (state == MNW_PROGRESS_NONE) { + if (g_dockProgress) [g_dockProgress stopAnimation:nil]; + tile.contentView = nil; + g_dockImageView = nil; + g_dockProgress = nil; + [tile display]; + return; + } + + EnsureDockViews(tile); + + if (state == MNW_PROGRESS_INDETERMINATE) { + g_dockProgress.indeterminate = YES; + [g_dockProgress startAnimation:nil]; + } else { + [g_dockProgress stopAnimation:nil]; + g_dockProgress.indeterminate = NO; + double clamped = fraction < 0.0 ? 0.0 : (fraction > 1.0 ? 1.0 : fraction); + g_dockProgress.doubleValue = clamped; + } + + g_dockProgress.hidden = NO; + [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/native/MacNotifyWrapper/Makefile b/native/MacNotifyWrapper/Makefile index f7f922e..3055007 100644 --- a/native/MacNotifyWrapper/Makefile +++ b/native/MacNotifyWrapper/Makefile @@ -16,6 +16,7 @@ CFLAGS := -fobjc-arc -fvisibility=hidden -O2 -Wall -Wextra \ -isysroot $(SDK) LDFLAGS := -dynamiclib \ -framework Foundation \ + -framework AppKit \ -framework UserNotifications \ -install_name @rpath/libMacNotifyWrapper.dylib diff --git a/samples/Notify.NET.Sample/Program.cs b/samples/Notify.NET.Sample/Program.cs index 221d705..098f851 100644 --- a/samples/Notify.NET.Sample/Program.cs +++ b/samples/Notify.NET.Sample/Program.cs @@ -119,9 +119,15 @@ services.AddNotifications(opts => opts.AppName = "Notify.NET Sample (DI)"; opts.AppUserModelId = "NotifyNET.Sample.DI"; }); +services.AddTaskbarProgress(opts => +{ + opts.AppName = "Notify.NET Sample (DI)"; + opts.DesktopFileId = "NotifyNET.Sample.DI"; // Linux: matches NotifyNET.Sample.DI.desktop +}); await using var provider = services.BuildServiceProvider(); -var diService = provider.GetRequiredService(); +var diService = provider.GetRequiredService(); +var diTaskbar = provider.GetRequiredService(); long id6 = await diService.ShowAsync( NotificationBuilder.Create("DI-registered Service") @@ -131,6 +137,65 @@ long id6 = await diService.ShowAsync( Console.WriteLine($" Shown with id={id6}"); await Task.Delay(3000); +// A realistic combined flow: drive the taskbar progress bar while a long-running +// job runs, then fire a completion notification when it finishes. +Console.WriteLine($" Taskbar progress supported: {diTaskbar.IsSupported}"); + +if (diTaskbar.IsSupported) +{ + Console.WriteLine(" Simulating a download with live taskbar progress..."); + const int totalBytes = 100; + for (int sent = 0; sent <= totalBytes; sent += 10) + { + diTaskbar.SetProgress((ulong)sent, (ulong)totalBytes); + await Task.Delay(300); + } + + await diService.ShowAsync( + NotificationBuilder.Create("Download complete") + .WithBody("All 100 bytes transferred.") + .Build()); + + diTaskbar.SetState(TaskbarProgressState.None); // clear the bar +} + +// ------ 7. Taskbar progress via the factory (no DI) -------------------------- +Console.WriteLine("\n[7] Demonstrating taskbar progress states (factory helper)..."); + +using var taskbar = ServiceCollectionExtensions.CreateTaskbarProgressService(opts => +{ + opts.AppName = "Notify.NET Sample"; + opts.DesktopFileId = "NotifyNET.Sample"; // Linux: matches NotifyNET.Sample.desktop +}); + +Console.WriteLine($" Taskbar progress supported: {taskbar.IsSupported}"); + +if (taskbar.IsSupported) +{ + // On Windows this drives the terminal's taskbar button: ITaskbarList3 for the classic + // console host, and the OSC 9;4 escape sequence for Windows Terminal (the Win11 default, + // where the app runs under a ConPTY and ITaskbarList3 has no visible button). For a + // WPF/WinForms app, call taskbar.SetWindow(mainWindowHandle) first to target its window. + Console.WriteLine(" Normal bar at 60%..."); + taskbar.SetProgress(0.60); + await Task.Delay(1500); + + Console.WriteLine(" Paused state..."); + taskbar.SetState(TaskbarProgressState.Paused); + await Task.Delay(1500); + + Console.WriteLine(" Error state..."); + taskbar.SetState(TaskbarProgressState.Error); + await Task.Delay(1500); + + Console.WriteLine(" Indeterminate state..."); + taskbar.SetState(TaskbarProgressState.Indeterminate); + await Task.Delay(1500); + + Console.WriteLine(" Clearing progress."); + taskbar.SetState(TaskbarProgressState.None); +} + Console.WriteLine("\nAll done."); // ============================================================================ 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/ITaskbarProgressService.cs b/src/Notify.NET/Abstractions/ITaskbarProgressService.cs new file mode 100644 index 0000000..a5f566c --- /dev/null +++ b/src/Notify.NET/Abstractions/ITaskbarProgressService.cs @@ -0,0 +1,99 @@ +using System; + +namespace Notify.NET.Abstractions +{ + /// + /// The visual state of a taskbar/dock/launcher progress indicator. + /// + public enum TaskbarProgressState + { + /// No progress bar is shown (the indicator is cleared). + None = 0, + + /// + /// A "marquee"/pulsing bar with no specific value, used when the total amount of work + /// is unknown. Honoured on Windows and macOS; Linux launchers fall back to a 0% bar. + /// + Indeterminate = 1, + + /// A normal (green, on Windows) progress bar reflecting the current value. + Normal = 2, + + /// + /// A paused (yellow, on Windows) progress bar. Other platforms render this the same as + /// because they cannot tint the bar. + /// + Paused = 3, + + /// + /// An error (red, on Windows) progress bar. On Linux the launcher entry is flagged + /// "urgent"; macOS renders this the same as . + /// + Error = 4 + } + + /// + /// Controls the progress indicator on the application's taskbar button (Windows), + /// launcher entry (Linux) or Dock tile (macOS). + /// + /// Capability notes: + /// + /// + /// Windows — uses ITaskbarList3. Requires a top-level window handle (HWND). + /// Defaults to the console window (GetConsoleWindow()); call + /// to target a WPF/WinForms main window instead. + /// + /// + /// Linux — uses the Unity LauncherEntry D-Bus API, honoured by KDE Plasma, Unity, + /// Dash-to-Dock, Plank and Latte. Requires the app to ship a .desktop file whose id + /// is supplied via NotificationOptions.DesktopFileId. + /// + /// + /// macOS — draws an NSProgressIndicator on the Dock tile. Only visible for a + /// regular (GUI/bundled) application that owns a Dock tile; a bare console process has none. + /// + /// + /// If the indicator is not available on the current platform, is + /// false and the methods are no-ops. + /// + public interface ITaskbarProgressService : IDisposable + { + /// + /// Whether a taskbar/launcher/dock progress indicator is available on this platform. + /// When false, all other methods are silent no-ops. + /// + bool IsSupported { get; } + + /// + /// Sets the visual state of the progress indicator without changing its value. + /// Use to clear it. + /// + void SetState(TaskbarProgressState state); + + /// + /// Sets the progress value and switches the indicator to + /// (unless it is currently in an + /// or + /// state, which are preserved). + /// + /// The amount of work completed. + /// The total amount of work. Must be greater than zero. + void SetProgress(ulong completed, ulong total); + + /// + /// Sets the progress as a fraction in the range 0.0–1.0, switching the indicator to + /// (subject to the same state-preservation rule + /// as ). + /// + /// A value between 0.0 and 1.0 (clamped). + void SetProgress(double fraction); + + /// + /// Windows only: targets a specific top-level window (e.g. a WPF/WinForms main window). + /// On other platforms this is a no-op. Passing reverts to the + /// console window. + /// + /// The HWND of the window whose taskbar button to control. + void SetWindow(IntPtr windowHandle); + } +} 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 20ead8e..bd26429 100644 --- a/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs +++ b/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs @@ -72,6 +72,113 @@ namespace Notify.NET.Extensions return new NullNotificationService(); } + + /// + /// Registers as a singleton, using the + /// platform-appropriate backend: + /// + /// Windows → (ITaskbarList3) + /// Linux → (Unity LauncherEntry D-Bus) + /// macOS → (Dock tile) + /// Other → ( = false) + /// + /// + /// The service collection to add to. + /// Optional delegate to configure . + public static IServiceCollection AddTaskbarProgress( + this IServiceCollection services, + Action? configure = null) + { + var options = new NotificationOptions(); + configure?.Invoke(options); + + services.AddSingleton(_ => CreateTaskbarService(options)); + return services; + } + + /// + /// Creates the platform-appropriate directly + /// (without a DI container), for use in simple console applications. + /// + public static ITaskbarProgressService CreateTaskbarProgressService( + Action? configure = null) + { + var opts = new NotificationOptions(); + configure?.Invoke(opts); + return CreateTaskbarService(opts); + } + + private static ITaskbarProgressService CreateTaskbarService(NotificationOptions opts) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return new WindowsTaskbarProgressService(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return new LinuxTaskbarProgressService( + opts.DesktopFileId ?? System.Diagnostics.Process.GetCurrentProcess().ProcessName); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return new MacOSTaskbarProgressService(); + + 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(); + } } /// @@ -103,6 +210,22 @@ namespace Notify.NET.Extensions /// Windows only — ignored on Linux and macOS. /// public string? AppIconPath { get; set; } + + /// + /// The application's .desktop file id (with or without the ".desktop" suffix), e.g. + /// "com.example.MyApp". Used by the Linux taskbar-progress backend to address the + /// correct launcher entry via the application://<id>.desktop URI. When null, + /// 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; } } /// @@ -122,4 +245,32 @@ namespace Notify.NET.Extensions public void Dispose() { } } + + /// + /// No-op implementation used when the current platform has no supported taskbar-progress + /// backend. is always false and every method is a silent no-op. + /// + internal sealed class NullTaskbarProgressService : ITaskbarProgressService + { + public bool IsSupported => false; + public void SetState(TaskbarProgressState state) { } + public void SetProgress(ulong completed, ulong total) { } + public void SetProgress(double fraction) { } + 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/GioDBusNative.cs b/src/Notify.NET/Platform/Linux/GioDBusNative.cs new file mode 100644 index 0000000..b6f44a5 --- /dev/null +++ b/src/Notify.NET/Platform/Linux/GioDBusNative.cs @@ -0,0 +1,104 @@ +using System; +using System.Runtime.InteropServices; + +namespace Notify.NET.Platform.Linux +{ + /// + /// P/Invoke declarations for GIO/GLib functions used to emit the Unity LauncherEntry + /// Update D-Bus signal that drives launcher/taskbar progress on Linux. + /// + /// The GVariant payload is built entirely from the non-variadic constructor functions + /// (g_variant_new_string, _double, _boolean, _variant, + /// _dict_entry, _array, _tuple) so that no g_variant_new + /// varargs call — which cannot be marshalled via P/Invoke — is required. Each helper + /// consumes the floating reference of its children, and + /// g_dbus_connection_emit_signal sinks the final floating tuple, so no manual + /// unref of the GVariants is needed. + /// + internal static class GioDBusNative + { + private const string LibGio = "libgio-2.0.so.0"; + private const string LibGLib = "libglib-2.0.so.0"; + + /// GBusType.G_BUS_TYPE_SESSION. + internal const int G_BUS_TYPE_SESSION = 2; + + // ------------------------------------------------------------------------- + // GIO — session bus + signal emission + // ------------------------------------------------------------------------- + + /// + /// Synchronously connects to a message bus. Returns a GDBusConnection* (a GObject + /// reference owned by the caller) or IntPtr.Zero on failure. + /// + [DllImport(LibGio, EntryPoint = "g_bus_get_sync")] + internal static extern IntPtr g_bus_get_sync(int busType, IntPtr cancellable, ref IntPtr error); + + /// + /// Emits a D-Bus signal on the given connection. must be a + /// tuple GVariant (its floating reference is sunk by this call). + /// + [DllImport(LibGio, EntryPoint = "g_dbus_connection_emit_signal", CharSet = CharSet.Ansi)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool g_dbus_connection_emit_signal( + IntPtr connection, + string? destinationBusName, + string objectPath, + string interfaceName, + string signalName, + IntPtr parameters, + ref IntPtr error); + + /// Synchronously flushes queued outgoing messages on the connection. + [DllImport(LibGio, EntryPoint = "g_dbus_connection_flush_sync")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool g_dbus_connection_flush_sync( + IntPtr connection, IntPtr cancellable, ref IntPtr error); + + // ------------------------------------------------------------------------- + // GLib — GVariant constructors (all return floating references) + // ------------------------------------------------------------------------- + + [DllImport(LibGLib, EntryPoint = "g_variant_new_string", CharSet = CharSet.Ansi)] + internal static extern IntPtr g_variant_new_string(string value); + + [DllImport(LibGLib, EntryPoint = "g_variant_new_double")] + internal static extern IntPtr g_variant_new_double(double value); + + [DllImport(LibGLib, EntryPoint = "g_variant_new_int64")] + internal static extern IntPtr g_variant_new_int64(long value); + + /// Creates a boolean GVariant. is a gboolean (0/1). + [DllImport(LibGLib, EntryPoint = "g_variant_new_boolean")] + internal static extern IntPtr g_variant_new_boolean(int value); + + /// Boxes a GVariant inside a variant (the "v" type), consuming the child's float ref. + [DllImport(LibGLib, EntryPoint = "g_variant_new_variant")] + internal static extern IntPtr g_variant_new_variant(IntPtr value); + + /// Creates a "{sv}" dictionary entry, consuming both children's float refs. + [DllImport(LibGLib, EntryPoint = "g_variant_new_dict_entry")] + internal static extern IntPtr g_variant_new_dict_entry(IntPtr key, IntPtr value); + + /// + /// Creates an array GVariant. With = Zero the element type is + /// inferred from the (non-empty) children, each of whose floating refs is consumed. + /// + [DllImport(LibGLib, EntryPoint = "g_variant_new_array")] + internal static extern IntPtr g_variant_new_array(IntPtr childType, IntPtr[] children, UIntPtr nChildren); + + /// Creates a tuple GVariant, consuming each child's floating reference. + [DllImport(LibGLib, EntryPoint = "g_variant_new_tuple")] + internal static extern IntPtr g_variant_new_tuple(IntPtr[] children, UIntPtr nChildren); + + // ------------------------------------------------------------------------- + // GObject / GLib cleanup + // ------------------------------------------------------------------------- + + [DllImport("libgobject-2.0.so.0", EntryPoint = "g_object_unref")] + internal static extern void g_object_unref(IntPtr obj); + + [DllImport(LibGLib, EntryPoint = "g_error_free")] + internal static extern void g_error_free(IntPtr error); + } +} 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/Linux/LinuxTaskbarProgressService.cs b/src/Notify.NET/Platform/Linux/LinuxTaskbarProgressService.cs new file mode 100644 index 0000000..e4cefbd --- /dev/null +++ b/src/Notify.NET/Platform/Linux/LinuxTaskbarProgressService.cs @@ -0,0 +1,185 @@ +using System; +using Notify.NET.Abstractions; + +namespace Notify.NET.Platform.Linux +{ + /// + /// implementation that drives launcher/taskbar progress + /// via the Unity LauncherEntry D-Bus API (com.canonical.Unity.LauncherEntry). This is + /// honoured by KDE Plasma, Unity, Dash-to-Dock, Plank and Latte. + /// + /// The signal is broadcast on the session bus and carries the app's application://<id>.desktop + /// URI plus a property dictionary (progress, progress-visible, urgent). The + /// app must therefore ship a matching .desktop file for any dock to display the bar. + /// + /// LauncherEntry has no indeterminate mode and cannot tint the bar, so + /// renders as an empty (0%) bar and only + /// is distinguished (via the "urgent" flag). + /// + public sealed class LinuxTaskbarProgressService : ITaskbarProgressService + { + private const string ObjectPath = "/com/canonical/Unity/LauncherEntry"; + private const string InterfaceName = "com.canonical.Unity.LauncherEntry"; + private const string SignalName = "Update"; + + private readonly object _lock = new object(); + private readonly string _appUri; + private IntPtr _connection; + + private TaskbarProgressState _state = TaskbarProgressState.None; + private double _progress; + private volatile bool _disposed; + + /// + public bool IsSupported { get; private set; } + + /// + /// The application's .desktop file id (with or without the ".desktop" suffix), e.g. + /// "myapp" or "com.example.MyApp.desktop". Used to build the + /// application://<id>.desktop URI the launcher matches against. + /// + public LinuxTaskbarProgressService(string desktopFileId) + { + if (desktopFileId == null) throw new ArgumentNullException(nameof(desktopFileId)); + + string id = desktopFileId.EndsWith(".desktop", StringComparison.Ordinal) + ? desktopFileId + : desktopFileId + ".desktop"; + _appUri = "application://" + id; + + try + { + IntPtr error = IntPtr.Zero; + _connection = GioDBusNative.g_bus_get_sync(GioDBusNative.G_BUS_TYPE_SESSION, IntPtr.Zero, ref error); + + if (_connection == IntPtr.Zero || error != IntPtr.Zero) + { + if (error != IntPtr.Zero) GioDBusNative.g_error_free(error); + IsSupported = false; + } + else + { + IsSupported = true; + } + } + catch (DllNotFoundException) + { + IsSupported = false; + } + } + + // ------------------------------------------------------------------ + // ITaskbarProgressService + // ------------------------------------------------------------------ + + /// + public void SetWindow(IntPtr windowHandle) { /* Windows-only concept; no-op on Linux. */ } + + /// + public void SetState(TaskbarProgressState state) + { + if (_disposed || !IsSupported) return; + lock (_lock) + { + _state = state; + if (state == TaskbarProgressState.None) _progress = 0; + Emit(); + } + } + + /// + public void SetProgress(ulong completed, ulong total) + { + if (_disposed || !IsSupported) return; + if (total == 0) throw new ArgumentOutOfRangeException(nameof(total), "Total must be greater than zero."); + SetProgress((double)completed / total); + } + + /// + public void SetProgress(double fraction) + { + if (_disposed || !IsSupported) return; + double clamped = fraction < 0 ? 0 : (fraction > 1 ? 1 : fraction); + lock (_lock) + { + _progress = clamped; + if (_state != TaskbarProgressState.Error && _state != TaskbarProgressState.Paused) + _state = TaskbarProgressState.Normal; + Emit(); + } + } + + // ------------------------------------------------------------------ + // IDisposable + // ------------------------------------------------------------------ + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + lock (_lock) + { + if (IsSupported && _connection != IntPtr.Zero) + { + // Clear the bar, then release the connection. + _state = TaskbarProgressState.None; + _progress = 0; + try { Emit(); } catch { /* best effort */ } + + GioDBusNative.g_object_unref(_connection); + _connection = IntPtr.Zero; + } + } + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + /// Builds and broadcasts the LauncherEntry "Update" signal for the current state. + private void Emit() + { + bool visible = _state != TaskbarProgressState.None; + bool urgent = _state == TaskbarProgressState.Error; + double progress = _state == TaskbarProgressState.Indeterminate ? 0.0 : _progress; + + // Build the a{sv} property dictionary. + IntPtr[] entries = + { + DictEntry("progress", GioDBusNative.g_variant_new_double(progress)), + DictEntry("progress-visible", GioDBusNative.g_variant_new_boolean(visible ? 1 : 0)), + DictEntry("urgent", GioDBusNative.g_variant_new_boolean(urgent ? 1 : 0)) + }; + + IntPtr dict = GioDBusNative.g_variant_new_array(IntPtr.Zero, entries, (UIntPtr)entries.Length); + + // Build the (s a{sv}) tuple. + IntPtr[] tupleChildren = { GioDBusNative.g_variant_new_string(_appUri), dict }; + IntPtr parameters = GioDBusNative.g_variant_new_tuple(tupleChildren, (UIntPtr)tupleChildren.Length); + + IntPtr error = IntPtr.Zero; + GioDBusNative.g_dbus_connection_emit_signal( + _connection, null, ObjectPath, InterfaceName, SignalName, parameters, ref error); + + if (error != IntPtr.Zero) + { + GioDBusNative.g_error_free(error); + return; + } + + IntPtr flushError = IntPtr.Zero; + GioDBusNative.g_dbus_connection_flush_sync(_connection, IntPtr.Zero, ref flushError); + if (flushError != IntPtr.Zero) GioDBusNative.g_error_free(flushError); + } + + /// Creates a "{sv}" dict entry, boxing in a variant. + private static IntPtr DictEntry(string key, IntPtr value) + { + IntPtr keyVariant = GioDBusNative.g_variant_new_string(key); + IntPtr boxedValue = GioDBusNative.g_variant_new_variant(value); + return GioDBusNative.g_variant_new_dict_entry(keyVariant, boxedValue); + } + } +} 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/MacNotifyNative.cs b/src/Notify.NET/Platform/MacOS/MacNotifyNative.cs index 4308157..0c67afa 100644 --- a/src/Notify.NET/Platform/MacOS/MacNotifyNative.cs +++ b/src/Notify.NET/Platform/MacOS/MacNotifyNative.cs @@ -87,5 +87,18 @@ namespace Notify.NET.Platform.MacOS [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] [return: MarshalAs(UnmanagedType.I1)] internal static extern bool MNW_HideNotification(long notifId); + + // ------------------------------------------------------------------ + // Dock-tile progress + // ------------------------------------------------------------------ + + internal const int MNW_PROGRESS_NONE = 0; + internal const int MNW_PROGRESS_INDETERMINATE = 1; + internal const int MNW_PROGRESS_NORMAL = 2; + internal const int MNW_PROGRESS_PAUSED = 3; + internal const int MNW_PROGRESS_ERROR = 4; + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern void MNW_SetTaskbarProgress(int state, double fraction); } } 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/MacOS/MacOSTaskbarProgressService.cs b/src/Notify.NET/Platform/MacOS/MacOSTaskbarProgressService.cs new file mode 100644 index 0000000..2b499ff --- /dev/null +++ b/src/Notify.NET/Platform/MacOS/MacOSTaskbarProgressService.cs @@ -0,0 +1,111 @@ +using System; +using Notify.NET.Abstractions; + +namespace Notify.NET.Platform.MacOS +{ + /// + /// implementation that draws an + /// NSProgressIndicator on the application's Dock tile via the native + /// libMacNotifyWrapper.dylib (MNW_SetTaskbarProgress). + /// + /// The Dock tile is only present for a regular GUI/bundled application whose main run loop + /// is running. A bare console process has no Dock tile, so the calls are harmless no-ops in + /// that case. The Dock cannot tint the bar, so and + /// render the same as . + /// + public sealed class MacOSTaskbarProgressService : ITaskbarProgressService + { + private TaskbarProgressState _state = TaskbarProgressState.None; + private double _progress; + private volatile bool _disposed; + + /// + public bool IsSupported { get; private set; } + + public MacOSTaskbarProgressService() + { + try + { + MacOSNativeLibraryLoader.EnsureLoaded(); + IsSupported = true; + } + catch (DllNotFoundException) + { + IsSupported = false; + } + } + + // ------------------------------------------------------------------ + // ITaskbarProgressService + // ------------------------------------------------------------------ + + /// + public void SetWindow(IntPtr windowHandle) { /* Windows-only concept; no-op on macOS. */ } + + /// + public void SetState(TaskbarProgressState state) + { + if (_disposed || !IsSupported) return; + _state = state; + if (state == TaskbarProgressState.None) _progress = 0; + Apply(); + } + + /// + public void SetProgress(ulong completed, ulong total) + { + if (_disposed || !IsSupported) return; + if (total == 0) throw new ArgumentOutOfRangeException(nameof(total), "Total must be greater than zero."); + SetProgress((double)completed / total); + } + + /// + public void SetProgress(double fraction) + { + if (_disposed || !IsSupported) return; + _progress = fraction < 0 ? 0 : (fraction > 1 ? 1 : fraction); + if (_state != TaskbarProgressState.Error && _state != TaskbarProgressState.Paused) + _state = TaskbarProgressState.Normal; + Apply(); + } + + // ------------------------------------------------------------------ + // IDisposable + // ------------------------------------------------------------------ + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + if (IsSupported) + { + _state = TaskbarProgressState.None; + _progress = 0; + try { Apply(); } catch { /* best effort */ } + } + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + private void Apply() + { + MacNotifyNative.MNW_SetTaskbarProgress(MapState(_state), _progress); + } + + private static int MapState(TaskbarProgressState state) + { + switch (state) + { + case TaskbarProgressState.Indeterminate: return MacNotifyNative.MNW_PROGRESS_INDETERMINATE; + case TaskbarProgressState.Normal: return MacNotifyNative.MNW_PROGRESS_NORMAL; + case TaskbarProgressState.Paused: return MacNotifyNative.MNW_PROGRESS_PAUSED; + case TaskbarProgressState.Error: return MacNotifyNative.MNW_PROGRESS_ERROR; + default: return MacNotifyNative.MNW_PROGRESS_NONE; + } + } + } +} 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/TaskbarListNative.cs b/src/Notify.NET/Platform/Windows/TaskbarListNative.cs new file mode 100644 index 0000000..feac2c9 --- /dev/null +++ b/src/Notify.NET/Platform/Windows/TaskbarListNative.cs @@ -0,0 +1,62 @@ +using System; +using System.Runtime.InteropServices; + +namespace Notify.NET.Platform.Windows +{ + /// + /// COM interop declarations for the Windows ITaskbarList3 interface, used to drive the + /// taskbar-button progress indicator. No native wrapper DLL is required — the COM object is the + /// in-box shell CLSID_TaskbarList coclass, available on Windows 7 and later. + /// + internal static class TaskbarListNative + { + /// Progress-bar states accepted by . + [Flags] + internal enum TBPFLAG + { + TBPF_NOPROGRESS = 0, + TBPF_INDETERMINATE = 0x1, + TBPF_NORMAL = 0x2, + TBPF_ERROR = 0x4, + TBPF_PAUSED = 0x8 + } + + /// + /// The shell taskbar-list coclass. Instantiate via new TaskbarInstance() and cast to + /// . + /// + [ComImport] + [Guid("56FDF344-FD6D-11d0-958A-006097C9A090")] + [ClassInterface(ClassInterfaceType.None)] + internal class TaskbarInstance { } + + /// + /// Subset of ITaskbarList3. Methods are declared in exact vtable order (inherited + /// ITaskbarList and ITaskbarList2 members first) up to the two we use; later + /// members are intentionally omitted. + /// + [ComImport] + [Guid("ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface ITaskbarList3 + { + // ---- ITaskbarList ---- + void HrInit(); + void AddTab(IntPtr hwnd); + void DeleteTab(IntPtr hwnd); + void ActivateTab(IntPtr hwnd); + void SetActiveAlt(IntPtr hwnd); + + // ---- ITaskbarList2 ---- + void MarkFullscreenWindow(IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fFullscreen); + + // ---- ITaskbarList3 (only the members we need) ---- + void SetProgressValue(IntPtr hwnd, ulong ullCompleted, ulong ullTotal); + void SetProgressState(IntPtr hwnd, TBPFLAG tbpFlags); + } + + /// Returns the HWND of the console window owned by this process, or Zero if none. + [DllImport("kernel32.dll")] + internal static extern IntPtr GetConsoleWindow(); + } +} 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 */ } + } + } + } +} diff --git a/src/Notify.NET/Platform/Windows/WindowsTaskbarProgressService.cs b/src/Notify.NET/Platform/Windows/WindowsTaskbarProgressService.cs new file mode 100644 index 0000000..0cd7700 --- /dev/null +++ b/src/Notify.NET/Platform/Windows/WindowsTaskbarProgressService.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using Notify.NET.Abstractions; + +namespace Notify.NET.Platform.Windows +{ + /// + /// implementation backed by the shell ITaskbarList3 + /// COM interface. No native wrapper DLL is required. + /// + /// Two mechanisms are driven together so that progress is visible across host environments: + /// + /// + /// ITaskbarList3 — sets progress on the taskbar button of the target window. + /// This is the mechanism for GUI apps (pass the window via ) and for + /// the classic console host (conhost.exe), whose console window owns a taskbar button. + /// + /// + /// OSC 9;4 — the ConEmu/Windows-Terminal progress escape sequence, written to stdout. + /// Under Windows Terminal (the Windows 11 default) the app runs through a ConPTY and + /// GetConsoleWindow() returns a hidden proxy window with no taskbar button, so + /// ITaskbarList3 has no visible effect; Windows Terminal instead reflects this + /// sequence on its own taskbar button. It is emitted only when targeting the default console + /// window and stdout is an interactive console (never when redirected, to avoid corrupting + /// piped output). + /// + /// + /// + /// Threading model: + /// ITaskbarList3 is an apartment-threaded in-proc COM object. This service owns a + /// dedicated STA background thread; the COM object is created on it and every call is + /// marshalled onto that thread via a work-item queue, mirroring + /// . + /// + public sealed class WindowsTaskbarProgressService : ITaskbarProgressService + { + private readonly Thread _staThread; + private readonly BlockingCollection _workQueue = new BlockingCollection(); + private readonly ManualResetEventSlim _initialised = new ManualResetEventSlim(false); + + // OSC 9;4 is only meaningful for the console scenario and must not pollute redirected output. + private readonly bool _consoleEligible = !Console.IsOutputRedirected; + + private TaskbarListNative.ITaskbarList3? _taskbarList; + private IntPtr _hwnd; + private TaskbarProgressState _state = TaskbarProgressState.None; + private int _percent; + private bool _explicitWindow; + private volatile bool _isSupported; + private volatile bool _disposed; + + /// + public bool IsSupported => _isSupported; + + public WindowsTaskbarProgressService() + { + _hwnd = TaskbarListNative.GetConsoleWindow(); + + _staThread = new Thread(StaThreadProc) + { + Name = "Notify.NET Taskbar STA", + IsBackground = true + }; + _staThread.SetApartmentState(ApartmentState.STA); + _staThread.Start(); + + _initialised.Wait(); + } + + // ------------------------------------------------------------------ + // ITaskbarProgressService + // ------------------------------------------------------------------ + + /// + public void SetWindow(IntPtr windowHandle) + { + if (_disposed || !_isSupported) return; + EnqueueOnSta(() => + { + if (windowHandle != IntPtr.Zero) + { + _hwnd = windowHandle; + // Targeting a real GUI window: stop emitting console sequences and clear any + // progress already shown on the terminal's taskbar button. + if (_explicitWindow == false) EmitConsole(TaskbarProgressState.None, 0); + _explicitWindow = true; + } + else + { + _hwnd = TaskbarListNative.GetConsoleWindow(); + _explicitWindow = false; + } + }); + } + + /// + public void SetState(TaskbarProgressState state) + { + if (_disposed || !_isSupported) return; + EnqueueOnSta(() => + { + _state = state; + if (state == TaskbarProgressState.None) _percent = 0; + _taskbarList!.SetProgressState(_hwnd, MapState(state)); + EmitConsole(_state, _percent); + }); + } + + /// + public void SetProgress(ulong completed, ulong total) + { + if (_disposed || !_isSupported) return; + if (total == 0) throw new ArgumentOutOfRangeException(nameof(total), "Total must be greater than zero."); + + EnqueueOnSta(() => + { + // SetProgressValue implicitly switches NoProgress/Indeterminate to Normal. + // Preserve an explicit Error/Paused colour if one is currently set. + if (_state != TaskbarProgressState.Error && _state != TaskbarProgressState.Paused) + { + _state = TaskbarProgressState.Normal; + _taskbarList!.SetProgressState(_hwnd, TaskbarListNative.TBPFLAG.TBPF_NORMAL); + } + _taskbarList!.SetProgressValue(_hwnd, completed, total); + + _percent = (int)Math.Round((double)completed / total * 100.0); + EmitConsole(_state, _percent); + }); + } + + /// + public void SetProgress(double fraction) + { + double clamped = fraction < 0 ? 0 : (fraction > 1 ? 1 : fraction); + SetProgress((ulong)Math.Round(clamped * 1000.0), 1000UL); + } + + // ------------------------------------------------------------------ + // IDisposable + // ------------------------------------------------------------------ + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _workQueue.CompleteAdding(); + if (_staThread.IsAlive) + _staThread.Join(TimeSpan.FromSeconds(5)); + + _workQueue.Dispose(); + _initialised.Dispose(); + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + private void EnqueueOnSta(Action action) + { + try { _workQueue.Add(action); } + catch (InvalidOperationException) { /* queue completed — service disposed */ } + } + + private static TaskbarListNative.TBPFLAG MapState(TaskbarProgressState state) + { + switch (state) + { + case TaskbarProgressState.Indeterminate: return TaskbarListNative.TBPFLAG.TBPF_INDETERMINATE; + case TaskbarProgressState.Normal: return TaskbarListNative.TBPFLAG.TBPF_NORMAL; + case TaskbarProgressState.Paused: return TaskbarListNative.TBPFLAG.TBPF_PAUSED; + case TaskbarProgressState.Error: return TaskbarListNative.TBPFLAG.TBPF_ERROR; + default: return TaskbarListNative.TBPFLAG.TBPF_NOPROGRESS; + } + } + + /// + /// Writes the ConEmu/Windows-Terminal OSC 9;4 progress sequence to stdout so the terminal's + /// own taskbar button reflects progress (the only mechanism that works under ConPTY). Emitted + /// only when targeting the default console window and stdout is a real interactive console. + /// + private void EmitConsole(TaskbarProgressState state, int percent) + { + if (_explicitWindow || !_consoleEligible) return; + + // OSC 9;4 state codes: 0=remove, 1=normal, 2=error, 3=indeterminate, 4=warning(paused). + int code; + switch (state) + { + case TaskbarProgressState.Indeterminate: code = 3; break; + case TaskbarProgressState.Error: code = 2; break; + case TaskbarProgressState.Paused: code = 4; break; + case TaskbarProgressState.None: code = 0; break; + default: code = 1; break; // Normal + } + + int clamped = percent < 0 ? 0 : (percent > 100 ? 100 : percent); + + try + { + Console.Out.Write("\x1b]9;4;" + code + ";" + clamped + "\x07"); + Console.Out.Flush(); + } + catch { /* no console attached — ignore */ } + } + + private void StaThreadProc() + { + try + { + var instance = (TaskbarListNative.ITaskbarList3)new TaskbarListNative.TaskbarInstance(); + instance.HrInit(); + _taskbarList = instance; + _isSupported = true; + } + catch (Exception) + { + // COM object unavailable (pre-Win7 or restricted) — degrade to no-op. + _isSupported = false; + } + finally + { + _initialised.Set(); + } + + if (!_isSupported) return; + + try + { + foreach (Action work in _workQueue.GetConsumingEnumerable()) + work(); + } + catch (InvalidOperationException) { /* queue completed */ } + finally + { + if (_taskbarList != null) + { + // Clear any visible progress before releasing the COM object. + try { _taskbarList.SetProgressState(_hwnd, TaskbarListNative.TBPFLAG.TBPF_NOPROGRESS); } + catch { /* best effort */ } + EmitConsole(TaskbarProgressState.None, 0); + + System.Runtime.InteropServices.Marshal.FinalReleaseComObject(_taskbarList); + _taskbarList = null; + } + } + } + } +}