From 1e81bcc7d930ed8d7f32c3d5fe7168133f3ffb86 Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Sun, 14 Jun 2026 15:00:52 -0500 Subject: [PATCH] Update README for docs --- README.md | 294 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) diff --git a/README.md b/README.md index 1600a4f..241f18d 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,268 @@ cleanup on macOS). --- +## Taskbar progress + +`ITaskbarProgressService` drives the progress indicator on the application's taskbar button +(Windows), launcher entry (Linux) or Dock tile (macOS) — the same green/red bar Windows +Explorer shows during a file copy. Use it to surface the progress of a long-running +operation without a custom UI. + +| Platform | Backend | Requirement | +|----------|---------|-------------| +| Windows | `ITaskbarList3` | A top-level window handle (defaults to the console window) | +| Linux | Unity LauncherEntry D-Bus API (KDE Plasma, Unity, Dash-to-Dock, Plank, Latte) | A `.desktop` file whose id is supplied via `DesktopFileId` | +| macOS | `NSProgressIndicator` drawn on the Dock tile | A bundled GUI app that owns a Dock tile | + +If the indicator is unavailable on the current platform, `IsSupported` is `false` and all +methods are silent no-ops. + +### Creating the service + +```csharp +// Direct (no DI container) +using var progress = ServiceCollectionExtensions.CreateTaskbarProgressService(opts => +{ + opts.DesktopFileId = "com.example.MyApp"; // Linux: the app's .desktop file id +}); + +// With Microsoft.Extensions.DependencyInjection +services.AddTaskbarProgress(opts => +{ + opts.DesktopFileId = "com.example.MyApp"; +}); +``` + +### Reporting progress + +```csharp +if (!progress.IsSupported) + return; + +// Determinate progress, by fraction (0.0–1.0, clamped)… +progress.SetProgress(0.25); + +// …or by completed / total counts. +for (ulong i = 0; i <= total; i++) +{ + DoWork(i); + progress.SetProgress(i, total); // total must be greater than zero +} + +// Clear the indicator when finished. +progress.SetState(TaskbarProgressState.None); +``` + +Calling either `SetProgress` overload switches the indicator to the `Normal` state, unless +it is currently in the `Error` or `Paused` state (those are preserved so a paused/failed +operation keeps its colour while its value updates). + +### States + +```csharp +progress.SetState(TaskbarProgressState.Indeterminate); // work of unknown length +progress.SetState(TaskbarProgressState.Paused); // operation paused +progress.SetState(TaskbarProgressState.Error); // operation failed +progress.SetState(TaskbarProgressState.None); // clear the indicator +``` + +| State | Windows | Linux | macOS | +|-------|---------|-------|-------| +| `None` | No bar | No bar | No bar | +| `Indeterminate` | Pulsing marquee bar | Falls back to a 0% bar | Animated bar | +| `Normal` | Green bar | Bar at the current value | Bar at the current value | +| `Paused` | Yellow bar | Same as `Normal` | Same as `Normal` | +| `Error` | Red bar | Launcher entry flagged "urgent" | Same as `Normal` | + +### Targeting a window (Windows) + +By default the Windows backend targets the console window (`GetConsoleWindow()`). For a +WPF/WinForms app, point it at your main window's HWND so the bar appears on the right +taskbar button. This is a no-op on Linux and macOS. + +```csharp +// WPF +var hwnd = new System.Windows.Interop.WindowInteropHelper(mainWindow).Handle; +progress.SetWindow(hwnd); + +// WinForms +progress.SetWindow(form.Handle); + +// Revert to the console window +progress.SetWindow(IntPtr.Zero); +``` + +### ITaskbarProgressService interface + +```csharp +public interface ITaskbarProgressService : IDisposable +{ + // False if a progress indicator is unavailable on this platform. + bool IsSupported { get; } + + // Sets the visual state without changing the value (None clears it). + void SetState(TaskbarProgressState state); + + // Sets the value and switches to Normal (Error/Paused are preserved). + void SetProgress(ulong completed, ulong total); // total must be > 0 + void SetProgress(double fraction); // 0.0–1.0, clamped + + // Windows only: target a specific top-level window (Zero reverts to the console window). + void SetWindow(IntPtr windowHandle); +} +``` + +--- + +## Jump lists + +A *jump list* is the menu of quick action shortcuts attached to an application's taskbar +button (Windows), launcher icon (Linux) or Dock icon (macOS). Notify.NET exposes this +through `IJumpListService`, which presents a single, uniform live-callback API across all +three platforms: when the user clicks a task, your already-running process receives an +`IJumpListHandler.OnTaskActivated(taskId)` call. + +| Platform | Backend | Activation model | +|----------|---------|------------------| +| Windows | Shell `ICustomDestinationList` "user tasks" (Windows 7+) | Relaunch + single-instance forwarding | +| Linux | freedesktop.org Desktop Actions in the app's `.desktop` file (GNOME, KDE, Unity, …) | Relaunch + single-instance forwarding | +| macOS | Dock menu via the application delegate (bundled GUI app only) | Live, in-process — no relaunch | + +On Windows and Linux a clicked task fundamentally relaunches the executable with a hidden +`--notify-jumplist ` argument. Notify.NET bundles a single-instance channel (a named +mutex plus a named pipe) that forwards the id to the running primary instance, so the +handler always fires live — uniform with macOS's natively-live Dock menu. + +Nothing is registered and no mutex, pipe or OS entry is created until you call `SetTasks` +or `SetHandler`, so applications that do not use jump lists incur zero overhead. + +### Creating the service + +```csharp +// Direct (no DI container) +using var jumpList = ServiceCollectionExtensions.CreateJumpListService(opts => +{ + opts.AppName = "My App"; + opts.AppUserModelId = "MyCompany.MyApp"; // Windows: must match the notification AUMI + opts.DesktopFileId = "com.example.MyApp"; // Linux: the app's .desktop file id +}); + +// With Microsoft.Extensions.DependencyInjection +services.AddJumpList(opts => +{ + opts.AppName = "My App"; + opts.AppUserModelId = "MyCompany.MyApp"; + opts.DesktopFileId = "com.example.MyApp"; +}); +``` + +`CreateJumpListService` / `AddJumpList` select the correct backend for the current OS. +On unsupported platforms they return a no-op service where `IsSupported` is `false`. + +### Wiring up activation + +On Windows and Linux, call `TryHandleActivation` at the very top of `Main`, before any UI +is shown. If this launch is a forwarded jump-list click, it returns `true` and the process +should exit immediately. Then attach a handler and register the tasks — the first call to +`SetTasks` / `SetHandler` makes this process the primary instance and starts the listener. + +```csharp +public static int Main(string[] args) +{ + using var jumpList = ServiceCollectionExtensions.CreateJumpListService(opts => + { + opts.AppName = "My App"; + opts.AppUserModelId = "MyCompany.MyApp"; + opts.DesktopFileId = "com.example.MyApp"; + }); + + // Forward a jump-list click to the already-running instance, then exit. + if (jumpList.TryHandleActivation(args)) + return 0; + + jumpList.SetHandler(new MyJumpListHandler()); + jumpList.SetTasks(new[] + { + new JumpListTask("new-doc", "New Document"), + new JumpListTask("open-last","Open Last File", description: "Reopen the most recent file"), + new JumpListTask("settings", "Settings", iconPath: @"C:\Apps\MyApp\settings.ico"), + }); + + RunApplication(); // your normal startup / message loop + return 0; +} + +public sealed class MyJumpListHandler : IJumpListHandler +{ + public void OnTaskActivated(string taskId) + { + // Fired on a background thread — marshal to your UI thread before touching UI. + switch (taskId) + { + case "new-doc": CreateDocument(); break; + case "open-last": OpenLastFile(); break; + case "settings": ShowSettings(); break; + } + } +} +``` + +If the app was launched cold by a jump-list click (no primary instance was running), the +activation is captured and replayed to the handler once one is set. + +### JumpListTask + +```csharp +new JumpListTask( + id: "open-last", // stable id passed back to OnTaskActivated (no whitespace) + title: "Open Last File", // label shown in the menu + description: "Reopen the most recent file", // tooltip (Windows); optional + iconPath: @"C:\Apps\MyApp\recent.ico", // optional; defaults to the host exe icon + iconIndex: 0); // icon index within iconPath (Windows) +``` + +### Managing tasks + +```csharp +jumpList.SetTasks(tasks); // replace the current task set (empty sequence == ClearTasks) +jumpList.ClearTasks(); // remove all tasks registered by this app +jumpList.SetHandler(null); // detach the handler +``` + +### Options + +| Option | Purpose | +|--------|---------| +| `AppName` | Human-readable name; used if a minimal Linux `.desktop` file must be created. | +| `AppUserModelId` | Windows — must match the AUMI used for notifications so the list attaches to the right taskbar button. | +| `DesktopFileId` | Linux — the app's `.desktop` file id (with or without the `.desktop` suffix). Defaults to the process name. | +| `ExecutablePath` | Windows/Linux — absolute path to relaunch on click. When null, the current process executable is used; pass an explicit path for framework-dependent `dotnet` apps where the auto-detected path may be the shared host. Ignored on macOS. | + +### IJumpListService interface + +```csharp +public interface IJumpListService : IDisposable +{ + // False if jump lists are unavailable on this platform; all methods become no-ops. + bool IsSupported { get; } + + // Registers the handler for OnTaskActivated events (also starts the listener). + void SetHandler(IJumpListHandler? handler); + + // Replaces the application's jump-list tasks (empty sequence clears them). + void SetTasks(IEnumerable tasks); + + // Removes all tasks registered by this application. + void ClearTasks(); + + // Call once at the start of Main. Returns true if the launch was a forwarded + // activation and the caller should exit immediately. + bool TryHandleActivation(string[] args); +} +``` + +--- + ## Platform notes ### Windows @@ -290,6 +552,14 @@ cleanup on macOS). published alongside the executable. - Toast callbacks are delivered on a WinRT thread-pool thread, not the STA thread. The library handles this internally. +- Jump lists use the shell `ICustomDestinationList` "user tasks" API (Windows 7+) — pure + managed COM interop, no native DLL required. The jump list attaches to the taskbar button + matching `AppUserModelId`, so it must be the same id used for notifications. The COM work + runs on a dedicated STA thread the library creates lazily on first use. +- Taskbar progress uses `ITaskbarList3` and needs a top-level window handle. It defaults to + the console window (`GetConsoleWindow()`); call `SetWindow` with your WPF/WinForms main + window HWND to move the bar onto that taskbar button. The COM work runs on its own lazily + created STA thread. ### Linux @@ -314,6 +584,18 @@ is present. Image support via `gdk-pixbuf` requires `libgdk-pixbuf-2.0` to be installed, which is typically included as a dependency of `libnotify4`. +Taskbar progress uses the Unity LauncherEntry D-Bus API, honoured by KDE Plasma, Unity, +Dash-to-Dock, Plank and Latte. It requires the app to ship (or have created) a `.desktop` +file whose id is supplied via `DesktopFileId`; the launcher matches the entry by that id. +Desktop environments without LauncherEntry support simply show no bar. + +Jump lists are written as `Actions` into the application's `.desktop` file. If no installed +`.desktop` file is found for `DesktopFileId`, a minimal one is created under +`$XDG_DATA_HOME/applications` (default `~/.local/share/applications`). Writing the file is +best-effort — a read-only or absent home directory will not crash the application. Each +action's `Exec` relaunches the executable with the activation argument, which the bundled +single-instance layer forwards to the running primary instance. + ### macOS - Requires macOS 10.14 (Mojave) or later. @@ -335,6 +617,18 @@ typically included as a dependency of `libnotify4`. `OnDismissed` callback is not fired after the user activates a notification or clicks a button (unlike Windows, where WinToastLib always fires the dismissed event after any interaction). +- Taskbar progress draws an `NSProgressIndicator` along the bottom of the **Dock tile**. + This is only visible for a regular bundled GUI application that owns a Dock tile and has a + running main loop; a bare console process has none, so the calls are harmless no-ops. The + Dock cannot tint the bar, so `Paused` and `Error` render the same as `Normal`. +- Jump-list tasks appear in the **Dock menu** (right-click / click-and-hold of the Dock + icon) and fire `OnTaskActivated` live in-process — there is no relaunch, so + `TryHandleActivation` always returns `false` on macOS. This is only effective for a + regular bundled GUI application with a running main loop; a bare console process has no + Dock menu and the calls are harmless no-ops. The wrapper supplies the menu via the + application delegate's `applicationDockMenu:`, installing its own delegate if the app has + none, or adding the method to the existing delegate without clobbering a Dock menu the app + already provides. ---