mirror of
https://github.com/LANCommander/Notify.NET.git
synced 2026-08-01 03:08:24 -04:00
Add support for progress bars and stats
This commit is contained in:
parent
a24e3cd46d
commit
c33f0df70a
12 changed files with 1054 additions and 1 deletions
|
|
@ -54,6 +54,15 @@ typedef void (*MNW_FailedCallback) (int64_t notifId);
|
|||
#define MNW_INTERRUPTION_TIME_SENSITIVE 2
|
||||
#define MNW_INTERRUPTION_CRITICAL 3
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Dock-tile progress states (passed to MNW_SetTaskbarProgress)
|
||||
* ------------------------------------------------------------------------- */
|
||||
#define MNW_PROGRESS_NONE 0 /* Clear the progress bar */
|
||||
#define MNW_PROGRESS_INDETERMINATE 1 /* Animated bar with no specific value */
|
||||
#define MNW_PROGRESS_NORMAL 2 /* Determinate bar at `fraction` */
|
||||
#define MNW_PROGRESS_PAUSED 3 /* Same visual as NORMAL (Dock cannot tint) */
|
||||
#define MNW_PROGRESS_ERROR 4 /* Same visual as NORMAL (Dock cannot tint) */
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Handler — bundle of four callback function pointers, copied by value.
|
||||
* Any pointer may be NULL to opt out of that event.
|
||||
|
|
@ -122,6 +131,21 @@ MACNOTIFYAPI int64_t MNW_ShowNotification(
|
|||
*/
|
||||
MACNOTIFYAPI bool MNW_HideNotification(int64_t notifId);
|
||||
|
||||
/**
|
||||
* Sets the Dock-tile progress indicator.
|
||||
*
|
||||
* @param state One of MNW_PROGRESS_*.
|
||||
* @param fraction Progress in the range 0.0–1.0 (used only when state is
|
||||
* MNW_PROGRESS_NORMAL / PAUSED / ERROR; ignored otherwise).
|
||||
*
|
||||
* The work is dispatched asynchronously onto the main thread because AppKit/Dock
|
||||
* APIs are main-thread-only. It is therefore only effective for a regular GUI
|
||||
* application whose main run loop is running and which owns a Dock tile; a bare
|
||||
* console process has no Dock tile and the call is a harmless no-op.
|
||||
* Safe to call before MNW_Initialize (it does not depend on notification state).
|
||||
*/
|
||||
MACNOTIFYAPI void MNW_SetTaskbarProgress(int state, double fraction);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
|
||||
#define MACNOTIFYWRAPPER_EXPORTS
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <UserNotifications/UserNotifications.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdlib.h>
|
||||
|
|
@ -442,3 +443,68 @@ bool MNW_HideNotification(int64_t notifId)
|
|||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Dock-tile progress
|
||||
*
|
||||
* AppKit Dock APIs are main-thread-only, so all work is dispatched onto the
|
||||
* main queue. The custom content view draws the application icon with an
|
||||
* NSProgressIndicator overlaid along the bottom edge.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/* Accessed only on the main thread (inside the dispatched block). */
|
||||
static NSImageView* g_dockImageView = nil;
|
||||
static NSProgressIndicator* g_dockProgress = nil;
|
||||
|
||||
static void EnsureDockViews(NSDockTile* tile)
|
||||
{
|
||||
if (g_dockImageView) return;
|
||||
|
||||
NSImageView* iconView = [[NSImageView alloc]
|
||||
initWithFrame:NSMakeRect(0, 0, tile.size.width, tile.size.height)];
|
||||
iconView.image = [NSApp applicationIconImage];
|
||||
|
||||
NSProgressIndicator* bar = [[NSProgressIndicator alloc]
|
||||
initWithFrame:NSMakeRect(0.0, 0.0, tile.size.width, 12.0)];
|
||||
bar.style = NSProgressIndicatorStyleBar;
|
||||
bar.indeterminate = NO;
|
||||
bar.minValue = 0.0;
|
||||
bar.maxValue = 1.0;
|
||||
[iconView addSubview:bar];
|
||||
|
||||
tile.contentView = iconView;
|
||||
g_dockImageView = iconView;
|
||||
g_dockProgress = bar;
|
||||
}
|
||||
|
||||
void MNW_SetTaskbarProgress(int state, double fraction)
|
||||
{
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSApplication* app = [NSApplication sharedApplication];
|
||||
NSDockTile* tile = [app dockTile];
|
||||
|
||||
if (state == MNW_PROGRESS_NONE) {
|
||||
if (g_dockProgress) [g_dockProgress stopAnimation:nil];
|
||||
tile.contentView = nil;
|
||||
g_dockImageView = nil;
|
||||
g_dockProgress = nil;
|
||||
[tile display];
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureDockViews(tile);
|
||||
|
||||
if (state == MNW_PROGRESS_INDETERMINATE) {
|
||||
g_dockProgress.indeterminate = YES;
|
||||
[g_dockProgress startAnimation:nil];
|
||||
} else {
|
||||
[g_dockProgress stopAnimation:nil];
|
||||
g_dockProgress.indeterminate = NO;
|
||||
double clamped = fraction < 0.0 ? 0.0 : (fraction > 1.0 ? 1.0 : fraction);
|
||||
g_dockProgress.doubleValue = clamped;
|
||||
}
|
||||
|
||||
g_dockProgress.hidden = NO;
|
||||
[tile display];
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ CFLAGS := -fobjc-arc -fvisibility=hidden -O2 -Wall -Wextra \
|
|||
-isysroot $(SDK)
|
||||
LDFLAGS := -dynamiclib \
|
||||
-framework Foundation \
|
||||
-framework AppKit \
|
||||
-framework UserNotifications \
|
||||
-install_name @rpath/libMacNotifyWrapper.dylib
|
||||
|
||||
|
|
|
|||
|
|
@ -119,9 +119,15 @@ services.AddNotifications(opts =>
|
|||
opts.AppName = "Notify.NET Sample (DI)";
|
||||
opts.AppUserModelId = "NotifyNET.Sample.DI";
|
||||
});
|
||||
services.AddTaskbarProgress(opts =>
|
||||
{
|
||||
opts.AppName = "Notify.NET Sample (DI)";
|
||||
opts.DesktopFileId = "NotifyNET.Sample.DI"; // Linux: matches NotifyNET.Sample.DI.desktop
|
||||
});
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var diService = provider.GetRequiredService<INotificationService>();
|
||||
var diService = provider.GetRequiredService<INotificationService>();
|
||||
var diTaskbar = provider.GetRequiredService<ITaskbarProgressService>();
|
||||
|
||||
long id6 = await diService.ShowAsync(
|
||||
NotificationBuilder.Create("DI-registered Service")
|
||||
|
|
@ -131,6 +137,65 @@ long id6 = await diService.ShowAsync(
|
|||
Console.WriteLine($" Shown with id={id6}");
|
||||
await Task.Delay(3000);
|
||||
|
||||
// A realistic combined flow: drive the taskbar progress bar while a long-running
|
||||
// job runs, then fire a completion notification when it finishes.
|
||||
Console.WriteLine($" Taskbar progress supported: {diTaskbar.IsSupported}");
|
||||
|
||||
if (diTaskbar.IsSupported)
|
||||
{
|
||||
Console.WriteLine(" Simulating a download with live taskbar progress...");
|
||||
const int totalBytes = 100;
|
||||
for (int sent = 0; sent <= totalBytes; sent += 10)
|
||||
{
|
||||
diTaskbar.SetProgress((ulong)sent, (ulong)totalBytes);
|
||||
await Task.Delay(300);
|
||||
}
|
||||
|
||||
await diService.ShowAsync(
|
||||
NotificationBuilder.Create("Download complete")
|
||||
.WithBody("All 100 bytes transferred.")
|
||||
.Build());
|
||||
|
||||
diTaskbar.SetState(TaskbarProgressState.None); // clear the bar
|
||||
}
|
||||
|
||||
// ------ 7. Taskbar progress via the factory (no DI) --------------------------
|
||||
Console.WriteLine("\n[7] Demonstrating taskbar progress states (factory helper)...");
|
||||
|
||||
using var taskbar = ServiceCollectionExtensions.CreateTaskbarProgressService(opts =>
|
||||
{
|
||||
opts.AppName = "Notify.NET Sample";
|
||||
opts.DesktopFileId = "NotifyNET.Sample"; // Linux: matches NotifyNET.Sample.desktop
|
||||
});
|
||||
|
||||
Console.WriteLine($" Taskbar progress supported: {taskbar.IsSupported}");
|
||||
|
||||
if (taskbar.IsSupported)
|
||||
{
|
||||
// On Windows this drives the terminal's taskbar button: ITaskbarList3 for the classic
|
||||
// console host, and the OSC 9;4 escape sequence for Windows Terminal (the Win11 default,
|
||||
// where the app runs under a ConPTY and ITaskbarList3 has no visible button). For a
|
||||
// WPF/WinForms app, call taskbar.SetWindow(mainWindowHandle) first to target its window.
|
||||
Console.WriteLine(" Normal bar at 60%...");
|
||||
taskbar.SetProgress(0.60);
|
||||
await Task.Delay(1500);
|
||||
|
||||
Console.WriteLine(" Paused state...");
|
||||
taskbar.SetState(TaskbarProgressState.Paused);
|
||||
await Task.Delay(1500);
|
||||
|
||||
Console.WriteLine(" Error state...");
|
||||
taskbar.SetState(TaskbarProgressState.Error);
|
||||
await Task.Delay(1500);
|
||||
|
||||
Console.WriteLine(" Indeterminate state...");
|
||||
taskbar.SetState(TaskbarProgressState.Indeterminate);
|
||||
await Task.Delay(1500);
|
||||
|
||||
Console.WriteLine(" Clearing progress.");
|
||||
taskbar.SetState(TaskbarProgressState.None);
|
||||
}
|
||||
|
||||
Console.WriteLine("\nAll done.");
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -72,6 +72,56 @@ namespace Notify.NET.Extensions
|
|||
|
||||
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>
|
||||
|
|
@ -103,6 +153,14 @@ namespace Notify.NET.Extensions
|
|||
/// Windows only — ignored on Linux and macOS.
|
||||
/// </summary>
|
||||
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>
|
||||
|
|
@ -122,4 +180,18 @@ namespace Notify.NET.Extensions
|
|||
|
||||
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() { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -87,5 +87,18 @@ namespace Notify.NET.Platform.MacOS
|
|||
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
internal static extern bool MNW_HideNotification(long notifId);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Dock-tile progress
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
internal const int MNW_PROGRESS_NONE = 0;
|
||||
internal const int MNW_PROGRESS_INDETERMINATE = 1;
|
||||
internal const int MNW_PROGRESS_NORMAL = 2;
|
||||
internal const int MNW_PROGRESS_PAUSED = 3;
|
||||
internal const int MNW_PROGRESS_ERROR = 4;
|
||||
|
||||
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern void MNW_SetTaskbarProgress(int state, double fraction);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
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