diff --git a/Program.cs b/Program.cs index 2afcac5..8b9743f 100644 --- a/Program.cs +++ b/Program.cs @@ -65,14 +65,15 @@ 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 (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 (); @@ -84,6 +85,58 @@ 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 + // 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 + { + 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/WINDOWS-TESTING.md b/WINDOWS-TESTING.md new file mode 100644 index 0000000..c729b62 --- /dev/null +++ b/WINDOWS-TESTING.md @@ -0,0 +1,113 @@ +# 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. + +**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. + +**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`). + +**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%). +- [ ] 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. 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/spikes/ComBackendSpike/ComBackendSpike.csproj b/spikes/ComBackendSpike/ComBackendSpike.csproj new file mode 100644 index 0000000..5e9a456 --- /dev/null +++ b/spikes/ComBackendSpike/ComBackendSpike.csproj @@ -0,0 +1,67 @@ + + + + + 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..f9b0e6c --- /dev/null +++ b/spikes/ComBackendSpike/Program.cs @@ -0,0 +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, and — critically — probe +// the AOT failure mode we hit on the first Windows run: +// +// 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; + +int Run (string query) +{ + Console.WriteLine ($"[spike] querying winget COM for: {query}"); + + 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 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 (); + + if (connect.Status != ConnectResultStatus.Ok) + { + Console.Error.WriteLine ($"[spike] connect failed: {connect.Status}"); + + return 1; + } + + PackageCatalog catalog = connect.PackageCatalog; + + // 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; +} + +// 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; + +// 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) +{ + int count; + + try + { + count = list.Count; + } + catch (Exception ex) + { + Console.WriteLine ($"[spike] {label}: ✗ even .Count threw {ex.GetType ().Name}: {ex.Message}"); + + return; + } + + 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 Run (args.Length > 0 ? args [0] : "powertoys"); 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 new file mode 100644 index 0000000..75c3392 --- /dev/null +++ b/spikes/ComBackendSpike/SPIKE-RESULTS.md @@ -0,0 +1,271 @@ +# 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. + +## 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 + +`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. + +### What did NOT fix it + +Referencing `Microsoft.Windows.CsWinRT` 2.2.0 and turning on the AOT optimizer ++ RCW factory fallback generator: + +```xml + +Auto +false +true +``` + +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. + +### What DID work — the recipe: index, don't enumerate + +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: + +``` +[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 + +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.** 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 + +```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) + +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 +# 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 +``` diff --git a/src/App.cs b/src/App.cs index 6beba06..e2fe612 100644 --- a/src/App.cs +++ b/src/App.cs @@ -25,6 +25,15 @@ 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; + + // 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; @@ -506,6 +515,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; } @@ -739,8 +749,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; @@ -901,6 +928,21 @@ 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 'V': + AskVerify (CurrentPackage ()); + key.Handled = true; + return; case 'u': AskUpgrade (CurrentPackage ()); @@ -1031,26 +1073,312 @@ 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); + + 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, InstallSettings? settings = null) + { + FetchThen ( + "Checking installer…", + ct => _state.Backend.GetInstallerPreviewAsync (p.Id, version, ct), + preview => + { + string title = version is null ? $"Install {p.Name}?" : $"Install {p.Name} {version}?"; + 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, 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; // cancelled + } - RunOperation ($"Installing {p.Name}", _ => _state.Backend.InstallAsync (p.Id, null, _)); + // 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); + } + /// 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; } - string? version = PromptForVersion (p); + 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); + }); + } - if (string.IsNullOrEmpty (version)) + private void ShowVerifyResult (Package p, InstallVerification v) + { + if (App is null) { return; } - RunOperation ($"Installing {p.Name} {version}", _ => _state.Backend.InstallAsync (p.Id, version, _)); + 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) + { + 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 + /// dialog without blocking the UI. Skipped if an operation is already in flight. + /// + private void FetchThen (string activity, Func> fetch, Action onResult) + { + // 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; + RefreshStatusBar (); + CancellationToken ct = _detailCts.Token; + + Task.Run (async () => + { + T result; + + try + { + 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) + { + App?.Invoke (() => + { + _preflightBusy = false; + _state.Loading = false; + _state.StatusMessage = $"Error: {ex.Message}"; + _state.StatusIsError = true; + RefreshStatusBar (); + }); + + 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; + _state.Loading = false; + _state.StatusMessage = string.Empty; + RefreshStatusBar (); + onResult (result); + }); + }); + } + + private string? PickVersion (Package p, IReadOnlyList versions) + { + if (App is null) + { + return null; + } + + VersionPickerDialog dlg = new (p.Name, versions); + App.Run (dlg); + string? value = dlg.Result; + dlg.Dispose (); + + return value; } private void AskUpgrade (Package? p) @@ -1065,7 +1393,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 +1408,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 +1426,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) @@ -1147,15 +1475,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 (); }); @@ -1164,7 +1510,15 @@ 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, ct); + } + catch (OperationCanceledException) + { + cancelled = true; + + break; } catch (Exception ex) { @@ -1178,6 +1532,11 @@ private void AskBatchUpgrade () App?.Invoke (() => { + if (result.Success) + { + _state.DetailCache.Remove (id); + } + _state.StatusMessage = result.Success ? $"Upgraded {id}" : $"Failed: {id}"; @@ -1188,27 +1547,54 @@ private void AskBatchUpgrade () App?.Invoke (() => { + _opCts?.Dispose (); + _opCts = null; _state.BatchSelected.Clear (); _state.Loading = false; + + if (cancelled) + { + _state.StatusMessage = "Cancelled"; + _state.StatusIsError = false; + } + TriggerRefresh (); }); }); } - private void RunOperation (string activity, Func> op) + 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; RefreshStatusBar (); + IProgress progress = new UiProgress (this); + Task.Run (async () => { - OpResult result; + OpResult? result = null; + bool cancelled = false; try { - result = await op (CancellationToken.None); + result = await op (progress, ct); + } + catch (OperationCanceledException) + { + cancelled = true; } catch (Exception ex) { @@ -1222,13 +1608,25 @@ private void RunOperation (string activity, Func { + _opCts?.Dispose (); + _opCts = null; _state.Loading = false; - _state.StatusMessage = result.Success ? "Done" : result.Message; - _state.StatusIsError = !result.Success; + _state.OpProgress = null; - if (result.Operation.PackageId is { } id) + if (cancelled) { - _state.DetailCache.Remove (id); + _state.StatusMessage = "Cancelled"; + _state.StatusIsError = false; + } + else + { + _state.StatusMessage = result!.Success ? "Done" : result.Message; + _state.StatusIsError = !result.Success; + + if (result.Operation.PackageId is { } id) + { + _state.DetailCache.Remove (id); + } } TriggerRefresh (); @@ -1236,6 +1634,37 @@ 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) + { + // 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; + } + + _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..3d7824a 100644 --- a/src/Backend.cs +++ b/src/Backend.cs @@ -7,9 +7,31 @@ 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); + + // 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. + 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); + + // 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 e8eb14d..ba85c09 100644 --- a/src/CliBackend.cs +++ b/src/CliBackend.cs @@ -48,15 +48,53 @@ public async Task> ListUpgradesAsync (SourceFilter source return ParseShow (id, output); } - public async Task InstallAsync (string id, string? version, CancellationToken ct) + // 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); + + // 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) { - (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 UninstallAsync (string id, CancellationToken ct) + public async Task DownloadAsync (string id, string? version, IProgress? progress, CancellationToken ct) + { + string dir = Path.Combine ( + Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), + "Downloads", + "winget-tui"); + Operation op = new () { Kind = OperationKind.Download, PackageId = id, Version = version }; + + try + { + Directory.CreateDirectory (dir); + } + catch (Exception ex) + { + 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); + + 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); Operation op = new () { Kind = OperationKind.Uninstall, PackageId = id }; @@ -64,7 +102,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); @@ -144,7 +182,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). @@ -156,6 +194,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 new file mode 100644 index 0000000..5a6cb91 --- /dev/null +++ b/src/ComBackend.cs @@ -0,0 +1,1030 @@ +// 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. +/// +/// 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. +/// - 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 +{ + 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)) + { + try + { + 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; + } + + 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)) + { + try + { + 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 + }); + } + catch + { + // Skip a malformed row (bad HRESULT on a property read) rather than failing + // the entire listing. + } + } + + 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); + + 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), + SupportUrl = NullIfEmpty (meta?.PublisherSupportUrl), + Tags = meta is null ? null : StringVector (() => meta.Tags), + Documentation = DocLinks (meta), + ProductCodes = StringVector (() => versionInfo.ProductCodes), + PackageFamilyNames = StringVector (() => versionInfo.PackageFamilyNames) + }; + } + 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; + } + } + + // ------------------------------------------------------------------------ + // 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)) + { + // 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 + { + // Latest: the default-install version, else the installed version. + versionInfo = SafeDefaultInstallVersion (pkg) ?? 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 + // ------------------------------------------------------------------------ + + 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); + + if (pkg is null) + { + 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); + + if (versionId is null) + { + return Fail (op, $"Version '{version}' is not available for {pkg.Name}."); + } + + options.PackageVersionId = versionId; + } + + // 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 ("Install", result)); + } + + public async Task UpgradeAsync (string id, IProgress? progress, 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 + }; + + 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 ("Upgrade", result)); + } + + 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); + + if (pkg is null) + { + return Fail (op, $"Installed package '{id}' not found."); + } + + UninstallOptions options = new () { PackageUninstallMode = PackageUninstallMode.Silent }; + 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)}") + : 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 (); + + try + { + Directory.CreateDirectory (dir); + } + catch (Exception ex) + { + return Fail (op, $"Could not prepare download folder '{dir}': {ex.Message}"); + } + + 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})"); + } + + public async Task VerifyInstalledAsync (string id, CancellationToken ct) + { + CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: true, ct); + + if (pkg is 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; + + try + { + result = await pkg.CheckInstalledStatusAsync (InstalledStatusType.AllChecks).AsTask (ct); + } + catch + { + return new () { Outcome = VerifyOutcome.Error }; + } + + try + { + if (result.Status != CheckInstalledStatusResultStatus.Ok) + { + return new () { Outcome = VerifyOutcome.Error }; + } + + 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 + { + entries = Materialize (installer.InstallerInstalledStatus); + } + catch + { + 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; + } + } + } + + // 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 + : hadReadError + ? VerifyOutcome.Error + : checks.Count == 0 + ? VerifyOutcome.NotApplicable + : VerifyOutcome.Ok; + + return new () { Outcome = outcome, Checks = checks }; + } + catch + { + // result.Status / PackageInstalledStatus materialization threw. + return new () { Outcome = VerifyOutcome.Error }; + } + } + + 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)) + { + try + { + string url = d.DocumentUrl; + + if (!string.IsNullOrWhiteSpace (url)) + { + links.Add (new (string.IsNullOrWhiteSpace (d.DocumentLabel) ? "Documentation" : d.DocumentLabel, url)); + } + } + catch + { + // Skip a malformed documentation entry rather than dropping the whole list. + } + } + + 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 ( + Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), + "Downloads", + "winget-tui"); + + /// 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). + // ------------------------------------------------------------------------ + + 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) + { + try + { + foreach (PackageVersionId vid in Materialize (pkg.AvailableVersions)) + { + 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; + } + + // ------------------------------------------------------------------------ + // 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; + } + + /// 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.Uninstalling, + PackageUninstallProgressState.PostUninstall => OpPhase.Finalizing, + PackageUninstallProgressState.Finished => OpPhase.Done, + _ => OpPhase.Uninstalling + }; + + 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})"; + + // 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 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/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 1e92d12..de916c0 100644 --- a/src/MockBackend.cs +++ b/src/MockBackend.cs @@ -113,35 +113,158 @@ 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); } - public Task InstallAsync (string id, string? version, CancellationToken ct) - => Task.FromResult (new OpResult + 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 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); + + 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 Task UninstallAsync (string id, CancellationToken ct) - => Task.FromResult (new OpResult + 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)" + }; + } + + public async Task UninstallAsync (string id, IProgress? progress, CancellationToken ct) + { + await SimulateProgressAsync (progress, downloads: false, ct); + + return new () { Operation = new () { Kind = OperationKind.Uninstall, PackageId = id }, Success = true, Message = $"[mock] Uninstalled {id}" - }); + }; + } - public Task UpgradeAsync (string id, CancellationToken ct) - => Task.FromResult (new OpResult + public async Task UpgradeAsync (string id, IProgress? progress, CancellationToken ct) + { + await SimulateProgressAsync (progress, downloads: true, ct); + + 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..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 @@ -169,7 +176,84 @@ 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; } + + /// 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); +} + +/// 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 @@ -186,3 +270,94 @@ 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, + Uninstalling, + 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.Uninstalling => "Uninstalling", + OpPhase.Finalizing => "Finalizing", + OpPhase.Done => "Done", + _ => 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 039664b..5513ed3 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); @@ -341,6 +359,134 @@ 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 (); + } +} + +/// +/// 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. /// @@ -408,9 +554,12 @@ 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 + V Verify install (check files / registration) p Pin / Unpin Space Toggle batch select (Upgrades only) a Select / deselect all (Upgrades only) @@ -428,7 +577,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 """; }