Update Notify.NET, fix progress in taskbar

This commit is contained in:
Pat Hartl 2026-06-14 19:40:47 -05:00
parent d9501aea4e
commit 07255ab552
5 changed files with 99 additions and 71 deletions

View file

@ -13,7 +13,7 @@
<PackageVersion Include="LiveChartsCore.SkiaSharpView.Avalonia" Version="2.0.0-rc5.4" />
<PackageVersion Include="Markdig" Version="0.44.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Configuration" Version="9.0.8" />
<PackageVersion Include="Notify.NET" Version="1.0.2" />
<PackageVersion Include="Notify.NET" Version="1.1.0" />
<PackageVersion Include="Svrooij.PowerShell.DI" Version="1.3.4" />
</ItemGroup>
<ItemGroup Label="AutoMapper">

View file

@ -79,11 +79,13 @@ namespace LANCommander.Launcher.Services
OnProgress?.Invoke(e);
};
_redistributableClient.OnInstallProgressUpdate += (e) =>
{
UpdateQueueItemFromProgress(e);
OnProgress?.Invoke(e);
};
// Note: RedistributableClient progress is intentionally NOT forwarded here.
// Its InstallProgress never carries a Game, so it can't be matched to a queue
// item, and redistributables are also installed/verified during game launch —
// forwarding those events would drive the queue footer and taskbar with
// out-of-band progress when nothing is actually queued. The game-level
// "Installing Redistributables" status (raised by GameClient with the owning
// game attached) still surfaces the redist phase of a queued install.
// New task-level progress forwarding
_gameClient.OnTaskProgress += OnSdkTaskProgress;

View file

@ -80,15 +80,25 @@ public partial class App : Application
};
desktop.MainWindow = mainWindow;
mainWindow.Show();
// Initialize taskbar progress service with the window handle
mainWindow.Opened += (_, _) =>
// Bind the taskbar progress indicator to the main window handle. This must be
// wired BEFORE Show(): on Windows, Show() raises Opened synchronously, so a
// handler attached afterwards would never fire and the progress bar would stay
// bound to Notify.NET's default GetConsoleWindow() target instead of the app.
void BindTaskbarProgress()
{
var hwnd = mainWindow.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
if (hwnd != IntPtr.Zero)
Services.GetRequiredService<TaskbarProgressService>().Initialize(hwnd);
};
}
mainWindow.Opened += (_, _) => BindTaskbarProgress();
mainWindow.Show();
// If Opened already fired synchronously during Show(), the handler above missed
// it; bind now since the handle is available once the window is shown.
BindTaskbarProgress();
// Single-instance pipe server: forward notification-click navigations
var singleInstance = Services.GetRequiredService<SingleInstanceService>();
@ -235,6 +245,10 @@ public partial class App : Application
opts.AppName = "LANCommander";
opts.AppUserModelId = "LANCommander.Launcher";
});
services.AddTaskbarProgress(opts =>
{
opts.DesktopFileId = "LANCommander.Launcher";
});
services.AddSingleton<NotificationService>();
services.AddSingleton<TaskbarProgressService>();
services.AddSingleton<SingleInstanceService>();

View file

@ -1,112 +1,109 @@
using System;
using System.Runtime.InteropServices;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Services;
using Microsoft.Extensions.Logging;
using Notify.NET.Abstractions;
namespace LANCommander.Launcher.Services;
/// <summary>
/// Drives the OS taskbar/Dock progress indicator for the active download,
/// backed by Notify.NET's cross-platform <see cref="ITaskbarProgressService"/>.
/// </summary>
public class TaskbarProgressService
{
private readonly ITaskbarProgressService _taskbar;
private readonly ILogger<TaskbarProgressService> _logger;
private ITaskbarList3? _taskbarList;
private IntPtr _hwnd;
public TaskbarProgressService(ILogger<TaskbarProgressService> logger)
public TaskbarProgressService(ITaskbarProgressService taskbar, ILogger<TaskbarProgressService> logger)
{
_taskbar = taskbar;
_logger = logger;
}
public void Initialize(IntPtr hwnd)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
_logger.LogInformation("TaskbarProgress.Initialize: IsSupported={IsSupported}, hwnd={Hwnd}", _taskbar.IsSupported, hwnd);
_hwnd = hwnd;
if (!_taskbar.IsSupported)
return;
try
{
_taskbarList = (ITaskbarList3)new TaskbarListInstance();
_taskbarList.HrInit();
_taskbar.SetWindow(hwnd);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to initialize ITaskbarList3");
_taskbarList = null;
_logger.LogWarning(ex, "Failed to bind taskbar progress to window handle");
}
}
public void SetProgress(float progress)
/// <summary>
/// Reflects the current install item's progress and status on the taskbar:
/// indeterminate phases pulse, active transfers show a value, failures turn red.
/// </summary>
public void Report(InstallProgress progress)
{
if (_taskbarList == null || _hwnd == IntPtr.Zero) return;
if (!_taskbar.IsSupported)
return;
try
{
const ulong total = 100_000;
var completed = (ulong)(progress * total);
_taskbarList.SetProgressState(_hwnd, TBPFLAG.TBPF_NORMAL);
_taskbarList.SetProgressValue(_hwnd, completed, total);
_logger.LogDebug("TaskbarProgress.Report: Status={Status}, Indeterminate={Indeterminate}, Progress={Progress}", progress.Status, progress.Indeterminate, progress.Progress);
switch (progress.Status)
{
case InstallStatus.Failed:
_taskbar.SetState(TaskbarProgressState.Error);
break;
case InstallStatus.Canceled:
case InstallStatus.Complete:
_taskbar.SetState(TaskbarProgressState.None);
break;
default:
// Progress is BytesTransferred/TotalBytes and is NaN before the total is
// known (division by zero). Treat that—and any non-finite value—as an
// indeterminate pulse rather than feeding NaN to the native indicator.
if (progress.Indeterminate || float.IsNaN(progress.Progress) || float.IsInfinity(progress.Progress))
_taskbar.SetState(TaskbarProgressState.Indeterminate);
else
_taskbar.SetProgress(progress.Progress);
break;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set taskbar progress");
_logger.LogWarning(ex, "Failed to update taskbar progress");
}
}
public void SetIndeterminate()
public void SetError()
{
if (_taskbarList == null || _hwnd == IntPtr.Zero) return;
if (!_taskbar.IsSupported)
return;
try
{
_taskbarList.SetProgressState(_hwnd, TBPFLAG.TBPF_INDETERMINATE);
_taskbar.SetState(TaskbarProgressState.Error);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set indeterminate taskbar progress");
_logger.LogWarning(ex, "Failed to set taskbar error state");
}
}
public void ClearProgress()
{
if (_taskbarList == null || _hwnd == IntPtr.Zero) return;
if (!_taskbar.IsSupported)
return;
try
{
_taskbarList.SetProgressState(_hwnd, TBPFLAG.TBPF_NOPROGRESS);
_taskbar.SetState(TaskbarProgressState.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to clear taskbar progress");
}
}
// ── COM interop ──────────────────────────────────────────────────────────
[Flags]
private enum TBPFLAG
{
TBPF_NOPROGRESS = 0x00,
TBPF_INDETERMINATE = 0x01,
TBPF_NORMAL = 0x02,
TBPF_ERROR = 0x04,
TBPF_PAUSED = 0x08,
}
[ComImport]
[Guid("ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface ITaskbarList3
{
void HrInit();
void AddTab(IntPtr hwnd);
void DeleteTab(IntPtr hwnd);
void ActivateTab(IntPtr hwnd);
void SetActiveAlt(IntPtr hwnd);
void MarkFullscreenWindow(IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fullscreen);
void SetProgressValue(IntPtr hwnd, ulong completed, ulong total);
void SetProgressState(IntPtr hwnd, TBPFLAG state);
}
[ComImport]
[Guid("56fdf344-fd6d-11d0-958a-006097c9a090")]
[ClassInterface(ClassInterfaceType.None)]
private class TaskbarListInstance { }
}

View file

@ -148,10 +148,23 @@ public partial class DownloadQueueViewModel : ViewModelBase
private Task OnProgress(InstallProgress progress)
{
_taskbarProgressService.Report(progress);
Dispatcher.UIThread.Post(() =>
{
var item = QueueItems.FirstOrDefault(i => i.Id == progress.Game?.Id);
// Progress can arrive out-of-band when nothing is queued (e.g. a game-launch
// sub-operation). Treat the event as a real install only if it maps to a queue
// item, or if some item is already actively installing (covers addon/expansion
// sub-installs, whose progress carries the addon — not the base queue item).
// Otherwise ignore it so it can't drive the footer or taskbar.
if (item == null && !QueueItems.Any(i => i.IsActive))
{
_taskbarProgressService.ClearProgress();
return;
}
_taskbarProgressService.Report(progress);
CurrentStatus = GetDisplayName(progress.Status);
CurrentProgress = progress.Progress;
CurrentTransferSpeed = progress.TransferSpeed;
@ -159,7 +172,7 @@ public partial class DownloadQueueViewModel : ViewModelBase
// Format progress text
var bytesDownloaded = ByteSize.FromBytes(progress.BytesTransferred);
var totalBytes = ByteSize.FromBytes(progress.TotalBytes);
CurrentProgressText = $"{bytesDownloaded} / {totalBytes} ({progress.Progress:P0})";
// Format transfer speed
@ -189,8 +202,9 @@ public partial class DownloadQueueViewModel : ViewModelBase
TimeRemainingText = string.Empty;
}
// Update the matching queue item
var item = QueueItems.FirstOrDefault(i => i.Id == progress.Game?.Id);
// Update the matching queue item. May be null for a sub-install (e.g. an addon)
// whose progress carries the addon rather than the active base queue item; the
// footer/taskbar above still reflect it.
if (item != null)
{
item.UpdateProgress(progress.Status, progress.Progress, progress.TransferSpeed, progress.BytesTransferred, progress.TotalBytes);
@ -249,7 +263,7 @@ public partial class DownloadQueueViewModel : ViewModelBase
{
_logger.LogError("Install failed for game {GameTitle}", game.Title);
_taskbarProgressService.ClearProgress();
_taskbarProgressService.SetError();
_notificationService.NotifyInstallFailed(game.Title ?? "Game", game.Id);
Dispatcher.UIThread.Post(RefreshQueue);
@ -747,6 +761,7 @@ public partial class InstallQueueItemViewModel : ViewModelBase
{
var member = typeof(InstallStatus).GetField(status.ToString());
var display = member?.GetCustomAttribute<DisplayAttribute>();
return display?.Name ?? status.ToString();
}
}