Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/Languages/lang_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1685,5 +1685,13 @@
"{0} allows stopping running applications.": "{0} allows stopping running applications.",
"{0} allows uninstalling the previous version.": "{0} allows uninstalling the previous version.",
"{0} warning(s)": "{0} warning(s)",
"Invalid policy files cannot be changed from UniGetUI. An administrator must correct or replace the protected policy file outside this app.": "Invalid policy files cannot be changed from UniGetUI. An administrator must correct or replace the protected policy file outside this app."
"Invalid policy files cannot be changed from UniGetUI. An administrator must correct or replace the protected policy file outside this app.": "Invalid policy files cannot be changed from UniGetUI. An administrator must correct or replace the protected policy file outside this app.",
"Downloading": "Downloading",
"Installing": "Installing",
"Updating": "Updating",
"Uninstalling": "Uninstalling",
"{0}...": "{0}...",
"{0} · {1}%": "{0} · {1}%",
"{0} · {1}% · {2} / {3}": "{0} · {1}% · {2} / {3}",
"{0} · {1}% · {2} / {3} · {4}": "{0} · {1}% · {2} / {3} · {4}"
}
62 changes: 50 additions & 12 deletions src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ public sealed partial class OperationViewModel : ViewModelBase
private static readonly Uri _fallbackIconUri =
new("avares://UniGetUI/Assets/package_color.png");

// Progress display state machine; the only UI-thread-owned copy.
// All event handlers below run on the UI thread via Dispatcher.UIThread.Post,
// which is FIFO at the same priority. That ordering is load-bearing on the
// failure path: Status=Failed clears determinate ownership before the failure
// line arrives, so the failure message is never swallowed.
private readonly OperationCardController _controller = new();

public OperationViewModel(AbstractOperation operation)
{
Operation = operation;
Expand All @@ -75,7 +82,28 @@ public OperationViewModel(AbstractOperation operation)

// Route all background-thread events to the UI thread
operation.LogLineAdded += (_, ev) =>
Dispatcher.UIThread.Post(() => LiveLine = ev.Item1);
Dispatcher.UIThread.Post(() =>
{
// Structured determinate progress owns the status line: raw per-frame
// progress text must not clobber it. (History already excludes
// ProgressIndicator lines, so this changes display only.)
if (_controller.TryApplyLogLine(ev.Item1, ev.Item2, out string liveLine))
{
LiveLine = liveLine;
}
});

operation.ProgressChanged += (_, progress) =>
Dispatcher.UIThread.Post(() =>
{
var (isIndeterminate, value, liveLine) = _controller.ApplyProgress(
Operation.Status,
progress
);
ProgressIndeterminate = isIndeterminate;
ProgressValue = value;
LiveLine = liveLine;
});

operation.StatusChanged += (_, status) =>
Dispatcher.UIThread.Post(() => ApplyStatus(status));
Expand Down Expand Up @@ -107,8 +135,17 @@ public OperationViewModel(AbstractOperation operation)
));
});

// Sync with current status in case the operation already started
ApplyStatus(operation.Status);
// Sync with current status in case the operation already started. The
// controller derives card, last log line, and determinate ownership from the
// same snapshot so a mid-download card starts in the formatted state.
// Note: SyncInitial already applies the status to the controller, so only
// the brush/menu visuals still need syncing here (a second ApplyStatus would
// flip a determinate Running card back to indeterminate).
_controller.SyncInitial(_liveLine, operation.Status, operation.CurrentProgress);
ProgressIndeterminate = _controller.Card.IsIndeterminate;
ProgressValue = _controller.Card.Value;
LiveLine = _controller.Card.LiveLine;
ApplyStatusVisuals(operation.Status);
}

// ── Icon loading ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -150,43 +187,44 @@ private async Task LoadIconAsync()

// ── Status → visual properties ────────────────────────────────────────────
private void ApplyStatus(OperationStatus status)
{
// Determinate ownership ends with the running phase; afterwards log lines
// (e.g. the success/failure message) own the status line again.
_controller.ApplyStatus(status);
ProgressIndeterminate = _controller.Card.IsIndeterminate;
ProgressValue = _controller.Card.Value;
ApplyStatusVisuals(status);
}

private void ApplyStatusVisuals(OperationStatus status)
{
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");
Expand Down
86 changes: 86 additions & 0 deletions src/UniGetUI.PackageEngine.Enums/OperationProgress.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
namespace UniGetUI.PackageEngine.Enums
{
/// <summary>
/// The phase a package operation is currently in. Manager-neutral: no WinGet, COM,
/// or CLI-specific concepts leak into this model.
/// </summary>
public enum OperationProgressStage
{
Unknown,
Downloading,
Installing,
Updating,
Uninstalling,
}

/// <summary>
/// Manager-neutral snapshot of package-operation progress.
///
/// Unknown percentage is represented as a null <see cref="Percentage"/> (never zero):
/// the UI must render null as indeterminate and a known value as determinate.
/// Download and install phases are never merged into a synthetic overall total.
/// Instances are immutable; the operation layer enriches download reports with a
/// measured <see cref="BytesPerSecond"/> downstream.
/// </summary>
public sealed record OperationProgress(
OperationProgressStage Stage,
double? Percentage,
ulong? BytesDownloaded,
ulong? BytesTotal,
double? BytesPerSecond
)
{
/// <summary>Plain unknown: indeterminate UI, log-driven status text is kept.</summary>
public static readonly OperationProgress Unknown = new(
OperationProgressStage.Unknown,
null,
null,
null,
null
);

/// <summary>Unknown progress within a known stage (e.g. installing with no counters).</summary>
public static OperationProgress ForStage(OperationProgressStage stage) =>
new(stage, null, null, null, null);

/// <summary>
/// Download progress from real cumulative byte counters. Percentage is derived as
/// <c>downloaded / total</c> and clamped to 100 when counters overshoot; a zero
/// total yields indeterminate (unknown) progress rather than a fake zero.
/// </summary>
public static OperationProgress FromDownload(ulong downloaded, ulong total) =>
total == 0
? new(OperationProgressStage.Downloading, null, downloaded, null, null)
: new(
OperationProgressStage.Downloading,
Math.Min(downloaded * 100.0 / total, 100.0),
downloaded,
total,
null
);

/// <summary>
/// True only for a real, finite percentage. Unknown progress is never zero.
/// </summary>
public bool IsDeterminate =>
Percentage is { } value
&& !double.IsNaN(value)
&& !double.IsInfinity(value)
&& value >= 0;

/// <summary>True when a usable measured throughput is attached.</summary>
public bool HasThroughput => NormalizeBytesPerSecond(BytesPerSecond) is not null;

/// <summary>
/// Accepts only positive finite speeds. NaN, Infinity, zero, negatives, and null
/// are rejected so they can never be displayed or averaged.
/// </summary>
public static double? NormalizeBytesPerSecond(double? bytesPerSecond) =>
bytesPerSecond is { } value
&& !double.IsNaN(value)
&& !double.IsInfinity(value)
&& value > 0
? value
: null;
}
}
1 change: 1 addition & 0 deletions src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,7 @@ protected virtual void Dispose(bool disposing)

scheduledRetry?.TrySetCanceled();
Cancel();
DisposeStaleSpeedTimer();
if (!IsExecutingOperation)
{
while (OperationQueue.Remove(this))
Expand Down
Loading
Loading