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 <path>] [--decision <path>] [--plan <path>]` (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 <id> --profile quick|standard|high-risk`, `ackit workflow show <id>`, `ackit workflow advance <id>`, `ackit workflow verify <id> --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 <id> --next-objective "<text>"`, `ackit checkpoint show <id>`, `ackit checkpoint validate <id>`, `ackit checkpoint export <id> [--out <file>]`; resume with `ackit task resume <id>`. +5. Link proof, do not assert it: `ackit evidence sync <id>`, `ackit evidence verify <id> --criterion AC-001 --type test --ref "<proof>"`, `ackit evidence validate <id>`; independent check via `ackit verification bundle <id>` then `ackit verification record <id> --verdict <file>` / `ackit verification show <id>`; watch drift with `ackit drift check <id>` (`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 <id> --force` (tier2 boundary). Never mark `[x]` without command output in Completion notes. +7. Archive after final evidence: `ackit task archive <id>` (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 <id>`, +`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 <id>` (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 @@ +<!-- ackit:managed:start (copilot) --> +Read and follow AGENTS.md in the repository root before acting. +<!-- ackit:managed:end (copilot) --> 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 +<!-- ackit:managed:start (claude) --> +@AGENTS.md +<!-- ackit:managed:end (claude) --> diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000000..80cbfc539f --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,3 @@ +<!-- ackit:managed:start (gemini) --> +Read and follow AGENTS.md in the repository root before acting. +<!-- ackit:managed:end (gemini) --> 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 <id>` 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:managed:start (codex) --> +# 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. +<!-- ackit:managed:end (codex) --> # 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 "<title>"`, start via `ackit task start <id>`. +- 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 <command> --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 /// <see cref="Percentage"/> 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. +/// <see cref="BytesPerSecond"/> is an optional measured throughput attached +/// downstream by the generic operation layer (never by manager mappers): it is +/// only present on <see cref="OperationProgressStage.Downloading"/> reports +/// with a usable byte counter, and is null otherwise. /// </summary> 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; } + /// <summary> + /// Normalizes a measured throughput in bytes per second: NaN, Infinity and + /// non-positive values carry no information and become unknown (null). + /// </summary> + 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; + } + + /// <summary> + /// True when <see cref="BytesPerSecond"/> holds a real usable throughput. + /// </summary> + public bool HasThroughput => NormalizeBytesPerSecond(BytesPerSecond).HasValue; + /// <summary> /// Builds a download progress report. Prefers byte-based percentage when /// <paramref name="bytesTotal"/> is greater than zero; otherwise falls back /// to <paramref name="reportedPercentage"/>. A zero/unknown total with no /// usable reported value yields an indeterminate report (no fake percent). + /// Throughput is never set here: <see cref="BytesPerSecond"/> stays null so + /// the mapper remains stateless, and the generic operation layer attaches + /// the measured speed downstream from cumulative byte samples. /// </summary> 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<DateTime> _utcNowProvider = static () => DateTime.UtcNow; + + /// <summary> + /// Test hook: overrides the clock used for download throughput sampling so + /// speed calculation is deterministic in tests. Production always uses + /// <see cref="DateTime.UtcNow"/>. + /// </summary> + internal void SetUtcNowProviderForTests(Func<DateTime> provider) + { + ArgumentNullException.ThrowIfNull(provider); + _utcNowProvider = provider; + } /// <summary> /// 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 <c>BytesPerSecond</c> throughput (see + /// <see cref="DownloadThroughputTracker"/>), so WinGet native COM, + /// <c>DownloadOperation</c> 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. /// </summary> 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); } + /// <summary> + /// Attaches a measured download throughput to <paramref name="progress"/> + /// when it is a downloading report with a usable byte counter. Must be + /// called with <c>ProgressLock</c> 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. + /// </summary> + 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 }; + } + /// <summary> /// 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. /// </summary> 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; + +/// <summary> +/// 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 <c>DownloadOperation</c>, future +/// managers) so manager mappers stay stateless and never synthesize speeds. +/// Thread-safe: all sample state is guarded by a private lock. +/// </summary> +/// <remarks> +/// Smoothing is an exponential moving average with <see cref="SmoothingFactor"/> +/// 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 <c>AbstractOperation</c>). 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. +/// </remarks> +internal sealed class DownloadThroughputTracker +{ + /// <summary> + /// EMA weight of the newest instantaneous sample. Justified above. + /// </summary> + internal const double SmoothingFactor = 0.3; + + private readonly object _lock = new(); + private ulong? _previousBytes; + private DateTime _previousTimestampUtc; + private double? _smoothedBytesPerSecond; + private bool _hasSample; + + /// <summary> + /// 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: + /// <list type="bullet"> + /// <item>The first sample only establishes the baseline (null).</item> + /// <item>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.</item> + /// <item>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.</item> + /// <item>A backwards counter (retry, restart, rewind) resets the baseline + /// and returns null; the rewound sample becomes the new first sample.</item> + /// </list> + /// The result is always null or a finite positive value: NaN/Infinity can + /// never leak to the UI. + /// </summary> + 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; + } + } + + /// <summary> + /// 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. + /// </summary> + 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 <see cref="OperationProgress"/> 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. /// </summary> 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); + + /// <summary> + /// Formats a measured throughput using the same binary-unit conventions as + /// <see cref="CoreTools.FormatAsSize"/> (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. + /// </summary> + 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<OperationVeredict> 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; + +/// <summary> +/// Proves the HTTP <see cref="DownloadOperation"/> 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 <c>BytesPerSecond</c>. No synthetic speeds. +/// </summary> +public sealed class DownloadOperationThroughputTests +{ + private sealed class ProbeDownloadOperation(IPackage package, string downloadPath) + : DownloadOperation(package, downloadPath) + { + public Task<OperationVeredict> InvokePerformOperationForTests() => + PerformOperation(); + } + + /// <summary> + /// Minimal throttled HTTP/1.1 server over loopback TCP: serves one fixed + /// payload with <c>Content-Length</c>, pacing chunks so the client's real + /// clock observes distinct progress samples. + /// </summary> + 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<double?>(); + 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<double?> 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<DateTime> provider) => + SetUtcNowProviderForTests(provider); + protected override void ApplyRetryAction(string retryMode) { } protected override Task<OperationVeredict> PerformOperation() => @@ -264,4 +267,378 @@ public void Formatter_Unknown_DoesNotThrow() var text = OperationProgressFormatter.Format(OperationProgress.Unknown); Assert.False(string.IsNullOrWhiteSpace(text)); } + + /// <summary> + /// Deterministic manual clock for throughput tests: production uses + /// <see cref="DateTime.UtcNow"/>, tests advance time explicitly. + /// </summary> + private sealed class ManualClock + { + private DateTime _now = new(2026, 9, 17, 12, 0, 0, DateTimeKind.Utc); + + public Func<DateTime> 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<double?>(); + 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); } + + /// <summary> + /// 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. + /// </summary> + [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<DateTime> 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<DateTime> provider) => + SetUtcNowProviderForTests(provider); + + protected override void ApplyRetryAction(string retryMode) { } + + protected override Task<OperationVeredict> PerformOperation() => + Task.FromResult(OperationVeredict.Success); + + public override Task<Uri> GetOperationIcon() => + Task.FromResult(new Uri("avares://UniGetUI/Assets/package_color.png")); + } } #endif