mirror of
https://github.com/LANCommander/Notify.NET.git
synced 2026-08-01 03:08:24 -04:00
Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f196048f9 | ||
|
|
1e81bcc7d9 | ||
|
|
8d412ede18 | ||
|
|
c33f0df70a |
26 changed files with 3129 additions and 2 deletions
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -27,7 +27,9 @@ permissions:
|
||||||
jobs:
|
jobs:
|
||||||
build-windows:
|
build-windows:
|
||||||
name: Build WinToastWrapper (${{ matrix.rid }})
|
name: Build WinToastWrapper (${{ matrix.rid }})
|
||||||
runs-on: windows-latest
|
# Pinned to windows-2022 because the project uses the v143 (VS 2022) toolset.
|
||||||
|
# windows-latest moved to a newer Visual Studio that no longer ships v143.
|
||||||
|
runs-on: windows-2022
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|
|
||||||
294
README.md
294
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 <id>` 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<JumpListTask> 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
|
## Platform notes
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
@ -290,6 +552,14 @@ cleanup on macOS).
|
||||||
published alongside the executable.
|
published alongside the executable.
|
||||||
- Toast callbacks are delivered on a WinRT thread-pool thread, not the STA thread. The
|
- Toast callbacks are delivered on a WinRT thread-pool thread, not the STA thread. The
|
||||||
library handles this internally.
|
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
|
### Linux
|
||||||
|
|
||||||
|
|
@ -314,6 +584,18 @@ is present.
|
||||||
Image support via `gdk-pixbuf` requires `libgdk-pixbuf-2.0` to be installed, which is
|
Image support via `gdk-pixbuf` requires `libgdk-pixbuf-2.0` to be installed, which is
|
||||||
typically included as a dependency of `libnotify4`.
|
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
|
### macOS
|
||||||
|
|
||||||
- Requires macOS 10.14 (Mojave) or later.
|
- 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
|
`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
|
a button (unlike Windows, where WinToastLib always fires the dismissed event after any
|
||||||
interaction).
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,15 @@ typedef void (*MNW_FailedCallback) (int64_t notifId);
|
||||||
#define MNW_INTERRUPTION_TIME_SENSITIVE 2
|
#define MNW_INTERRUPTION_TIME_SENSITIVE 2
|
||||||
#define MNW_INTERRUPTION_CRITICAL 3
|
#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.
|
* Handler — bundle of four callback function pointers, copied by value.
|
||||||
* Any pointer may be NULL to opt out of that event.
|
* Any pointer may be NULL to opt out of that event.
|
||||||
|
|
@ -122,6 +131,56 @@ MACNOTIFYAPI int64_t MNW_ShowNotification(
|
||||||
*/
|
*/
|
||||||
MACNOTIFYAPI bool MNW_HideNotification(int64_t notifId);
|
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
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@
|
||||||
|
|
||||||
#define MACNOTIFYWRAPPER_EXPORTS
|
#define MACNOTIFYWRAPPER_EXPORTS
|
||||||
#import <Foundation/Foundation.h>
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <AppKit/AppKit.h>
|
||||||
#import <UserNotifications/UserNotifications.h>
|
#import <UserNotifications/UserNotifications.h>
|
||||||
|
#import <objc/runtime.h>
|
||||||
#include <stdatomic.h>
|
#include <stdatomic.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include "MacNotifyWrapper.h"
|
#include "MacNotifyWrapper.h"
|
||||||
|
|
@ -442,3 +444,219 @@ bool MNW_HideNotification(int64_t notifId)
|
||||||
|
|
||||||
return true;
|
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 <NSApplicationDelegate>
|
||||||
|
@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<NSString*>* idArr = [NSMutableArray arrayWithCapacity:(count > 0 ? count : 0)];
|
||||||
|
NSMutableArray<NSString*>* 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ CFLAGS := -fobjc-arc -fvisibility=hidden -O2 -Wall -Wextra \
|
||||||
-isysroot $(SDK)
|
-isysroot $(SDK)
|
||||||
LDFLAGS := -dynamiclib \
|
LDFLAGS := -dynamiclib \
|
||||||
-framework Foundation \
|
-framework Foundation \
|
||||||
|
-framework AppKit \
|
||||||
-framework UserNotifications \
|
-framework UserNotifications \
|
||||||
-install_name @rpath/libMacNotifyWrapper.dylib
|
-install_name @rpath/libMacNotifyWrapper.dylib
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,15 @@ services.AddNotifications(opts =>
|
||||||
opts.AppName = "Notify.NET Sample (DI)";
|
opts.AppName = "Notify.NET Sample (DI)";
|
||||||
opts.AppUserModelId = "NotifyNET.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();
|
await using var provider = services.BuildServiceProvider();
|
||||||
var diService = provider.GetRequiredService<INotificationService>();
|
var diService = provider.GetRequiredService<INotificationService>();
|
||||||
|
var diTaskbar = provider.GetRequiredService<ITaskbarProgressService>();
|
||||||
|
|
||||||
long id6 = await diService.ShowAsync(
|
long id6 = await diService.ShowAsync(
|
||||||
NotificationBuilder.Create("DI-registered Service")
|
NotificationBuilder.Create("DI-registered Service")
|
||||||
|
|
@ -131,6 +137,65 @@ long id6 = await diService.ShowAsync(
|
||||||
Console.WriteLine($" Shown with id={id6}");
|
Console.WriteLine($" Shown with id={id6}");
|
||||||
await Task.Delay(3000);
|
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.");
|
Console.WriteLine("\nAll done.");
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
23
src/Notify.NET/Abstractions/IJumpListHandler.cs
Normal file
23
src/Notify.NET/Abstractions/IJumpListHandler.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
namespace Notify.NET.Abstractions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Receives activation events when the user clicks an entry in the application's jump list,
|
||||||
|
/// launcher shortcut menu or Dock menu.
|
||||||
|
/// </summary>
|
||||||
|
public interface IJumpListHandler
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="taskId">The <see cref="JumpListTask.Id"/> of the task that was clicked.</param>
|
||||||
|
void OnTaskActivated(string taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/Notify.NET/Abstractions/IJumpListService.cs
Normal file
100
src/Notify.NET/Abstractions/IJumpListService.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Notify.NET.Abstractions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Manages the application's jump list (Windows), launcher shortcut menu (Linux
|
||||||
|
/// <c>.desktop</c> Actions) or Dock menu (macOS), with a bundled live-callback layer so a
|
||||||
|
/// clicked task is delivered to the already-running instance via
|
||||||
|
/// <see cref="IJumpListHandler.OnTaskActivated"/>.
|
||||||
|
///
|
||||||
|
/// <para><b>Activation model.</b> Jump-list and <c>.desktop</c> 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:</para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description>
|
||||||
|
/// A clicked task on Windows/Linux relaunches the app with a hidden activation argument.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// Call <see cref="TryHandleActivation"/> at the very start of <c>Main</c>. 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 <c>true</c> — the caller should exit
|
||||||
|
/// immediately without showing any UI.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// Otherwise the method returns <c>false</c> and the app continues normal startup. The first
|
||||||
|
/// call to <see cref="SetTasks"/> 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.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>Nothing is registered and no listener, mutex or pipe is created until
|
||||||
|
/// <see cref="SetTasks"/> (or <see cref="SetHandler"/>) is first called, so applications that do
|
||||||
|
/// not use jump lists incur no overhead.</para>
|
||||||
|
///
|
||||||
|
/// <para>Capability notes:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// <b>Windows</b> — uses the shell <c>ICustomDestinationList</c> "user tasks" (Windows 7+).
|
||||||
|
/// Requires the same AppUserModelId used for notifications so the list attaches to the
|
||||||
|
/// correct taskbar button.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <b>Linux</b> — writes <c>Actions</c> into the application's <c>.desktop</c> file (honoured
|
||||||
|
/// by GNOME, KDE, Unity and others). Requires <c>NotificationOptions.DesktopFileId</c>; if no
|
||||||
|
/// installed <c>.desktop</c> file is found, a minimal one is created under
|
||||||
|
/// <c>~/.local/share/applications</c>.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <b>macOS</b> — 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.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// When jump lists are not available on the current platform, <see cref="IsSupported"/> is
|
||||||
|
/// <c>false</c> and all methods are silent no-ops (<see cref="TryHandleActivation"/> returns
|
||||||
|
/// <c>false</c>).
|
||||||
|
/// </summary>
|
||||||
|
public interface IJumpListService : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether jump lists / launcher actions / Dock-menu items are available on this platform.
|
||||||
|
/// When <c>false</c>, all other methods are silent no-ops.
|
||||||
|
/// </summary>
|
||||||
|
bool IsSupported { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the handler that receives <see cref="IJumpListHandler.OnTaskActivated"/> 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 <c>null</c>
|
||||||
|
/// to detach the current handler.
|
||||||
|
/// </summary>
|
||||||
|
void SetHandler(IJumpListHandler? handler);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="ClearTasks"/>.
|
||||||
|
/// </summary>
|
||||||
|
void SetTasks(IEnumerable<JumpListTask> tasks);
|
||||||
|
|
||||||
|
/// <summary>Removes all jump-list tasks registered by this application.</summary>
|
||||||
|
void ClearTasks();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inspects the process command-line arguments for a jump-list activation. Call this once, as
|
||||||
|
/// early as possible in <c>Main</c>, before any UI is shown.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="args">The arguments passed to <c>Main</c>.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// <c>true</c> 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
|
||||||
|
/// <c>false</c> (continue normal startup — the activation, if any, will be replayed to the
|
||||||
|
/// handler once this instance becomes primary).
|
||||||
|
/// </returns>
|
||||||
|
bool TryHandleActivation(string[] args);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/Notify.NET/Abstractions/ITaskbarProgressService.cs
Normal file
99
src/Notify.NET/Abstractions/ITaskbarProgressService.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Notify.NET.Abstractions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The visual state of a taskbar/dock/launcher progress indicator.
|
||||||
|
/// </summary>
|
||||||
|
public enum TaskbarProgressState
|
||||||
|
{
|
||||||
|
/// <summary>No progress bar is shown (the indicator is cleared).</summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Indeterminate = 1,
|
||||||
|
|
||||||
|
/// <summary>A normal (green, on Windows) progress bar reflecting the current value.</summary>
|
||||||
|
Normal = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A paused (yellow, on Windows) progress bar. Other platforms render this the same as
|
||||||
|
/// <see cref="Normal"/> because they cannot tint the bar.
|
||||||
|
/// </summary>
|
||||||
|
Paused = 3,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An error (red, on Windows) progress bar. On Linux the launcher entry is flagged
|
||||||
|
/// "urgent"; macOS renders this the same as <see cref="Normal"/>.
|
||||||
|
/// </summary>
|
||||||
|
Error = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controls the progress indicator on the application's taskbar button (Windows),
|
||||||
|
/// launcher entry (Linux) or Dock tile (macOS).
|
||||||
|
///
|
||||||
|
/// Capability notes:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// <b>Windows</b> — uses <c>ITaskbarList3</c>. Requires a top-level window handle (HWND).
|
||||||
|
/// Defaults to the console window (<c>GetConsoleWindow()</c>); call <see cref="SetWindow"/>
|
||||||
|
/// to target a WPF/WinForms main window instead.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <b>Linux</b> — uses the Unity LauncherEntry D-Bus API, honoured by KDE Plasma, Unity,
|
||||||
|
/// Dash-to-Dock, Plank and Latte. Requires the app to ship a <c>.desktop</c> file whose id
|
||||||
|
/// is supplied via <c>NotificationOptions.DesktopFileId</c>.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <b>macOS</b> — draws an <c>NSProgressIndicator</c> on the Dock tile. Only visible for a
|
||||||
|
/// regular (GUI/bundled) application that owns a Dock tile; a bare console process has none.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// If the indicator is not available on the current platform, <see cref="IsSupported"/> is
|
||||||
|
/// <c>false</c> and the methods are no-ops.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITaskbarProgressService : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a taskbar/launcher/dock progress indicator is available on this platform.
|
||||||
|
/// When <c>false</c>, all other methods are silent no-ops.
|
||||||
|
/// </summary>
|
||||||
|
bool IsSupported { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the visual state of the progress indicator without changing its value.
|
||||||
|
/// Use <see cref="TaskbarProgressState.None"/> to clear it.
|
||||||
|
/// </summary>
|
||||||
|
void SetState(TaskbarProgressState state);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the progress value and switches the indicator to
|
||||||
|
/// <see cref="TaskbarProgressState.Normal"/> (unless it is currently in an
|
||||||
|
/// <see cref="TaskbarProgressState.Error"/> or <see cref="TaskbarProgressState.Paused"/>
|
||||||
|
/// state, which are preserved).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="completed">The amount of work completed.</param>
|
||||||
|
/// <param name="total">The total amount of work. Must be greater than zero.</param>
|
||||||
|
void SetProgress(ulong completed, ulong total);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the progress as a fraction in the range 0.0–1.0, switching the indicator to
|
||||||
|
/// <see cref="TaskbarProgressState.Normal"/> (subject to the same state-preservation rule
|
||||||
|
/// as <see cref="SetProgress(ulong,ulong)"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fraction">A value between 0.0 and 1.0 (clamped).</param>
|
||||||
|
void SetProgress(double fraction);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows only: targets a specific top-level window (e.g. a WPF/WinForms main window).
|
||||||
|
/// On other platforms this is a no-op. Passing <see cref="IntPtr.Zero"/> reverts to the
|
||||||
|
/// console window.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="windowHandle">The HWND of the window whose taskbar button to control.</param>
|
||||||
|
void SetWindow(IntPtr windowHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/Notify.NET/Abstractions/JumpListTask.cs
Normal file
81
src/Notify.NET/Abstractions/JumpListTask.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Notify.NET.Abstractions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A single entry in an application's jump list (Windows), launcher shortcut menu
|
||||||
|
/// (Linux <c>.desktop</c> 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 <see cref="Id"/> back to <see cref="IJumpListHandler.OnTaskActivated"/> in the
|
||||||
|
/// already-running instance (see <see cref="IJumpListService"/> for the model).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class JumpListTask
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A stable, machine-readable identifier for this task (e.g. <c>"open-library"</c>).
|
||||||
|
/// It is passed back to <see cref="IJumpListHandler.OnTaskActivated"/> 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,
|
||||||
|
/// <c>-</c> and <c>_</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
/// <summary>The human-readable label shown in the menu (e.g. <c>"Open Library"</c>).</summary>
|
||||||
|
public string Title { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional tooltip/description. Shown on Windows jump-list tasks on hover.
|
||||||
|
/// Ignored on Linux and macOS.
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional path to an icon. On Windows this is a path to an <c>.ico</c>, <c>.exe</c> or
|
||||||
|
/// <c>.dll</c> file whose icon at <see cref="IconIndex"/> is shown next to the task.
|
||||||
|
/// On Linux it is an icon name (per the freedesktop icon theme) or absolute path written
|
||||||
|
/// into the <c>.desktop</c> Action. Ignored on macOS (Dock menus do not show item icons).
|
||||||
|
/// </summary>
|
||||||
|
public string? IconPath { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The zero-based index of the icon to use within <see cref="IconPath"/> when it refers to
|
||||||
|
/// a multi-icon file (e.g. an <c>.exe</c>/<c>.dll</c>). Windows only; defaults to 0.
|
||||||
|
/// </summary>
|
||||||
|
public int IconIndex { get; }
|
||||||
|
|
||||||
|
/// <param name="id">A stable, whitespace-free identifier passed to the handler when invoked.</param>
|
||||||
|
/// <param name="title">The label shown in the menu.</param>
|
||||||
|
/// <param name="description">Optional Windows-only tooltip.</param>
|
||||||
|
/// <param name="iconPath">Optional icon file (Windows) or icon name/path (Linux).</param>
|
||||||
|
/// <param name="iconIndex">Icon index within <paramref name="iconPath"/> (Windows only).</param>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,113 @@ namespace Notify.NET.Extensions
|
||||||
|
|
||||||
return new NullNotificationService();
|
return new NullNotificationService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers <see cref="ITaskbarProgressService"/> as a singleton, using the
|
||||||
|
/// platform-appropriate backend:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>Windows → <see cref="WindowsTaskbarProgressService"/> (ITaskbarList3)</description></item>
|
||||||
|
/// <item><description>Linux → <see cref="LinuxTaskbarProgressService"/> (Unity LauncherEntry D-Bus)</description></item>
|
||||||
|
/// <item><description>macOS → <see cref="MacOSTaskbarProgressService"/> (Dock tile)</description></item>
|
||||||
|
/// <item><description>Other → <see cref="NullTaskbarProgressService"/> (<see cref="ITaskbarProgressService.IsSupported"/> = false)</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection to add to.</param>
|
||||||
|
/// <param name="configure">Optional delegate to configure <see cref="NotificationOptions"/>.</param>
|
||||||
|
public static IServiceCollection AddTaskbarProgress(
|
||||||
|
this IServiceCollection services,
|
||||||
|
Action<NotificationOptions>? configure = null)
|
||||||
|
{
|
||||||
|
var options = new NotificationOptions();
|
||||||
|
configure?.Invoke(options);
|
||||||
|
|
||||||
|
services.AddSingleton<ITaskbarProgressService>(_ => CreateTaskbarService(options));
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the platform-appropriate <see cref="ITaskbarProgressService"/> directly
|
||||||
|
/// (without a DI container), for use in simple console applications.
|
||||||
|
/// </summary>
|
||||||
|
public static ITaskbarProgressService CreateTaskbarProgressService(
|
||||||
|
Action<NotificationOptions>? 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers <see cref="IJumpListService"/> as a singleton, using the
|
||||||
|
/// platform-appropriate backend:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>Windows → <see cref="WindowsJumpListService"/> (ICustomDestinationList user tasks)</description></item>
|
||||||
|
/// <item><description>Linux → <see cref="LinuxJumpListService"/> (freedesktop.org Desktop Actions)</description></item>
|
||||||
|
/// <item><description>macOS → <see cref="MacOSJumpListService"/> (Dock menu)</description></item>
|
||||||
|
/// <item><description>Other → <see cref="NullJumpListService"/> (<see cref="IJumpListService.IsSupported"/> = false)</description></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// On Windows and Linux a clicked task relaunches the executable with
|
||||||
|
/// <c>--notify-jumplist <id></c>; 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection to add to.</param>
|
||||||
|
/// <param name="configure">Optional delegate to configure <see cref="NotificationOptions"/>.</param>
|
||||||
|
public static IServiceCollection AddJumpList(
|
||||||
|
this IServiceCollection services,
|
||||||
|
Action<NotificationOptions>? configure = null)
|
||||||
|
{
|
||||||
|
var options = new NotificationOptions();
|
||||||
|
configure?.Invoke(options);
|
||||||
|
|
||||||
|
services.AddSingleton<IJumpListService>(_ => CreateJumpListServiceCore(options));
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the platform-appropriate <see cref="IJumpListService"/> directly
|
||||||
|
/// (without a DI container), for use in simple console applications.
|
||||||
|
/// </summary>
|
||||||
|
public static IJumpListService CreateJumpListService(
|
||||||
|
Action<NotificationOptions>? 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -103,6 +210,22 @@ namespace Notify.NET.Extensions
|
||||||
/// Windows only — ignored on Linux and macOS.
|
/// Windows only — ignored on Linux and macOS.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? AppIconPath { get; set; }
|
public string? AppIconPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The application's <c>.desktop</c> file id (with or without the ".desktop" suffix), e.g.
|
||||||
|
/// <c>"com.example.MyApp"</c>. Used by the Linux taskbar-progress backend to address the
|
||||||
|
/// correct launcher entry via the <c>application://<id>.desktop</c> URI. When null,
|
||||||
|
/// the process name is used. Ignored on Windows and macOS.
|
||||||
|
/// </summary>
|
||||||
|
public string? DesktopFileId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>dotnet</c> 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).
|
||||||
|
/// </summary>
|
||||||
|
public string? ExecutablePath { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -122,4 +245,32 @@ namespace Notify.NET.Extensions
|
||||||
|
|
||||||
public void Dispose() { }
|
public void Dispose() { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No-op implementation used when the current platform has no supported taskbar-progress
|
||||||
|
/// backend. <see cref="IsSupported"/> is always false and every method is a silent no-op.
|
||||||
|
/// </summary>
|
||||||
|
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() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No-op implementation used when the current platform has no supported jump-list backend.
|
||||||
|
/// <see cref="IsSupported"/> is always false and every method is a silent no-op.
|
||||||
|
/// </summary>
|
||||||
|
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<JumpListTask> tasks) { }
|
||||||
|
public void ClearTasks() { }
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
72
src/Notify.NET/Platform/JumpListActivation.cs
Normal file
72
src/Notify.NET/Platform/JumpListActivation.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="ActivationFlag"/> followed by the task id, e.g.
|
||||||
|
/// <c>myapp --notify-jumplist open-library</c>. The bundled single-instance layer parses this,
|
||||||
|
/// forwards the id to the primary instance and exits.
|
||||||
|
/// </summary>
|
||||||
|
internal static class JumpListActivation
|
||||||
|
{
|
||||||
|
/// <summary>The command-line flag that precedes a jump-list task id on relaunch.</summary>
|
||||||
|
internal const string ActivationFlag = "--notify-jumplist";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the task id from a jump-list activation command line, or <c>null</c> if these
|
||||||
|
/// arguments are not a jump-list activation.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort absolute path to the current process executable, used as the relaunch target.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/Notify.NET/Platform/JumpListActivationRouter.cs
Normal file
113
src/Notify.NET/Platform/JumpListActivationRouter.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using Notify.NET.Abstractions;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="EnsureListening"/> or a
|
||||||
|
/// non-null <see cref="SetHandler"/> is first called.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a possible jump-list activation command line. Returns <c>true</c> if the activation
|
||||||
|
/// was forwarded to an already-running primary instance (caller should exit); otherwise
|
||||||
|
/// <c>false</c> (the activation, if any, is captured for cold-start replay).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets (or clears) the handler and starts listening when a handler is attached.</summary>
|
||||||
|
internal void SetHandler(IJumpListHandler? handler)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
if (handler != null) EnsureListening_NoLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Becomes the primary instance (if elected) and begins listening for forwarded activations.
|
||||||
|
/// Called by the service the first time tasks are registered.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
224
src/Notify.NET/Platform/Linux/DesktopFileWriter.cs
Normal file
224
src/Notify.NET/Platform/Linux/DesktopFileWriter.cs
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Notify.NET.Abstractions;
|
||||||
|
using Notify.NET.Platform;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform.Linux
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Writes freedesktop.org "Desktop Actions" (launcher shortcut entries) into an application's
|
||||||
|
/// <c>.desktop</c> file. Desktop Actions appear in the right-click menu of the launcher/taskbar
|
||||||
|
/// icon on GNOME, KDE, Unity and other environments; each action's <c>Exec</c> relaunches the
|
||||||
|
/// executable with a jump-list activation argument.
|
||||||
|
///
|
||||||
|
/// The file's <c>Actions</c> key and all <c>[Desktop Action *]</c> groups are owned and managed
|
||||||
|
/// by this writer — existing ones are replaced on each write. If no installed <c>.desktop</c>
|
||||||
|
/// file exists for the application, a minimal one is created under
|
||||||
|
/// <c>$XDG_DATA_HOME/applications</c> (default <c>~/.local/share/applications</c>).
|
||||||
|
/// </summary>
|
||||||
|
internal static class DesktopFileWriter
|
||||||
|
{
|
||||||
|
/// <summary>Registers the supplied tasks as Desktop Actions, creating/merging the file.</summary>
|
||||||
|
internal static void WriteActions(
|
||||||
|
string desktopFileId,
|
||||||
|
string appName,
|
||||||
|
string executablePath,
|
||||||
|
IReadOnlyList<JumpListTask> tasks)
|
||||||
|
{
|
||||||
|
string path = ResolveUserDesktopPath(desktopFileId);
|
||||||
|
string? source = FindExistingDesktopPath(desktopFileId) ?? (File.Exists(path) ? path : null);
|
||||||
|
|
||||||
|
List<Section> sections = source != null
|
||||||
|
? ParseSections(File.ReadAllLines(source))
|
||||||
|
: CreateMinimal(appName, executablePath);
|
||||||
|
|
||||||
|
ApplyActions(sections, executablePath, tasks);
|
||||||
|
WriteFile(path, sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes the <c>Actions</c> key and all action groups managed by this writer.</summary>
|
||||||
|
internal static void RemoveActions(string desktopFileId)
|
||||||
|
{
|
||||||
|
string path = ResolveUserDesktopPath(desktopFileId);
|
||||||
|
if (!File.Exists(path)) return;
|
||||||
|
|
||||||
|
List<Section> sections = ParseSections(File.ReadAllLines(path));
|
||||||
|
ApplyActions(sections, executablePath: null, tasks: Array.Empty<JumpListTask>());
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The user-writable path we always write to.</summary>
|
||||||
|
internal static string ResolveUserDesktopPath(string desktopFileId)
|
||||||
|
{
|
||||||
|
string id = StripSuffix(desktopFileId);
|
||||||
|
return Path.Combine(DataHome(), "applications", id + ".desktop");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks for an existing installed <c>.desktop</c> file (user dir first, then the system
|
||||||
|
/// <c>XDG_DATA_DIRS</c>) to use as the merge source. Returns null if none exists.
|
||||||
|
/// </summary>
|
||||||
|
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<string> Lines = new List<string>(); // 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<Section> ParseSections(string[] lines)
|
||||||
|
{
|
||||||
|
var sections = new List<Section>();
|
||||||
|
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<Section> 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<Section> { entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Action application
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static void ApplyActions(
|
||||||
|
List<Section> sections, string? executablePath, IReadOnlyList<JumpListTask> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Quotes an executable path for a Desktop Entry <c>Exec</c> value if needed.</summary>
|
||||||
|
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<Section> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/Notify.NET/Platform/Linux/GioDBusNative.cs
Normal file
104
src/Notify.NET/Platform/Linux/GioDBusNative.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform.Linux
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// P/Invoke declarations for GIO/GLib functions used to emit the Unity LauncherEntry
|
||||||
|
/// <c>Update</c> D-Bus signal that drives launcher/taskbar progress on Linux.
|
||||||
|
///
|
||||||
|
/// The GVariant payload is built entirely from the non-variadic constructor functions
|
||||||
|
/// (<c>g_variant_new_string</c>, <c>_double</c>, <c>_boolean</c>, <c>_variant</c>,
|
||||||
|
/// <c>_dict_entry</c>, <c>_array</c>, <c>_tuple</c>) so that no <c>g_variant_new</c>
|
||||||
|
/// varargs call — which cannot be marshalled via P/Invoke — is required. Each helper
|
||||||
|
/// consumes the floating reference of its children, and
|
||||||
|
/// <c>g_dbus_connection_emit_signal</c> sinks the final floating tuple, so no manual
|
||||||
|
/// unref of the GVariants is needed.
|
||||||
|
/// </summary>
|
||||||
|
internal static class GioDBusNative
|
||||||
|
{
|
||||||
|
private const string LibGio = "libgio-2.0.so.0";
|
||||||
|
private const string LibGLib = "libglib-2.0.so.0";
|
||||||
|
|
||||||
|
/// <summary>GBusType.G_BUS_TYPE_SESSION.</summary>
|
||||||
|
internal const int G_BUS_TYPE_SESSION = 2;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GIO — session bus + signal emission
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synchronously connects to a message bus. Returns a GDBusConnection* (a GObject
|
||||||
|
/// reference owned by the caller) or IntPtr.Zero on failure.
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(LibGio, EntryPoint = "g_bus_get_sync")]
|
||||||
|
internal static extern IntPtr g_bus_get_sync(int busType, IntPtr cancellable, ref IntPtr error);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits a D-Bus signal on the given connection. <paramref name="parameters"/> must be a
|
||||||
|
/// tuple GVariant (its floating reference is sunk by this call).
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
|
||||||
|
/// <summary>Synchronously flushes queued outgoing messages on the connection.</summary>
|
||||||
|
[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);
|
||||||
|
|
||||||
|
/// <summary>Creates a boolean GVariant. <paramref name="value"/> is a gboolean (0/1).</summary>
|
||||||
|
[DllImport(LibGLib, EntryPoint = "g_variant_new_boolean")]
|
||||||
|
internal static extern IntPtr g_variant_new_boolean(int value);
|
||||||
|
|
||||||
|
/// <summary>Boxes a GVariant inside a variant (the "v" type), consuming the child's float ref.</summary>
|
||||||
|
[DllImport(LibGLib, EntryPoint = "g_variant_new_variant")]
|
||||||
|
internal static extern IntPtr g_variant_new_variant(IntPtr value);
|
||||||
|
|
||||||
|
/// <summary>Creates a "{sv}" dictionary entry, consuming both children's float refs.</summary>
|
||||||
|
[DllImport(LibGLib, EntryPoint = "g_variant_new_dict_entry")]
|
||||||
|
internal static extern IntPtr g_variant_new_dict_entry(IntPtr key, IntPtr value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an array GVariant. With <paramref name="childType"/> = Zero the element type is
|
||||||
|
/// inferred from the (non-empty) children, each of whose floating refs is consumed.
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(LibGLib, EntryPoint = "g_variant_new_array")]
|
||||||
|
internal static extern IntPtr g_variant_new_array(IntPtr childType, IntPtr[] children, UIntPtr nChildren);
|
||||||
|
|
||||||
|
/// <summary>Creates a tuple GVariant, consuming each child's floating reference.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/Notify.NET/Platform/Linux/LinuxJumpListService.cs
Normal file
99
src/Notify.NET/Platform/Linux/LinuxJumpListService.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Notify.NET.Abstractions;
|
||||||
|
using Notify.NET.Platform;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform.Linux
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IJumpListService"/> implementation that registers launcher shortcut tasks as
|
||||||
|
/// freedesktop.org Desktop Actions in the application's <c>.desktop</c> file (see
|
||||||
|
/// <see cref="DesktopFileWriter"/>), honoured by GNOME, KDE, Unity and others.
|
||||||
|
///
|
||||||
|
/// Clicking an action relaunches the executable with <c>--notify-jumplist <id></c>; the bundled
|
||||||
|
/// <see cref="JumpListActivationRouter"/> forwards the id to the running primary instance so
|
||||||
|
/// <see cref="IJumpListHandler.OnTaskActivated"/> fires live (or replays it on a cold start).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsSupported => true;
|
||||||
|
|
||||||
|
/// <param name="appName">Human-readable application name, used if a new .desktop file is created.</param>
|
||||||
|
/// <param name="desktopFileId">
|
||||||
|
/// The application's <c>.desktop</c> file id (with or without the ".desktop" suffix). Identifies
|
||||||
|
/// which launcher entry the actions are written into and keys the single-instance channel.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="executablePath">
|
||||||
|
/// Absolute command used to relaunch the app for an action's <c>Exec</c>. 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).
|
||||||
|
/// </param>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool TryHandleActivation(string[] args)
|
||||||
|
{
|
||||||
|
if (_disposed) return false;
|
||||||
|
return _router.TryHandleActivation(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SetHandler(IJumpListHandler? handler)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_router.SetHandler(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SetTasks(IEnumerable<JumpListTask> tasks)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
if (tasks == null) throw new ArgumentNullException(nameof(tasks));
|
||||||
|
|
||||||
|
var list = new List<JumpListTask>(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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void ClearTasks()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
try { DesktopFileWriter.RemoveActions(_desktopFileId); }
|
||||||
|
catch (Exception) { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_router.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/Notify.NET/Platform/Linux/LinuxTaskbarProgressService.cs
Normal file
185
src/Notify.NET/Platform/Linux/LinuxTaskbarProgressService.cs
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
using System;
|
||||||
|
using Notify.NET.Abstractions;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform.Linux
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="ITaskbarProgressService"/> implementation that drives launcher/taskbar progress
|
||||||
|
/// via the Unity LauncherEntry D-Bus API (<c>com.canonical.Unity.LauncherEntry</c>). 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 <c>application://<id>.desktop</c>
|
||||||
|
/// URI plus a property dictionary (<c>progress</c>, <c>progress-visible</c>, <c>urgent</c>). The
|
||||||
|
/// app must therefore ship a matching <c>.desktop</c> file for any dock to display the bar.
|
||||||
|
///
|
||||||
|
/// LauncherEntry has no indeterminate mode and cannot tint the bar, so
|
||||||
|
/// <see cref="TaskbarProgressState.Indeterminate"/> renders as an empty (0%) bar and only
|
||||||
|
/// <see cref="TaskbarProgressState.Error"/> is distinguished (via the "urgent" flag).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsSupported { get; private set; }
|
||||||
|
|
||||||
|
/// <param name="desktopFileId">
|
||||||
|
/// The application's .desktop file id (with or without the ".desktop" suffix), e.g.
|
||||||
|
/// <c>"myapp"</c> or <c>"com.example.MyApp.desktop"</c>. Used to build the
|
||||||
|
/// <c>application://<id>.desktop</c> URI the launcher matches against.
|
||||||
|
/// </param>
|
||||||
|
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
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SetWindow(IntPtr windowHandle) { /* Windows-only concept; no-op on Linux. */ }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SetState(TaskbarProgressState state)
|
||||||
|
{
|
||||||
|
if (_disposed || !IsSupported) return;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_state = state;
|
||||||
|
if (state == TaskbarProgressState.None) _progress = 0;
|
||||||
|
Emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Builds and broadcasts the LauncherEntry "Update" signal for the current state.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a "{sv}" dict entry, boxing <paramref name="value"/> in a variant.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/Notify.NET/Platform/MacOS/MacJumpListNative.cs
Normal file
45
src/Notify.NET/Platform/MacOS/MacJumpListNative.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform.MacOS
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// P/Invoke declarations for the Dock-menu ("jump list") entry points exported by
|
||||||
|
/// <c>libMacNotifyWrapper.dylib</c> (see <c>MacNotifyWrapper.h</c>).
|
||||||
|
///
|
||||||
|
/// Unlike the Windows/Linux jump lists, the macOS Dock menu fires a live in-process
|
||||||
|
/// callback (<see cref="DockMenuCallback"/>) — 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 <see cref="UnmanagedType.LPStr"/>
|
||||||
|
/// marshalling is a faithful round-trip. Every function uses the C calling convention (cdecl).
|
||||||
|
/// </summary>
|
||||||
|
internal static class MacJumpListNative
|
||||||
|
{
|
||||||
|
internal const string LibName = "MacNotifyWrapper";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired on the main thread when the user clicks a Dock-menu item; <paramref name="taskId"/>
|
||||||
|
/// is the id supplied to <see cref="MNW_SetDockMenu"/> for that item.
|
||||||
|
/// </summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,5 +87,18 @@ namespace Notify.NET.Platform.MacOS
|
||||||
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
|
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
|
||||||
[return: MarshalAs(UnmanagedType.I1)]
|
[return: MarshalAs(UnmanagedType.I1)]
|
||||||
internal static extern bool MNW_HideNotification(long notifId);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
142
src/Notify.NET/Platform/MacOS/MacOSJumpListService.cs
Normal file
142
src/Notify.NET/Platform/MacOS/MacOSJumpListService.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Notify.NET.Abstractions;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform.MacOS
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IJumpListService"/> implementation backed by the application's macOS Dock menu
|
||||||
|
/// (shown on right-click / click-and-hold of the Dock icon), provided through the native
|
||||||
|
/// <c>libMacNotifyWrapper.dylib</c> (<c>MNW_SetDockMenu</c> and friends).
|
||||||
|
///
|
||||||
|
/// Unlike the Windows and Linux services there is no relaunch and no single-instance
|
||||||
|
/// forwarding: clicking a Dock-menu item fires <see cref="IJumpListHandler.OnTaskActivated"/>
|
||||||
|
/// live in the running process. Consequently <see cref="TryHandleActivation"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsSupported { get; }
|
||||||
|
|
||||||
|
static MacOSJumpListService()
|
||||||
|
{
|
||||||
|
_staticCallback = OnDockItemActivated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MacOSJumpListService()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MacOSNativeLibraryLoader.EnsureLoaded();
|
||||||
|
IsSupported = MacJumpListNative.MNW_IsSupported();
|
||||||
|
}
|
||||||
|
catch (DllNotFoundException)
|
||||||
|
{
|
||||||
|
IsSupported = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// IJumpListService
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SetTasks(IEnumerable<JumpListTask> tasks)
|
||||||
|
{
|
||||||
|
if (_disposed || !IsSupported) return;
|
||||||
|
if (tasks == null) throw new ArgumentNullException(nameof(tasks));
|
||||||
|
|
||||||
|
var list = new List<JumpListTask>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void ClearTasks()
|
||||||
|
{
|
||||||
|
if (_disposed || !IsSupported) return;
|
||||||
|
MacJumpListNative.MNW_ClearDockMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// IDisposable
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/Notify.NET/Platform/MacOS/MacOSTaskbarProgressService.cs
Normal file
111
src/Notify.NET/Platform/MacOS/MacOSTaskbarProgressService.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
using System;
|
||||||
|
using Notify.NET.Abstractions;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform.MacOS
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="ITaskbarProgressService"/> implementation that draws an
|
||||||
|
/// <c>NSProgressIndicator</c> on the application's Dock tile via the native
|
||||||
|
/// <c>libMacNotifyWrapper.dylib</c> (<c>MNW_SetTaskbarProgress</c>).
|
||||||
|
///
|
||||||
|
/// 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 <see cref="TaskbarProgressState.Paused"/> and
|
||||||
|
/// <see cref="TaskbarProgressState.Error"/> render the same as <see cref="TaskbarProgressState.Normal"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MacOSTaskbarProgressService : ITaskbarProgressService
|
||||||
|
{
|
||||||
|
private TaskbarProgressState _state = TaskbarProgressState.None;
|
||||||
|
private double _progress;
|
||||||
|
private volatile bool _disposed;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsSupported { get; private set; }
|
||||||
|
|
||||||
|
public MacOSTaskbarProgressService()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MacOSNativeLibraryLoader.EnsureLoaded();
|
||||||
|
IsSupported = true;
|
||||||
|
}
|
||||||
|
catch (DllNotFoundException)
|
||||||
|
{
|
||||||
|
IsSupported = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// ITaskbarProgressService
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SetWindow(IntPtr windowHandle) { /* Windows-only concept; no-op on macOS. */ }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SetState(TaskbarProgressState state)
|
||||||
|
{
|
||||||
|
if (_disposed || !IsSupported) return;
|
||||||
|
_state = state;
|
||||||
|
if (state == TaskbarProgressState.None) _progress = 0;
|
||||||
|
Apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/Notify.NET/Platform/SingleInstanceChannel.cs
Normal file
166
src/Notify.NET/Platform/SingleInstanceChannel.cs
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="Mutex"/> to elect the
|
||||||
|
/// primary instance.
|
||||||
|
///
|
||||||
|
/// The primary instance (the first to call <see cref="EnsureListening"/>) runs a background loop
|
||||||
|
/// that accepts connections and invokes a callback with each received task id. Any instance can
|
||||||
|
/// statically <see cref="TryForward"/> a task id to the primary; if no primary is listening the
|
||||||
|
/// call returns <c>false</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Whether this process won the election and is the listening primary instance.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal void EnsureListening(Action<string> onActivated)
|
||||||
|
{
|
||||||
|
if (_disposed || !_isPrimary || _listenTask != null) return;
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_listenTask = Task.Run(() => AcceptLoopAsync(onActivated, _cts.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcceptLoopAsync(Action<string> 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<string> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to deliver <paramref name="taskId"/> to a primary instance listening on
|
||||||
|
/// <paramref name="pipeName"/>. Returns <c>true</c> if a primary accepted the connection and
|
||||||
|
/// the id was written; <c>false</c> if no primary is listening (a cold start).
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/Notify.NET/Platform/Windows/CustomDestinationListNative.cs
Normal file
183
src/Notify.NET/Platform/Windows/CustomDestinationListNative.cs
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform.Windows
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// COM interop declarations for building a Windows 7+ jump list via the shell
|
||||||
|
/// <c>ICustomDestinationList</c> "user tasks" API. No native wrapper DLL is required — every
|
||||||
|
/// coclass used here is an in-box shell object, mirroring <see cref="TaskbarListNative"/>.
|
||||||
|
///
|
||||||
|
/// A user task is an <c>IShellLink</c> (a shortcut) that relaunches the application's executable
|
||||||
|
/// with arguments; its display label is set via the <c>System.Title</c> (<c>PKEY_Title</c>)
|
||||||
|
/// property on the link's <c>IPropertyStore</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CustomDestinationListNative
|
||||||
|
{
|
||||||
|
// VT_LPWSTR — the only PROPVARIANT type we produce (for the task title).
|
||||||
|
private const ushort VT_LPWSTR = 31;
|
||||||
|
|
||||||
|
/// <summary><c>System.Title</c> — the label shown for a jump-list user task.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A deliberately minimal PROPVARIANT large enough for the simple inline value we set
|
||||||
|
/// (<c>VT_LPWSTR</c>). The trailing padding makes the managed size match the native
|
||||||
|
/// <c>PROPVARIANT</c> (16 bytes on x86, 24 on x64), which is all that <c>SetValue</c> and
|
||||||
|
/// <c>PropVariantClear</c> require here.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a string property on a link's property store and commits it. Used to assign the
|
||||||
|
/// task's display title (<see cref="PKEY_Title"/>), which is required for the task to appear.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/Notify.NET/Platform/Windows/TaskbarListNative.cs
Normal file
62
src/Notify.NET/Platform/Windows/TaskbarListNative.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform.Windows
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// COM interop declarations for the Windows <c>ITaskbarList3</c> interface, used to drive the
|
||||||
|
/// taskbar-button progress indicator. No native wrapper DLL is required — the COM object is the
|
||||||
|
/// in-box shell <c>CLSID_TaskbarList</c> coclass, available on Windows 7 and later.
|
||||||
|
/// </summary>
|
||||||
|
internal static class TaskbarListNative
|
||||||
|
{
|
||||||
|
/// <summary>Progress-bar states accepted by <see cref="ITaskbarList3.SetProgressState"/>.</summary>
|
||||||
|
[Flags]
|
||||||
|
internal enum TBPFLAG
|
||||||
|
{
|
||||||
|
TBPF_NOPROGRESS = 0,
|
||||||
|
TBPF_INDETERMINATE = 0x1,
|
||||||
|
TBPF_NORMAL = 0x2,
|
||||||
|
TBPF_ERROR = 0x4,
|
||||||
|
TBPF_PAUSED = 0x8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shell taskbar-list coclass. Instantiate via <c>new TaskbarInstance()</c> and cast to
|
||||||
|
/// <see cref="ITaskbarList3"/>.
|
||||||
|
/// </summary>
|
||||||
|
[ComImport]
|
||||||
|
[Guid("56FDF344-FD6D-11d0-958A-006097C9A090")]
|
||||||
|
[ClassInterface(ClassInterfaceType.None)]
|
||||||
|
internal class TaskbarInstance { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subset of <c>ITaskbarList3</c>. Methods are declared in exact vtable order (inherited
|
||||||
|
/// <c>ITaskbarList</c> and <c>ITaskbarList2</c> members first) up to the two we use; later
|
||||||
|
/// members are intentionally omitted.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the HWND of the console window owned by this process, or Zero if none.</summary>
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
internal static extern IntPtr GetConsoleWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
264
src/Notify.NET/Platform/Windows/WindowsJumpListService.cs
Normal file
264
src/Notify.NET/Platform/Windows/WindowsJumpListService.cs
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using Notify.NET.Abstractions;
|
||||||
|
using Notify.NET.Platform;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform.Windows
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IJumpListService"/> implementation backed by the shell
|
||||||
|
/// <c>ICustomDestinationList</c> "user tasks" API (Windows 7+). No native wrapper DLL is required.
|
||||||
|
///
|
||||||
|
/// Each task is an <c>IShellLink</c> that relaunches the host executable with
|
||||||
|
/// <c>--notify-jumplist <id></c>; the bundled <see cref="SingleInstanceChannel"/> forwards the
|
||||||
|
/// id to the already-running primary instance so <see cref="IJumpListHandler.OnTaskActivated"/>
|
||||||
|
/// 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 <see cref="WindowsTaskbarProgressService"/>.
|
||||||
|
/// </summary>
|
||||||
|
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<Action>? _workQueue;
|
||||||
|
private ManualResetEventSlim? _staReady;
|
||||||
|
private volatile bool _disposed;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsSupported { get; }
|
||||||
|
|
||||||
|
/// <param name="appUserModelId">
|
||||||
|
/// The same AppUserModelId used for notifications, so the jump list attaches to the correct
|
||||||
|
/// taskbar button.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="executablePath">
|
||||||
|
/// Absolute path to the executable to relaunch when a task is clicked. When null, the current
|
||||||
|
/// process executable is used.
|
||||||
|
/// </param>
|
||||||
|
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
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool TryHandleActivation(string[] args)
|
||||||
|
{
|
||||||
|
if (!IsSupported || _disposed) return false;
|
||||||
|
return _router.TryHandleActivation(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SetHandler(IJumpListHandler? handler)
|
||||||
|
{
|
||||||
|
if (!IsSupported || _disposed) return;
|
||||||
|
_router.SetHandler(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SetTasks(IEnumerable<JumpListTask> tasks)
|
||||||
|
{
|
||||||
|
if (!IsSupported || _disposed) return;
|
||||||
|
if (tasks == null) throw new ArgumentNullException(nameof(tasks));
|
||||||
|
|
||||||
|
var list = new List<JumpListTask>(tasks);
|
||||||
|
_router.EnsureListening();
|
||||||
|
EnqueueOnSta(() => BuildList(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void ClearTasks()
|
||||||
|
{
|
||||||
|
if (!IsSupported || _disposed) return;
|
||||||
|
EnqueueOnSta(DeleteList);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// IDisposable
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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<Action>();
|
||||||
|
_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<JumpListTask> 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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
251
src/Notify.NET/Platform/Windows/WindowsTaskbarProgressService.cs
Normal file
251
src/Notify.NET/Platform/Windows/WindowsTaskbarProgressService.cs
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Threading;
|
||||||
|
using Notify.NET.Abstractions;
|
||||||
|
|
||||||
|
namespace Notify.NET.Platform.Windows
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="ITaskbarProgressService"/> implementation backed by the shell <c>ITaskbarList3</c>
|
||||||
|
/// COM interface. No native wrapper DLL is required.
|
||||||
|
///
|
||||||
|
/// Two mechanisms are driven together so that progress is visible across host environments:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// <b><c>ITaskbarList3</c></b> — sets progress on the taskbar button of the target window.
|
||||||
|
/// This is the mechanism for GUI apps (pass the window via <see cref="SetWindow"/>) and for
|
||||||
|
/// the classic console host (<c>conhost.exe</c>), whose console window owns a taskbar button.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <b>OSC 9;4</b> — the ConEmu/Windows-Terminal progress escape sequence, written to stdout.
|
||||||
|
/// Under Windows Terminal (the Windows 11 default) the app runs through a ConPTY and
|
||||||
|
/// <c>GetConsoleWindow()</c> returns a hidden proxy window with no taskbar button, so
|
||||||
|
/// <c>ITaskbarList3</c> 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).
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// Threading model:
|
||||||
|
/// <c>ITaskbarList3</c> 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
|
||||||
|
/// <see cref="WindowsNotificationService"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WindowsTaskbarProgressService : ITaskbarProgressService
|
||||||
|
{
|
||||||
|
private readonly Thread _staThread;
|
||||||
|
private readonly BlockingCollection<Action> _workQueue = new BlockingCollection<Action>();
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SetProgress(double fraction)
|
||||||
|
{
|
||||||
|
double clamped = fraction < 0 ? 0 : (fraction > 1 ? 1 : fraction);
|
||||||
|
SetProgress((ulong)Math.Round(clamped * 1000.0), 1000UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// IDisposable
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue