From 3f43bb95763122a94224a2fa51bd40b1a9b0d8c8 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Wed, 27 May 2026 19:05:49 -0500 Subject: [PATCH 01/18] spike: ComBackendSpike to validate WinGet COM API AOT readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone console project that exercises the smallest end-to-end path through the WinGet COM API (PackageManager → ConnectAsync → FindPackages with PackageMatchFilter → CatalogPackage property access). Goal is to catch the AOT-blocking warnings before committing to a full ComBackend implementation. Findings (full writeup in spikes/ComBackendSpike/SPIKE-RESULTS.md): - Trim analysis from Linux cross-compile shows 0 IL2026 and 0 IL3050 warnings — the two categories that historically block CsWinRT adoption in AOT-published apps. - 35 IL2081 warnings all originate from CsWinRT marshaler fallback paths (ABI.System.Collections.Generic.*, ABI.Windows.Foundation.*, WinRT.Marshaler<>). Zero warnings originate from the Microsoft.Management.Deployment surface itself. - The COM projection requires net*-windows10.0.26100.0 TFM; consuming project must pick a Windows TFM (or multi-target). - InProcCom package is ~440MB extracted; only needed when bundling the OOP COM server. Standard winget installations don't require it. - Cross-OS Native AOT is not supported by ilc, so the final `PublishAot=true` codegen and runtime smoke must be done on Windows. Compile-time gates pass; runtime gates remain. Verdict: green light to build the real ComBackend on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- spikes/ComBackendSpike/ComBackendSpike.csproj | 53 ++++++ spikes/ComBackendSpike/Program.cs | 86 ++++++++++ spikes/ComBackendSpike/SPIKE-RESULTS.md | 160 ++++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 spikes/ComBackendSpike/ComBackendSpike.csproj create mode 100644 spikes/ComBackendSpike/Program.cs create mode 100644 spikes/ComBackendSpike/SPIKE-RESULTS.md diff --git a/spikes/ComBackendSpike/ComBackendSpike.csproj b/spikes/ComBackendSpike/ComBackendSpike.csproj new file mode 100644 index 0000000..abd8b6d --- /dev/null +++ b/spikes/ComBackendSpike/ComBackendSpike.csproj @@ -0,0 +1,53 @@ + + + + + Exe + net10.0-windows10.0.26100.0 + enable + enable + latest + ComBackendSpike + com-backend-spike + + + true + true + true + + + win-x64;win-arm64 + + + true + + + false + false + true + true + false + + + + + + + + + diff --git a/spikes/ComBackendSpike/Program.cs b/spikes/ComBackendSpike/Program.cs new file mode 100644 index 0000000..cada92f --- /dev/null +++ b/spikes/ComBackendSpike/Program.cs @@ -0,0 +1,86 @@ +// Spike: exercise the smallest end-to-end path through the WinGet COM API +// that touches the types we'd use in a real backend. The goal is to make +// the AOT compiler reason about ConnectAsync, FindPackagesAsync, match +// filters, and CatalogPackage property access — if any of those don't +// survive trimming or fail AOT analysis, this is where it shows up. +// +// Layout choices: +// - No fancy CLI parsing; first arg is an optional query, default "powertoys". +// - No exception handling around the COM calls — we want the failure to +// bubble up so the runtime smoke (on Windows) reports it clearly. +// - All async via .AsTask() so the trim analyzer sees a normal Task +// and not just the WinRT projection. + +using Microsoft.Management.Deployment; + +string query = args.Length > 0 ? args [0] : "powertoys"; + +Console.WriteLine ($"[spike] querying winget COM for: {query}"); + +PackageManager pm = new (); + +// Step 1: enumerate available catalogs. Cheap; verifies COM activation works. +IReadOnlyList catalogs = pm.GetPackageCatalogs (); +Console.WriteLine ($"[spike] {catalogs.Count} catalogs available:"); + +foreach (PackageCatalogReference c in catalogs) +{ + Console.WriteLine ($" - {c.Info.Name} ({c.Info.Type})"); +} + +// Step 2: connect to the default winget catalog. +PackageCatalogReference wingetRef = pm.GetPackageCatalogByName ("winget"); +Console.WriteLine ("[spike] connecting to winget catalog…"); +ConnectResult connect = await wingetRef.ConnectAsync ().AsTask (); + +if (connect.Status != ConnectResultStatus.Ok) +{ + Console.Error.WriteLine ($"[spike] connect failed: {connect.Status}"); + + return 1; +} + +PackageCatalog catalog = connect.PackageCatalog; + +// Step 3: find packages. Uses every relevant filter/match type so the +// trimmer sees real usage of FindPackagesOptions / PackageMatchFilter. +FindPackagesOptions opts = new (); +PackageMatchFilter filter = new () +{ + // Name is one of the broadly-supported field types per the IDL + // (PackageManager.idl: CatalogDefault, Id, Name, Moniker, Command, Tag, PackageFamilyName, ProductCode). + Field = PackageMatchField.Name, + Option = PackageFieldMatchOption.ContainsCaseInsensitive, + Value = query +}; +opts.Filters.Add (filter); + +Console.WriteLine ($"[spike] running FindPackagesAsync…"); +FindPackagesResult result = await catalog.FindPackagesAsync (opts).AsTask (); +Console.WriteLine ($"[spike] {result.Matches.Count} matches (truncated={result.WasLimitExceeded})"); + +// Step 4: dereference each CatalogPackage to force the AOT compiler to +// retain InstalledVersion / AvailableVersions / DefaultInstallVersion +// property getters in the trimmed image. +int shown = 0; + +foreach (MatchResult m in result.Matches) +{ + CatalogPackage pkg = m.CatalogPackage; + PackageVersionInfo? installed = pkg.InstalledVersion; + IReadOnlyList available = pkg.AvailableVersions; + string installedV = installed?.Version ?? ""; + string latestV = available.Count > 0 ? available [0].Version : ""; + Console.WriteLine ($" · {pkg.Id} name={pkg.Name} installed={installedV} latest={latestV}"); + + if (++shown >= 5) + { + Console.WriteLine ($" · … ({result.Matches.Count - shown} more)"); + + break; + } +} + +Console.WriteLine ("[spike] done."); + +return 0; diff --git a/spikes/ComBackendSpike/SPIKE-RESULTS.md b/spikes/ComBackendSpike/SPIKE-RESULTS.md new file mode 100644 index 0000000..0531e27 --- /dev/null +++ b/spikes/ComBackendSpike/SPIKE-RESULTS.md @@ -0,0 +1,160 @@ +# ComBackendSpike — AOT readiness findings + +## What this spike answers + +> Before writing 350+ LOC of `ComBackend.cs`, can the WinGet COM API +> (`Microsoft.WindowsPackageManager.ComInterop`) be consumed from a +> Native-AOT-published winget-tui-sharp build? + +## Methodology + +A standalone console project (`spikes/ComBackendSpike/`) that exercises +the same code paths a real backend would touch: `PackageManager` → +`GetPackageCatalogs()` → `ConnectAsync()` → `FindPackagesAsync()` with +a `PackageMatchFilter`, then property access on every returned +`CatalogPackage`. ~60 LOC. + +Project settings mirror the main app: `PublishAot=true`, +`InvariantGlobalization=true`, `StripSymbols=true`. Trim/AOT analyzers +are explicitly enabled with `TrimmerSingleWarn=false` so every warning +is surfaced individually. + +## What can be verified from a Linux dev box + +- ✅ NuGet restore + build for `win-x64` (with `EnableWindowsTargeting=true`) +- ✅ Source-level Roslyn AOT/Trim analyzer pass (`EnableTrimAnalyzer`, `EnableAotAnalyzer`) +- ✅ IL-level trim analysis via `PublishTrimmed=true` (this is the bulk of what AOT cares about) +- ❌ **Native AOT codegen** — `dotnet publish -p:PublishAot=true` fails with + *"Cross-OS native compilation is not supported"*. Requires a Windows host. +- ❌ **Runtime smoke** — the produced .exe needs the Windows COM runtime + and an installed App Installer to actually do anything. + +So "fully test AOT support" can only be partially answered from this +environment. The cross-compile + trim-analysis pass below catches the +class of issues that historically blocked AOT for CsWinRT-projected +COM APIs; the final ilc step and runtime smoke must be done on Windows. + +## Verified findings (from Linux) + +### 1. The COM projection's TFM constraint + +`Microsoft.WindowsPackageManager.ComInterop` ships its managed projection +under `lib/net8.0-windows10.0.26100.0/Microsoft.Management.Deployment.CsWinRTProjection.dll`. + +Consequence: any project consuming it must target `netN.0-windows10.0.26100.0` +(or higher). For the main project, that means a TFM bump from `net10.0` to +`net10.0-windows10.0.26100.0` **on Windows publish profiles only**. +Non-Windows dev iteration with `MockBackend` would need a multi-targeted +csproj or a separate Windows-only project. + +### 2. The package rejects `AnyCPU` + +`Microsoft.WindowsPackageManager.ComInterop.common.targets` errors with +*"Microsoft.Management.Deployment.dll could not be copied because the +AnyCPU platform is being used"* unless one of `RuntimeIdentifier`, +`Platform=x64|arm64|x86`, or `MicrosoftManagementDeployment-Platform` +is set. Fine for our use — we always publish with a RID — but worth +knowing. + +### 3. The `InProcCom` package is huge + +`Microsoft.WindowsPackageManager.InProcCom` is 100 MB compressed, +~438 MB extracted. Most of that is debug PDBs (130+ MB per arch). The +runtime native DLLs are ~7–8 MB each. Only needed if you want to bundle +the COM server in-process; if the user already has App Installer +installed (the normal case on Windows 10/11), you can rely on the +registered OOP COM server and skip this package entirely. The spike's +`.csproj` references it as commented-out. + +### 4. Trim analyzer results — the key data point + +After `dotnet publish -r win-x64 -c Release -p:PublishTrimmed=true`: + +| Metric | Count | +|---|---| +| Total ILLink warnings | **35** | +| IL2026 (RequiresUnreferencedCode call — AOT-blocking) | **0** | +| IL3050 (RequiresDynamicCode call — AOT-blocking) | **0** | +| IL2081 (generic-arg DAM mismatch — informational) | **35** | +| Warnings in our spike code | **0** | +| Warnings in `Microsoft.Management.Deployment.*` (the WinGet API surface) | **0** | +| Warnings in `ABI.System.*` / `ABI.Windows.*` / `WinRT.Marshaler<>` (CsWinRT infra) | 35 | + +**Interpretation:** the WinGet COM projection itself is clean. Every +warning originates from CsWinRT's marshaler fallback paths (CCW +initialization helpers for `IList`, `IDictionary`, +`IEnumerable`, `IAsyncOperation`, `IAsyncOperationWithProgress`, +etc.). These are CsWinRT runtime infrastructure, not the WinGet API. + +IL2081 is "generic argument does not satisfy +`DynamicallyAccessedMemberTypes.PublicParameterlessConstructor`". It's a +*warning*, not an error — it means the trimmer might remove members the +fallback ABI path would have used. These fallback paths in CsWinRT are +guarded by checks the trimmer can't statically prove, so they emit warnings +even though in practice they're either never invoked or invoked with types +that survived trimming anyway. + +The CsWinRT team is aware (long-standing issue in microsoft/CsWinRT). +For our purposes the warnings are noise — but noise we'd want to suppress +in the real backend project to keep the build output readable. A targeted +`IL2081` scoped to the AOT publish, or `[UnconditionalSuppressMessage]` +where appropriate, is the standard workaround. + +### 5. Trimmed publish size + +21 MB for the entire `win-x64/publish/` directory (framework + spike). +Spike .exe itself is 159 KB. Once ilc runs (on Windows) this will collapse +further as native code replaces the JIT-compiled assemblies — expect the +final single-file AOT binary to land around 5–10 MB. + +## What needs a Windows host to finish verifying + +1. **Run `dotnet publish -r win-x64 -c Release -p:PublishAot=true`** on + a Windows machine. Watch for any IL2026/IL3050 the cross-OS trim + analyzer didn't catch, plus any IL2050 / IL3001 from ilc itself + (COM-specific codegen warnings). Goal: same 0 count as above. +2. **Smoke-run the produced `com-backend-spike.exe`**. Expected output: + list of catalogs, "connecting to winget catalog…", a count of matches, + and up to 5 package rows. If it throws on `new PackageManager()`, + App Installer isn't registered. If it throws on `ConnectAsync()`, + the registered COM server version is older than the projection. +3. **Measure binary size** after AOT. Compare to current `win-x64` + build of `winget-tui-sharp` to budget the cost of bundling the COM + backend. +4. **(Optional) repeat for `win-arm64`** on an arm64 host or via an + arm64 ilc cross-target. + +## Verdict + +**Go.** The AOT-blocking warnings (IL2026, IL3050) are zero in both our +code and the WinGet API projection. The 35 IL2081 warnings are +well-understood CsWinRT infrastructure noise, suppressible. The +compile-time AOT readiness gate — the gate that historically killed COM +adoption in AOT apps — passes. + +Remaining unknowns are all *runtime* unknowns that need a Windows tester: +does the COM activation actually work, is the binary size acceptable, +does cancellation propagate cleanly through `IAsyncOperationWithProgress`. +None of these are likely to surprise based on the static analysis above. + +## Reproducing on Linux + +```bash +cd spikes/ComBackendSpike +dotnet restore -r win-x64 +dotnet publish -r win-x64 -c Release \ + -p:PublishAot=false \ + -p:PublishTrimmed=true \ + -p:TrimmerSingleWarn=false \ + -p:SuppressTrimAnalysisWarnings=false +``` + +## Reproducing on Windows (the real test) + +```powershell +cd spikes\ComBackendSpike +dotnet restore -r win-x64 +dotnet publish -r win-x64 -c Release -p:PublishAot=true +.\bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\com-backend-spike.exe +.\bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\com-backend-spike.exe "visual studio code" +``` From cab1e6d6a43ac4adcce235e92554a35fb50b4cb5 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Wed, 27 May 2026 20:24:15 -0500 Subject: [PATCH 02/18] spike: add Run-AotSpike.ps1 wrapper anchored on $PSScriptRoot Lets the AOT publish + smoke run be invoked from any directory by absolute path. Wraps the publish/run sequence with cleaner output, parameters for query/RID, and a -SkipPublish flag for rerunning the already-built binary. Co-Authored-By: Claude Opus 4.7 (1M context) --- spikes/ComBackendSpike/Run-AotSpike.ps1 | 91 +++++++++++++++++++++++++ spikes/ComBackendSpike/SPIKE-RESULTS.md | 22 ++++-- 2 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 spikes/ComBackendSpike/Run-AotSpike.ps1 diff --git a/spikes/ComBackendSpike/Run-AotSpike.ps1 b/spikes/ComBackendSpike/Run-AotSpike.ps1 new file mode 100644 index 0000000..b85037e --- /dev/null +++ b/spikes/ComBackendSpike/Run-AotSpike.ps1 @@ -0,0 +1,91 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Publishes the ComBackendSpike as a NativeAOT win-x64 binary and runs it. + +.DESCRIPTION + Verifies that the WinGet COM API (Microsoft.WindowsPackageManager.ComInterop) + survives Native AOT codegen end-to-end, then smoke-tests the produced .exe + against the locally-installed winget catalog. + + Runs from any directory — all paths are resolved relative to this script's + location via $PSScriptRoot. + +.PARAMETER Query + Search term to pass to the spike. Defaults to "powertoys". + +.PARAMETER Rid + Runtime identifier to publish for. Defaults to "win-x64". + Use "win-arm64" on Windows on ARM hosts. + +.PARAMETER SkipPublish + Skip the publish step and just run the previously-built .exe. + +.EXAMPLE + .\Run-AotSpike.ps1 + Publish + run with the default "powertoys" query. + +.EXAMPLE + .\Run-AotSpike.ps1 -Query "visual studio code" + Publish + run with a custom query. + +.EXAMPLE + pwsh C:\src\winget-tui-sharp\spikes\ComBackendSpike\Run-AotSpike.ps1 -Rid win-arm64 + Invoke from any directory by absolute path. +#> + +[CmdletBinding ()] +param ( + [string] $Query = 'powertoys', + [ValidateSet ('win-x64', 'win-arm64')] + [string] $Rid = 'win-x64', + [switch] $SkipPublish +) + +$ErrorActionPreference = 'Stop' + +# Anchor every path on the script's own directory so cwd doesn't matter. +$projectDir = $PSScriptRoot +$projectFile = Join-Path $projectDir 'ComBackendSpike.csproj' +$tfm = 'net10.0-windows10.0.26100.0' +$publishDir = Join-Path $projectDir "bin\Release\$tfm\$Rid\publish" +$spikeExe = Join-Path $publishDir 'com-backend-spike.exe' + +if (-not (Test-Path -LiteralPath $projectFile)) +{ + throw "Project file not found: $projectFile" +} + +Write-Host "==> spike project : $projectFile" +Write-Host "==> publish dir : $publishDir" +Write-Host "==> RID : $Rid" +Write-Host "==> query : $Query" +Write-Host '' + +if (-not $SkipPublish) +{ + Write-Host '==> dotnet publish (Native AOT)…' + & dotnet publish $projectFile -r $Rid -c Release -p:PublishAot=true + if ($LASTEXITCODE -ne 0) + { + throw "dotnet publish failed with exit code $LASTEXITCODE" + } +} + +if (-not (Test-Path -LiteralPath $spikeExe)) +{ + throw "Spike binary not found at $spikeExe — publish step may have failed silently." +} + +$exeSize = (Get-Item -LiteralPath $spikeExe).Length / 1MB +Write-Host '' +Write-Host ("==> binary size : {0:N2} MB" -f $exeSize) +Write-Host "==> running : $spikeExe '$Query'" +Write-Host '' + +& $spikeExe $Query +$spikeExit = $LASTEXITCODE + +Write-Host '' +Write-Host "==> spike exit code: $spikeExit" +exit $spikeExit diff --git a/spikes/ComBackendSpike/SPIKE-RESULTS.md b/spikes/ComBackendSpike/SPIKE-RESULTS.md index 0531e27..4368e62 100644 --- a/spikes/ComBackendSpike/SPIKE-RESULTS.md +++ b/spikes/ComBackendSpike/SPIKE-RESULTS.md @@ -151,10 +151,22 @@ dotnet publish -r win-x64 -c Release \ ## Reproducing on Windows (the real test) +A self-contained PowerShell wrapper at `Run-AotSpike.ps1` publishes the +spike with `PublishAot=true` and runs the produced binary. All paths +inside the script are anchored on `$PSScriptRoot`, so it works no +matter the current directory: + ```powershell -cd spikes\ComBackendSpike -dotnet restore -r win-x64 -dotnet publish -r win-x64 -c Release -p:PublishAot=true -.\bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\com-backend-spike.exe -.\bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\com-backend-spike.exe "visual studio code" +# from anywhere +pwsh C:\path\to\winget-tui-sharp\spikes\ComBackendSpike\Run-AotSpike.ps1 +pwsh C:\path\to\winget-tui-sharp\spikes\ComBackendSpike\Run-AotSpike.ps1 -Query "visual studio code" +pwsh C:\path\to\winget-tui-sharp\spikes\ComBackendSpike\Run-AotSpike.ps1 -Rid win-arm64 +pwsh C:\path\to\winget-tui-sharp\spikes\ComBackendSpike\Run-AotSpike.ps1 -SkipPublish # rerun without rebuilding +``` + +Or the equivalent without the wrapper: + +```powershell +dotnet publish C:\path\to\winget-tui-sharp\spikes\ComBackendSpike\ComBackendSpike.csproj -r win-x64 -c Release -p:PublishAot=true +& C:\path\to\winget-tui-sharp\spikes\ComBackendSpike\bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\com-backend-spike.exe ``` From 2aa6e1d59331f06246f54e1cfca6a8896ced0b8c Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 15:59:34 -0500 Subject: [PATCH 03/18] spike: fix AOT InvalidCastException enumerating projected COM collections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First Windows AOT run proved codegen + COM activation work (3 catalogs returned) but threw InvalidCastException in IReadOnlyListImpl.GetEnumerator the moment we foreach'd a projected IReadOnlyList: the IIterable RCW factory for that generic instantiation must be generated in the consuming app, and ComInterop ships only the WinRT.Runtime runtime, not the CsWinRT source generator. Fix (ComBackendSpike.csproj): - reference Microsoft.Windows.CsWinRT 2.2.0 (matches WinRT.Runtime in graph) so the AOT optimizer source generator runs - CsWinRTAotOptimizerEnabled=Auto, AllowUnsafeBlocks=true - CsWinRTGenerateProjection=false so it consumes ComInterop's prebuilt projection instead of re-running cswinrt.exe over the winmd - CsWinRTRcwFactoryFallbackGeneratorForceOptIn=true — the by-name knob that generates RCW factories for projected types consumed under AOT Program.cs rewritten as a two-lever diagnostic: Probe() traverses each projected collection via foreach (IIterable) and, on failure, via indexed access (IVectorView.GetAt), reporting which survives AOT — so one Windows run yields a decision table instead of a crash. Verified as far as a non-Windows host allows: restore (no version skew), compile, and WinRT.SourceGenerator running with ForceOptIn flowing to the compiler. Runtime confirmation pending a Windows re-run of Run-AotSpike.ps1. Co-Authored-By: Claude Opus 4.8 (1M context) --- spikes/ComBackendSpike/ComBackendSpike.csproj | 46 ++++ spikes/ComBackendSpike/Program.cs | 209 ++++++++++++------ spikes/ComBackendSpike/SPIKE-RESULTS.md | 93 +++++++- 3 files changed, 273 insertions(+), 75 deletions(-) diff --git a/spikes/ComBackendSpike/ComBackendSpike.csproj b/spikes/ComBackendSpike/ComBackendSpike.csproj index abd8b6d..b44edd8 100644 --- a/spikes/ComBackendSpike/ComBackendSpike.csproj +++ b/spikes/ComBackendSpike/ComBackendSpike.csproj @@ -29,6 +29,44 @@ true true + + true + Auto + + + false + + + true + win-x64;win-arm64 @@ -45,6 +83,14 @@ + + diff --git a/spikes/ComBackendSpike/Program.cs b/spikes/ComBackendSpike/Program.cs index cada92f..f9b0e6c 100644 --- a/spikes/ComBackendSpike/Program.cs +++ b/spikes/ComBackendSpike/Program.cs @@ -1,86 +1,165 @@ // Spike: exercise the smallest end-to-end path through the WinGet COM API -// that touches the types we'd use in a real backend. The goal is to make -// the AOT compiler reason about ConnectAsync, FindPackagesAsync, match -// filters, and CatalogPackage property access — if any of those don't -// survive trimming or fail AOT analysis, this is where it shows up. +// that touches the types we'd use in a real backend, and — critically — probe +// the AOT failure mode we hit on the first Windows run: // -// Layout choices: -// - No fancy CLI parsing; first arg is an optional query, default "powertoys". -// - No exception handling around the COM calls — we want the failure to -// bubble up so the runtime smoke (on Windows) reports it clearly. -// - All async via .AsTask() so the trim analyzer sees a normal Task -// and not just the WinRT projection. +// Unhandled exception. System.InvalidCastException: Specified cast is not valid. +// at System.Collections.Generic.IReadOnlyListImpl`1.Make_IEnumerableObjRef() +// at System.Collections.Generic.IReadOnlyListImpl`1.GetEnumerator() +// +// Diagnosis: AOT codegen + COM activation both work (we got "3 catalogs"). The +// failure is enumerating a CsWinRT-projected IReadOnlyList via foreach, which +// goes through IIterable — a generic-instantiation RCW factory that must be +// generated in the *consuming* app. `.Count` (IVectorView.Size) works without it; +// `foreach` (IIterable.First) does not. +// +// This spike now tests TWO independent fixes in a single run so one round-trip on +// Windows tells us exactly what the real backend needs: +// 1. Config: CsWinRTRcwFactoryFallbackGeneratorForceOptIn=true (see .csproj) — +// should make `foreach` work by generating the RCW factories. +// 2. Code: indexed access via IVectorView.GetAt (list[i]) — should work +// regardless, since `.Count` already does. +// +// For each projected collection we touch, Probe() reports whether foreach works +// and whether indexing works, so the output is a decision table, not a crash. using Microsoft.Management.Deployment; -string query = args.Length > 0 ? args [0] : "powertoys"; +int Run (string query) +{ + Console.WriteLine ($"[spike] querying winget COM for: {query}"); -Console.WriteLine ($"[spike] querying winget COM for: {query}"); + PackageManager pm = new (); -PackageManager pm = new (); + // Step 1: enumerate available catalogs. Verifies COM activation + that the + // first projected collection (IReadOnlyList) can be + // traversed under AOT — this is exactly where the original run threw. + IReadOnlyList catalogs = pm.GetPackageCatalogs (); + Probe ( + "catalogs (IReadOnlyList)", + catalogs, + c => Console.WriteLine ($" - {c.Info.Name} ({c.Info.Type})")); -// Step 1: enumerate available catalogs. Cheap; verifies COM activation works. -IReadOnlyList catalogs = pm.GetPackageCatalogs (); -Console.WriteLine ($"[spike] {catalogs.Count} catalogs available:"); + // Step 2: connect to the default winget catalog. + PackageCatalogReference wingetRef = pm.GetPackageCatalogByName ("winget"); + Console.WriteLine ("[spike] connecting to winget catalog…"); + ConnectResult connect = wingetRef.ConnectAsync ().AsTask ().GetAwaiter ().GetResult (); -foreach (PackageCatalogReference c in catalogs) -{ - Console.WriteLine ($" - {c.Info.Name} ({c.Info.Type})"); -} + if (connect.Status != ConnectResultStatus.Ok) + { + Console.Error.WriteLine ($"[spike] connect failed: {connect.Status}"); -// Step 2: connect to the default winget catalog. -PackageCatalogReference wingetRef = pm.GetPackageCatalogByName ("winget"); -Console.WriteLine ("[spike] connecting to winget catalog…"); -ConnectResult connect = await wingetRef.ConnectAsync ().AsTask (); + return 1; + } -if (connect.Status != ConnectResultStatus.Ok) -{ - Console.Error.WriteLine ($"[spike] connect failed: {connect.Status}"); + PackageCatalog catalog = connect.PackageCatalog; - return 1; + // Step 3: find packages. + FindPackagesOptions opts = new (); + PackageMatchFilter filter = new () + { + Field = PackageMatchField.Name, + Option = PackageFieldMatchOption.ContainsCaseInsensitive, + Value = query + }; + opts.Filters.Add (filter); + + Console.WriteLine ($"[spike] running FindPackagesAsync…"); + FindPackagesResult result = catalog.FindPackagesAsync (opts).AsTask ().GetAwaiter ().GetResult (); + + // Step 4: traverse the matches (IReadOnlyList) and, per package, + // the AvailableVersions list (IReadOnlyList) — the two other + // projected-collection instantiations the real backend depends on. + Probe ( + "matches (IReadOnlyList)", + result.Matches, + m => + { + CatalogPackage pkg = m.CatalogPackage; + PackageVersionInfo? installed = pkg.InstalledVersion; + IReadOnlyList available = pkg.AvailableVersions; + string installedV = installed?.Version ?? ""; + string latestV = available.Count > 0 ? GetFirstVersion (available) : ""; + Console.WriteLine ($" · {pkg.Id} name={pkg.Name} installed={installedV} latest={latestV}"); + }); + + Console.WriteLine ("[spike] done."); + + return 0; } -PackageCatalog catalog = connect.PackageCatalog; +// Read the first element of a projected list via indexing only (IVectorView.GetAt), +// deliberately avoiding foreach so this never trips the IIterable path. +string GetFirstVersion (IReadOnlyList versions) + => versions [0].Version; -// Step 3: find packages. Uses every relevant filter/match type so the -// trimmer sees real usage of FindPackagesOptions / PackageMatchFilter. -FindPackagesOptions opts = new (); -PackageMatchFilter filter = new () -{ - // Name is one of the broadly-supported field types per the IDL - // (PackageManager.idl: CatalogDefault, Id, Name, Moniker, Command, Tag, PackageFamilyName, ProductCode). - Field = PackageMatchField.Name, - Option = PackageFieldMatchOption.ContainsCaseInsensitive, - Value = query -}; -opts.Filters.Add (filter); - -Console.WriteLine ($"[spike] running FindPackagesAsync…"); -FindPackagesResult result = await catalog.FindPackagesAsync (opts).AsTask (); -Console.WriteLine ($"[spike] {result.Matches.Count} matches (truncated={result.WasLimitExceeded})"); - -// Step 4: dereference each CatalogPackage to force the AOT compiler to -// retain InstalledVersion / AvailableVersions / DefaultInstallVersion -// property getters in the trimmed image. -int shown = 0; - -foreach (MatchResult m in result.Matches) +// Traverse a projected IReadOnlyList both ways and report which paths survive AOT. +// `.Count` is assumed to work (it did in the original run); the question is foreach +// (IIterable) vs indexing (IVectorView.GetAt). +void Probe (string label, IReadOnlyList list, Action print) { - CatalogPackage pkg = m.CatalogPackage; - PackageVersionInfo? installed = pkg.InstalledVersion; - IReadOnlyList available = pkg.AvailableVersions; - string installedV = installed?.Version ?? ""; - string latestV = available.Count > 0 ? available [0].Version : ""; - Console.WriteLine ($" · {pkg.Id} name={pkg.Name} installed={installedV} latest={latestV}"); - - if (++shown >= 5) + int count; + + try + { + count = list.Count; + } + catch (Exception ex) { - Console.WriteLine ($" · … ({result.Matches.Count - shown} more)"); + Console.WriteLine ($"[spike] {label}: ✗ even .Count threw {ex.GetType ().Name}: {ex.Message}"); - break; + return; } -} -Console.WriteLine ("[spike] done."); + Console.WriteLine ($"[spike] {label}: {count} item(s)"); + + // Path A — foreach, i.e. IIterable.First/IIterator. This is the path that + // threw on the first AOT run. If the RCW factory fallback fix worked, it now runs. + bool foreachOk; + + try + { + int shown = 0; + + foreach (T item in list) + { + print (item); + + if (++shown >= 5) + { + break; + } + } + + foreachOk = true; + Console.WriteLine ($"[spike] ✓ foreach (IIterable<{typeof (T).Name}>) WORKS under AOT"); + } + catch (Exception ex) + { + foreachOk = false; + Console.WriteLine ($"[spike] ✗ foreach threw {ex.GetType ().Name}: {ex.Message}"); + } + + if (foreachOk) + { + return; + } + + // Path B — indexed access via IVectorView.GetAt. Same ABI surface as .Count, + // so this is the robust fallback for the real backend if config can't cover + // every instantiation. + try + { + for (int i = 0; i < Math.Min (count, 5); i++) + { + print (list [i]); + } + + Console.WriteLine ($"[spike] ✓ indexed for-loop (IVectorView.GetAt) WORKS — use this pattern in the backend"); + } + catch (Exception ex) + { + Console.WriteLine ($"[spike] ✗ indexed access ALSO threw {ex.GetType ().Name}: {ex.Message} — problem is deeper than enumeration"); + } +} -return 0; +return Run (args.Length > 0 ? args [0] : "powertoys"); diff --git a/spikes/ComBackendSpike/SPIKE-RESULTS.md b/spikes/ComBackendSpike/SPIKE-RESULTS.md index 4368e62..090ad82 100644 --- a/spikes/ComBackendSpike/SPIKE-RESULTS.md +++ b/spikes/ComBackendSpike/SPIKE-RESULTS.md @@ -107,6 +107,76 @@ Spike .exe itself is 159 KB. Once ilc runs (on Windows) this will collapse further as native code replaces the JIT-compiled assemblies — expect the final single-file AOT binary to land around 5–10 MB. +## Runtime findings (from the first Windows AOT run) + +The AOT publish **succeeded** and the binary ran. Two of the three runtime +unknowns resolved immediately, and the third surfaced a real, well-understood +CsWinRT-under-AOT issue with a known fix. + +``` +[spike] querying winget COM for: powertoys +[spike] 3 catalogs available: +Unhandled exception. System.InvalidCastException: Specified cast is not valid. + at System.Collections.Generic.IReadOnlyListImpl`1.Make_IEnumerableObjRef() + at System.Collections.Generic.IReadOnlyListImpl`1.GetEnumerator() + at Program.<
$>d__0.MoveNext() +``` + +What this proves: + +- ✅ **Native AOT codegen works** — the `+ 0xNN` raw native offsets (no IL/line + info) are the AOT signature; this was a real ilc-compiled binary, not JIT. +- ✅ **COM activation works** — `new PackageManager()` succeeded and + `GetPackageCatalogs()` returned **3 catalogs**, so `IVectorView.Size` + (`.Count`) marshals fine. +- ❌ **`foreach` over a projected `IReadOnlyList` throws** — enumeration goes + through `IIterable`, whose RCW (runtime-callable-wrapper) factory for that + *generic instantiation* must be generated in the **consuming** app. With no JIT + to synthesize it on demand, the `IIterable` cast fails. + +### Root cause + +The `ComInterop` package ships the WinRT projection (`WinRT.Runtime` 2.2.0, and +the projection DLL even carries `WinRTExposedTypeAttribute`, so the projection +itself is AOT-aware) — **but it does not bring the CsWinRT build-time source +generator**. A consuming app that wants AOT must reference `Microsoft.Windows.CsWinRT` +itself so the generator emits the marshaling for the generic-collection +instantiations *its own code* touches. Ours didn't, so `IIterable` was never +generated → `.Count` works, `foreach` throws. + +### The fix (in `ComBackendSpike.csproj`) + +```xml + +true +Auto + +false + +true +``` + +`CsWinRTRcwFactoryFallbackGeneratorForceOptIn` is the by-name knob for exactly +this scenario ("I consume a third-party projection under AOT"); it's off by +default for non-component apps, which is why the bare consumer hit the cast. + +### Belt-and-suspenders: indexing instead of `foreach` + +The spike's `Probe()` now traverses each projected list **both** ways and +reports which survives AOT. Indexed access (`list[i]` → `IVectorView.GetAt`) uses +the same ABI surface as `.Count`, so it's expected to work even if the config fix +somehow misses an instantiation. **If `foreach` still throws after the fix, the +real backend should consume WinRT vector views with `for (int i…)` loops** — a +cheap, guaranteed-AOT-safe pattern. One Windows run now produces a decision table +instead of a crash. + +> ⚠️ Verification limit: this fix was validated only as far as a non-Windows dev +> box allows — restore resolves with no version skew (CsWinRT 2.2.0 matches the +> `WinRT.Runtime` already in the graph), the project compiles, and the +> `WinRT.SourceGenerator` analyzer runs with `…ForceOptIn = true` confirmed +> flowing to the compiler. The runtime behavior must be confirmed by re-running +> `Run-AotSpike.ps1` on Windows. + ## What needs a Windows host to finish verifying 1. **Run `dotnet publish -r win-x64 -c Release -p:PublishAot=true`** on @@ -126,16 +196,19 @@ final single-file AOT binary to land around 5–10 MB. ## Verdict -**Go.** The AOT-blocking warnings (IL2026, IL3050) are zero in both our -code and the WinGet API projection. The 35 IL2081 warnings are -well-understood CsWinRT infrastructure noise, suppressible. The -compile-time AOT readiness gate — the gate that historically killed COM -adoption in AOT apps — passes. - -Remaining unknowns are all *runtime* unknowns that need a Windows tester: -does the COM activation actually work, is the binary size acceptable, -does cancellation propagate cleanly through `IAsyncOperationWithProgress`. -None of these are likely to surprise based on the static analysis above. +**Go — with a known, fixed gotcha.** The static gate passes (0 IL2026/IL3050; +the 35 IL2081 are CsWinRT infra noise). The first Windows run then proved AOT +codegen *and* COM activation both work, and surfaced exactly one real issue: +enumerating projected generic collections under AOT needs the consuming app to +run the CsWinRT source generator and opt into the RCW factory fallback +(`CsWinRTRcwFactoryFallbackGeneratorForceOptIn=true`). That's now wired into the +spike's `.csproj`, with indexed-access as a guaranteed-safe fallback pattern for +the real backend. This was the one issue that historically blocks COM-in-AOT, and +it has a documented fix — not a dead end. + +Re-run `Run-AotSpike.ps1` on Windows to confirm the fix at runtime. Remaining +genuine unknowns: final AOT binary size, and clean cancellation through +`IAsyncOperationWithProgress` — neither expected to surprise. ## Reproducing on Linux From ae9aba92d3ff06e4e8e896f8632be64d4a92b544 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 16:33:17 -0500 Subject: [PATCH 04/18] spike: AOT recipe is ComInterop + indexing; drop ineffective CsWinRT optimizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows runtime result: even with Microsoft.Windows.CsWinRT 2.2.0 + CsWinRTRcwFactoryFallbackGeneratorForceOptIn=true wired in and confirmed reaching the compiler, `foreach` over a projected IReadOnlyList still throws InvalidCastException under AOT for both catalogs and matches — the IIterable generic-instantiation RCW is not generated for this third-party projection. Indexed access (IVectorView.GetAt) works perfectly with no optimizer at all (same surface as .Count, which worked in the very first run). So the optimizer package earned nothing: removed it and all four CsWinRT properties + AllowUnsafeBlocks. Final recipe is just the ComInterop reference plus index-based loops over WinRT collections. SPIKE-RESULTS.md updated with the decision table, the Materialize helper pattern for the real backend, and the corrected verdict. Compile-clean on Linux with no cswinrt.exe in the graph; runtime behavior validated on Windows. Co-Authored-By: Claude Opus 4.8 (1M context) --- spikes/ComBackendSpike/ComBackendSpike.csproj | 54 ++------- spikes/ComBackendSpike/SPIKE-RESULTS.md | 108 +++++++++++------- 2 files changed, 78 insertions(+), 84 deletions(-) diff --git a/spikes/ComBackendSpike/ComBackendSpike.csproj b/spikes/ComBackendSpike/ComBackendSpike.csproj index b44edd8..5e9a456 100644 --- a/spikes/ComBackendSpike/ComBackendSpike.csproj +++ b/spikes/ComBackendSpike/ComBackendSpike.csproj @@ -30,42 +30,18 @@ true - true - Auto - - - false - - - true win-x64;win-arm64 @@ -83,14 +59,6 @@ - - diff --git a/spikes/ComBackendSpike/SPIKE-RESULTS.md b/spikes/ComBackendSpike/SPIKE-RESULTS.md index 090ad82..75c3392 100644 --- a/spikes/ComBackendSpike/SPIKE-RESULTS.md +++ b/spikes/ComBackendSpike/SPIKE-RESULTS.md @@ -136,46 +136,72 @@ What this proves: ### Root cause -The `ComInterop` package ships the WinRT projection (`WinRT.Runtime` 2.2.0, and -the projection DLL even carries `WinRTExposedTypeAttribute`, so the projection -itself is AOT-aware) — **but it does not bring the CsWinRT build-time source -generator**. A consuming app that wants AOT must reference `Microsoft.Windows.CsWinRT` -itself so the generator emits the marshaling for the generic-collection -instantiations *its own code* touches. Ours didn't, so `IIterable` was never -generated → `.Count` works, `foreach` throws. +`foreach` goes through `IIterable` (`GetEnumerator` → `First`). Under AOT, +the runtime-callable-wrapper for that *generic instantiation* +(`IIterable`, etc.) must be generated ahead of time +because there is no JIT to synthesize it on demand. The `ComInterop` projection +is itself AOT-aware (`WinRT.Runtime` 2.2.0; the projection DLL carries +`WinRTExposedTypeAttribute`) and registers RCWs for its **own types** — which is +why property access (`pkg.Id`, `pkg.InstalledVersion`, …) and `IVectorView` +(`.Count`, `GetAt`) all work. But it does not register the generic *collection* +instantiations a consumer might enumerate, and nothing else generated them. -### The fix (in `ComBackendSpike.csproj`) +### What did NOT fix it + +Referencing `Microsoft.Windows.CsWinRT` 2.2.0 and turning on the AOT optimizer ++ RCW factory fallback generator: ```xml -true Auto - false - true ``` -`CsWinRTRcwFactoryFallbackGeneratorForceOptIn` is the by-name knob for exactly -this scenario ("I consume a third-party projection under AOT"); it's off by -default for non-component apps, which is why the bare consumer hit the cast. +This is the documented knob for "I consume a third-party projection under AOT," +and it was confirmed wired in (analyzer ran, `…ForceOptIn = true` reached the +compiler). **But the Windows runtime still threw `InvalidCastException` on +`foreach` for both `catalogs` and `matches`.** The generator does not emit the +`IIterable` instantiation for this projection's element types. (CsWinRT 3.0, +built on .NET 10 with trim/AOT as a first-class goal, is the upstream fix; not +shipped as stable yet.) These properties were therefore **removed** from the +spike — they added a heavy build-time dependency and the `cswinrt.exe` cross-OS +friction for zero runtime benefit. -### Belt-and-suspenders: indexing instead of `foreach` +### What DID work — the recipe: index, don't enumerate -The spike's `Probe()` now traverses each projected list **both** ways and -reports which survives AOT. Indexed access (`list[i]` → `IVectorView.GetAt`) uses -the same ABI surface as `.Count`, so it's expected to work even if the config fix -somehow misses an instantiation. **If `foreach` still throws after the fix, the -real backend should consume WinRT vector views with `for (int i…)` loops** — a -cheap, guaranteed-AOT-safe pattern. One Windows run now produces a decision table -instead of a crash. +Indexed access via `IVectorView.GetAt` (`list[i]`) works perfectly under AOT and +needs **no** CsWinRT optimizer at all — the same `IVectorView` surface as `.Count`, +which worked in the very first run before the optimizer package existed. The +spike's `Probe()` confirms this for every projected collection: -> ⚠️ Verification limit: this fix was validated only as far as a non-Windows dev -> box allows — restore resolves with no version skew (CsWinRT 2.2.0 matches the -> `WinRT.Runtime` already in the graph), the project compiles, and the -> `WinRT.SourceGenerator` analyzer runs with `…ForceOptIn = true` confirmed -> flowing to the compiler. The runtime behavior must be confirmed by re-running -> `Run-AotSpike.ps1` on Windows. +``` +[spike] catalogs (IReadOnlyList): 3 item(s) +[spike] ✗ foreach threw InvalidCastException: Specified cast is not valid. +[spike] ✓ indexed for-loop (IVectorView.GetAt) WORKS - use this pattern in the backend +[spike] matches (IReadOnlyList): 3 item(s) +[spike] ✗ foreach threw InvalidCastException: Specified cast is not valid. +[spike] ✓ indexed for-loop (IVectorView.GetAt) WORKS - use this pattern in the backend +``` + +**Backend rule:** never `foreach` (or LINQ) directly over a WinRT-projected +collection. Index it. The clean way is one tiny helper that materializes a WinRT +list into a normal `List` via indexing, after which all the usual +`foreach`/LINQ works on the managed copy: + +```csharp +static List Materialize (IReadOnlyList winrt) +{ + var copy = new List (winrt.Count); + for (int i = 0; i < winrt.Count; i++) copy.Add (winrt [i]); + return copy; +} +``` + +### Final dependency footprint + +`Microsoft.WindowsPackageManager.ComInterop` only. No CsWinRT optimizer package, +no extra build-time tooling, no `AllowUnsafeBlocks`. The simplest possible recipe. ## What needs a Windows host to finish verifying @@ -196,19 +222,19 @@ instead of a crash. ## Verdict -**Go — with a known, fixed gotcha.** The static gate passes (0 IL2026/IL3050; -the 35 IL2081 are CsWinRT infra noise). The first Windows run then proved AOT -codegen *and* COM activation both work, and surfaced exactly one real issue: -enumerating projected generic collections under AOT needs the consuming app to -run the CsWinRT source generator and opt into the RCW factory fallback -(`CsWinRTRcwFactoryFallbackGeneratorForceOptIn=true`). That's now wired into the -spike's `.csproj`, with indexed-access as a guaranteed-safe fallback pattern for -the real backend. This was the one issue that historically blocks COM-in-AOT, and -it has a documented fix — not a dead end. - -Re-run `Run-AotSpike.ps1` on Windows to confirm the fix at runtime. Remaining -genuine unknowns: final AOT binary size, and clean cancellation through -`IAsyncOperationWithProgress` — neither expected to surprise. +**Go.** Native AOT codegen, COM activation, `ConnectAsync`, `FindPackagesAsync`, +and property marshaling on projected types all work on a real Windows AOT build. +The static gate passes (0 IL2026/IL3050; the 35 IL2081 are CsWinRT infra noise). + +The single constraint: **do not `foreach`/LINQ over WinRT-projected collections +— index them** (`IVectorView.GetAt`), or materialize to a `List` first via the +helper above. `foreach` (`IIterable`) throws `InvalidCastException` under AOT +and the CsWinRT optimizer did not fix it for this projection; indexing works with +zero extra dependencies. That's a cheap, well-bounded rule, not a blocker. + +Dependency footprint is just `ComInterop`. Remaining genuine unknowns: final AOT +binary size, and clean cancellation through `IAsyncOperationWithProgress` — +neither expected to surprise. ## Reproducing on Linux From af467a6fa4b53cb27bd11ea0ac7a6be8bc935d84 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 16:52:39 -0500 Subject: [PATCH 05/18] feat: add WinGet COM backend with runtime backend selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ComBackend (IBackend over the WinGet COM API) as a third backend alongside CliBackend and MockBackend, selectable at runtime, plus the build plumbing to ship it only where it can exist. Structure: - Main project now multi-targets net10.0 (cross-platform; mock/CLI) and net10.0-windows10.0.26100.0 (adds COM). ComBackend.cs + the ComInterop package + the WINGET_COM compile constant are scoped to the Windows TFM via csproj conditions; on net10.0 the file compiles to nothing so the Linux/mac dev loop is unaffected. EnableWindowsTargeting lets the Windows TFM build from a non-Windows host. Windows publish now needs -f net10.0-windows10.0.26100.0 (README updated). - Also fixes a pre-existing breakage: the root project globbed spikes/** into the main compile; now excluded alongside tests/**. ComBackend: - Search/Installed/Upgrades via composite catalogs (RemotePackagesFromRemote for search, LocalCatalogs for installed/upgrades); Show reads CatalogPackageMetadata; Install/Upgrade/Uninstall via the PackageManager async ops with status->OpResult mapping. - AOT rule from the spike honored throughout: never foreach a WinRT-projected collection (IIterable RCW isn't generated under AOT). All projected lists go through Materialize(), which copies via indexed IVectorView.GetAt. Trim analysis on the Windows TFM: 0 IL2026/IL3050. - No COM pin API, so Pin/Unpin/ListPins delegate to an internal CliBackend (winget.exe is always present where the COM server is) — keeps full parity. - HRESULT ExtendedErrorCode projects to System.Exception in this projection; HResultOf() pulls the numeric code back out for error messages. Runtime selection (Program.cs): --mock/-m, --cli, --com; default is COM on the Windows build, CLI elsewhere; both degrade to mock if winget is unusable. COM activation failure is caught and falls back to CLI rather than crashing. Verified: net10.0 builds + tests pass on Linux; Windows TFM compiles and trim-analyzes clean via EnableWindowsTargeting. Live COM runtime still to be exercised on Windows. Co-Authored-By: Claude Opus 4.8 (1M context) --- Program.cs | 65 +++++- README.md | 75 ++++--- WingetTuiSharp.csproj | 46 +++- src/ComBackend.cs | 496 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 644 insertions(+), 38 deletions(-) create mode 100644 src/ComBackend.cs diff --git a/Program.cs b/Program.cs index 2afcac5..722fb73 100644 --- a/Program.cs +++ b/Program.cs @@ -65,14 +65,13 @@ return; } -bool useMock = args.Any (a => a is "--mock" or "-m") || !IsWingetAvailable (); - -if (useMock && !args.Any (a => a is "--mock" or "-m")) -{ - Console.Error.WriteLine ("winget not found on PATH — falling back to mock backend. Run with `winget` available to drive the real CLI."); -} - -IBackend backend = useMock ? new MockBackend () : new CliBackend (); +// Backend selection (in priority order): +// --mock / -m force the in-memory mock (cross-platform dev/parity) +// --cli force the winget.exe CLI parser +// --com force the WinGet COM API backend (Windows builds only) +// (default) COM on Windows builds, CLI elsewhere; either falls back to mock +// if winget isn't usable. +IBackend backend = SelectBackend (args); Theme.Register (); @@ -84,6 +83,56 @@ return; +static IBackend SelectBackend (string [] args) +{ + bool wantMock = args.Any (a => a is "--mock" or "-m"); + bool wantCli = args.Any (a => a is "--cli"); + bool wantCom = args.Any (a => a is "--com"); + + if (wantMock) + { + return new MockBackend (); + } + +#if WINGET_COM + // On the Windows build, COM is the default unless the user explicitly asked for the CLI. + if (wantCom || (!wantCli && OperatingSystem.IsWindows ())) + { + try + { + return new ComBackend (); + } + catch (Exception ex) + { + // COM server not registered / activation failed — degrade gracefully rather than crash. + Console.Error.WriteLine ($"COM backend unavailable ({ex.Message}); falling back to the CLI backend."); + } + } +#else + if (wantCom) + { + Console.Error.WriteLine ("--com is only available in the Windows build (net10.0-windows…); using the CLI backend instead."); + } +#endif + + // CLI path (explicit --cli, or the non-Windows / COM-unavailable default). + if (!IsWingetAvailable ()) + { + if (!wantCli) + { + Console.Error.WriteLine ("winget not found on PATH — falling back to mock backend. Run with `winget` available to drive the real CLI."); + } + else + { + Console.Error.WriteLine ("winget not found on PATH — mock backend used despite --cli."); + } + + return new MockBackend (); + } + + return new CliBackend (); +} + static bool IsWingetAvailable () { try diff --git a/README.md b/README.md index 3b2f112..ddc38b2 100644 --- a/README.md +++ b/README.md @@ -103,19 +103,23 @@ Or right-click the exe → *Properties* → check *Unblock* → *OK*. On the fir The architecture you build for must match where the binary will run: -| Target Windows machine | Command | -| --------------------------------------------------------------- | ---------------------------------------- | -| Intel / AMD x64 (most Windows PCs) | `dotnet publish -c Release -r win-x64` | -| ARM64 (Surface Pro X, Snapdragon Copilot+ PCs, Windows Dev Kit) | `dotnet publish -c Release -r win-arm64` | +The project multi-targets `net10.0` (cross-platform; mock/CLI backends) and +`net10.0-windows10.0.26100.0` (the Windows deploy target, which adds the COM backend). +Windows release builds must select the Windows TFM with `-f`: + +| Target Windows machine | Command | +| --------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| Intel / AMD x64 (most Windows PCs) | `dotnet publish -c Release -f net10.0-windows10.0.26100.0 -r win-x64` | +| ARM64 (Surface Pro X, Snapdragon Copilot+ PCs, Windows Dev Kit) | `dotnet publish -c Release -f net10.0-windows10.0.26100.0 -r win-arm64` | ```powershell # x64 (Intel/AMD) -dotnet publish -c Release -r win-x64 -.\bin\Release\net10.0\win-x64\publish\winget-tui-sharp.exe +dotnet publish -c Release -f net10.0-windows10.0.26100.0 -r win-x64 +.\bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\winget-tui-sharp.exe # arm64 -dotnet publish -c Release -r win-arm64 -.\bin\Release\net10.0\win-arm64\publish\winget-tui-sharp.exe +dotnet publish -c Release -f net10.0-windows10.0.26100.0 -r win-arm64 +.\bin\Release\net10.0-windows10.0.26100.0\win-arm64\publish\winget-tui-sharp.exe ``` **Cross-architecture compile** (`x64 → arm64` or `arm64 → x64`) works on Windows as long @@ -126,13 +130,24 @@ Copy `winget-tui-sharp.exe` anywhere, no other files required. ### Dev iteration on any host (including WSL / macOS / Linux) -For iterating on the code, `dotnet run` is faster than re-publishing AOT each time, and unlike the AOT publish it works on any OS - handy for hacking on the UI from WSL. There's no `winget` to invoke on non-Windows hosts, so use `--mock`: +For iterating on the code, `dotnet run` is faster than re-publishing AOT each time, and unlike the AOT publish it works on any OS - handy for hacking on the UI from WSL. Because the project multi-targets, pick the cross-platform TFM with `-f net10.0` off-Windows. There's no `winget` to invoke on non-Windows hosts, so use `--mock`: ```bash -dotnet run # Windows: hits real winget -dotnet run -- --mock # any host: mock backend, useful for UI development +dotnet run -f net10.0 # any host: auto-falls back to mock if winget is absent +dotnet run -f net10.0 -- --mock # any host: force the mock backend (UI development) ``` +#### Choosing a backend at runtime + +| Flag | Backend | Notes | +| ----------- | ------------- | ---------------------------------------------------------------- | +| `--mock` / `-m` | `MockBackend` | In-memory fixtures; works on any OS. | +| `--cli` | `CliBackend` | Shells out to `winget.exe` and parses its table output. | +| `--com` | `ComBackend` | WinGet **COM API** — structured results, no stdout parsing. Windows build only. | +| _(default)_ | COM on Windows builds, CLI elsewhere | Either degrades to the mock backend if `winget` isn't usable. | + +The COM backend talks to the WinGet COM API directly instead of parsing CLI output. Pinning has no COM surface, so pin/unpin/list-pins transparently delegate to the CLI. See [`spikes/ComBackendSpike/SPIKE-RESULTS.md`](spikes/ComBackendSpike/SPIKE-RESULTS.md) for the AOT validation behind it. + ### Run the test suite ```bash @@ -243,33 +258,38 @@ Mirrors `src/handler.rs` in the upstream: │ IBackend │ │ Search · ListInstalled · ListUpgrades · Show │ │ Install · Uninstall · Upgrade · Pin · Unpin · ListPins │ - └─────────────┬───────────────────────────────────┬───────────┘ - ▼ ▼ - ┌──────────────────────────────┐ ┌──────────────────────────┐ - │ CliBackend (Windows) │ │ MockBackend (--mock) │ - │ ParseTable / ParseShow / │ │ in-memory fixtures so │ - │ ParsePins / dedupe / │ │ the UI runs on any host │ - │ display-width column slice │ │ for development │ - └──────────────┬───────────────┘ └──────────────────────────┘ - ▼ - ┌─────────────────────────────────────────┐ - │ winget.exe (system, Windows-only) │ - └─────────────────────────────────────────┘ + └────────┬──────────────────┬───────────────────────┬─────────┘ + ▼ ▼ ▼ + ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ + │ ComBackend │ │ CliBackend │ │ MockBackend (--mock)│ + │ (--com, Win; │ │ (--cli) │ │ in-memory fixtures │ + │ default on Win)│ │ ParseTable / │ │ so the UI runs on │ + │ WinGet COM API; │ │ ParseShow / │ │ any host for dev │ + │ indexed access; │ │ ParsePins / │ └──────────────────────┘ + │ pins → CLI │ │ dedupe │ + └───────┬─────────┘ └────────┬─────────┘ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────────────────────────────┐ + │ WinGet COM │ │ winget.exe (system, Windows-only) │ + │ server (Win) │ └─────────────────────────────────────────┘ + └─────────────────┘ ``` Three layers, top to bottom: **UI** (`App` owns the widgets from `Ui.cs` plus `DetailPanel`), **state** (`AppState` is the single source of truth for what's filtered and selected, with generation counters that invalidate stale async responses), and -**backend** (`IBackend` interface, two implementations). Async results from the -backend flow back through `App.Invoke` on the UI thread, where they pass through -the generation guard before mutating `AppState` and triggering a redraw. +**backend** (`IBackend` interface, three implementations selected at runtime — see +[Choosing a backend](#choosing-a-backend-at-runtime)). The `ComBackend` is compiled +only into the Windows TFM. Async results from the backend flow back through +`App.Invoke` on the UI thread, where they pass through the generation guard before +mutating `AppState` and triggering a redraw. ## Project layout ``` winget-tui-sharp/ ├── Program.cs # Entry point + winget-detection + --dump diagnostic -├── WingetTuiSharp.csproj # PackageReference on Terminal.Gui; AOT-configured +├── WingetTuiSharp.csproj # Multi-targets net10.0 + net10.0-windows; Terminal.Gui; AOT-configured ├── README.md ├── LICENSE # MIT ├── feature-gaps.md # Terminal.Gui parity findings vs upstream @@ -279,6 +299,7 @@ winget-tui-sharp/ │ ├── Models.cs # Package, PackageDetail, enums, OpResult │ ├── Backend.cs # IBackend interface │ ├── CliBackend.cs # Shells out to winget; parses table output +│ ├── ComBackend.cs # WinGet COM API backend (Windows TFM only; pins → CLI) │ ├── MockBackend.cs # Fake packages so the UI runs anywhere │ ├── AppState.cs # Filters, sort, selection, generation counters │ ├── Theme.cs # Warm-amber palette + Schemes + pixel-art Logo diff --git a/WingetTuiSharp.csproj b/WingetTuiSharp.csproj index e6a0ce0..29245d9 100644 --- a/WingetTuiSharp.csproj +++ b/WingetTuiSharp.csproj @@ -1,7 +1,18 @@ Exe - net10.0 + + net10.0;net10.0-windows10.0.26100.0 enable enable latest @@ -12,6 +23,8 @@ true true true + + true win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64 + + + + $(DefineConstants);WINGET_COM + + x64 + + + + + - + + + diff --git a/src/ComBackend.cs b/src/ComBackend.cs new file mode 100644 index 0000000..1ce722f --- /dev/null +++ b/src/ComBackend.cs @@ -0,0 +1,496 @@ +// The COM backend is Windows-only: it talks to the WinGet COM API +// (Microsoft.Management.Deployment) instead of shelling out to winget.exe and parsing +// stdout. The whole file is gated on WINGET_COM, which the .csproj defines only for the +// net10.0-windows10.0.26100.0 TFM — on net10.0 this file compiles to nothing so the +// cross-platform build stays clean. +// +// === The one AOT rule (see spikes/ComBackendSpike/SPIKE-RESULTS.md) === +// NEVER `foreach` or LINQ directly over a WinRT-projected collection. Under Native AOT the +// IIterable runtime-callable-wrapper for the generic instantiation isn't generated and +// enumeration throws InvalidCastException at runtime. Indexed access (IVectorView.GetAt, +// i.e. `list[i]`) works fine. Every projected list is funneled through Materialize() +// below, which copies via indexing into a normal List; after that, ordinary +// foreach/LINQ on the managed copy is safe. + +#if WINGET_COM +using Microsoft.Management.Deployment; + +namespace WingetTuiSharp; + +/// +/// implementation over the WinGet COM API. Returns structured objects +/// directly from the package manager rather than parsing CLI tabular output. +/// +/// Pinning has no COM surface (the API exposes no pin/unpin/list-pins), so those three +/// operations are delegated to an internal — winget.exe is always +/// present on a machine where the COM server is registered, so this keeps full feature parity. +/// +public sealed class ComBackend : IBackend +{ + private readonly PackageManager _pm = new (); + + // Pin operations fall through to the CLI — the COM API has no pinning surface. + private readonly CliBackend _cliForPins = new (); + + // ------------------------------------------------------------------------ + // Reads + // ------------------------------------------------------------------------ + + public async Task> SearchAsync (string query, SourceFilter source, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace (query)) + { + return []; + } + + // Composite over the remote catalog(s), returning remote packages correlated with + // installed status. CatalogDefault searches the catalog's default field set + // (Id/Name/Moniker/Tags) — the free-text-search field. + PackageCatalog catalog = await ConnectAsync ( + CompositeRef (RemoteRefs (source), CompositeSearchBehavior.RemotePackagesFromRemoteCatalogs), + ct); + + FindPackagesOptions opts = new (); + opts.Selectors.Add (new () + { + Field = PackageMatchField.CatalogDefault, + Option = PackageFieldMatchOption.ContainsCaseInsensitive, + Value = query + }); + + FindPackagesResult result = await catalog.FindPackagesAsync (opts).AsTask (ct); + + List packages = []; + + foreach (MatchResult m in Materialize (result.Matches)) + { + CatalogPackage pkg = m.CatalogPackage; + string version = SafeVersion (SafeDefaultInstallVersion (pkg)) ?? LatestAvailableVersion (pkg) ?? string.Empty; + + packages.Add (new () + { + Id = pkg.Id, + Name = pkg.Name, + Version = version, + Source = SourceOf (pkg) + }); + } + + return packages; + } + + public Task> ListInstalledAsync (SourceFilter source, CancellationToken ct) + => ListLocalAsync (source, upgradesOnly: false, ct); + + public Task> ListUpgradesAsync (SourceFilter source, CancellationToken ct) + => ListLocalAsync (source, upgradesOnly: true, ct); + + /// + /// Installed packages, optionally filtered to those with an available upgrade. Uses a + /// composite catalog with : results come + /// from the implicit local "installed" catalog, correlated against the supplied remote + /// catalog(s) so each row knows its available version / update status. + /// + private async Task> ListLocalAsync (SourceFilter source, bool upgradesOnly, CancellationToken ct) + { + PackageCatalog catalog = await ConnectAsync ( + CompositeRef (RemoteRefs (source), CompositeSearchBehavior.LocalCatalogs), + ct); + + // An empty filter set returns every installed package. + FindPackagesResult result = await catalog.FindPackagesAsync (new ()).AsTask (ct); + + List packages = []; + + foreach (MatchResult m in Materialize (result.Matches)) + { + CatalogPackage pkg = m.CatalogPackage; + bool updateAvailable = SafeIsUpdateAvailable (pkg); + + if (upgradesOnly && !updateAvailable) + { + continue; + } + + string installed = SafeVersion (SafeInstalledVersion (pkg)) ?? string.Empty; + + packages.Add (new () + { + Id = pkg.Id, + Name = pkg.Name, + Version = installed, + Source = SourceOf (pkg), + AvailableVersion = updateAvailable ? LatestAvailableVersion (pkg) : null + }); + } + + return packages; + } + + public async Task ShowAsync (string id, CancellationToken ct) + { + CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: false, ct); + + if (pkg is null) + { + return null; + } + + // Prefer the default-install (latest) version's manifest; fall back to installed. + PackageVersionInfo? versionInfo = SafeDefaultInstallVersion (pkg) ?? SafeInstalledVersion (pkg); + + if (versionInfo is null) + { + return null; + } + + CatalogPackageMetadata? meta = null; + + try + { + meta = versionInfo.GetCatalogPackageMetadata (); + } + catch + { + // No localized manifest metadata available; fall back to the bare fields below. + } + + string? description = Coalesce (meta?.Description, meta?.ShortDescription); + + return new () + { + Id = pkg.Id, + Name = Coalesce (meta?.PackageName, pkg.Name) ?? pkg.Id, + Version = SafeVersion (SafeInstalledVersion (pkg)) ?? SafeVersion (versionInfo) ?? string.Empty, + AvailableVersion = LatestAvailableVersion (pkg), + Source = SourceOf (pkg), + Publisher = NullIfEmpty (meta?.Publisher), + Description = description, + Homepage = NullIfEmpty (meta?.PackageUrl), + License = NullIfEmpty (meta?.License), + ReleaseNotesUrl = NullIfEmpty (meta?.ReleaseNotesUrl) + }; + } + + // ------------------------------------------------------------------------ + // Writes + // ------------------------------------------------------------------------ + + public async Task InstallAsync (string id, string? version, CancellationToken ct) + { + Operation op = new () { Kind = OperationKind.Install, PackageId = id, Version = version }; + CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: false, ct); + + if (pkg is null) + { + return Fail (op, $"Package '{id}' not found in any configured source."); + } + + InstallOptions options = new () + { + PackageInstallMode = PackageInstallMode.Silent, + AcceptPackageAgreements = true + }; + + if (!string.IsNullOrEmpty (version)) + { + PackageVersionId? versionId = FindVersionId (pkg, version); + + if (versionId is null) + { + return Fail (op, $"Version '{version}' is not available for {pkg.Name}."); + } + + options.PackageVersionId = versionId; + } + + InstallResult result = await _pm.InstallPackageAsync (pkg, options).AsTask (ct); + + return result.Status == InstallResultStatus.Ok + ? Ok (op, $"Installed {pkg.Name}{(result.RebootRequired ? " (reboot required)" : string.Empty)}") + : Fail (op, DescribeInstall (result)); + } + + public async Task UpgradeAsync (string id, CancellationToken ct) + { + Operation op = new () { Kind = OperationKind.Upgrade, PackageId = id }; + + // Installed context so the package carries both its installed version and the + // correlated remote available versions that the upgrade resolves against. + CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: true, ct); + + if (pkg is null) + { + return Fail (op, $"Installed package '{id}' not found."); + } + + InstallOptions options = new () + { + PackageInstallMode = PackageInstallMode.Silent, + AcceptPackageAgreements = true + }; + + InstallResult result = await _pm.UpgradePackageAsync (pkg, options).AsTask (ct); + + return result.Status == InstallResultStatus.Ok + ? Ok (op, $"Upgraded {pkg.Name}{(result.RebootRequired ? " (reboot required)" : string.Empty)}") + : Fail (op, DescribeInstall (result)); + } + + public async Task UninstallAsync (string id, CancellationToken ct) + { + Operation op = new () { Kind = OperationKind.Uninstall, PackageId = id }; + CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: true, ct); + + if (pkg is null) + { + return Fail (op, $"Installed package '{id}' not found."); + } + + UninstallOptions options = new () { PackageUninstallMode = PackageUninstallMode.Silent }; + UninstallResult result = await _pm.UninstallPackageAsync (pkg, options).AsTask (ct); + + return result.Status == UninstallResultStatus.Ok + ? Ok (op, $"Uninstalled {pkg.Name}{(result.RebootRequired ? " (reboot required)" : string.Empty)}") + : Fail (op, $"Uninstall failed: {result.Status} (installer 0x{result.UninstallerErrorCode:X}, hr 0x{HResultOf (result.ExtendedErrorCode):X8})"); + } + + // ------------------------------------------------------------------------ + // Pinning — delegated to the CLI (no COM surface for pins). + // ------------------------------------------------------------------------ + + public Task PinAsync (string id, CancellationToken ct) => _cliForPins.PinAsync (id, ct); + + public Task UnpinAsync (string id, CancellationToken ct) => _cliForPins.UnpinAsync (id, ct); + + public Task> ListPinsAsync (CancellationToken ct) => _cliForPins.ListPinsAsync (ct); + + // ------------------------------------------------------------------------ + // Catalog plumbing + // ------------------------------------------------------------------------ + + /// Resolve the configured remote catalog reference(s) for a source filter. + private List RemoteRefs (SourceFilter source) + { + string [] names = source switch + { + SourceFilter.Winget => ["winget"], + SourceFilter.MsStore => ["msstore"], + _ => ["winget", "msstore"] + }; + + List refs = []; + + foreach (string name in names) + { + PackageCatalogReference? r = _pm.GetPackageCatalogByName (name); + + if (r is not null) + { + // Accept source agreements up front so ConnectAsync doesn't fail with + // SourceAgreementsNotAccepted on a fresh machine. + r.AcceptSourceAgreements = true; + refs.Add (r); + } + } + + return refs; + } + + /// + /// Wrap one-or-more remote references into a composite catalog. The local "installed" + /// catalog is implicit in every composite; selects which side + /// queries return. + /// + private PackageCatalogReference CompositeRef (List refs, CompositeSearchBehavior behavior) + { + CreateCompositePackageCatalogOptions opts = new () { CompositeSearchBehavior = behavior }; + + foreach (PackageCatalogReference r in refs) + { + opts.Catalogs.Add (r); + } + + return _pm.CreateCompositePackageCatalog (opts); + } + + private static async Task ConnectAsync (PackageCatalogReference reference, CancellationToken ct) + { + reference.AcceptSourceAgreements = true; + ConnectResult result = await reference.ConnectAsync ().AsTask (ct); + + if (result.Status != ConnectResultStatus.Ok || result.PackageCatalog is null) + { + throw new InvalidOperationException ($"Could not connect to package catalog: {result.Status}"); + } + + return result.PackageCatalog; + } + + /// Find a single package by exact (case-insensitive) id. + private async Task FindByIdAsync (string id, SourceFilter source, bool installedContext, CancellationToken ct) + { + PackageCatalog catalog = await ConnectAsync ( + CompositeRef ( + RemoteRefs (source), + installedContext ? CompositeSearchBehavior.LocalCatalogs : CompositeSearchBehavior.RemotePackagesFromRemoteCatalogs), + ct); + + FindPackagesOptions opts = new (); + opts.Filters.Add (new () + { + Field = PackageMatchField.Id, + Option = PackageFieldMatchOption.EqualsCaseInsensitive, + Value = id + }); + + FindPackagesResult result = await catalog.FindPackagesAsync (opts).AsTask (ct); + List matches = Materialize (result.Matches); + + return matches.Count > 0 ? matches [0].CatalogPackage : null; + } + + private static PackageVersionId? FindVersionId (CatalogPackage pkg, string version) + { + foreach (PackageVersionId vid in Materialize (pkg.AvailableVersions)) + { + if (string.Equals (vid.Version, version, StringComparison.OrdinalIgnoreCase)) + { + return vid; + } + } + + return null; + } + + // ------------------------------------------------------------------------ + // Field extraction — every WinRT property access that can throw on an odd + // package is wrapped so one bad row never sinks the whole listing. + // ------------------------------------------------------------------------ + + private static string SourceOf (CatalogPackage pkg) + { + // The remote source the package is available from (installed-only rows fall back to + // the local "InstalledPackages" catalog name, which we'd rather not show — prefer remote). + PackageVersionInfo? v = SafeDefaultInstallVersion (pkg) ?? SafeInstalledVersion (pkg); + + try + { + return v?.PackageCatalog?.Info?.Name ?? string.Empty; + } + catch + { + return string.Empty; + } + } + + private static PackageVersionInfo? SafeInstalledVersion (CatalogPackage pkg) + { + try + { + return pkg.InstalledVersion; + } + catch + { + return null; + } + } + + private static PackageVersionInfo? SafeDefaultInstallVersion (CatalogPackage pkg) + { + try + { + return pkg.DefaultInstallVersion; + } + catch + { + return null; + } + } + + private static bool SafeIsUpdateAvailable (CatalogPackage pkg) + { + try + { + return pkg.IsUpdateAvailable; + } + catch + { + return false; + } + } + + private static string? SafeVersion (PackageVersionInfo? info) + { + if (info is null) + { + return null; + } + + try + { + return NullIfEmpty (info.Version); + } + catch + { + return null; + } + } + + /// Latest available version string (AvailableVersions is newest-first), else the default-install version. + private static string? LatestAvailableVersion (CatalogPackage pkg) + { + try + { + // Indexed access only — never enumerate the projected view (AOT). + if (pkg.AvailableVersions is { Count: > 0 } versions) + { + return NullIfEmpty (versions [0].Version); + } + } + catch + { + // fall through + } + + return SafeVersion (SafeDefaultInstallVersion (pkg)); + } + + // ------------------------------------------------------------------------ + // Small helpers + // ------------------------------------------------------------------------ + + /// + /// Copy a WinRT-projected list into a managed using indexed access. + /// This is the AOT-safe substitute for enumerating the projection directly (see the file + /// header). Callers may then foreach/LINQ the returned managed copy freely. + /// + private static List Materialize (IReadOnlyList projected) + { + int count = projected.Count; + List copy = new (count); + + for (int i = 0; i < count; i++) + { + copy.Add (projected [i]); + } + + return copy; + } + + private static string DescribeInstall (InstallResult result) + => $"Install failed: {result.Status} (installer {result.InstallerErrorCode}, hr 0x{HResultOf (result.ExtendedErrorCode):X8})"; + + // In this projection, the IDL's `HRESULT ExtendedErrorCode` surfaces as a System.Exception + // (CsWinRT maps a failed HRESULT to its exception). Pull the numeric HRESULT back out. + private static uint HResultOf (Exception? error) => (uint)(error?.HResult ?? 0); + + private static OpResult Ok (Operation op, string message) => new () { Operation = op, Success = true, Message = message }; + + private static OpResult Fail (Operation op, string message) => new () { Operation = op, Success = false, Message = message }; + + private static string? NullIfEmpty (string? value) => string.IsNullOrWhiteSpace (value) ? null : value; + + private static string? Coalesce (string? a, string? b) => NullIfEmpty (a) ?? NullIfEmpty (b); +} +#endif From 1413e084dffb3f47b760c18cf97b27e08502f0d7 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 19:24:05 -0500 Subject: [PATCH 06/18] feat: live install progress in the status bar (COM path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads structured operation progress from the backend to the status bar via an IProgress parameter on InstallAsync/UpgradeAsync/UninstallAsync. - Models: OpPhase (Queued/Downloading/Installing/Finalizing/Done) + OpProgress (phase + 0..1 fraction), both cross-platform. - ComBackend: sets the WinRT op's .Progress handler before awaiting and maps InstallProgress / UninstallProgress (state + Download/Installation fraction) onto OpProgress. The handler fires on a COM thread; UiProgress marshals it. - MockBackend: synthesizes a download->install ramp so the progress UI is visible/testable on any host (no Windows needed). CliBackend ignores progress (winget.exe only emits an ANSI bar we don't scrape). - StatusBar: renders a determinate bar (▕████░░░░░░▏ 42% Installing X) with the phase label, replacing the spinner while an op runs. - App: RunOperation builds a UiProgress that App.Invoke-marshals reports to the UI thread; OnOpProgress ignores late reports after the op settles. Batch upgrade passes null (the loop owns its own per-item status line). Verified: both TFMs build, tests pass, Windows-TFM trim analysis still 0 IL2026/IL3050 (the .Progress delegate-callback marshaling rides the same CCW path the spike's awaited ConnectAsync already exercised under AOT). Live COM progress to be confirmed on Windows. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.cs | 54 +++++++++++++++++++++++++++++++++-------- src/AppState.cs | 3 +++ src/Backend.cs | 10 +++++--- src/CliBackend.cs | 8 ++++--- src/ComBackend.cs | 58 +++++++++++++++++++++++++++++++++++++++----- src/MockBackend.cs | 60 +++++++++++++++++++++++++++++++++++++++------- src/Models.cs | 34 ++++++++++++++++++++++++++ src/Ui.cs | 24 ++++++++++++++++--- 8 files changed, 217 insertions(+), 34 deletions(-) diff --git a/src/App.cs b/src/App.cs index 6beba06..0ffa518 100644 --- a/src/App.cs +++ b/src/App.cs @@ -506,6 +506,7 @@ private void RefreshStatusBar () _statusBar.Message = _state.StatusMessage; _statusBar.IsError = _state.StatusIsError; _statusBar.IsLoading = _state.Loading || _state.DetailLoading; + _statusBar.Op = _state.OpProgress; _statusBar.SetNeedsDraw (); _detailPanel.Mode = _state.Mode; } @@ -1038,7 +1039,7 @@ private void AskInstall (Package? p, bool specificVersion) return; } - RunOperation ($"Installing {p.Name}", _ => _state.Backend.InstallAsync (p.Id, null, _)); + RunOperation ($"Installing {p.Name}", (prog, ct) => _state.Backend.InstallAsync (p.Id, null, prog, ct)); return; } @@ -1050,7 +1051,7 @@ private void AskInstall (Package? p, bool specificVersion) return; } - RunOperation ($"Installing {p.Name} {version}", _ => _state.Backend.InstallAsync (p.Id, version, _)); + RunOperation ($"Installing {p.Name} {version}", (prog, ct) => _state.Backend.InstallAsync (p.Id, version, prog, ct)); } private void AskUpgrade (Package? p) @@ -1065,7 +1066,7 @@ private void AskUpgrade (Package? p) return; } - RunOperation ($"Upgrading {p.Name}", _ => _state.Backend.UpgradeAsync (p.Id, _)); + RunOperation ($"Upgrading {p.Name}", (prog, ct) => _state.Backend.UpgradeAsync (p.Id, prog, ct)); } private void AskUninstall (Package? p) @@ -1080,7 +1081,7 @@ private void AskUninstall (Package? p) return; } - RunOperation ($"Uninstalling {p.Name}", _ => _state.Backend.UninstallAsync (p.Id, _)); + RunOperation ($"Uninstalling {p.Name}", (prog, ct) => _state.Backend.UninstallAsync (p.Id, prog, ct)); } private void TogglePin (Package? p) @@ -1098,9 +1099,9 @@ private void TogglePin (Package? p) return; } - RunOperation ($"{label}ning {p.Name}", _ => pinned - ? _state.Backend.UnpinAsync (p.Id, _) - : _state.Backend.PinAsync (p.Id, _)); + RunOperation ($"{label}ning {p.Name}", (_, ct) => pinned + ? _state.Backend.UnpinAsync (p.Id, ct) + : _state.Backend.PinAsync (p.Id, ct)); } private void ToggleBatchSelect (Package? p) @@ -1164,7 +1165,9 @@ private void AskBatchUpgrade () try { - result = await _state.Backend.UpgradeAsync (id, CancellationToken.None); + // Per-item progress would fight the batch loop's own status line; the + // loop reports "Upgrading {id}…" per package instead. + result = await _state.Backend.UpgradeAsync (id, null, CancellationToken.None); } catch (Exception ex) { @@ -1195,20 +1198,23 @@ private void AskBatchUpgrade () }); } - private void RunOperation (string activity, Func> op) + private void RunOperation (string activity, Func, CancellationToken, Task> op) { _state.StatusMessage = activity; _state.Loading = true; _state.StatusIsError = false; + _state.OpProgress = null; RefreshStatusBar (); + IProgress progress = new UiProgress (this); + Task.Run (async () => { OpResult result; try { - result = await op (CancellationToken.None); + result = await op (progress, CancellationToken.None); } catch (Exception ex) { @@ -1223,6 +1229,7 @@ private void RunOperation (string activity, Func { _state.Loading = false; + _state.OpProgress = null; _state.StatusMessage = result.Success ? "Done" : result.Message; _state.StatusIsError = !result.Success; @@ -1236,6 +1243,33 @@ private void RunOperation (string activity, Func + /// Apply a backend progress sample to the status bar. Runs on the UI thread (marshaled by + /// ). Ignored once the operation has settled so a late report can't + /// resurrect the progress bar after the final "Done". + /// + private void OnOpProgress (OpProgress value) + { + if (!_state.Loading) + { + return; + } + + _state.OpProgress = value; + RefreshStatusBar (); + } + + private void ReportProgress (OpProgress value) => App?.Invoke (() => OnOpProgress (value)); + + /// + /// bridge that marshals backend progress (raised on a background + /// or COM thread) onto the Terminal.Gui UI thread before touching view state. + /// + private sealed class UiProgress (App owner) : IProgress + { + public void Report (OpProgress value) => owner.ReportProgress (value); + } + private bool Confirm (string title, string message) { if (App is null) diff --git a/src/AppState.cs b/src/AppState.cs index 7b9d78a..e5ef5c9 100644 --- a/src/AppState.cs +++ b/src/AppState.cs @@ -32,6 +32,9 @@ public sealed class AppState public string StatusMessage { get; set; } = string.Empty; public bool StatusIsError { get; set; } + /// Progress of the in-flight install/upgrade/uninstall, or null when none is running. + public OpProgress? OpProgress { get; set; } + public int ViewGeneration { get; private set; } public int DetailGeneration { get; private set; } diff --git a/src/Backend.cs b/src/Backend.cs index d573324..474b726 100644 --- a/src/Backend.cs +++ b/src/Backend.cs @@ -7,9 +7,13 @@ public interface IBackend Task> ListInstalledAsync (SourceFilter source, CancellationToken ct); Task> ListUpgradesAsync (SourceFilter source, CancellationToken ct); Task ShowAsync (string id, CancellationToken ct); - Task InstallAsync (string id, string? version, CancellationToken ct); - Task UninstallAsync (string id, CancellationToken ct); - Task UpgradeAsync (string id, CancellationToken ct); + + // The install/upgrade/uninstall operations optionally report structured progress through + // `progress`. Backends that can't (CLI) ignore it; the COM backend maps the WinGet COM + // progress events onto OpProgress; the mock backend synthesizes a download→install ramp. + Task InstallAsync (string id, string? version, IProgress? progress, CancellationToken ct); + Task UninstallAsync (string id, IProgress? progress, CancellationToken ct); + Task UpgradeAsync (string id, IProgress? progress, CancellationToken ct); Task PinAsync (string id, CancellationToken ct); Task UnpinAsync (string id, CancellationToken ct); Task> ListPinsAsync (CancellationToken ct); diff --git a/src/CliBackend.cs b/src/CliBackend.cs index e8eb14d..6f8a7b7 100644 --- a/src/CliBackend.cs +++ b/src/CliBackend.cs @@ -48,7 +48,9 @@ public async Task> ListUpgradesAsync (SourceFilter source return ParseShow (id, output); } - public async Task InstallAsync (string id, string? version, CancellationToken ct) + // progress is unused: winget.exe only emits an ANSI progress bar to stdout, which we + // capture as a whole rather than scrape. The COM backend is the one that reports progress. + public async Task InstallAsync (string id, string? version, IProgress? progress, CancellationToken ct) { (int code, string output) = await RunWithCodeAsync (InstallArgs (id, version), ct); Operation op = new () { Kind = OperationKind.Install, PackageId = id, Version = version }; @@ -56,7 +58,7 @@ public async Task InstallAsync (string id, string? version, Cancellati return new () { Operation = op, Success = code == 0, Message = output }; } - public async Task UninstallAsync (string id, CancellationToken ct) + public async Task UninstallAsync (string id, IProgress? progress, CancellationToken ct) { (int code, string output) = await RunWithCodeAsync (UninstallArgs (id), ct); Operation op = new () { Kind = OperationKind.Uninstall, PackageId = id }; @@ -64,7 +66,7 @@ public async Task UninstallAsync (string id, CancellationToken ct) return new () { Operation = op, Success = code == 0, Message = output }; } - public async Task UpgradeAsync (string id, CancellationToken ct) + public async Task UpgradeAsync (string id, IProgress? progress, CancellationToken ct) { // Upstream tries id (non-exact) first, then falls back to name (exact). Match that. (int code, string output) = await RunWithCodeAsync (UpgradeByIdArgs (id), ct); diff --git a/src/ComBackend.cs b/src/ComBackend.cs index 1ce722f..e31b416 100644 --- a/src/ComBackend.cs +++ b/src/ComBackend.cs @@ -176,7 +176,7 @@ private async Task> ListLocalAsync (SourceFilter source, // Writes // ------------------------------------------------------------------------ - public async Task InstallAsync (string id, string? version, CancellationToken ct) + public async Task InstallAsync (string id, string? version, IProgress? progress, CancellationToken ct) { Operation op = new () { Kind = OperationKind.Install, PackageId = id, Version = version }; CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: false, ct); @@ -204,14 +204,18 @@ public async Task InstallAsync (string id, string? version, Cancellati options.PackageVersionId = versionId; } - InstallResult result = await _pm.InstallPackageAsync (pkg, options).AsTask (ct); + // Set the progress handler on the WinRT op before awaiting; it fires on a COM thread, + // so the IProgress<> the caller supplies is responsible for marshaling to the UI. + var asyncOp = _pm.InstallPackageAsync (pkg, options); + asyncOp.Progress = (_, p) => progress?.Report (MapInstall (p)); + InstallResult result = await asyncOp.AsTask (ct); return result.Status == InstallResultStatus.Ok ? Ok (op, $"Installed {pkg.Name}{(result.RebootRequired ? " (reboot required)" : string.Empty)}") : Fail (op, DescribeInstall (result)); } - public async Task UpgradeAsync (string id, CancellationToken ct) + public async Task UpgradeAsync (string id, IProgress? progress, CancellationToken ct) { Operation op = new () { Kind = OperationKind.Upgrade, PackageId = id }; @@ -230,14 +234,16 @@ public async Task UpgradeAsync (string id, CancellationToken ct) AcceptPackageAgreements = true }; - InstallResult result = await _pm.UpgradePackageAsync (pkg, options).AsTask (ct); + var asyncOp = _pm.UpgradePackageAsync (pkg, options); + asyncOp.Progress = (_, p) => progress?.Report (MapInstall (p)); + InstallResult result = await asyncOp.AsTask (ct); return result.Status == InstallResultStatus.Ok ? Ok (op, $"Upgraded {pkg.Name}{(result.RebootRequired ? " (reboot required)" : string.Empty)}") : Fail (op, DescribeInstall (result)); } - public async Task UninstallAsync (string id, CancellationToken ct) + public async Task UninstallAsync (string id, IProgress? progress, CancellationToken ct) { Operation op = new () { Kind = OperationKind.Uninstall, PackageId = id }; CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: true, ct); @@ -248,7 +254,9 @@ public async Task UninstallAsync (string id, CancellationToken ct) } UninstallOptions options = new () { PackageUninstallMode = PackageUninstallMode.Silent }; - UninstallResult result = await _pm.UninstallPackageAsync (pkg, options).AsTask (ct); + var asyncOp = _pm.UninstallPackageAsync (pkg, options); + asyncOp.Progress = (_, p) => progress?.Report (MapUninstall (p)); + UninstallResult result = await asyncOp.AsTask (ct); return result.Status == UninstallResultStatus.Ok ? Ok (op, $"Uninstalled {pkg.Name}{(result.RebootRequired ? " (reboot required)" : string.Empty)}") @@ -478,6 +486,44 @@ private static List Materialize (IReadOnlyList projected) return copy; } + /// Map the WinGet install/upgrade progress struct onto the backend-agnostic model. + private static OpProgress MapInstall (InstallProgress p) + { + OpPhase phase = p.State switch + { + PackageInstallProgressState.Queued => OpPhase.Queued, + PackageInstallProgressState.Downloading => OpPhase.Downloading, + PackageInstallProgressState.Installing => OpPhase.Installing, + PackageInstallProgressState.PostInstall => OpPhase.Finalizing, + PackageInstallProgressState.Finished => OpPhase.Done, + _ => OpPhase.Installing + }; + + double fraction = p.State switch + { + PackageInstallProgressState.Downloading => p.DownloadProgress, + PackageInstallProgressState.Installing => p.InstallationProgress, + PackageInstallProgressState.Finished => 1.0, + _ => 0.0 + }; + + return new (phase, fraction); + } + + private static OpProgress MapUninstall (UninstallProgress p) + { + OpPhase phase = p.State switch + { + PackageUninstallProgressState.Queued => OpPhase.Queued, + PackageUninstallProgressState.Uninstalling => OpPhase.Installing, + PackageUninstallProgressState.PostUninstall => OpPhase.Finalizing, + PackageUninstallProgressState.Finished => OpPhase.Done, + _ => OpPhase.Installing + }; + + return new (phase, p.UninstallationProgress); + } + private static string DescribeInstall (InstallResult result) => $"Install failed: {result.Status} (installer {result.InstallerErrorCode}, hr 0x{HResultOf (result.ExtendedErrorCode):X8})"; diff --git a/src/MockBackend.cs b/src/MockBackend.cs index 1e92d12..73fc49f 100644 --- a/src/MockBackend.cs +++ b/src/MockBackend.cs @@ -119,29 +119,71 @@ public Task> ListUpgradesAsync (SourceFilter source, Canc return Task.FromResult (detail); } - public Task InstallAsync (string id, string? version, CancellationToken ct) - => Task.FromResult (new OpResult + public async Task InstallAsync (string id, string? version, IProgress? progress, CancellationToken ct) + { + await SimulateProgressAsync (progress, downloads: true, ct); + + return new () { Operation = new () { Kind = OperationKind.Install, PackageId = id, Version = version }, Success = true, Message = $"[mock] Installed {id}" + (version is null ? string.Empty : $" v{version}") - }); + }; + } + + public async Task UninstallAsync (string id, IProgress? progress, CancellationToken ct) + { + await SimulateProgressAsync (progress, downloads: false, ct); - public Task UninstallAsync (string id, CancellationToken ct) - => Task.FromResult (new OpResult + return new () { Operation = new () { Kind = OperationKind.Uninstall, PackageId = id }, Success = true, Message = $"[mock] Uninstalled {id}" - }); + }; + } + + public async Task UpgradeAsync (string id, IProgress? progress, CancellationToken ct) + { + await SimulateProgressAsync (progress, downloads: true, ct); - public Task UpgradeAsync (string id, CancellationToken ct) - => Task.FromResult (new OpResult + return new () { Operation = new () { Kind = OperationKind.Upgrade, PackageId = id }, Success = true, Message = $"[mock] Upgraded {id}" - }); + }; + } + + /// + /// Synthesize a believable progress ramp so the status-bar progress UI can be exercised on + /// any host (the mock has no real work to do). Downloads ramp 0→1 then install ramps 0→1; + /// uninstall skips the download phase. + /// + private static async Task SimulateProgressAsync (IProgress? progress, bool downloads, CancellationToken ct) + { + if (progress is null) + { + return; + } + + if (downloads) + { + for (int i = 0; i <= 10; i++) + { + progress.Report (new (OpPhase.Downloading, i / 10.0)); + await Task.Delay (55, ct); + } + } + + for (int i = 0; i <= 10; i++) + { + progress.Report (new (OpPhase.Installing, i / 10.0)); + await Task.Delay (45, ct); + } + + progress.Report (new (OpPhase.Done, 1.0)); + } public Task PinAsync (string id, CancellationToken ct) { diff --git a/src/Models.cs b/src/Models.cs index 78ed3ff..489da7c 100644 --- a/src/Models.cs +++ b/src/Models.cs @@ -186,3 +186,37 @@ public sealed class OpResult public bool Success { get; init; } public string Message { get; init; } = string.Empty; } + +/// +/// Coarse phase of a long-running install/upgrade/uninstall, backend-agnostic. The COM +/// backend maps the WinGet InstallProgress/UninstallProgress states onto these; +/// the mock backend synthesizes them; the CLI backend can't report progress so it reports none. +/// +public enum OpPhase +{ + Queued, + Downloading, + Installing, + Finalizing, + Done +} + +/// +/// A single progress sample for an in-flight operation. is 0..1 within +/// the current (or overall once installing). Reported via +/// through the backend operation methods. +/// +public readonly record struct OpProgress (OpPhase Phase, double Fraction) +{ + /// Human-readable label for the status bar. + public string Label + => Phase switch + { + OpPhase.Queued => "Queued", + OpPhase.Downloading => "Downloading", + OpPhase.Installing => "Installing", + OpPhase.Finalizing => "Finalizing", + OpPhase.Done => "Done", + _ => string.Empty + }; +} diff --git a/src/Ui.cs b/src/Ui.cs index 039664b..21337f5 100644 --- a/src/Ui.cs +++ b/src/Ui.cs @@ -128,8 +128,21 @@ public StatusBar () public bool IsLoading { get; set; } public int Tick { get; set; } + /// When set, a determinate progress bar replaces the spinner (install/upgrade/uninstall). + public OpProgress? Op { get; set; } + private static readonly char [] _spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + /// Render a compact fixed-width progress bar like ▕████░░░░░░▏ 42%. + private static string RenderBar (double fraction) + { + const int width = 10; + fraction = Math.Clamp (fraction, 0, 1); + int filled = (int)Math.Round (fraction * width); + + return $"▕{new string ('█', filled)}{new string ('░', width - filled)}▏ {fraction * 100,3:0}%"; + } + /// protected override bool OnDrawingContent (DrawContext? context) { @@ -181,10 +194,15 @@ protected override bool OnDrawingContent (DrawContext? context) int hintsWidth = HintsWidth (visiblePairs, elided); int hintsStart = Math.Max (x0 + 1, Viewport.Width - hintsWidth); - // Status message between filters and hints + // Status message between filters and hints. A running operation shows a determinate + // progress bar with its phase; an indeterminate load shows the braille spinner. string msg = Message; - if (IsLoading) + if (Op is { } op) + { + msg = $"{RenderBar (op.Fraction)} {op.Label} {msg}"; + } + else if (IsLoading) { char spin = _spinner [Tick % _spinner.Length]; msg = $"{spin} {msg}"; @@ -199,7 +217,7 @@ protected override bool OnDrawingContent (DrawContext? context) Attribute msgAttr = IsError ? new Attribute (Theme.Danger, Theme.Surface, TextStyle.Bold) - : IsLoading + : IsLoading || Op is not null ? new Attribute (Theme.Accent, Theme.Surface) : new Attribute (Theme.TextPrimary, Theme.Surface); SetAttribute (msgAttr); From 642835ed856089ba28a65d5a889093e64aec1d02 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 20:49:41 -0500 Subject: [PATCH 07/18] feat: cancel in-flight operations with Esc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The progress plumbing already forwarded a token to the COM op's AsTask(ct) (which calls IAsyncInfo.Cancel cooperatively), but the UI passed CancellationToken.None and had no cancel gesture. This wires it up. - App owns a nullable _opCts; non-null = an operation is in flight (distinct from _viewCts/_detailCts, which cover list/detail refreshes). RunOperation creates it, passes its token to the op, and refuses to start a second op while one runs (avoids a leaked CTS and an ambiguous cancel target). - Esc is split out of the shared Q/Esc quit case: while an op runs it cancels the op (and shows "Cancelling…") instead of quitting; otherwise unchanged. - OperationCanceledException is caught distinctly -> "Cancelled" (not an error), and the list still refreshes since state may have partially changed. - Batch upgrade shares the same _opCts: Esc aborts the in-flight item via its token and the loop stops on the next iteration. - Status activity text advertises "· Esc to cancel" while running. Cross-backend: COM cancels cooperatively; mock's simulated ramp honors the token; CLI stops awaiting but does not kill winget.exe (unsafe mid-install). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.cs | 101 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 91 insertions(+), 10 deletions(-) diff --git a/src/App.cs b/src/App.cs index 0ffa518..9f99eac 100644 --- a/src/App.cs +++ b/src/App.cs @@ -25,6 +25,11 @@ public sealed class App : Runnable private readonly Label _searchHint; private CancellationTokenSource _viewCts = new (); private CancellationTokenSource _detailCts = new (); + + // Non-null only while an install/upgrade/uninstall (or batch) is in flight. Doubles as the + // "an operation is running" gate for Esc-to-cancel — distinct from _viewCts/_detailCts, which + // cover list/detail refreshes that already cancel implicitly on navigation. + private CancellationTokenSource? _opCts; private object? _spinnerTimer; private bool _initialLoadDone; @@ -740,8 +745,25 @@ private void OnKeyDown (object? sender, Key key) switch (key.KeyCode) { - case KeyCode.Q: case KeyCode.Esc: + + // While an operation is in flight, Esc cancels it (COM aborts cooperatively) + // rather than quitting. With nothing running, Esc quits as before. + if (_opCts is { } opCts) + { + opCts.Cancel (); + _state.StatusMessage = "Cancelling…"; + RefreshStatusBar (); + key.Handled = true; + + return; + } + + RequestStop (); + key.Handled = true; + + return; + case KeyCode.Q: RequestStop (); key.Handled = true; @@ -1148,15 +1170,33 @@ private void AskBatchUpgrade () return; } + // Share the single-operation gate so Esc cancels the batch (the in-flight item aborts via + // its token; the loop then stops on the next iteration). Refuse to start atop another op. + if (_opCts is not null) + { + return; + } + + _opCts = new (); + CancellationToken ct = _opCts.Token; string [] ids = [.. _state.BatchSelected]; Task.Run (async () => { + bool cancelled = false; + foreach (string id in ids) { + if (ct.IsCancellationRequested) + { + cancelled = true; + + break; + } + App?.Invoke (() => { - _state.StatusMessage = $"Upgrading {id}…"; + _state.StatusMessage = $"Upgrading {id}… · Esc to cancel"; _state.Loading = true; RefreshStatusBar (); }); @@ -1167,7 +1207,13 @@ private void AskBatchUpgrade () { // Per-item progress would fight the batch loop's own status line; the // loop reports "Upgrading {id}…" per package instead. - result = await _state.Backend.UpgradeAsync (id, null, CancellationToken.None); + result = await _state.Backend.UpgradeAsync (id, null, ct); + } + catch (OperationCanceledException) + { + cancelled = true; + + break; } catch (Exception ex) { @@ -1191,8 +1237,17 @@ private void AskBatchUpgrade () App?.Invoke (() => { + _opCts?.Dispose (); + _opCts = null; _state.BatchSelected.Clear (); _state.Loading = false; + + if (cancelled) + { + _state.StatusMessage = "Cancelled"; + _state.StatusIsError = false; + } + TriggerRefresh (); }); }); @@ -1200,7 +1255,17 @@ private void AskBatchUpgrade () private void RunOperation (string activity, Func, CancellationToken, Task> op) { - _state.StatusMessage = activity; + // One operation at a time: a second request while one is in flight is ignored, which + // keeps _opCts (and the Esc-cancel target) unambiguous and avoids leaking a CTS. + if (_opCts is not null) + { + return; + } + + _opCts = new (); + CancellationToken ct = _opCts.Token; + + _state.StatusMessage = $"{activity} · Esc to cancel"; _state.Loading = true; _state.StatusIsError = false; _state.OpProgress = null; @@ -1210,11 +1275,16 @@ private void RunOperation (string activity, Func, Cancella Task.Run (async () => { - OpResult result; + OpResult? result = null; + bool cancelled = false; try { - result = await op (progress, CancellationToken.None); + result = await op (progress, ct); + } + catch (OperationCanceledException) + { + cancelled = true; } catch (Exception ex) { @@ -1228,14 +1298,25 @@ private void RunOperation (string activity, Func, Cancella App?.Invoke (() => { + _opCts?.Dispose (); + _opCts = null; _state.Loading = false; _state.OpProgress = null; - _state.StatusMessage = result.Success ? "Done" : result.Message; - _state.StatusIsError = !result.Success; - if (result.Operation.PackageId is { } id) + if (cancelled) + { + _state.StatusMessage = "Cancelled"; + _state.StatusIsError = false; + } + else { - _state.DetailCache.Remove (id); + _state.StatusMessage = result!.Success ? "Done" : result.Message; + _state.StatusIsError = !result.Success; + + if (result.Operation.PackageId is { } id) + { + _state.DetailCache.Remove (id); + } } TriggerRefresh (); From 79c7987c8ddccf05ceac000a4463d7b66ae801ab Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 20:57:28 -0500 Subject: [PATCH 08/18] fix: address Copilot code-review findings on the COM backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate op-progress reports on _opCts (the precise "operation in flight" signal) instead of _state.Loading, which is also toggled by ordinary list/detail refreshes — closes a race where a concurrent refresh could drop progress samples or let a late report through after the op settled. - Skip malformed package rows in Search/Installed/Upgrades instead of letting a bad HRESULT on a single Id/Name read tear down the whole listing. - Add an Uninstalling phase so the status bar no longer says "Installing" during an uninstall. Documented two deferred findings as known limitations in ComBackend's header: all-or-nothing composite connect (mitigated by the 'f' source filter) and by-id-only operation resolution (matches CliBackend; needs an IBackend change to carry source identity). Both left as-is rather than ship untested COM connection-probing or an interface change that a Linux build can't verify. Both TFMs build, tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.cs | 6 +++- src/ComBackend.cs | 75 +++++++++++++++++++++++++++++++---------------- src/Models.cs | 2 ++ 3 files changed, 57 insertions(+), 26 deletions(-) diff --git a/src/App.cs b/src/App.cs index 9f99eac..f048402 100644 --- a/src/App.cs +++ b/src/App.cs @@ -1331,7 +1331,11 @@ private void RunOperation (string activity, Func, Cancella /// private void OnOpProgress (OpProgress value) { - if (!_state.Loading) + // Gate on the operation CTS, not _state.Loading: Loading is also toggled by ordinary + // list/detail refreshes, so a concurrent refresh could otherwise drop op samples or let + // a late report through after the op settled. _opCts is non-null iff an op is in flight + // and is cleared before the final refresh. + if (_opCts is null) { return; } diff --git a/src/ComBackend.cs b/src/ComBackend.cs index e31b416..833c621 100644 --- a/src/ComBackend.cs +++ b/src/ComBackend.cs @@ -24,6 +24,15 @@ namespace WingetTuiSharp; /// Pinning has no COM surface (the API exposes no pin/unpin/list-pins), so those three /// operations are delegated to an internal — winget.exe is always /// present on a machine where the COM server is registered, so this keeps full feature parity. +/// +/// Known limitations (from code review, deferred deliberately): +/// - Composite connect is all-or-nothing: a configured-but-unhealthy source (e.g. a broken +/// msstore) can fail a SourceFilter.All query even when winget alone is fine. Mitigated by +/// the in-app source filter ('f') which lets the user narrow to a single working source. +/// - Operations resolve a package by id alone (FindByIdAsync over SourceFilter.All takes the +/// first exact match). If the same id existed in multiple catalogs the wrong source could be +/// chosen. Rare in practice (winget vs msstore ids differ), and matches CliBackend's by-id +/// behavior; carrying source identity through IBackend would be a separate change. /// public sealed class ComBackend : IBackend { @@ -64,16 +73,24 @@ public async Task> SearchAsync (string query, SourceFilte foreach (MatchResult m in Materialize (result.Matches)) { - CatalogPackage pkg = m.CatalogPackage; - string version = SafeVersion (SafeDefaultInstallVersion (pkg)) ?? LatestAvailableVersion (pkg) ?? string.Empty; - - packages.Add (new () + try { - Id = pkg.Id, - Name = pkg.Name, - Version = version, - Source = SourceOf (pkg) - }); + CatalogPackage pkg = m.CatalogPackage; + string version = SafeVersion (SafeDefaultInstallVersion (pkg)) ?? LatestAvailableVersion (pkg) ?? string.Empty; + + packages.Add (new () + { + Id = pkg.Id, + Name = pkg.Name, + Version = version, + Source = SourceOf (pkg) + }); + } + catch + { + // A bad HRESULT on Id/Name surfaces as an exception here; skip the malformed + // row rather than failing the entire search. + } } return packages; @@ -104,24 +121,32 @@ private async Task> ListLocalAsync (SourceFilter source, foreach (MatchResult m in Materialize (result.Matches)) { - CatalogPackage pkg = m.CatalogPackage; - bool updateAvailable = SafeIsUpdateAvailable (pkg); - - if (upgradesOnly && !updateAvailable) + try { - continue; + CatalogPackage pkg = m.CatalogPackage; + bool updateAvailable = SafeIsUpdateAvailable (pkg); + + if (upgradesOnly && !updateAvailable) + { + continue; + } + + string installed = SafeVersion (SafeInstalledVersion (pkg)) ?? string.Empty; + + packages.Add (new () + { + Id = pkg.Id, + Name = pkg.Name, + Version = installed, + Source = SourceOf (pkg), + AvailableVersion = updateAvailable ? LatestAvailableVersion (pkg) : null + }); } - - string installed = SafeVersion (SafeInstalledVersion (pkg)) ?? string.Empty; - - packages.Add (new () + catch { - Id = pkg.Id, - Name = pkg.Name, - Version = installed, - Source = SourceOf (pkg), - AvailableVersion = updateAvailable ? LatestAvailableVersion (pkg) : null - }); + // Skip a malformed row (bad HRESULT on a property read) rather than failing + // the entire listing. + } } return packages; @@ -515,7 +540,7 @@ private static OpProgress MapUninstall (UninstallProgress p) OpPhase phase = p.State switch { PackageUninstallProgressState.Queued => OpPhase.Queued, - PackageUninstallProgressState.Uninstalling => OpPhase.Installing, + PackageUninstallProgressState.Uninstalling => OpPhase.Uninstalling, PackageUninstallProgressState.PostUninstall => OpPhase.Finalizing, PackageUninstallProgressState.Finished => OpPhase.Done, _ => OpPhase.Installing diff --git a/src/Models.cs b/src/Models.cs index 489da7c..8de364b 100644 --- a/src/Models.cs +++ b/src/Models.cs @@ -197,6 +197,7 @@ public enum OpPhase Queued, Downloading, Installing, + Uninstalling, Finalizing, Done } @@ -215,6 +216,7 @@ public string Label OpPhase.Queued => "Queued", OpPhase.Downloading => "Downloading", OpPhase.Installing => "Installing", + OpPhase.Uninstalling => "Uninstalling", OpPhase.Finalizing => "Finalizing", OpPhase.Done => "Done", _ => string.Empty From 0301e63ccd15a24604311626dc2a7fdbde398e5c Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 21:08:38 -0500 Subject: [PATCH 09/18] fix: second Copilot review pass on COM backend + backend switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend gate/switch (Program.cs): - --cli now takes precedence over --com: COM is chosen only when --cli was not passed (was: --com won even alongside --cli, contradicting the documented order). Precedence is now --mock > --cli > --com > default. - Reworded the selection comment: flags are preferences, not hard guarantees — an unavailable requested backend degrades with a stderr note (--com off-Windows → CLI; CLI with no winget → mock). Behavior unchanged (graceful fallback is right for an interactive TUI); the docs just no longer claim "force". ComBackend.cs: - FindVersionId and ShowAsync now guard their COM property reads (AvailableVersions, vid.Version, pkg.Id/Name); a bad HRESULT yields "version not found" / a null detail (→ stub) instead of throwing, matching the list path's skip behavior. - Unknown uninstall progress state maps to Uninstalling (was Installing). - Upgrade failures now say "Upgrade failed" (DescribeInstall takes the verb). Documented two untestable-from-Linux items as known limitations: the shared PackageManager's thread-agility assumption (watch for RPC_E_WRONG_THREAD on Windows) and that pin ops require winget.exe on PATH even on the COM backend. Both TFMs build, tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Program.cs | 20 ++++++++------ src/ComBackend.cs | 67 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/Program.cs b/Program.cs index 722fb73..8b9743f 100644 --- a/Program.cs +++ b/Program.cs @@ -65,12 +65,14 @@ return; } -// Backend selection (in priority order): -// --mock / -m force the in-memory mock (cross-platform dev/parity) -// --cli force the winget.exe CLI parser -// --com force the WinGet COM API backend (Windows builds only) -// (default) COM on Windows builds, CLI elsewhere; either falls back to mock -// if winget isn't usable. +// Backend selection (precedence: --mock > --cli > --com > default): +// --mock / -m the in-memory mock (cross-platform dev/parity) +// --cli the winget.exe CLI parser +// --com the WinGet COM API backend (Windows builds only) +// (default) COM on Windows builds, CLI elsewhere +// These are preferences, not hard guarantees: a requested backend that can't run degrades +// (with a stderr note) — --com on a non-Windows build → CLI, and any CLI path with no winget +// on PATH → mock. Scripts that need a guaranteed backend should check that note. IBackend backend = SelectBackend (args); Theme.Register (); @@ -95,8 +97,10 @@ static IBackend SelectBackend (string [] args) } #if WINGET_COM - // On the Windows build, COM is the default unless the user explicitly asked for the CLI. - if (wantCom || (!wantCli && OperatingSystem.IsWindows ())) + // Precedence: an explicit --cli always wins over --com (and over the Windows COM default). + // So COM is chosen only when --cli was NOT passed and either --com was, or we're the + // default on Windows. + if (!wantCli && (wantCom || OperatingSystem.IsWindows ())) { try { diff --git a/src/ComBackend.cs b/src/ComBackend.cs index 833c621..a828b1e 100644 --- a/src/ComBackend.cs +++ b/src/ComBackend.cs @@ -33,6 +33,13 @@ namespace WingetTuiSharp; /// first exact match). If the same id existed in multiple catalogs the wrong source could be /// chosen. Rare in practice (winget vs msstore ids differ), and matches CliBackend's by-id /// behavior; carrying source identity through IBackend would be a separate change. +/// - A single PackageManager is shared across operations that the UI invokes from background +/// (threadpool/MTA) threads. WinGet's COM objects are expected to be agile, but that hasn't +/// been verified under this app's usage; if RPC_E_WRONG_THREAD or intermittent COM errors +/// surface on Windows, switch to a fresh PackageManager per operation. +/// - Pinning delegates to winget.exe, so pin/unpin/list-pins need winget on PATH even on this +/// backend. If the COM server is registered but winget.exe isn't reachable, pin operations +/// fail (visibly, via the returned OpResult) while everything else keeps working. /// public sealed class ComBackend : IBackend { @@ -182,19 +189,29 @@ private async Task> ListLocalAsync (SourceFilter source, string? description = Coalesce (meta?.Description, meta?.ShortDescription); - return new () - { - Id = pkg.Id, - Name = Coalesce (meta?.PackageName, pkg.Name) ?? pkg.Id, - Version = SafeVersion (SafeInstalledVersion (pkg)) ?? SafeVersion (versionInfo) ?? string.Empty, - AvailableVersion = LatestAvailableVersion (pkg), - Source = SourceOf (pkg), - Publisher = NullIfEmpty (meta?.Publisher), - Description = description, - Homepage = NullIfEmpty (meta?.PackageUrl), - License = NullIfEmpty (meta?.License), - ReleaseNotesUrl = NullIfEmpty (meta?.ReleaseNotesUrl) - }; + try + { + return new () + { + Id = pkg.Id, + Name = Coalesce (meta?.PackageName, pkg.Name) ?? pkg.Id, + Version = SafeVersion (SafeInstalledVersion (pkg)) ?? SafeVersion (versionInfo) ?? string.Empty, + AvailableVersion = LatestAvailableVersion (pkg), + Source = SourceOf (pkg), + Publisher = NullIfEmpty (meta?.Publisher), + Description = description, + Homepage = NullIfEmpty (meta?.PackageUrl), + License = NullIfEmpty (meta?.License), + ReleaseNotesUrl = NullIfEmpty (meta?.ReleaseNotesUrl) + }; + } + catch + { + // Core id/name getters threw (bad HRESULT). Return null so the app falls back to its + // stub detail rather than surfacing a "Detail error", matching the list path's + // skip-the-bad-row behavior. + return null; + } } // ------------------------------------------------------------------------ @@ -237,7 +254,7 @@ public async Task InstallAsync (string id, string? version, IProgress< return result.Status == InstallResultStatus.Ok ? Ok (op, $"Installed {pkg.Name}{(result.RebootRequired ? " (reboot required)" : string.Empty)}") - : Fail (op, DescribeInstall (result)); + : Fail (op, DescribeInstall ("Install", result)); } public async Task UpgradeAsync (string id, IProgress? progress, CancellationToken ct) @@ -265,7 +282,7 @@ public async Task UpgradeAsync (string id, IProgress? prog return result.Status == InstallResultStatus.Ok ? Ok (op, $"Upgraded {pkg.Name}{(result.RebootRequired ? " (reboot required)" : string.Empty)}") - : Fail (op, DescribeInstall (result)); + : Fail (op, DescribeInstall ("Upgrade", result)); } public async Task UninstallAsync (string id, IProgress? progress, CancellationToken ct) @@ -385,13 +402,21 @@ private static async Task ConnectAsync (PackageCatalogReference private static PackageVersionId? FindVersionId (CatalogPackage pkg, string version) { - foreach (PackageVersionId vid in Materialize (pkg.AvailableVersions)) + try { - if (string.Equals (vid.Version, version, StringComparison.OrdinalIgnoreCase)) + foreach (PackageVersionId vid in Materialize (pkg.AvailableVersions)) { - return vid; + if (string.Equals (vid.Version, version, StringComparison.OrdinalIgnoreCase)) + { + return vid; + } } } + catch + { + // Version list unreadable (bad HRESULT) — treat as "version not found" so the caller + // returns a clean OpResult instead of throwing. + } return null; } @@ -543,14 +568,14 @@ private static OpProgress MapUninstall (UninstallProgress p) PackageUninstallProgressState.Uninstalling => OpPhase.Uninstalling, PackageUninstallProgressState.PostUninstall => OpPhase.Finalizing, PackageUninstallProgressState.Finished => OpPhase.Done, - _ => OpPhase.Installing + _ => OpPhase.Uninstalling }; return new (phase, p.UninstallationProgress); } - private static string DescribeInstall (InstallResult result) - => $"Install failed: {result.Status} (installer {result.InstallerErrorCode}, hr 0x{HResultOf (result.ExtendedErrorCode):X8})"; + private static string DescribeInstall (string verb, InstallResult result) + => $"{verb} failed: {result.Status} (installer {result.InstallerErrorCode}, hr 0x{HResultOf (result.ExtendedErrorCode):X8})"; // In this projection, the IDL's `HRESULT ExtendedErrorCode` surfaces as a System.Exception // (CsWinRT maps a failed HRESULT to its exception). Pull the numeric HRESULT back out. From b5241143361e5b9c5b1d43682f315cce41b24e19 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 21:12:53 -0500 Subject: [PATCH 10/18] =?UTF-8?q?docs:=20add=20WINDOWS-TESTING.md=20?= =?UTF-8?q?=E2=80=94=20consolidated=20Windows=20verification=20checklist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single P0/P1/P2 checklist of everything that can only be confirmed on Windows (AOT can't cross-compile from Linux; the WinGet COM server and installs need Windows): foundational COM runtime, search/list/upgrade/show, the operations, the live progress bar (incl. the .Progress delegate CCW unknown), Esc cancellation, and the review-flagged concerns (shared PackageManager thread agility, unhealthy-source composite connect, pin-needs-winget, binary-size measurement). Includes build/run commands and expected results per item. Co-Authored-By: Claude Opus 4.8 (1M context) --- WINDOWS-TESTING.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 WINDOWS-TESTING.md diff --git a/WINDOWS-TESTING.md b/WINDOWS-TESTING.md new file mode 100644 index 0000000..1fb6f66 --- /dev/null +++ b/WINDOWS-TESTING.md @@ -0,0 +1,77 @@ +# Windows verification checklist — `feat/com-backend` + +Everything below can only be confirmed on a real Windows host (Native AOT codegen +can't cross-compile from Linux, and the WinGet COM server + installs need Windows). +Work top-down; **P0** gates everything else. + +## Build & run + +```powershell +# from the repo root, on Windows +dotnet publish -c Release -f net10.0-windows10.0.26100.0 -r win-x64 +$exe = ".\bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\winget-tui-sharp.exe" + +# confirm it's a real Native-AOT image (single native exe, no CoreCLR shipped): +Test-Path ".\bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\coreclr.dll" # expect: False + +& $exe # default backend (COM on Windows) +& $exe --cli # force the CLI backend +& $exe --mock # force the mock backend +& $exe --com # force COM explicitly +``` + +For quick iteration without AOT: `dotnet run -f net10.0-windows10.0.26100.0`. + +--- + +## P0 — Foundational COM runtime (must pass first) + +- [ ] **AOT publish succeeds** and produces a native exe; `coreclr.dll` is absent (true AOT, not self-contained). +- [ ] **No `InvalidCastException` anywhere at runtime.** The whole backend uses indexed `Materialize` instead of `foreach` over projected collections (the spike's AOT rule). Exercise search/list/upgrades/show and confirm none throw the spike's original cast error. +- [ ] **Default backend = COM** on the Windows build with no flags (not CLI). Sanity: search is fast/structured, IDs are never truncated with `…`. +- [ ] **Flag selection** works: `--cli`, `--com`, `--mock` pick the right backend; **`--cli` wins when both `--cli` and `--com` are passed** (precedence `--mock > --cli > --com > default`). +- [ ] **Search** (`/`) returns real catalog results with version + source columns. +- [ ] **Installed** tab lists installed packages with correct installed versions. +- [ ] **Upgrades** tab shows only packages with an available update, with the Available column populated. +- [ ] **Details** panel: selecting a row fetches metadata (publisher, description, homepage, license, release-notes URL). +- [ ] **Source filter** (`f`) cycles All / winget / msstore and re-queries correctly. + +## P1 — Operations + the two new features + +Operations (pick a small, safe package to install/uninstall, e.g. a CLI tool): + +- [ ] **Install** (`i`) via COM succeeds; status shows result; reboot-required note appears when applicable. +- [ ] **Install specific version** (`I`) resolves the chosen version (`PackageVersionId` path). +- [ ] **Upgrade** (`u`) works; a forced failure says **"Upgrade failed"** (not "Install failed"). +- [ ] **Uninstall** (`x`) works. +- [ ] **Batch upgrade** (Upgrades tab → space to select → `U`) runs sequentially with per-item status. + +**Live progress bar** (the headline feature — also tests `.Progress` delegate marshaling under AOT, the one CCW-callback unknown): + +- [ ] During a real COM **install**, the status bar shows a determinate bar that **advances**, moving through **Downloading → Installing** phases with changing percentages (not stuck at 0%/100%). +- [ ] During an **uninstall**, the phase reads **"Uninstalling"** (not "Installing"). +- [ ] Progress callbacks don't crash under AOT (the managed→native delegate CCW works — same path the spike's awaited `ConnectAsync` exercised). + +**Cancellation** (`Esc`): + +- [ ] **Esc during an install cancels it** cooperatively (COM `Cancel()`): status shows "Cancelling…" then **"Cancelled"**, and the list refreshes. +- [ ] **Esc with no op running** still quits the app (unchanged behavior). +- [ ] `q` and `Ctrl+C` **still quit** during an op (only `Esc` cancels). +- [ ] **Batch upgrade + Esc**: the in-flight item cancels and the remaining queue stops. +- [ ] **One-op-at-a-time guard**: triggering a second operation while one is running is ignored (no second progress bar, no crash). + +## P2 — Review-flagged real-Windows concerns & measurements + +- [ ] **Shared `PackageManager` thread-agility.** The backend reuses one `PackageManager` across operations invoked from background/threadpool (MTA) threads. Watch for `RPC_E_WRONG_THREAD` or intermittent COM errors under rapid search/typing or back-to-back ops. **If seen → switch to a fresh `PackageManager` per operation** (currently shared as a perf choice). *(Open from review pass 2.)* +- [ ] **Unhealthy source + `All`.** Disable/break the `msstore` source, then do a default (All) search. Does the all-or-nothing composite connect fail the whole query? Confirm the documented workaround — pressing `f` to narrow to winget-only — recovers. *(Open from review pass 1.)* +- [ ] **Pinning on the COM backend.** Pin (`p`), unpin, and pin annotations (📌) work — these delegate to `winget.exe`, so they need winget on PATH even on the COM backend. Confirm pin state shows in Installed/Upgrades and pin/unpin succeed. +- [ ] **Same-id-across-catalogs** (rare): if a package id exists in multiple sources, operations resolve the first match. Only worth checking if you hit an odd case. +- [ ] **CLI-backend cancel** (`--cli`, then Esc mid-install): confirm it stops watching but does **not** kill `winget.exe` (the install continues) — documented, lower priority. +- [ ] **Measure the AOT binary size** of the COM (Windows) build and compare to the CLI/mock build, to budget the COM backend's cost. *(Open spike question.)* +- [ ] **(Optional) win-arm64**: repeat the P0 smoke on an arm64 host or arm64 cross-target. + +--- + +### Notes +- Spike repro for the COM-in-AOT mechanics lives in `spikes/ComBackendSpike/` (`Run-AotSpike.ps1`, `SPIKE-RESULTS.md`) — already validated; useful if a low-level COM/AOT question resurfaces. +- This checklist is maintained as the canonical "verify on Windows" list for the COM work; new COM-backend changes that can't be checked from Linux should add an item here. From a9201c2fc86591ab7017324cbebf203ec0a1d309 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 21:21:29 -0500 Subject: [PATCH 11/18] feat: install preview dialog + real version picker (COM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two COM-backed UX upgrades, plumbed through IBackend so CLI/Mock degrade. - IBackend gains ListVersionsAsync and GetInstallerPreviewAsync, plus an InstallerPreview model (installer type / architecture / scope / elevation + a "MSI · x64 · machine · admin" Summary). Note: the WinGet COM API exposes NO installer download size (BytesRequired is runtime-progress-only), so size is intentionally omitted rather than faked. - ComBackend: ListVersions enumerates AvailableVersions (indexed/Materialize, newest-first, deduped); GetInstallerPreview resolves PackageVersionInfo (for the chosen or default version) and maps GetApplicableInstaller(InstallOptions) → InstallerPreview, with friendly enum mapping and full exception guarding. - CliBackend degrades: empty version list (→ free-text fallback) and null preview (→ plain confirm). MockBackend returns representative data so both features are exercisable on Linux. - UI: new VersionPickerDialog (ListView). App.AskInstall is now async: `i` → fetch preview → confirm showing the installer summary → install; `I` → fetch versions → picker (or free-text fallback) → preview confirm → install. A FetchThen helper runs the short async fetch off-thread with a transient status, then marshals the modal onto the UI thread. Both TFMs build, tests pass, Windows-TFM trim analysis 0 IL2026/IL3050. WINDOWS-TESTING.md updated with preview/picker verification items. Co-Authored-By: Claude Opus 4.8 (1M context) --- WINDOWS-TESTING.md | 12 ++++ src/App.cs | 113 +++++++++++++++++++++++++++++--- src/Backend.cs | 10 +++ src/CliBackend.cs | 10 +++ src/ComBackend.cs | 152 ++++++++++++++++++++++++++++++++++++++++++++ src/GlobalUsings.cs | 1 + src/MockBackend.cs | 31 +++++++++ src/Models.cs | 55 ++++++++++++++++ src/Ui.cs | 54 ++++++++++++++++ 9 files changed, 429 insertions(+), 9 deletions(-) diff --git a/WINDOWS-TESTING.md b/WINDOWS-TESTING.md index 1fb6f66..c3736ae 100644 --- a/WINDOWS-TESTING.md +++ b/WINDOWS-TESTING.md @@ -46,6 +46,18 @@ Operations (pick a small, safe package to install/uninstall, e.g. a CLI tool): - [ ] **Uninstall** (`x`) works. - [ ] **Batch upgrade** (Upgrades tab → space to select → `U`) runs sequentially with per-item status. +**Install preview dialog** (`i` — COM-only data): + +- [ ] Pressing `i` briefly shows "Checking installer…", then the confirm dialog includes an installer summary line, e.g. **`MSI · x64 · machine · admin`** (type · architecture · scope · elevation). Note: the COM API exposes **no download size**, so size is intentionally absent. +- [ ] The summary reflects reality — e.g. a Store package shows `Store`, a per-user installer shows `user`, an installer needing admin shows `admin`. +- [ ] If installer resolution fails (e.g. no applicable installer for this arch), the confirm still appears with just "Install X?" (no summary line) rather than erroring. + +**Real version picker** (`I`): + +- [ ] `I` shows a **selectable list of real versions** (newest first), not the free-text box, when the COM backend can enumerate them. +- [ ] Picking a version → the install confirm shows that version + its installer preview → installs the chosen version. +- [ ] (CLI backend, `--cli`) `I` falls back to the **free-text** version prompt, since the CLI path returns no version list. + **Live progress bar** (the headline feature — also tests `.Progress` delegate marshaling under AOT, the one CCW-callback unknown): - [ ] During a real COM **install**, the status bar shows a determinate bar that **advances**, moving through **Downloading → Installing** phases with changing percentages (not stuck at 0%/100%). diff --git a/src/App.cs b/src/App.cs index f048402..fc97585 100644 --- a/src/App.cs +++ b/src/App.cs @@ -1054,26 +1054,121 @@ private void AskInstall (Package? p, bool specificVersion) return; } - if (!specificVersion) + if (specificVersion) { - if (!Confirm ("Install", $"Install {p.Name}?")) + BeginVersionPick (p); + } + else + { + ConfirmAndInstall (p, null); + } + } + + /// + /// Fetch the available versions, then let the user pick one (real list from the backend) and + /// continue to the install confirm. Falls back to the free-text prompt when the backend can't + /// enumerate versions (e.g. the CLI backend returns an empty list). + /// + private void BeginVersionPick (Package p) + { + FetchThen ( + "Loading versions…", + ct => _state.Backend.ListVersionsAsync (p.Id, ct), + versions => { - return; - } + string? chosen = versions.Count > 0 ? PickVersion (p, versions) : PromptForVersion (p); - RunOperation ($"Installing {p.Name}", (prog, ct) => _state.Backend.InstallAsync (p.Id, null, prog, ct)); + if (!string.IsNullOrEmpty (chosen)) + { + ConfirmAndInstall (p, chosen); + } + }); + } + /// + /// Fetch the applicable-installer preview, show it in the confirm dialog + /// (e.g. "Install X? \n MSI · x64 · machine · admin"), then install on confirm. + /// + private void ConfirmAndInstall (Package p, string? version) + { + FetchThen ( + "Checking installer…", + ct => _state.Backend.GetInstallerPreviewAsync (p.Id, version, ct), + preview => + { + string title = version is null ? $"Install {p.Name}?" : $"Install {p.Name} {version}?"; + string summary = preview?.Summary ?? string.Empty; + string body = string.IsNullOrEmpty (summary) ? title : $"{title}\n\n{summary}"; + + if (Confirm ("Install", body)) + { + string activity = version is null ? $"Installing {p.Name}" : $"Installing {p.Name} {version}"; + RunOperation (activity, (prog, ct) => _state.Backend.InstallAsync (p.Id, version, prog, ct)); + } + }); + } + + /// + /// Run a short async fetch on a background thread (with a transient status), then invoke the + /// continuation on the UI thread. Used to gather version/installer info before showing a modal + /// dialog without blocking the UI. Skipped if an operation is already in flight. + /// + private void FetchThen (string activity, Func> fetch, Action onResult) + { + if (_opCts is not null) + { return; } - string? version = PromptForVersion (p); + _state.StatusMessage = activity; + _state.Loading = true; + _state.StatusIsError = false; + RefreshStatusBar (); - if (string.IsNullOrEmpty (version)) + Task.Run (async () => + { + T result; + + try + { + result = await fetch (CancellationToken.None); + } + catch (Exception ex) + { + App?.Invoke (() => + { + _state.Loading = false; + _state.StatusMessage = $"Error: {ex.Message}"; + _state.StatusIsError = true; + RefreshStatusBar (); + }); + + return; + } + + App?.Invoke (() => + { + _state.Loading = false; + _state.StatusMessage = string.Empty; + RefreshStatusBar (); + onResult (result); + }); + }); + } + + private string? PickVersion (Package p, IReadOnlyList versions) + { + if (App is null) { - return; + return null; } - RunOperation ($"Installing {p.Name} {version}", (prog, ct) => _state.Backend.InstallAsync (p.Id, version, prog, ct)); + VersionPickerDialog dlg = new (p.Name, versions); + App.Run (dlg); + string? value = dlg.Result; + dlg.Dispose (); + + return value; } private void AskUpgrade (Package? p) diff --git a/src/Backend.cs b/src/Backend.cs index 474b726..e2d2659 100644 --- a/src/Backend.cs +++ b/src/Backend.cs @@ -8,6 +8,16 @@ public interface IBackend Task> ListUpgradesAsync (SourceFilter source, CancellationToken ct); Task ShowAsync (string id, CancellationToken ct); + // Available versions for a package, newest first. Drives the version picker. Backends that + // can't enumerate versions (CLI) return an empty list, in which case the UI falls back to a + // free-text version prompt. + Task> ListVersionsAsync (string id, CancellationToken ct); + + // What would be installed (installer type / architecture / scope / elevation) for a package, + // optionally at a specific version. Shown in the install confirm dialog. Returns null when the + // backend can't resolve it (CLI), in which case the confirm shows no preview line. + Task GetInstallerPreviewAsync (string id, string? version, CancellationToken ct); + // The install/upgrade/uninstall operations optionally report structured progress through // `progress`. Backends that can't (CLI) ignore it; the COM backend maps the WinGet COM // progress events onto OpProgress; the mock backend synthesizes a download→install ramp. diff --git a/src/CliBackend.cs b/src/CliBackend.cs index 6f8a7b7..d5f3bc7 100644 --- a/src/CliBackend.cs +++ b/src/CliBackend.cs @@ -48,6 +48,16 @@ public async Task> ListUpgradesAsync (SourceFilter source return ParseShow (id, output); } + // The CLI has no structured version list or applicable-installer query worth scraping, so + // these degrade: an empty version list makes the UI fall back to a free-text version prompt, + // and a null preview means the install confirm shows no installer summary line. The COM + // backend is the one that answers these. + public Task> ListVersionsAsync (string id, CancellationToken ct) + => Task.FromResult> ([]); + + public Task GetInstallerPreviewAsync (string id, string? version, CancellationToken ct) + => Task.FromResult (null); + // progress is unused: winget.exe only emits an ANSI progress bar to stdout, which we // capture as a whole rather than scrape. The COM backend is the one that reports progress. public async Task InstallAsync (string id, string? version, IProgress? progress, CancellationToken ct) diff --git a/src/ComBackend.cs b/src/ComBackend.cs index a828b1e..d69dacb 100644 --- a/src/ComBackend.cs +++ b/src/ComBackend.cs @@ -214,6 +214,158 @@ private async Task> ListLocalAsync (SourceFilter source, } } + // ------------------------------------------------------------------------ + // Version list + install preview + // ------------------------------------------------------------------------ + + public async Task> ListVersionsAsync (string id, CancellationToken ct) + { + CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: false, ct); + + if (pkg is null) + { + return []; + } + + List versions = []; + HashSet seen = new (StringComparer.OrdinalIgnoreCase); + + try + { + // AvailableVersions is newest-first. Indexed access via Materialize (AOT rule). + foreach (PackageVersionId vid in Materialize (pkg.AvailableVersions)) + { + string v = vid.Version; + + if (!string.IsNullOrWhiteSpace (v) && seen.Add (v)) + { + versions.Add (v); + } + } + } + catch + { + // Return whatever we collected before the version list became unreadable. + } + + return versions; + } + + public async Task GetInstallerPreviewAsync (string id, string? version, CancellationToken ct) + { + CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: false, ct); + + if (pkg is null) + { + return null; + } + + PackageVersionInfo? versionInfo; + + if (!string.IsNullOrEmpty (version)) + { + PackageVersionId? vid = FindVersionId (pkg, version); + versionInfo = vid is null ? null : SafeGetVersionInfo (pkg, vid); + } + else + { + versionInfo = SafeDefaultInstallVersion (pkg); + } + + versionInfo ??= SafeInstalledVersion (pkg); + + if (versionInfo is null) + { + return null; + } + + try + { + // Resolve the installer that *would* be chosen for default options on this machine. + PackageInstallerInfo installer = versionInfo.GetApplicableInstaller (new InstallOptions ()); + + if (installer is null) + { + return null; + } + + return new InstallerPreview + { + InstallerType = TypeName (installer.InstallerType), + Architecture = ArchName (installer.Architecture), + Scope = ScopeName (installer.Scope), + RequiresElevation = RequiresElevation (installer), + Version = SafeVersion (versionInfo) + }; + } + catch + { + // No applicable installer (e.g. arch mismatch) or the API isn't available — no preview. + return null; + } + } + + private static PackageVersionInfo? SafeGetVersionInfo (CatalogPackage pkg, PackageVersionId vid) + { + try + { + return pkg.GetPackageVersionInfo (vid); + } + catch + { + return null; + } + } + + private static bool RequiresElevation (PackageInstallerInfo installer) + { + try + { + return installer.ElevationRequirement == ElevationRequirement.ElevationRequired; + } + catch + { + // ElevationRequirement is a newer contract member; absent on older COM servers. + return false; + } + } + + private static string? TypeName (PackageInstallerType t) + => t switch + { + PackageInstallerType.Msi => "MSI", + PackageInstallerType.Msix => "MSIX", + PackageInstallerType.Exe => "EXE", + PackageInstallerType.MSStore => "Store", + PackageInstallerType.Inno => "Inno", + PackageInstallerType.Nullsoft => "Nullsoft", + PackageInstallerType.Wix => "WiX", + PackageInstallerType.Burn => "Burn", + PackageInstallerType.Zip => "Zip", + PackageInstallerType.Portable => "Portable", + PackageInstallerType.Font => "Font", + _ => null + }; + + private static string? ArchName (Windows.System.ProcessorArchitecture a) + => a switch + { + Windows.System.ProcessorArchitecture.X64 => "x64", + Windows.System.ProcessorArchitecture.X86 => "x86", + Windows.System.ProcessorArchitecture.Arm64 => "arm64", + Windows.System.ProcessorArchitecture.Arm => "arm", + Windows.System.ProcessorArchitecture.Neutral => "neutral", + _ => null + }; + + private static string? ScopeName (PackageInstallerScope s) + => s switch + { + PackageInstallerScope.System => "machine", + PackageInstallerScope.User => "user", + _ => null + }; + // ------------------------------------------------------------------------ // Writes // ------------------------------------------------------------------------ diff --git a/src/GlobalUsings.cs b/src/GlobalUsings.cs index a18e145..afead09 100644 --- a/src/GlobalUsings.cs +++ b/src/GlobalUsings.cs @@ -3,6 +3,7 @@ // (almost never wanted) and Terminal.Gui.Drawing.Attribute (what we mean every time). global using System.Collections.Generic; +global using System.Collections.ObjectModel; global using System.Diagnostics; global using System.IO; global using System.Linq; diff --git a/src/MockBackend.cs b/src/MockBackend.cs index 73fc49f..a9dd155 100644 --- a/src/MockBackend.cs +++ b/src/MockBackend.cs @@ -119,6 +119,37 @@ public Task> ListUpgradesAsync (SourceFilter source, Canc return Task.FromResult (detail); } + public Task> ListVersionsAsync (string id, CancellationToken ct) + { + Package? p = _searchResults.Concat (_installed) + .FirstOrDefault (x => x.Id.Equals (id, StringComparison.OrdinalIgnoreCase)); + string baseV = p?.AvailableVersion ?? p?.Version ?? "1.0.0"; + + // A small, distinct, newest-first list so the version picker is exercisable on any host. + IReadOnlyList versions = new [] { baseV, "1.1.0", "1.0.0", "0.9.0" } + .Distinct (StringComparer.OrdinalIgnoreCase) + .ToList (); + + return Task.FromResult (versions); + } + + public Task GetInstallerPreviewAsync (string id, string? version, CancellationToken ct) + { + Package? p = _searchResults.Concat (_installed) + .FirstOrDefault (x => x.Id.Equals (id, StringComparison.OrdinalIgnoreCase)); + + InstallerPreview preview = new () + { + InstallerType = p?.Source == "msstore" ? "Store" : "MSI", + Architecture = "x64", + Scope = "machine", + RequiresElevation = true, + Version = version ?? p?.AvailableVersion ?? p?.Version + }; + + return Task.FromResult (preview); + } + public async Task InstallAsync (string id, string? version, IProgress? progress, CancellationToken ct) { await SimulateProgressAsync (progress, downloads: true, ct); diff --git a/src/Models.cs b/src/Models.cs index 8de364b..71a3189 100644 --- a/src/Models.cs +++ b/src/Models.cs @@ -222,3 +222,58 @@ public string Label _ => string.Empty }; } + +/// +/// What would actually be installed for a package, shown in the install confirm dialog. Sourced +/// from the WinGet COM API's applicable-installer resolution (type/arch/scope/elevation). Note: +/// the COM API exposes no installer download size, so size is intentionally absent. Backends that +/// can't resolve this (CLI) return null; the mock fills representative values. +/// +public sealed class InstallerPreview +{ + /// Friendly installer type, e.g. "MSI", "EXE", "MSIX", "Store". + public string? InstallerType { get; init; } + + /// Friendly architecture, e.g. "x64", "arm64", "x86". + public string? Architecture { get; init; } + + /// Install scope: "machine", "user", or null when unknown. + public string? Scope { get; init; } + + /// True when the installer requires elevation (admin). + public bool RequiresElevation { get; init; } + + /// The version this installer resolves to, when known. + public string? Version { get; init; } + + /// One-line summary like MSI · x64 · machine · admin for the confirm dialog. + public string Summary + { + get + { + List parts = []; + + if (!string.IsNullOrWhiteSpace (InstallerType)) + { + parts.Add (InstallerType); + } + + if (!string.IsNullOrWhiteSpace (Architecture)) + { + parts.Add (Architecture); + } + + if (!string.IsNullOrWhiteSpace (Scope)) + { + parts.Add (Scope); + } + + if (RequiresElevation) + { + parts.Add ("admin"); + } + + return string.Join (" · ", parts); + } + } +} diff --git a/src/Ui.cs b/src/Ui.cs index 21337f5..e43c971 100644 --- a/src/Ui.cs +++ b/src/Ui.cs @@ -359,6 +359,60 @@ public VersionInputDialog (string packageName) } } +/// +/// Modal that lets the user pick a real version from a list (newest first), used when the backend +/// can enumerate available versions (the COM backend). Falls back to +/// when no versions are available (e.g. the CLI backend). Result is the chosen version, or null on cancel. +/// +public sealed class VersionPickerDialog : Runnable +{ + public VersionPickerDialog (string packageName, IReadOnlyList versions) + { + Title = $" Select version of {packageName} "; + BorderStyle = LineStyle.Rounded; + Width = 60; + Height = Math.Clamp (versions.Count + 6, 10, 22); + X = Pos.Center (); + Y = Pos.Center (); + SchemeName = Theme.SurfaceSchemeName; + Arrangement = ViewArrangement.Movable; + + Label prompt = new () { X = 1, Y = 0, Text = "Pick a version (newest first):" }; + + ListView list = new () + { + X = 1, + Y = 1, + Width = Dim.Fill (1), + Height = Dim.Fill (2), + SchemeName = Theme.SurfaceSchemeName + }; + list.SetSource (new ObservableCollection (versions)); + list.SelectedItem = 0; + + Button install = new () { X = Pos.Center () - 8, Y = Pos.AnchorEnd (1), Text = "_Install", IsDefault = true }; + Button cancel = new () { X = Pos.Center () + 2, Y = Pos.AnchorEnd (1), Text = "Cancel" }; + + install.Accepting += (_, e) => + { + int idx = list.SelectedItem ?? -1; + Result = idx >= 0 && idx < versions.Count ? versions [idx] : null; + RequestStop (); + e.Handled = true; + }; + + cancel.Accepting += (_, e) => + { + Result = null; + RequestStop (); + e.Handled = true; + }; + + Add (prompt, list, install, cancel); + list.SetFocus (); + } +} + /// /// Help overlay shown by pressing ?. Mirrors the contents from src/ui.rs::render_help. /// From 03bdd41b3a66d73a9c022b11bdfe6a74cae8f09f Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 21:26:19 -0500 Subject: [PATCH 12/18] =?UTF-8?q?fix:=20Copilot=20review=20of=20preview/pi?= =?UTF-8?q?cker=20=E2=80=94=20version=20fallback=20+=20preflight=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GetInstallerPreviewAsync no longer falls back to the installed version when a SPECIFIC version was requested but couldn't be resolved (that produced a preview computed from the wrong installer while the confirm said "Install X "). Explicit version → resolve exactly or return null; latest → default-install else installed. - FetchThen now serializes preflight fetches via a _preflightBusy gate, so a rapid second trigger can't queue a duplicate modal or race the status line. Rejected from the review: the claim that ListView.SelectedItem is `int` (it's `int?` in Terminal.Gui 2.4.3-develop.9 — the build proves it; `?? -1` stays). Other low items (picker empty-list defensiveness, PickVersion using-dispose) left to match existing dialog patterns / caller contract. Both TFMs build, tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.cs | 13 ++++++++++++- src/ComBackend.cs | 8 +++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/App.cs b/src/App.cs index fc97585..1b49752 100644 --- a/src/App.cs +++ b/src/App.cs @@ -30,6 +30,10 @@ public sealed class App : Runnable // "an operation is running" gate for Esc-to-cancel — distinct from _viewCts/_detailCts, which // cover list/detail refreshes that already cancel implicitly on navigation. private CancellationTokenSource? _opCts; + + // True while a short preflight fetch (version list / installer preview) is running, so a + // rapid second trigger can't queue a duplicate modal or race the status line. + private bool _preflightBusy; private object? _spinnerTimer; private bool _initialLoadDone; @@ -1115,11 +1119,14 @@ private void ConfirmAndInstall (Package p, string? version) /// private void FetchThen (string activity, Func> fetch, Action onResult) { - if (_opCts is not null) + // Serialize preflight fetches and don't start one atop a running operation: prevents a + // rapid double-trigger from queuing a second modal behind the first. + if (_opCts is not null || _preflightBusy) { return; } + _preflightBusy = true; _state.StatusMessage = activity; _state.Loading = true; _state.StatusIsError = false; @@ -1137,6 +1144,7 @@ private void FetchThen (string activity, Func> fet { App?.Invoke (() => { + _preflightBusy = false; _state.Loading = false; _state.StatusMessage = $"Error: {ex.Message}"; _state.StatusIsError = true; @@ -1148,6 +1156,9 @@ private void FetchThen (string activity, Func> fet App?.Invoke (() => { + // Clear the gate before onResult so its (modal) flow — and any + // RunOperation it starts — isn't blocked by this guard. + _preflightBusy = false; _state.Loading = false; _state.StatusMessage = string.Empty; RefreshStatusBar (); diff --git a/src/ComBackend.cs b/src/ComBackend.cs index d69dacb..4036b7e 100644 --- a/src/ComBackend.cs +++ b/src/ComBackend.cs @@ -264,16 +264,18 @@ public async Task> ListVersionsAsync (string id, Cancellat if (!string.IsNullOrEmpty (version)) { + // Explicit version: resolve exactly that. Do NOT fall back to a different version — + // a fallback would compute the preview from the wrong installer while the confirm + // dialog still says "Install X ". PackageVersionId? vid = FindVersionId (pkg, version); versionInfo = vid is null ? null : SafeGetVersionInfo (pkg, vid); } else { - versionInfo = SafeDefaultInstallVersion (pkg); + // Latest: the default-install version, else the installed version. + versionInfo = SafeDefaultInstallVersion (pkg) ?? SafeInstalledVersion (pkg); } - versionInfo ??= SafeInstalledVersion (pkg); - if (versionInfo is null) { return null; From c470695801fc67dd1b7ad2141b6672722667bf94 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 21:35:26 -0500 Subject: [PATCH 13/18] feat: download-only + advanced install options (COM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more COM-backed operations, plumbed through IBackend (CLI maps to winget flags, mock simulates). Download-only ('d'): - IBackend.DownloadAsync; ComBackend uses DownloadPackageAsync(DownloadOptions) into %USERPROFILE%\Downloads\winget-tui, mapping PackageDownloadProgress onto the existing OpProgress bar; CLI runs `winget download -d `; mock ramps a download. Reuses RunOperation, so the progress bar + Esc-cancel work for free. Advanced install ('A'): - InstallSettings model (scope / mode / arch / custom args). InstallAsync gains an InstallSettings? param across the interface + all three backends. - ComBackend maps it onto InstallOptions (PackageInstallScope, PackageInstallMode, AllowedArchitectures.Add, AdditionalInstallerArguments); CLI maps to --scope / --silent|--interactive / --architecture / --custom; mock echoes it. - New AdvancedInstallDialog (OptionSelector x3 + args field) gathers settings; the install confirm shows an "Options: …" line alongside the installer preview. InstallArgs keeps a default settings=null param so existing parser tests are unaffected. OperationKind += Download. Help text + WINDOWS-TESTING.md updated. Both TFMs build, tests pass, Windows-TFM trim analysis 0 IL2026/IL3050. Co-Authored-By: Claude Opus 4.8 (1M context) --- WINDOWS-TESTING.md | 13 +++++ src/App.cs | 107 ++++++++++++++++++++++++++++++++++++++-- src/Backend.cs | 6 ++- src/CliBackend.cs | 104 +++++++++++++++++++++++++++++++++++++-- src/ComBackend.cs | 119 ++++++++++++++++++++++++++++++++++++++++++++- src/MockBackend.cs | 30 +++++++++++- src/Models.cs | 39 ++++++++++++++- src/Ui.cs | 82 +++++++++++++++++++++++++++++-- 8 files changed, 485 insertions(+), 15 deletions(-) diff --git a/WINDOWS-TESTING.md b/WINDOWS-TESTING.md index c3736ae..6dfbeda 100644 --- a/WINDOWS-TESTING.md +++ b/WINDOWS-TESTING.md @@ -58,6 +58,19 @@ Operations (pick a small, safe package to install/uninstall, e.g. a CLI tool): - [ ] Picking a version → the install confirm shows that version + its installer preview → installs the chosen version. - [ ] (CLI backend, `--cli`) `I` falls back to the **free-text** version prompt, since the CLI path returns no version list. +**Download-only** (`d`): + +- [ ] `d` on a package downloads its installer **without installing**, showing the progress bar (Downloading phase), and reports the path (default `%USERPROFILE%\Downloads\winget-tui`). Verify the installer file actually lands there. +- [ ] `Esc` cancels a download in progress (same cooperative-cancel path as install). +- [ ] (CLI backend) `d` runs `winget download`; on an older winget without that verb, the failure message is shown rather than a crash. + +**Advanced install** (`A`): + +- [ ] `A` opens the options panel (Scope / Mode / Arch option selectors + custom-args field). Arrow/selection works; Install/Cancel behave. +- [ ] Choosing **User** vs **Machine** scope, **Silent** vs **Interactive** mode, a specific **arch**, and **custom args** is reflected in the install confirm ("Options: …") and actually applied (e.g. Interactive mode shows the installer UI; user-scope installs to the user profile). +- [ ] Cancelling the panel aborts with no install. +- [ ] (CLI backend) the same options map to winget flags (`--scope`, `--silent`/`--interactive`, `--architecture`, `--custom`). + **Live progress bar** (the headline feature — also tests `.Progress` delegate marshaling under AOT, the one CCW-callback unknown): - [ ] During a real COM **install**, the status bar shows a determinate bar that **advances**, moving through **Downloading → Installing** phases with changing percentages (not stuck at 0%/100%). diff --git a/src/App.cs b/src/App.cs index 1b49752..0624831 100644 --- a/src/App.cs +++ b/src/App.cs @@ -928,6 +928,16 @@ private void OnKeyDown (object? sender, Key key) AskInstall (CurrentPackage (), specificVersion: true); key.Handled = true; + return; + case 'd': + AskDownload (CurrentPackage ()); + key.Handled = true; + + return; + case 'A': + AskAdvancedInstall (CurrentPackage ()); + key.Handled = true; + return; case 'u': AskUpgrade (CurrentPackage ()); @@ -1093,7 +1103,7 @@ private void BeginVersionPick (Package p) /// Fetch the applicable-installer preview, show it in the confirm dialog /// (e.g. "Install X? \n MSI · x64 · machine · admin"), then install on confirm. /// - private void ConfirmAndInstall (Package p, string? version) + private void ConfirmAndInstall (Package p, string? version, InstallSettings? settings = null) { FetchThen ( "Checking installer…", @@ -1101,17 +1111,106 @@ private void ConfirmAndInstall (Package p, string? version) preview => { string title = version is null ? $"Install {p.Name}?" : $"Install {p.Name} {version}?"; - string summary = preview?.Summary ?? string.Empty; - string body = string.IsNullOrEmpty (summary) ? title : $"{title}\n\n{summary}"; + List lines = []; + + if (!string.IsNullOrEmpty (preview?.Summary)) + { + lines.Add (preview!.Summary); + } + + string optionsLine = settings is null ? string.Empty : DescribeSettings (settings); + + if (optionsLine.Length > 0) + { + lines.Add (optionsLine); + } + + string body = lines.Count == 0 ? title : $"{title}\n\n{string.Join ("\n", lines)}"; if (Confirm ("Install", body)) { string activity = version is null ? $"Installing {p.Name}" : $"Installing {p.Name} {version}"; - RunOperation (activity, (prog, ct) => _state.Backend.InstallAsync (p.Id, version, prog, ct)); + RunOperation (activity, (prog, ct) => _state.Backend.InstallAsync (p.Id, version, settings, prog, ct)); } }); } + private static string DescribeSettings (InstallSettings s) + { + List parts = []; + + if (s.Scope != InstallScopePref.Default) + { + parts.Add (s.Scope == InstallScopePref.Machine ? "machine" : "user"); + } + + if (s.Mode != InstallModePref.Default) + { + parts.Add (s.Mode.ToString ().ToLowerInvariant ()); + } + + if (s.Architecture != InstallArchPref.Default) + { + parts.Add (s.Architecture.ToString ().ToLowerInvariant ()); + } + + if (!string.IsNullOrWhiteSpace (s.CustomArgs)) + { + parts.Add ($"custom: {s.CustomArgs}"); + } + + return parts.Count == 0 ? string.Empty : "Options: " + string.Join (" · ", parts); + } + + /// Fetch the installer to disk without installing, reusing the operation progress bar. + private void AskDownload (Package? p) + { + if (p is null || App is null || GuardTruncatedId (p, "download")) + { + return; + } + + if (!Confirm ("Download", $"Download the installer for {p.Name} without installing it?")) + { + return; + } + + RunOperation ($"Downloading {p.Name}", (prog, ct) => _state.Backend.DownloadAsync (p.Id, null, prog, ct)); + } + + /// Open the advanced-options panel, then install the latest version with those options. + private void AskAdvancedInstall (Package? p) + { + if (p is null || App is null || GuardTruncatedId (p, "install")) + { + return; + } + + InstallSettings? settings = PromptAdvancedOptions (p); + + if (settings is null) + { + return; + } + + ConfirmAndInstall (p, null, settings); + } + + private InstallSettings? PromptAdvancedOptions (Package p) + { + if (App is null) + { + return null; + } + + AdvancedInstallDialog dlg = new (p.Name); + App.Run (dlg); + InstallSettings? result = dlg.Result; + dlg.Dispose (); + + return result; + } + /// /// Run a short async fetch on a background thread (with a transient status), then invoke the /// continuation on the UI thread. Used to gather version/installer info before showing a modal diff --git a/src/Backend.cs b/src/Backend.cs index e2d2659..00af9a7 100644 --- a/src/Backend.cs +++ b/src/Backend.cs @@ -21,9 +21,13 @@ public interface IBackend // The install/upgrade/uninstall operations optionally report structured progress through // `progress`. Backends that can't (CLI) ignore it; the COM backend maps the WinGet COM // progress events onto OpProgress; the mock backend synthesizes a download→install ramp. - Task InstallAsync (string id, string? version, IProgress? progress, CancellationToken ct); + Task InstallAsync (string id, string? version, InstallSettings? settings, IProgress? progress, CancellationToken ct); Task UninstallAsync (string id, IProgress? progress, CancellationToken ct); Task UpgradeAsync (string id, IProgress? progress, CancellationToken ct); + + // Fetch a package's installer to disk without installing it (winget "download"), reusing the + // same progress reporting as install. Returns the download location in the OpResult message. + Task DownloadAsync (string id, string? version, IProgress? progress, CancellationToken ct); Task PinAsync (string id, CancellationToken ct); Task UnpinAsync (string id, CancellationToken ct); Task> ListPinsAsync (CancellationToken ct); diff --git a/src/CliBackend.cs b/src/CliBackend.cs index d5f3bc7..d735b2b 100644 --- a/src/CliBackend.cs +++ b/src/CliBackend.cs @@ -60,14 +60,36 @@ public Task> ListVersionsAsync (string id, CancellationTok // progress is unused: winget.exe only emits an ANSI progress bar to stdout, which we // capture as a whole rather than scrape. The COM backend is the one that reports progress. - public async Task InstallAsync (string id, string? version, IProgress? progress, CancellationToken ct) + public async Task InstallAsync (string id, string? version, InstallSettings? settings, IProgress? progress, CancellationToken ct) { - (int code, string output) = await RunWithCodeAsync (InstallArgs (id, version), ct); + (int code, string output) = await RunWithCodeAsync (InstallArgs (id, version, settings), ct); Operation op = new () { Kind = OperationKind.Install, PackageId = id, Version = version }; return new () { Operation = op, Success = code == 0, Message = output }; } + public async Task DownloadAsync (string id, string? version, IProgress? progress, CancellationToken ct) + { + string dir = Path.Combine ( + Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), + "Downloads", + "winget-tui"); + + try + { + Directory.CreateDirectory (dir); + } + catch + { + // Let winget attempt the download even if we couldn't pre-create the folder. + } + + (int code, string output) = await RunWithCodeAsync (DownloadArgs (id, version, dir), ct); + Operation op = new () { Kind = OperationKind.Download, PackageId = id, Version = version }; + + return new () { Operation = op, Success = code == 0, Message = code == 0 ? $"Downloaded to {dir}" : output }; + } + public async Task UninstallAsync (string id, IProgress? progress, CancellationToken ct) { (int code, string output) = await RunWithCodeAsync (UninstallArgs (id), ct); @@ -156,7 +178,7 @@ internal static string [] ListUpgradesArgs (SourceFilter source) internal static string [] ShowArgs (string id) => ["show", "--id", id, "--exact", "--accept-source-agreements"]; - internal static string [] InstallArgs (string id, string? version) + internal static string [] InstallArgs (string id, string? version, InstallSettings? settings = null) { // Match upstream's argument list: no `--exact`. Some ids need substring match // against the catalog (e.g. monikered store packages). @@ -168,6 +190,82 @@ internal static string [] InstallArgs (string id, string? version) args.Add (version); } + AppendInstallSettings (args, settings); + + return [.. args]; + } + + // Map the advanced-install options onto winget flags. --custom appends to the installer's + // default args (matching the COM AdditionalInstallerArguments semantics). + private static void AppendInstallSettings (List args, InstallSettings? settings) + { + if (settings is null) + { + return; + } + + switch (settings.Scope) + { + case InstallScopePref.User: + args.Add ("--scope"); + args.Add ("user"); + + break; + case InstallScopePref.Machine: + args.Add ("--scope"); + args.Add ("machine"); + + break; + } + + switch (settings.Mode) + { + case InstallModePref.Silent: + args.Add ("--silent"); + + break; + case InstallModePref.Interactive: + args.Add ("--interactive"); + + break; + } + + string? arch = settings.Architecture switch + { + InstallArchPref.X64 => "x64", + InstallArchPref.X86 => "x86", + InstallArchPref.Arm64 => "arm64", + _ => null + }; + + if (arch is not null) + { + args.Add ("--architecture"); + args.Add (arch); + } + + if (!string.IsNullOrWhiteSpace (settings.CustomArgs)) + { + args.Add ("--custom"); + args.Add (settings.CustomArgs); + } + } + + internal static string [] DownloadArgs (string id, string? version, string directory) + { + List args = + [ + "download", "--id", id, + "--accept-source-agreements", "--accept-package-agreements", + "--download-directory", directory + ]; + + if (!string.IsNullOrEmpty (version)) + { + args.Add ("--version"); + args.Add (version); + } + return [.. args]; } diff --git a/src/ComBackend.cs b/src/ComBackend.cs index 4036b7e..c034823 100644 --- a/src/ComBackend.cs +++ b/src/ComBackend.cs @@ -372,7 +372,7 @@ private static bool RequiresElevation (PackageInstallerInfo installer) // Writes // ------------------------------------------------------------------------ - public async Task InstallAsync (string id, string? version, IProgress? progress, CancellationToken ct) + public async Task InstallAsync (string id, string? version, InstallSettings? settings, IProgress? progress, CancellationToken ct) { Operation op = new () { Kind = OperationKind.Install, PackageId = id, Version = version }; CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: false, ct); @@ -382,12 +382,15 @@ public async Task InstallAsync (string id, string? version, IProgress< return Fail (op, $"Package '{id}' not found in any configured source."); } + // Default to a silent install; advanced settings may override mode/scope/arch/args below. InstallOptions options = new () { PackageInstallMode = PackageInstallMode.Silent, AcceptPackageAgreements = true }; + ApplyInstallSettings (options, settings); + if (!string.IsNullOrEmpty (version)) { PackageVersionId? versionId = FindVersionId (pkg, version); @@ -459,6 +462,107 @@ public async Task UninstallAsync (string id, IProgress? pr : Fail (op, $"Uninstall failed: {result.Status} (installer 0x{result.UninstallerErrorCode:X}, hr 0x{HResultOf (result.ExtendedErrorCode):X8})"); } + public async Task DownloadAsync (string id, string? version, IProgress? progress, CancellationToken ct) + { + Operation op = new () { Kind = OperationKind.Download, PackageId = id, Version = version }; + CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: false, ct); + + if (pkg is null) + { + return Fail (op, $"Package '{id}' not found in any configured source."); + } + + string dir = DownloadDirectory (); + + DownloadOptions options = new () + { + DownloadDirectory = dir, + AcceptPackageAgreements = true + }; + + if (!string.IsNullOrEmpty (version)) + { + PackageVersionId? versionId = FindVersionId (pkg, version); + + if (versionId is null) + { + return Fail (op, $"Version '{version}' is not available for {pkg.Name}."); + } + + options.PackageVersionId = versionId; + } + + var asyncOp = _pm.DownloadPackageAsync (pkg, options); + asyncOp.Progress = (_, p) => progress?.Report (MapDownload (p)); + DownloadResult result = await asyncOp.AsTask (ct); + + return result.Status == DownloadResultStatus.Ok + ? Ok (op, $"Downloaded {pkg.Name} to {dir}") + : Fail (op, $"Download failed: {result.Status} (hr 0x{HResultOf (result.ExtendedErrorCode):X8})"); + } + + /// Where DownloadPackageAsync drops installers: a stable folder under the user's Downloads. + private static string DownloadDirectory () + { + string dir = Path.Combine ( + Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), + "Downloads", + "winget-tui"); + + try + { + Directory.CreateDirectory (dir); + } + catch + { + // If we can't pre-create it, let the COM server attempt the download anyway. + } + + return dir; + } + + /// Map the user's advanced-install choices onto the WinGet InstallOptions. + private static void ApplyInstallSettings (InstallOptions options, InstallSettings? settings) + { + if (settings is null) + { + return; + } + + options.PackageInstallScope = settings.Scope switch + { + InstallScopePref.User => PackageInstallScope.User, + InstallScopePref.Machine => PackageInstallScope.System, + _ => options.PackageInstallScope + }; + + if (settings.Mode != InstallModePref.Default) + { + options.PackageInstallMode = settings.Mode == InstallModePref.Interactive + ? PackageInstallMode.Interactive + : PackageInstallMode.Silent; + } + + Windows.System.ProcessorArchitecture? arch = settings.Architecture switch + { + InstallArchPref.X64 => Windows.System.ProcessorArchitecture.X64, + InstallArchPref.X86 => Windows.System.ProcessorArchitecture.X86, + InstallArchPref.Arm64 => Windows.System.ProcessorArchitecture.Arm64, + _ => null + }; + + if (arch is { } a) + { + // .Add on the projected IVector is a method call (not enumeration) — AOT-safe. + options.AllowedArchitectures.Add (a); + } + + if (!string.IsNullOrWhiteSpace (settings.CustomArgs)) + { + options.AdditionalInstallerArguments = settings.CustomArgs; + } + } + // ------------------------------------------------------------------------ // Pinning — delegated to the CLI (no COM surface for pins). // ------------------------------------------------------------------------ @@ -728,6 +832,19 @@ private static OpProgress MapUninstall (UninstallProgress p) return new (phase, p.UninstallationProgress); } + private static OpProgress MapDownload (PackageDownloadProgress p) + { + OpPhase phase = p.State switch + { + PackageDownloadProgressState.Queued => OpPhase.Queued, + PackageDownloadProgressState.Downloading => OpPhase.Downloading, + PackageDownloadProgressState.Finished => OpPhase.Done, + _ => OpPhase.Downloading + }; + + return new (phase, p.State == PackageDownloadProgressState.Finished ? 1.0 : p.DownloadProgress); + } + private static string DescribeInstall (string verb, InstallResult result) => $"{verb} failed: {result.Status} (installer {result.InstallerErrorCode}, hr 0x{HResultOf (result.ExtendedErrorCode):X8})"; diff --git a/src/MockBackend.cs b/src/MockBackend.cs index a9dd155..ddbba91 100644 --- a/src/MockBackend.cs +++ b/src/MockBackend.cs @@ -150,15 +150,41 @@ public Task> ListVersionsAsync (string id, CancellationTok return Task.FromResult (preview); } - public async Task InstallAsync (string id, string? version, IProgress? progress, CancellationToken ct) + public async Task InstallAsync (string id, string? version, InstallSettings? settings, IProgress? progress, CancellationToken ct) { await SimulateProgressAsync (progress, downloads: true, ct); + string note = settings is null + ? string.Empty + : $" [{settings.Scope}/{settings.Mode}/{settings.Architecture}{(string.IsNullOrWhiteSpace (settings.CustomArgs) ? string.Empty : $"/\"{settings.CustomArgs}\"")}]"; + return new () { Operation = new () { Kind = OperationKind.Install, PackageId = id, Version = version }, Success = true, - Message = $"[mock] Installed {id}" + (version is null ? string.Empty : $" v{version}") + Message = $"[mock] Installed {id}" + (version is null ? string.Empty : $" v{version}") + note + }; + } + + public async Task DownloadAsync (string id, string? version, IProgress? progress, CancellationToken ct) + { + // Download-only: a download ramp with no install phase. + if (progress is not null) + { + for (int i = 0; i <= 10; i++) + { + progress.Report (new (OpPhase.Downloading, i / 10.0)); + await Task.Delay (55, ct); + } + + progress.Report (new (OpPhase.Done, 1.0)); + } + + return new () + { + Operation = new () { Kind = OperationKind.Download, PackageId = id, Version = version }, + Success = true, + Message = $"[mock] Downloaded {id} to (mock path)" }; } diff --git a/src/Models.cs b/src/Models.cs index 71a3189..6905589 100644 --- a/src/Models.cs +++ b/src/Models.cs @@ -169,7 +169,44 @@ public enum OperationKind Upgrade, Pin, Unpin, - BatchUpgrade + BatchUpgrade, + Download +} + +public enum InstallScopePref +{ + Default, + User, + Machine +} + +public enum InstallModePref +{ + Default, + Silent, + Interactive +} + +public enum InstallArchPref +{ + Default, + X64, + X86, + Arm64 +} + +/// +/// User-chosen advanced install options, gathered from the advanced-install panel and passed to +/// . The COM backend maps these onto InstallOptions +/// (scope / mode / AllowedArchitectures / AdditionalInstallerArguments); the CLI backend maps them +/// onto winget flags; the mock ignores them. A null settings object means "backend defaults". +/// +public sealed class InstallSettings +{ + public InstallScopePref Scope { get; init; } = InstallScopePref.Default; + public InstallModePref Mode { get; init; } = InstallModePref.Default; + public InstallArchPref Architecture { get; init; } = InstallArchPref.Default; + public string? CustomArgs { get; init; } } public sealed class Operation diff --git a/src/Ui.cs b/src/Ui.cs index e43c971..3888c59 100644 --- a/src/Ui.cs +++ b/src/Ui.cs @@ -413,6 +413,80 @@ public VersionPickerDialog (string packageName, IReadOnlyList versions) } } +/// +/// Advanced install options panel: install scope, mode, architecture, and custom installer args. +/// Result is the chosen , or null on cancel. The COM backend maps +/// these onto InstallOptions; the CLI backend onto winget flags. Each OptionSelector's index lines +/// up with the corresponding enum's member order. +/// +public sealed class AdvancedInstallDialog : Runnable +{ + public AdvancedInstallDialog (string packageName) + { + Title = $" Advanced install: {packageName} "; + BorderStyle = LineStyle.Rounded; + Width = 66; + Height = 15; + X = Pos.Center (); + Y = Pos.Center (); + SchemeName = Theme.SurfaceSchemeName; + Arrangement = ViewArrangement.Movable; + + Label scopeLabel = new () { X = 1, Y = 1, Text = "Scope: " }; + OptionSelector scope = new () + { + X = Pos.Right (scopeLabel), Y = 1, Width = Dim.Fill (1), + Labels = ["Default", "User", "Machine"] + }; + scope.Value = 0; + + Label modeLabel = new () { X = 1, Y = 3, Text = "Mode: " }; + OptionSelector mode = new () + { + X = Pos.Right (modeLabel), Y = 3, Width = Dim.Fill (1), + Labels = ["Default", "Silent", "Interactive"] + }; + mode.Value = 0; + + Label archLabel = new () { X = 1, Y = 5, Text = "Arch: " }; + OptionSelector arch = new () + { + X = Pos.Right (archLabel), Y = 5, Width = Dim.Fill (1), + Labels = ["Default", "x64", "x86", "arm64"] + }; + arch.Value = 0; + + Label argsLabel = new () { X = 1, Y = 7, Text = "Custom installer args (optional):" }; + TextField argsField = new () { X = 1, Y = 8, Width = Dim.Fill (1) }; + + Button install = new () { X = Pos.Center () - 8, Y = Pos.AnchorEnd (1), Text = "_Install", IsDefault = true }; + Button cancel = new () { X = Pos.Center () + 2, Y = Pos.AnchorEnd (1), Text = "Cancel" }; + + install.Accepting += (_, e) => + { + Result = new InstallSettings + { + Scope = (InstallScopePref)(scope.Value ?? 0), + Mode = (InstallModePref)(mode.Value ?? 0), + Architecture = (InstallArchPref)(arch.Value ?? 0), + CustomArgs = string.IsNullOrWhiteSpace (argsField.Text) ? null : argsField.Text + }; + RequestStop (); + e.Handled = true; + }; + + cancel.Accepting += (_, e) => + { + Result = null; + RequestStop (); + e.Handled = true; + }; + + Add (scopeLabel, scope, modeLabel, mode, archLabel, arch, argsLabel, argsField, install, cancel); + scope.SetFocus (); + } +} + /// /// Help overlay shown by pressing ?. Mirrors the contents from src/ui.rs::render_help. /// @@ -480,7 +554,9 @@ r Refresh Actions i Install - I Install specific version + I Install specific version (pick from list) + A Advanced install (scope / mode / arch / args) + d Download installer only (no install) u Upgrade x Uninstall p Pin / Unpin @@ -500,7 +576,7 @@ Scroll wheel to navigate General ? Toggle this help - q / Esc Quit - Ctrl+C Quit + Esc Cancel a running operation, else quit + q / Ctrl+C Quit """; } From 5747eedbe5d6a4935498157a610ccc4f1b925328 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 21:41:56 -0500 Subject: [PATCH 14/18] fix: Copilot review of download/advanced-install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Advanced install: normalize an all-default selection to null ("backend defaults") via InstallSettings.IsDefault, so it behaves identically to a plain install on every backend instead of passing a no-op settings object. (The underlying COM-forces-silent default is pre-existing and unchanged.) - Download: stop swallowing Directory.CreateDirectory failures. Both COM and CLI now fail fast with a clear "could not prepare download folder" message instead of degrading into a more obscure winget/COM error later. Kept (Low): the AdvancedInstallDialog enum-by-index cast — the alignment is documented and an unexpected value degrades safely to Default; explicit map arrays weren't worth the churn. Both TFMs build, tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.cs | 6 ++++-- src/CliBackend.cs | 6 +++--- src/ComBackend.cs | 24 ++++++++++-------------- src/Models.cs | 7 +++++++ 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/App.cs b/src/App.cs index 0624831..de455a6 100644 --- a/src/App.cs +++ b/src/App.cs @@ -1190,10 +1190,12 @@ private void AskAdvancedInstall (Package? p) if (settings is null) { - return; + return; // cancelled } - ConfirmAndInstall (p, null, settings); + // All-default selection means "backend defaults" — normalize to null so it behaves + // identically to a plain install on every backend (no per-backend "Default" ambiguity). + ConfirmAndInstall (p, null, settings.IsDefault ? null : settings); } private InstallSettings? PromptAdvancedOptions (Package p) diff --git a/src/CliBackend.cs b/src/CliBackend.cs index d735b2b..034841d 100644 --- a/src/CliBackend.cs +++ b/src/CliBackend.cs @@ -74,18 +74,18 @@ public async Task DownloadAsync (string id, string? version, IProgress Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), "Downloads", "winget-tui"); + Operation op = new () { Kind = OperationKind.Download, PackageId = id, Version = version }; try { Directory.CreateDirectory (dir); } - catch + catch (Exception ex) { - // Let winget attempt the download even if we couldn't pre-create the folder. + return new () { Operation = op, Success = false, Message = $"Could not prepare download folder '{dir}': {ex.Message}" }; } (int code, string output) = await RunWithCodeAsync (DownloadArgs (id, version, dir), ct); - Operation op = new () { Kind = OperationKind.Download, PackageId = id, Version = version }; return new () { Operation = op, Success = code == 0, Message = code == 0 ? $"Downloaded to {dir}" : output }; } diff --git a/src/ComBackend.cs b/src/ComBackend.cs index c034823..37c9b73 100644 --- a/src/ComBackend.cs +++ b/src/ComBackend.cs @@ -474,6 +474,15 @@ public async Task DownloadAsync (string id, string? version, IProgress string dir = DownloadDirectory (); + try + { + Directory.CreateDirectory (dir); + } + catch (Exception ex) + { + return Fail (op, $"Could not prepare download folder '{dir}': {ex.Message}"); + } + DownloadOptions options = new () { DownloadDirectory = dir, @@ -503,24 +512,11 @@ public async Task DownloadAsync (string id, string? version, IProgress /// Where DownloadPackageAsync drops installers: a stable folder under the user's Downloads. private static string DownloadDirectory () - { - string dir = Path.Combine ( + => Path.Combine ( Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), "Downloads", "winget-tui"); - try - { - Directory.CreateDirectory (dir); - } - catch - { - // If we can't pre-create it, let the COM server attempt the download anyway. - } - - return dir; - } - /// Map the user's advanced-install choices onto the WinGet InstallOptions. private static void ApplyInstallSettings (InstallOptions options, InstallSettings? settings) { diff --git a/src/Models.cs b/src/Models.cs index 6905589..01c37d0 100644 --- a/src/Models.cs +++ b/src/Models.cs @@ -207,6 +207,13 @@ public sealed class InstallSettings public InstallModePref Mode { get; init; } = InstallModePref.Default; public InstallArchPref Architecture { get; init; } = InstallArchPref.Default; public string? CustomArgs { get; init; } + + /// True when nothing was customized — callers normalize this to null ("backend defaults"). + public bool IsDefault + => Scope == InstallScopePref.Default + && Mode == InstallModePref.Default + && Architecture == InstallArchPref.Default + && string.IsNullOrWhiteSpace (CustomArgs); } public sealed class Operation From e694110cf4fb4a2339bb7f02077db31a6c5e3148 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 22:01:55 -0500 Subject: [PATCH 15/18] feat: verify-install action + richer detail panel (COM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify install ('V'): - IBackend.VerifyInstalledAsync; ComBackend maps CheckInstalledStatusAsync (InstalledStatusType.AllChecks) onto an InstallVerification (Ok / Issues / NotApplicable / Error + per-check list). Traverses the nested projected vectors (PackageInstalledStatus → InstallerInstalledStatus) via Materialize (AOT rule); an InstalledStatus whose HRESULT projects to a null Exception = S_OK = passed. CLI returns null (no equivalent); mock fakes Ok/Issues. - UI: 'V' runs the check via FetchThen and shows a ✓/✗ result MessageBox; the detail panel and help advertise it. Richer detail panel: - PackageDetail gains Tags, SupportUrl, Documentation (DocLink list), ProductCodes, PackageFamilyNames. ComBackend.ShowAsync fills them from CatalogPackageMetadata (Tags/Documentations/PublisherSupportUrl) and PackageVersionInfo (ProductCodes/PackageFamilyNames) via guarded indexed reads. DetailPanel renders Tags/identifiers as KV lines and Support/Docs as clickable link rows; absent fields are omitted (no empty rows). Mock supplies representative values; CLI leaves them null. Both TFMs build, tests pass, Windows-TFM trim analysis 0 IL2026/IL3050. Help + WINDOWS-TESTING.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- WINDOWS-TESTING.md | 11 ++++ src/App.cs | 59 ++++++++++++++++++ src/Backend.cs | 4 ++ src/CliBackend.cs | 4 ++ src/ComBackend.cs | 149 ++++++++++++++++++++++++++++++++++++++++++++- src/DetailPanel.cs | 30 +++++++++ src/MockBackend.cs | 26 +++++++- src/Models.cs | 40 ++++++++++++ src/Ui.cs | 1 + 9 files changed, 322 insertions(+), 2 deletions(-) diff --git a/WINDOWS-TESTING.md b/WINDOWS-TESTING.md index 6dfbeda..c729b62 100644 --- a/WINDOWS-TESTING.md +++ b/WINDOWS-TESTING.md @@ -71,6 +71,17 @@ Operations (pick a small, safe package to install/uninstall, e.g. a CLI tool): - [ ] Cancelling the panel aborts with no install. - [ ] (CLI backend) the same options map to winget flags (`--scope`, `--silent`/`--interactive`, `--architecture`, `--custom`). +**Verify install** (`V` — COM-only): + +- [ ] `V` on an installed package runs `CheckInstalledStatus` and shows a result dialog: "Installed correctly" with ✓ checks (registry entry / install location / files), or a list of ✗ failures if the install is corrupt. +- [ ] Deliberately break an install (e.g. delete a file from the install dir) and confirm `V` reports the **Issues** outcome with the failing check. +- [ ] (CLI backend, `--cli`) `V` reports "Verify is only available on the COM backend" rather than erroring. + +**Richer detail panel** (COM): + +- [ ] The detail panel for a package shows the extra manifest fields when present: **Tags**, **Product code**, **Family name**, a clickable **Support** link, and **Documentation** links — in addition to the existing fields. Verify the links open. +- [ ] Packages without these fields don't render empty rows (the lines are omitted when absent). + **Live progress bar** (the headline feature — also tests `.Progress` delegate marshaling under AOT, the one CCW-callback unknown): - [ ] During a real COM **install**, the status bar shows a determinate bar that **advances**, moving through **Downloading → Installing** phases with changing percentages (not stuck at 0%/100%). diff --git a/src/App.cs b/src/App.cs index de455a6..38c05c8 100644 --- a/src/App.cs +++ b/src/App.cs @@ -938,6 +938,11 @@ private void OnKeyDown (object? sender, Key key) AskAdvancedInstall (CurrentPackage ()); key.Handled = true; + return; + case 'V': + AskVerify (CurrentPackage ()); + key.Handled = true; + return; case 'u': AskUpgrade (CurrentPackage ()); @@ -1198,6 +1203,60 @@ private void AskAdvancedInstall (Package? p) ConfirmAndInstall (p, null, settings.IsDefault ? null : settings); } + /// Run CheckInstalledStatus on a package and report whether its install is intact. + private void AskVerify (Package? p) + { + if (p is null || App is null || GuardTruncatedId (p, "verify")) + { + return; + } + + FetchThen ( + $"Verifying {p.Name}…", + ct => _state.Backend.VerifyInstalledAsync (p.Id, ct), + verification => + { + if (verification is null) + { + _state.StatusMessage = "Verify is only available on the COM backend."; + _state.StatusIsError = false; + RefreshStatusBar (); + + return; + } + + ShowVerifyResult (p, verification); + }); + } + + private void ShowVerifyResult (Package p, InstallVerification v) + { + if (App is null) + { + return; + } + + StringBuilder sb = new (); + sb.AppendLine (v.Summary); + + int shown = 0; + + foreach (VerifyCheck c in v.Checks) + { + if (shown++ >= 12) + { + sb.AppendLine ("…"); + + break; + } + + string detail = string.IsNullOrEmpty (c.Detail) ? string.Empty : $" — {c.Detail}"; + sb.AppendLine ($"{(c.Ok ? "✓" : "✗")} {c.Label}{detail}"); + } + + MessageBox.Query (App, $"Verify: {p.Name}", sb.ToString ().TrimEnd (), "_OK"); + } + private InstallSettings? PromptAdvancedOptions (Package p) { if (App is null) diff --git a/src/Backend.cs b/src/Backend.cs index 00af9a7..3d7824a 100644 --- a/src/Backend.cs +++ b/src/Backend.cs @@ -28,6 +28,10 @@ public interface IBackend // Fetch a package's installer to disk without installing it (winget "download"), reusing the // same progress reporting as install. Returns the download location in the OpResult message. Task DownloadAsync (string id, string? version, IProgress? progress, CancellationToken ct); + + // Check whether an installed package's files/registration are intact (COM + // CheckInstalledStatus). Returns null when the backend has no equivalent (CLI). + Task VerifyInstalledAsync (string id, CancellationToken ct); Task PinAsync (string id, CancellationToken ct); Task UnpinAsync (string id, CancellationToken ct); Task> ListPinsAsync (CancellationToken ct); diff --git a/src/CliBackend.cs b/src/CliBackend.cs index 034841d..ba85c09 100644 --- a/src/CliBackend.cs +++ b/src/CliBackend.cs @@ -58,6 +58,10 @@ public Task> ListVersionsAsync (string id, CancellationTok public Task GetInstallerPreviewAsync (string id, string? version, CancellationToken ct) => Task.FromResult (null); + // No CLI equivalent to CheckInstalledStatus — null signals "verify unavailable on this backend". + public Task VerifyInstalledAsync (string id, CancellationToken ct) + => Task.FromResult (null); + // progress is unused: winget.exe only emits an ANSI progress bar to stdout, which we // capture as a whole rather than scrape. The COM backend is the one that reports progress. public async Task InstallAsync (string id, string? version, InstallSettings? settings, IProgress? progress, CancellationToken ct) diff --git a/src/ComBackend.cs b/src/ComBackend.cs index 37c9b73..c35967f 100644 --- a/src/ComBackend.cs +++ b/src/ComBackend.cs @@ -202,7 +202,12 @@ private async Task> ListLocalAsync (SourceFilter source, Description = description, Homepage = NullIfEmpty (meta?.PackageUrl), License = NullIfEmpty (meta?.License), - ReleaseNotesUrl = NullIfEmpty (meta?.ReleaseNotesUrl) + ReleaseNotesUrl = NullIfEmpty (meta?.ReleaseNotesUrl), + SupportUrl = NullIfEmpty (meta?.PublisherSupportUrl), + Tags = meta is null ? null : StringVector (() => meta.Tags), + Documentation = DocLinks (meta), + ProductCodes = StringVector (() => versionInfo.ProductCodes), + PackageFamilyNames = StringVector (() => versionInfo.PackageFamilyNames) }; } catch @@ -510,6 +515,148 @@ public async Task DownloadAsync (string id, string? version, IProgress : Fail (op, $"Download failed: {result.Status} (hr 0x{HResultOf (result.ExtendedErrorCode):X8})"); } + public async Task VerifyInstalledAsync (string id, CancellationToken ct) + { + CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: true, ct); + + if (pkg is null) + { + return null; + } + + CheckInstalledStatusResult result; + + try + { + result = await pkg.CheckInstalledStatusAsync (InstalledStatusType.AllChecks).AsTask (ct); + } + catch + { + return new () { Outcome = VerifyOutcome.Error }; + } + + if (result.Status != CheckInstalledStatusResultStatus.Ok) + { + return new () { Outcome = VerifyOutcome.Error }; + } + + List checks = []; + bool anyFailed = false; + + // Two nested projected vectors — indexed via Materialize (AOT rule). + foreach (PackageInstallerInstalledStatus installer in Materialize (result.PackageInstalledStatus)) + { + IReadOnlyList entries; + + try + { + entries = Materialize (installer.InstallerInstalledStatus); + } + catch + { + continue; + } + + foreach (InstalledStatus entry in entries) + { + try + { + // HRESULT projects to an Exception: null means S_OK (the check passed). + bool ok = entry.Status is null; + string? path = NullIfEmpty (entry.Path); + checks.Add (new (StatusTypeName (entry.Type), ok, ok ? path : Coalesce (path, $"hr 0x{HResultOf (entry.Status):X8}"))); + + if (!ok) + { + anyFailed = true; + } + } + catch + { + // Skip a malformed status entry. + } + } + } + + VerifyOutcome outcome = checks.Count == 0 + ? VerifyOutcome.NotApplicable + : anyFailed + ? VerifyOutcome.Issues + : VerifyOutcome.Ok; + + return new () { Outcome = outcome, Checks = checks }; + } + + private static string StatusTypeName (InstalledStatusType t) + => t switch + { + InstalledStatusType.AppsAndFeaturesEntry => "Registry entry", + InstalledStatusType.AppsAndFeaturesEntryInstallLocation => "Install location", + InstalledStatusType.AppsAndFeaturesEntryInstallLocationFile => "Install-location file", + InstalledStatusType.DefaultInstallLocation => "Default install location", + InstalledStatusType.DefaultInstallLocationFile => "Default-location file", + _ => t.ToString () + }; + + /// Read a projected string vector into a managed list (indexed, guarded), or null if empty/unreadable. + private static IReadOnlyList? StringVector (Func?> get) + { + try + { + IReadOnlyList? projected = get (); + + if (projected is null) + { + return null; + } + + List list = []; + + foreach (string s in Materialize (projected)) + { + if (!string.IsNullOrWhiteSpace (s)) + { + list.Add (s); + } + } + + return list.Count > 0 ? list : null; + } + catch + { + return null; + } + } + + private static IReadOnlyList? DocLinks (CatalogPackageMetadata? meta) + { + if (meta is null) + { + return null; + } + + try + { + List links = []; + + foreach (Documentation d in Materialize (meta.Documentations)) + { + string url = d.DocumentUrl; + + if (!string.IsNullOrWhiteSpace (url)) + { + links.Add (new (string.IsNullOrWhiteSpace (d.DocumentLabel) ? "Documentation" : d.DocumentLabel, url)); + } + } + + return links.Count > 0 ? links : null; + } + catch + { + return null; + } + } + /// Where DownloadPackageAsync drops installers: a stable folder under the user's Downloads. private static string DownloadDirectory () => Path.Combine ( diff --git a/src/DetailPanel.cs b/src/DetailPanel.cs index acdc12a..80b22b5 100644 --- a/src/DetailPanel.cs +++ b/src/DetailPanel.cs @@ -90,6 +90,21 @@ public void SetDetail (PackageDetail? detail, bool loading) AddKv ("License", detail.License); } + if (detail.Tags is { Count: > 0 } tags) + { + AddKv ("Tags", string.Join (", ", tags)); + } + + if (detail.ProductCodes is { Count: > 0 } productCodes) + { + AddKv ("Product code", string.Join (", ", productCodes)); + } + + if (detail.PackageFamilyNames is { Count: > 0 } familyNames) + { + AddKv ("Family name", string.Join (", ", familyNames)); + } + if (detail.PinState.IsPinned) { AddSingle ($"\U0001F4CC {detail.PinState.DisplayLabel ()}", Theme.Selection); @@ -106,6 +121,19 @@ public void SetDetail (PackageDetail? detail, bool loading) AddMarkdownLinkRow ("Release notes", detail.ReleaseNotesUrl); } + if (!string.IsNullOrEmpty (detail.SupportUrl)) + { + AddMarkdownLinkRow ("Support", detail.SupportUrl); + } + + if (detail.Documentation is { Count: > 0 } docs) + { + foreach (DocLink doc in docs) + { + AddMarkdownLinkRow (doc.Label, doc.Url); + } + } + if (!string.IsNullOrEmpty (detail.Description)) { AddBlank (); @@ -141,11 +169,13 @@ public void SetDetail (PackageDetail? detail, bool loading) AddAction ("x", "Uninstall"); AddAction ("p", "Pin/Unpin"); + AddAction ("V", "Verify install"); break; case AppMode.Upgrades: AddAction ("u", "Upgrade"); AddAction ("x", "Uninstall"); + AddAction ("V", "Verify install"); AddAction ("Spc", "Select"); AddAction ("a", "Toggle All"); AddAction ("U", "Upgrade selected"); diff --git a/src/MockBackend.cs b/src/MockBackend.cs index ddbba91..de916c0 100644 --- a/src/MockBackend.cs +++ b/src/MockBackend.cs @@ -113,7 +113,12 @@ public Task> ListUpgradesAsync (SourceFilter source, Canc + "When running on Windows with winget installed, real manifest data is fetched here. ", Homepage = $"https://example.invalid/{p.Id}", License = "MIT", - ReleaseNotesUrl = $"https://example.invalid/{p.Id}/releases" + ReleaseNotesUrl = $"https://example.invalid/{p.Id}/releases", + SupportUrl = $"https://example.invalid/{p.Id}/support", + Tags = ["mock", "cli", "utility"], + Documentation = [new ("Getting started", $"https://example.invalid/{p.Id}/docs")], + ProductCodes = [$"{{{p.Id}-0000}}"], + PackageFamilyNames = p.Source == "msstore" ? [$"{p.Id}_8wekyb3d8bbwe"] : null }; return Task.FromResult (detail); @@ -150,6 +155,25 @@ public Task> ListVersionsAsync (string id, CancellationTok return Task.FromResult (preview); } + public Task VerifyInstalledAsync (string id, CancellationToken ct) + { + // Deterministically fake a "corrupt" result for one package so the Issues path is visible. + bool corrupt = id.Contains ("Firefox", StringComparison.OrdinalIgnoreCase); + + InstallVerification v = new () + { + Outcome = corrupt ? VerifyOutcome.Issues : VerifyOutcome.Ok, + Checks = + [ + new ("Registry entry", true, @"HKLM\…\Uninstall"), + new ("Install location", !corrupt, @"C:\Program Files\" + id), + new ("Install-location file", !corrupt, corrupt ? "missing: app.exe" : "app.exe") + ] + }; + + return Task.FromResult (v); + } + public async Task InstallAsync (string id, string? version, InstallSettings? settings, IProgress? progress, CancellationToken ct) { await SimulateProgressAsync (progress, downloads: true, ct); diff --git a/src/Models.cs b/src/Models.cs index 01c37d0..f160f1c 100644 --- a/src/Models.cs +++ b/src/Models.cs @@ -99,6 +99,13 @@ public sealed class PackageDetail public string? License { get; init; } public string? ReleaseNotesUrl { get; init; } + // Richer manifest fields (populated by the COM backend; null elsewhere). + public string? SupportUrl { get; init; } + public IReadOnlyList? Tags { get; init; } + public IReadOnlyList? Documentation { get; init; } + public IReadOnlyList? ProductCodes { get; init; } + public IReadOnlyList? PackageFamilyNames { get; init; } + /// /// True when holds a synthesized "couldn't fetch / nothing available" /// note rather than real manifest copy. Used by the detail panel to dim/annotate the line so @@ -216,6 +223,39 @@ public bool IsDefault && string.IsNullOrWhiteSpace (CustomArgs); } +/// A labelled documentation link from a package manifest (Documentations entry). +public sealed record DocLink (string Label, string Url); + +public enum VerifyOutcome +{ + Ok, // all checks passed + Issues, // at least one check failed (files/registration missing → corrupt install) + NotApplicable, // nothing to check (not installed, or no checks returned) + Error // the check itself failed, or the backend can't verify (CLI) +} + +/// One installed-status check (e.g. a registry entry or install-location file). +public sealed record VerifyCheck (string Label, bool Ok, string? Detail); + +/// +/// Result of the "verify install" action, backend-agnostic. The COM backend maps +/// CheckInstalledStatusAsync onto this; the CLI backend returns null (no equivalent). +/// +public sealed class InstallVerification +{ + public required VerifyOutcome Outcome { get; init; } + public IReadOnlyList Checks { get; init; } = []; + + public string Summary + => Outcome switch + { + VerifyOutcome.Ok => "Installed correctly — all checks passed.", + VerifyOutcome.Issues => $"{Checks.Count (c => !c.Ok)} of {Checks.Count} check(s) failed — the install may be corrupt.", + VerifyOutcome.NotApplicable => "No installed-status checks were applicable.", + _ => "Could not verify the install." + }; +} + public sealed class Operation { public required OperationKind Kind { get; init; } diff --git a/src/Ui.cs b/src/Ui.cs index 3888c59..5513ed3 100644 --- a/src/Ui.cs +++ b/src/Ui.cs @@ -559,6 +559,7 @@ A Advanced install (scope / mode / arch / args) d Download installer only (no install) u Upgrade x Uninstall + V Verify install (check files / registration) p Pin / Unpin Space Toggle batch select (Upgrades only) a Select / deselect all (Upgrades only) From 74174320581b7a106e115bf8e6ac345fc190f429 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Thu, 28 May 2026 22:06:08 -0500 Subject: [PATCH 16/18] fix: Copilot review of verify/richer-detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VerifyInstalledAsync hardening: - Track hadReadError across the nested COM reads; if any installer/entry read throws, don't report Ok/NotApplicable (which would falsely call a partially- read install "clean") — report Error. - Wrap the result.Status / PackageInstalledStatus traversal in a guard so a throwing getter/materialization maps to VerifyOutcome.Error instead of leaking. - Stop overloading null: a not-found/not-installed package now returns NotApplicable; null is reserved for "backend can't verify" (CLI), so the UI's "only on the COM backend" message is no longer shown for a Windows lookup miss. DocLinks: per-element try/catch so one malformed Documentation entry is skipped instead of dropping the whole documentation list. Both TFMs build, tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ComBackend.cs | 102 ++++++++++++++++++++++++++++------------------ 1 file changed, 63 insertions(+), 39 deletions(-) diff --git a/src/ComBackend.cs b/src/ComBackend.cs index c35967f..5a6cb91 100644 --- a/src/ComBackend.cs +++ b/src/ComBackend.cs @@ -521,7 +521,9 @@ public async Task DownloadAsync (string id, string? version, IProgress if (pkg is null) { - return null; + // Not "can't verify" (null is reserved for the CLI backend) — the package just + // isn't found / installed, so there's nothing to check. + return new () { Outcome = VerifyOutcome.NotApplicable }; } CheckInstalledStatusResult result; @@ -535,56 +537,71 @@ public async Task DownloadAsync (string id, string? version, IProgress return new () { Outcome = VerifyOutcome.Error }; } - if (result.Status != CheckInstalledStatusResultStatus.Ok) - { - return new () { Outcome = VerifyOutcome.Error }; - } - - List checks = []; - bool anyFailed = false; - - // Two nested projected vectors — indexed via Materialize (AOT rule). - foreach (PackageInstallerInstalledStatus installer in Materialize (result.PackageInstalledStatus)) + try { - IReadOnlyList entries; - - try + if (result.Status != CheckInstalledStatusResultStatus.Ok) { - entries = Materialize (installer.InstallerInstalledStatus); - } - catch - { - continue; + return new () { Outcome = VerifyOutcome.Error }; } - foreach (InstalledStatus entry in entries) + List checks = []; + bool anyFailed = false; + bool hadReadError = false; + + // Two nested projected vectors — indexed via Materialize (AOT rule). + foreach (PackageInstallerInstalledStatus installer in Materialize (result.PackageInstalledStatus)) { + IReadOnlyList entries; + try { - // HRESULT projects to an Exception: null means S_OK (the check passed). - bool ok = entry.Status is null; - string? path = NullIfEmpty (entry.Path); - checks.Add (new (StatusTypeName (entry.Type), ok, ok ? path : Coalesce (path, $"hr 0x{HResultOf (entry.Status):X8}"))); - - if (!ok) - { - anyFailed = true; - } + entries = Materialize (installer.InstallerInstalledStatus); } catch { - // Skip a malformed status entry. + hadReadError = true; + + continue; + } + + foreach (InstalledStatus entry in entries) + { + try + { + // HRESULT projects to an Exception: null means S_OK (the check passed). + bool ok = entry.Status is null; + string? path = NullIfEmpty (entry.Path); + checks.Add (new (StatusTypeName (entry.Type), ok, ok ? path : Coalesce (path, $"hr 0x{HResultOf (entry.Status):X8}"))); + + if (!ok) + { + anyFailed = true; + } + } + catch + { + hadReadError = true; + } } } - } - VerifyOutcome outcome = checks.Count == 0 - ? VerifyOutcome.NotApplicable - : anyFailed + // A confirmed failed check → Issues. Otherwise, if any read errored we can't honestly + // claim the install is clean, so report Error rather than Ok/NotApplicable. + VerifyOutcome outcome = anyFailed ? VerifyOutcome.Issues - : VerifyOutcome.Ok; + : hadReadError + ? VerifyOutcome.Error + : checks.Count == 0 + ? VerifyOutcome.NotApplicable + : VerifyOutcome.Ok; - return new () { Outcome = outcome, Checks = checks }; + return new () { Outcome = outcome, Checks = checks }; + } + catch + { + // result.Status / PackageInstalledStatus materialization threw. + return new () { Outcome = VerifyOutcome.Error }; + } } private static string StatusTypeName (InstalledStatusType t) @@ -641,11 +658,18 @@ private static string StatusTypeName (InstalledStatusType t) foreach (Documentation d in Materialize (meta.Documentations)) { - string url = d.DocumentUrl; + try + { + string url = d.DocumentUrl; - if (!string.IsNullOrWhiteSpace (url)) + if (!string.IsNullOrWhiteSpace (url)) + { + links.Add (new (string.IsNullOrWhiteSpace (d.DocumentLabel) ? "Documentation" : d.DocumentLabel, url)); + } + } + catch { - links.Add (new (string.IsNullOrWhiteSpace (d.DocumentLabel) ? "Documentation" : d.DocumentLabel, url)); + // Skip a malformed documentation entry rather than dropping the whole list. } } From 77a17e299ec1d9244f19b20e9c48c09d6f6b6c99 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Fri, 29 May 2026 11:36:57 -0500 Subject: [PATCH 17/18] Invalidate detail cache after batch upgrades Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/App.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/App.cs b/src/App.cs index 38c05c8..13b6b7e 100644 --- a/src/App.cs +++ b/src/App.cs @@ -1493,6 +1493,11 @@ private void AskBatchUpgrade () App?.Invoke (() => { + if (result.Success) + { + _state.DetailCache.Remove (id); + } + _state.StatusMessage = result.Success ? $"Upgraded {id}" : $"Failed: {id}"; From 7530f7ea040402fa79777383e63612aa21fd7b52 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Fri, 29 May 2026 14:34:57 -0500 Subject: [PATCH 18/18] Cancel preflight fetches on navigation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/App.cs | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/App.cs b/src/App.cs index 13b6b7e..e2fe612 100644 --- a/src/App.cs +++ b/src/App.cs @@ -1291,6 +1291,7 @@ private void FetchThen (string activity, Func> fet _state.Loading = true; _state.StatusIsError = false; RefreshStatusBar (); + CancellationToken ct = _detailCts.Token; Task.Run (async () => { @@ -1298,7 +1299,20 @@ private void FetchThen (string activity, Func> fet try { - result = await fetch (CancellationToken.None); + result = await fetch (ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + App?.Invoke (() => + { + _preflightBusy = false; + _state.Loading = false; + _state.StatusMessage = string.Empty; + _state.StatusIsError = false; + RefreshStatusBar (); + }); + + return; } catch (Exception ex) { @@ -1314,8 +1328,33 @@ private void FetchThen (string activity, Func> fet return; } + if (ct.IsCancellationRequested) + { + App?.Invoke (() => + { + _preflightBusy = false; + _state.Loading = false; + _state.StatusMessage = string.Empty; + _state.StatusIsError = false; + RefreshStatusBar (); + }); + + return; + } + App?.Invoke (() => { + if (ct.IsCancellationRequested) + { + _preflightBusy = false; + _state.Loading = false; + _state.StatusMessage = string.Empty; + _state.StatusIsError = false; + RefreshStatusBar (); + + return; + } + // Clear the gate before onResult so its (modal) flow — and any // RunOperation it starts — isn't blocked by this guard. _preflightBusy = false;