diff --git a/native/MacNotifyWrapper/MacNotifyWrapper.h b/native/MacNotifyWrapper/MacNotifyWrapper.h index ec59e44..be3d464 100644 --- a/native/MacNotifyWrapper/MacNotifyWrapper.h +++ b/native/MacNotifyWrapper/MacNotifyWrapper.h @@ -54,6 +54,15 @@ typedef void (*MNW_FailedCallback) (int64_t notifId); #define MNW_INTERRUPTION_TIME_SENSITIVE 2 #define MNW_INTERRUPTION_CRITICAL 3 +/* ------------------------------------------------------------------------- + * Dock-tile progress states (passed to MNW_SetTaskbarProgress) + * ------------------------------------------------------------------------- */ +#define MNW_PROGRESS_NONE 0 /* Clear the progress bar */ +#define MNW_PROGRESS_INDETERMINATE 1 /* Animated bar with no specific value */ +#define MNW_PROGRESS_NORMAL 2 /* Determinate bar at `fraction` */ +#define MNW_PROGRESS_PAUSED 3 /* Same visual as NORMAL (Dock cannot tint) */ +#define MNW_PROGRESS_ERROR 4 /* Same visual as NORMAL (Dock cannot tint) */ + /* ------------------------------------------------------------------------- * Handler — bundle of four callback function pointers, copied by value. * Any pointer may be NULL to opt out of that event. @@ -122,6 +131,21 @@ 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); + #ifdef __cplusplus } #endif diff --git a/native/MacNotifyWrapper/MacNotifyWrapper.m b/native/MacNotifyWrapper/MacNotifyWrapper.m index 8fa09e9..a7498d2 100644 --- a/native/MacNotifyWrapper/MacNotifyWrapper.m +++ b/native/MacNotifyWrapper/MacNotifyWrapper.m @@ -20,6 +20,7 @@ #define MACNOTIFYWRAPPER_EXPORTS #import +#import #import #include #include @@ -442,3 +443,68 @@ 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]; + }); +} diff --git a/native/MacNotifyWrapper/Makefile b/native/MacNotifyWrapper/Makefile index f7f922e..3055007 100644 --- a/native/MacNotifyWrapper/Makefile +++ b/native/MacNotifyWrapper/Makefile @@ -16,6 +16,7 @@ CFLAGS := -fobjc-arc -fvisibility=hidden -O2 -Wall -Wextra \ -isysroot $(SDK) LDFLAGS := -dynamiclib \ -framework Foundation \ + -framework AppKit \ -framework UserNotifications \ -install_name @rpath/libMacNotifyWrapper.dylib diff --git a/samples/Notify.NET.Sample/Program.cs b/samples/Notify.NET.Sample/Program.cs index 221d705..098f851 100644 --- a/samples/Notify.NET.Sample/Program.cs +++ b/samples/Notify.NET.Sample/Program.cs @@ -119,9 +119,15 @@ services.AddNotifications(opts => opts.AppName = "Notify.NET Sample (DI)"; opts.AppUserModelId = "NotifyNET.Sample.DI"; }); +services.AddTaskbarProgress(opts => +{ + opts.AppName = "Notify.NET Sample (DI)"; + opts.DesktopFileId = "NotifyNET.Sample.DI"; // Linux: matches NotifyNET.Sample.DI.desktop +}); await using var provider = services.BuildServiceProvider(); -var diService = provider.GetRequiredService(); +var diService = provider.GetRequiredService(); +var diTaskbar = provider.GetRequiredService(); long id6 = await diService.ShowAsync( NotificationBuilder.Create("DI-registered Service") @@ -131,6 +137,65 @@ long id6 = await diService.ShowAsync( Console.WriteLine($" Shown with id={id6}"); await Task.Delay(3000); +// A realistic combined flow: drive the taskbar progress bar while a long-running +// job runs, then fire a completion notification when it finishes. +Console.WriteLine($" Taskbar progress supported: {diTaskbar.IsSupported}"); + +if (diTaskbar.IsSupported) +{ + Console.WriteLine(" Simulating a download with live taskbar progress..."); + const int totalBytes = 100; + for (int sent = 0; sent <= totalBytes; sent += 10) + { + diTaskbar.SetProgress((ulong)sent, (ulong)totalBytes); + await Task.Delay(300); + } + + await diService.ShowAsync( + NotificationBuilder.Create("Download complete") + .WithBody("All 100 bytes transferred.") + .Build()); + + diTaskbar.SetState(TaskbarProgressState.None); // clear the bar +} + +// ------ 7. Taskbar progress via the factory (no DI) -------------------------- +Console.WriteLine("\n[7] Demonstrating taskbar progress states (factory helper)..."); + +using var taskbar = ServiceCollectionExtensions.CreateTaskbarProgressService(opts => +{ + opts.AppName = "Notify.NET Sample"; + opts.DesktopFileId = "NotifyNET.Sample"; // Linux: matches NotifyNET.Sample.desktop +}); + +Console.WriteLine($" Taskbar progress supported: {taskbar.IsSupported}"); + +if (taskbar.IsSupported) +{ + // On Windows this drives the terminal's taskbar button: ITaskbarList3 for the classic + // console host, and the OSC 9;4 escape sequence for Windows Terminal (the Win11 default, + // where the app runs under a ConPTY and ITaskbarList3 has no visible button). For a + // WPF/WinForms app, call taskbar.SetWindow(mainWindowHandle) first to target its window. + Console.WriteLine(" Normal bar at 60%..."); + taskbar.SetProgress(0.60); + await Task.Delay(1500); + + Console.WriteLine(" Paused state..."); + taskbar.SetState(TaskbarProgressState.Paused); + await Task.Delay(1500); + + Console.WriteLine(" Error state..."); + taskbar.SetState(TaskbarProgressState.Error); + await Task.Delay(1500); + + Console.WriteLine(" Indeterminate state..."); + taskbar.SetState(TaskbarProgressState.Indeterminate); + await Task.Delay(1500); + + Console.WriteLine(" Clearing progress."); + taskbar.SetState(TaskbarProgressState.None); +} + Console.WriteLine("\nAll done."); // ============================================================================ diff --git a/src/Notify.NET/Abstractions/ITaskbarProgressService.cs b/src/Notify.NET/Abstractions/ITaskbarProgressService.cs new file mode 100644 index 0000000..a5f566c --- /dev/null +++ b/src/Notify.NET/Abstractions/ITaskbarProgressService.cs @@ -0,0 +1,99 @@ +using System; + +namespace Notify.NET.Abstractions +{ + /// + /// The visual state of a taskbar/dock/launcher progress indicator. + /// + public enum TaskbarProgressState + { + /// No progress bar is shown (the indicator is cleared). + None = 0, + + /// + /// A "marquee"/pulsing bar with no specific value, used when the total amount of work + /// is unknown. Honoured on Windows and macOS; Linux launchers fall back to a 0% bar. + /// + Indeterminate = 1, + + /// A normal (green, on Windows) progress bar reflecting the current value. + Normal = 2, + + /// + /// A paused (yellow, on Windows) progress bar. Other platforms render this the same as + /// because they cannot tint the bar. + /// + Paused = 3, + + /// + /// An error (red, on Windows) progress bar. On Linux the launcher entry is flagged + /// "urgent"; macOS renders this the same as . + /// + Error = 4 + } + + /// + /// Controls the progress indicator on the application's taskbar button (Windows), + /// launcher entry (Linux) or Dock tile (macOS). + /// + /// Capability notes: + /// + /// + /// Windows — uses ITaskbarList3. Requires a top-level window handle (HWND). + /// Defaults to the console window (GetConsoleWindow()); call + /// to target a WPF/WinForms main window instead. + /// + /// + /// Linux — uses the Unity LauncherEntry D-Bus API, honoured by KDE Plasma, Unity, + /// Dash-to-Dock, Plank and Latte. Requires the app to ship a .desktop file whose id + /// is supplied via NotificationOptions.DesktopFileId. + /// + /// + /// macOS — draws an NSProgressIndicator on the Dock tile. Only visible for a + /// regular (GUI/bundled) application that owns a Dock tile; a bare console process has none. + /// + /// + /// If the indicator is not available on the current platform, is + /// false and the methods are no-ops. + /// + public interface ITaskbarProgressService : IDisposable + { + /// + /// Whether a taskbar/launcher/dock progress indicator is available on this platform. + /// When false, all other methods are silent no-ops. + /// + bool IsSupported { get; } + + /// + /// Sets the visual state of the progress indicator without changing its value. + /// Use to clear it. + /// + void SetState(TaskbarProgressState state); + + /// + /// Sets the progress value and switches the indicator to + /// (unless it is currently in an + /// or + /// state, which are preserved). + /// + /// The amount of work completed. + /// The total amount of work. Must be greater than zero. + void SetProgress(ulong completed, ulong total); + + /// + /// Sets the progress as a fraction in the range 0.0–1.0, switching the indicator to + /// (subject to the same state-preservation rule + /// as ). + /// + /// A value between 0.0 and 1.0 (clamped). + void SetProgress(double fraction); + + /// + /// Windows only: targets a specific top-level window (e.g. a WPF/WinForms main window). + /// On other platforms this is a no-op. Passing reverts to the + /// console window. + /// + /// The HWND of the window whose taskbar button to control. + void SetWindow(IntPtr windowHandle); + } +} diff --git a/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs b/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs index 20ead8e..710bea6 100644 --- a/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs +++ b/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs @@ -72,6 +72,56 @@ 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(); + } } /// @@ -103,6 +153,14 @@ 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; } } /// @@ -122,4 +180,18 @@ 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() { } + } } diff --git a/src/Notify.NET/Platform/Linux/GioDBusNative.cs b/src/Notify.NET/Platform/Linux/GioDBusNative.cs new file mode 100644 index 0000000..b6f44a5 --- /dev/null +++ b/src/Notify.NET/Platform/Linux/GioDBusNative.cs @@ -0,0 +1,104 @@ +using System; +using System.Runtime.InteropServices; + +namespace Notify.NET.Platform.Linux +{ + /// + /// P/Invoke declarations for GIO/GLib functions used to emit the Unity LauncherEntry + /// Update D-Bus signal that drives launcher/taskbar progress on Linux. + /// + /// The GVariant payload is built entirely from the non-variadic constructor functions + /// (g_variant_new_string, _double, _boolean, _variant, + /// _dict_entry, _array, _tuple) so that no g_variant_new + /// varargs call — which cannot be marshalled via P/Invoke — is required. Each helper + /// consumes the floating reference of its children, and + /// g_dbus_connection_emit_signal sinks the final floating tuple, so no manual + /// unref of the GVariants is needed. + /// + internal static class GioDBusNative + { + private const string LibGio = "libgio-2.0.so.0"; + private const string LibGLib = "libglib-2.0.so.0"; + + /// GBusType.G_BUS_TYPE_SESSION. + internal const int G_BUS_TYPE_SESSION = 2; + + // ------------------------------------------------------------------------- + // GIO — session bus + signal emission + // ------------------------------------------------------------------------- + + /// + /// Synchronously connects to a message bus. Returns a GDBusConnection* (a GObject + /// reference owned by the caller) or IntPtr.Zero on failure. + /// + [DllImport(LibGio, EntryPoint = "g_bus_get_sync")] + internal static extern IntPtr g_bus_get_sync(int busType, IntPtr cancellable, ref IntPtr error); + + /// + /// Emits a D-Bus signal on the given connection. must be a + /// tuple GVariant (its floating reference is sunk by this call). + /// + [DllImport(LibGio, EntryPoint = "g_dbus_connection_emit_signal", CharSet = CharSet.Ansi)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool g_dbus_connection_emit_signal( + IntPtr connection, + string? destinationBusName, + string objectPath, + string interfaceName, + string signalName, + IntPtr parameters, + ref IntPtr error); + + /// Synchronously flushes queued outgoing messages on the connection. + [DllImport(LibGio, EntryPoint = "g_dbus_connection_flush_sync")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool g_dbus_connection_flush_sync( + IntPtr connection, IntPtr cancellable, ref IntPtr error); + + // ------------------------------------------------------------------------- + // GLib — GVariant constructors (all return floating references) + // ------------------------------------------------------------------------- + + [DllImport(LibGLib, EntryPoint = "g_variant_new_string", CharSet = CharSet.Ansi)] + internal static extern IntPtr g_variant_new_string(string value); + + [DllImport(LibGLib, EntryPoint = "g_variant_new_double")] + internal static extern IntPtr g_variant_new_double(double value); + + [DllImport(LibGLib, EntryPoint = "g_variant_new_int64")] + internal static extern IntPtr g_variant_new_int64(long value); + + /// Creates a boolean GVariant. is a gboolean (0/1). + [DllImport(LibGLib, EntryPoint = "g_variant_new_boolean")] + internal static extern IntPtr g_variant_new_boolean(int value); + + /// Boxes a GVariant inside a variant (the "v" type), consuming the child's float ref. + [DllImport(LibGLib, EntryPoint = "g_variant_new_variant")] + internal static extern IntPtr g_variant_new_variant(IntPtr value); + + /// Creates a "{sv}" dictionary entry, consuming both children's float refs. + [DllImport(LibGLib, EntryPoint = "g_variant_new_dict_entry")] + internal static extern IntPtr g_variant_new_dict_entry(IntPtr key, IntPtr value); + + /// + /// Creates an array GVariant. With = Zero the element type is + /// inferred from the (non-empty) children, each of whose floating refs is consumed. + /// + [DllImport(LibGLib, EntryPoint = "g_variant_new_array")] + internal static extern IntPtr g_variant_new_array(IntPtr childType, IntPtr[] children, UIntPtr nChildren); + + /// Creates a tuple GVariant, consuming each child's floating reference. + [DllImport(LibGLib, EntryPoint = "g_variant_new_tuple")] + internal static extern IntPtr g_variant_new_tuple(IntPtr[] children, UIntPtr nChildren); + + // ------------------------------------------------------------------------- + // GObject / GLib cleanup + // ------------------------------------------------------------------------- + + [DllImport("libgobject-2.0.so.0", EntryPoint = "g_object_unref")] + internal static extern void g_object_unref(IntPtr obj); + + [DllImport(LibGLib, EntryPoint = "g_error_free")] + internal static extern void g_error_free(IntPtr error); + } +} diff --git a/src/Notify.NET/Platform/Linux/LinuxTaskbarProgressService.cs b/src/Notify.NET/Platform/Linux/LinuxTaskbarProgressService.cs new file mode 100644 index 0000000..e4cefbd --- /dev/null +++ b/src/Notify.NET/Platform/Linux/LinuxTaskbarProgressService.cs @@ -0,0 +1,185 @@ +using System; +using Notify.NET.Abstractions; + +namespace Notify.NET.Platform.Linux +{ + /// + /// implementation that drives launcher/taskbar progress + /// via the Unity LauncherEntry D-Bus API (com.canonical.Unity.LauncherEntry). This is + /// honoured by KDE Plasma, Unity, Dash-to-Dock, Plank and Latte. + /// + /// The signal is broadcast on the session bus and carries the app's application://<id>.desktop + /// URI plus a property dictionary (progress, progress-visible, urgent). The + /// app must therefore ship a matching .desktop file for any dock to display the bar. + /// + /// LauncherEntry has no indeterminate mode and cannot tint the bar, so + /// renders as an empty (0%) bar and only + /// is distinguished (via the "urgent" flag). + /// + public sealed class LinuxTaskbarProgressService : ITaskbarProgressService + { + private const string ObjectPath = "/com/canonical/Unity/LauncherEntry"; + private const string InterfaceName = "com.canonical.Unity.LauncherEntry"; + private const string SignalName = "Update"; + + private readonly object _lock = new object(); + private readonly string _appUri; + private IntPtr _connection; + + private TaskbarProgressState _state = TaskbarProgressState.None; + private double _progress; + private volatile bool _disposed; + + /// + public bool IsSupported { get; private set; } + + /// + /// The application's .desktop file id (with or without the ".desktop" suffix), e.g. + /// "myapp" or "com.example.MyApp.desktop". Used to build the + /// application://<id>.desktop URI the launcher matches against. + /// + public LinuxTaskbarProgressService(string desktopFileId) + { + if (desktopFileId == null) throw new ArgumentNullException(nameof(desktopFileId)); + + string id = desktopFileId.EndsWith(".desktop", StringComparison.Ordinal) + ? desktopFileId + : desktopFileId + ".desktop"; + _appUri = "application://" + id; + + try + { + IntPtr error = IntPtr.Zero; + _connection = GioDBusNative.g_bus_get_sync(GioDBusNative.G_BUS_TYPE_SESSION, IntPtr.Zero, ref error); + + if (_connection == IntPtr.Zero || error != IntPtr.Zero) + { + if (error != IntPtr.Zero) GioDBusNative.g_error_free(error); + IsSupported = false; + } + else + { + IsSupported = true; + } + } + catch (DllNotFoundException) + { + IsSupported = false; + } + } + + // ------------------------------------------------------------------ + // ITaskbarProgressService + // ------------------------------------------------------------------ + + /// + public void SetWindow(IntPtr windowHandle) { /* Windows-only concept; no-op on Linux. */ } + + /// + public void SetState(TaskbarProgressState state) + { + if (_disposed || !IsSupported) return; + lock (_lock) + { + _state = state; + if (state == TaskbarProgressState.None) _progress = 0; + Emit(); + } + } + + /// + public void SetProgress(ulong completed, ulong total) + { + if (_disposed || !IsSupported) return; + if (total == 0) throw new ArgumentOutOfRangeException(nameof(total), "Total must be greater than zero."); + SetProgress((double)completed / total); + } + + /// + public void SetProgress(double fraction) + { + if (_disposed || !IsSupported) return; + double clamped = fraction < 0 ? 0 : (fraction > 1 ? 1 : fraction); + lock (_lock) + { + _progress = clamped; + if (_state != TaskbarProgressState.Error && _state != TaskbarProgressState.Paused) + _state = TaskbarProgressState.Normal; + Emit(); + } + } + + // ------------------------------------------------------------------ + // IDisposable + // ------------------------------------------------------------------ + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + lock (_lock) + { + if (IsSupported && _connection != IntPtr.Zero) + { + // Clear the bar, then release the connection. + _state = TaskbarProgressState.None; + _progress = 0; + try { Emit(); } catch { /* best effort */ } + + GioDBusNative.g_object_unref(_connection); + _connection = IntPtr.Zero; + } + } + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + /// Builds and broadcasts the LauncherEntry "Update" signal for the current state. + private void Emit() + { + bool visible = _state != TaskbarProgressState.None; + bool urgent = _state == TaskbarProgressState.Error; + double progress = _state == TaskbarProgressState.Indeterminate ? 0.0 : _progress; + + // Build the a{sv} property dictionary. + IntPtr[] entries = + { + DictEntry("progress", GioDBusNative.g_variant_new_double(progress)), + DictEntry("progress-visible", GioDBusNative.g_variant_new_boolean(visible ? 1 : 0)), + DictEntry("urgent", GioDBusNative.g_variant_new_boolean(urgent ? 1 : 0)) + }; + + IntPtr dict = GioDBusNative.g_variant_new_array(IntPtr.Zero, entries, (UIntPtr)entries.Length); + + // Build the (s a{sv}) tuple. + IntPtr[] tupleChildren = { GioDBusNative.g_variant_new_string(_appUri), dict }; + IntPtr parameters = GioDBusNative.g_variant_new_tuple(tupleChildren, (UIntPtr)tupleChildren.Length); + + IntPtr error = IntPtr.Zero; + GioDBusNative.g_dbus_connection_emit_signal( + _connection, null, ObjectPath, InterfaceName, SignalName, parameters, ref error); + + if (error != IntPtr.Zero) + { + GioDBusNative.g_error_free(error); + return; + } + + IntPtr flushError = IntPtr.Zero; + GioDBusNative.g_dbus_connection_flush_sync(_connection, IntPtr.Zero, ref flushError); + if (flushError != IntPtr.Zero) GioDBusNative.g_error_free(flushError); + } + + /// Creates a "{sv}" dict entry, boxing in a variant. + private static IntPtr DictEntry(string key, IntPtr value) + { + IntPtr keyVariant = GioDBusNative.g_variant_new_string(key); + IntPtr boxedValue = GioDBusNative.g_variant_new_variant(value); + return GioDBusNative.g_variant_new_dict_entry(keyVariant, boxedValue); + } + } +} diff --git a/src/Notify.NET/Platform/MacOS/MacNotifyNative.cs b/src/Notify.NET/Platform/MacOS/MacNotifyNative.cs index 4308157..0c67afa 100644 --- a/src/Notify.NET/Platform/MacOS/MacNotifyNative.cs +++ b/src/Notify.NET/Platform/MacOS/MacNotifyNative.cs @@ -87,5 +87,18 @@ namespace Notify.NET.Platform.MacOS [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] [return: MarshalAs(UnmanagedType.I1)] internal static extern bool MNW_HideNotification(long notifId); + + // ------------------------------------------------------------------ + // Dock-tile progress + // ------------------------------------------------------------------ + + internal const int MNW_PROGRESS_NONE = 0; + internal const int MNW_PROGRESS_INDETERMINATE = 1; + internal const int MNW_PROGRESS_NORMAL = 2; + internal const int MNW_PROGRESS_PAUSED = 3; + internal const int MNW_PROGRESS_ERROR = 4; + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern void MNW_SetTaskbarProgress(int state, double fraction); } } diff --git a/src/Notify.NET/Platform/MacOS/MacOSTaskbarProgressService.cs b/src/Notify.NET/Platform/MacOS/MacOSTaskbarProgressService.cs new file mode 100644 index 0000000..2b499ff --- /dev/null +++ b/src/Notify.NET/Platform/MacOS/MacOSTaskbarProgressService.cs @@ -0,0 +1,111 @@ +using System; +using Notify.NET.Abstractions; + +namespace Notify.NET.Platform.MacOS +{ + /// + /// implementation that draws an + /// NSProgressIndicator on the application's Dock tile via the native + /// libMacNotifyWrapper.dylib (MNW_SetTaskbarProgress). + /// + /// The Dock tile is only present for a regular GUI/bundled application whose main run loop + /// is running. A bare console process has no Dock tile, so the calls are harmless no-ops in + /// that case. The Dock cannot tint the bar, so and + /// render the same as . + /// + public sealed class MacOSTaskbarProgressService : ITaskbarProgressService + { + private TaskbarProgressState _state = TaskbarProgressState.None; + private double _progress; + private volatile bool _disposed; + + /// + public bool IsSupported { get; private set; } + + public MacOSTaskbarProgressService() + { + try + { + MacOSNativeLibraryLoader.EnsureLoaded(); + IsSupported = true; + } + catch (DllNotFoundException) + { + IsSupported = false; + } + } + + // ------------------------------------------------------------------ + // ITaskbarProgressService + // ------------------------------------------------------------------ + + /// + public void SetWindow(IntPtr windowHandle) { /* Windows-only concept; no-op on macOS. */ } + + /// + public void SetState(TaskbarProgressState state) + { + if (_disposed || !IsSupported) return; + _state = state; + if (state == TaskbarProgressState.None) _progress = 0; + Apply(); + } + + /// + public void SetProgress(ulong completed, ulong total) + { + if (_disposed || !IsSupported) return; + if (total == 0) throw new ArgumentOutOfRangeException(nameof(total), "Total must be greater than zero."); + SetProgress((double)completed / total); + } + + /// + public void SetProgress(double fraction) + { + if (_disposed || !IsSupported) return; + _progress = fraction < 0 ? 0 : (fraction > 1 ? 1 : fraction); + if (_state != TaskbarProgressState.Error && _state != TaskbarProgressState.Paused) + _state = TaskbarProgressState.Normal; + Apply(); + } + + // ------------------------------------------------------------------ + // IDisposable + // ------------------------------------------------------------------ + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + if (IsSupported) + { + _state = TaskbarProgressState.None; + _progress = 0; + try { Apply(); } catch { /* best effort */ } + } + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + private void Apply() + { + MacNotifyNative.MNW_SetTaskbarProgress(MapState(_state), _progress); + } + + private static int MapState(TaskbarProgressState state) + { + switch (state) + { + case TaskbarProgressState.Indeterminate: return MacNotifyNative.MNW_PROGRESS_INDETERMINATE; + case TaskbarProgressState.Normal: return MacNotifyNative.MNW_PROGRESS_NORMAL; + case TaskbarProgressState.Paused: return MacNotifyNative.MNW_PROGRESS_PAUSED; + case TaskbarProgressState.Error: return MacNotifyNative.MNW_PROGRESS_ERROR; + default: return MacNotifyNative.MNW_PROGRESS_NONE; + } + } + } +} diff --git a/src/Notify.NET/Platform/Windows/TaskbarListNative.cs b/src/Notify.NET/Platform/Windows/TaskbarListNative.cs new file mode 100644 index 0000000..feac2c9 --- /dev/null +++ b/src/Notify.NET/Platform/Windows/TaskbarListNative.cs @@ -0,0 +1,62 @@ +using System; +using System.Runtime.InteropServices; + +namespace Notify.NET.Platform.Windows +{ + /// + /// COM interop declarations for the Windows ITaskbarList3 interface, used to drive the + /// taskbar-button progress indicator. No native wrapper DLL is required — the COM object is the + /// in-box shell CLSID_TaskbarList coclass, available on Windows 7 and later. + /// + internal static class TaskbarListNative + { + /// Progress-bar states accepted by . + [Flags] + internal enum TBPFLAG + { + TBPF_NOPROGRESS = 0, + TBPF_INDETERMINATE = 0x1, + TBPF_NORMAL = 0x2, + TBPF_ERROR = 0x4, + TBPF_PAUSED = 0x8 + } + + /// + /// The shell taskbar-list coclass. Instantiate via new TaskbarInstance() and cast to + /// . + /// + [ComImport] + [Guid("56FDF344-FD6D-11d0-958A-006097C9A090")] + [ClassInterface(ClassInterfaceType.None)] + internal class TaskbarInstance { } + + /// + /// Subset of ITaskbarList3. Methods are declared in exact vtable order (inherited + /// ITaskbarList and ITaskbarList2 members first) up to the two we use; later + /// members are intentionally omitted. + /// + [ComImport] + [Guid("ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface ITaskbarList3 + { + // ---- ITaskbarList ---- + void HrInit(); + void AddTab(IntPtr hwnd); + void DeleteTab(IntPtr hwnd); + void ActivateTab(IntPtr hwnd); + void SetActiveAlt(IntPtr hwnd); + + // ---- ITaskbarList2 ---- + void MarkFullscreenWindow(IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fFullscreen); + + // ---- ITaskbarList3 (only the members we need) ---- + void SetProgressValue(IntPtr hwnd, ulong ullCompleted, ulong ullTotal); + void SetProgressState(IntPtr hwnd, TBPFLAG tbpFlags); + } + + /// Returns the HWND of the console window owned by this process, or Zero if none. + [DllImport("kernel32.dll")] + internal static extern IntPtr GetConsoleWindow(); + } +} diff --git a/src/Notify.NET/Platform/Windows/WindowsTaskbarProgressService.cs b/src/Notify.NET/Platform/Windows/WindowsTaskbarProgressService.cs new file mode 100644 index 0000000..0cd7700 --- /dev/null +++ b/src/Notify.NET/Platform/Windows/WindowsTaskbarProgressService.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using Notify.NET.Abstractions; + +namespace Notify.NET.Platform.Windows +{ + /// + /// implementation backed by the shell ITaskbarList3 + /// COM interface. No native wrapper DLL is required. + /// + /// Two mechanisms are driven together so that progress is visible across host environments: + /// + /// + /// ITaskbarList3 — sets progress on the taskbar button of the target window. + /// This is the mechanism for GUI apps (pass the window via ) and for + /// the classic console host (conhost.exe), whose console window owns a taskbar button. + /// + /// + /// OSC 9;4 — the ConEmu/Windows-Terminal progress escape sequence, written to stdout. + /// Under Windows Terminal (the Windows 11 default) the app runs through a ConPTY and + /// GetConsoleWindow() returns a hidden proxy window with no taskbar button, so + /// ITaskbarList3 has no visible effect; Windows Terminal instead reflects this + /// sequence on its own taskbar button. It is emitted only when targeting the default console + /// window and stdout is an interactive console (never when redirected, to avoid corrupting + /// piped output). + /// + /// + /// + /// Threading model: + /// ITaskbarList3 is an apartment-threaded in-proc COM object. This service owns a + /// dedicated STA background thread; the COM object is created on it and every call is + /// marshalled onto that thread via a work-item queue, mirroring + /// . + /// + public sealed class WindowsTaskbarProgressService : ITaskbarProgressService + { + private readonly Thread _staThread; + private readonly BlockingCollection _workQueue = new BlockingCollection(); + private readonly ManualResetEventSlim _initialised = new ManualResetEventSlim(false); + + // OSC 9;4 is only meaningful for the console scenario and must not pollute redirected output. + private readonly bool _consoleEligible = !Console.IsOutputRedirected; + + private TaskbarListNative.ITaskbarList3? _taskbarList; + private IntPtr _hwnd; + private TaskbarProgressState _state = TaskbarProgressState.None; + private int _percent; + private bool _explicitWindow; + private volatile bool _isSupported; + private volatile bool _disposed; + + /// + public bool IsSupported => _isSupported; + + public WindowsTaskbarProgressService() + { + _hwnd = TaskbarListNative.GetConsoleWindow(); + + _staThread = new Thread(StaThreadProc) + { + Name = "Notify.NET Taskbar STA", + IsBackground = true + }; + _staThread.SetApartmentState(ApartmentState.STA); + _staThread.Start(); + + _initialised.Wait(); + } + + // ------------------------------------------------------------------ + // ITaskbarProgressService + // ------------------------------------------------------------------ + + /// + public void SetWindow(IntPtr windowHandle) + { + if (_disposed || !_isSupported) return; + EnqueueOnSta(() => + { + if (windowHandle != IntPtr.Zero) + { + _hwnd = windowHandle; + // Targeting a real GUI window: stop emitting console sequences and clear any + // progress already shown on the terminal's taskbar button. + if (_explicitWindow == false) EmitConsole(TaskbarProgressState.None, 0); + _explicitWindow = true; + } + else + { + _hwnd = TaskbarListNative.GetConsoleWindow(); + _explicitWindow = false; + } + }); + } + + /// + public void SetState(TaskbarProgressState state) + { + if (_disposed || !_isSupported) return; + EnqueueOnSta(() => + { + _state = state; + if (state == TaskbarProgressState.None) _percent = 0; + _taskbarList!.SetProgressState(_hwnd, MapState(state)); + EmitConsole(_state, _percent); + }); + } + + /// + public void SetProgress(ulong completed, ulong total) + { + if (_disposed || !_isSupported) return; + if (total == 0) throw new ArgumentOutOfRangeException(nameof(total), "Total must be greater than zero."); + + EnqueueOnSta(() => + { + // SetProgressValue implicitly switches NoProgress/Indeterminate to Normal. + // Preserve an explicit Error/Paused colour if one is currently set. + if (_state != TaskbarProgressState.Error && _state != TaskbarProgressState.Paused) + { + _state = TaskbarProgressState.Normal; + _taskbarList!.SetProgressState(_hwnd, TaskbarListNative.TBPFLAG.TBPF_NORMAL); + } + _taskbarList!.SetProgressValue(_hwnd, completed, total); + + _percent = (int)Math.Round((double)completed / total * 100.0); + EmitConsole(_state, _percent); + }); + } + + /// + public void SetProgress(double fraction) + { + double clamped = fraction < 0 ? 0 : (fraction > 1 ? 1 : fraction); + SetProgress((ulong)Math.Round(clamped * 1000.0), 1000UL); + } + + // ------------------------------------------------------------------ + // IDisposable + // ------------------------------------------------------------------ + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _workQueue.CompleteAdding(); + if (_staThread.IsAlive) + _staThread.Join(TimeSpan.FromSeconds(5)); + + _workQueue.Dispose(); + _initialised.Dispose(); + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + private void EnqueueOnSta(Action action) + { + try { _workQueue.Add(action); } + catch (InvalidOperationException) { /* queue completed — service disposed */ } + } + + private static TaskbarListNative.TBPFLAG MapState(TaskbarProgressState state) + { + switch (state) + { + case TaskbarProgressState.Indeterminate: return TaskbarListNative.TBPFLAG.TBPF_INDETERMINATE; + case TaskbarProgressState.Normal: return TaskbarListNative.TBPFLAG.TBPF_NORMAL; + case TaskbarProgressState.Paused: return TaskbarListNative.TBPFLAG.TBPF_PAUSED; + case TaskbarProgressState.Error: return TaskbarListNative.TBPFLAG.TBPF_ERROR; + default: return TaskbarListNative.TBPFLAG.TBPF_NOPROGRESS; + } + } + + /// + /// Writes the ConEmu/Windows-Terminal OSC 9;4 progress sequence to stdout so the terminal's + /// own taskbar button reflects progress (the only mechanism that works under ConPTY). Emitted + /// only when targeting the default console window and stdout is a real interactive console. + /// + private void EmitConsole(TaskbarProgressState state, int percent) + { + if (_explicitWindow || !_consoleEligible) return; + + // OSC 9;4 state codes: 0=remove, 1=normal, 2=error, 3=indeterminate, 4=warning(paused). + int code; + switch (state) + { + case TaskbarProgressState.Indeterminate: code = 3; break; + case TaskbarProgressState.Error: code = 2; break; + case TaskbarProgressState.Paused: code = 4; break; + case TaskbarProgressState.None: code = 0; break; + default: code = 1; break; // Normal + } + + int clamped = percent < 0 ? 0 : (percent > 100 ? 100 : percent); + + try + { + Console.Out.Write("\x1b]9;4;" + code + ";" + clamped + "\x07"); + Console.Out.Flush(); + } + catch { /* no console attached — ignore */ } + } + + private void StaThreadProc() + { + try + { + var instance = (TaskbarListNative.ITaskbarList3)new TaskbarListNative.TaskbarInstance(); + instance.HrInit(); + _taskbarList = instance; + _isSupported = true; + } + catch (Exception) + { + // COM object unavailable (pre-Win7 or restricted) — degrade to no-op. + _isSupported = false; + } + finally + { + _initialised.Set(); + } + + if (!_isSupported) return; + + try + { + foreach (Action work in _workQueue.GetConsumingEnumerable()) + work(); + } + catch (InvalidOperationException) { /* queue completed */ } + finally + { + if (_taskbarList != null) + { + // Clear any visible progress before releasing the COM object. + try { _taskbarList.SetProgressState(_hwnd, TaskbarListNative.TBPFLAG.TBPF_NOPROGRESS); } + catch { /* best effort */ } + EmitConsole(TaskbarProgressState.None, 0); + + System.Runtime.InteropServices.Marshal.FinalReleaseComObject(_taskbarList); + _taskbarList = null; + } + } + } + } +}