diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90a6442..604589c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,9 +27,7 @@ permissions: jobs: build-windows: name: Build WinToastWrapper (${{ matrix.rid }}) - # 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 + runs-on: windows-latest strategy: fail-fast: false matrix: diff --git a/README.md b/README.md index 241f18d..1600a4f 100644 --- a/README.md +++ b/README.md @@ -276,268 +276,6 @@ 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 @@ -552,14 +290,6 @@ public interface IJumpListService : IDisposable 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 @@ -584,18 +314,6 @@ 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. @@ -617,18 +335,6 @@ single-instance layer forwards to the running primary instance. `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 9e56616..ec59e44 100644 --- a/native/MacNotifyWrapper/MacNotifyWrapper.h +++ b/native/MacNotifyWrapper/MacNotifyWrapper.h @@ -54,15 +54,6 @@ 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. @@ -131,56 +122,6 @@ 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 371e10b..8fa09e9 100644 --- a/native/MacNotifyWrapper/MacNotifyWrapper.m +++ b/native/MacNotifyWrapper/MacNotifyWrapper.m @@ -20,9 +20,7 @@ #define MACNOTIFYWRAPPER_EXPORTS #import -#import #import -#import #include #include #include "MacNotifyWrapper.h" @@ -444,219 +442,3 @@ 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 3055007..f7f922e 100644 --- a/native/MacNotifyWrapper/Makefile +++ b/native/MacNotifyWrapper/Makefile @@ -16,7 +16,6 @@ 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 098f851..221d705 100644 --- a/samples/Notify.NET.Sample/Program.cs +++ b/samples/Notify.NET.Sample/Program.cs @@ -119,15 +119,9 @@ 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 diTaskbar = provider.GetRequiredService(); +var diService = provider.GetRequiredService(); long id6 = await diService.ShowAsync( NotificationBuilder.Create("DI-registered Service") @@ -137,65 +131,6 @@ 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 deleted file mode 100644 index 3e85340..0000000 --- a/src/Notify.NET/Abstractions/IJumpListHandler.cs +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 5ff9696..0000000 --- a/src/Notify.NET/Abstractions/IJumpListService.cs +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index a5f566c..0000000 --- a/src/Notify.NET/Abstractions/ITaskbarProgressService.cs +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index f6e5557..0000000 --- a/src/Notify.NET/Abstractions/JumpListTask.cs +++ /dev/null @@ -1,81 +0,0 @@ -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 bd26429..20ead8e 100644 --- a/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs +++ b/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs @@ -72,113 +72,6 @@ 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(); - } } /// @@ -210,22 +103,6 @@ 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; } } /// @@ -245,32 +122,4 @@ 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 deleted file mode 100644 index fdc11cf..0000000 --- a/src/Notify.NET/Platform/JumpListActivation.cs +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index b4e6e4d..0000000 --- a/src/Notify.NET/Platform/JumpListActivationRouter.cs +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index 9cf1a31..0000000 --- a/src/Notify.NET/Platform/Linux/DesktopFileWriter.cs +++ /dev/null @@ -1,224 +0,0 @@ -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 deleted file mode 100644 index b6f44a5..0000000 --- a/src/Notify.NET/Platform/Linux/GioDBusNative.cs +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 8d9eabb..0000000 --- a/src/Notify.NET/Platform/Linux/LinuxJumpListService.cs +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index e4cefbd..0000000 --- a/src/Notify.NET/Platform/Linux/LinuxTaskbarProgressService.cs +++ /dev/null @@ -1,185 +0,0 @@ -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 deleted file mode 100644 index db7d323..0000000 --- a/src/Notify.NET/Platform/MacOS/MacJumpListNative.cs +++ /dev/null @@ -1,45 +0,0 @@ -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 0c67afa..4308157 100644 --- a/src/Notify.NET/Platform/MacOS/MacNotifyNative.cs +++ b/src/Notify.NET/Platform/MacOS/MacNotifyNative.cs @@ -87,18 +87,5 @@ 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 deleted file mode 100644 index 6d40966..0000000 --- a/src/Notify.NET/Platform/MacOS/MacOSJumpListService.cs +++ /dev/null @@ -1,142 +0,0 @@ -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 deleted file mode 100644 index 2b499ff..0000000 --- a/src/Notify.NET/Platform/MacOS/MacOSTaskbarProgressService.cs +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index 9811912..0000000 --- a/src/Notify.NET/Platform/SingleInstanceChannel.cs +++ /dev/null @@ -1,166 +0,0 @@ -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 deleted file mode 100644 index 9b8337e..0000000 --- a/src/Notify.NET/Platform/Windows/CustomDestinationListNative.cs +++ /dev/null @@ -1,183 +0,0 @@ -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 deleted file mode 100644 index feac2c9..0000000 --- a/src/Notify.NET/Platform/Windows/TaskbarListNative.cs +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 0f2ae3c..0000000 --- a/src/Notify.NET/Platform/Windows/WindowsJumpListService.cs +++ /dev/null @@ -1,264 +0,0 @@ -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 deleted file mode 100644 index 0cd7700..0000000 --- a/src/Notify.NET/Platform/Windows/WindowsTaskbarProgressService.cs +++ /dev/null @@ -1,251 +0,0 @@ -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; - } - } - } - } -}