Skip to content

Commit 1e81bcc

Browse files
committed
Update README for docs
1 parent 8d412ed commit 1e81bcc

1 file changed

Lines changed: 294 additions & 0 deletions

File tree

README.md

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,268 @@ cleanup on macOS).
276276

277277
---
278278

279+
## Taskbar progress
280+
281+
`ITaskbarProgressService` drives the progress indicator on the application's taskbar button
282+
(Windows), launcher entry (Linux) or Dock tile (macOS) — the same green/red bar Windows
283+
Explorer shows during a file copy. Use it to surface the progress of a long-running
284+
operation without a custom UI.
285+
286+
| Platform | Backend | Requirement |
287+
|----------|---------|-------------|
288+
| Windows | `ITaskbarList3` | A top-level window handle (defaults to the console window) |
289+
| Linux | Unity LauncherEntry D-Bus API (KDE Plasma, Unity, Dash-to-Dock, Plank, Latte) | A `.desktop` file whose id is supplied via `DesktopFileId` |
290+
| macOS | `NSProgressIndicator` drawn on the Dock tile | A bundled GUI app that owns a Dock tile |
291+
292+
If the indicator is unavailable on the current platform, `IsSupported` is `false` and all
293+
methods are silent no-ops.
294+
295+
### Creating the service
296+
297+
```csharp
298+
// Direct (no DI container)
299+
using var progress = ServiceCollectionExtensions.CreateTaskbarProgressService(opts =>
300+
{
301+
opts.DesktopFileId = "com.example.MyApp"; // Linux: the app's .desktop file id
302+
});
303+
304+
// With Microsoft.Extensions.DependencyInjection
305+
services.AddTaskbarProgress(opts =>
306+
{
307+
opts.DesktopFileId = "com.example.MyApp";
308+
});
309+
```
310+
311+
### Reporting progress
312+
313+
```csharp
314+
if (!progress.IsSupported)
315+
return;
316+
317+
// Determinate progress, by fraction (0.0–1.0, clamped)…
318+
progress.SetProgress(0.25);
319+
320+
// …or by completed / total counts.
321+
for (ulong i = 0; i <= total; i++)
322+
{
323+
DoWork(i);
324+
progress.SetProgress(i, total); // total must be greater than zero
325+
}
326+
327+
// Clear the indicator when finished.
328+
progress.SetState(TaskbarProgressState.None);
329+
```
330+
331+
Calling either `SetProgress` overload switches the indicator to the `Normal` state, unless
332+
it is currently in the `Error` or `Paused` state (those are preserved so a paused/failed
333+
operation keeps its colour while its value updates).
334+
335+
### States
336+
337+
```csharp
338+
progress.SetState(TaskbarProgressState.Indeterminate); // work of unknown length
339+
progress.SetState(TaskbarProgressState.Paused); // operation paused
340+
progress.SetState(TaskbarProgressState.Error); // operation failed
341+
progress.SetState(TaskbarProgressState.None); // clear the indicator
342+
```
343+
344+
| State | Windows | Linux | macOS |
345+
|-------|---------|-------|-------|
346+
| `None` | No bar | No bar | No bar |
347+
| `Indeterminate` | Pulsing marquee bar | Falls back to a 0% bar | Animated bar |
348+
| `Normal` | Green bar | Bar at the current value | Bar at the current value |
349+
| `Paused` | Yellow bar | Same as `Normal` | Same as `Normal` |
350+
| `Error` | Red bar | Launcher entry flagged "urgent" | Same as `Normal` |
351+
352+
### Targeting a window (Windows)
353+
354+
By default the Windows backend targets the console window (`GetConsoleWindow()`). For a
355+
WPF/WinForms app, point it at your main window's HWND so the bar appears on the right
356+
taskbar button. This is a no-op on Linux and macOS.
357+
358+
```csharp
359+
// WPF
360+
var hwnd = new System.Windows.Interop.WindowInteropHelper(mainWindow).Handle;
361+
progress.SetWindow(hwnd);
362+
363+
// WinForms
364+
progress.SetWindow(form.Handle);
365+
366+
// Revert to the console window
367+
progress.SetWindow(IntPtr.Zero);
368+
```
369+
370+
### ITaskbarProgressService interface
371+
372+
```csharp
373+
public interface ITaskbarProgressService : IDisposable
374+
{
375+
// False if a progress indicator is unavailable on this platform.
376+
bool IsSupported { get; }
377+
378+
// Sets the visual state without changing the value (None clears it).
379+
void SetState(TaskbarProgressState state);
380+
381+
// Sets the value and switches to Normal (Error/Paused are preserved).
382+
void SetProgress(ulong completed, ulong total); // total must be > 0
383+
void SetProgress(double fraction); // 0.0–1.0, clamped
384+
385+
// Windows only: target a specific top-level window (Zero reverts to the console window).
386+
void SetWindow(IntPtr windowHandle);
387+
}
388+
```
389+
390+
---
391+
392+
## Jump lists
393+
394+
A *jump list* is the menu of quick action shortcuts attached to an application's taskbar
395+
button (Windows), launcher icon (Linux) or Dock icon (macOS). Notify.NET exposes this
396+
through `IJumpListService`, which presents a single, uniform live-callback API across all
397+
three platforms: when the user clicks a task, your already-running process receives an
398+
`IJumpListHandler.OnTaskActivated(taskId)` call.
399+
400+
| Platform | Backend | Activation model |
401+
|----------|---------|------------------|
402+
| Windows | Shell `ICustomDestinationList` "user tasks" (Windows 7+) | Relaunch + single-instance forwarding |
403+
| Linux | freedesktop.org Desktop Actions in the app's `.desktop` file (GNOME, KDE, Unity, …) | Relaunch + single-instance forwarding |
404+
| macOS | Dock menu via the application delegate (bundled GUI app only) | Live, in-process — no relaunch |
405+
406+
On Windows and Linux a clicked task fundamentally relaunches the executable with a hidden
407+
`--notify-jumplist <id>` argument. Notify.NET bundles a single-instance channel (a named
408+
mutex plus a named pipe) that forwards the id to the running primary instance, so the
409+
handler always fires live — uniform with macOS's natively-live Dock menu.
410+
411+
Nothing is registered and no mutex, pipe or OS entry is created until you call `SetTasks`
412+
or `SetHandler`, so applications that do not use jump lists incur zero overhead.
413+
414+
### Creating the service
415+
416+
```csharp
417+
// Direct (no DI container)
418+
using var jumpList = ServiceCollectionExtensions.CreateJumpListService(opts =>
419+
{
420+
opts.AppName = "My App";
421+
opts.AppUserModelId = "MyCompany.MyApp"; // Windows: must match the notification AUMI
422+
opts.DesktopFileId = "com.example.MyApp"; // Linux: the app's .desktop file id
423+
});
424+
425+
// With Microsoft.Extensions.DependencyInjection
426+
services.AddJumpList(opts =>
427+
{
428+
opts.AppName = "My App";
429+
opts.AppUserModelId = "MyCompany.MyApp";
430+
opts.DesktopFileId = "com.example.MyApp";
431+
});
432+
```
433+
434+
`CreateJumpListService` / `AddJumpList` select the correct backend for the current OS.
435+
On unsupported platforms they return a no-op service where `IsSupported` is `false`.
436+
437+
### Wiring up activation
438+
439+
On Windows and Linux, call `TryHandleActivation` at the very top of `Main`, before any UI
440+
is shown. If this launch is a forwarded jump-list click, it returns `true` and the process
441+
should exit immediately. Then attach a handler and register the tasks — the first call to
442+
`SetTasks` / `SetHandler` makes this process the primary instance and starts the listener.
443+
444+
```csharp
445+
public static int Main(string[] args)
446+
{
447+
using var jumpList = ServiceCollectionExtensions.CreateJumpListService(opts =>
448+
{
449+
opts.AppName = "My App";
450+
opts.AppUserModelId = "MyCompany.MyApp";
451+
opts.DesktopFileId = "com.example.MyApp";
452+
});
453+
454+
// Forward a jump-list click to the already-running instance, then exit.
455+
if (jumpList.TryHandleActivation(args))
456+
return 0;
457+
458+
jumpList.SetHandler(new MyJumpListHandler());
459+
jumpList.SetTasks(new[]
460+
{
461+
new JumpListTask("new-doc", "New Document"),
462+
new JumpListTask("open-last","Open Last File", description: "Reopen the most recent file"),
463+
new JumpListTask("settings", "Settings", iconPath: @"C:\Apps\MyApp\settings.ico"),
464+
});
465+
466+
RunApplication(); // your normal startup / message loop
467+
return 0;
468+
}
469+
470+
public sealed class MyJumpListHandler : IJumpListHandler
471+
{
472+
public void OnTaskActivated(string taskId)
473+
{
474+
// Fired on a background thread — marshal to your UI thread before touching UI.
475+
switch (taskId)
476+
{
477+
case "new-doc": CreateDocument(); break;
478+
case "open-last": OpenLastFile(); break;
479+
case "settings": ShowSettings(); break;
480+
}
481+
}
482+
}
483+
```
484+
485+
If the app was launched cold by a jump-list click (no primary instance was running), the
486+
activation is captured and replayed to the handler once one is set.
487+
488+
### JumpListTask
489+
490+
```csharp
491+
new JumpListTask(
492+
id: "open-last", // stable id passed back to OnTaskActivated (no whitespace)
493+
title: "Open Last File", // label shown in the menu
494+
description: "Reopen the most recent file", // tooltip (Windows); optional
495+
iconPath: @"C:\Apps\MyApp\recent.ico", // optional; defaults to the host exe icon
496+
iconIndex: 0); // icon index within iconPath (Windows)
497+
```
498+
499+
### Managing tasks
500+
501+
```csharp
502+
jumpList.SetTasks(tasks); // replace the current task set (empty sequence == ClearTasks)
503+
jumpList.ClearTasks(); // remove all tasks registered by this app
504+
jumpList.SetHandler(null); // detach the handler
505+
```
506+
507+
### Options
508+
509+
| Option | Purpose |
510+
|--------|---------|
511+
| `AppName` | Human-readable name; used if a minimal Linux `.desktop` file must be created. |
512+
| `AppUserModelId` | Windows — must match the AUMI used for notifications so the list attaches to the right taskbar button. |
513+
| `DesktopFileId` | Linux — the app's `.desktop` file id (with or without the `.desktop` suffix). Defaults to the process name. |
514+
| `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. |
515+
516+
### IJumpListService interface
517+
518+
```csharp
519+
public interface IJumpListService : IDisposable
520+
{
521+
// False if jump lists are unavailable on this platform; all methods become no-ops.
522+
bool IsSupported { get; }
523+
524+
// Registers the handler for OnTaskActivated events (also starts the listener).
525+
void SetHandler(IJumpListHandler? handler);
526+
527+
// Replaces the application's jump-list tasks (empty sequence clears them).
528+
void SetTasks(IEnumerable<JumpListTask> tasks);
529+
530+
// Removes all tasks registered by this application.
531+
void ClearTasks();
532+
533+
// Call once at the start of Main. Returns true if the launch was a forwarded
534+
// activation and the caller should exit immediately.
535+
bool TryHandleActivation(string[] args);
536+
}
537+
```
538+
539+
---
540+
279541
## Platform notes
280542

281543
### Windows
@@ -290,6 +552,14 @@ cleanup on macOS).
290552
published alongside the executable.
291553
- Toast callbacks are delivered on a WinRT thread-pool thread, not the STA thread. The
292554
library handles this internally.
555+
- Jump lists use the shell `ICustomDestinationList` "user tasks" API (Windows 7+) — pure
556+
managed COM interop, no native DLL required. The jump list attaches to the taskbar button
557+
matching `AppUserModelId`, so it must be the same id used for notifications. The COM work
558+
runs on a dedicated STA thread the library creates lazily on first use.
559+
- Taskbar progress uses `ITaskbarList3` and needs a top-level window handle. It defaults to
560+
the console window (`GetConsoleWindow()`); call `SetWindow` with your WPF/WinForms main
561+
window HWND to move the bar onto that taskbar button. The COM work runs on its own lazily
562+
created STA thread.
293563

294564
### Linux
295565

@@ -314,6 +584,18 @@ is present.
314584
Image support via `gdk-pixbuf` requires `libgdk-pixbuf-2.0` to be installed, which is
315585
typically included as a dependency of `libnotify4`.
316586

587+
Taskbar progress uses the Unity LauncherEntry D-Bus API, honoured by KDE Plasma, Unity,
588+
Dash-to-Dock, Plank and Latte. It requires the app to ship (or have created) a `.desktop`
589+
file whose id is supplied via `DesktopFileId`; the launcher matches the entry by that id.
590+
Desktop environments without LauncherEntry support simply show no bar.
591+
592+
Jump lists are written as `Actions` into the application's `.desktop` file. If no installed
593+
`.desktop` file is found for `DesktopFileId`, a minimal one is created under
594+
`$XDG_DATA_HOME/applications` (default `~/.local/share/applications`). Writing the file is
595+
best-effort — a read-only or absent home directory will not crash the application. Each
596+
action's `Exec` relaunches the executable with the activation argument, which the bundled
597+
single-instance layer forwards to the running primary instance.
598+
317599
### macOS
318600

319601
- Requires macOS 10.14 (Mojave) or later.
@@ -335,6 +617,18 @@ typically included as a dependency of `libnotify4`.
335617
`OnDismissed` callback is not fired after the user activates a notification or clicks
336618
a button (unlike Windows, where WinToastLib always fires the dismissed event after any
337619
interaction).
620+
- Taskbar progress draws an `NSProgressIndicator` along the bottom of the **Dock tile**.
621+
This is only visible for a regular bundled GUI application that owns a Dock tile and has a
622+
running main loop; a bare console process has none, so the calls are harmless no-ops. The
623+
Dock cannot tint the bar, so `Paused` and `Error` render the same as `Normal`.
624+
- Jump-list tasks appear in the **Dock menu** (right-click / click-and-hold of the Dock
625+
icon) and fire `OnTaskActivated` live in-process — there is no relaunch, so
626+
`TryHandleActivation` always returns `false` on macOS. This is only effective for a
627+
regular bundled GUI application with a running main loop; a bare console process has no
628+
Dock menu and the calls are harmless no-ops. The wrapper supplies the menu via the
629+
application delegate's `applicationDockMenu:`, installing its own delegate if the app has
630+
none, or adding the method to the existing delegate without clobbering a Dock menu the app
631+
already provides.
338632

339633
---
340634

0 commit comments

Comments
 (0)