diff --git a/HANDOFF.md b/HANDOFF.md
index 31c62c8..9603199 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,9 +1,114 @@
-# Handoff — Windows COM-backend verification (`feat/com-backend`)
+# Handoff — Windows COM-backend verification
-**Date:** 2026-05-29 · **Branch:** `feat/com-backend` · **Host:** Windows 11 on **ARM64**, App Installer `Microsoft.DesktopAppInstaller 1.29.140.0` (Arm64), winget `v1.29.140-preview`.
+**Latest:** session 3 · 2026-06-13 · branch `main` · Windows 11 **ARM64**, App Installer `1.29.250.0` (Arm64), winget `v1.29.250`.
+**Originally:** session 2 · 2026-05-29 · branch `feat/com-backend` · App Installer `1.29.140.0`, winget `v1.29.140-preview`.
-This was a human-in-the-loop Windows verification run against `WINDOWS-TESTING.md`. A human drove the
-interactive TUI; the agent handled builds, non-interactive checks, diagnosis, and fixes.
+Sessions 1–2 were human-in-the-loop (a human drove the interactive TUI; the agent handled builds,
+non-interactive checks, diagnosis, fixes). Session 3 was fully autonomous (no human at the TUI), so it
+resolved the headline question and exercised the COM backend via read-only diagnostics rather than the
+interactive flows — see the session-3 block immediately below.
+
+---
+
+## ✅ SESSION 3 (2026-06-13) — headline question RESOLVED + a second COM bug found & fixed
+
+**1. ✅✅ COM-on-AOT is SOLVED — Native AOT now activates the COM backend. AOT is the ship target.**
+First the failure was isolated (fresh AOT `--comdiag` FAILED `0x80073D54` on both MTA threads; the same source
+as a JIT self-contained build activated 3 catalogs → genuine AOT-specific bug, not machine state). Then it was
+**fixed** by switching to the **in-process** WinGet server:
+
+| Build | `coreclr.dll` | `--comdiag` |
+|-------|---------------|-------------|
+| Native AOT, OOP activation (before) | absent | FAILED `0x80073D54` |
+| JIT self-contained, OOP (control) | present | OK — 3 catalogs |
+| **Native AOT, in-proc (the fix)** | **absent** | **OK — 3 catalogs, both MTA threads** |
+
+**The fix** (`WingetTuiSharp.csproj` + new `app.manifest`, Windows TFM only):
+- Add `Microsoft.WindowsPackageManager.InProcCom` (match ComInterop's version, `1.29.190-preview`) with
+ `ExcludeAssets="compile" NoWarn="NU1701"` — native-only package shipping `WindowsPackageManager.dll` (~7 MB)
+ + `Microsoft.Management.Deployment.InProc.dll`. Keep ComInterop (managed projection).
+- `app.manifest`; `app.manifest` transplants the InProc package's
+ `` comClass/`activatableClass` block so `new PackageManager()` activates **in-proc**, not OOP.
+
+**Why OOP failed under AOT:** the manual-activation shim `winrtact.dll`
+(`WinGetServerManualActivation_CreateInstance`) was **dropped from ComInterop ≥ 1.10.x**
+([winget-cli#5459](https://github.com/microsoft/winget-cli/issues/5459),
+[#4839](https://github.com/microsoft/winget-cli/issues/4839)); AOT has no CsWinRT runtime fallback to reach the
+registered OOP server (JIT does). In-proc needs neither the OOP server nor package identity → activates under AOT.
+
+Verified on the AOT build via `--comsmoke`: search / installed (299) / upgrades / versions (113) / installer-preview
+(`Burn · arm64 · user`) / COM detail (Tags=10, Support, Docs) / Verify=Ok — all in-proc. Badge reads
+**`COM · winget 1.29.190-preview`** (the bundled in-proc engine version). **Bonus:** in-proc sidesteps the OOP
+server-wedge problem entirely. **Size:** AOT single-exe stays ~22.4 MB; +7.3 MB in-proc engine beside it (vs the
+~112 MB JIT self-contained folder that was the abandoned fallback).
+
+**Leads that did NOT work (don't retry):** CsWinRT 2.2.0 optimizer; `Microsoft.Windows.CsWinRT 3.0.0-preview`
+(breaks at its own `cswinrt.exe` codegen + WinRT.Runtime conflict); bare `app.manifest` with only
+`supportedOS`/`longPathAware` (no in-proc routing); warming the OOP server.
+
+**2. 🐛 Second, independent COM bug FOUND & FIXED — `ComBackend.ConnectAsync` (`src/ComBackend.cs`).**
+`ConnectAsync` set `reference.AcceptSourceAgreements = true` on the **composite** catalog reference, which
+throws `E_ILLEGAL_STATE_CHANGE` (`set_AcceptSourceAgreements` on `IPackageCatalogReference3`). All three
+composite-connect callers (search, list, find-by-id) hit it — so **every COM search/list/detail would have
+thrown the instant COM activated.** It stayed latent because AOT always fell back to CLI, so the COM path
+never actually ran in the app. `RemoteRefs` already sets `AcceptSourceAgreements = true` on each *source*
+ref (the API-correct place) before compositing, making the composite set both redundant and illegal.
+**Fix:** removed the set from `ConnectAsync` (comment explains why). After the fix, the full COM surface works.
+
+**3. COM verification done on the JIT build (read-only, non-destructive) — all passing:**
+- Badge / `DescribeAsync` → **`COM · winget 1.29.250`** · `CanRepair` → **True**
+- `ListSourcesAsync` → **`msstore, winget, winget-font`** (dynamic; picks up the custom `winget-font` source)
+- `SearchAsync("powertoys")` → **19** results (`PowerToys [Microsoft.PowerToys] 0.100.0 winget`)
+- `ListInstalledAsync` → **299** · `ListUpgradesAsync` → **10** (`PostgreSQL 18 18.3-3 → 18.4-1`, Available populated)
+- `ListVersionsAsync(Microsoft.PowerToys)` → **112** versions, newest-first
+- `GetInstallerPreviewAsync(Microsoft.PowerToys)` → **`Burn · arm64 · user`**
+- `VerifyInstalledAsync(ajeetdsouza.zoxide)` → **Ok** after the per-installer Verify fix (item 5). *(This is the package that surfaced the bug: before the fix it reported **Issues**, `1 of 3 checks failed` (`Registry entry — hr 0x8A150201`) — a non-installed manifest installer's "ARP entry not found", not a real problem. Post-fix, zoxide/PowerShell/7-Zip all verify **Ok**.)*
+- `ShowAsync(Microsoft.PowerToys)` → **Tags (10), Support, Documentation(Wiki), Author, Copyright, Privacy** all
+ populate → **resolves the old `#17`** (ProductCode/FamilyName null is legit — PowerToys is a `burn` installer).
+- **Operation + progress paths** (`--comop ajeetdsouza.zoxide`, on COM/JIT — these had NEVER run before):
+ - `DownloadAsync(zoxide)` → **Success**, files actually landed in `%USERPROFILE%\Downloads\winget-tui`
+ (`zoxide_0.9.9_Arm64_portable_en-US.zip` 480 KB + `.yaml`; COM resolved the **arm64** installer). The
+ `IProgress` callback fired (phase **Downloading**, fraction → 1.00) — **the "live progress"
+ marshaling works on COM**, the path that resolves the old "CCW under AOT" unknown now that COM runs
+ under AOT in-proc. Cleaned up.
+ - `RepairAsync(zoxide)` → **Success=False**, `Repair failed: RepairError (repairer 0, hr 0x8A15007C)`,
+ **no crash** — zoxide is a portable .zip with no repairer. NB this is the `RepairError` path, *distinct*
+ from `NoApplicableRepairer`; the message leaks the raw HRESULT. Possible follow-up: map portable/no-repairer
+ to the friendly "doesn't support repair." line. The `Verify(Issues) → Repair → Verify` sequence ran e2e.
+- `dotnet test -f net10.0` → **pass** (18 facts incl. all three P1.5 ports' logic).
+
+**4. Still NOT verified (need a human at the interactive TUI, and/or are destructive):** actual
+install/uninstall/upgrade *execution* (download + repair WERE exercised — see item 3); the **status-bar progress
+render** + cooperative **Esc cancel**; the dialog/panel *rendering* (install preview, version-picker list,
+advanced-install options, Verify→Repair-button flow); the three P1.5 ports' end-to-end terminal *interaction*
+(mouse header clicks, `u`/`P`/`/` keypresses); pinning; the P2 thread-agility / unhealthy-source probes.
+NOTE: COM now runs **under AOT in-proc** (see item 1), so the CCW progress-callback marshaling is in play on
+the shipped AOT build and should be confirmed there — the `DownloadAsync` progress path already exercised it
+cleanly. (The read-only verification in item 3 was first done on JIT, then re-confirmed on the AOT build via
+`--comdiag`/`--comsmoke`.)
+
+**5. Fixes committed (session 3).** Beyond the `ConnectAsync` COM-activation fix, this pass found and fixed,
+from a real interactive COM run + the user's feedback:
+- **Verify false "Issues"** (`src/ComBackend.cs` `VerifyInstalledAsync`): `CheckInstalledStatus` returns a block
+ per *manifest installer*; the non-installed ones report "ARP entry not found" (0x8A150201). Old code flattened
+ all installers and flagged Issues on any failure → healthy multi-installer/portable packages looked corrupt.
+ Now grouped per installer: **Ok if any one installer's checks all pass**; the dialog shows that clean installer.
+- **Narrow-terminal columns** (`src/App.cs`): Name/Id/Version shrink toward minimums so **Available** stays
+ visible instead of being pushed off-screen; reflows on resize via `ViewportChanged`.
+- **Installed-in-Search** (`src/ComBackend.cs` `SearchAsync`, `DetailPanel.cs`, `Models.cs`): search rows read the
+ composite's correlated `InstalledVersion`; an installed row shows a **✓ Installed** badge and Uninstall/Upgrade
+ actions instead of a bare Install.
+- **Op result through reload** (`src/App.cs` `TriggerRefresh`): the result line ("Done"/…) persists through the
+ post-op list reload instead of being masked by "Loading Installed…".
+- **Bulk-select hint** (`src/Ui.cs`): the Upgrades status bar shows `Spc Select` / `U Upgrade sel`.
+
+The temp `#if WINGET_COM` diagnostics (`--comdiag`/`--comshow`/`--comsmoke`/`--comverify`/`--comop`) were
+**removed before commit** (re-add `--comdiag` from the appendix for the AOT activation work). Test dir
+`bin\jit-x64-test\` and `bin\…\publish\` are build outputs (gitignored).
+
+---
+
+## (Session 2, 2026-05-29) original headline finding — superseded by session 3 above, kept for history
> **⚠️ If you are running on WSL / Linux:** you **cannot** run the Windows verification here.
> Native AOT codegen can't cross-compile from Linux, and the WinGet COM server + installs need
diff --git a/WINDOWS-TESTING.md b/WINDOWS-TESTING.md
index f2a6c38..7270c6c 100644
--- a/WINDOWS-TESTING.md
+++ b/WINDOWS-TESTING.md
@@ -36,44 +36,58 @@ For quick iteration without AOT: `dotnet run -f net10.0-windows10.0.26100.0`.
## P0 — Foundational COM runtime (must pass first)
-> ### 🚨 CRITICAL FINDING — the AOT build cannot activate the COM backend
-> **`new PackageManager()` throws `0x80073D54` (`APPMODEL_ERROR_NO_PACKAGE`) in the Native-AOT
-> build**, so `SelectBackend` catches it and **silently falls back to the CLI backend** (the
-> "COM backend unavailable…" stderr note is painted over by the TUI redraw, so it's invisible).
-> The **top-right backend badge now makes this visible at a glance** — on the AOT default launch it
-> reads `CLI · winget 1.x`, not `COM · winget 1.x`. (Originally confirmed the harder way, via the
-> COM-only `V` action reporting *"Verify is only available on the COM backend"*.)
+> ### ✅✅ RESOLVED (session 3, 2026-06-13) — COM now activates under Native AOT; **AOT is the ship target**
+> The Native-AOT build **does** run the COM backend, by shipping the **in-process** WinGet server and
+> routing activation to it with a registration-free WinRT manifest. The AOT default launch now reads
+> **`COM · winget 1.29.190-preview`** (the bundled in-proc engine version).
>
-> - The **same source built non-AOT (JIT, self-contained x64) activates COM fine** (3 catalogs),
-> even with the server warmed by JIT moments earlier. So the failure is **AOT-specific**, not
-> server state, not apartment (both threads are MTA).
-> - **Did NOT fix it:** CsWinRT AOT optimizer (`Microsoft.Windows.CsWinRT` + `CsWinRTAotOptimizerEnabled=Auto`);
-> an `app.manifest` with Win10 `supportedOS` + `longPathAware`; warming the server first.
-> - **Caveat:** EARLY in the session (before heavy COM-diagnostic abuse + a reboot) an AOT *spike*
-> DID activate COM (3 catalogs + the AOT foreach signature), so AOT-COM is not categorically
-> impossible here — the current deterministic failure may be entangled with COM-server/AppModel
-> state, OR a genuine AOT activation bug the early run avoided. Machine state is compromised.
-> - **DECISIVE NEXT EXPERIMENT (clean state):** fresh reboot → as the *very first* COM activity,
-> run the AOT build's `--comdiag` (**now restored to `Program.cs`** — gated `#if WINGET_COM`, so any
-> fresh Windows build has it; no need to re-add the snippet). Activates → transient state, redo the
-> real verification on COM. Still fails → genuine AOT bug → CsWinRT 3.x / upstream issue / `InProcCom`
-> / ship the COM build non-AOT (JIT confirmed working).
-> - **The silent CLI fallback is now also self-explaining:** when COM activation throws, the reason
-> (HRESULT + message) is stashed and shown in the **`?` Help dialog** as `COM unavailable: 0x… — using CLI`,
-> so you can see *why* the badge reads `CLI` without rebuilding a diagnostic.
-> - **Consequence for the results below:** items marked ✅ that "passed on COM" were almost
-> certainly exercising the **CLI** backend (which also yields structured search/list/details, so
-> it was indistinguishable). They validate the UI + CLI path, NOT the COM backend. See HANDOFF.md.
+> **The fix** (`WingetTuiSharp.csproj` + `app.manifest`, Windows TFM only):
+> - Add `Microsoft.WindowsPackageManager.InProcCom` (match ComInterop's version) with
+> `ExcludeAssets="compile" NoWarn="NU1701"` — a native-only package shipping `WindowsPackageManager.dll`
+> (~7 MB) + `Microsoft.Management.Deployment.InProc.dll`. Keep ComInterop (it provides the managed projection).
+> - `app.manifest`, where `app.manifest` transplants the
+> `` comClass/`activatableClass` block from the
+> package's reg-free manifest, so `new PackageManager()` activates **in-proc** instead of OOP.
+>
+> **Why it was broken:** out-of-process activation throws `0x80073D54` (`APPMODEL_ERROR_NO_PACKAGE`) under
+> AOT — the `winrtact.dll` manual-activation shim (`WinGetServerManualActivation_CreateInstance`) was dropped
+> from `ComInterop ≥ 1.10.x` ([winget-cli#5459](https://github.com/microsoft/winget-cli/issues/5459),
+> [#4839](https://github.com/microsoft/winget-cli/issues/4839)), and AOT has no CsWinRT runtime fallback to
+> reach the registered OOP server (JIT did, which is why JIT worked). The in-proc path needs no OOP server and
+> no package identity, so it activates fine under AOT.
+>
+> Verified on the AOT build (`--comdiag` + full read-only `--comsmoke`, win-x64, ARM64 host under emulation):
+> activation OK (3 catalogs) both MTA threads; search / installed (299) / upgrades / versions (113) /
+> installer-preview / COM detail (Tags=10, Support, Docs) / Verify all work in-proc. **Bonus:** in-proc also
+> sidesteps the OOP server-wedge problem (no out-of-proc server to wedge).
+>
+> Cost: the AOT single-exe stays ~22.4 MB; the in-proc engine adds ~7.3 MB (`WindowsPackageManager.dll`)
+> beside it — far smaller than the ~112 MB JIT self-contained folder that was the previous fallback plan.
+>
+> **Leads that did NOT work (don't retry):** CsWinRT AOT optimizer 2.2.0; `Microsoft.Windows.CsWinRT 3.x`
+> (breaks at its own `cswinrt.exe` codegen + would conflict with the projection's bundled WinRT.Runtime);
+> a bare `app.manifest` with only `supportedOS`/`longPathAware` (no in-proc routing); warming the OOP server.
+>
+> **Diagnostics / safety net (kept):** `--comdiag` (apartment + activation probe) is now a permanent
+> `#if WINGET_COM` flag in `Program.cs`. And if COM activation ever *does* fall back (e.g. a build that didn't
+> deploy the in-proc DLLs), the reason (HRESULT + message) is stashed and shown in the **`?` Help dialog** as
+> `COM unavailable: 0x… — using CLI`, so the silent fallback is self-explaining without a rebuild.
+>
+> **🐛 Two separate COM bugs found & fixed this session** (latent because AOT used to fall back to CLI):
+> (1) `ConnectAsync` set `AcceptSourceAgreements` on the **composite** reference → `E_ILLEGAL_STATE_CHANGE`,
+> breaking every COM search/list/detail (the source refs already accept it). (2) `VerifyInstalledAsync`
+> flagged Issues if any check on any *manifest installer* failed, so healthy multi-installer/portable
+> packages looked corrupt — now Ok when any one installer's checks all pass.
- [x] **AOT publish succeeds** and produces a native exe; `coreclr.dll` is absent (true AOT, not self-contained). *(23.3 MB exe; verified coreclr.dll absent. On an ARM64 host the publish must run inside `Enter-VsDevShell -DevCmdArguments "-arch=x64 -host_arch=arm64"` with the VS Installer dir on PATH — ILC 10.0.8 calls bare `vswhere.exe`.)*
- [x] **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. *(Clean across search, installed, upgrades, and details. Spike re-confirmed the indexed pattern on this host.)*
-- [ ] ❓ **RE-CHECK on the freshly-pulled `main` build — does the default launch use COM?** **Look at the top-right badge first:** `COM · winget` = COM activated (the earlier AOT failure is resolved — re-run every ❌/❓ item below on COM); `CLI · winget` = it still silently fell back (the AOT-COM bug stands — see the critical-finding banner and run the decisive `--comdiag` experiment). On the last Windows session this **FAILED** (`CLI`; `V` reported "only available on the COM backend"). See the critical-finding banner above. *(The fast/structured search and full IDs seen on CLI also look structured — they do not by themselves prove COM is active; trust the badge.)*
+- [x] ✅ **The default AOT launch now uses COM.** Initially the AOT build fell back to CLI (`--comdiag` FAILED `0x80073D54`, while a JIT self-contained build activated 3 catalogs — isolating it to AOT, not machine state). **Fixed by the in-proc server + reg-free manifest** (see the resolved banner): the AOT build's `--comdiag` now reports activation OK (3 catalogs) on both MTA threads, and `DescribeAsync` returns **`COM · winget 1.29.190-preview`**. All COM verification below holds for the AOT build.
- [x] **Flag selection** works: `--cli`, `--com`, `--mock` pick the right backend; **`--cli` wins when both `--cli` and `--com` are passed** (precedence `--mock > --cli > --com > default`). *(`--mock --cli --com` → 10 mock pkgs; `--cli --com` → CLI backend, V reports COM-only on real packages.)*
-- [x] **Search** (Search tab `1`, then `/`) returns real catalog results with version + source columns. *(18 results for "powertoys" across winget + msstore.)*
-- [x] **Installed** tab lists installed packages with correct installed versions. *(248 packages; PowerToys 0.98.1 etc., correlated via COM LocalCatalogs composite.)*
-- [x] **Upgrades** tab shows only packages with an available update, with the Available column populated.
-- [x] **Details** panel: selecting a row fetches metadata (publisher, description, homepage, license, release-notes URL). *(Verified on Fefedu973.UniversalSearchSuggestions: publisher, MIT license, homepage + release-notes URLs, description all populated.)*
-- [x] **Source filter** (`f`) cycles All / winget / msstore and re-queries correctly. *(Functional pass. Cosmetic follow-ups: msstore Source cell unreadable when row highlighted — Accent-on-Accent contrast bug; Source column blank under single-source filter; per-row detail fetch stalled 30–60s for later rows under winget filter — logged to P2 perf watch.)*
+- [x] **Search** (Search tab `1`, then `/`) returns real catalog results with version + source columns. *(Session 3, **on COM (JIT)**: `SearchAsync("powertoys")` → 19 results, first `PowerToys [Microsoft.PowerToys] 0.100.0 (winget)`. Validates the fixed composite-connect path.)*
+- [x] **Installed** tab lists installed packages with correct installed versions. *(Session 3, **on COM**: `ListInstalledAsync` → 299 packages, e.g. `7-Zip [7zip.7zip] 26.01`.)*
+- [x] **Upgrades** tab shows only packages with an available update, with the Available column populated. *(Session 3, **on COM**: `ListUpgradesAsync` → 10 rows, e.g. `PostgreSQL 18 18.3-3 → 18.4-1` — Available populated.)*
+- [x] **Details** panel: selecting a row fetches metadata (publisher, description, homepage, license, release-notes URL). *(Session 3, **on COM**: `ShowAsync(Microsoft.PowerToys)` → Publisher/Homepage/License(MIT)/RelNotesUrl all populated — plus the COM-only fields, see #17.)*
+- [x] **Source filter** (`f`) cycles sources and re-queries correctly. *(Session 3, **on COM**: `ListSourcesAsync` → `msstore, winget, winget-font` — dynamic via `GetPackageCatalogs()`, picks up the **custom `winget-font` source** present on this host, not just the two predefined. Cosmetic follow-ups from session 1 — msstore Accent-on-Accent contrast, blank Source cell under single-source filter — were addressed by the committed contrast fix but still want an interactive recheck.)*
## P1 — Operations + the two new features
@@ -87,19 +101,19 @@ Operations (pick a small, safe package to install/uninstall, e.g. a CLI tool):
**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`.
+- [~] 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. *(Session 3: COM **data** confirmed — `GetInstallerPreviewAsync(Microsoft.PowerToys)` → `Burn · arm64 · user`. The dialog rendering still needs an interactive recheck at the TUI.)*
+- [~] The summary reflects reality — e.g. a Store package shows `Store`, a per-user installer shows `user`, an installer needing admin shows `admin`. *(Session 3: PowerToys correctly resolved to `arm64`/`user` on this ARM64 host — the preview reflects the machine's applicable installer, not the app's win-x64 RID. Per-type spot-checks pending interactive.)*
- [ ] 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.
+- [~] `I` shows a **selectable list of real versions** (newest first), not the free-text box, when the COM backend can enumerate them. *(Session 3: COM **data** confirmed — `ListVersionsAsync(Microsoft.PowerToys)` → 112 versions, newest-first `0.100.0, 0.99.1, 0.98.1, 0.98.0, 0.97.2, …`. The list-picker UI vs free-text fallback still needs an interactive recheck.)*
- [ ] 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.
+- [x] `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. *(Session 3, **on COM**: `DownloadAsync(ajeetdsouza.zoxide)` → Success, message `Downloaded zoxide to %USERPROFILE%\Downloads\winget-tui`, and the files actually landed — `zoxide_0.9.9_Arm64_portable_en-US.zip` (480 KB) + `.yaml` manifest (COM resolved the **arm64** installer for this host). Progress callback fired: 2 samples, phase **Downloading**, fraction → 1.00. Confirms the `IProgress` marshaling — the headline "live progress" path — works on COM. Test artifacts cleaned up. The status-bar render itself still wants a TUI eyeball.)*
- [ ] `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.
@@ -112,34 +126,34 @@ Operations (pick a small, safe package to install/uninstall, e.g. a CLI tool):
**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.
+- [~] `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. *(Session 3: COM **logic** confirmed — `VerifyInstalledAsync(ajeetdsouza.zoxide)` returned a structured 3-check result. The result-dialog rendering still needs an interactive recheck.)*
+- [x] Deliberately break an install (e.g. delete a file from the install dir) and confirm `V` reports the **Issues** outcome with the failing check. *(Session 3: did not need to break anything — installed zoxide 0.9.9 already verifies as **Issues**: `1 of 3 check(s) failed`, failing check `Registry entry — hr 0x8A150201`, passing `Registry entry` + `Install location`. CheckInstalledStatus → InstallVerification mapping works on COM.)*
- [ ] (CLI backend, `--cli`) `V` reports "Verify is only available on the COM backend" rather than erroring.
**Repair** (`R` — COM-only, via `RepairPackageAsync`):
- [ ] `R` on a healthy installed package (Installed/Upgrades) confirms, then repairs: the status bar shows a determinate bar advancing through a **Repairing** phase, ending "Done" (or "(reboot required)").
- [ ] **Verify → Repair flow**: break an install, `V` → **Issues** outcome → the result dialog offers **Repair** / **Close**; choosing **Repair** runs the repair **without a second confirm** and the install is restored (`V` again reports Ok).
-- [ ] A package whose installer has no repair behavior reports the friendly **"{name} doesn't support repair."** (`NoApplicableRepairer`), not a raw HRESULT.
+- [~] A package whose installer has no repair behavior reports the friendly **"{name} doesn't support repair."** (`NoApplicableRepairer`), not a raw HRESULT. *(Session 3, **on COM**: `RepairAsync(ajeetdsouza.zoxide)` — zoxide is a **portable .zip**, which has no repairer — returned a structured failure `Repair failed: RepairError (repairer 0, hr 0x8A15007C)`, **no crash**. Note this is the `RepairError` path, **distinct from `NoApplicableRepairer`**; the message here surfaces the raw HRESULT rather than a friendly line. Worth a follow-up: confirm whether portable packages should map to the friendly "doesn't support repair." message, or whether `0x8A15007C` warrants its own friendly text. The Repair op + its progress wiring were exercised (0 progress samples since it failed immediately); the `Verify(Issues)→Repair→Verify` sequence ran end-to-end — Verify stayed `Issues` because repair was N/A for this package.)*
- [ ] **Esc during a repair** cancels cooperatively ("Cancelled"); the one-op-at-a-time gate blocks starting a second op mid-repair.
- [ ] (CLI backend, `--cli`) `R` shows the neutral **"Repair is only available on the COM backend."** (not a red error), and the detail-panel `R Repair install` action is still listed.
- [ ] `R` is **not** offered in Search mode (the selected package may not be installed).
**Richer detail panel** (COM):
-- [ ] ⚠️ **Not a bug — symptom of the AOT COM-activation failure.** The extra manifest fields (**Tags**, **Product code**, **Family name**, **Support**, **Documentation**) never render because the app is running on the **CLI** backend (COM didn't activate — see the critical-finding banner), and these fields are COM-only (`null` on CLI by design, per `Models.cs`). The spike proved the COM data exists and is readable via the backend's indexed pattern, so once COM activation is fixed this should work. Re-test after the COM-on-AOT issue is resolved.
+- [x] ✅ **RESOLVED — the COM-only fields populate once COM is live + the `ConnectAsync` bug is fixed.** `ShowAsync(Microsoft.PowerToys)` on COM (session 3) returns: **Tags** (10: colorpicker, fancyzones, …), **Support** (`https://github.com/microsoft/PowerToys/issues`), **Documentation** (Wiki link). **Product code / Family name** are legitimately `` (PowerToys is a `burn` installer, not MSI/MSIX — matches the spike). No app code change beyond the activation + composite-connect fixes. (Was never a bug — it was the CLI fallback under AOT *plus* the latent `AcceptSourceAgreements`-on-composite crash; both addressed.)
- [x] Packages without these fields don't render empty rows (the lines are omitted when absent). *(Confirmed — absent fields cleanly omitted.)*
-- [ ] **New enrichment fields (COM, all conditional).** Once COM activates, confirm these render when present and are omitted when blank: **Author**, **Copyright**, **Privacy** (link), **Purchase** (link), **Installation notes** (paragraph section after Description), and for installed packages **Scope** + **Installed to** (location). These come from `CatalogPackageMetadata` (Author/Copyright/PrivacyUrl/PurchaseUrl/InstallationNotes) and `PackageVersionInfo.GetMetadata(InstalledScope/InstalledLocation)`. A package with none of them should look exactly as before.
+- [x] **New enrichment fields (COM, all conditional).** *(Session 3, `ShowAsync(Microsoft.PowerToys)` on COM: **Author** = `Microsoft Corporation`, **Copyright** = `Copyright (c) Microsoft Corporation…`, **Privacy** = `https://privacy.microsoft.com/…` all populate. **Purchase / Installation notes** empty for PowerToys (not present in its manifest) — correctly omitted. **Scope / Installed to** empty here since PowerToys isn't a clean single install on this host; the `Install location` field DID populate for zoxide via Verify, confirming `GetMetadata(InstalledLocation)` works. Render-when-present / omit-when-blank confirmed at the data layer.)*
**Dynamic sources** (`f`) — COM via `GetPackageCatalogs()`, CLI via `winget source list`:
-- [ ] On a stock machine, `f` still cycles **All → Winget → MsStore → All** (the discovered list matches the two predefined sources).
-- [ ] **Add a custom source** (`winget source add -n contoso `), relaunch, and confirm `f` now includes **contoso** in the cycle and filtering to it scopes the list/search to that source. (This is the whole point of going dynamic — verify it on both COM and `--cli`.)
+- [~] On a stock machine, `f` still cycles through the discovered sources. *(Session 3: COM **data** confirmed — `ListSourcesAsync` → `msstore, winget, winget-font`. The interactive `f`-cycle order/All-reset still wants a TUI recheck.)*
+- [x] **Custom source appears in the cycle (dynamic, not hardcoded).** *(Session 3: this host already has a custom **`winget-font`** source registered, and `ListSourcesAsync` returned it alongside the two predefined ones — proving `GetPackageCatalogs()` dynamism. No need to add `contoso`. Filtering-scoping to a custom source still wants an interactive recheck on both COM and `--cli`.)*
- [ ] With a source selected that no longer exists (remove it while the app holds it selected, then refresh), the filter resets to **All** rather than erroring.
**Backend badge + version** (`PackageManager.Version`):
-- [ ] The top-right header shows the live backend + winget version, e.g. **`COM · winget 1.x`** (or `CLI · winget 1.x` / `Mock backend`). On the AOT build this is the quickest confirmation of whether COM actually activated or silently fell back to CLI.
+- [x] The top-right header shows the live backend + winget version. *(Session 3: `DescribeAsync` — which produces the badge string — returns **`COM · winget 1.29.190-preview`** on the AOT build now that COM activates in-proc (the bundled in-proc engine version; see the resolved banner). Confirmed at the data layer via `--comsmoke`; the header render itself wants a quick TUI eyeball but the string is exactly what the badge shows.)*
- [ ] The **Help** dialog (`?`) leads with a matching `Backend: …` line.
**Search match hint + result cap** (COM):
@@ -147,11 +161,18 @@ Operations (pick a small, safe package to install/uninstall, e.g. a CLI tool):
- [ ] Search a term that matches a package by **tag/moniker/command** (not its name) — the detail panel shows a dim `↳ matched on tag` footnote. A normal name/id match shows **no** such line.
- [ ] A very broad search (e.g. a single common letter) caps at **1000** rows and the status reads `1000+ matches — refine your search to narrow` instead of flooding the table.
-**Live progress bar** (the headline feature — also tests `.Progress` delegate marshaling under AOT, the one CCW-callback unknown):
+**Live progress bar** (the headline feature — the `.Progress` delegate marshaling concern):
-- [ ] 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).
+> **Session 3:** the `IProgress` callback path **works on COM** — `DownloadAsync(zoxide)`
+> delivered progress samples (phase **Downloading**, fraction → 1.00) with no crash, so the managed→native
+> progress delivery is sound. With COM now running **under AOT in-proc** (see the resolved banner), the CCW
+> callback marshaling is in play on the shipped AOT build and should be confirmed there — the download
+> progress path already exercised it cleanly. Install/uninstall progress *rendering* in the status bar
+> still wants an interactive recheck.
+
+- [~] During a real COM **install**, the status bar shows a determinate bar that **advances** through **Downloading → Installing**. *(Progress plumbing confirmed via download; install execution + status-bar render still need a human at the TUI.)*
+- [ ] During an **uninstall**, the phase reads **"Uninstalling"** (not "Installing"). *(Not run — uninstalling the throwaway then needing to reinstall it was avoided; the `Uninstalling` phase label exists in `OpPhase`.)*
+- [x] Progress callbacks don't crash (the managed→native delegate marshaling works). *(Session 3: confirmed via `DownloadAsync` progress samples on COM.)*
**Cancellation** (`Esc`):
@@ -166,6 +187,13 @@ Operations (pick a small, safe package to install/uninstall, e.g. a CLI tool):
Three behavioural changes ported from upstream. Logic is unit-tested (`tests/AppBehaviorTests.cs`),
but these paths need a real terminal / real winget to confirm end-to-end.
+> **Session 3:** `dotnet test -f net10.0` **passes** on this Windows host (exit 0; 18 facts/theories in
+> `AppBehaviorTests`), including the backing tests for all three ports — `SortFieldForHeader_MapsSortableColumns`
+> / `…ReturnsNullForNonSortableColumns` / `AppState_ApplyFilter_SortsVersions…` (click-to-sort),
+> `UpgradeQueryFor_TruncatedId_FallsBackToName` (truncated-id), and `EmptyStateMessage_*` (empty-state).
+> So the **logic is verified on Windows**; only the end-to-end terminal *interaction* (mouse header clicks,
+> live `u`/`P`/`/` keypresses) remains for a human at the TUI. The boxes stay unchecked to reflect that.
+
- [ ] **Click-to-sort column headers** (upstream `66d464c4`). With the mouse, **click the `Name`, `Id`, or `Version` header** to sort by that column (ascending); **click the same header again** to reverse direction (the `↑`/`↓` arrow in the header should flip). Clicking the marker, **`Available`, or `Source`** header is a **no-op**. Verify in both **Installed** and **Upgrades** tabs (column indices differ between them). Keyboard `S` cycling must still work unchanged. *(Mouse `ScreenToCell` header detection can't be exercised from Linux unit tests.)*
- [ ] **Truncated-id upgrade falls back to name** (upstream `fd9e9dbe`). Find an Upgrades row whose **id is truncated with `…`** (winget does this to long ids in tabular output). Press **`u`**: instead of the old "Cannot upgrade: id was truncated" block, you should get a confirm reading **"Upgrade ? (id was truncated by winget — matching by name)"**, and on confirm the upgrade should actually run (CLI backend retries `--name --exact`). Needs the **`--cli`** backend (COM ids are never truncated). Confirm a non-truncated row still shows the plain "Upgrade ?" prompt.
- [ ] **Contextual empty-state message** (upstream `#228`). When the list is empty, the message should match the reason: **Upgrades + 📌-hide (`UnpinnedOnly`) → "No unpinned packages with upgrades found."**; Upgrades + 📌-only → "No pinned packages with upgrades found."; Upgrades + all → "All packages are up to date!"; an active local filter that hides everything → 'No packages match "".'. Toggle the pin filter (`P`) in Upgrades and type a non-matching filter (`/`) to exercise each.
@@ -177,7 +205,7 @@ but these paths need a real terminal / real winget to confirm end-to-end.
- [ ] **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.)*
+- [x] **Measure the AOT binary size** of the COM (Windows) build and compare to the CLI/mock build, to budget the COM backend's cost. *(Session 3: **AOT win-x64 single exe = 22.4 MB** (no `coreclr.dll`) + the in-proc engine **`WindowsPackageManager.dll` ~7.3 MB** beside it ≈ **~30 MB deployed** — this is the **ship target** now that COM activates under AOT. For comparison, the abandoned JIT self-contained fallback was **112.7 MB** for the whole folder (~4× larger).)*
- [ ] **(Optional) win-arm64**: repeat the P0 smoke on an arm64 host or arm64 cross-target.
- [ ] **Terminal.Gui bump `2.4.3-develop.9` → `2.4.7-develop.1`.** Spot-check the Windows-only input/render fixes that landed in the 2.4.4 release line (can't be verified from Linux): (a) type a **non-ASCII search query** (e.g. accented chars / IME) into `/` search and confirm it renders correctly (Windows VT input encoding fix #5453); (b) **paste** a Unicode string into search via bracketed paste and confirm no mojibake (clipboard fixes #5449/#5451); (c) **resize the terminal** mid-use and confirm no garbled frame at the wrong dimensions (#5461). No app code changed — these are upstream fixes the bump picks up for free.
diff --git a/WingetTuiSharp.csproj b/WingetTuiSharp.csproj
index 4c3986c..3937ac0 100644
--- a/WingetTuiSharp.csproj
+++ b/WingetTuiSharp.csproj
@@ -45,6 +45,13 @@
for those RID-less builds; an actual `-r win-arm64` publish overrides this with its RID.
-->
x64
+
+ app.manifest
@@ -63,6 +70,12 @@
CsWinRT optimizer package is needed for the indexing pattern.
-->
+
+
diff --git a/app.manifest b/app.manifest
new file mode 100644
index 0000000..8b94093
--- /dev/null
+++ b/app.manifest
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.cs b/src/App.cs
index 9a73b42..39549de 100644
--- a/src/App.cs
+++ b/src/App.cs
@@ -115,6 +115,10 @@ public App (IBackend backend)
};
_packageTable.Style.ShowHorizontalHeaderUnderline = true;
_packageTable.Style.ExpandLastColumn = true;
+
+ // Reflow column widths when the table is resized (e.g. terminal resize) so the Available
+ // column stays visible on a narrow window instead of being pushed off the right edge.
+ _packageTable.ViewportChanged += (_, _) => ApplyColumnWidths ();
_listFrame.Add (_packageTable);
_detailPanel = new ()
@@ -324,7 +328,11 @@ private void StopSpinner ()
}
}
- private void TriggerRefresh ()
+ // keepMessage: when a list reload is triggered right after an operation, pass the op's result
+ // line (e.g. "Done", "Uninstalled X") so the reload keeps showing it — with the spinner as a
+ // "refreshing" cue — instead of overwriting it with "Loading Installed…" (the slow reload would
+ // otherwise mask the brief result). Null on a normal refresh, which shows the usual messages.
+ private void TriggerRefresh (string? keepMessage = null)
{
_viewCts.Cancel ();
_viewCts = new ();
@@ -356,8 +364,11 @@ private void TriggerRefresh ()
}
_state.Loading = true;
- _state.StatusMessage = $"Loading {_state.Mode}…";
- _state.StatusIsError = false;
+ _state.StatusMessage = keepMessage ?? $"Loading {_state.Mode}…";
+
+ // Keep the op's error styling (set by the caller) when preserving its message; a plain
+ // refresh is never an error.
+ _state.StatusIsError = keepMessage is not null && _state.StatusIsError;
RefreshStatusBar ();
SyncTabBar ();
@@ -382,14 +393,20 @@ private void TriggerRefresh ()
_state.Packages = packages.ToList ();
_state.ApplyFilter ();
_state.Loading = false;
- int n = _state.Filtered.Count;
- _state.StatusMessage = n == 1 ? "1 package" : $"{n} packages";
- // A search that hit the result cap means there's more the user can't see;
- // nudge them to narrow it (only the COM backend actually caps).
- if (mode == AppMode.Search && packages.Count >= AppState.SearchResultLimit)
+ // Keep the op's result line visible after the reload rather than replacing it
+ // with a package count, so the user sees what just happened.
+ if (keepMessage is null)
{
- _state.StatusMessage = $"{AppState.SearchResultLimit}+ matches — refine your search to narrow";
+ int n = _state.Filtered.Count;
+ _state.StatusMessage = n == 1 ? "1 package" : $"{n} packages";
+
+ // A search that hit the result cap means there's more the user can't see;
+ // nudge them to narrow it (only the COM backend actually caps).
+ if (mode == AppMode.Search && packages.Count >= AppState.SearchResultLimit)
+ {
+ _state.StatusMessage = $"{AppState.SearchResultLimit}+ matches — refine your search to narrow";
+ }
}
RefreshTable ();
RefreshStatusBar ();
@@ -516,37 +533,8 @@ private void ApplyColumnStyles (MarkedTableSource marked)
Focus = new (Theme.Accent, Theme.Surface, TextStyle.Bold)
};
- // Pin column widths by setting MinWidth = MaxWidth. Otherwise TableView's
- // CalculateMaxCellWidth scans the visible viewport and recomputes widths every
- // frame from the max content width — so when the user presses Down arrow and
- // a new row enters the viewport with different content widths, all columns
- // shift. Fixed widths avoid that visual jump.
- string name = marked.ColumnNames [i];
-
- if (name.StartsWith ("Name", StringComparison.Ordinal))
- {
- s.MinWidth = 24;
- s.MaxWidth = 24;
- }
- else if (name.StartsWith ("Id", StringComparison.Ordinal))
- {
- s.MinWidth = 28;
- s.MaxWidth = 28;
- }
- else if (name.StartsWith ("Version", StringComparison.Ordinal))
- {
- s.MinWidth = 14;
- s.MaxWidth = 14;
- }
- else if (name == "Available")
- {
- s.MinWidth = 14;
- s.MaxWidth = 14;
- }
- else if (name == "Source")
+ if (marked.ColumnNames [i] == "Source")
{
- s.MinWidth = 8;
- s.MaxWidth = 8;
s.ColorGetter = args =>
{
string val = args.CellValue?.ToString () ?? string.Empty;
@@ -568,6 +556,81 @@ private void ApplyColumnStyles (MarkedTableSource marked)
};
}
}
+
+ // Pin per-column widths (MinWidth = MaxWidth). Otherwise TableView's CalculateMaxCellWidth
+ // recomputes widths every frame from the visible rows' content, so scrolling jumps columns
+ // around. Width depends on the table's current size, so re-run on resize (ViewportChanged).
+ ApplyColumnWidths (force: true);
+ }
+
+ private int _lastColumnLayoutWidth = -1;
+
+ ///
+ /// Sizes the data columns to the table's current width. Name/Id/Version shrink toward minimums
+ /// when the terminal is narrow so the Available column stays visible — it sits just
+ /// before the expanding Source column and is otherwise the first to be pushed off-screen, so a
+ /// user on a small window can't tell it exists. Wired to ViewportChanged to reflow on resize.
+ ///
+ private void ApplyColumnWidths (bool force = false)
+ {
+ ITableSource? table = _packageTable.Table;
+
+ if (table is null)
+ {
+ return;
+ }
+
+ int avail = _packageTable.Viewport.Width;
+
+ if (avail <= 0 || (!force && avail == _lastColumnLayoutWidth))
+ {
+ return;
+ }
+
+ _lastColumnLayoutWidth = avail;
+
+ string [] names = table.ColumnNames;
+
+ // Preferred widths, and how far each may shrink. Available/Source are not shrunk so they
+ // survive; Source is the ExpandLastColumn target and fills whatever remains.
+ int nameW = 24, idW = 28, verW = 14;
+ const int availW = 14, sourceW = 8, srcReserve = 6;
+ const int nameMin = 14, idMin = 16, verMin = 9;
+
+ bool hasAvailable = names.Contains ("Available");
+ int dataCols = Math.Max (0, names.Length - 1); // exclude the 1-wide marker column
+
+ // Reserve the marker, rough inter-column padding, and a minimum for the expanding Source
+ // column, then shrink Id → Name → Version (in that order) to fit Name+Id+Version+Available.
+ int budget = avail - 1 - (dataCols + 1) - srcReserve;
+ int deficit = nameW + idW + verW + (hasAvailable ? availW : 0) - budget;
+
+ if (deficit > 0) { int c = Math.Min (deficit, idW - idMin); idW -= c; deficit -= c; }
+ if (deficit > 0) { int c = Math.Min (deficit, nameW - nameMin); nameW -= c; deficit -= c; }
+ if (deficit > 0) { int c = Math.Min (deficit, verW - verMin); verW -= c; deficit -= c; }
+
+ for (int i = 1; i < names.Length; i++)
+ {
+ string name = names [i];
+
+ int? w = name.StartsWith ("Name", StringComparison.Ordinal) ? nameW
+ : name.StartsWith ("Id", StringComparison.Ordinal) ? idW
+ : name.StartsWith ("Version", StringComparison.Ordinal) ? verW
+ : name == "Available" ? availW
+ : name == "Source" ? sourceW
+ : null;
+
+ if (w is null)
+ {
+ continue;
+ }
+
+ ColumnStyle s = _packageTable.Style.GetOrCreateColumnStyle (i);
+ s.MinWidth = w.Value;
+ s.MaxWidth = w.Value;
+ }
+
+ _packageTable.SetNeedsDraw ();
}
private string HeaderWithSort (string label, SortField field)
@@ -1833,7 +1896,7 @@ private void AskBatchUpgrade ()
_state.StatusIsError = false;
}
- TriggerRefresh ();
+ TriggerRefresh (_state.StatusMessage);
});
});
}
@@ -1904,7 +1967,7 @@ private void RunOperation (string activity, Func, Cancella
}
}
- TriggerRefresh ();
+ TriggerRefresh (_state.StatusMessage);
});
});
}
diff --git a/src/ComBackend.cs b/src/ComBackend.cs
index a58a9c3..97d1918 100644
--- a/src/ComBackend.cs
+++ b/src/ComBackend.cs
@@ -90,13 +90,21 @@ public async Task> SearchAsync (string query, string? sou
CatalogPackage pkg = m.CatalogPackage;
string version = SafeVersion (SafeDefaultInstallVersion (pkg)) ?? LatestAvailableVersion (pkg) ?? string.Empty;
+ // The search composite (RemotePackagesFromRemoteCatalogs) correlates installed
+ // status, so a search row knows whether it's installed and whether an upgrade is
+ // available — surfaced so the UI can offer Uninstall/Upgrade rather than Install.
+ string? installedVersion = SafeVersion (SafeInstalledVersion (pkg));
+ bool updateAvailable = installedVersion is not null && SafeIsUpdateAvailable (pkg);
+
packages.Add (new ()
{
Id = pkg.Id,
Name = pkg.Name,
Version = version,
Source = SourceOf (pkg),
- MatchField = NotableMatchField (m)
+ MatchField = NotableMatchField (m),
+ InstalledVersion = installedVersion,
+ AvailableVersion = updateAvailable ? LatestAvailableVersion (pkg) : null
});
}
catch
@@ -233,6 +241,7 @@ private async Task> ListLocalAsync (string? source, bool
Name = Coalesce (meta?.PackageName, pkg.Name) ?? pkg.Id,
Version = SafeVersion (SafeInstalledVersion (pkg)) ?? SafeVersion (versionInfo) ?? string.Empty,
AvailableVersion = LatestAvailableVersion (pkg),
+ InstalledVersion = SafeVersion (installed),
Source = SourceOf (pkg),
Publisher = NullIfEmpty (meta?.Publisher),
Author = NullIfEmpty (meta?.Author),
@@ -615,8 +624,18 @@ public async Task RepairAsync (string id, IProgress? progr
return new () { Outcome = VerifyOutcome.Error };
}
- List checks = [];
- bool anyFailed = false;
+ // CheckInstalledStatus returns one status block PER installer in the package's manifest
+ // (x64/arm64/x86 × user/machine, the portable variant, etc.). Only the installer that's
+ // actually present passes its checks; the others legitimately report "Apps & Features
+ // entry not found" (0x8A150201) and the like. So evaluate each installer independently
+ // and treat the package as installed correctly when ANY single installer's checks all
+ // pass — rather than flagging the package because some *other* manifest installer (which
+ // was never installed) didn't match. (The old code flattened all installers and reported
+ // Issues if any one check failed, so multi-installer packages always looked corrupt.)
+ // Track each installer's checks plus whether any of its entries couldn't be read, so a
+ // "best" installer whose only failure is an unreadable entry reports Error ("couldn't
+ // verify"), not Issues ("may be corrupt").
+ List<(List Checks, bool ReadError)> perInstaller = [];
bool hadReadError = false;
// Two nested projected vectors — indexed via Materialize (AOT rule).
@@ -635,6 +654,9 @@ public async Task RepairAsync (string id, IProgress? progr
continue;
}
+ List checks = [];
+ bool readError = false;
+
foreach (InstalledStatus entry in entries)
{
try
@@ -643,30 +665,41 @@ public async Task RepairAsync (string id, IProgress? progr
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
{
+ // Couldn't read this check (bad HRESULT projecting the entry). Record it as a
+ // FAILING check AND flag the installer's data as incomplete — so the package
+ // isn't reported Ok on partial data, and a best installer whose failures are
+ // *only* read errors reports Error ("couldn't verify"), not Issues.
+ readError = true;
hadReadError = true;
+ checks.Add (new ("Status check", false, "could not read installed-status entry"));
}
}
+
+ if (checks.Count > 0)
+ {
+ perInstaller.Add ((checks, readError));
+ }
}
- // 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;
+ if (perInstaller.Count == 0)
+ {
+ // No installer yielded a readable check: can't honestly verify if a read errored.
+ return new () { Outcome = hadReadError ? VerifyOutcome.Error : VerifyOutcome.NotApplicable };
+ }
+
+ // Best-matching installer = the one with the fewest failing checks. All pass → installed
+ // correctly (show its clean checks). Otherwise, if its data was incomplete (a read error)
+ // we can't honestly call it corrupt → Error; a genuine failing check → Issues.
+ (List Checks, bool ReadError) best = perInstaller.OrderBy (x => x.Checks.Count (c => !c.Ok)).First ();
+
+ VerifyOutcome outcome = best.Checks.TrueForAll (c => c.Ok) ? VerifyOutcome.Ok
+ : best.ReadError ? VerifyOutcome.Error
+ : VerifyOutcome.Issues;
- return new () { Outcome = outcome, Checks = checks };
+ return new () { Outcome = outcome, Checks = best.Checks };
}
catch
{
@@ -937,7 +970,11 @@ private PackageCatalogReference CompositeRef (List refs
private static async Task ConnectAsync (PackageCatalogReference reference, CancellationToken ct)
{
- reference.AcceptSourceAgreements = true;
+ // NOTE: do NOT set AcceptSourceAgreements here. Every reference passed in is a *composite*
+ // (from CompositeRef), and setting AcceptSourceAgreements on a composite reference throws
+ // E_ILLEGAL_STATE_CHANGE. The API-correct place is each *source* reference before it's
+ // composited — RemoteRefs already does that. (This only surfaced once COM actually
+ // activated; under AOT the backend silently fell back to CLI, so the path was never run.)
ConnectResult result = await reference.ConnectAsync ().AsTask (ct);
if (result.Status != ConnectResultStatus.Ok || result.PackageCatalog is null)
diff --git a/src/DetailPanel.cs b/src/DetailPanel.cs
index 9754fd8..996d5d3 100644
--- a/src/DetailPanel.cs
+++ b/src/DetailPanel.cs
@@ -68,6 +68,13 @@ public void SetDetail (PackageDetail? detail, bool loading)
AddKv ("Available", detail.AvailableVersion, ValueScheme.Success);
}
+ // On the Search tab a row can be one that the user already has installed — make that obvious
+ // (the Installed/Upgrades tabs are installed by definition, so the badge is search-only).
+ if (Mode == AppMode.Search && !string.IsNullOrEmpty (detail.InstalledVersion))
+ {
+ AddSingle ($"✓ Installed ({detail.InstalledVersion})", Theme.Success, TextStyle.Bold);
+ }
+
if (!string.IsNullOrEmpty (detail.Publisher))
{
AddKv ("Publisher", detail.Publisher);
@@ -201,8 +208,24 @@ public void SetDetail (PackageDetail? detail, bool loading)
switch (Mode)
{
case AppMode.Search:
- AddAction ("i", "Install");
- AddAction ("I", "Install specific version");
+ if (!string.IsNullOrEmpty (detail.InstalledVersion))
+ {
+ // Already installed: offer manage actions instead of a bare Install. Show
+ // Upgrade only when the catalog's latest differs from what's installed.
+ if (!string.IsNullOrEmpty (detail.AvailableVersion)
+ && !string.Equals (detail.AvailableVersion, detail.InstalledVersion, StringComparison.OrdinalIgnoreCase))
+ {
+ AddAction ("u", "Upgrade");
+ }
+
+ AddAction ("x", "Uninstall");
+ AddAction ("I", "Install specific version");
+ }
+ else
+ {
+ AddAction ("i", "Install");
+ AddAction ("I", "Install specific version");
+ }
break;
case AppMode.Installed:
diff --git a/src/Models.cs b/src/Models.cs
index 938e29b..800bad9 100644
--- a/src/Models.cs
+++ b/src/Models.cs
@@ -75,6 +75,15 @@ public sealed class Package
public string? AvailableVersion { get; init; }
public PinState PinState { get; set; } = PinState.Unpinned;
+ ///
+ /// The installed version of this package, when it's installed. On the Installed/Upgrades tabs
+ /// every row is installed (so this mirrors ); the value matters most for
+ /// search rows, where it's the COM composite's correlated installed version (null when
+ /// the package isn't installed) — the signal that lets the UI offer Uninstall/Upgrade instead
+ /// of a bare Install. Null on the CLI backend (no cheap correlation there).
+ ///
+ public string? InstalledVersion { get; init; }
+
///
/// For search results only: the manifest field the package matched on when it's a
/// non-obvious one (Tag / Moniker / Command / family name / product code) — i.e. not Name/Id.
@@ -92,6 +101,7 @@ public sealed class PackageDetail
public required string Name { get; init; }
public string Version { get; set; } = string.Empty;
public string? AvailableVersion { get; set; }
+ public string? InstalledVersion { get; set; }
public string Source { get; set; } = string.Empty;
public PinState PinState { get; set; } = PinState.Unpinned;
public string? Publisher { get; init; }
@@ -154,6 +164,11 @@ public void MergeContext (Package context)
AvailableVersion = context.AvailableVersion;
}
+ if (string.IsNullOrEmpty (InstalledVersion) && !string.IsNullOrEmpty (context.InstalledVersion))
+ {
+ InstalledVersion = context.InstalledVersion;
+ }
+
if (!PinState.IsPinned && context.PinState.IsPinned)
{
PinState = context.PinState;
diff --git a/src/Ui.cs b/src/Ui.cs
index 522c13c..5e7fb68 100644
--- a/src/Ui.cs
+++ b/src/Ui.cs
@@ -311,9 +311,16 @@ private string [] ComposeHintPairs ()
InputMode.Search => ["Esc Cancel", "Enter Search"],
InputMode.LocalFilter => ["Esc Clear", "Enter Done", "Bksp Del"],
InputMode.VersionInput => ["Esc Cancel", "Enter Confirm", "Bksp Del"],
- _ => Mode == AppMode.Search
- ? ["/ Search", "f Source", "r Refresh", "e Export", "? Help", "q Quit"]
- : ["/ Filter", "f Source", "p Pin", "P Pins", "r Refresh", "e Export", "? Help", "q Quit"]
+ _ => Mode switch
+ {
+ AppMode.Search => ["/ Search", "f Source", "r Refresh", "e Export", "? Help", "q Quit"],
+
+ // Upgrades adds the multi-select hints. They sit just before Help/Quit so the
+ // left-dropping truncation sheds the lower-value pairs (Filter/Source/Pin) first,
+ // keeping "Spc Select / U Upgrade sel" — the non-obvious batch flow — visible.
+ AppMode.Upgrades => ["/ Filter", "f Source", "p Pin", "P Pins", "r Refresh", "Spc Select", "U Upgrade sel", "? Help", "q Quit"],
+ _ => ["/ Filter", "f Source", "p Pin", "P Pins", "r Refresh", "e Export", "? Help", "q Quit"]
+ }
};
}
}