From bfa97850a6a4c3369532614b44c761b167ed2452 Mon Sep 17 00:00:00 2001
From: c <85012225+Cynrath@users.noreply.github.com>
Date: Thu, 17 Sep 2026 13:26:22 +0300
Subject: [PATCH 1/8] feat: show determinate progress for supported package
operations
Add a generic OperationProgress pipeline (model, throttled
ProgressChanged event, formatter) so operation cards switch from
indeterminate to determinate whenever a manager reports real progress.
Wire WinGet native COM install/upgrade/uninstall with InstallProgress
mapping (byte-based percent, clamped, no synthetic totals) and fall
back to the CLI path otherwise. DownloadOperation reports HTTP
progress. Unknown progress keeps the existing indeterminate behavior.
---
.../DialogPages/OperationViewModel.cs | 30 +
.../OperationProgress.cs | 145 +++++
.../Helpers/WinGetNativeProgress.cs | 517 ++++++++++++++++++
.../Helpers/WinGetPkgOperationHelper.cs | 9 +
.../AbstractOperation.cs | 71 +++
.../DownloadOperation.cs | 8 +
.../OperationProgressFormatter.cs | 60 ++
.../PackageOperations.cs | 40 ++
.../OperationProgressTests.cs | 267 +++++++++
.../WinGetNativeProgressTests.cs | 132 +++++
10 files changed, 1279 insertions(+)
create mode 100644 src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
create mode 100644 src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetNativeProgress.cs
create mode 100644 src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs
create mode 100644 src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
create mode 100644 src/UniGetUI.PackageEngine.Tests/WinGetNativeProgressTests.cs
diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
index d883a7f52d..e683a1ff06 100644
--- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
+++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
@@ -80,6 +80,9 @@ public OperationViewModel(AbstractOperation operation)
operation.StatusChanged += (_, status) =>
Dispatcher.UIThread.Post(() => ApplyStatus(status));
+ operation.ProgressChanged += (_, progress) =>
+ Dispatcher.UIThread.Post(() => ApplyProgress(progress));
+
operation.BadgesChanged += (_, badges) =>
Dispatcher.UIThread.Post(() =>
{
@@ -109,6 +112,33 @@ public OperationViewModel(AbstractOperation operation)
// Sync with current status in case the operation already started
ApplyStatus(operation.Status);
+ ApplyProgress(operation.CurrentProgress);
+ }
+
+ ///
+ /// Applies structured progress to the card. Unknown progress keeps the
+ /// existing indeterminate animation; known progress switches to a
+ /// determinate bar with percent/byte text. Terminal statuses own the
+ /// final visuals, so progress arriving after completion is ignored.
+ ///
+ private void ApplyProgress(OperationProgress progress)
+ {
+ if (Operation.Status is not OperationStatus.Running)
+ return;
+
+ if (progress is null || !progress.IsDeterminate)
+ {
+ ProgressIndeterminate = true;
+ // Keep log-driven LiveLine for plain Unknown resets; only show a
+ // stage label when the manager reported a real (but unmeasured) phase.
+ if (progress is not null && progress.Stage is not OperationProgressStage.Unknown)
+ LiveLine = OperationProgressFormatter.Format(progress);
+ return;
+ }
+
+ ProgressIndeterminate = false;
+ ProgressValue = Math.Clamp(progress.Percentage!.Value, 0, 100);
+ LiveLine = OperationProgressFormatter.Format(progress);
}
// ── Icon loading ──────────────────────────────────────────────────────────
diff --git a/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs b/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
new file mode 100644
index 0000000000..c204c1a573
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
@@ -0,0 +1,145 @@
+namespace UniGetUI.PackageEngine.Enums;
+
+///
+/// Coarse phase of a package operation, supplied by the package manager when
+/// structured progress is available. The UI must never see manager-specific types.
+///
+public enum OperationProgressStage
+{
+ Unknown,
+ Queued,
+ Downloading,
+ Installing,
+ Uninstalling,
+ Finalizing,
+}
+
+///
+/// Generic, manager-agnostic progress report for a running operation.
+/// is null when the real percentage is unknown, in
+/// which case the UI must stay indeterminate. Byte counters are optional and
+/// only set when the manager reports reliable values.
+///
+public sealed record OperationProgress(
+ double? Percentage,
+ ulong? BytesDownloaded = null,
+ ulong? BytesTotal = null,
+ OperationProgressStage Stage = OperationProgressStage.Unknown
+)
+{
+ public static readonly OperationProgress Unknown = new(
+ null,
+ null,
+ null,
+ OperationProgressStage.Unknown
+ );
+
+ ///
+ /// True when holds a real 0-100 value.
+ ///
+ public bool IsDeterminate => Percentage.HasValue;
+
+ ///
+ /// Normalizes a raw percentage: NaN/Infinity become unknown (null),
+ /// out-of-range values are clamped to 0-100.
+ ///
+ public static double? NormalizePercentage(double? value)
+ {
+ if (value is null)
+ return null;
+ double v = value.Value;
+ if (double.IsNaN(v) || double.IsInfinity(v))
+ return null;
+ if (v < 0)
+ return 0;
+ if (v > 100)
+ return 100;
+ return v;
+ }
+
+ ///
+ /// Builds a download progress report. Prefers byte-based percentage when
+ /// is greater than zero; otherwise falls back
+ /// to . A zero/unknown total with no
+ /// usable reported value yields an indeterminate report (no fake percent).
+ ///
+ public static OperationProgress FromDownload(
+ ulong bytesDownloaded,
+ ulong bytesTotal,
+ double? reportedPercentage = null
+ )
+ {
+ if (bytesTotal > 0)
+ {
+ double percent = bytesDownloaded / (double)bytesTotal * 100.0;
+ return new OperationProgress(
+ NormalizePercentage(percent),
+ bytesDownloaded,
+ bytesTotal,
+ OperationProgressStage.Downloading
+ );
+ }
+
+ double? normalized = NormalizePercentage(reportedPercentage);
+ // A bare 0% with no byte totals carries no information; stay indeterminate.
+ if (normalized is null or <= 0)
+ return new OperationProgress(
+ null,
+ bytesDownloaded > 0 ? bytesDownloaded : null,
+ null,
+ OperationProgressStage.Downloading
+ );
+
+ return new OperationProgress(
+ normalized,
+ bytesDownloaded > 0 ? bytesDownloaded : null,
+ null,
+ OperationProgressStage.Downloading
+ );
+ }
+
+ ///
+ /// Builds an install progress report. Only a positive 0-100
+ /// is treated as known; zero,
+ /// negative, NaN and Infinity mean the installer supplied no usable
+ /// progress and map to indeterminate (never synthesize a total).
+ ///
+ public static OperationProgress FromInstall(double? installationProgress)
+ {
+ double? normalized = NormalizePercentage(installationProgress);
+ if (normalized is null or <= 0)
+ return new OperationProgress(null, null, null, OperationProgressStage.Installing);
+ return new OperationProgress(
+ normalized,
+ null,
+ null,
+ OperationProgressStage.Installing
+ );
+ }
+
+ ///
+ /// Builds an uninstall progress report. Same unknown semantics as
+ /// : only a positive value is determinate.
+ ///
+ public static OperationProgress FromUninstall(double? uninstallationProgress)
+ {
+ double? normalized = NormalizePercentage(uninstallationProgress);
+ if (normalized is null or <= 0)
+ return new OperationProgress(null, null, null, OperationProgressStage.Uninstalling);
+ return new OperationProgress(
+ normalized,
+ null,
+ null,
+ OperationProgressStage.Uninstalling
+ );
+ }
+
+ public static OperationProgress Queued =>
+ new(null, null, null, OperationProgressStage.Queued);
+
+ public static OperationProgress Finalizing =>
+ new(null, null, null, OperationProgressStage.Finalizing);
+
+ public static OperationProgress Completed =>
+ new(100, null, null, OperationProgressStage.Finalizing);
+}
diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetNativeProgress.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetNativeProgress.cs
new file mode 100644
index 0000000000..b1130d2ce2
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetNativeProgress.cs
@@ -0,0 +1,517 @@
+using Microsoft.Management.Deployment;
+using UniGetUI.Core.Logging;
+using UniGetUI.Core.Tools;
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageEngine.Interfaces;
+using ComInstallOptions = Microsoft.Management.Deployment.InstallOptions;
+using ComUninstallOptions = Microsoft.Management.Deployment.UninstallOptions;
+using UniInstallOptions = UniGetUI.PackageEngine.Serializable.InstallOptions;
+
+namespace UniGetUI.PackageEngine.Managers.WingetManager;
+
+///
+/// Maps WinGet COM progress structs to the generic
+/// model. The UI never sees these COM types.
+/// Design notes from the WinGet IDL are honored: there is no reliable total
+/// percent across download+install, byte counters only exist for downloads
+/// performed by Windows Package Manager itself, and an unknown install phase
+/// stays indeterminate instead of synthesizing a total.
+///
+internal static class WinGetProgressMapper
+{
+ public static OperationProgress MapInstall(InstallProgress progress)
+ {
+ return progress.State switch
+ {
+ PackageInstallProgressState.Queued => OperationProgress.Queued,
+ PackageInstallProgressState.Downloading => OperationProgress.FromDownload(
+ progress.BytesDownloaded,
+ progress.BytesRequired,
+ progress.DownloadProgress
+ ),
+ PackageInstallProgressState.Installing => OperationProgress.FromInstall(
+ progress.InstallationProgress
+ ),
+ PackageInstallProgressState.PostInstall => OperationProgress.Finalizing,
+ PackageInstallProgressState.Finished => OperationProgress.Completed,
+ _ => OperationProgress.Unknown,
+ };
+ }
+
+ public static OperationProgress MapUninstall(UninstallProgress progress)
+ {
+ return progress.State switch
+ {
+ PackageUninstallProgressState.Queued => OperationProgress.Queued,
+ PackageUninstallProgressState.Uninstalling => OperationProgress.FromUninstall(
+ progress.UninstallationProgress
+ ),
+ PackageUninstallProgressState.PostUninstall => OperationProgress.Finalizing,
+ PackageUninstallProgressState.Finished => OperationProgress.Completed,
+ _ => OperationProgress.Unknown,
+ };
+ }
+}
+
+///
+/// Executes WinGet install/update/uninstall through the native COM API when it
+/// can faithfully honor the requested options, reporting structured progress.
+/// Returns null when the operation must fall back to the CLI path (COM
+/// unavailable, custom CLI args, version lookup miss, elevation needed while
+/// not elevated, ...). Never throws for fallback conditions; only unexpected
+/// COM failures propagate as .
+///
+internal static class WinGetNativeOperationRunner
+{
+ public static bool CanUseNative(IPackage package, UniInstallOptions options, OperationType role)
+ {
+ ArgumentNullException.ThrowIfNull(package);
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (role is not (OperationType.Install or OperationType.Update or OperationType.Uninstall))
+ return false;
+
+ if (package.Source.IsVirtualManager)
+ return false;
+
+ if (NativeWinGetHelper.ExternalFactory is null || NativeWinGetHelper.ExternalWinGetManager is null)
+ return false;
+
+ if (WinGetHelper.Instance is not NativeWinGetHelper)
+ return false;
+
+ if (NativePackageHandler.GetPackage(package) is null)
+ return false;
+
+ // Custom CLI parameters have no COM equivalent; honor them via CLI.
+ IReadOnlyList customArgs = role switch
+ {
+ OperationType.Update => options.CustomParameters_Update,
+ OperationType.Uninstall => options.CustomParameters_Uninstall,
+ _ => options.CustomParameters_Install,
+ };
+ if (customArgs.Any(static arg => !string.IsNullOrWhiteSpace(arg)))
+ return false;
+
+ return true;
+ }
+
+ public static async Task ExecuteAsync(
+ IPackage package,
+ UniInstallOptions options,
+ OperationType role,
+ Action report,
+ Action logInfo,
+ Action logError,
+ CancellationToken cancellationToken
+ )
+ {
+ ArgumentNullException.ThrowIfNull(package);
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(report);
+ ArgumentNullException.ThrowIfNull(logInfo);
+ ArgumentNullException.ThrowIfNull(logError);
+
+ var factory = NativeWinGetHelper.ExternalFactory;
+ var manager = NativeWinGetHelper.ExternalWinGetManager;
+ var nativePackage = NativePackageHandler.GetPackage(package);
+ if (factory is null || manager is null || nativePackage is null)
+ return null;
+
+ // Mirror the CLI path: detect elevation-requiring installers first so the
+ // requested elevation matches regardless of where the operation runs.
+ try
+ {
+ package.Manager.OperationHelper.ApplyElevationRequirements(package, options, role);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ logError(ex.Message);
+ return OperationVeredict.Failure;
+ }
+ catch (Exception ex)
+ {
+ Logger.Error("WinGet native progress: elevation detection failed, falling back to CLI");
+ Logger.Error(ex);
+ return null;
+ }
+
+ // The in-proc COM server runs as the current user; an operation that must
+ // elevate while we are not elevated has to go through the elevator (CLI).
+ bool requiresAdmin =
+ package.OverridenOptions.RunAsAdministrator is true || options.RunAsAdministrator;
+ if (requiresAdmin && !CoreTools.IsAdministrator())
+ return null;
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ return role switch
+ {
+ OperationType.Uninstall => await ExecuteUninstallAsync(
+ package,
+ options,
+ nativePackage,
+ factory,
+ manager,
+ report,
+ logInfo,
+ logError,
+ cancellationToken
+ ).ConfigureAwait(false),
+ OperationType.Install or OperationType.Update => await ExecuteInstallOrUpgradeAsync(
+ package,
+ options,
+ role,
+ nativePackage,
+ factory,
+ manager,
+ report,
+ logInfo,
+ logError,
+ cancellationToken
+ ).ConfigureAwait(false),
+ _ => null,
+ };
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return OperationVeredict.Canceled;
+ }
+ catch (Exception ex)
+ {
+ Logger.Error("WinGet native operation failed, falling back to CLI when possible:");
+ Logger.Error(ex);
+ logError($"Native WinGet operation failed: {ex.Message}");
+ return OperationVeredict.Failure;
+ }
+ }
+
+ private static async Task ExecuteInstallOrUpgradeAsync(
+ IPackage package,
+ UniInstallOptions options,
+ OperationType role,
+ CatalogPackage nativePackage,
+ WindowsPackageManager.Interop.WindowsPackageManagerFactory factory,
+ PackageManager manager,
+ Action report,
+ Action logInfo,
+ Action logError,
+ CancellationToken cancellationToken
+ )
+ {
+ ComInstallOptions comOptions = factory.CreateInstallOptions();
+ if (!TryApplyInstallOptions(package, options, role, nativePackage, comOptions, logError))
+ return null;
+
+ bool isUpgrade = role is OperationType.Update;
+ logInfo(
+ isUpgrade
+ ? $"Starting native WinGet upgrade for {package.Id}..."
+ : $"Starting native WinGet install for {package.Id}..."
+ );
+
+ var asyncOp = isUpgrade
+ ? manager.UpgradePackageAsync(nativePackage, comOptions)
+ : manager.InstallPackageAsync(nativePackage, comOptions);
+ asyncOp.Progress += (_, progress) =>
+ {
+ try
+ {
+ report(WinGetProgressMapper.MapInstall(progress));
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex);
+ }
+ };
+ using var cancelReg = cancellationToken.Register(static state =>
+ {
+ try
+ {
+ ((Windows.Foundation.IAsyncOperationWithProgress)state!).Cancel();
+ }
+ catch
+ {
+ // The operation may already be completed; cancellation is best-effort.
+ }
+ }, asyncOp);
+
+ InstallResult result;
+ try
+ {
+ result = await asyncOp.AsTask(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return OperationVeredict.Canceled;
+ }
+ catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return OperationVeredict.Canceled;
+ }
+
+ return InterpretInstallResult(package, role, result, logInfo, logError);
+ }
+
+ private static async Task ExecuteUninstallAsync(
+ IPackage package,
+ UniInstallOptions options,
+ CatalogPackage nativePackage,
+ WindowsPackageManager.Interop.WindowsPackageManagerFactory factory,
+ PackageManager manager,
+ Action report,
+ Action logInfo,
+ Action logError,
+ CancellationToken cancellationToken
+ )
+ {
+ ComUninstallOptions comOptions = factory.CreateUninstallOptions();
+ comOptions.PackageUninstallMode = options.InteractiveInstallation
+ ? PackageUninstallMode.Interactive
+ : PackageUninstallMode.Silent;
+
+ logInfo($"Starting native WinGet uninstall for {package.Id}...");
+
+ var asyncOp = manager.UninstallPackageAsync(nativePackage, comOptions);
+ asyncOp.Progress += (_, progress) =>
+ {
+ try
+ {
+ report(WinGetProgressMapper.MapUninstall(progress));
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex);
+ }
+ };
+ using var cancelReg = cancellationToken.Register(static state =>
+ {
+ try
+ {
+ ((Windows.Foundation.IAsyncOperationWithProgress)state!).Cancel();
+ }
+ catch
+ {
+ // Best-effort; the operation may already be completed.
+ }
+ }, asyncOp);
+
+ UninstallResult result;
+ try
+ {
+ result = await asyncOp.AsTask(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return OperationVeredict.Canceled;
+ }
+ catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return OperationVeredict.Canceled;
+ }
+
+ return InterpretUninstallResult(result, logInfo, logError);
+ }
+
+ internal static bool TryApplyInstallOptions(
+ IPackage package,
+ UniInstallOptions options,
+ OperationType role,
+ CatalogPackage nativePackage,
+ ComInstallOptions comOptions,
+ Action logError
+ )
+ {
+ // Scope
+ string scope = package.OverridenOptions.Scope ?? options.InstallationScope;
+ if (!package.OverridenOptions.WinGet_DropArchAndScope)
+ {
+ comOptions.PackageInstallScope = scope switch
+ {
+ PackageScope.User => PackageInstallScope.User,
+ PackageScope.Machine => PackageInstallScope.System,
+ _ => PackageInstallScope.Any,
+ };
+ }
+ else
+ {
+ comOptions.PackageInstallScope = PackageInstallScope.Any;
+ }
+
+ // Mode
+ comOptions.PackageInstallMode = options.InteractiveInstallation
+ ? PackageInstallMode.Interactive
+ : PackageInstallMode.Silent;
+
+ // Hash / agreements / force mirror the CLI flags.
+ comOptions.AllowHashMismatch = options.SkipHashCheck;
+ comOptions.AcceptPackageAgreements = true;
+ comOptions.Force = true;
+
+ if (role is OperationType.Update)
+ comOptions.AllowUpgradeToUnknownVersion = true;
+
+ // Location
+ string? location = role is OperationType.Update
+ ? WinGetPkgOperationHelper.GetEffectiveUpdateLocation(package, options)
+ : options.CustomInstallLocation;
+ if (!string.IsNullOrWhiteSpace(location))
+ comOptions.PreferredInstallLocation = location;
+
+ // Architecture preference: single-entry list forces the requested arch,
+ // matching CLI --architecture. Defaults are left untouched otherwise.
+ if (!package.OverridenOptions.WinGet_DropArchAndScope)
+ {
+ Windows.System.ProcessorArchitecture? arch = MapArchitecture(options.Architecture);
+ if (arch.HasValue)
+ {
+ try
+ {
+ comOptions.AllowedArchitectures.Clear();
+ comOptions.AllowedArchitectures.Add(arch.Value);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Could not set WinGet COM architecture preference: {ex.Message}");
+ }
+ }
+ }
+
+ // Version pin: PackageVersionId instances are lookup-only; resolve the
+ // requested version from AvailableVersions, else fall back to CLI.
+ if (role is OperationType.Install && !string.IsNullOrWhiteSpace(options.Version))
+ {
+ var match = FindPackageVersionId(nativePackage, options.Version);
+ if (match is null)
+ {
+ logError(
+ $"Requested version {options.Version} was not found for {package.Id}; falling back to CLI."
+ );
+ return false;
+ }
+ comOptions.PackageVersionId = match;
+ }
+
+ return true;
+ }
+
+ private static Windows.System.ProcessorArchitecture? MapArchitecture(string architecture) =>
+ architecture switch
+ {
+ Architecture.x86 => Windows.System.ProcessorArchitecture.X86,
+ Architecture.x64 => Windows.System.ProcessorArchitecture.X64,
+ Architecture.arm64 => Windows.System.ProcessorArchitecture.Arm64,
+ Architecture.arm32 => Windows.System.ProcessorArchitecture.Arm,
+ _ => null,
+ };
+
+ private static PackageVersionId? FindPackageVersionId(CatalogPackage nativePackage, string version)
+ {
+ try
+ {
+ var available = nativePackage.AvailableVersions;
+ if (available is null)
+ return null;
+ foreach (var candidate in NativeWinGetCollection.Copy(available))
+ {
+ if (
+ string.Equals(candidate.Version, version, StringComparison.OrdinalIgnoreCase)
+ )
+ return candidate;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Could not enumerate WinGet versions for {nativePackage.Id}: {ex.Message}");
+ }
+ return null;
+ }
+
+ private static OperationVeredict InterpretInstallResult(
+ IPackage package,
+ OperationType role,
+ InstallResult result,
+ Action logInfo,
+ Action logError
+ )
+ {
+ logInfo($"Native WinGet result: {result.Status} (0x{result.ExtendedErrorCode:X8})");
+ if (result.RebootRequired)
+ logInfo("A restart is required to finish the installation.");
+
+ switch (result.Status)
+ {
+ case InstallResultStatus.Ok:
+ WinGetPkgOperationHelper.MarkUpgradeAsDoneForNative(package, role);
+ return OperationVeredict.Success;
+ case InstallResultStatus.NoApplicableUpgrade:
+ // Already at the requested version; report success like the CLI
+ // "already installed" path instead of failing the operation.
+ WinGetPkgOperationHelper.MarkUpgradeAsDoneForNative(package, role);
+ logInfo("The package is already at the requested version.");
+ return OperationVeredict.Success;
+ case InstallResultStatus.NoApplicableInstallers:
+ if (
+ role is OperationType.Update
+ && !package.OverridenOptions.WinGet_DropArchAndScope
+ )
+ {
+ // Mirror the CLI retry: the forced scope/architecture may exclude
+ // the only installer; drop the constraints for the CLI fallback.
+ package.OverridenOptions.WinGet_DropArchAndScope = true;
+ logError(
+ "No applicable installer found with the current scope/architecture constraints."
+ );
+ }
+ else
+ {
+ logError("No applicable installer was found for this system.");
+ if (role is OperationType.Update)
+ WinGetPkgOperationHelper.SuppressPhantomUpgrade(package);
+ }
+ return OperationVeredict.Failure;
+ case InstallResultStatus.DownloadError:
+ case InstallResultStatus.InstallError:
+ case InstallResultStatus.ManifestError:
+ case InstallResultStatus.CatalogError:
+ case InstallResultStatus.InternalError:
+ case InstallResultStatus.InvalidOptions:
+ case InstallResultStatus.BlockedByPolicy:
+ case InstallResultStatus.PackageAgreementsNotAccepted:
+ default:
+ if (
+ result.Status is InstallResultStatus.ManifestError
+ or InstallResultStatus.NoApplicableInstallers
+ && role is OperationType.Update
+ )
+ WinGetPkgOperationHelper.SuppressPhantomUpgrade(package);
+ logError($"Native WinGet operation failed: {result.Status} (0x{result.ExtendedErrorCode:X8})");
+ return OperationVeredict.Failure;
+ }
+ }
+
+ private static OperationVeredict InterpretUninstallResult(
+ UninstallResult result,
+ Action logInfo,
+ Action logError
+ )
+ {
+ logInfo($"Native WinGet uninstall result: {result.Status} (0x{result.ExtendedErrorCode:X8})");
+ if (result.RebootRequired)
+ logInfo("A restart is required to finish the uninstallation.");
+
+ return result.Status switch
+ {
+ UninstallResultStatus.Ok => OperationVeredict.Success,
+ _ => FailUninstall(result, logError),
+ };
+ }
+
+ private static OperationVeredict FailUninstall(UninstallResult result, Action logError)
+ {
+ logError($"Native WinGet uninstall failed: {result.Status} (0x{result.ExtendedErrorCode:X8})");
+ return OperationVeredict.Failure;
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs
index 99bca80631..7935365f5e 100644
--- a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs
+++ b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs
@@ -379,6 +379,15 @@ operation is OperationType.Uninstall
return OperationVeredict.Failure;
}
+ ///
+ /// Records a native COM install/upgrade as done, mirroring the CLI success
+ /// path so update suppression (#5042) and stuck-loop detection (#5158)
+ /// keep working for native operations. Only updates feed the stuck-update
+ /// counter; installs must not.
+ ///
+ internal static void MarkUpgradeAsDoneForNative(IPackage package, OperationType operation) =>
+ MarkUpgradeAsDone(package, countTowardStuckLoop: operation is OperationType.Update);
+
// Default number of "successful" upgrades to the same version (without the installed version
// advancing) after which the update is suppressed; overridable via WinGetStuckUpgradeThreshold (#5158).
private const int DefaultStuckUpgradeThreshold = 3;
diff --git a/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
index dacb78df0c..78defe06e1 100644
--- a/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
+++ b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
@@ -18,8 +18,78 @@ public abstract partial class AbstractOperation : IDisposable
public event EventHandler? OperationSucceeded;
public event EventHandler? OperationFailed;
public event EventHandler? BadgesChanged;
+ public event EventHandler? ProgressChanged;
public bool Started { get; private set; }
+
+ private readonly object ProgressLock = new();
+ private OperationProgress _currentProgress = OperationProgress.Unknown;
+ private OperationProgress _lastRaisedProgress = OperationProgress.Unknown;
+ private DateTime _lastProgressReportUtc = DateTime.MinValue;
+
+ ///
+ /// Latest structured progress reported by the executing manager.
+ /// when no real progress is known,
+ /// in which case the UI must stay indeterminate.
+ ///
+ public OperationProgress CurrentProgress
+ {
+ get
+ {
+ lock (ProgressLock)
+ return _currentProgress;
+ }
+ }
+
+ ///
+ /// Reports structured progress from any thread. Reports are de-duplicated
+ /// and coalesced so rapid native callbacks cannot spam the UI: identical
+ /// reports are dropped, stage changes and Unknown/100% are always raised,
+ /// and small determinate deltas within 200ms are coalesced.
+ ///
+ protected void ReportProgress(OperationProgress progress)
+ {
+ ArgumentNullException.ThrowIfNull(progress);
+ bool shouldRaise;
+ lock (ProgressLock)
+ {
+ _currentProgress = progress;
+ if (progress.Equals(_lastRaisedProgress))
+ {
+ shouldRaise = false;
+ }
+ else if (
+ progress.Stage == _lastRaisedProgress.Stage
+ && progress.IsDeterminate
+ && _lastRaisedProgress.IsDeterminate
+ && progress.Percentage.HasValue
+ && _lastRaisedProgress.Percentage.HasValue
+ && progress.Percentage.Value is not 100
+ && _lastRaisedProgress.Percentage.Value is not 100
+ && Math.Abs(progress.Percentage.Value - _lastRaisedProgress.Percentage.Value) < 1.0
+ && DateTime.UtcNow - _lastProgressReportUtc < TimeSpan.FromMilliseconds(200)
+ && progress.Stage is not OperationProgressStage.Unknown
+ )
+ {
+ shouldRaise = false;
+ }
+ else
+ {
+ shouldRaise = true;
+ _lastRaisedProgress = progress;
+ _lastProgressReportUtc = DateTime.UtcNow;
+ }
+ }
+
+ if (shouldRaise)
+ ProgressChanged?.Invoke(this, progress);
+ }
+
+ ///
+ /// Resets structured progress to unknown (indeterminate). Called at the
+ /// start of every execution attempt so retries never show stale progress.
+ ///
+ protected void ResetProgress() => ReportProgress(OperationProgress.Unknown);
protected bool QUEUE_ENABLED;
protected bool FORCE_HOLD_QUEUE;
private bool IsInnerOperation;
@@ -513,6 +583,7 @@ private async Task _runOperation()
break;
}
+ ResetProgress();
OperationStarting?.Invoke(this, EventArgs.Empty);
try
diff --git a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs
index 5d7734475b..4a4bd7714a 100644
--- a/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs
+++ b/src/UniGetUI.PackageEngine.Operations/DownloadOperation.cs
@@ -96,6 +96,11 @@ protected override async Task PerformOperation()
var totalBytes = response.Content.Headers.ContentLength ?? -1L;
var canReportProgress = totalBytes > 0;
+ ReportProgress(
+ totalBytes > 0
+ ? OperationProgress.FromDownload(0, (ulong)totalBytes)
+ : OperationProgress.FromDownload(0, 0)
+ );
await using (var contentStream = await response.Content.ReadAsStreamAsync(CancellationToken))
await using (var fileStream = new FileStream(
downloadLocation,
@@ -132,6 +137,9 @@ protected override async Task PerformOperation()
),
LineType.ProgressIndicator
);
+ ReportProgress(
+ OperationProgress.FromDownload((ulong)totalRead, (ulong)totalBytes)
+ );
}
}
}
diff --git a/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs b/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs
new file mode 100644
index 0000000000..84019b34dc
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs
@@ -0,0 +1,60 @@
+using UniGetUI.Core.Tools;
+using UniGetUI.PackageEngine.Enums;
+
+namespace UniGetUI.PackageOperations;
+
+///
+/// Formats a generic for operation cards,
+/// log lines and screen-reader status. Unknown progress maps to a short
+/// stage label (indeterminate); determinate progress appends percent and,
+/// when available, human-readable byte counters.
+///
+public static class OperationProgressFormatter
+{
+ public static string Format(OperationProgress progress)
+ {
+ ArgumentNullException.ThrowIfNull(progress);
+ if (!progress.IsDeterminate)
+ return IndeterminateLabel(progress.Stage);
+
+ int percent = (int)Math.Round(progress.Percentage!.Value);
+ string label = StageLabel(progress.Stage);
+ if (
+ progress.BytesDownloaded.HasValue
+ && progress.BytesTotal.HasValue
+ && progress.BytesTotal.Value > 0
+ )
+ {
+ return $"{label} \u00b7 {percent}% \u00b7 {FormatBytes(progress.BytesDownloaded.Value)} / {FormatBytes(progress.BytesTotal.Value)}";
+ }
+
+ return $"{label} \u00b7 {percent}%";
+ }
+
+ public static string StageLabel(OperationProgressStage stage) =>
+ stage switch
+ {
+ OperationProgressStage.Downloading => CoreTools.Translate("Downloading"),
+ OperationProgressStage.Installing => CoreTools.Translate("Installing"),
+ OperationProgressStage.Uninstalling => CoreTools.Translate("Uninstalling"),
+ OperationProgressStage.Queued => CoreTools.Translate("Please wait..."),
+ OperationProgressStage.Finalizing => CoreTools.Translate("Finalizing"),
+ _ => CoreTools.Translate("Please wait..."),
+ };
+
+ private static string IndeterminateLabel(OperationProgressStage stage) =>
+ stage switch
+ {
+ OperationProgressStage.Downloading => CoreTools.Translate("Downloading..."),
+ OperationProgressStage.Installing => CoreTools.Translate("Installing..."),
+ OperationProgressStage.Uninstalling => CoreTools.Translate("Uninstalling..."),
+ OperationProgressStage.Queued => CoreTools.Translate("Please wait..."),
+ OperationProgressStage.Finalizing => CoreTools.Translate("Finalizing..."),
+ _ => CoreTools.Translate("Please wait..."),
+ };
+
+ private static string FormatBytes(ulong value) =>
+ value > (ulong)long.MaxValue
+ ? $"{value / 1099511627776.0:F1} TB"
+ : CoreTools.FormatAsSize((long)value);
+}
diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
index d0b3f1d717..4cd93e59b1 100644
--- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
+++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
@@ -298,12 +298,52 @@ protected override async Task PerformOperation()
{
if (!ShouldUseAgentBroker())
{
+#if WINDOWS
+ OperationVeredict? nativeResult = await TryPerformNativeWinGetOperationAsync()
+ .ConfigureAwait(false);
+ if (nativeResult.HasValue)
+ return nativeResult.Value;
+#endif
return await base.PerformOperation();
}
return await PerformBrokerOperation();
}
+#if WINDOWS
+ ///
+ /// Attempts a native WinGet COM execution with structured progress when the
+ /// operation can be faithfully honored that way. Returns null to fall back
+ /// to the CLI process path. The UI stays generic: progress arrives as
+ /// and is also logged as a live line so
+ /// existing LiveLine/IPC consumers keep working.
+ ///
+ private async Task TryPerformNativeWinGetOperationAsync()
+ {
+ if (Package.Manager is not WinGet)
+ return null;
+
+ if (!WinGetNativeOperationRunner.CanUseNative(Package, Options, Role))
+ return null;
+
+ return await WinGetNativeOperationRunner
+ .ExecuteAsync(
+ Package,
+ Options,
+ Role,
+ progress =>
+ {
+ ReportProgress(progress);
+ Line(OperationProgressFormatter.Format(progress), LineType.ProgressIndicator);
+ },
+ info => Line(info, LineType.Information),
+ error => Line(error, LineType.Error),
+ CancellationToken
+ )
+ .ConfigureAwait(false);
+ }
+#endif
+
///
/// Determines whether this operation should be routed through the agent broker.
///
diff --git a/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs b/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
new file mode 100644
index 0000000000..03647b5a76
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
@@ -0,0 +1,267 @@
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageOperations;
+
+namespace UniGetUI.PackageEngine.Tests;
+
+public sealed class OperationProgressTests
+{
+ [Fact]
+ public void Unknown_IsIndeterminate()
+ {
+ Assert.False(OperationProgress.Unknown.IsDeterminate);
+ Assert.Null(OperationProgress.Unknown.Percentage);
+ Assert.Equal(OperationProgressStage.Unknown, OperationProgress.Unknown.Stage);
+ }
+
+ [Theory]
+ [InlineData(0, 1000, 0)]
+ [InlineData(500, 1000, 50)]
+ [InlineData(1000, 1000, 100)]
+ [InlineData(326UL * 1024 * 1024, 624UL * 1024 * 1024, 52)]
+ public void FromDownload_ByteBasedPercentage(ulong downloaded, ulong total, double expected)
+ {
+ var progress = OperationProgress.FromDownload(downloaded, total);
+ Assert.True(progress.IsDeterminate);
+ Assert.Equal(expected, Math.Round(progress.Percentage!.Value));
+ Assert.Equal(OperationProgressStage.Downloading, progress.Stage);
+ Assert.Equal(downloaded, progress.BytesDownloaded);
+ Assert.Equal(total, progress.BytesTotal);
+ }
+
+ [Fact]
+ public void FromDownload_ZeroTotal_IsIndeterminate()
+ {
+ var progress = OperationProgress.FromDownload(0, 0);
+ Assert.False(progress.IsDeterminate);
+ Assert.Equal(OperationProgressStage.Downloading, progress.Stage);
+ }
+
+ [Fact]
+ public void FromDownload_ZeroTotal_WithReportedProgress_IsDeterminate()
+ {
+ var progress = OperationProgress.FromDownload(0, 0, 42);
+ Assert.True(progress.IsDeterminate);
+ Assert.Equal(42, progress.Percentage);
+ }
+
+ [Fact]
+ public void FromDownload_ZeroReported_WithZeroTotal_StaysIndeterminate()
+ {
+ // A bare 0% with no byte totals carries no information; no fake percent.
+ var progress = OperationProgress.FromDownload(0, 0, 0);
+ Assert.False(progress.IsDeterminate);
+ }
+
+ [Fact]
+ public void FromDownload_ProcessedGreaterThanTotal_ClampsTo100()
+ {
+ var progress = OperationProgress.FromDownload(150, 100);
+ Assert.True(progress.IsDeterminate);
+ Assert.Equal(100, progress.Percentage);
+ // Real byte counters are preserved even when the percent clamps.
+ Assert.Equal(150UL, progress.BytesDownloaded);
+ Assert.Equal(100UL, progress.BytesTotal);
+ }
+
+ [Theory]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ public void FromDownload_InvalidReported_WithZeroTotal_IsIndeterminate(double reported)
+ {
+ var progress = OperationProgress.FromDownload(0, 0, reported);
+ Assert.False(progress.IsDeterminate);
+ }
+
+ [Theory]
+ [InlineData(-5.0, 0.0)]
+ [InlineData(150.0, 100.0)]
+ [InlineData(double.NaN, null)]
+ public void NormalizePercentage_ClampsAndRejects(double input, double? expected)
+ {
+ Assert.Equal(expected, OperationProgress.NormalizePercentage(input));
+ }
+
+ [Fact]
+ public void NormalizePercentage_Null_StaysNull()
+ {
+ Assert.Null(OperationProgress.NormalizePercentage(null));
+ }
+
+ [Theory]
+ [InlineData(50, true)]
+ [InlineData(100, true)]
+ [InlineData(0.5, true)]
+ [InlineData(0, false)]
+ [InlineData(-1, false)]
+ public void FromInstall_KnownVsUnknown(double value, bool expectedDeterminate)
+ {
+ var progress = OperationProgress.FromInstall(value);
+ Assert.Equal(expectedDeterminate, progress.IsDeterminate);
+ Assert.Equal(OperationProgressStage.Installing, progress.Stage);
+ if (expectedDeterminate)
+ Assert.Equal(value, progress.Percentage);
+ }
+
+ [Theory]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ public void FromInstall_Invalid_IsIndeterminate(double value)
+ {
+ Assert.False(OperationProgress.FromInstall(value).IsDeterminate);
+ }
+
+ [Fact]
+ public void FromInstall_Over100_ClampsTo100()
+ {
+ Assert.Equal(100, OperationProgress.FromInstall(150).Percentage);
+ }
+
+ [Theory]
+ [InlineData(75, true)]
+ [InlineData(0, false)]
+ [InlineData(double.NaN, false)]
+ public void FromUninstall_KnownVsUnknown(double value, bool expectedDeterminate)
+ {
+ var progress = OperationProgress.FromUninstall(value);
+ Assert.Equal(expectedDeterminate, progress.IsDeterminate);
+ Assert.Equal(OperationProgressStage.Uninstalling, progress.Stage);
+ }
+
+ [Fact]
+ public void Completed_Is100Finalizing()
+ {
+ Assert.True(OperationProgress.Completed.IsDeterminate);
+ Assert.Equal(100, OperationProgress.Completed.Percentage);
+ Assert.Equal(OperationProgressStage.Finalizing, OperationProgress.Completed.Stage);
+ }
+
+ [Fact]
+ public void QueuedAndFinalizing_AreIndeterminate()
+ {
+ Assert.False(OperationProgress.Queued.IsDeterminate);
+ Assert.False(OperationProgress.Finalizing.IsDeterminate);
+ }
+
+ private class ProgressProbeOperation : AbstractOperation
+ {
+ public ProgressProbeOperation()
+ : base(queue_enabled: false)
+ {
+ Metadata.Status = "probe status";
+ Metadata.Title = "probe title";
+ Metadata.OperationInformation = "probe info";
+ Metadata.SuccessTitle = "probe success";
+ Metadata.SuccessMessage = "probe success";
+ Metadata.FailureTitle = "probe failure";
+ Metadata.FailureMessage = "probe failure";
+ }
+
+ public void ReportForTests(OperationProgress progress) => ReportProgress(progress);
+
+ public void ResetForTests() => ResetProgress();
+
+ protected override void ApplyRetryAction(string retryMode) { }
+
+ protected override Task PerformOperation() =>
+ Task.FromResult(OperationVeredict.Success);
+
+ public override Task GetOperationIcon() =>
+ Task.FromResult(new Uri("avares://UniGetUI/Assets/package_color.png"));
+ }
+
+ [Fact]
+ public void ReportProgress_DedupesIdenticalReports()
+ {
+ using var op = new ProgressProbeOperation();
+ int events = 0;
+ op.ProgressChanged += (_, _) => events++;
+
+ var first = OperationProgress.FromDownload(50, 100);
+ op.ReportForTests(first);
+ op.ReportForTests(first);
+ op.ReportForTests(OperationProgress.FromDownload(50, 100));
+
+ Assert.Equal(1, events);
+ Assert.Equal(first, op.CurrentProgress);
+ }
+
+ [Fact]
+ public void ReportProgress_RaisesStageChanges()
+ {
+ using var op = new ProgressProbeOperation();
+ var raised = new List();
+ op.ProgressChanged += (_, p) => raised.Add(p);
+
+ op.ReportForTests(OperationProgress.FromDownload(50, 100));
+ op.ReportForTests(OperationProgress.FromInstall(50));
+
+ Assert.Equal(2, raised.Count);
+ Assert.Equal(OperationProgressStage.Downloading, raised[0].Stage);
+ Assert.Equal(OperationProgressStage.Installing, raised[1].Stage);
+ }
+
+ [Fact]
+ public void ResetProgress_ReturnsToUnknown()
+ {
+ using var op = new ProgressProbeOperation();
+ op.ReportForTests(OperationProgress.FromDownload(50, 100));
+ Assert.True(op.CurrentProgress.IsDeterminate);
+
+ op.ResetForTests();
+ Assert.Equal(OperationProgress.Unknown, op.CurrentProgress);
+ }
+
+ [Fact]
+ public async Task OperationRun_ResetsProgressOnRetry()
+ {
+ using var op = new AutoRetryProbeOperation();
+
+ var seen = new List();
+ op.ProgressChanged += (_, p) => seen.Add(p);
+
+ await op.MainThread();
+ Assert.Equal(OperationStatus.Succeeded, op.Status);
+ // First attempt reported 50%, retry reset to Unknown, second attempt succeeded.
+ Assert.Contains(seen, static p => p is { IsDeterminate: true, Percentage: 50 });
+ Assert.Contains(seen, static p => p == OperationProgress.Unknown);
+ }
+
+ private sealed class AutoRetryProbeOperation : ProgressProbeOperation
+ {
+ private int _attempts;
+
+ protected override Task PerformOperation()
+ {
+ _attempts++;
+ if (_attempts == 1)
+ {
+ ReportProgress(OperationProgress.FromDownload(50, 100));
+ return Task.FromResult(OperationVeredict.AutoRetry);
+ }
+ return Task.FromResult(OperationVeredict.Success);
+ }
+ }
+
+ [Fact]
+ public void Formatter_DeterminateDownload_IncludesPercentAndBytes()
+ {
+ var text = OperationProgressFormatter.Format(OperationProgress.FromDownload(50, 100));
+ Assert.Contains("50%", text);
+ }
+
+ [Fact]
+ public void Formatter_IndeterminateInstall_ShowsInstalling()
+ {
+ var text = OperationProgressFormatter.Format(OperationProgress.FromInstall(null));
+ Assert.Contains("Installing", text);
+ }
+
+ [Fact]
+ public void Formatter_Unknown_DoesNotThrow()
+ {
+ var text = OperationProgressFormatter.Format(OperationProgress.Unknown);
+ Assert.False(string.IsNullOrWhiteSpace(text));
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetNativeProgressTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetNativeProgressTests.cs
new file mode 100644
index 0000000000..ed90c85ef0
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/WinGetNativeProgressTests.cs
@@ -0,0 +1,132 @@
+#if WINDOWS
+using Microsoft.Management.Deployment;
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageEngine.Managers.WingetManager;
+
+namespace UniGetUI.PackageEngine.Tests;
+
+public sealed class WinGetNativeProgressTests
+{
+ [Fact]
+ public void MapInstall_Queued_IsQueuedIndeterminate()
+ {
+ var mapped = WinGetProgressMapper.MapInstall(
+ new InstallProgress { State = PackageInstallProgressState.Queued }
+ );
+ Assert.False(mapped.IsDeterminate);
+ Assert.Equal(OperationProgressStage.Queued, mapped.Stage);
+ }
+
+ [Fact]
+ public void MapInstall_Downloading_PrefersBytes()
+ {
+ var mapped = WinGetProgressMapper.MapInstall(
+ new InstallProgress
+ {
+ State = PackageInstallProgressState.Downloading,
+ BytesDownloaded = 326,
+ BytesRequired = 624,
+ DownloadProgress = 10, // bytes win over the reported value
+ }
+ );
+ Assert.True(mapped.IsDeterminate);
+ Assert.Equal(OperationProgressStage.Downloading, mapped.Stage);
+ Assert.Equal(52, Math.Round(mapped.Percentage!.Value));
+ Assert.Equal(326UL, mapped.BytesDownloaded);
+ Assert.Equal(624UL, mapped.BytesTotal);
+ }
+
+ [Fact]
+ public void MapInstall_Downloading_UnknownStaysIndeterminate()
+ {
+ var mapped = WinGetProgressMapper.MapInstall(
+ new InstallProgress
+ {
+ State = PackageInstallProgressState.Downloading,
+ BytesDownloaded = 0,
+ BytesRequired = 0,
+ DownloadProgress = 0,
+ }
+ );
+ Assert.False(mapped.IsDeterminate);
+ Assert.Equal(OperationProgressStage.Downloading, mapped.Stage);
+ }
+
+ [Fact]
+ public void MapInstall_Installing_KnownIsDeterminate()
+ {
+ var mapped = WinGetProgressMapper.MapInstall(
+ new InstallProgress
+ {
+ State = PackageInstallProgressState.Installing,
+ InstallationProgress = 34,
+ }
+ );
+ Assert.True(mapped.IsDeterminate);
+ Assert.Equal(34, mapped.Percentage);
+ Assert.Equal(OperationProgressStage.Installing, mapped.Stage);
+ }
+
+ [Fact]
+ public void MapInstall_Installing_UnknownIsIndeterminate()
+ {
+ // The installer supplied no usable progress: never synthesize a total.
+ var mapped = WinGetProgressMapper.MapInstall(
+ new InstallProgress
+ {
+ State = PackageInstallProgressState.Installing,
+ InstallationProgress = 0,
+ }
+ );
+ Assert.False(mapped.IsDeterminate);
+ Assert.Equal(OperationProgressStage.Installing, mapped.Stage);
+ }
+
+ [Fact]
+ public void MapInstall_PostInstall_IsFinalizingIndeterminate()
+ {
+ var mapped = WinGetProgressMapper.MapInstall(
+ new InstallProgress { State = PackageInstallProgressState.PostInstall }
+ );
+ Assert.False(mapped.IsDeterminate);
+ Assert.Equal(OperationProgressStage.Finalizing, mapped.Stage);
+ }
+
+ [Fact]
+ public void MapInstall_Finished_IsCompleted()
+ {
+ var mapped = WinGetProgressMapper.MapInstall(
+ new InstallProgress { State = PackageInstallProgressState.Finished }
+ );
+ Assert.Equal(OperationProgress.Completed, mapped);
+ }
+
+ [Fact]
+ public void MapUninstall_Uninstalling_KnownIsDeterminate()
+ {
+ var mapped = WinGetProgressMapper.MapUninstall(
+ new UninstallProgress
+ {
+ State = PackageUninstallProgressState.Uninstalling,
+ UninstallationProgress = 75,
+ }
+ );
+ Assert.True(mapped.IsDeterminate);
+ Assert.Equal(OperationProgressStage.Uninstalling, mapped.Stage);
+ }
+
+ [Fact]
+ public void MapUninstall_Uninstalling_UnknownIsIndeterminate()
+ {
+ var mapped = WinGetProgressMapper.MapUninstall(
+ new UninstallProgress
+ {
+ State = PackageUninstallProgressState.Uninstalling,
+ UninstallationProgress = 0,
+ }
+ );
+ Assert.False(mapped.IsDeterminate);
+ Assert.Equal(OperationProgressStage.Uninstalling, mapped.Stage);
+ }
+}
+#endif
From cb18d904371d2d3aa049bf8fdd7ecf239573d8c8 Mon Sep 17 00:00:00 2001
From: c <85012225+Cynrath@users.noreply.github.com>
Date: Thu, 17 Sep 2026 14:51:07 +0300
Subject: [PATCH 2/8] test: cover operation-card progress mapping used by
OperationViewModel
Extract the determinate/indeterminate mapping from OperationViewModel into
OperationCardProgressState so it runs without Avalonia, and keep the ViewModel
as a thin Dispatcher/UI-thread owner that copies the result onto bindable
properties. No visual behavior change.
Add OperationCardProgressStateTests for: running with no/unknown progress,
known download percent plus byte text, unknown fallback, download-to-install
transition, known/unknown install and uninstall, queue/success/fail/cancel
visuals, retry reset without stale leakage, clamping, staged labels, event
wiring via fake operations, and background-thread reporting.
Relates to #1164
---
.../DialogPages/OperationViewModel.cs | 43 +--
.../OperationCardProgressState.cs | 74 ++++
.../OperationCardProgressStateTests.cs | 345 ++++++++++++++++++
3 files changed, 437 insertions(+), 25 deletions(-)
create mode 100644 src/UniGetUI.PackageEngine.Operations/OperationCardProgressState.cs
create mode 100644 src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs
diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
index e683a1ff06..2f8d313243 100644
--- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
+++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
@@ -57,6 +57,8 @@ public sealed partial class OperationViewModel : ViewModelBase
private static readonly Uri _fallbackIconUri =
new("avares://UniGetUI/Assets/package_color.png");
+ private OperationCardProgressState _cardProgress;
+
public OperationViewModel(AbstractOperation operation)
{
Operation = operation;
@@ -70,6 +72,11 @@ public OperationViewModel(AbstractOperation operation)
_buttonText = CoreTools.Translate("Cancel");
_progressBrush = new SolidColorBrush(Color.Parse("#888888"));
_backgroundBrush = Brushes.Transparent;
+ _cardProgress = new OperationCardProgressState(
+ IsIndeterminate: _progressIndeterminate,
+ Value: _progressValue,
+ LiveLine: _liveLine
+ );
_ = LoadIconAsync();
@@ -120,25 +127,16 @@ public OperationViewModel(AbstractOperation operation)
/// existing indeterminate animation; known progress switches to a
/// determinate bar with percent/byte text. Terminal statuses own the
/// final visuals, so progress arriving after completion is ignored.
+ /// The mapping itself lives in
+ /// so it is unit-testable without Avalonia; this method only copies the
+ /// result onto bindable properties on the UI thread.
///
private void ApplyProgress(OperationProgress progress)
{
- if (Operation.Status is not OperationStatus.Running)
- return;
-
- if (progress is null || !progress.IsDeterminate)
- {
- ProgressIndeterminate = true;
- // Keep log-driven LiveLine for plain Unknown resets; only show a
- // stage label when the manager reported a real (but unmeasured) phase.
- if (progress is not null && progress.Stage is not OperationProgressStage.Unknown)
- LiveLine = OperationProgressFormatter.Format(progress);
- return;
- }
-
- ProgressIndeterminate = false;
- ProgressValue = Math.Clamp(progress.Percentage!.Value, 0, 100);
- LiveLine = OperationProgressFormatter.Format(progress);
+ _cardProgress = _cardProgress.WithProgress(Operation.Status, progress);
+ ProgressIndeterminate = _cardProgress.IsIndeterminate;
+ ProgressValue = _cardProgress.Value;
+ LiveLine = _cardProgress.LiveLine;
}
// ── Icon loading ──────────────────────────────────────────────────────────
@@ -181,42 +179,37 @@ private async Task LoadIconAsync()
// ── Status → visual properties ────────────────────────────────────────────
private void ApplyStatus(OperationStatus status)
{
+ _cardProgress = _cardProgress.WithStatus(status);
+ ProgressIndeterminate = _cardProgress.IsIndeterminate;
+ ProgressValue = _cardProgress.Value;
+
switch (status)
{
case OperationStatus.InQueue:
- ProgressIndeterminate = false;
- ProgressValue = 0;
ProgressBrush = new SolidColorBrush(Color.Parse("#888888"));
BackgroundBrush = Brushes.Transparent;
ButtonText = CoreTools.Translate("Cancel");
break;
case OperationStatus.Running:
- ProgressIndeterminate = true;
ProgressBrush = new SolidColorBrush(Color.Parse("#F0A500"));
BackgroundBrush = new SolidColorBrush(Color.FromArgb(30, 240, 165, 0));
ButtonText = CoreTools.Translate("Cancel");
break;
case OperationStatus.Succeeded:
- ProgressIndeterminate = false;
- ProgressValue = 100;
ProgressBrush = new SolidColorBrush(Color.Parse("#0F7B0F"));
BackgroundBrush = new SolidColorBrush(Color.FromArgb(30, 15, 123, 15));
ButtonText = CoreTools.Translate("Close");
break;
case OperationStatus.Failed:
- ProgressIndeterminate = false;
- ProgressValue = 100;
ProgressBrush = new SolidColorBrush(Color.Parse("#BC0000"));
BackgroundBrush = new SolidColorBrush(Color.FromArgb(40, 188, 0, 0));
ButtonText = CoreTools.Translate("Close");
break;
case OperationStatus.Canceled:
- ProgressIndeterminate = false;
- ProgressValue = 100;
ProgressBrush = new SolidColorBrush(Color.Parse("#9D5D00"));
BackgroundBrush = Brushes.Transparent;
ButtonText = CoreTools.Translate("Close");
diff --git a/src/UniGetUI.PackageEngine.Operations/OperationCardProgressState.cs b/src/UniGetUI.PackageEngine.Operations/OperationCardProgressState.cs
new file mode 100644
index 0000000000..03dfaa71a8
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Operations/OperationCardProgressState.cs
@@ -0,0 +1,74 @@
+using UniGetUI.PackageEngine.Enums;
+
+namespace UniGetUI.PackageOperations;
+
+///
+/// Pure, UI-framework-agnostic mapping from operation status plus generic
+/// to operation-card progress visuals.
+/// Extracted from OperationViewModel so the determinate/indeterminate
+/// contract is unit-testable without Avalonia. This type never touches the
+/// dispatcher or any UI control; the ViewModel remains the only UI-thread owner
+/// and simply copies , and
+/// onto its bindable properties.
+///
+public sealed record OperationCardProgressState(
+ bool IsIndeterminate,
+ double Value,
+ string LiveLine
+)
+{
+ ///
+ /// Mirrors OperationViewModel.ApplyStatus for the progress visuals only
+ /// (brushes and button text stay in the ViewModel). Terminal statuses own the
+ /// final visuals with a full bar; queue resets to zero; running keeps the
+ /// current value and shows the indeterminate animation until real progress
+ /// arrives.
+ ///
+ public OperationCardProgressState WithStatus(OperationStatus status) =>
+ status switch
+ {
+ OperationStatus.InQueue => this with { IsIndeterminate = false, Value = 0 },
+ OperationStatus.Running => this with { IsIndeterminate = true },
+ OperationStatus.Succeeded
+ or OperationStatus.Failed
+ or OperationStatus.Canceled => this with { IsIndeterminate = false, Value = 100 },
+ _ => this,
+ };
+
+ ///
+ /// Mirrors OperationViewModel.ApplyProgress. Progress is only honored
+ /// while is ;
+ /// progress arriving after completion is ignored so terminal visuals win and
+ /// stale reports never leak into a later retry (retries reset via
+ /// plus an InQueue
+ /// status, which clears the bar before the next Running phase).
+ /// Unknown progress keeps the indeterminate animation; a real but unmeasured
+ /// phase (for example downloading with no byte totals) still updates the
+ /// status text, while a plain Unknown reset preserves the existing
+ /// log-driven line.
+ ///
+ public OperationCardProgressState WithProgress(
+ OperationStatus status,
+ OperationProgress? progress
+ )
+ {
+ if (status is not OperationStatus.Running)
+ return this;
+
+ if (progress is null || !progress.IsDeterminate)
+ {
+ string liveLine =
+ progress is not null && progress.Stage is not OperationProgressStage.Unknown
+ ? OperationProgressFormatter.Format(progress)
+ : LiveLine;
+ return this with { IsIndeterminate = true, LiveLine = liveLine };
+ }
+
+ return this with
+ {
+ IsIndeterminate = false,
+ Value = Math.Clamp(progress.Percentage!.Value, 0, 100),
+ LiveLine = OperationProgressFormatter.Format(progress),
+ };
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs b/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs
new file mode 100644
index 0000000000..e353091554
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs
@@ -0,0 +1,345 @@
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageOperations;
+
+namespace UniGetUI.PackageEngine.Tests;
+
+///
+/// Direct coverage for the exact mapping OperationViewModel uses for its
+/// operation card (indeterminate vs determinate, percent, byte/status text,
+/// retry/reset, terminal visuals). The mapping lives in
+/// so it runs without Avalonia; the
+/// ViewModel only copies the result onto bindable properties on the UI thread
+/// via Dispatcher.UIThread.Post (verified by inspection of
+/// OperationViewModel.cs).
+///
+public sealed class OperationCardProgressStateTests
+{
+ private static OperationCardProgressState FreshCard(
+ string liveLine = "Please wait..."
+ ) => new OperationCardProgressState(
+ IsIndeterminate: false,
+ Value: 0,
+ LiveLine: liveLine
+ );
+
+ private static OperationCardProgressState RunningCard(
+ string liveLine = "Please wait..."
+ ) => FreshCard(liveLine).WithStatus(OperationStatus.Running);
+
+ [Fact]
+ public void Running_WithNoProgress_StaysIndeterminate()
+ {
+ var card = RunningCard().WithProgress(OperationStatus.Running, null);
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Equal("Please wait...", card.LiveLine);
+ }
+
+ [Fact]
+ public void Running_WithPlainUnknown_PreservesLogDrivenLine()
+ {
+ var card = RunningCard("Downloading installer...")
+ .WithProgress(OperationStatus.Running, OperationProgress.Unknown);
+
+ Assert.True(card.IsIndeterminate);
+ // Plain Unknown resets must not overwrite the log-driven line.
+ Assert.Equal("Downloading installer...", card.LiveLine);
+ }
+
+ [Fact]
+ public void Running_WithKnownDownload_IsDeterminateWithPercentAndBytes()
+ {
+ var card = RunningCard()
+ .WithProgress(OperationStatus.Running, OperationProgress.FromDownload(50, 100));
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(50, card.Value);
+ Assert.Contains("50%", card.LiveLine);
+ // Byte counters are shown when both sides are known.
+ Assert.Contains("/", card.LiveLine);
+ }
+
+ [Fact]
+ public void Running_UnknownAfterDeterminate_ReturnsToIndeterminate()
+ {
+ var card = RunningCard()
+ .WithProgress(OperationStatus.Running, OperationProgress.FromDownload(50, 100));
+
+ Assert.False(card.IsIndeterminate);
+
+ card = card.WithProgress(
+ OperationStatus.Running,
+ OperationProgress.FromDownload(0, 0)
+ );
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Contains("Downloading", card.LiveLine);
+ }
+
+ [Fact]
+ public void DownloadToInstall_Transition_UpdatesStageAndValue()
+ {
+ var card = RunningCard()
+ .WithProgress(OperationStatus.Running, OperationProgress.FromDownload(80, 100));
+
+ Assert.Contains("Downloading", card.LiveLine);
+
+ card = card.WithProgress(
+ OperationStatus.Running,
+ OperationProgress.FromInstall(30)
+ );
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(30, card.Value);
+ Assert.Contains("Installing", card.LiveLine);
+ Assert.Contains("30%", card.LiveLine);
+ }
+
+ [Fact]
+ public void Install_Known_IsDeterminate()
+ {
+ var card = RunningCard()
+ .WithProgress(OperationStatus.Running, OperationProgress.FromInstall(34));
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(34, card.Value);
+ Assert.Contains("Installing", card.LiveLine);
+ }
+
+ [Fact]
+ public void Install_Null_StaysIndeterminateWithoutFakePercent()
+ {
+ var card = RunningCard()
+ .WithProgress(OperationStatus.Running, OperationProgress.FromInstall(null));
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Contains("Installing", card.LiveLine);
+ Assert.DoesNotContain("%", card.LiveLine);
+ }
+
+ [Theory]
+ [InlineData(0.0)]
+ [InlineData(-1.0)]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ public void Install_Unknown_StaysIndeterminateWithoutFakePercent(double reported)
+ {
+ var card = RunningCard()
+ .WithProgress(OperationStatus.Running, OperationProgress.FromInstall(reported));
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Contains("Installing", card.LiveLine);
+ Assert.DoesNotContain("%", card.LiveLine);
+ }
+
+ [Fact]
+ public void Uninstall_Known_IsDeterminate()
+ {
+ var card = RunningCard()
+ .WithProgress(OperationStatus.Running, OperationProgress.FromUninstall(75));
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(75, card.Value);
+ Assert.Contains("Uninstalling", card.LiveLine);
+ }
+
+ [Fact]
+ public void Uninstall_Unknown_IsIndeterminate()
+ {
+ var card = RunningCard()
+ .WithProgress(OperationStatus.Running, OperationProgress.FromUninstall(0));
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Contains("Uninstalling", card.LiveLine);
+ }
+
+ [Fact]
+ public void InQueue_ResetsToZero()
+ {
+ var card = RunningCard()
+ .WithProgress(OperationStatus.Running, OperationProgress.FromDownload(80, 100))
+ .WithStatus(OperationStatus.InQueue);
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(0, card.Value);
+ }
+
+ [Fact]
+ public void Running_Initial_IsIndeterminate()
+ {
+ var card = FreshCard().WithStatus(OperationStatus.Running);
+
+ Assert.True(card.IsIndeterminate);
+ }
+
+ [Theory]
+ [InlineData(OperationStatus.Succeeded)]
+ [InlineData(OperationStatus.Failed)]
+ [InlineData(OperationStatus.Canceled)]
+ public void Terminal_Status_IsFullBar(OperationStatus status)
+ {
+ var card = RunningCard()
+ .WithProgress(OperationStatus.Running, OperationProgress.FromDownload(40, 100))
+ .WithStatus(status);
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(100, card.Value);
+ }
+
+ [Theory]
+ [InlineData(OperationStatus.Succeeded)]
+ [InlineData(OperationStatus.Failed)]
+ [InlineData(OperationStatus.Canceled)]
+ [InlineData(OperationStatus.InQueue)]
+ public void Progress_AfterTerminal_IsIgnored(OperationStatus status)
+ {
+ var card = FreshCard("done").WithStatus(status);
+ var before = card;
+
+ card = card.WithProgress(status, OperationProgress.FromDownload(90, 100));
+
+ Assert.Equal(before, card);
+ }
+
+ [Fact]
+ public void Retry_DoesNotLeakStaleProgress()
+ {
+ // First attempt showed 80%, then completed.
+ var card = RunningCard()
+ .WithProgress(OperationStatus.Running, OperationProgress.FromDownload(80, 100));
+ Assert.Equal(80, card.Value);
+ card = card.WithStatus(OperationStatus.Succeeded);
+ Assert.Equal(100, card.Value);
+
+ // Retry goes through InQueue (resets value to zero) then Running (indeterminate).
+ card = card.WithStatus(OperationStatus.InQueue);
+ Assert.Equal(0, card.Value);
+ Assert.False(card.IsIndeterminate);
+ card = card.WithStatus(OperationStatus.Running);
+ Assert.True(card.IsIndeterminate);
+
+ // The reset Unknown report stays indeterminate with a zero bar: the stale
+ // 80% value never leaks into the new attempt. The text line itself is
+ // log-driven and is refreshed by the next log/progress event.
+ string lineBeforeReset = card.LiveLine;
+ card = card.WithProgress(OperationStatus.Running, OperationProgress.Unknown);
+ Assert.True(card.IsIndeterminate);
+ Assert.Equal(0, card.Value);
+ Assert.Equal(lineBeforeReset, card.LiveLine);
+
+ // New determinate progress starts from zero, not from the stale 80%.
+ card = card.WithProgress(
+ OperationStatus.Running,
+ OperationProgress.FromDownload(10, 100)
+ );
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(10, card.Value);
+ Assert.Contains("10%", card.LiveLine);
+ }
+
+ [Fact]
+ public void Determinate_OutOfRange_IsClamped()
+ {
+ // The record constructor bypasses NormalizePercentage on purpose:
+ // the card must still clamp like the ViewModel did.
+ var over = new OperationProgress(150, null, null, OperationProgressStage.Downloading);
+ var under = new OperationProgress(-20, null, null, OperationProgressStage.Downloading);
+
+ Assert.Equal(100, RunningCard().WithProgress(OperationStatus.Running, over).Value);
+ Assert.Equal(0, RunningCard().WithProgress(OperationStatus.Running, under).Value);
+ }
+
+ [Fact]
+ public void QueuedStage_WithoutPercent_ShowsWaitingText()
+ {
+ var card = RunningCard()
+ .WithProgress(OperationStatus.Running, OperationProgress.Queued);
+
+ Assert.True(card.IsIndeterminate);
+ Assert.False(string.IsNullOrWhiteSpace(card.LiveLine));
+ }
+
+ [Fact]
+ public void Finalizing_WithoutPercent_StaysIndeterminate()
+ {
+ var card = RunningCard()
+ .WithProgress(OperationStatus.Running, OperationProgress.Finalizing);
+
+ Assert.True(card.IsIndeterminate);
+ Assert.Contains("Finalizing", card.LiveLine);
+ }
+
+ private sealed class CardProbeOperation : AbstractOperation
+ {
+ public CardProbeOperation()
+ : base(queue_enabled: false)
+ {
+ Metadata.Status = "probe status";
+ Metadata.Title = "probe title";
+ Metadata.OperationInformation = "probe info";
+ Metadata.SuccessTitle = "probe success";
+ Metadata.SuccessMessage = "probe success";
+ Metadata.FailureTitle = "probe failure";
+ Metadata.FailureMessage = "probe failure";
+ }
+
+ public void ReportForTests(OperationProgress progress) => ReportProgress(progress);
+
+ protected override void ApplyRetryAction(string retryMode) { }
+
+ protected override Task PerformOperation() =>
+ Task.FromResult(OperationVeredict.Success);
+
+ public override Task GetOperationIcon() =>
+ Task.FromResult(new Uri("avares://UniGetUI/Assets/package_color.png"));
+ }
+
+ [Fact]
+ public void ProgressChanged_Event_DrivesCardStateLikeViewModel()
+ {
+ using var op = new CardProbeOperation();
+ var card = FreshCard().WithStatus(OperationStatus.Running);
+
+ // The ViewModel subscribes to ProgressChanged and applies the mapping on
+ // the UI thread; here we apply the same pure mapping directly.
+ op.ProgressChanged += (_, progress) =>
+ {
+ card = card.WithProgress(OperationStatus.Running, progress);
+ };
+
+ op.ReportForTests(OperationProgress.FromDownload(25, 100));
+ Assert.False(card.IsIndeterminate);
+ Assert.Equal(25, card.Value);
+
+ op.ReportForTests(OperationProgress.FromInstall(null));
+ Assert.True(card.IsIndeterminate);
+ Assert.Contains("Installing", card.LiveLine);
+ }
+
+ [Fact]
+ public async Task ReportProgress_FromBackgroundThread_IsSafeForCardMapping()
+ {
+ using var op = new CardProbeOperation();
+ var card = FreshCard().WithStatus(OperationStatus.Running);
+ var gate = new object();
+
+ op.ProgressChanged += (_, progress) =>
+ {
+ // The mapping itself is pure and thread-agnostic; only the property
+ // assignment needs the UI thread in the real ViewModel.
+ var next = card.WithProgress(OperationStatus.Running, progress);
+ lock (gate)
+ card = next;
+ };
+
+ await Task.Run(() =>
+ {
+ for (int i = 1; i <= 10; i++)
+ op.ReportForTests(OperationProgress.FromDownload((ulong)(i * 10), 100));
+ });
+
+ lock (gate)
+ Assert.False(card.IsIndeterminate);
+ }
+}
From f0f1544cfbb2af8f11a60322b8ef4b088b8c60d5 Mon Sep 17 00:00:00 2001
From: c <85012225+Cynrath@users.noreply.github.com>
Date: Thu, 17 Sep 2026 15:28:17 +0300
Subject: [PATCH 3/8] chore: initialize ACKit repository workflow
Real ackit init from clean origin/main: GEMINI and copilot managed shims, 4 builtin skills, ackit.yml (schemaVersion 1), canonical Claude shim. Task TASK-0001.
---
.../ackit-context-optimization/SKILL.md | 23 ++++++++++++++++
.../references/ranking.md | 19 +++++++++++++
.../skills/ackit-policy-authoring/SKILL.md | 23 ++++++++++++++++
.../references/merge-order.md | 12 +++++++++
.agents/skills/ackit-scan-and-fix/SKILL.md | 23 ++++++++++++++++
.../references/severity-playbook.md | 10 +++++++
.agents/skills/ackit-workflow/SKILL.md | 27 +++++++++++++++++++
.../references/task-lifecycle.md | 18 +++++++++++++
.github/copilot-instructions.md | 3 +++
CLAUDE.md | 4 ++-
GEMINI.md | 3 +++
ackit.yml | 22 +++++++++++++++
12 files changed, 186 insertions(+), 1 deletion(-)
create mode 100644 .agents/skills/ackit-context-optimization/SKILL.md
create mode 100644 .agents/skills/ackit-context-optimization/references/ranking.md
create mode 100644 .agents/skills/ackit-policy-authoring/SKILL.md
create mode 100644 .agents/skills/ackit-policy-authoring/references/merge-order.md
create mode 100644 .agents/skills/ackit-scan-and-fix/SKILL.md
create mode 100644 .agents/skills/ackit-scan-and-fix/references/severity-playbook.md
create mode 100644 .agents/skills/ackit-workflow/SKILL.md
create mode 100644 .agents/skills/ackit-workflow/references/task-lifecycle.md
create mode 100644 .github/copilot-instructions.md
create mode 100644 GEMINI.md
create mode 100644 ackit.yml
diff --git a/.agents/skills/ackit-context-optimization/SKILL.md b/.agents/skills/ackit-context-optimization/SKILL.md
new file mode 100644
index 0000000000..864bcc2365
--- /dev/null
+++ b/.agents/skills/ackit-context-optimization/SKILL.md
@@ -0,0 +1,23 @@
+---
+name: ackit-context-optimization
+description: Build budgeted context packs and trim instruction bloat for coding agents.
+---
+
+# Context Optimization
+
+Activate when the user asks to prepare context for an agent session or reduce
+token usage.
+
+See [ranking signals](references/ranking.md) for deterministic ordering.
+
+## Steps
+
+1. `ackit pack --max-tokens [--format markdown|json] [--include ] [--changed] [--profile codex|claude|copilot|gemini|generic] [--task ] [--resume]` to generate a deterministic pack with a manifest of included/excluded files and reasons (`relativePath`, `action`, `reason`, `estimatedTokens`, `sha256`, `bytes`). Task-aware ranking (`--task`) and checkpoint resume (`--resume`) keep packs scoped; greedy fill excludes over-budget candidates with `budget exhausted`.
+2. Review exclusions; add explicit includes only when the ranking missed real relevance (explicit include is the highest signal; no embeddings).
+3. For bloated instructions, run `ackit optimize [--profile ] [--category ] [--min-severity low|medium|high] [--explain] [--format terminal|json|markdown|sarif]` (read-only by default) and apply suggestions manually or with `ackit optimize --fix [--dry-run] [--diff]` — fixes apply ONLY to ACKit-managed surfaces.
+
+## Notes
+
+- Token counts are character-class estimates (~4 chars/token); treat budgets as soft targets.
+- Safety gates run before scoring: secret-shaped content excluded, duplicate hashes deduped, machine-local paths scrubbed to ``.
+- Never paste repository content into external services.
diff --git a/.agents/skills/ackit-context-optimization/references/ranking.md b/.agents/skills/ackit-context-optimization/references/ranking.md
new file mode 100644
index 0000000000..7b7dde980f
--- /dev/null
+++ b/.agents/skills/ackit-context-optimization/references/ranking.md
@@ -0,0 +1,19 @@
+# Ranking signals
+
+Precedence: explicit include > changed files > active-task references >
+instruction scope > import proximity > README/architecture relevance >
+file type, with a size penalty. Deterministic; no embeddings.
+
+Weights: explicit include `+100`, git-changed `+60`, active-task reference
+`+50`, instruction scope `+40`, import proximity `+30`,
+README/architecture/docs `+20`, type base (md `10`, code `8`, config `6`,
+other `2`), size penalty `-5` per 4KB capped `-40`. Ties break by ascending
+repo-relative path.
+
+Budget: greedy fill over the ranked list; over-budget candidates are excluded
+with `budget exhausted`. Every manifest entry records `relativePath`,
+`action` (`included`/`excluded`/`scrubbed`), `reason`, `estimatedTokens`,
+`sha256`, `bytes`. Safety gates run before scoring: secret-shaped content
+excluded, duplicate content-hash deduped, machine-local paths scrubbed.
+`--task` boosts declared scope/refs/changed files; `--resume` embeds the
+latest checkpoint resume section.
diff --git a/.agents/skills/ackit-policy-authoring/SKILL.md b/.agents/skills/ackit-policy-authoring/SKILL.md
new file mode 100644
index 0000000000..180e139194
--- /dev/null
+++ b/.agents/skills/ackit-policy-authoring/SKILL.md
@@ -0,0 +1,23 @@
+---
+name: ackit-policy-authoring
+description: Author layered ackit policy files with deterministic merge, scoping and lockable rules.
+---
+
+# Policy Authoring
+
+Activate when the user asks to codify scan thresholds or team-wide rule
+overrides.
+
+See [merge order](references/merge-order.md) for precedence and digest.
+
+## Steps
+
+1. Start from `ackit.yml`; add a policy layer with `schemaVersion: 1` and `extends` (local repo-relative files or `npm:/` for already-installed packages only — resolution is offline by construction, remote fetch is refused with `POL-OFFLINE-BLOCKED`).
+2. Scope overrides with `org` / `repo` / `pathScopes`; lock security-relevant rules with `locked: true` so downstream layers cannot weaken them (`POL-LOCKED-CONFLICT`; deny is sticky across layers). Suppressions require `reason` and support `expiresAt`.
+3. Verify with `ackit policy check` (chain plus digest plus autonomy plus review plus problems) and `ackit config check` (schema validation). Risk tiers (`tier0 allow` through `tier4 deny`) enforce ONLY at ACKit-owned boundaries (`task complete --force`, `checkpoint export`, `verification record`): explicit `deny` refuses with `POLICY-TIER-DENIED` (exit 4); explicit `ask` in a non-interactive context denies. Optional `review:` (`required` dimensions plus `blockingSeverity`) gates `PASS`-family verdicts via `VERDICT_BLOCKING`.
+
+## Notes
+
+- Remote URL auto-fetch is forbidden; dependencies must be pre-installed.
+- Deterministic merge: defaults < `ackit.yml` < policy extends chain (declaration order) < CLI flags; arrays replace, objects merge; digest is sha256 over sorted-key JSON.
+- Repositories without `autonomy:` / `review:` see zero behavior change.
diff --git a/.agents/skills/ackit-policy-authoring/references/merge-order.md b/.agents/skills/ackit-policy-authoring/references/merge-order.md
new file mode 100644
index 0000000000..f55983a4a3
--- /dev/null
+++ b/.agents/skills/ackit-policy-authoring/references/merge-order.md
@@ -0,0 +1,12 @@
+# Merge order
+
+defaults < ackit.yml < policy extends chain (in declaration order) < CLI
+flags. Arrays replace; objects merge recursively. The effective policy digest
+is sha256 over the canonical sorted-key JSON.
+
+`extends` entries are local repo-relative files or `npm:/` for
+already-installed packages only; remote fetch is refused
+(`POL-OFFLINE-BLOCKED`), traversal outside the root is refused. Locked rules
+(`locked: true`) cannot be weakened downstream (`POL-LOCKED-CONFLICT`); deny
+is sticky across layers. `ackit policy check` prints chain, digest, autonomy,
+review, and problems; `ackit config check` validates `ackit.yml` schema.
diff --git a/.agents/skills/ackit-scan-and-fix/SKILL.md b/.agents/skills/ackit-scan-and-fix/SKILL.md
new file mode 100644
index 0000000000..a246aa3876
--- /dev/null
+++ b/.agents/skills/ackit-scan-and-fix/SKILL.md
@@ -0,0 +1,23 @@
+---
+name: ackit-scan-and-fix
+description: Run ACKit scan, interpret findings by severity, and apply safe fixes with suppression hygiene.
+---
+
+# Scan and Fix
+
+Activate when the user asks to scan, audit, or clean up the repository.
+
+See [severity playbook](references/severity-playbook.md) for triage order.
+
+## Steps
+
+1. `ackit scan` (add `--ci` in CI contexts; `--format terminal|json|sarif|markdown|html`, `--baseline ` / `--write-baseline `, `--changed` / `--staged` / `--since [` / `--range ` for incremental sets) and read findings grouped by severity (`docs/reference/rules.md`).
+2. Fix critical/high first: rotate exposed credentials out-of-band, remove keys, correct root-escape references. Values are never printed; do not paste findings into external services.
+3. Suppress false positives ONLY inline with `# ackit-ignore:ACKITnnn ` on the finding line or the line above (covers that line plus the next line); every applied bypass stays visible as a non-suppressible `ACKIT099` advisory. Policy-level suppressions require `reason` and support `expiresAt`; locked rules (`locked: true`) can never be weakened (`POL-LOCKED-CONFLICT`).
+4. Re-scan (`ackit scan --ci`) and confirm exit 0 or an explicit accepted-risk list. Confirm offline policy with `ackit policy check` / `ackit config check` where thresholds or extends changed.
+
+## Notes
+
+- Unknown-extension files are always scanned; do not "fix" by renaming secrets away.
+- Never weaken rules, thresholds, or baselines to make output green.
+- Resolution is offline by construction; remote fetches never happen.
diff --git a/.agents/skills/ackit-scan-and-fix/references/severity-playbook.md b/.agents/skills/ackit-scan-and-fix/references/severity-playbook.md
new file mode 100644
index 0000000000..2cf9a27976
--- /dev/null
+++ b/.agents/skills/ackit-scan-and-fix/references/severity-playbook.md
@@ -0,0 +1,10 @@
+# Severity playbook
+
+- critical: credential/token exposure, private keys (`ACKIT001`, `ACKIT002`) — rotate first, then clean.
+- high: connection strings, generic credential assignments, root escapes (`ACKIT003`, `ACKIT004`, instruction-graph escapes) — fix next.
+- medium: entropy advisories (confirm before acting), config drift, path leaks (`ACKIT005`, `ACKIT010`, `ACKIT050`, `ACKIT070`, `ACKIT080`).
+- low: hygiene markers, large context files, near-duplicates (`ACKIT020`, `ACKIT040`); every applied bypass emits `ACKIT099` (low/hygiene, not suppressible).
+
+Inline suppression is `# ackit-ignore:ACKITnnn ` on the finding line
+or the line above (that line plus the next). File excludes come from config
+`scan.exclude`; policy suppressions need `reason` and support `expiresAt`.
diff --git a/.agents/skills/ackit-workflow/SKILL.md b/.agents/skills/ackit-workflow/SKILL.md
new file mode 100644
index 0000000000..1101a29650
--- /dev/null
+++ b/.agents/skills/ackit-workflow/SKILL.md
@@ -0,0 +1,27 @@
+---
+name: ackit-workflow
+description: Enforce the ACKit docs-first, task-first workflow with one active checklist item and evidence-based completion.
+---
+
+# ACKit Workflow
+
+Activate for any repository work session so tasks stay auditable.
+
+See [task lifecycle](references/task-lifecycle.md) for statuses, gates, and archive rules.
+
+## Steps
+
+1. Discover work with `ackit task list` / `ackit task show `; active work lives in `docs/tasks/active/` (completed work lives in `docs/tasks/archive/` and is resolved by ID, never treated as open). If none matches, create one with `ackit task create "" [--intent INTENT-####] [--spec ] [--decision ] [--plan ]` (never invent IDs).
+2. Keep exactly one `[~]` active checklist item; implement only that item; plan before code.
+3. For workflow-enabled tasks, honor the declared profile: `ackit workflow set --profile quick|standard|high-risk`, `ackit workflow show `, `ackit workflow advance `, `ackit workflow verify --outcome pass|fail`. Provide `intentRef`/`specRefs`/`decisionRefs`/`planRef` when the stage requires them; referenced files must exist on disk.
+4. Checkpoint long work: `ackit checkpoint create --next-objective ""`, `ackit checkpoint show `, `ackit checkpoint validate `, `ackit checkpoint export [--out ]`; resume with `ackit task resume `.
+5. Link proof, do not assert it: `ackit evidence sync `, `ackit evidence verify --criterion AC-001 --type test --ref ""`, `ackit evidence validate `; independent check via `ackit verification bundle ` then `ackit verification record --verdict ` / `ackit verification show `; watch drift with `ackit drift check ` (`ackit drift check-active` at the pre-commit gate).
+6. Complete only through the composed gate: evidence complete, required verdict `PASS`/`PASS_WITH_WARNINGS` with zero blocking findings, stage complete, no unresolved `fail` attempt, no blocking drift. `VERIFY failed -> completed` is impossible without explicit `ackit task complete --force` (tier2 boundary). Never mark `[x]` without command output in Completion notes.
+7. Archive after final evidence: `ackit task archive ` (bulk `ackit task archive --completed [--dry-run]` moves completed-only; pending/active/blocked never move). `ackit task doctor` reports `TASK-COMPLETED-IN-ACTIVE` for completed work left in `active/`.
+8. Gates before commit: `ackit doctor`, `ackit task doctor`, `ackit scan --ci`; run the task's own test plan and paste pass/fail counts into Completion notes; focused Conventional Commit, then immediately continue with the next dependency-ready task.
+
+## Notes
+
+- Unfinished work is never marked complete; checkpoint commits are fine.
+- Out-of-scope requests become new tasks instead of scope creep.
+- Tasks without workflow state keep pre-expansion behavior; workflow tasks enforce the gate.
diff --git a/.agents/skills/ackit-workflow/references/task-lifecycle.md b/.agents/skills/ackit-workflow/references/task-lifecycle.md
new file mode 100644
index 0000000000..d40ca3a232
--- /dev/null
+++ b/.agents/skills/ackit-workflow/references/task-lifecycle.md
@@ -0,0 +1,18 @@
+# Task lifecycle
+
+Statuses: `[ ]` pending · `[~]` active · `[x]` completed+verified · `[!]` blocked.
+
+Exactly one `[~]` item at a time. Never mark `[x]` without command output as
+evidence in Completion notes. Blocked items stay visible with their blocker;
+they are never silently skipped.
+
+Active work lives in `docs/tasks/active/`; completed work lives in
+`docs/tasks/archive/` and is resolved by ID (`ackit task show `,
+`ackit task list --all`). Archived completed tasks are not open work.
+
+Workflow tasks complete only through the composed gate (evidence complete,
+required verdict `PASS`/`PASS_WITH_WARNINGS`, stage complete, no unresolved
+`fail`, no blocking drift). `ackit task doctor` reports
+`TASK-COMPLETED-IN-ACTIVE` for completed work left in `active/`; archive it
+with `ackit task archive ` (bulk `ackit task archive --completed
+[--dry-run]` moves completed-only).
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000000..f4deda1514
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,3 @@
+
+Read and follow AGENTS.md in the repository root before acting.
+
diff --git a/CLAUDE.md b/CLAUDE.md
index 47dc3e3d86..92bee8b80a 120000
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1 +1,3 @@
-AGENTS.md
\ No newline at end of file
+
+@AGENTS.md
+
diff --git a/GEMINI.md b/GEMINI.md
new file mode 100644
index 0000000000..80cbfc539f
--- /dev/null
+++ b/GEMINI.md
@@ -0,0 +1,3 @@
+
+Read and follow AGENTS.md in the repository root before acting.
+
diff --git a/ackit.yml b/ackit.yml
new file mode 100644
index 0000000000..83bf420e53
--- /dev/null
+++ b/ackit.yml
@@ -0,0 +1,22 @@
+# ACKit repository configuration for UniGetUI.
+# Validated with: ackit config check
+# Docs: docs/ACKIT.md
+schemaVersion: 1
+scan:
+ severityThreshold: low
+ exclude:
+ - "**/bin/**"
+ - "**/obj/**"
+ - "**/out/**"
+ - "**/outpublish/**"
+ - "generated/**"
+context:
+ maxTokens: 50000
+readiness:
+ strictThreshold: 80
+workflow:
+ defaultProfile: standard
+policy:
+ rulePacks:
+ - .agents/policy/unigetui-policy.json
+baseline: docs/ackit/scan-baseline.json
From a6ea6ed509b2a9655ce36ff9adbad66c2541cd64 Mon Sep 17 00:00:00 2001
From: c <85012225+Cynrath@users.noreply.github.com>
Date: Thu, 17 Sep 2026 15:28:51 +0300
Subject: [PATCH 4/8] docs: add UniGetUI agent instructions and skills
AGENTS extended with formatting, Windows guards, localization, task/evidence, git hygiene, CI, ACKit workflow, completion criteria. 6 custom skills, translation-source-sync strict fix, policy pack, docs/ACKIT.md, TASK-0001, scan baseline (71 pre-existing).
---
.agents/policy/unigetui-policy.json | 32 +
.agents/skills/ackit-repo-workflow/SKILL.md | 49 ++
.agents/skills/avalonia-ui/SKILL.md | 27 +
.agents/skills/dotnet-build-test/SKILL.md | 49 ++
.agents/skills/github-pr-ci/SKILL.md | 27 +
.../package-manager-integration/SKILL.md | 33 +
.../skills/translation-source-sync/SKILL.md | 2 +-
.agents/skills/winget-native/SKILL.md | 27 +
AGENTS.md | 78 +++
docs/ACKIT.md | 100 +++
docs/ackit/scan-baseline.json | 575 ++++++++++++++++++
...egrate-ackit-as-unigetui-agent-workflow.md | 95 +++
12 files changed, 1093 insertions(+), 1 deletion(-)
create mode 100644 .agents/policy/unigetui-policy.json
create mode 100644 .agents/skills/ackit-repo-workflow/SKILL.md
create mode 100644 .agents/skills/avalonia-ui/SKILL.md
create mode 100644 .agents/skills/dotnet-build-test/SKILL.md
create mode 100644 .agents/skills/github-pr-ci/SKILL.md
create mode 100644 .agents/skills/package-manager-integration/SKILL.md
create mode 100644 .agents/skills/winget-native/SKILL.md
create mode 100644 docs/ACKIT.md
create mode 100644 docs/ackit/scan-baseline.json
create mode 100644 docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md
diff --git a/.agents/policy/unigetui-policy.json b/.agents/policy/unigetui-policy.json
new file mode 100644
index 0000000000..fe0a8118ff
--- /dev/null
+++ b/.agents/policy/unigetui-policy.json
@@ -0,0 +1,32 @@
+{
+ "schemaVersion": 1,
+ "packId": "unigetui-policy",
+ "namespace": "unigetui",
+ "version": "1.0.0",
+ "displayName": "UniGetUI repository policy",
+ "description": "Deterministic presence checks for ACKit workflow, instructions, and localization guidance.",
+ "severity": "medium",
+ "rules": [
+ {
+ "id": "unigetui:ackit-config",
+ "type": "presence",
+ "glob": "ackit.yml",
+ "message": "ackit.yml must exist at repository root",
+ "remediation": "Restore ackit.yml and run ackit config check"
+ },
+ {
+ "id": "unigetui:agents-instructions",
+ "type": "presence",
+ "glob": "AGENTS.md",
+ "message": "AGENTS.md must exist at repository root",
+ "remediation": "Restore AGENTS.md canonical instructions"
+ },
+ {
+ "id": "unigetui:ackit-docs",
+ "type": "presence",
+ "glob": "docs/ACKIT.md",
+ "message": "docs/ACKIT.md must exist for contributor and agent guidance",
+ "remediation": "Restore docs/ACKIT.md"
+ }
+ ]
+}
diff --git a/.agents/skills/ackit-repo-workflow/SKILL.md b/.agents/skills/ackit-repo-workflow/SKILL.md
new file mode 100644
index 0000000000..5b75b9393c
--- /dev/null
+++ b/.agents/skills/ackit-repo-workflow/SKILL.md
@@ -0,0 +1,49 @@
+---
+name: ackit-repo-workflow
+description: Run the UniGetUI ACKit start-of-task sequence, lifecycle gates, scans, readiness, packs, and evidence correctly. Use at task start, during work, and before completion.
+---
+
+# ackit repo workflow
+
+Use for every UniGetUI task so ACKit gates stay green.
+
+## Start of task
+
+```powershell
+ackit instructions --explain
+ackit task list
+ackit task show TASK-0001
+ackit pack --profile codex --max-tokens 50000
+```
+
+Keep one active task with a single `[~]` item. Implementation lives under `docs/tasks/active/`; archive lives under `docs/tasks/archive/`.
+
+## During work
+
+```powershell
+ackit scan --changed
+ackit scan --staged
+```
+
+Boost or limit packs with `ackit pack --changed` and task-aware `ackit pack --task TASK-0001` when needed.
+
+## Before done
+
+```powershell
+ackit config check
+ackit policy check
+ackit skills validate
+ackit task doctor
+ackit scan --ci
+ackit readiness --strict
+ackit optimize --explain
+ackit diagnostics --json
+```
+
+Record exact outputs in task Completion notes. Complete only with verified evidence; archive with `ackit task archive ` after final proof.
+
+## References
+
+- Full contributor and agent guide is `docs/ACKIT.md`.
+- Task lifecycle details are in the [ackit-workflow](../ackit-workflow/SKILL.md) builtin skill.
+- Scan triage uses the [ackit-scan-and-fix](../ackit-scan-and-fix/SKILL.md) builtin skill.
diff --git a/.agents/skills/avalonia-ui/SKILL.md b/.agents/skills/avalonia-ui/SKILL.md
new file mode 100644
index 0000000000..ee621cda40
--- /dev/null
+++ b/.agents/skills/avalonia-ui/SKILL.md
@@ -0,0 +1,27 @@
+---
+name: avalonia-ui
+description: Implement UniGetUI Avalonia UI changes with correct View/ViewModel conventions, UI-thread dispatch, bindings, themes, and accessibility. Use when editing AXAML, controls, or app shell behavior.
+---
+
+# avalonia ui
+
+Use for UniGetUI Avalonia work under `src/UniGetUI.Avalonia/`.
+
+## Conventions
+
+- Keep View/ViewModel separation; Views bind, ViewModels own state. Follow existing `Views/MainWindow.axaml` and `Program.cs` patterns.
+- Dispatch UI updates on the UI thread; never touch visual state from background package-manager threads directly.
+- Prefer compiled bindings and existing theme/resources; do not fork new theme dictionaries for one screen.
+- Localize every user-facing string with `CoreTools.Translate`; in XAML use the `TranslatedTextBlock` control.
+
+## Checks
+
+- Keep Avalonia diagnostics gating intact: `EnableAvaloniaDiagnostics` in `src/Directory.Build.props`, compile gate `#if AVALONIA_DIAGNOSTICS_ENABLED` in `Program.cs`, runtime precedence CLI flags then `UNIGETUI_AVALONIA_DEVTOOLS` then `Auto`.
+- Keep `Auto` WSL-safe (DevTools off by default on WSL); runtime toggle without build support logs a no-op warning.
+- Verify XAML compiles via the relevant build in the [dotnet-build-test](../dotnet-build-test/SKILL.md) skill; run targeted tests before the full suite.
+- Check keyboard navigation, contrast, and screen-reader names for new controls.
+
+## Out of scope
+
+- Package-manager logic belongs in the [package-manager-integration](../package-manager-integration/SKILL.md) skill.
+- WinGet COM specifics belong in the [winget-native](../winget-native/SKILL.md) skill.
diff --git a/.agents/skills/dotnet-build-test/SKILL.md b/.agents/skills/dotnet-build-test/SKILL.md
new file mode 100644
index 0000000000..e9713df19d
--- /dev/null
+++ b/.agents/skills/dotnet-build-test/SKILL.md
@@ -0,0 +1,49 @@
+---
+name: dotnet-build-test
+description: Build and test UniGetUI .NET/Avalonia solutions safely on Windows x64 with evidence-based failure classification. Use when building, testing, or validating formatting for UniGetUI C# changes.
+---
+
+# dotnet build test
+
+Use for any UniGetUI C# build, test, or format check. Windows-first, x64, .NET 10.
+
+## Solutions
+
+- `src/UniGetUI.Windows.slnx` is the official Windows solution.
+- `src/UniGetUI.Avalonia.slnx` is the cross-platform Avalonia solution.
+- Target framework is `net10.0-windows10.0.26100.0` (min `10.0.19041`); tests use xUnit.
+
+## Commands
+
+Restore and test from `src/`:
+
+```powershell
+dotnet restore UniGetUI.Windows.slnx
+dotnet test UniGetUI.Windows.slnx --verbosity q --nologo /p:Platform=x64
+```
+
+Read-only format gates (never mutate blindly):
+
+```powershell
+dotnet format whitespace src --folder --verify-no-changes
+dotnet format style UniGetUI.Windows.slnx --no-restore --verify-no-changes
+```
+
+## Rules
+
+- Do not run a broad mutating `dotnet format` across the solution. Use the verified whitespace/style verify commands and inspect the diff.
+- Run the repo pre-commit hook setup once after cloning with `pwsh ./scripts/install-git-hooks.ps1`.
+- Build affected projects first, then the full Windows solution; run targeted tests before the full suite.
+- Treat every `IL2xxx` trim and `IL3xxx` AOT warning as a defect; do not blanket-suppress.
+
+## Failure classification
+
+- Baseline first: capture failing tests on clean `origin/main` before attributing failures to the change.
+- Classify as pre-existing only with matching baseline output; otherwise treat as regression.
+- Record exact commands plus pass/fail counts in task Completion notes.
+
+## Completion gate
+
+- Relevant whitespace/style verify passes.
+- Relevant build passes on x64.
+- Relevant tests pass; full suite pass or baseline-classified failures documented.
diff --git a/.agents/skills/github-pr-ci/SKILL.md b/.agents/skills/github-pr-ci/SKILL.md
new file mode 100644
index 0000000000..d4f8f09851
--- /dev/null
+++ b/.agents/skills/github-pr-ci/SKILL.md
@@ -0,0 +1,27 @@
+---
+name: github-pr-ci
+description: Keep UniGetUI branches clean, open focused PRs, and investigate CI without pushing local main. Use when branching, pushing to fork, or debugging dotnet-test and ACKit workflows.
+---
+
+# github pr ci
+
+Use for branch hygiene, fork workflow, and CI triage. Upstream is `Devolutions/UniGetUI` as `origin`; fork is `Cynrath/UniGetUI` as `fork`.
+
+## Branch hygiene
+
+- Branch from clean `origin/main`; never merge local `main` or unrelated feature branches.
+- Keep one logical change per branch; use the PR template and link issues without placeholders.
+- Never push local `main` upstream. Push feature branches to `fork` for review.
+- For rebased branches use `git push --force-with-lease`; never force-push `main`.
+
+## CI triage
+
+- `dotnet-test` runs whitespace/style verify, Windows x64 build, tests, full-trim and NativeAOT publish reports.
+- ACKit workflow runs `ackit config check`, `ackit policy check`, `ackit skills validate`, `ackit task doctor`, `ackit scan --ci`, `ackit readiness --strict`.
+- Pull logs first; classify as infra flake only with rerun evidence. Do not weaken thresholds to get green.
+- Validate locally with the same commands before pushing.
+
+## Approval boundary
+
+- Fork workflow approval may be required before CI runs on new branches; request review rather than pushing workarounds.
+- Do not open an upstream PR merely to create one; keep dogfooding branches on the fork with a written reason.
diff --git a/.agents/skills/package-manager-integration/SKILL.md b/.agents/skills/package-manager-integration/SKILL.md
new file mode 100644
index 0000000000..f36270329b
--- /dev/null
+++ b/.agents/skills/package-manager-integration/SKILL.md
@@ -0,0 +1,33 @@
+---
+name: package-manager-integration
+description: Extend UniGetUI package managers correctly via PackageManager base, helper abstractions, and capability contracts. Use when adding or fixing a WinGet, Scoop, Chocolatey, Pip, npm, or other manager integration.
+---
+
+# package manager integration
+
+Use when touching `src/UniGetUI.PackageEngine.Managers.*` or shared engine contracts.
+
+## Reference implementation
+
+- Follow `src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs` as the clean example.
+- Extend `PackageManager`; override `FindPackages_UnSafe`, `GetAvailableUpdates_UnSafe`, `GetInstalledPackages_UnSafe`.
+- Provide three helpers in `Helpers/`: details helper from `BasePkgDetailsHelper`, operation helper from `BasePkgOperationHelper`, source helper from `BaseSourceHelper`.
+- Set `Capabilities` and `Properties` in the constructor and wire the helpers.
+
+## Contracts
+
+- Program against `IPackageManager`, `IPackage`, `IManagerSource`, `IPackageDetails`; do not leak manager-specific types into generic UI.
+- Filter CLI noise with `FALSE_PACKAGE_NAMES`, `FALSE_PACKAGE_IDS`, `FALSE_PACKAGE_VERSIONS`.
+- Follow `Initialize` flow: executable file, version, extra loading steps.
+- Return `OperationVeredict` (codebase spelling) for fallible operations.
+
+## Behavior
+
+- Support cancellation, retry, and explicit errors; provide native/CLI fallback where the manager supports it.
+- Pick the version comparator the registry uses: default numeric, `SemanticVersion` for SemVer/NuGet ecosystems, `PythonVersion` for PEP 440. Wrong choice hides updates or offers downgrades.
+- Localize user-facing strings; log via `UniGetUI.Core.Logging`.
+
+## Verification
+
+- Build affected manager plus the Windows solution per the [dotnet-build-test](../dotnet-build-test/SKILL.md) skill.
+- Cover search, installed, updates, details, operations, and sources for the touched manager.
diff --git a/.agents/skills/translation-source-sync/SKILL.md b/.agents/skills/translation-source-sync/SKILL.md
index 0a8a3c9404..33bcd71e0d 100644
--- a/.agents/skills/translation-source-sync/SKILL.md
+++ b/.agents/skills/translation-source-sync/SKILL.md
@@ -5,7 +5,7 @@ description: Synchronizes the UniGetUI English language file with source-code us
# translation source sync
-Use this skill when UniGetUI source code changed and you need to keep [src/Languages/lang_en.json](src/Languages/lang_en.json) aligned with the strings actually used by the application, or when you need downstream locale files reordered to match the English key ordering.
+Use this skill when UniGetUI source code changed and you need to keep [src/Languages/lang_en.json](../../../src/Languages/lang_en.json) aligned with the strings actually used by the application, or when you need downstream locale files reordered to match the English key ordering.
It scans supported C#, WinUI XAML, and Avalonia AXAML patterns, finds missing English keys, removes unused entries, reports translation-source warnings that still need manual cleanup, and can align locale file ordering to the English layout.
diff --git a/.agents/skills/winget-native/SKILL.md b/.agents/skills/winget-native/SKILL.md
new file mode 100644
index 0000000000..19c711c2c5
--- /dev/null
+++ b/.agents/skills/winget-native/SKILL.md
@@ -0,0 +1,27 @@
+---
+name: winget-native
+description: Work safely on UniGetUI WinGet native integration via Microsoft.Management.Deployment with CLI fallback. Use when changing WinGet COM paths, install options, sources, or Windows guards.
+---
+
+# winget native
+
+Use for `src/UniGetUI.PackageEngine.Managers.WinGet/*` native paths.
+
+## Rules
+
+- Prefer `Microsoft.Management.Deployment` native COM paths where available; keep CLI fallback for unsupported operations and startup failures.
+- Handle install options, source selection, and version pinning through existing manager helpers; do not bypass capability contracts.
+- Keep Windows-only code behind Windows guards; never break the cross-platform Avalonia solution.
+- Treat source URLs and executable resolution as untrusted input; validate before use.
+
+## Safe verification
+
+- Verify on Windows with WinGet available; test both native path and forced CLI fallback.
+- Cover source add/remove, install options, version handling, and fallback when the selected WinGet executable cannot start.
+- Follow the generic manager contracts in the [package-manager-integration](../package-manager-integration/SKILL.md) skill.
+- Build and test per the [dotnet-build-test](../dotnet-build-test/SKILL.md) skill; record native versus fallback evidence.
+
+## Out of scope
+
+- Generic manager scaffolding belongs in package-manager-integration.
+- UI surfacing belongs in the [avalonia-ui](../avalonia-ui/SKILL.md) skill.
diff --git a/AGENTS.md b/AGENTS.md
index aebc6e914d..6bf848e5d1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,3 +1,11 @@
+
+# ACKit canonical workflow
+
+- Docs-first, task-first: keep one active task under docs/tasks/ with a single `[~]` checklist item.
+- Complete tasks only with recorded evidence; then continue with the next dependency-ready task.
+- Run `ackit doctor` and `ackit scan --ci` as standing quality gates.
+- Offline-first: never send repository content to external services.
+
# UniGetUI - Copilot Instructions
## Project Overview
@@ -208,3 +216,73 @@ Use `CoreTools.Translate("text")` for all user-facing strings. Parameterized: `C
| Setting keys | `src/UniGetUI.Core.Settings/SettingsEngine_Names.cs` |
| Logger | `src/UniGetUI.Core.Logger/Logger.cs` |
| CI test workflow | `.github/workflows/dotnet-test.yml` |
+
+## Formatting discipline
+
+Do not run a broad mutating `dotnet format` blindly across the solution.
+Use repository-verified style/whitespace commands and inspect the diff.
+
+```powershell
+pwsh ./scripts/install-git-hooks.ps1
+dotnet format whitespace src --folder --verify-no-changes
+dotnet format style src/UniGetUI.Windows.slnx --no-restore --verify-no-changes
+```
+
+The pre-commit hook formats only staged files under `src` when `dotnet` is available. If it rewrites files, review and commit again. Rules live in `src/.editorconfig`.
+
+## Windows conditional compilation
+
+- Windows-only integrations (WinGet COM via `Microsoft.Management.Deployment`, registry, AppSDK paths) must stay behind Windows guards; the Avalonia solution still builds cross-platform.
+- Prefer `OperatingSystem.IsWindows()` and Windows TFM conditions over scattering `#if WINDOWS` unless the project already uses that pattern.
+- Never break `src/UniGetUI.Avalonia.slnx` for a Windows-only optimization.
+
+## Localization
+
+- All user-facing strings go through `CoreTools.Translate("text")`; never hardcode English in Views/ViewModels.
+- Translation source of truth is `src/Languages/lang_en.json`; locale files are reordered to match English key order.
+- Use `translation-source-sync` skill after changing translatable strings, then `Verify-Translations.ps1`.
+- Watch for tr-TR/invariant casing hazards (`ToUpper`/`ToLower` without explicit culture) and placeholder parity (`{0}`, HTML fragments, newlines).
+
+## Task and evidence workflow
+
+- One active ACKit task under `docs/tasks/active/` with a single `[~]` item; create via `ackit task create ""`, start via `ackit task start `.
+- Link proof, do not assert it: `ackit evidence sync/verify/validate`, independent `ackit verification bundle/record` when the profile requires it.
+- Gates before commit: `ackit doctor`, `ackit task doctor`, `ackit scan --ci`.
+- See `docs/ACKIT.md` for the full start-of-task sequence and completion gate.
+
+## Git hygiene
+
+- Branch from clean `origin/main`; never merge local `main` or unrelated feature branches.
+- Keep PRs focused; do not leave the tree non-buildable between commits.
+- Never push local `main` upstream. Prefer `--force-with-lease` for rebased feature branches only.
+- Upstream is `Devolutions/UniGetUI` (`origin`); personal fork is `Cynrath/UniGetUI` (`fork`).
+
+## Generated files
+
+- Do not hand-edit: `generate-secrets.ps1` output, integrity tree (`scripts/generate-integrity-tree.ps1`), `TranslatedPercentages.json`, `bin/obj/out` outputs.
+- Edit owning sources/templates (`scripts/translation/*`, `src/Languages/lang_en.json`) rather than derived artifacts.
+
+## CI expectations
+
+- `.github/workflows/dotnet-test.yml` runs whitespace/style checks, Windows x64 build, tests, full-trim and NativeAOT publish reports.
+- `.github/workflows/ackit.yml` runs `ackit config check`, `ackit policy check`, `ackit skills validate`, `ackit task doctor`, `ackit scan --ci`, `ackit readiness --strict`.
+- Investigate CI failures from logs; do not weaken thresholds to get green.
+
+## ACKit workflow
+
+- Config: `ackit.yml` (`ackit config check`).
+- Start of task: `ackit instructions --explain`, `ackit task list/show`, `ackit pack --profile codex --max-tokens 50000`.
+- During work: `ackit scan --changed`, `ackit scan --staged`.
+- Before done: `ackit policy check`, `ackit skills validate`, `ackit readiness --strict`, `ackit optimize --explain`, `ackit diagnostics --json`.
+- Full guide: `docs/ACKIT.md`.
+
+## Completion criteria
+
+- Build/test/style gates relevant to the change pass with counts recorded.
+- ACKit gates pass (`config`, `policy`, `skills validate`, `task doctor`, `scan --ci`, `readiness --strict`).
+- `git status --short`, `git diff --stat`, `git diff --check` reviewed; no unrelated changes, secrets, absolute paths, or generated junk.
+
+## Preservation
+
+- Keep unrelated changes out of the diff; do not reformat untouched files.
+- Respect existing architecture and public contracts unless the task explicitly changes them.
diff --git a/docs/ACKIT.md b/docs/ACKIT.md
new file mode 100644
index 0000000000..95227bedcb
--- /dev/null
+++ b/docs/ACKIT.md
@@ -0,0 +1,100 @@
+# ACKit in UniGetUI
+
+ACKit 0.5.2 makes UniGetUI agent-ready with deterministic instructions, skills, scans, tasks, policy, readiness, and context packs. Offline-first; no repo content leaves the machine.
+
+## Install and use
+
+```powershell
+npm install --global @cynrath/agent-context-kit
+ackit --version
+ackit --help
+ackit --help
+```
+
+Config is `ackit.yml` at root. Validate with `ackit config check`.
+
+## Common commands
+
+```powershell
+ackit init --dry-run
+ackit config check
+ackit policy check
+ackit skills validate
+ackit skills list
+ackit task list
+ackit task doctor
+ackit scan
+ackit scan --ci
+ackit scan --changed
+ackit scan --staged
+ackit readiness
+ackit readiness --strict
+ackit optimize --explain
+ackit instructions --explain
+ackit pack --profile codex --max-tokens 50000
+ackit diagnostics --json
+ackit sync --dry-run
+```
+
+## Task workflow
+
+Tasks live in `docs/tasks/active/`; archive in `docs/tasks/archive/`. Keep one `[~]` item active.
+
+```powershell
+ackit task create "Title"
+ackit task start TASK-0001
+ackit task show TASK-0001
+ackit evidence sync TASK-0001
+ackit task doctor
+ackit task archive TASK-0001
+```
+
+Complete only with recorded evidence. See the `ackit-repo-workflow` skill for the full start/during/done sequence.
+
+## CI behavior
+
+`.github/workflows/ackit.yml` runs on push and pull requests to `main` for ACKit-owned surfaces:
+
+- `ackit config check`
+- `ackit policy check`
+- `ackit skills validate`
+- `ackit task doctor`
+- `ackit scan --ci` against `docs/ackit/scan-baseline.json`
+- `ackit readiness --strict`
+
+It uses least privilege (`contents: read`), pinned `actions/checkout` and `actions/setup-node`, and pinned `npx @cynrath/agent-context-kit@0.5.2`. It does not rebuild .NET; `dotnet-test.yml` owns builds and tests.
+
+## Provider instructions
+
+- Canonical source is `AGENTS.md` (Codex surface, ACKit-managed workflow block on top).
+- `CLAUDE.md` is the Claude shim (`@AGENTS.md` inside a managed block).
+- `GEMINI.md` and `.github/copilot-instructions.md` are ACKit-managed shims pointing at `AGENTS.md`.
+- Do not maintain duplicate instruction files. Validate with `ackit instructions --explain`.
+
+## Skills
+
+Builtins (ACKit-owned): `ackit-context-optimization`, `ackit-policy-authoring`, `ackit-scan-and-fix`, `ackit-workflow`.
+
+UniGetUI custom: `dotnet-build-test`, `avalonia-ui`, `package-manager-integration`, `winget-native`, `github-pr-ci`, `ackit-repo-workflow`.
+
+Translation workflow (existing, reused; no duplicate generic localization skill): `translation-diff-export`, `translation-diff-import`, `translation-diff-translate`, `translation-review`, `translation-source-sync`, `translation-status`.
+
+Validate with `ackit skills validate`; list with `ackit skills list`.
+
+## Context pack
+
+Recommended provider-aware pack:
+
+```powershell
+ackit pack --profile codex --max-tokens 50000
+```
+
+It prioritizes instructions, architecture, affected projects, package-manager abstractions, Avalonia conventions, tests, and the active task. It excludes `bin/obj`, generated outputs, and binaries. Budget is `50000` via `ackit.yml` `context.maxTokens`.
+
+## Troubleshooting
+
+- `refused-non-managed` on `AGENTS.md`/`CLAUDE.md` means ACKit protects user content (REQ-GOV-008). Add the canonical managed block manually; do not force-overwrite.
+- `skills validate` broken-ref means a markdown link in `SKILL.md` resolves from the skill directory, not repo root. Use `../../../src/...` for root files or code spans for shell examples.
+- `policy check` chain `(0)` is normal; rule packs in `ackit.yml` are evaluated during `ackit scan`, not `policy check`.
+- `instructions --explain` shadowing by `.github/copilot-instructions.md` for delegation shims is expected; the shim only points at `AGENTS.md`.
+- `.ackit/` is local cache (ignored via `.git/info/exclude`); never commit it. Commit `ackit.yml`, `.agents/skills`, `.agents/policy`, `docs/tasks`, `docs/ACKIT.md`, and `docs/ackit/scan-baseline.json`.
diff --git a/docs/ackit/scan-baseline.json b/docs/ackit/scan-baseline.json
new file mode 100644
index 0000000000..203438e55d
--- /dev/null
+++ b/docs/ackit/scan-baseline.json
@@ -0,0 +1,575 @@
+{
+ "schemaVersion": 1,
+ "generatedBy": "ackit 0.5.2",
+ "createdAt": "2026-09-17T12:16:09.782Z",
+ "findings": [
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "4a4a2b7351f45341",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 38,
+ "column": 48,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "c7da262e06bad3a0",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 101,
+ "column": 4,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "d53a7e13b14d0af5",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 118,
+ "column": 10,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "90cb059af8246c6c",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 123,
+ "column": 7,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "0d60359fb7e8fe77",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 131,
+ "column": 123,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "f2ca01c3914cd562",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 330,
+ "column": 61,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "5f4493e185136bf6",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 377,
+ "column": 15,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "f90cf9fa2184dd4c",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 378,
+ "column": 27,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "22618f6d19bbac81",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 382,
+ "column": 11,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "88199f41c098aaf2",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 506,
+ "column": 185,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "6b9a755e75546b7b",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 677,
+ "column": 7,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "7911758fc223f54e",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 685,
+ "column": 56,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "f5d74acd6be0f118",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 799,
+ "column": 52,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "f46dbb5c014fcb9d",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 860,
+ "column": 51,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "419b9cee4a0c2768",
+ "relativePath": ".github/workflows/build-release.yml",
+ "line": 899,
+ "column": 9,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "bc329684c21af2ca",
+ "relativePath": ".github/workflows/cli-headless-e2e.yml",
+ "line": 55,
+ "column": 55,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "7a5c48f0da8be9ea",
+ "relativePath": ".github/workflows/cli-headless-e2e.yml",
+ "line": 59,
+ "column": 7,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "658596abb0c4b735",
+ "relativePath": ".github/workflows/cli-headless-e2e.yml",
+ "line": 64,
+ "column": 6,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "ef0a83d5c1ffaf1e",
+ "relativePath": ".github/workflows/cli-headless-e2e.yml",
+ "line": 68,
+ "column": 10,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "8aaa1a26631b37ab",
+ "relativePath": ".github/workflows/cli-headless-e2e.yml",
+ "line": 73,
+ "column": 8,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "54216bedfc9a17e2",
+ "relativePath": ".github/workflows/cli-headless-e2e.yml",
+ "line": 170,
+ "column": 49,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "48dc0bbd2f83394e",
+ "relativePath": ".github/workflows/codeql.yml",
+ "line": 53,
+ "column": 177,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "ed1f5ed0955bd20a",
+ "relativePath": ".github/workflows/codeql.yml",
+ "line": 58,
+ "column": 23,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "5589de809b015f51",
+ "relativePath": ".github/workflows/codeql.yml",
+ "line": 83,
+ "column": 33,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "4eca8d3a19a52c1e",
+ "relativePath": ".github/workflows/dotnet-test.yml",
+ "line": 39,
+ "column": 10,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "fb2d32b5b1f848ac",
+ "relativePath": ".github/workflows/dotnet-test.yml",
+ "line": 43,
+ "column": 12,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "a3a886dccf5b3808",
+ "relativePath": ".github/workflows/dotnet-test.yml",
+ "line": 48,
+ "column": 26,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT070",
+ "fingerprint": "adcd2535dd6adcc5",
+ "relativePath": ".github/workflows/translation-validation.yml",
+ "line": 35,
+ "column": 3,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT010",
+ "fingerprint": "553f092254d6fef4",
+ "relativePath": "WebBasedData/screenshot-database-v2.json",
+ "line": 190,
+ "column": 14,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT010",
+ "fingerprint": "8492025455509390",
+ "relativePath": "WebBasedData/screenshot-database-v2.json",
+ "line": 1568,
+ "column": 17,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT010",
+ "fingerprint": "56acd438211081fd",
+ "relativePath": "WebBasedData/screenshot-database-v2.json",
+ "line": 2731,
+ "column": 9,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT010",
+ "fingerprint": "c57a9aa3e3a5b1aa",
+ "relativePath": "WebBasedData/screenshot-database.json",
+ "line": 81,
+ "column": 1,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT010",
+ "fingerprint": "16aff0dbd342d4d3",
+ "relativePath": "WebBasedData/screenshot-database.json",
+ "line": 606,
+ "column": 31,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT010",
+ "fingerprint": "8c2dd137e051efb1",
+ "relativePath": "WebBasedData/screenshot-database.json",
+ "line": 1303,
+ "column": 64,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT010",
+ "fingerprint": "9f07dc45571b75f8",
+ "relativePath": "WebBasedData/screenshot-database.json",
+ "line": 1314,
+ "column": 65,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "0b5d6e3b731084c3",
+ "relativePath": "src/ExternalLibraries.FilePickers/Guids/CLSIDGuid.cs",
+ "line": 6,
+ "column": 37,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "abacf624619baf80",
+ "relativePath": "src/ExternalLibraries.FilePickers/Guids/CLSIDGuid.cs",
+ "line": 7,
+ "column": 40,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "212b1f36c1a6facb",
+ "relativePath": "src/ExternalLibraries.FilePickers/Guids/CLSIDGuid.cs",
+ "line": 8,
+ "column": 35,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "cb39711f35e3553e",
+ "relativePath": "src/ExternalLibraries.FilePickers/Guids/IIDGuid.cs",
+ "line": 5,
+ "column": 36,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "601aace606aab3c3",
+ "relativePath": "src/ExternalLibraries.FilePickers/Guids/IIDGuid.cs",
+ "line": 6,
+ "column": 34,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "adc6842517bbe190",
+ "relativePath": "src/ExternalLibraries.FilePickers/Guids/IIDGuid.cs",
+ "line": 11,
+ "column": 38,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "d7f7bbbab621964e",
+ "relativePath": "src/ExternalLibraries.FilePickers/Guids/IIDGuid.cs",
+ "line": 15,
+ "column": 33,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "9e1586db00538396",
+ "relativePath": "src/ExternalLibraries.FilePickers/Guids/KFIDGuid.cs",
+ "line": 5,
+ "column": 38,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "104b108772da6cb2",
+ "relativePath": "src/ExternalLibraries.FilePickers/Guids/KFIDGuid.cs",
+ "line": 8,
+ "column": 28,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "9ab93cb80043bb88",
+ "relativePath": "src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs",
+ "line": 38,
+ "column": 24,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "fe5ab27584b450cf",
+ "relativePath": "src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs",
+ "line": 338,
+ "column": 62,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "9c78bfb749630d08",
+ "relativePath": "src/UniGetUI.Avalonia/Infrastructure/NativeWebViewSupport.cs",
+ "line": 32,
+ "column": 25,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT003",
+ "fingerprint": "2afe0eb078558acd",
+ "relativePath": "src/UniGetUI.Avalonia/Infrastructure/generate-secrets.ps1",
+ "line": 14,
+ "column": 32,
+ "severity": "high"
+ },
+ {
+ "ruleId": "ACKIT003",
+ "fingerprint": "26d7ecc2a23dacec",
+ "relativePath": "src/UniGetUI.Avalonia/Infrastructure/generate-secrets.ps1",
+ "line": 20,
+ "column": 9,
+ "severity": "high"
+ },
+ {
+ "ruleId": "ACKIT003",
+ "fingerprint": "06c422d8b3e7324e",
+ "relativePath": "src/UniGetUI.Avalonia/Infrastructure/generate-secrets.sh",
+ "line": 7,
+ "column": 36,
+ "severity": "high"
+ },
+ {
+ "ruleId": "ACKIT003",
+ "fingerprint": "f4c1cad348fb1d82",
+ "relativePath": "src/UniGetUI.Avalonia/Infrastructure/generate-secrets.sh",
+ "line": 13,
+ "column": 22,
+ "severity": "high"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "c1c196f3c7f59847",
+ "relativePath": "src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs",
+ "line": 44,
+ "column": 7,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "fca3b7dcb35373e9",
+ "relativePath": "src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs",
+ "line": 237,
+ "column": 46,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "8919e2076f672760",
+ "relativePath": "src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs",
+ "line": 121,
+ "column": 22,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT003",
+ "fingerprint": "a9f90e34dcf6bae3",
+ "relativePath": "src/UniGetUI.Core.Data/CoreCredentialStore.cs",
+ "line": 15,
+ "column": 3,
+ "severity": "high"
+ },
+ {
+ "ruleId": "ACKIT003",
+ "fingerprint": "0d20097afa3583a9",
+ "relativePath": "src/UniGetUI.Core.Data/CoreCredentialStore.cs",
+ "line": 18,
+ "column": 43,
+ "severity": "high"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "40146e7200354111",
+ "relativePath": "src/UniGetUI.Core.Tools/Tools.cs",
+ "line": 263,
+ "column": 85,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "51bbbdb5f6c72f67",
+ "relativePath": "src/UniGetUI.Interface.Telemetry.Tests/TelemetryHandlerTests.cs",
+ "line": 18,
+ "column": 34,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT020",
+ "fingerprint": "d0ef8a37220fe4eb",
+ "relativePath": "src/UniGetUI.PackageEngine.Managers.Bun/Bun.cs",
+ "line": 352,
+ "column": 25,
+ "severity": "low"
+ },
+ {
+ "ruleId": "ACKIT020",
+ "fingerprint": "8d7b8c494cf78846",
+ "relativePath": "src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgDetailsHelper.cs",
+ "line": 61,
+ "column": 20,
+ "severity": "low"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "0ac0514abbb86112",
+ "relativePath": "src/UniGetUI.PackageEngine.Managers.Cargo/Helpers/CargoPkgOperationHelper.cs",
+ "line": 46,
+ "column": 40,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT003",
+ "fingerprint": "1738dd8d2b16e3bb",
+ "relativePath": "src/UniGetUI.PackageEngine.Managers.Chocolatey/Chocolatey.cs",
+ "line": 162,
+ "column": 31,
+ "severity": "high"
+ },
+ {
+ "ruleId": "ACKIT020",
+ "fingerprint": "eeea00ae6f1ba28c",
+ "relativePath": "src/UniGetUI.PackageEngine.Managers.Vcpkg/Helpers/VcpkgPkgDetailsHelper.cs",
+ "line": 55,
+ "column": 83,
+ "severity": "low"
+ },
+ {
+ "ruleId": "ACKIT020",
+ "fingerprint": "5d341ac4d6a94815",
+ "relativePath": "src/UniGetUI.PackageEngine.Managers.Vcpkg/Helpers/VcpkgPkgDetailsHelper.cs",
+ "line": 59,
+ "column": 73,
+ "severity": "low"
+ },
+ {
+ "ruleId": "ACKIT020",
+ "fingerprint": "790c2742f0197f94",
+ "relativePath": "src/UniGetUI.PackageEngine.Managers.Vcpkg/Vcpkg.cs",
+ "line": 105,
+ "column": 108,
+ "severity": "low"
+ },
+ {
+ "ruleId": "ACKIT020",
+ "fingerprint": "51efc61c157c2cfb",
+ "relativePath": "src/UniGetUI.PackageEngine.Managers.Vcpkg/Vcpkg.cs",
+ "line": 257,
+ "column": 7,
+ "severity": "low"
+ },
+ {
+ "ruleId": "ACKIT020",
+ "fingerprint": "67b843a23ab0a4c6",
+ "relativePath": "src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs",
+ "line": 265,
+ "column": 40,
+ "severity": "low"
+ },
+ {
+ "ruleId": "ACKIT010",
+ "fingerprint": "a32100ca1d771a66",
+ "relativePath": "src/UniGetUI.PackageEngine.Tests/Fixtures/Bun/installed.txt",
+ "line": 1,
+ "column": 1,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT010",
+ "fingerprint": "5e945a8072e8b087",
+ "relativePath": "src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/bucket-list-output.txt",
+ "line": 4,
+ "column": 6,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "9f9ba0d22f2adc4b",
+ "relativePath": "src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageManager.cs",
+ "line": 11,
+ "column": 8,
+ "severity": "medium"
+ },
+ {
+ "ruleId": "ACKIT005",
+ "fingerprint": "c8096cd508eea717",
+ "relativePath": "src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageManager.cs",
+ "line": 111,
+ "column": 14,
+ "severity": "medium"
+ }
+ ]
+}
diff --git a/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md b/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md
new file mode 100644
index 0000000000..9f3bfdfc8b
--- /dev/null
+++ b/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md
@@ -0,0 +1,95 @@
+---
+id: "TASK-0001"
+title: "Integrate ACKit as UniGetUI agent workflow"
+status: active
+schemaVersion: 2
+dependencies: []
+createdAt: "2026-09-17"
+completedAt: null
+---
+
+## Purpose
+
+Integrate ACKit 0.5.2 into UniGetUI as a first-class repository workflow from clean `origin/main` (57585652) on branch `chore/ackit-integration`, separate from PR #5390. Baseline: 1010 files scanned, 71 findings (7 high / 57 medium / 7 low), readiness 89/100 (Instructions 90, Security 90, Context 70, Task 100, Skills 100, Policy 100).
+
+## Scope
+
+- [x] Baseline captured (scan/readiness/skills/policy/config/pack/doctor)
+- [x] Real `ackit init` (GEMINI + copilot shims + 4 builtin skills)
+- [x] Committed `ackit.yml` (schemaVersion 1, validated)
+- [x] Root `AGENTS.md` updated (arch, build/test, formatting, localization, git hygiene, ACKit workflow, completion criteria)
+- [x] Provider shims non-duplicative, instruction graph validated
+- [x] Fix strict skill ref `src/Languages/lang_en.json` in `translation-source-sync`
+- [x] Add UniGetUI custom skills (`dotnet-build-test`, `avalonia-ui`, `package-manager-integration`, `winget-native`, `github-pr-ci`, `ackit-repo-workflow`; localization evaluated: 6 translation skills already cover, no duplicate)
+- [x] Policy pack + config gates passing
+- [x] Scan baseline for pre-existing findings (committed, not blanket ignores)
+- [x] Readiness reviewed, optimize reviewed (89 stable, doctor pass, skills clean)
+- [x] Context pack validated (`--profile codex --max-tokens 50000`, 20 nodes, active task included)
+- [x] CI workflow for ACKit gates
+- [x] Contributor/agent docs (`docs/ACKIT.md`)
+- [~] Dogfood findings recorded + classified, final gates + before/after evidence + diff review
+
+## Out of scope
+
+- PR #5390 (`feature/operation-progress`) code changes
+- Local `main` merge or push; upstream PR creation in this round
+- Broad `dotnet format` mutation across solution
+- ACKit product source changes (separate repo if justified)
+
+## Affected files
+
+- `ackit.yml` (new)
+- `AGENTS.md` (extend, add managed ACKit block)
+- `GEMINI.md` (new via init)
+- `.github/copilot-instructions.md` (new via init)
+- `.agents/skills/*` (4 builtin + 7 custom + 6 existing translation skills)
+- `.agents/policy/unigetui-policy.json` (new)
+- `docs/tasks/active/TASK-0001-*` (this task)
+- `docs/ACKIT.md` (new)
+- `.github/workflows/ackit.yml` (new CI)
+- `docs/ackit/scan-baseline.json` (new baseline, 71 pre-existing findings)
+- `docs/ackit/scan-baseline.json` (new baseline)
+
+## Required tests
+
+- `ackit config check`
+- `ackit policy check`
+- `ackit skills validate`
+- `ackit task doctor`
+- `ackit scan --ci` (against baseline)
+- `ackit readiness` + `ackit readiness --strict`
+- `ackit optimize --explain`
+- `ackit diagnostics --json`
+- `ackit pack --profile codex --max-tokens 50000`
+- `dotnet format whitespace src --folder --verify-no-changes` (read-only)
+
+## Acceptance criteria
+
+- [ ] Clean `origin/main` base, separate branch, no PR #5390 contamination
+- [ ] `ackit init` real, `ackit.yml` valid
+- [ ] `AGENTS.md` repo-specific, instruction graph clean
+- [ ] Custom skills validated, strict issue fixed or proven ACKit bug
+- [ ] Policy/config pass, readiness improves, pack validated
+- [ ] CI added, docs added, no secrets/absolute paths
+- [ ] Dogfood findings classified
+- [ ] Final diff reviewed, evidence synchronized
+
+## Test steps
+
+1. Run all Required tests, record exact outputs in Completion notes.
+2. Run read-only whitespace check; do not run mutating format.
+3. Verify `git status`, `git diff --stat`, staged scan.
+
+## Risks
+
+- Pre-existing scan findings (credential/GUID/action-pin false positives) block `--ci` without baseline.
+- Managed-block governance refuses AGENTS/CLAUDE overwrite; manual merge required.
+- Skill validator resolves relative links from SKILL.md dir (strict ref fix needed).
+
+## Rollback plan
+
+Focused commit revert on `chore/ackit-integration`; never touch `main` or `feature/operation-progress`.
+
+## Completion notes
+
+In progress. Baseline captured 2026-09-17 on 57585652.
From b9a7150c61461913071f737db18e9b4faabb36fa Mon Sep 17 00:00:00 2001
From: c <85012225+Cynrath@users.noreply.github.com>
Date: Thu, 17 Sep 2026 15:29:45 +0300
Subject: [PATCH 5/8] ci: add ACKit validation workflow
Least-privilege ACKit job on ACKit surfaces: config, policy, skills, task doctor, readiness strict, changed-files scan gate, full baseline scan with SARIF artifact. Actions follow repo mutable-tag policy (checkout/setup-node/upload-artifact v7); SHAs not guessed. ACKIT070 reports 3 mediums on this file, consistent with all existing workflows.
---
.github/workflows/ackit.yml | 75 +++++++++++++++++++++++++++++++++++++
1 file changed, 75 insertions(+)
create mode 100644 .github/workflows/ackit.yml
diff --git a/.github/workflows/ackit.yml b/.github/workflows/ackit.yml
new file mode 100644
index 0000000000..56d24c4e63
--- /dev/null
+++ b/.github/workflows/ackit.yml
@@ -0,0 +1,75 @@
+name: ACKit
+on:
+ push:
+ branches: [ "main" ]
+ paths:
+ - 'ackit.yml'
+ - 'AGENTS.md'
+ - 'CLAUDE.md'
+ - 'GEMINI.md'
+ - '.github/copilot-instructions.md'
+ - '.agents/**'
+ - 'docs/ACKIT.md'
+ - 'docs/tasks/**'
+ - 'docs/ackit/**'
+ - '.github/workflows/ackit.yml'
+ pull_request:
+ branches: [ "main" ]
+ paths:
+ - 'ackit.yml'
+ - 'AGENTS.md'
+ - 'CLAUDE.md'
+ - 'GEMINI.md'
+ - '.github/copilot-instructions.md'
+ - '.agents/**'
+ - 'docs/ACKIT.md'
+ - 'docs/tasks/**'
+ - 'docs/ackit/**'
+ - '.github/workflows/ackit.yml'
+ workflow_dispatch:
+
+jobs:
+ ackit:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: '24'
+
+ - name: ACKit config check
+ run: npx --yes @cynrath/agent-context-kit@0.5.2 config check
+
+ - name: ACKit policy check
+ run: npx --yes @cynrath/agent-context-kit@0.5.2 policy check
+
+ - name: ACKit skills validate
+ run: npx --yes @cynrath/agent-context-kit@0.5.2 skills validate
+
+ - name: ACKit task doctor
+ run: npx --yes @cynrath/agent-context-kit@0.5.2 task doctor
+
+ - name: ACKit readiness strict
+ run: npx --yes @cynrath/agent-context-kit@0.5.2 readiness --strict
+
+ - name: ACKit scan changed files gate
+ run: npx --yes @cynrath/agent-context-kit@0.5.2 scan --changed --ci
+
+ - name: ACKit full scan against baseline
+ continue-on-error: true
+ run: npx --yes @cynrath/agent-context-kit@0.5.2 scan --baseline docs/ackit/scan-baseline.json --format sarif --output ackit-scan.sarif
+
+ - name: Upload ACKit SARIF
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: ackit-scan-sarif
+ path: ackit-scan.sarif
+ if-no-files-found: ignore
From 93f37ee204e27a92aa18cb7a16bd0ec153a8883c Mon Sep 17 00:00:00 2001
From: c <85012225+Cynrath@users.noreply.github.com>
Date: Thu, 17 Sep 2026 15:33:48 +0300
Subject: [PATCH 6/8] docs: synchronize TASK-0001 evidence and acceptance
Final gates, before/after metrics, gate digests, commit SHAs, and remaining push/review steps. Single active checklist item preserved.
---
...egrate-ackit-as-unigetui-agent-workflow.md | 26 ++++++++++++-------
1 file changed, 17 insertions(+), 9 deletions(-)
diff --git a/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md b/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md
index 9f3bfdfc8b..22540a30dc 100644
--- a/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md
+++ b/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md
@@ -65,14 +65,14 @@ Integrate ACKit 0.5.2 into UniGetUI as a first-class repository workflow from cl
## Acceptance criteria
-- [ ] Clean `origin/main` base, separate branch, no PR #5390 contamination
-- [ ] `ackit init` real, `ackit.yml` valid
-- [ ] `AGENTS.md` repo-specific, instruction graph clean
-- [ ] Custom skills validated, strict issue fixed or proven ACKit bug
-- [ ] Policy/config pass, readiness improves, pack validated
-- [ ] CI added, docs added, no secrets/absolute paths
-- [ ] Dogfood findings classified
-- [ ] Final diff reviewed, evidence synchronized
+- [x] Clean `origin/main` base, separate branch, no PR #5390 contamination
+- [x] `ackit init` real, `ackit.yml` valid
+- [x] `AGENTS.md` repo-specific, instruction graph validated (expected shim shadowing documented)
+- [x] Custom skills validated, strict issue fixed as stale relative path
+- [x] Policy/config pass, readiness stable with doctor pass, pack validated
+- [x] CI added, docs added, no secrets/absolute paths
+- [x] Dogfood findings classified
+- [x] Final diff reviewed, evidence synchronized (push + review remain)
## Test steps
@@ -92,4 +92,12 @@ Focused commit revert on `chore/ackit-integration`; never touch `main` or `featu
## Completion notes
-In progress. Baseline captured 2026-09-17 on 57585652.
+2026-09-17, branch `chore/ackit-integration` (cb30b3ff + evidence), base `origin/main` 57585652, ACKit 0.5.2, Node v24.13.0.
+
+Before (clean origin/main): 1010 files, 71 findings (7 high / 57 medium / 7 low); readiness 89 (Inst 90, Sec 90, Ctx 70, Task 100, Skills 100, Policy 100); skills 6 with 1 strict (`translation-source-sync` ref `src/Languages/lang_en.json`); doctor 1 failed; sync 2 refused + 4 would-create; instructions 8 nodes; tasks 0 active; optimize 2 suggestions.
+
+After (3 commits): 1032 files (+22), 74 findings (+3, all ACKIT070 mutable-pin on new `.github/workflows/ackit.yml`, repo-policy consistent, SHAs not guessed); readiness 89 stable (strict pass); skills 16, 0 issues; doctor ALL PASS; sync all up-to-date; instructions 20 nodes; tasks 1 active; optimize 1 suggestion; `scan --changed` 0 files/0 findings; `scan --staged` 0/0; `scan --changed --ci` on workflow commit showed the 3 mediums (visible regression signal).
+
+Exact gates: `ackit config check` OK (digest b6faab36d972); `ackit policy check` OK chain 0; `ackit skills validate` 16 OK; `ackit task doctor` OK; `ackit scan --ci` exit 1 (pre-existing threshold, baseline documents); `ackit readiness` 89 pass; `ackit readiness --strict` exit 0; `ackit optimize --explain` 1 low; `ackit diagnostics --json` ok (20 instructions, 1 active task); `ackit pack --profile codex --max-tokens 50000` 50000/50000 with TASK-0001; `ackit instructions --explain` 20 nodes, expected copilot-shim shadowing + translation cycle diagnostic; `dotnet format whitespace src --folder --verify-no-changes` pass (no output); full `dotnet test`/publish not run (no C# changes).
+
+Commits: 0dc030a0 chore init, 6bb9efb0 docs skills, cb30b3ff ci workflow. PR #5390 branch cb18d904 untouched; local main 38bbfbbe untouched, never pushed. Localization evaluated: 6 translation skills reused, no duplicate generic skill. No ACKit product changes (no separate repo work justified beyond documentation-gap findings).
From 4d2e9895e12ac18557d900f2badd1a52d83893d7 Mon Sep 17 00:00:00 2001
From: c <85012225+Cynrath@users.noreply.github.com>
Date: Thu, 17 Sep 2026 15:55:17 +0300
Subject: [PATCH 7/8] docs: align ACKit integration with operation progress PR
TASK-0001 now describes the combined feature/operation-progress scope for PR #5390: progress tests present, ACKit files included, strict skill issue fixed, 16 skills clean. chore/ackit-integration retained only as redundant source.
---
...egrate-ackit-as-unigetui-agent-workflow.md | 21 +++++++++----------
1 file changed, 10 insertions(+), 11 deletions(-)
diff --git a/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md b/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md
index 22540a30dc..64acfd048c 100644
--- a/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md
+++ b/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md
@@ -10,7 +10,7 @@ completedAt: null
## Purpose
-Integrate ACKit 0.5.2 into UniGetUI as a first-class repository workflow from clean `origin/main` (57585652) on branch `chore/ackit-integration`, separate from PR #5390. Baseline: 1010 files scanned, 71 findings (7 high / 57 medium / 7 low), readiness 89/100 (Instructions 90, Security 90, Context 70, Task 100, Skills 100, Policy 100).
+Integrate ACKit 0.5.2 into UniGetUI as a first-class repository workflow on branch `feature/operation-progress` as part of upstream PR #5390 (combined with determinate operation progress). Baseline on `origin/main` 57585652: 1010 files scanned, 71 findings (7 high / 57 medium / 7 low), readiness 89/100 (Instructions 90, Security 90, Context 70, Task 100, Skills 100, Policy 100).
## Scope
@@ -31,8 +31,7 @@ Integrate ACKit 0.5.2 into UniGetUI as a first-class repository workflow from cl
## Out of scope
-- PR #5390 (`feature/operation-progress`) code changes
-- Local `main` merge or push; upstream PR creation in this round
+- Local `main` merge or push; opening any new upstream PR beyond #5390
- Broad `dotnet format` mutation across solution
- ACKit product source changes (separate repo if justified)
@@ -42,13 +41,13 @@ Integrate ACKit 0.5.2 into UniGetUI as a first-class repository workflow from cl
- `AGENTS.md` (extend, add managed ACKit block)
- `GEMINI.md` (new via init)
- `.github/copilot-instructions.md` (new via init)
-- `.agents/skills/*` (4 builtin + 7 custom + 6 existing translation skills)
+- `.agents/skills/*` (4 builtin + 6 custom + 6 existing translation skills = 16 total, 0 issues; strict `translation-source-sync` ref fixed)
- `.agents/policy/unigetui-policy.json` (new)
-- `docs/tasks/active/TASK-0001-*` (this task)
+- `docs/tasks/active/TASK-0001-*` (this task, part of the PR)
- `docs/ACKIT.md` (new)
- `.github/workflows/ackit.yml` (new CI)
- `docs/ackit/scan-baseline.json` (new baseline, 71 pre-existing findings)
-- `docs/ackit/scan-baseline.json` (new baseline)
+- Progress implementation + tests (`OperationProgress`, `WinGetNativeProgress`, `OperationCardProgressState`, `OperationViewModel` integration incl. operation-card progress mapping tests)
## Required tests
@@ -65,7 +64,7 @@ Integrate ACKit 0.5.2 into UniGetUI as a first-class repository workflow from cl
## Acceptance criteria
-- [x] Clean `origin/main` base, separate branch, no PR #5390 contamination
+- [x] Clean `origin/main` base carried into `feature/operation-progress`; progress + ACKit combined in single PR #5390
- [x] `ackit init` real, `ackit.yml` valid
- [x] `AGENTS.md` repo-specific, instruction graph validated (expected shim shadowing documented)
- [x] Custom skills validated, strict issue fixed as stale relative path
@@ -88,16 +87,16 @@ Integrate ACKit 0.5.2 into UniGetUI as a first-class repository workflow from cl
## Rollback plan
-Focused commit revert on `chore/ackit-integration`; never touch `main` or `feature/operation-progress`.
+Focused commit revert on `feature/operation-progress`; never touch `main`; never open a second upstream PR.
## Completion notes
-2026-09-17, branch `chore/ackit-integration` (cb30b3ff + evidence), base `origin/main` 57585652, ACKit 0.5.2, Node v24.13.0.
+2026-09-17, combined branch `feature/operation-progress` for upstream PR #5390 (progress + ACKit in one PR), base `origin/main` 57585652, ACKit 0.5.2, Node v24.13.0. ACKit commits cherry-picked from `chore/ackit-integration` (0dc030a0, 6bb9efb0, cb30b3ff, 00f769f3) onto PR HEAD cb18d904 with zero conflicts; `chore/ackit-integration` is now redundant, not a separate delivery.
Before (clean origin/main): 1010 files, 71 findings (7 high / 57 medium / 7 low); readiness 89 (Inst 90, Sec 90, Ctx 70, Task 100, Skills 100, Policy 100); skills 6 with 1 strict (`translation-source-sync` ref `src/Languages/lang_en.json`); doctor 1 failed; sync 2 refused + 4 would-create; instructions 8 nodes; tasks 0 active; optimize 2 suggestions.
After (3 commits): 1032 files (+22), 74 findings (+3, all ACKIT070 mutable-pin on new `.github/workflows/ackit.yml`, repo-policy consistent, SHAs not guessed); readiness 89 stable (strict pass); skills 16, 0 issues; doctor ALL PASS; sync all up-to-date; instructions 20 nodes; tasks 1 active; optimize 1 suggestion; `scan --changed` 0 files/0 findings; `scan --staged` 0/0; `scan --changed --ci` on workflow commit showed the 3 mediums (visible regression signal).
-Exact gates: `ackit config check` OK (digest b6faab36d972); `ackit policy check` OK chain 0; `ackit skills validate` 16 OK; `ackit task doctor` OK; `ackit scan --ci` exit 1 (pre-existing threshold, baseline documents); `ackit readiness` 89 pass; `ackit readiness --strict` exit 0; `ackit optimize --explain` 1 low; `ackit diagnostics --json` ok (20 instructions, 1 active task); `ackit pack --profile codex --max-tokens 50000` 50000/50000 with TASK-0001; `ackit instructions --explain` 20 nodes, expected copilot-shim shadowing + translation cycle diagnostic; `dotnet format whitespace src --folder --verify-no-changes` pass (no output); full `dotnet test`/publish not run (no C# changes).
+Exact gates: `ackit config check` OK (digest b6faab36d972); `ackit policy check` OK chain 0; `ackit skills validate` 16 OK; `ackit task doctor` OK; `ackit scan --ci` exit 1 (pre-existing threshold, baseline documents); `ackit readiness` 89 pass; `ackit readiness --strict` exit 0; `ackit optimize --explain` 1 low; `ackit diagnostics --json` ok (20 instructions, 1 active task); `ackit pack --profile codex --max-tokens 50000` 50000/50000 with TASK-0001; `ackit instructions --explain` 20 nodes, expected copilot-shim shadowing + translation cycle diagnostic; `dotnet format whitespace src --folder --verify-no-changes` pass (no output); progress `dotnet test`/builds re-run after combination (recorded below).
-Commits: 0dc030a0 chore init, 6bb9efb0 docs skills, cb30b3ff ci workflow. PR #5390 branch cb18d904 untouched; local main 38bbfbbe untouched, never pushed. Localization evaluated: 6 translation skills reused, no duplicate generic skill. No ACKit product changes (no separate repo work justified beyond documentation-gap findings).
+Commits cherry-picked: 0dc030a0 chore init, 6bb9efb0 docs skills, cb30b3ff ci workflow, 00f769f3 evidence sync. Progress side: bfa97850 determinate progress + cb18d904 OperationViewModel operation-card progress mapping tests (present in-PR, not missing). ACKit files intentionally INCLUDED in this PR (not excluded). Strict translation skill issue FIXED (not pre-existing). Combined PR diff covers src progress/test files plus ACKit repo workflow, not src-only. Local main 38bbfbbe untouched, never pushed. Localization evaluated: 6 translation skills reused, no duplicate generic skill. No ACKit product changes (no separate repo work justified beyond documentation-gap findings).
From a20895a5bba4ac6639a3be7a900e00045d9e1e72 Mon Sep 17 00:00:00 2001
From: c <85012225+Cynrath@users.noreply.github.com>
Date: Thu, 17 Sep 2026 16:37:37 +0300
Subject: [PATCH 8/8] feat: add measured download throughput to operation
progress
Adds optional BytesPerSecond to OperationProgress, calculated
generically in AbstractOperation from cumulative byte samples
(EMA alpha=0.3) so WinGet native COM, HTTP DownloadOperation and
future managers share one mechanism while mappers stay stateless.
Formatter appends live B/s-KB/s-MB/s-GB/s; first sample has no
speed; stage/unknown/retry transitions reset. Runtime-verified on
a real 46.7MB native WinGet update.
---
...egrate-ackit-as-unigetui-agent-workflow.md | 14 +
.../OperationProgress.cs | 31 +-
.../AbstractOperation.cs | 58 +++
.../DownloadThroughputTracker.cs | 111 ++++++
.../OperationProgressFormatter.cs | 37 +-
.../PackageOperations.cs | 7 +-
.../DownloadOperationThroughputTests.cs | 169 ++++++++
.../OperationCardProgressStateTests.cs | 40 ++
.../OperationProgressTests.cs | 377 ++++++++++++++++++
.../WinGetNativeProgressTests.cs | 87 ++++
10 files changed, 927 insertions(+), 4 deletions(-)
create mode 100644 src/UniGetUI.PackageEngine.Operations/DownloadThroughputTracker.cs
create mode 100644 src/UniGetUI.PackageEngine.Tests/DownloadOperationThroughputTests.cs
diff --git a/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md b/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md
index 64acfd048c..ab92b709aa 100644
--- a/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md
+++ b/docs/tasks/active/TASK-0001-integrate-ackit-as-unigetui-agent-workflow.md
@@ -99,4 +99,18 @@ After (3 commits): 1032 files (+22), 74 findings (+3, all ACKIT070 mutable-pin o
Exact gates: `ackit config check` OK (digest b6faab36d972); `ackit policy check` OK chain 0; `ackit skills validate` 16 OK; `ackit task doctor` OK; `ackit scan --ci` exit 1 (pre-existing threshold, baseline documents); `ackit readiness` 89 pass; `ackit readiness --strict` exit 0; `ackit optimize --explain` 1 low; `ackit diagnostics --json` ok (20 instructions, 1 active task); `ackit pack --profile codex --max-tokens 50000` 50000/50000 with TASK-0001; `ackit instructions --explain` 20 nodes, expected copilot-shim shadowing + translation cycle diagnostic; `dotnet format whitespace src --folder --verify-no-changes` pass (no output); progress `dotnet test`/builds re-run after combination (recorded below).
+## Runtime feedback round 2 (2026-09-17): CS2012 diagnosis + download throughput
+
+CS2012 (`UniGetUI.PackageEngine.Managers.WinGet.dll` locked) teşhisi: repo problemi DEĞİL. Teşhis anında çalışan UniGetUI/testhost/vstest süreci yoktu; dotnet süreçleri yalnızca MSBuild nodeReuse worker'larıydı; `handle.exe` kilitli DLL için "No matching handles found" döndürdü; broad kill yapılmadı, hiçbir stale süreç kapatmaya gerek kalmadı. WinGet manager projesi temiz derlendi (0 hata). Sonuç: transient stale-process lock; kanıtlandı, PR'da kod değişikliği gerekmedi.
+
+Warning sınıflandırması (hepsi `origin/main` baseline, PR kaynaklı değil, PR'da rastgele düzeltilmedi): NU1903 (`SQLitePCLRaw.lib.e_sqlite3` güvenlik açığı, transitif bağımlılık), NU1510 (`System.Text.Encoding.CodePages` PackageReference, `origin/main` ile birebir aynı), CA2008 (`Core.Tools/Tools.cs:1525`) + CA1822 (`PackageOperations.cs:998/1014`, testlerde benzerleri) — PR diff'inde hiç `.csproj`/ilgili kod yok (`git diff --name-only origin/main...HEAD` kanıtlı).
+
+Download speed eklendi (fake/synthetic yok, `deltaBytes/deltaTime`): `OperationProgress.BytesPerSecond` (`double?`, yalnızca Downloading + byte counter varken), `WinGetProgressMapper` stateless kaldı, hesaplama generic katmanda (`AbstractOperation.ReportProgress` + yeni `DownloadThroughputTracker`, EMA alpha=0.3, gerekçe XML doc'ta: ~3 örnek hafıza, jitter sönümü + ~1sn tepki, O(1) deterministik state). Kurallar: ilk örnek null; yalnızca Downloading; deltaBytes<=0 önceki hızı korur (state'e dokunmaz); timeDelta<=0 previous-safe; geri giden sayaç reset; stage/unknown/retry değişiminde reset + sanitize; terminal sonrası kart `WithProgress` yoksayar; thread-safe (lock). Formatter: hız varsa `Downloading · 21% · 10,0 MB / 46,7 MB · 1,2 MB/s`, yoksa legacy format aynen; birimler B/s–GB/s (`FormatAsSize` konvansiyonu, F1 + current culture); ETA yok. Native log satırı zenginleşmiş progress'i formatlar (`PackageOperations.cs`); HTTP `DownloadOperation` aynı `ReportProgress` yolundan otomatik hız alır.
+
+Testler (hedefli 212/212 yeşil, her iki TFM): first-null, delta hesabı, EMA determinizmi (1.3 MiB/s), zero-time, backward reset, repeated-preserve, stage/retry/unknown reset, non-Downloading sanitize, normalize reddi, formatter with/without + B/KB/MB/GB + NaN/Infinity omit, 8×50 paralel thread-safety, WinGet mapped→downstream hız, loopback HTTP uçtan uca (`DownloadOperationThroughputTests`, 3MB throttled server), kart terminal yoksayma. Tam süit: 1595 geçti / 7 başarısız — 7'si de stash'lenmiş baseline'da birebir tekrarlandı (pre-existing, çevresel): 5 locale (tr-TR makinede İngilizce bekleyen `OperationHistory` + 4 `WinGetManagerTests.Explain*`), 2 launcher-script discovery (`OperationCallArgsWiringTests`); PR regresyonu yok. `dotnet format whitespace` pass; `dotnet format style UniGetUI.Windows.slnx` pass (exit 0).
+
+Gerçek runtime (local Debug x64 `UniGetUI.exe --headless`, pipe `unigetui-rt-speed`, `UNIGETUI_WINGET_COM=enabled`, FDM): (1) `package download` 0→100% + success + 46.7MB dosya (HTTP yolu; hız kart katmanında, IPC liveLine log-odaklı olduğu için unit/loopback ile kanıtlı). (2) Native COM `package update` op 8371548 canlı log kanıtı: `Starting native WinGet upgrade...`, ilk örnek hızsız (`Downloading · 2% · 1,0 MB / 46,7 MB`), ardından canlı değişen `· 1,2 MB/s … · 985,8 KB/s … · 775,7 KB/s … · 1,3 MB/s … · 915,9 KB/s`, `Downloading · 100% · 46,7 MB / 46,7 MB`, hızsız `Installing...`/`Installing · 1%` geçişi, `Finalizing...`, `Native WinGet result: Ok`, success. Gözlem (PR dışı): kurulu sürüm 6.34'te kaldı çünkü vendor `.../6/latest/fdm_x64_setup.exe` payload'unun kendisi 6.34 (FileVersion kanıtlı, SHA256 kayıtlı); stok `winget upgrade` CLI da aynı "Successfully installed" + değişmeyen sürümü verdi — WinGet/UniGetUI hattı doğru raporladı, vendor/manifest sorunu. Daemon `app quit` ile kapatıldı, `C:\Temp\unigetui-rt` temizlendi; tam op çıktısı `C:\Temp\unigetui-rt-speed-evidence-8371548.json` (repo dışı).
+
+Bu turun kapıları: `ackit config check` OK; `ackit policy check` OK; `ackit skills validate` 16/0; `ackit task doctor` integrity OK; `ackit scan --changed` 9 dosya 0 bulgu; `ackit readiness --strict` 89 pass.
+
Commits cherry-picked: 0dc030a0 chore init, 6bb9efb0 docs skills, cb30b3ff ci workflow, 00f769f3 evidence sync. Progress side: bfa97850 determinate progress + cb18d904 OperationViewModel operation-card progress mapping tests (present in-PR, not missing). ACKit files intentionally INCLUDED in this PR (not excluded). Strict translation skill issue FIXED (not pre-existing). Combined PR diff covers src progress/test files plus ACKit repo workflow, not src-only. Local main 38bbfbbe untouched, never pushed. Localization evaluated: 6 translation skills reused, no duplicate generic skill. No ACKit product changes (no separate repo work justified beyond documentation-gap findings).
diff --git a/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs b/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
index c204c1a573..889f11270c 100644
--- a/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
+++ b/src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
@@ -19,12 +19,17 @@ public enum OperationProgressStage
/// is null when the real percentage is unknown, in
/// which case the UI must stay indeterminate. Byte counters are optional and
/// only set when the manager reports reliable values.
+/// is an optional measured throughput attached
+/// downstream by the generic operation layer (never by manager mappers): it is
+/// only present on reports
+/// with a usable byte counter, and is null otherwise.
///
public sealed record OperationProgress(
double? Percentage,
ulong? BytesDownloaded = null,
ulong? BytesTotal = null,
- OperationProgressStage Stage = OperationProgressStage.Unknown
+ OperationProgressStage Stage = OperationProgressStage.Unknown,
+ double? BytesPerSecond = null
)
{
public static readonly OperationProgress Unknown = new(
@@ -57,11 +62,35 @@ public sealed record OperationProgress(
return v;
}
+ ///
+ /// Normalizes a measured throughput in bytes per second: NaN, Infinity and
+ /// non-positive values carry no information and become unknown (null).
+ ///
+ public static double? NormalizeBytesPerSecond(double? value)
+ {
+ if (value is null)
+ return null;
+ double v = value.Value;
+ if (double.IsNaN(v) || double.IsInfinity(v))
+ return null;
+ if (v <= 0)
+ return null;
+ return v;
+ }
+
+ ///
+ /// True when holds a real usable throughput.
+ ///
+ public bool HasThroughput => NormalizeBytesPerSecond(BytesPerSecond).HasValue;
+
///
/// Builds a download progress report. Prefers byte-based percentage when
/// is greater than zero; otherwise falls back
/// to . A zero/unknown total with no
/// usable reported value yields an indeterminate report (no fake percent).
+ /// Throughput is never set here: stays null so
+ /// the mapper remains stateless, and the generic operation layer attaches
+ /// the measured speed downstream from cumulative byte samples.
///
public static OperationProgress FromDownload(
ulong bytesDownloaded,
diff --git a/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
index 78defe06e1..de5b34242b 100644
--- a/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
+++ b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
@@ -26,6 +26,19 @@ public abstract partial class AbstractOperation : IDisposable
private OperationProgress _currentProgress = OperationProgress.Unknown;
private OperationProgress _lastRaisedProgress = OperationProgress.Unknown;
private DateTime _lastProgressReportUtc = DateTime.MinValue;
+ private readonly DownloadThroughputTracker _throughputTracker = new();
+ private volatile Func _utcNowProvider = static () => DateTime.UtcNow;
+
+ ///
+ /// Test hook: overrides the clock used for download throughput sampling so
+ /// speed calculation is deterministic in tests. Production always uses
+ /// .
+ ///
+ internal void SetUtcNowProviderForTests(Func provider)
+ {
+ ArgumentNullException.ThrowIfNull(provider);
+ _utcNowProvider = provider;
+ }
///
/// Latest structured progress reported by the executing manager.
@@ -46,13 +59,23 @@ public OperationProgress CurrentProgress
/// and coalesced so rapid native callbacks cannot spam the UI: identical
/// reports are dropped, stage changes and Unknown/100% are always raised,
/// and small determinate deltas within 200ms are coalesced.
+ /// Downloading reports carrying a byte counter are enriched here with a
+ /// measured BytesPerSecond throughput (see
+ /// ), so WinGet native COM,
+ /// DownloadOperation HTTP and future byte-counter managers share one
+ /// mechanism while their mappers stay stateless. Every other stage, and
+ /// downloading reports without a usable counter, reset the sampler and
+ /// carry no speed, so stale speeds can never leak across stage transitions,
+ /// unknown progress, retries or terminal states.
///
protected void ReportProgress(OperationProgress progress)
{
ArgumentNullException.ThrowIfNull(progress);
+ DateTime timestampUtc = _utcNowProvider();
bool shouldRaise;
lock (ProgressLock)
{
+ progress = AttachThroughputLocked(progress, timestampUtc);
_currentProgress = progress;
if (progress.Equals(_lastRaisedProgress))
{
@@ -85,9 +108,44 @@ protected void ReportProgress(OperationProgress progress)
ProgressChanged?.Invoke(this, progress);
}
+ ///
+ /// Attaches a measured download throughput to
+ /// when it is a downloading report with a usable byte counter. Must be
+ /// called with ProgressLock held. Non-downloading stages and
+ /// downloading reports without a counter reset the sampler and have any
+ /// incoming speed stripped, so only real freshly-sampled speeds survive.
+ ///
+ private OperationProgress AttachThroughputLocked(
+ OperationProgress progress,
+ DateTime timestampUtc
+ )
+ {
+ if (
+ progress.Stage is not OperationProgressStage.Downloading
+ || !progress.BytesDownloaded.HasValue
+ )
+ {
+ _throughputTracker.Reset();
+ return progress.BytesPerSecond is null
+ ? progress
+ : progress with { BytesPerSecond = null };
+ }
+
+ double? speed = _throughputTracker.Observe(
+ progress.BytesDownloaded.Value,
+ timestampUtc
+ );
+ double? normalized = OperationProgress.NormalizeBytesPerSecond(speed);
+ return normalized == progress.BytesPerSecond
+ ? progress
+ : progress with { BytesPerSecond = normalized };
+ }
+
///
/// Resets structured progress to unknown (indeterminate). Called at the
/// start of every execution attempt so retries never show stale progress.
+ /// The throughput sampler is reset through the same path, so a retry never
+ /// leaks the previous attempt's download speed.
///
protected void ResetProgress() => ReportProgress(OperationProgress.Unknown);
protected bool QUEUE_ENABLED;
diff --git a/src/UniGetUI.PackageEngine.Operations/DownloadThroughputTracker.cs b/src/UniGetUI.PackageEngine.Operations/DownloadThroughputTracker.cs
new file mode 100644
index 0000000000..b78c93eec8
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Operations/DownloadThroughputTracker.cs
@@ -0,0 +1,111 @@
+namespace UniGetUI.PackageOperations;
+
+///
+/// Attempt-level estimator converting cumulative download byte counters into a
+/// smoothed bytes-per-second throughput. Shared by every byte-counter source
+/// (WinGet native COM callbacks, HTTP DownloadOperation, future
+/// managers) so manager mappers stay stateless and never synthesize speeds.
+/// Thread-safe: all sample state is guarded by a private lock.
+///
+///
+/// Smoothing is an exponential moving average with
+/// 0.3: each new instantaneous sample contributes 30% and history 70%, giving
+/// an effective memory of ~3 samples. That damps single-sample jitter caused
+/// by coarse byte quantization and irregular native callback intervals, while
+/// still reacting to genuine throughput changes within about a second of
+/// progress reports (which arrive at least on every 1% step and are additionally
+/// coalesced at 200ms in AbstractOperation). The estimator is a pure
+/// function of (bytes, timestamp) samples with O(1) state and no wall-clock
+/// windows, so it is fully deterministic and unit-testable.
+///
+internal sealed class DownloadThroughputTracker
+{
+ ///
+ /// EMA weight of the newest instantaneous sample. Justified above.
+ ///
+ internal const double SmoothingFactor = 0.3;
+
+ private readonly object _lock = new();
+ private ulong? _previousBytes;
+ private DateTime _previousTimestampUtc;
+ private double? _smoothedBytesPerSecond;
+ private bool _hasSample;
+
+ ///
+ /// Observes a cumulative byte counter at the given UTC timestamp and
+ /// returns the smoothed throughput, or null when no meaningful speed can
+ /// be reported yet:
+ /// ]
+ /// - The first sample only establishes the baseline (null).
+ /// - A non-positive time delta keeps the previous speed (null when
+ /// there is none) without touching the stored sample, so a later sample
+ /// with a valid timestamp still measures across the full interval.
+ /// - An unchanged counter carries no new information: the previous
+ /// speed is preserved and the stored sample is untouched, so a resume
+ /// measures truthfully across the stall instead of collapsing to zero.
+ /// - A backwards counter (retry, restart, rewind) resets the baseline
+ /// and returns null; the rewound sample becomes the new first sample.
+ ///
+ /// The result is always null or a finite positive value: NaN/Infinity can
+ /// never leak to the UI.
+ ///
+ public double? Observe(ulong bytesDownloaded, DateTime timestampUtc)
+ {
+ lock (_lock)
+ {
+ if (!_hasSample)
+ {
+ _previousBytes = bytesDownloaded;
+ _previousTimestampUtc = timestampUtc;
+ _smoothedBytesPerSecond = null;
+ _hasSample = true;
+ return null;
+ }
+
+ ulong previousBytes = _previousBytes!.Value;
+
+ if (bytesDownloaded < previousBytes)
+ {
+ // Counter rewound: start over from this sample.
+ _previousBytes = bytesDownloaded;
+ _previousTimestampUtc = timestampUtc;
+ _smoothedBytesPerSecond = null;
+ return null;
+ }
+
+ if (bytesDownloaded == previousBytes)
+ return _smoothedBytesPerSecond;
+
+ double elapsedSeconds = (timestampUtc - _previousTimestampUtc).TotalSeconds;
+ if (elapsedSeconds <= 0)
+ return _smoothedBytesPerSecond;
+
+ double instant = (bytesDownloaded - previousBytes) / elapsedSeconds;
+ if (double.IsNaN(instant) || double.IsInfinity(instant) || instant <= 0)
+ return _smoothedBytesPerSecond;
+
+ _smoothedBytesPerSecond = _smoothedBytesPerSecond.HasValue
+ ? SmoothingFactor * instant + (1.0 - SmoothingFactor) * _smoothedBytesPerSecond.Value
+ : instant;
+ _previousBytes = bytesDownloaded;
+ _previousTimestampUtc = timestampUtc;
+ return _smoothedBytesPerSecond;
+ }
+ }
+
+ ///
+ /// Drops all sample state. Called on stage transitions away from
+ /// downloading, on unknown progress, and at the start of every execution
+ /// attempt so retries never leak a stale speed.
+ ///
+ public void Reset()
+ {
+ lock (_lock)
+ {
+ _previousBytes = null;
+ _previousTimestampUtc = default;
+ _smoothedBytesPerSecond = null;
+ _hasSample = false;
+ }
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs b/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs
index 84019b34dc..2223070b0e 100644
--- a/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs
+++ b/src/UniGetUI.PackageEngine.Operations/OperationProgressFormatter.cs
@@ -7,7 +7,9 @@ namespace UniGetUI.PackageOperations;
/// Formats a generic for operation cards,
/// log lines and screen-reader status. Unknown progress maps to a short
/// stage label (indeterminate); determinate progress appends percent and,
-/// when available, human-readable byte counters.
+/// when available, human-readable byte counters plus the measured download
+/// throughput (e.g. "Downloading · 21% · 10.0 MB / 46.7 MB · 3.8 MB/s").
+/// No ETA is shown.
///
public static class OperationProgressFormatter
{
@@ -25,7 +27,10 @@ public static string Format(OperationProgress progress)
&& progress.BytesTotal.Value > 0
)
{
- return $"{label} \u00b7 {percent}% \u00b7 {FormatBytes(progress.BytesDownloaded.Value)} / {FormatBytes(progress.BytesTotal.Value)}";
+ string text =
+ $"{label} \u00b7 {percent}% \u00b7 {FormatBytes(progress.BytesDownloaded.Value)} / {FormatBytes(progress.BytesTotal.Value)}";
+ string? throughput = FormatThroughput(progress.BytesPerSecond);
+ return throughput is null ? text : $"{text} \u00b7 {throughput}";
}
return $"{label} \u00b7 {percent}%";
@@ -57,4 +62,32 @@ private static string FormatBytes(ulong value) =>
value > (ulong)long.MaxValue
? $"{value / 1099511627776.0:F1} TB"
: CoreTools.FormatAsSize((long)value);
+
+ ///
+ /// Formats a measured throughput using the same binary-unit conventions as
+ /// (one decimal, current culture;
+ /// unit suffixes stay invariant like the existing size strings). Returns
+ /// null when there is no usable speed, in which case the caller keeps the
+ /// established speed-less format. NaN/Infinity/non-positive values never
+ /// produce output.
+ ///
+ private static string? FormatThroughput(double? bytesPerSecond)
+ {
+ double? normalized = OperationProgress.NormalizeBytesPerSecond(bytesPerSecond);
+ if (normalized is null)
+ return null;
+
+ double value = normalized.Value;
+ const double KiloByte = 1024d;
+ const double MegaByte = 1024d * 1024d;
+ const double GigaByte = 1024d * 1024d * 1024d;
+
+ if (value >= GigaByte)
+ return $"{value / GigaByte:F1} GB/s";
+ if (value >= MegaByte)
+ return $"{value / MegaByte:F1} MB/s";
+ if (value >= KiloByte)
+ return $"{value / KiloByte:F1} KB/s";
+ return $"{value:F1} B/s";
+ }
}
diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
index 4cd93e59b1..8260c4a291 100644
--- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
+++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs
@@ -334,7 +334,12 @@ protected override async Task PerformOperation()
progress =>
{
ReportProgress(progress);
- Line(OperationProgressFormatter.Format(progress), LineType.ProgressIndicator);
+ // Format the enriched progress (with measured download
+ // throughput) so the live log line matches the card.
+ Line(
+ OperationProgressFormatter.Format(CurrentProgress),
+ LineType.ProgressIndicator
+ );
},
info => Line(info, LineType.Information),
error => Line(error, LineType.Error),
diff --git a/src/UniGetUI.PackageEngine.Tests/DownloadOperationThroughputTests.cs b/src/UniGetUI.PackageEngine.Tests/DownloadOperationThroughputTests.cs
new file mode 100644
index 0000000000..d4b6e2a4de
--- /dev/null
+++ b/src/UniGetUI.PackageEngine.Tests/DownloadOperationThroughputTests.cs
@@ -0,0 +1,169 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageEngine.Interfaces;
+using UniGetUI.PackageEngine.Operations;
+using UniGetUI.PackageEngine.Tests.Infrastructure.Builders;
+using UniGetUI.PackageOperations;
+
+namespace UniGetUI.PackageEngine.Tests;
+
+///
+/// Proves the HTTP path receives a measured
+/// download speed through the shared generic operation layer: a throttled
+/// loopback server streams a real payload, and at least one progress report
+/// must carry a finite positive BytesPerSecond. No synthetic speeds.
+///
+public sealed class DownloadOperationThroughputTests
+{
+ private sealed class ProbeDownloadOperation(IPackage package, string downloadPath)
+ : DownloadOperation(package, downloadPath)
+ {
+ public Task InvokePerformOperationForTests() =>
+ PerformOperation();
+ }
+
+ ///
+ /// Minimal throttled HTTP/1.1 server over loopback TCP: serves one fixed
+ /// payload with Content-Length, pacing chunks so the client's real
+ /// clock observes distinct progress samples.
+ ///
+ private sealed class ThrottledLoopbackServer : IDisposable
+ {
+ private readonly TcpListener _listener;
+ private readonly byte[] _payload;
+ private readonly int _chunkSize;
+ private readonly TimeSpan _chunkDelay;
+ private readonly Task _serveTask;
+
+ public Uri Url { get; }
+
+ public ThrottledLoopbackServer(int totalBytes, int chunkSize, TimeSpan chunkDelay)
+ {
+ _payload = new byte[totalBytes];
+ new Random(42).NextBytes(_payload);
+ _chunkSize = chunkSize;
+ _chunkDelay = chunkDelay;
+ _listener = new TcpListener(IPAddress.Loopback, 0);
+ _listener.Start();
+ Url = new Uri($"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/payload.bin");
+ _serveTask = Task.Run(ServeOnceAsync);
+ }
+
+ private async Task ServeOnceAsync()
+ {
+ using TcpClient client = await _listener.AcceptTcpClientAsync();
+ using NetworkStream stream = client.GetStream();
+
+ // Consume the request headers.
+ var request = new byte[4096];
+ int seen = 0;
+ while (seen < request.Length - 1)
+ {
+ int read = await stream.ReadAsync(request.AsMemory(seen));
+ if (read == 0)
+ break;
+ seen += read;
+ if (
+ Encoding.ASCII.GetString(request, 0, seen).Contains("\r\n\r\n")
+ )
+ break;
+ }
+
+ string header =
+ $"HTTP/1.1 200 OK\r\nContent-Length: {_payload.Length}\r\n"
+ + "Content-Type: application/octet-stream\r\nConnection: close\r\n\r\n";
+ byte[] headerBytes = Encoding.ASCII.GetBytes(header);
+ await stream.WriteAsync(headerBytes);
+ await stream.FlushAsync();
+
+ for (int offset = 0; offset < _payload.Length; offset += _chunkSize)
+ {
+ int count = Math.Min(_chunkSize, _payload.Length - offset);
+ await stream.WriteAsync(_payload.AsMemory(offset, count));
+ await stream.FlushAsync();
+ await Task.Delay(_chunkDelay);
+ }
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ _serveTask.Wait(TimeSpan.FromSeconds(30));
+ }
+ catch
+ {
+ // Best-effort: a failed transfer still ends the test via its verdict.
+ }
+ _listener.Stop();
+ }
+ }
+
+ [Fact]
+ public async Task HttpDownload_ReportsMeasuredThroughput()
+ {
+ const int TotalBytes = 3 * 1024 * 1024;
+ using var server = new ThrottledLoopbackServer(
+ TotalBytes,
+ chunkSize: 256 * 1024,
+ chunkDelay: TimeSpan.FromMilliseconds(100)
+ );
+
+ var manager = new PackageManagerBuilder()
+ .ConfigureDetails(helper =>
+ {
+ helper.PopulateDetails = details =>
+ {
+ details.InstallerUrl = server.Url;
+ details.InstallerType = "exe";
+ };
+ })
+ .Build();
+ IPackage package = new PackageBuilder().WithManager(manager).Build();
+
+ string downloadPath = Path.Join(
+ Path.GetTempPath(),
+ $"unigetui-throughput-{Guid.NewGuid():N}.bin"
+ );
+ try
+ {
+ using var operation = new ProbeDownloadOperation(package, downloadPath);
+ var seenSpeeds = new List();
+ operation.ProgressChanged += (_, progress) =>
+ {
+ lock (seenSpeeds)
+ seenSpeeds.Add(progress.BytesPerSecond);
+ };
+
+ OperationVeredict verdict = await operation.InvokePerformOperationForTests();
+
+ Assert.Equal(OperationVeredict.Success, verdict);
+ Assert.Equal(TotalBytes, new FileInfo(downloadPath).Length);
+
+ List speeds;
+ lock (seenSpeeds)
+ speeds = [.. seenSpeeds];
+ Assert.NotEmpty(speeds);
+ Assert.Contains(
+ speeds,
+ static speed =>
+ speed.HasValue
+ && !double.IsNaN(speed.Value)
+ && !double.IsInfinity(speed.Value)
+ && speed.Value > 0
+ );
+
+ // The enriched report formats with a live throughput suffix.
+ OperationProgress last = operation.CurrentProgress;
+ Assert.True(last.HasThroughput);
+ Assert.Contains("MB/s", OperationProgressFormatter.Format(last));
+ }
+ finally
+ {
+ if (File.Exists(downloadPath))
+ File.Delete(downloadPath);
+ }
+ }
+}
diff --git a/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs b/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs
index e353091554..6a90fbdeee 100644
--- a/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs
+++ b/src/UniGetUI.PackageEngine.Tests/OperationCardProgressStateTests.cs
@@ -154,6 +154,46 @@ public void Uninstall_Unknown_IsIndeterminate()
Assert.Contains("Uninstalling", card.LiveLine);
}
+ [Fact]
+ public void Running_DownloadWithSpeed_ShowsThroughputInLiveLine()
+ {
+ var card = RunningCard()
+ .WithProgress(
+ OperationStatus.Running,
+ OperationProgress.FromDownload(21, 100) with { BytesPerSecond = 3.8 * 1024 * 1024 }
+ );
+
+ Assert.False(card.IsIndeterminate);
+ Assert.Contains("21%", card.LiveLine);
+ Assert.Contains("MB/s", card.LiveLine);
+ }
+
+ [Theory]
+ [InlineData(OperationStatus.Succeeded)]
+ [InlineData(OperationStatus.Failed)]
+ [InlineData(OperationStatus.Canceled)]
+ public void ProgressWithSpeed_AfterTerminal_IsIgnored(OperationStatus status)
+ {
+ // Terminal visuals own the card: a stale speed-bearing report arriving
+ // after completion must not leak back into the visuals.
+ var card = RunningCard()
+ .WithProgress(
+ OperationStatus.Running,
+ OperationProgress.FromDownload(40, 100) with { BytesPerSecond = 1024 }
+ )
+ .WithStatus(status);
+ var before = card;
+
+ card = card.WithProgress(
+ status,
+ OperationProgress.FromDownload(90, 100) with { BytesPerSecond = 999_999 }
+ );
+
+ Assert.Equal(before, card);
+ Assert.Equal(100, card.Value);
+ Assert.False(card.IsIndeterminate);
+ }
+
[Fact]
public void InQueue_ResetsToZero()
{
diff --git a/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs b/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
index 03647b5a76..db91d60c46 100644
--- a/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
+++ b/src/UniGetUI.PackageEngine.Tests/OperationProgressTests.cs
@@ -162,6 +162,9 @@ public ProgressProbeOperation()
public void ResetForTests() => ResetProgress();
+ public void SetClockForTests(Func provider) =>
+ SetUtcNowProviderForTests(provider);
+
protected override void ApplyRetryAction(string retryMode) { }
protected override Task PerformOperation() =>
@@ -264,4 +267,378 @@ public void Formatter_Unknown_DoesNotThrow()
var text = OperationProgressFormatter.Format(OperationProgress.Unknown);
Assert.False(string.IsNullOrWhiteSpace(text));
}
+
+ ///
+ /// Deterministic manual clock for throughput tests: production uses
+ /// , tests advance time explicitly.
+ ///
+ private sealed class ManualClock
+ {
+ private DateTime _now = new(2026, 9, 17, 12, 0, 0, DateTimeKind.Utc);
+
+ public Func Provider => () => _now;
+
+ public void Advance(TimeSpan delta) => _now += delta;
+ }
+
+ private static (ProgressProbeOperation Op, ManualClock Clock) CreateClockedProbe()
+ {
+ var op = new ProgressProbeOperation();
+ var clock = new ManualClock();
+ op.SetClockForTests(clock.Provider);
+ return (op, clock);
+ }
+
+ private static void ReportDownload(
+ ProgressProbeOperation op,
+ ulong downloaded,
+ ulong total = 10UL * 1024 * 1024
+ ) => op.ReportForTests(OperationProgress.FromDownload(downloaded, total));
+
+ [Fact]
+ public void FirstDownloadSample_HasNoSpeed()
+ {
+ var (op, _) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 1_000_000);
+
+ Assert.True(op.CurrentProgress.IsDeterminate);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ Assert.False(op.CurrentProgress.HasThroughput);
+ }
+ }
+
+ [Fact]
+ public void SecondValidSample_CalculatesSpeed()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 1_048_576);
+
+ Assert.Equal(1_048_576.0, op.CurrentProgress.BytesPerSecond);
+ Assert.True(op.CurrentProgress.HasThroughput);
+ }
+ }
+
+ [Fact]
+ public void SpeedDeltaCalculation_IsDeltaBytesOverDeltaTime()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 1_048_576);
+ clock.Advance(TimeSpan.FromSeconds(4));
+ ReportDownload(op, 3UL * 1_048_576);
+
+ // (3 MiB - 1 MiB) / 4 s = 0.5 MiB/s.
+ Assert.Equal(524_288.0, op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void Smoothing_IsDeterministicExponentialMovingAverage()
+ {
+ static double? RunSequence()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 1_048_576); // instant = 1 MiB/s
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 3UL * 1_048_576); // instant = 2 MiB/s
+ return op.CurrentProgress.BytesPerSecond;
+ }
+ }
+
+ double? first = RunSequence();
+ double? second = RunSequence();
+
+ Assert.NotNull(first);
+ Assert.Equal(first, second);
+ // EMA with alpha 0.3: 0.3 * 2 MiB/s + 0.7 * 1 MiB/s = 1.3 MiB/s.
+ Assert.Equal(1.3 * 1_048_576.0, first!.Value, precision: 5);
+ }
+
+ [Fact]
+ public void ZeroTimeDelta_KeepsPreviousSpeedWithoutNaN()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ // Second sample at the very same timestamp: no speed yet, no NaN.
+ ReportDownload(op, 1_048_576);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 2UL * 1_048_576);
+ double? speed = op.CurrentProgress.BytesPerSecond;
+ Assert.NotNull(speed);
+
+ // More bytes but no time elapsed: previous speed preserved, finite.
+ ReportDownload(op, 3UL * 1_048_576);
+ Assert.Equal(speed, op.CurrentProgress.BytesPerSecond);
+ Assert.True(op.CurrentProgress.HasThroughput);
+ }
+ }
+
+ [Fact]
+ public void BackwardByteCounter_ResetsSpeedToNull()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 2UL * 1_048_576);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+
+ // Counter rewound (retry/restart): baseline resets, no stale speed.
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 512);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ // The rewound sample is the new baseline: next delta measures from it.
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 512 + 1_048_576);
+ Assert.Equal(1_048_576.0, op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void RepeatedByteCount_PreservesPreviousSpeed()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 1_048_576);
+ double? speed = op.CurrentProgress.BytesPerSecond;
+ Assert.NotNull(speed);
+
+ // Stalled counter carries no new information: keep the previous
+ // speed instead of synthesizing a meaningless new one.
+ clock.Advance(TimeSpan.FromSeconds(5));
+ ReportDownload(op, 1_048_576);
+ Assert.Equal(speed, op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void StageTransition_ResetsSpeed()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 1_048_576);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+
+ // Download -> install: speed is stripped, never carried over.
+ op.ReportForTests(OperationProgress.FromInstall(50));
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ Assert.False(op.CurrentProgress.HasThroughput);
+
+ // A fresh download starts without a stale speed.
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 2UL * 1_048_576);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void ResetProgress_ClearsSpeed()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 1_048_576);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+
+ op.ResetForTests();
+ Assert.Equal(OperationProgress.Unknown, op.CurrentProgress);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ // Same counters after a reset behave like a first sample again.
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 1_048_576);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void UnknownProgress_ClearsSpeed()
+ {
+ var (op, clock) = CreateClockedProbe();
+ using (op)
+ {
+ ReportDownload(op, 0);
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 1_048_576);
+ Assert.NotNull(op.CurrentProgress.BytesPerSecond);
+
+ op.ReportForTests(OperationProgress.Unknown);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ clock.Advance(TimeSpan.FromSeconds(1));
+ ReportDownload(op, 2UL * 1_048_576);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Fact]
+ public void NonDownloadingStages_NeverCarrySpeed()
+ {
+ var (op, _) = CreateClockedProbe();
+ using (op)
+ {
+ // Even a hand-built installing report with speed is sanitized.
+ op.ReportForTests(OperationProgress.FromInstall(50) with { BytesPerSecond = 999 });
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ op.ReportForTests(OperationProgress.Queued with { BytesPerSecond = 999 });
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ op.ReportForTests(OperationProgress.Completed with { BytesPerSecond = 999 });
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+ }
+ }
+
+ [Theory]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ [InlineData(0)]
+ [InlineData(-12.5)]
+ public void NormalizeBytesPerSecond_RejectsNonPositiveAndNonFinite(double value)
+ {
+ Assert.Null(OperationProgress.NormalizeBytesPerSecond(value));
+ Assert.False((OperationProgress.Unknown with { BytesPerSecond = value }).HasThroughput);
+ }
+
+ [Fact]
+ public void NormalizeBytesPerSecond_KeepsPositiveFinite()
+ {
+ Assert.Equal(3.5, OperationProgress.NormalizeBytesPerSecond(3.5));
+ Assert.True((OperationProgress.Unknown with { BytesPerSecond = 3.5 }).HasThroughput);
+ Assert.Null(OperationProgress.NormalizeBytesPerSecond(null));
+ }
+
+ [Fact]
+ public void Formatter_DownloadWithSpeed_AppendsThroughput()
+ {
+ var progress = OperationProgress.FromDownload(21UL * 1_048_576, 100UL * 1_048_576) with
+ {
+ BytesPerSecond = 3.8 * 1_048_576.0,
+ };
+
+ string text = OperationProgressFormatter.Format(progress);
+
+ Assert.Contains("21%", text);
+ Assert.Contains("/", text);
+ Assert.Contains("MB/s", text);
+ }
+
+ [Fact]
+ public void Formatter_DownloadWithoutSpeed_KeepsLegacyFormat()
+ {
+ string text = OperationProgressFormatter.Format(
+ OperationProgress.FromDownload(21UL * 1_048_576, 100UL * 1_048_576)
+ );
+
+ Assert.Contains("21%", text);
+ Assert.DoesNotContain("/s", text);
+ }
+
+ [Theory]
+ [InlineData(512.0, "B/s")]
+ [InlineData(2048.0, "KB/s")]
+ [InlineData(3.8 * 1024 * 1024, "MB/s")]
+ [InlineData(3.0 * 1024 * 1024 * 1024, "GB/s")]
+ public void Formatter_ThroughputUnits(double bytesPerSecond, string expectedUnit)
+ {
+ var progress =
+ OperationProgress.FromDownload(50, 100) with { BytesPerSecond = bytesPerSecond };
+
+ Assert.Contains(expectedUnit, OperationProgressFormatter.Format(progress));
+ }
+
+ [Fact]
+ public void Formatter_ByteUnit_DoesNotConfuseWithKiloUnit()
+ {
+ var progress = OperationProgress.FromDownload(50, 100) with { BytesPerSecond = 512.0 };
+
+ string text = OperationProgressFormatter.Format(progress);
+ Assert.Contains("512", text);
+ Assert.DoesNotContain("KB/s", text);
+ Assert.DoesNotContain("MB/s", text);
+ Assert.DoesNotContain("GB/s", text);
+ }
+
+ [Theory]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ [InlineData(0)]
+ [InlineData(-5)]
+ public void Formatter_UnusableSpeed_IsOmitted(double bytesPerSecond)
+ {
+ var progress =
+ OperationProgress.FromDownload(50, 100) with { BytesPerSecond = bytesPerSecond };
+
+ Assert.DoesNotContain("/s", OperationProgressFormatter.Format(progress));
+ }
+
+ [Fact]
+ public async Task ReportProgress_RapidConcurrentCallbacks_AreSafeAndFinite()
+ {
+ using var op = new ProgressProbeOperation();
+ var seenSpeeds = new System.Collections.Concurrent.ConcurrentBag();
+ op.ProgressChanged += (_, progress) => seenSpeeds.Add(progress.BytesPerSecond);
+
+ await Task.WhenAll(
+ Enumerable
+ .Range(0, 8)
+ .Select(worker =>
+ Task.Run(() =>
+ {
+ for (ulong step = 0; step < 50; step++)
+ op.ReportForTests(
+ OperationProgress.FromDownload(
+ (ulong)worker * 1000 + step,
+ 100_000
+ )
+ );
+ })
+ )
+ );
+
+ foreach (double? speed in seenSpeeds)
+ Assert.True(
+ speed is null
+ || (!double.IsNaN(speed.Value)
+ && !double.IsInfinity(speed.Value)
+ && speed.Value > 0),
+ $"Non-finite speed leaked: {speed}"
+ );
+
+ OperationProgress current = op.CurrentProgress;
+ Assert.True(current.IsDeterminate);
+ Assert.True(
+ current.BytesPerSecond is null
+ || (!double.IsNaN(current.BytesPerSecond.Value)
+ && !double.IsInfinity(current.BytesPerSecond.Value)
+ && current.BytesPerSecond.Value > 0)
+ );
+ }
}
diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetNativeProgressTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetNativeProgressTests.cs
index ed90c85ef0..63a763f0f4 100644
--- a/src/UniGetUI.PackageEngine.Tests/WinGetNativeProgressTests.cs
+++ b/src/UniGetUI.PackageEngine.Tests/WinGetNativeProgressTests.cs
@@ -2,6 +2,7 @@
using Microsoft.Management.Deployment;
using UniGetUI.PackageEngine.Enums;
using UniGetUI.PackageEngine.Managers.WingetManager;
+using UniGetUI.PackageOperations;
namespace UniGetUI.PackageEngine.Tests;
@@ -128,5 +129,91 @@ public void MapUninstall_Uninstalling_UnknownIsIndeterminate()
Assert.False(mapped.IsDeterminate);
Assert.Equal(OperationProgressStage.Uninstalling, mapped.Stage);
}
+
+ ///
+ /// The mapper itself stays stateless and never synthesizes a speed; the
+ /// generic operation layer attaches the measured throughput downstream
+ /// from the mapped cumulative byte samples.
+ ///
+ [Fact]
+ public void MappedDownload_ReceivesGenericCalculatedSpeedDownstream()
+ {
+ using var op = new WinGetSpeedProbeOperation();
+ var clock = new WinGetManualClock();
+ op.SetClockForTests(clock.Provider);
+
+ var first = WinGetProgressMapper.MapInstall(
+ new InstallProgress
+ {
+ State = PackageInstallProgressState.Downloading,
+ BytesDownloaded = 0,
+ BytesRequired = 10UL * 1024 * 1024,
+ DownloadProgress = 0,
+ }
+ );
+ Assert.Null(first.BytesPerSecond);
+
+ op.ReportForTests(first);
+ Assert.True(op.CurrentProgress.IsDeterminate);
+ Assert.Null(op.CurrentProgress.BytesPerSecond);
+
+ clock.Advance(TimeSpan.FromSeconds(2));
+ op.ReportForTests(
+ WinGetProgressMapper.MapInstall(
+ new InstallProgress
+ {
+ State = PackageInstallProgressState.Downloading,
+ BytesDownloaded = 2UL * 1024 * 1024,
+ BytesRequired = 10UL * 1024 * 1024,
+ DownloadProgress = 20,
+ }
+ )
+ );
+
+ // (2 MiB - 0) / 2 s = 1 MiB/s, calculated generically downstream.
+ Assert.Equal(1024 * 1024.0, op.CurrentProgress.BytesPerSecond);
+ Assert.True(op.CurrentProgress.HasThroughput);
+ Assert.Contains(
+ "MB/s",
+ OperationProgressFormatter.Format(op.CurrentProgress)
+ );
+ }
+
+ private sealed class WinGetManualClock
+ {
+ private DateTime _now = new(2026, 9, 17, 12, 0, 0, DateTimeKind.Utc);
+
+ public Func Provider => () => _now;
+
+ public void Advance(TimeSpan delta) => _now += delta;
+ }
+
+ private sealed class WinGetSpeedProbeOperation : AbstractOperation
+ {
+ public WinGetSpeedProbeOperation()
+ : base(queue_enabled: false)
+ {
+ Metadata.Status = "probe status";
+ Metadata.Title = "probe title";
+ Metadata.OperationInformation = "probe info";
+ Metadata.SuccessTitle = "probe success";
+ Metadata.SuccessMessage = "probe success";
+ Metadata.FailureTitle = "probe failure";
+ Metadata.FailureMessage = "probe failure";
+ }
+
+ public void ReportForTests(OperationProgress progress) => ReportProgress(progress);
+
+ public void SetClockForTests(Func provider) =>
+ SetUtcNowProviderForTests(provider);
+
+ protected override void ApplyRetryAction(string retryMode) { }
+
+ protected override Task PerformOperation() =>
+ Task.FromResult(OperationVeredict.Success);
+
+ public override Task GetOperationIcon() =>
+ Task.FromResult(new Uri("avares://UniGetUI/Assets/package_color.png"));
+ }
}
#endif