mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-01 16:09:46 +02:00
Merge branch 'main' into feature/shortcutguidev2
This commit is contained in:
14
.github/actions/spell-check/allow/code.txt
vendored
14
.github/actions/spell-check/allow/code.txt
vendored
@@ -187,6 +187,12 @@ xmlutil
|
||||
# Prefix
|
||||
pcs
|
||||
|
||||
# EXPRTK / C++ MATH
|
||||
|
||||
ifunction
|
||||
isinf
|
||||
isnan
|
||||
|
||||
# User32.SYSTEM_METRICS_INDEX.cs
|
||||
|
||||
CLEANBOOT
|
||||
@@ -311,6 +317,7 @@ onefuzz
|
||||
# NameInCode
|
||||
leilzh
|
||||
mengyuanchen
|
||||
contoso
|
||||
|
||||
# DllName
|
||||
testhost
|
||||
@@ -351,6 +358,7 @@ WINDOWPOS
|
||||
WINEVENTPROC
|
||||
WORKERW
|
||||
FULLSCREENAPP
|
||||
DEFAULTTONEAREST
|
||||
|
||||
# COM/WinRT interface prefixes and type fragments
|
||||
BAlt
|
||||
@@ -385,6 +393,12 @@ YYY
|
||||
# Unicode
|
||||
precomposed
|
||||
|
||||
# names of characters
|
||||
zwsp
|
||||
|
||||
# mermaid
|
||||
autonumber
|
||||
|
||||
# GitHub issue/PR commands
|
||||
azp
|
||||
feedbackhub
|
||||
|
||||
1
.github/actions/spell-check/allow/names.txt
vendored
1
.github/actions/spell-check/allow/names.txt
vendored
@@ -209,6 +209,7 @@ Bilibili
|
||||
BVID
|
||||
capturevideosample
|
||||
cmdow
|
||||
contoso
|
||||
Contoso
|
||||
Controlz
|
||||
cortana
|
||||
|
||||
5
.github/actions/spell-check/excludes.txt
vendored
5
.github/actions/spell-check/excludes.txt
vendored
@@ -105,13 +105,13 @@
|
||||
^src/common/ManagedCommon/ColorFormatHelper\.cs$
|
||||
^src/common/notifications/BackgroundActivatorDLL/cpp\.hint$
|
||||
^src/common/sysinternals/Eula/
|
||||
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherComparisonTests.cs$
|
||||
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherDiacriticsTests.cs$
|
||||
^doc/devdocs/modules/cmdpal/initial-sdk-spec/list-elements-mock-002\.pdn$
|
||||
^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$
|
||||
^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$
|
||||
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/.*\.TestData\.cs$
|
||||
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/Text/.*\.cs$
|
||||
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherComparisonTests.cs$
|
||||
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherDiacriticsTests.cs$
|
||||
^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$
|
||||
^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/
|
||||
^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$
|
||||
@@ -143,3 +143,4 @@ ignore$
|
||||
src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs
|
||||
^src/modules/powerrename/unittests/testdata/avif_test\.avif$
|
||||
^src/modules/powerrename/unittests/testdata/heif_test\.heic$
|
||||
^deps/spdlog-msvc-fix/
|
||||
|
||||
9
.github/actions/spell-check/expect.txt
vendored
9
.github/actions/spell-check/expect.txt
vendored
@@ -245,6 +245,7 @@ clickable
|
||||
clickonce
|
||||
clientedge
|
||||
clientside
|
||||
cliextensions
|
||||
CLIPBOARDUPDATE
|
||||
CLIPCHILDREN
|
||||
CLIPSIBLINGS
|
||||
@@ -483,6 +484,7 @@ DString
|
||||
DSVG
|
||||
dto
|
||||
DUMMYUNIONNAME
|
||||
dumpbin
|
||||
dutil
|
||||
DVASPECT
|
||||
DVASPECTINFO
|
||||
@@ -769,6 +771,7 @@ HOOKPROC
|
||||
HORZRES
|
||||
HORZSIZE
|
||||
Hostbackdropbrush
|
||||
hostfxr
|
||||
hostsfileeditor
|
||||
Hostx
|
||||
hotfixes
|
||||
@@ -1103,6 +1106,7 @@ Metadatas
|
||||
metafile
|
||||
metapackage
|
||||
mfc
|
||||
mfcm
|
||||
Mgmt
|
||||
Microwaved
|
||||
middleclickaction
|
||||
@@ -1546,6 +1550,7 @@ ptd
|
||||
PTOKEN
|
||||
PToy
|
||||
ptstr
|
||||
ptsym
|
||||
pui
|
||||
pvct
|
||||
PWAs
|
||||
@@ -1634,6 +1639,7 @@ resmimetype
|
||||
RESOURCEID
|
||||
RESTORETOMAXIMIZED
|
||||
RETURNONLYFSDIRS
|
||||
Revalidates
|
||||
RGBQUAD
|
||||
rgbs
|
||||
rgelt
|
||||
@@ -1691,7 +1697,6 @@ scrollviewer
|
||||
sddl
|
||||
SDKDDK
|
||||
sdns
|
||||
SDTVDONGLE
|
||||
searchterm
|
||||
SEARCHUI
|
||||
secondaryclickaction
|
||||
@@ -2001,6 +2006,7 @@ trx
|
||||
tsa
|
||||
tskill
|
||||
tstoi
|
||||
tsv
|
||||
tweakable
|
||||
TWF
|
||||
tymed
|
||||
@@ -2047,6 +2053,7 @@ unitconverter
|
||||
unittests
|
||||
UNLEN
|
||||
UNORM
|
||||
unparsable
|
||||
unremapped
|
||||
Unsend
|
||||
unsubscribes
|
||||
|
||||
23
.github/skills/release-note-generation/SKILL.md
vendored
23
.github/skills/release-note-generation/SKILL.md
vendored
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: release-note-generation
|
||||
description: Toolkit for generating PowerToys release notes from GitHub milestone PRs or commit ranges. Use when asked to create release notes, summarize milestone PRs, generate changelog, prepare release documentation, request Copilot reviews for PRs, update README for a new release, manage PR milestones, or collect PRs between commits/tags. Supports PR collection by milestone or commit range, milestone assignment, grouping by label, summarization with external contributor attribution, and README version bumping.
|
||||
description: Toolkit for generating PowerToys release notes from GitHub milestone PRs or commit ranges. Use when asked to create release notes, summarize milestone PRs, generate changelog, prepare release documentation, generate PR review summaries locally for release notes, update README for a new release, manage PR milestones, collect PRs between commits/tags, or prepare release assets (download installers and compute installer hashes).
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
# Release Note Generation Skill
|
||||
|
||||
Generate professional release notes for PowerToys milestones by collecting merged PRs, requesting Copilot code reviews, grouping by label, and producing user-facing summaries.
|
||||
Generate professional release notes for PowerToys milestones by collecting merged PRs, summarizing each PR with the local CLI agent, grouping by label, and producing user-facing summaries.
|
||||
|
||||
## Output Directory
|
||||
|
||||
@@ -26,16 +26,17 @@ Generated Files/ReleaseNotes/
|
||||
|
||||
- Generate release notes for a milestone
|
||||
- Summarize PRs merged in a release
|
||||
- Request Copilot reviews for milestone PRs
|
||||
- Generate per-PR review summaries locally for release-notes copy
|
||||
- Assign milestones to PRs missing them
|
||||
- Collect PRs between two commits/tags
|
||||
- Update README.md for a new version
|
||||
- Prepare GitHub release assets (download installers/symbols + compute hashes)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **GitHub CLI (`gh`) installed and authenticated** — The collection script uses `gh pr view` and `gh api graphql` to fetch PR metadata and co-author information. Run `gh auth status` to verify; if not logged in, run `gh auth login` first. See [Step 1.0.0](./references/step1-collection.md) for details.
|
||||
- MCP Server: github-mcp-server installed
|
||||
- GitHub Copilot code review enabled for the org/repo
|
||||
- MCP Server: github-mcp-server installed (used to fetch PR diffs/files for the local-agent review step)
|
||||
- For [prepare-release-assets.ps1](./scripts/prepare-release-assets.ps1) only: **Azure CLI** authenticated against the Microsoft tenant (`az login`) with the `azure-devops` extension; access to the `microsoft/Dart` ADO project
|
||||
|
||||
## Required Variables
|
||||
|
||||
@@ -65,12 +66,12 @@ Generated Files/ReleaseNotes/
|
||||
└────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────┐
|
||||
│ 3.1 Request Reviews (Copilot) │
|
||||
│ 3.1 Local-agent PR summaries │
|
||||
│ (writes CopilotSummary) │
|
||||
└────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────┐
|
||||
│ 3.2 Refresh PR data │
|
||||
│ (CopilotSummary) │
|
||||
│ 3.2 (Optional) Refresh PR data │
|
||||
└────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────┐
|
||||
@@ -93,7 +94,7 @@ Generated Files/ReleaseNotes/
|
||||
| 1.1 | Collect PRs | From previous release tag on `stable` branch → `sorted_prs.csv` |
|
||||
| 1.2 | Assign Milestones | Ensure all PRs have correct milestone |
|
||||
| 2.1–2.4 | Label PRs | Auto-suggest + human label low-confidence |
|
||||
| 3.1–3.3 | Reviews & Grouping | Request Copilot reviews → refresh → group by label |
|
||||
| 3.1–3.3 | Reviews & Grouping | Local agent summarizes each PR diff into `CopilotSummary` → (optional refresh) → group by label |
|
||||
| 4.1–4.2 | Summaries & Final | Generate grouped summaries, then consolidate |
|
||||
|
||||
## Detailed workflow docs
|
||||
@@ -114,6 +115,7 @@ Do not read all steps at once—only read the step you are executing.
|
||||
| [group-prs-by-label.ps1](./scripts/group-prs-by-label.ps1) | Group PRs into CSVs |
|
||||
| [collect-or-apply-milestones.ps1](./scripts/collect-or-apply-milestones.ps1) | Assign milestones |
|
||||
| [diff_prs.ps1](./scripts/diff_prs.ps1) | Incremental PR diff |
|
||||
| [prepare-release-assets.ps1](./scripts/prepare-release-assets.ps1) | Download installers + symbols from an ADO build, compute SHA256, emit the "Installer Hashes" markdown table for the GitHub release page |
|
||||
|
||||
## References
|
||||
|
||||
@@ -133,5 +135,6 @@ Do not read all steps at once—only read the step you are executing.
|
||||
|-------|----------|
|
||||
| `gh` command not found | Install GitHub CLI and add to PATH |
|
||||
| No PRs returned | Verify milestone title matches exactly |
|
||||
| Empty CopilotSummary | Request Copilot reviews first, then re-run dump |
|
||||
| Empty `CopilotSummary` for many PRs | Run Step 3.1 (local-agent summaries). Do **not** use `mcp_github_request_copilot_review` from a CLI/coding agent — the GitHub API rejects bot-initiated review requests, so the column will stay empty. |
|
||||
| Many unlabeled PRs | Return to labeling step before grouping |
|
||||
| `prepare-release-assets.ps1` fails with "Failed to acquire ADO access token" | Run `az login` and ensure you have access to the `microsoft/Dart` ADO project |
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
# Step 3: Copilot Reviews and Grouping
|
||||
# Step 3: Local Agent Reviews and Grouping
|
||||
|
||||
## 3.0 To-do
|
||||
- 3.1 Request Copilot Reviews (Agent Mode)
|
||||
- 3.2 Refresh PR Data
|
||||
- 3.1 Generate PR Summaries with the Local Agent
|
||||
- 3.2 (Optional) Refresh PR Data
|
||||
- 3.3 Group PRs by Label
|
||||
|
||||
## 3.1 Request Copilot Reviews (Agent Mode)
|
||||
## 3.1 Generate PR Summaries with the Local Agent
|
||||
|
||||
Use MCP tools to request Copilot reviews for all PRs in `Generated Files/ReleaseNotes/sorted_prs.csv`:
|
||||
> ⚠️ **Do not use `mcp_github_request_copilot_review` (or any "request Copilot review" tool that calls the GitHub API).**
|
||||
> When this skill is driven from a CLI / coding agent, the request is made from a bot identity and the GitHub API rejects it ("Bot reviewers cannot be requested"). The PR ends up with no Copilot review and `CopilotSummary` stays empty.
|
||||
>
|
||||
> Instead, **the local agent that is running this skill performs the review itself** and writes the summary directly into `sorted_prs.csv`.
|
||||
|
||||
- Use `mcp_github_request_copilot_review` for each PR ID
|
||||
- Do NOT generate or run scripts for this step
|
||||
For every PR listed in `Generated Files/ReleaseNotes/sorted_prs.csv` whose `CopilotSummary` is empty:
|
||||
|
||||
1. Fetch the PR diff using a tool that does **not** post anything back to GitHub. Any of these works:
|
||||
- `mcp_github_pull_request_read` with `method: get_diff`
|
||||
- `mcp_github_pull_request_read` with `method: get_files` (when the diff is large)
|
||||
- `gh pr diff <PR_NUMBER> --repo microsoft/PowerToys`
|
||||
2. Read the PR title, body, and diff. Produce a 1–3 sentence, user-facing summary in the same style as a Copilot PR review (focus on observable behavior change, not implementation details).
|
||||
3. Write the summary into the `CopilotSummary` column for that PR row in `Generated Files/ReleaseNotes/sorted_prs.csv`. Preserve all other columns and the existing row order.
|
||||
|
||||
**Batching guidance**
|
||||
|
||||
- Process PRs in the order they appear in `sorted_prs.csv`.
|
||||
- Generate summaries for **all** PRs in one pass before continuing to Step 3.3, so the human reviewer can validate them together.
|
||||
- For very large diffs, summarize from `get_files` (filenames + per-file patches) rather than the full diff.
|
||||
- Skip PRs that already have a non-empty `CopilotSummary` (e.g. PRs where a human reviewer already pasted one). Do not overwrite existing summaries.
|
||||
|
||||
**Why not post the summary back to the PR?** Posting a comment from the agent's identity would not be picked up by `dump-prs-since-commit.ps1` (which only matches Copilot bot authors), and it adds noise to the PR. Writing straight into the CSV keeps the artifact self-contained.
|
||||
|
||||
---
|
||||
|
||||
## 3.2 Refresh PR Data
|
||||
## 3.2 (Optional) Refresh PR Data
|
||||
|
||||
Re-run the collection script to capture Copilot review summaries into the `CopilotSummary` column:
|
||||
Only re-run the collection script if PR metadata on GitHub has changed (new labels, retitled PRs, etc.) since Step 1.1. **Skip this step if you only want to preserve the locally generated `CopilotSummary` values from Step 3.1**, because re-running the dump will overwrite the CSV.
|
||||
|
||||
```powershell
|
||||
pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 `
|
||||
@@ -24,6 +42,8 @@ pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1
|
||||
-OutputDir 'Generated Files/ReleaseNotes'
|
||||
```
|
||||
|
||||
If you do refresh, redo Step 3.1 afterwards to repopulate `CopilotSummary`.
|
||||
|
||||
---
|
||||
|
||||
## 3.3 Group PRs by Label
|
||||
@@ -35,3 +55,4 @@ pwsh ./.github/skills/release-note-generation/scripts/group-prs-by-label.ps1 -Cs
|
||||
Creates `Generated Files/ReleaseNotes/grouped_csv/` with one CSV per label combination.
|
||||
|
||||
**Validation:** The `Unlabeled.csv` file should be minimal (ideally empty). If many PRs remain unlabeled, return to Step 2 (see [step2-labeling.md](./step2-labeling.md)).
|
||||
|
||||
|
||||
334
.github/skills/release-note-generation/scripts/prepare-release-assets.ps1
vendored
Normal file
334
.github/skills/release-note-generation/scripts/prepare-release-assets.ps1
vendored
Normal file
@@ -0,0 +1,334 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Prepares the binary assets for a PowerToys GitHub release: downloads the
|
||||
four installers (per-user/per-machine x x64/arm64) and the symbol archives
|
||||
from an ADO pipeline build, computes SHA256 hashes, and emits the
|
||||
"Installer Hashes" markdown table.
|
||||
|
||||
.DESCRIPTION
|
||||
Given an ADO Dart pipeline build id (e.g. from
|
||||
https://microsoft.visualstudio.com/Dart/_build/results?buildId=NNN),
|
||||
downloads the four installer EXEs and the per-arch symbol zips into a
|
||||
single per-version folder, then writes a hashes.md alongside them with a
|
||||
markdown table ready to paste into the GitHub release notes.
|
||||
|
||||
Requires: az login (Azure CLI authenticated), az devops extension.
|
||||
|
||||
.EXAMPLE
|
||||
.\prepare-release-assets.ps1 -BuildId 145505247
|
||||
.\prepare-release-assets.ps1 -BuildId 145505247 -OutputFolder D:\Releases
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$BuildId,
|
||||
|
||||
[string]$OutputFolder = "$env:USERPROFILE\Downloads",
|
||||
|
||||
[string]$Organization = "https://dev.azure.com/microsoft",
|
||||
[string]$Project = "Dart",
|
||||
|
||||
[string]$GitHubRepo = "microsoft/PowerToys"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$env:AZURE_CORE_NO_PROMPT = "true"
|
||||
|
||||
# --- Helpers -----------------------------------------------------------------
|
||||
|
||||
# Invoke an `az` CLI command and capture stderr in $script:LastAzError so
|
||||
# callers can surface the underlying message (expired login, blocked extension,
|
||||
# tenant policy, ...) instead of swallowing it with `2>$null`.
|
||||
function Invoke-Az {
|
||||
$tmpErr = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$output = & az @args 2>$tmpErr
|
||||
# Get-Content -Raw returns $null for an empty file, and calling .Trim()
|
||||
# on $null throws under $ErrorActionPreference = 'Stop' -- which would
|
||||
# turn every successful (no-stderr) az call into a fatal error. Guard
|
||||
# explicitly so $script:LastAzError is always a (possibly empty) string.
|
||||
$rawErr = Get-Content $tmpErr -Raw -ErrorAction SilentlyContinue
|
||||
$script:LastAzError = if ($null -eq $rawErr) { '' } else { $rawErr.Trim() }
|
||||
return $output
|
||||
}
|
||||
finally {
|
||||
Remove-Item $tmpErr -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# Build an ADO artifact download URL from scratch instead of regex-replacing
|
||||
# the URL returned by `az pipelines runs artifact list`. Preserves any other
|
||||
# query parameters and only swaps `format` and `subPath`, so we don't break if
|
||||
# the upstream URL shape ever changes.
|
||||
function Get-ArtifactDownloadUrl {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$BaseUrl,
|
||||
[Parameter(Mandatory)][string]$SubPath,
|
||||
[Parameter(Mandatory)][ValidateSet('file', 'zip')][string]$Format
|
||||
)
|
||||
$encodedSubPath = [Uri]::EscapeDataString($SubPath)
|
||||
$idx = $BaseUrl.IndexOf('?')
|
||||
if ($idx -lt 0) {
|
||||
return "${BaseUrl}?format=${Format}&subPath=${encodedSubPath}"
|
||||
}
|
||||
$base = $BaseUrl.Substring(0, $idx)
|
||||
$kept = $BaseUrl.Substring($idx + 1) -split '&' | Where-Object {
|
||||
$_ -and -not ($_ -match '^(format|subPath)=')
|
||||
}
|
||||
$kept = @($kept) + @("format=$Format", "subPath=$encodedSubPath")
|
||||
return "${base}?$($kept -join '&')"
|
||||
}
|
||||
|
||||
# Download a single ADO artifact file with bearer auth and a small retry/backoff
|
||||
# loop. A transient network blip on a ~200 MB installer or symbol zip otherwise
|
||||
# aborts the entire release-prep run.
|
||||
function Invoke-AdoDownload {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Url,
|
||||
[Parameter(Mandatory)][string]$DestPath,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[int]$MaxAttempts = 3
|
||||
)
|
||||
$lastError = $null
|
||||
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
|
||||
$webClient = New-Object System.Net.WebClient
|
||||
$webClient.Headers.Add("Authorization", "Bearer $Token")
|
||||
try {
|
||||
$webClient.DownloadFile($Url, $DestPath)
|
||||
return
|
||||
}
|
||||
catch {
|
||||
$lastError = $_
|
||||
if (Test-Path $DestPath) {
|
||||
Remove-Item $DestPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($attempt -lt $MaxAttempts) {
|
||||
$backoffSec = [int][Math]::Pow(2, $attempt) # 2, 4, 8 ...
|
||||
Write-Host " Attempt $attempt failed: $($_.Exception.Message). Retrying in ${backoffSec}s..." -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds $backoffSec
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$webClient.Dispose()
|
||||
}
|
||||
}
|
||||
throw "Download failed after $MaxAttempts attempts. Last error: $($lastError.Exception.Message)`nURL: $Url"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Work around broken az extensions: if the default extension dir has
|
||||
# inaccessible files, redirect to a clean directory.
|
||||
$defaultExtDir = "$env:USERPROFILE\.azure\cliextensions"
|
||||
if (-not $env:AZURE_EXTENSION_DIR -and (Test-Path $defaultExtDir)) {
|
||||
$broken = Get-ChildItem "$defaultExtDir\*\*.dist-info" -Directory -ErrorAction SilentlyContinue | Where-Object {
|
||||
try { [System.IO.Directory]::GetFiles($_.FullName) | Out-Null; $false } catch { $true }
|
||||
}
|
||||
if ($broken) {
|
||||
$cleanDir = "$env:USERPROFILE\.azure\cliextensions_clean"
|
||||
Write-Host " Detected broken az extension, redirecting to $cleanDir" -ForegroundColor Yellow
|
||||
$env:AZURE_EXTENSION_DIR = $cleanDir
|
||||
if (-not (Test-Path $cleanDir)) { New-Item -ItemType Directory -Path $cleanDir -Force | Out-Null }
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure azure-devops extension is installed
|
||||
$ext = Invoke-Az extension list --query "[?name=='azure-devops']" -o tsv
|
||||
if (-not $ext) {
|
||||
Write-Host "Installing azure-devops extension..." -ForegroundColor Yellow
|
||||
Invoke-Az extension add --name azure-devops --yes | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to install azure-devops extension. (az: $script:LastAzError)"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Configure az devops defaults
|
||||
Invoke-Az devops configure --defaults organization=$Organization project=$Project | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to configure az devops defaults. (az: $script:LastAzError)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Step 1: Get build info to determine version ---
|
||||
Write-Host "Fetching build $BuildId info..." -ForegroundColor Cyan
|
||||
$buildJson = Invoke-Az pipelines build show --id $BuildId --output json
|
||||
if (-not $buildJson) {
|
||||
Write-Error "Could not fetch build $BuildId. Are you logged in (az login)? (az: $script:LastAzError)"
|
||||
exit 1
|
||||
}
|
||||
$build = $buildJson | ConvertFrom-Json
|
||||
|
||||
$versionParam = $build.templateParameters.VersionNumber
|
||||
if (-not $versionParam) {
|
||||
Write-Error "Could not determine version from build $BuildId"
|
||||
exit 1
|
||||
}
|
||||
Write-Host " Version: $versionParam" -ForegroundColor DarkGray
|
||||
|
||||
# --- Step 2: Get artifact metadata once ---
|
||||
Write-Host "Fetching artifact metadata..." -ForegroundColor Cyan
|
||||
$artifactsJson = Invoke-Az pipelines runs artifact list --run-id $BuildId --output json
|
||||
if (-not $artifactsJson) {
|
||||
Write-Error "Could not list artifacts for build $BuildId. (az: $script:LastAzError)"
|
||||
exit 1
|
||||
}
|
||||
$artifacts = $artifactsJson | ConvertFrom-Json
|
||||
|
||||
# --- Step 3: Prepare destination folder ---
|
||||
$destFolder = Join-Path $OutputFolder "PowerToys-v$versionParam"
|
||||
if (-not (Test-Path $destFolder)) {
|
||||
New-Item -ItemType Directory -Path $destFolder -Force | Out-Null
|
||||
}
|
||||
Write-Host " Destination: $destFolder" -ForegroundColor DarkGray
|
||||
|
||||
# --- Step 4: Get an ADO access token once ---
|
||||
$token = Invoke-Az account get-access-token --resource "499b84ac-1321-427f-aa17-267ca6975798" --query accessToken -o tsv
|
||||
if (-not $token) {
|
||||
Write-Error "Failed to acquire ADO access token. Run 'az login' first. (az: $script:LastAzError)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Step 5: Define the four installers to download ---
|
||||
$targets = @(
|
||||
[pscustomobject]@{ Description = "Per user - x64"; Scope = "perUser"; Arch = "x64"; Artifact = "build-x64-Release"; FileName = "PowerToysUserSetup-$versionParam-x64.exe" }
|
||||
[pscustomobject]@{ Description = "Per user - ARM64"; Scope = "perUser"; Arch = "arm64"; Artifact = "build-arm64-Release"; FileName = "PowerToysUserSetup-$versionParam-arm64.exe" }
|
||||
[pscustomobject]@{ Description = "Machine wide - x64"; Scope = "perMachine"; Arch = "x64"; Artifact = "build-x64-Release"; FileName = "PowerToysSetup-$versionParam-x64.exe" }
|
||||
[pscustomobject]@{ Description = "Machine wide - ARM64"; Scope = "perMachine"; Arch = "arm64"; Artifact = "build-arm64-Release"; FileName = "PowerToysSetup-$versionParam-arm64.exe" }
|
||||
)
|
||||
|
||||
# --- Step 6: Download each installer (skip if already present) ---
|
||||
foreach ($t in $targets) {
|
||||
$destPath = Join-Path $destFolder $t.FileName
|
||||
|
||||
if (Test-Path $destPath) {
|
||||
$sizeMB = [math]::Round((Get-Item $destPath).Length / 1MB, 1)
|
||||
Write-Host "[skip] $($t.FileName) already exists ($sizeMB MB)" -ForegroundColor DarkGray
|
||||
continue
|
||||
}
|
||||
|
||||
$artifact = $artifacts | Where-Object { $_.name -eq $t.Artifact }
|
||||
if (-not $artifact) {
|
||||
Write-Error "Artifact '$($t.Artifact)' not found in build $BuildId. Available: $(($artifacts | ForEach-Object name) -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$fileUrl = Get-ArtifactDownloadUrl -BaseUrl $artifact.resource.downloadUrl -SubPath "/$($t.FileName)" -Format file
|
||||
|
||||
Write-Host "Downloading $($t.FileName) ..." -ForegroundColor Cyan
|
||||
try {
|
||||
Invoke-AdoDownload -Url $fileUrl -DestPath $destPath -Token $token
|
||||
}
|
||||
catch {
|
||||
Write-Error "Download failed for $($t.FileName): $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$sizeMB = [math]::Round((Get-Item $destPath).Length / 1MB, 1)
|
||||
Write-Host " Saved ($sizeMB MB)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- Step 6b: Download symbols (one zip per arch) ---
|
||||
$symbolTargets = @(
|
||||
[pscustomobject]@{ Arch = "x64"; Artifact = "build-x64-Release"; SubPath = "/symbols-x64" }
|
||||
[pscustomobject]@{ Arch = "arm64"; Artifact = "build-arm64-Release"; SubPath = "/symbols-arm64" }
|
||||
)
|
||||
|
||||
foreach ($s in $symbolTargets) {
|
||||
$finalZip = Join-Path $destFolder "symbols-$($s.Arch).zip"
|
||||
if (Test-Path $finalZip) {
|
||||
$sizeMB = [math]::Round((Get-Item $finalZip).Length / 1MB, 1)
|
||||
Write-Host "[skip] symbols-$($s.Arch).zip already exists ($sizeMB MB)" -ForegroundColor DarkGray
|
||||
continue
|
||||
}
|
||||
|
||||
$artifact = $artifacts | Where-Object { $_.name -eq $s.Artifact }
|
||||
if (-not $artifact) {
|
||||
Write-Error "Artifact '$($s.Artifact)' not found in build $BuildId."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Symbols are downloaded as a folder => keep format=zip and append subPath
|
||||
$symbolsUrl = Get-ArtifactDownloadUrl -BaseUrl $artifact.resource.downloadUrl -SubPath $s.SubPath -Format zip
|
||||
|
||||
$tmpZip = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-$($s.Arch)-$([Guid]::NewGuid().ToString('N')).zip")
|
||||
$tmpExtract = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-$($s.Arch)-$([Guid]::NewGuid().ToString('N'))")
|
||||
$stageRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-stage-$([Guid]::NewGuid().ToString('N'))")
|
||||
|
||||
try {
|
||||
Write-Host "Downloading symbols-$($s.Arch).zip ..." -ForegroundColor Cyan
|
||||
try {
|
||||
Invoke-AdoDownload -Url $symbolsUrl -DestPath $tmpZip -Token $token
|
||||
}
|
||||
catch {
|
||||
Write-Error "Symbols download failed for $($s.Arch): $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host " Extracting..." -ForegroundColor DarkGray
|
||||
Expand-Archive -Path $tmpZip -DestinationPath $tmpExtract -Force
|
||||
|
||||
# Walk down while the current dir holds exactly one subfolder and no files.
|
||||
$current = Get-Item $tmpExtract
|
||||
while ($true) {
|
||||
$children = Get-ChildItem -LiteralPath $current.FullName -Force
|
||||
$subDirs = @($children | Where-Object { $_.PSIsContainer })
|
||||
$files = @($children | Where-Object { -not $_.PSIsContainer })
|
||||
if ($subDirs.Count -eq 1 -and $files.Count -eq 0) {
|
||||
$current = $subDirs[0]
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Stage to a folder named symbols-<arch> so the zip extracts to that name.
|
||||
$stageInner = Join-Path $stageRoot "symbols-$($s.Arch)"
|
||||
New-Item -ItemType Directory -Path $stageInner -Force | Out-Null
|
||||
Get-ChildItem -LiteralPath $current.FullName -Force | ForEach-Object {
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $stageInner -Recurse -Force
|
||||
}
|
||||
|
||||
Write-Host " Repacking to $finalZip ..." -ForegroundColor DarkGray
|
||||
if (Test-Path $finalZip) { Remove-Item $finalZip -Force }
|
||||
Compress-Archive -Path "$stageInner\*" -DestinationPath $finalZip -CompressionLevel Optimal
|
||||
|
||||
$sizeMB = [math]::Round((Get-Item $finalZip).Length / 1MB, 1)
|
||||
Write-Host " Saved symbols-$($s.Arch).zip ($sizeMB MB)" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
# Don't leave a half-built zip behind if anything in the pipeline blew up.
|
||||
if (Test-Path $finalZip) { Remove-Item $finalZip -Force -ErrorAction SilentlyContinue }
|
||||
throw
|
||||
}
|
||||
finally {
|
||||
Remove-Item $tmpZip -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $tmpExtract -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# --- Step 7: Compute SHA256 and build markdown ---
|
||||
Write-Host "`nComputing SHA256 hashes..." -ForegroundColor Cyan
|
||||
|
||||
$sb = [System.Text.StringBuilder]::new()
|
||||
[void]$sb.AppendLine("## Installer Hashes")
|
||||
[void]$sb.AppendLine("")
|
||||
[void]$sb.AppendLine("| Description | Filename | sha256 hash |")
|
||||
[void]$sb.AppendLine("| --- | --- | --- |")
|
||||
|
||||
foreach ($t in $targets) {
|
||||
$destPath = Join-Path $destFolder $t.FileName
|
||||
$hash = (Get-FileHash -Path $destPath -Algorithm SHA256).Hash.ToUpper()
|
||||
[void]$sb.AppendLine("| $($t.Description) | $($t.FileName) | $hash |")
|
||||
Write-Host " $($t.FileName) $hash" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
$markdown = $sb.ToString()
|
||||
$mdPath = Join-Path $destFolder "hashes.md"
|
||||
Set-Content -Path $mdPath -Value $markdown -Encoding UTF8
|
||||
|
||||
Write-Host "`nMarkdown written to: $mdPath" -ForegroundColor Green
|
||||
Write-Host "`n----- Installer Hashes -----`n" -ForegroundColor Yellow
|
||||
Write-Host $markdown
|
||||
|
||||
Write-Host "Draft a new GitHub release at: https://github.com/$GitHubRepo/releases/new?tag=v$versionParam" -ForegroundColor Green
|
||||
5
.github/workflows/auto-label-issues.yml
vendored
5
.github/workflows/auto-label-issues.yml
vendored
@@ -29,6 +29,11 @@ jobs:
|
||||
steps:
|
||||
- name: Apply area labels with AI
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
# actions/github-script does not propagate `github-token` to
|
||||
# process.env. Expose it explicitly so the inline script can
|
||||
# authenticate against the GitHub Models inference endpoint.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
- template: steps-ensure-dotnet-version.yml
|
||||
parameters:
|
||||
sdk: true
|
||||
version: '9.0'
|
||||
version: '10.0'
|
||||
|
||||
- template: .\steps-restore-nuget.yml
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ stages:
|
||||
name: SHINE-INT-L
|
||||
${{ else }}:
|
||||
name: SHINE-OSS-L
|
||||
demands: ImageOverride -equals SHINE-VS18-Latest
|
||||
buildPlatforms:
|
||||
- ${{ parameters.platform }}
|
||||
uiTestModules: ${{ parameters.uiTestModules }}
|
||||
|
||||
@@ -65,4 +65,20 @@
|
||||
<!-- Note: For C++ skipped test projects, build is effectively skipped by removing all compile items above.
|
||||
We don't define empty Build/Rebuild/Clean targets here because MSBuild Target definitions with Condition
|
||||
on the Target element still override the default targets even when condition is false. -->
|
||||
|
||||
<!-- Clean up unused VC++ runtime DLLs that CopyCppRuntimeToOutputDir copies from the full
|
||||
VCRedist tree (MFC, C++ AMP, OpenMP). No PowerToys binary links against these — verified
|
||||
with dumpbin /dependents across all installed binaries. -->
|
||||
<Target Name="RemoveUnusedVCRuntimeDlls"
|
||||
AfterTargets="Build"
|
||||
Condition="'$(CopyCppRuntimeToOutputDir)' == 'true' and '$(MSBuildProjectExtension)' == '.vcxproj'">
|
||||
<ItemGroup>
|
||||
<_UnusedVCRuntimeDlls Include="$(OutDir)mfc140*.dll" />
|
||||
<_UnusedVCRuntimeDlls Include="$(OutDir)mfcm140*.dll" />
|
||||
<_UnusedVCRuntimeDlls Include="$(OutDir)vcamp140*.dll" />
|
||||
<_UnusedVCRuntimeDlls Include="$(OutDir)vcomp140*.dll" />
|
||||
</ItemGroup>
|
||||
<Delete Files="@(_UnusedVCRuntimeDlls)" Condition="'@(_UnusedVCRuntimeDlls)' != ''" />
|
||||
<Message Importance="normal" Text="Cleaned up unused VC runtime DLLs: @(_UnusedVCRuntimeDlls)" Condition="'@(_UnusedVCRuntimeDlls)' != ''" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -110,8 +110,6 @@
|
||||
<PackageVersion Include="System.ComponentModel.Composition" Version="10.0.7" />
|
||||
<PackageVersion Include="System.Configuration.ConfigurationManager" Version="10.0.7" />
|
||||
<PackageVersion Include="System.Data.OleDb" Version="10.0.7" />
|
||||
<!-- Package System.Data.SqlClient added to force it as a dependency of Microsoft.Windows.Compatibility to the latest version available at this time. -->
|
||||
<PackageVersion Include="System.Data.SqlClient" Version="4.9.1" />
|
||||
<!-- Package System.Diagnostics.EventLog added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Data.OleDb but the 8.0.1 version wasn't published to nuget. -->
|
||||
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.7" />
|
||||
<!-- Package System.Diagnostics.PerformanceCounter added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.11. -->
|
||||
|
||||
@@ -206,6 +206,10 @@
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Actions/Microsoft.CmdPal.Ext.Actions.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
|
||||
94
deps/spdlog-msvc-fix/include/spdlog-msvc-fix.h
vendored
Normal file
94
deps/spdlog-msvc-fix/include/spdlog-msvc-fix.h
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
// spdlog-msvc-fix.h
|
||||
//
|
||||
// Workaround for MSVC 14.51 (compiler version 19.51, _MSC_VER >= 1951) removing
|
||||
// stdext::checked_array_iterator. Force-included for all spdlog consumers via
|
||||
// deps/spdlog.props, because spdlog v1.8.5's bundled fmt format.h(357) still
|
||||
// references this type inside #if defined(_SECURE_SCL) && _SECURE_SCL -- a
|
||||
// branch entered in Debug builds where _ITERATOR_DEBUG_LEVEL > 0.
|
||||
//
|
||||
// On MSVC 14.50 and earlier, the type still exists in <iterator>, so this shim
|
||||
// is a no-op via the _MSC_VER guard. On MSVC 14.51+, it provides a minimal
|
||||
// pointer-backed substitute that satisfies the bundled fmt's usage:
|
||||
//
|
||||
// template <typename T> using checked_ptr = stdext::checked_array_iterator<T*>;
|
||||
// template <typename T> checked_ptr<T> make_checked(T* p, size_t size) {
|
||||
// return {p, size};
|
||||
// }
|
||||
// ... return make_checked(get_data(c) + size, n);
|
||||
//
|
||||
// When deps/spdlog is bumped past v1.14 (which ships fmt 10.2 and drops this
|
||||
// dependency), this shim and its <ForcedIncludeFiles> entry in deps/spdlog.props
|
||||
// can be deleted.
|
||||
|
||||
#pragma once
|
||||
|
||||
#if defined(__cplusplus) && defined(_MSC_VER) && _MSC_VER >= 1951
|
||||
|
||||
#include <cstddef>
|
||||
#include <iterator>
|
||||
#include <type_traits>
|
||||
|
||||
namespace stdext
|
||||
{
|
||||
template <typename _Ptr>
|
||||
class checked_array_iterator
|
||||
{
|
||||
_Ptr _Myarray = nullptr;
|
||||
std::size_t _Mysize = 0;
|
||||
std::size_t _Myindex = 0;
|
||||
|
||||
public:
|
||||
using iterator_category = std::random_access_iterator_tag;
|
||||
using value_type = std::remove_cv_t<std::remove_pointer_t<_Ptr>>;
|
||||
using difference_type = std::ptrdiff_t;
|
||||
using pointer = _Ptr;
|
||||
using reference = std::remove_pointer_t<_Ptr>&;
|
||||
|
||||
constexpr checked_array_iterator() = default;
|
||||
|
||||
constexpr checked_array_iterator(_Ptr arr, std::size_t size, std::size_t idx = 0) noexcept
|
||||
: _Myarray(arr), _Mysize(size), _Myindex(idx)
|
||||
{
|
||||
}
|
||||
|
||||
constexpr reference operator*() const noexcept { return _Myarray[_Myindex]; }
|
||||
constexpr pointer operator->() const noexcept { return _Myarray + _Myindex; }
|
||||
constexpr reference operator[](difference_type n) const noexcept
|
||||
{
|
||||
return _Myarray[_Myindex + static_cast<std::size_t>(n)];
|
||||
}
|
||||
|
||||
constexpr checked_array_iterator& operator++() noexcept { ++_Myindex; return *this; }
|
||||
constexpr checked_array_iterator operator++(int) noexcept { auto t = *this; ++_Myindex; return t; }
|
||||
constexpr checked_array_iterator& operator--() noexcept { --_Myindex; return *this; }
|
||||
constexpr checked_array_iterator operator--(int) noexcept { auto t = *this; --_Myindex; return t; }
|
||||
|
||||
constexpr checked_array_iterator& operator+=(difference_type n) noexcept
|
||||
{
|
||||
_Myindex = static_cast<std::size_t>(static_cast<difference_type>(_Myindex) + n);
|
||||
return *this;
|
||||
}
|
||||
constexpr checked_array_iterator& operator-=(difference_type n) noexcept
|
||||
{
|
||||
_Myindex = static_cast<std::size_t>(static_cast<difference_type>(_Myindex) - n);
|
||||
return *this;
|
||||
}
|
||||
|
||||
friend constexpr checked_array_iterator operator+(checked_array_iterator it, difference_type n) noexcept { it += n; return it; }
|
||||
friend constexpr checked_array_iterator operator+(difference_type n, checked_array_iterator it) noexcept { return it + n; }
|
||||
friend constexpr checked_array_iterator operator-(checked_array_iterator it, difference_type n) noexcept { it -= n; return it; }
|
||||
friend constexpr difference_type operator-(checked_array_iterator a, checked_array_iterator b) noexcept
|
||||
{
|
||||
return static_cast<difference_type>(a._Myindex) - static_cast<difference_type>(b._Myindex);
|
||||
}
|
||||
|
||||
friend constexpr bool operator==(checked_array_iterator a, checked_array_iterator b) noexcept { return a._Myindex == b._Myindex; }
|
||||
friend constexpr bool operator!=(checked_array_iterator a, checked_array_iterator b) noexcept { return !(a == b); }
|
||||
friend constexpr bool operator<(checked_array_iterator a, checked_array_iterator b) noexcept { return a._Myindex < b._Myindex; }
|
||||
friend constexpr bool operator>(checked_array_iterator a, checked_array_iterator b) noexcept { return b < a; }
|
||||
friend constexpr bool operator<=(checked_array_iterator a, checked_array_iterator b) noexcept { return !(b < a); }
|
||||
friend constexpr bool operator>=(checked_array_iterator a, checked_array_iterator b) noexcept { return !(a < b); }
|
||||
};
|
||||
} // namespace stdext
|
||||
|
||||
#endif // __cplusplus && _MSC_VER >= 1951
|
||||
1
deps/spdlog.props
vendored
1
deps/spdlog.props
vendored
@@ -3,6 +3,7 @@
|
||||
<ClCompile>
|
||||
<AdditionalIncludeDirectories>$(MSBuildThisFileDirectory)spdlog\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
<PreprocessorDefinitions>SPDLOG_WCHAR_TO_UTF8_SUPPORT;SPDLOG_COMPILED_LIB;SPDLOG_WCHAR_FILENAMES;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ForcedIncludeFiles>$(MSBuildThisFileDirectory)spdlog-msvc-fix\include\spdlog-msvc-fix.h;%(ForcedIncludeFiles)</ForcedIncludeFiles>
|
||||
</ClCompile>
|
||||
</ItemDefinitionGroup>
|
||||
</Project>
|
||||
|
||||
@@ -60,7 +60,7 @@ A markdown page is a page inside of command palette that displays markdown conte
|
||||
|
||||
```csharp
|
||||
interface IMarkdownPage requires IPage {
|
||||
String[] Bodies(); // TODO! should this be an IBody, so we can make it observable?
|
||||
String[] Bodies();
|
||||
IDetails Details();
|
||||
IContextItem[] Commands { get; };
|
||||
}
|
||||
|
||||
167
doc/devdocs/modules/cmdpal/extension-gallery.md
Normal file
167
doc/devdocs/modules/cmdpal/extension-gallery.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Command Palette Extension Gallery
|
||||
|
||||
This document describes how Command Palette (CmdPal) discovers extensions for
|
||||
the in-app **Extension gallery** page.
|
||||
|
||||
## At a glance
|
||||
|
||||
- The gallery loads a single JSON feed called `extensions.json` from a remote
|
||||
HTTPS URL, parses it, and renders the entries.
|
||||
- The default feed lives in the external repo
|
||||
**`microsoft/CmdPal-Extensions`** at
|
||||
`https://aka.ms/CmdPal-ExtensionsJson`.
|
||||
- Feed content + icon images are cached on disk so the page works offline and
|
||||
survives short network hiccups.
|
||||
- There is no WinGet discovery, no per-extension `manifest.json` fetch, and no
|
||||
other network call for rendering the list.
|
||||
|
||||
## Implementation pointers
|
||||
|
||||
| Concern | File |
|
||||
| --- | --- |
|
||||
| Fetching, parsing, caching, pruning | `Microsoft.CmdPal.Common/ExtensionGallery/Services/ExtensionGalleryService.cs` |
|
||||
| Resolving which URL to fetch | `Microsoft.CmdPal.Common/ExtensionGallery/Services/GalleryFeedUrlProvider.cs` + `Microsoft.CmdPal.UI/Helpers/GalleryServiceRegistration.cs` |
|
||||
| HTTP + on-disk cache | `Microsoft.CmdPal.Common/ExtensionGallery/Services/ExtensionGalleryHttpClient.cs` (wraps `Microsoft.CmdPal.Common/Services/HttpCaching/HttpCachingClient`) |
|
||||
| Feed + entry models | `Microsoft.CmdPal.Common/ExtensionGallery/Models/` |
|
||||
|
||||
## Feed URL resolution
|
||||
|
||||
`ExtensionGalleryService.GetFeedUrl()` returns, in order:
|
||||
|
||||
1. The user-configured URL from CmdPal settings (`SettingsModel.GalleryFeedUrl`,
|
||||
exposed via the hidden `InternalPage` settings page). Any non-empty value
|
||||
wins. Mostly used for local testing against a custom feed.
|
||||
2. Otherwise, the built-in default
|
||||
`https://aka.ms/CmdPal-ExtensionsJson`.
|
||||
|
||||
Local `file://` URIs are allowed too — `FetchFeedDocumentAsync` reads the file
|
||||
directly and bypasses the HTTP cache.
|
||||
|
||||
## Feed format
|
||||
|
||||
The feed is a single wrapped JSON document with inline entries:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/microsoft/CmdPal-Extensions/main/.github/schemas/gallery.schema.json",
|
||||
"extensions": [
|
||||
{
|
||||
"id": "sample-extension",
|
||||
"title": "Sample Extension",
|
||||
"description": "A sample extension demonstrating the gallery feed format.",
|
||||
"author": { "name": "Microsoft", "url": "https://github.com/microsoft" },
|
||||
"homepage": "https://github.com/microsoft/CmdPal-Extensions",
|
||||
"iconUrl": "https://.../icon.png",
|
||||
"screenshotUrls": ["https://.../screenshot-1.png"],
|
||||
"tags": ["sample"],
|
||||
"installSources": [
|
||||
{ "type": "winget", "id": "Contoso.SampleExtension" },
|
||||
{ "type": "msstore", "id": "9P…" },
|
||||
{ "type": "url", "uri": "https://github.com/contoso/sample/releases/latest" }
|
||||
],
|
||||
"detection": { "packageFamilyName": "Contoso.SampleExtension_1234567890abc" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Only the `extensions` array is read at runtime. The authoritative JSON
|
||||
schema for an entry lives in the upstream feed repo
|
||||
([`microsoft/CmdPal-Extensions`](https://github.com/microsoft/CmdPal-Extensions));
|
||||
don't duplicate it here — it drifts.
|
||||
|
||||
### Required + optional entry fields
|
||||
|
||||
| Field | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | yes | Lowercase stable identifier; entries with empty id are dropped. |
|
||||
| `title` | yes | Display name. |
|
||||
| `description` | yes | Shown in list and detail views. |
|
||||
| `author.name` | yes | `author.url` optional. |
|
||||
| `installSources` | yes | At least one entry; see [Install sources](#install-sources). |
|
||||
| `homepage`, `iconUrl`, `screenshotUrls`, `tags`, `detection.packageFamilyName` | no | All optional. |
|
||||
|
||||
Relative `iconUrl` / `screenshotUrls` are resolved against the feed URL's
|
||||
directory (useful only for local / `file://` feeds during development).
|
||||
|
||||
## Install sources
|
||||
|
||||
Each entry's `installSources` is consumed by
|
||||
`ExtensionGalleryItemViewModel` to decide which install affordances to show.
|
||||
|
||||
| `type` | Required field | Behaviour |
|
||||
| --- | --- | --- |
|
||||
| `winget` | `id` | Enables the "Install via WinGet" button (uses the shared WinGet service), and joins in-flight install progress + installed/update status. |
|
||||
| `msstore` | `id` | Opens `ms-windows-store://pdp/?ProductId={id}`. |
|
||||
| `url` | `uri` | Shown as a "GitHub" or "Website" link depending on host. |
|
||||
|
||||
An entry can declare any combination. Sources the runtime does not recognise
|
||||
are surfaced as an "unknown source" indicator.
|
||||
|
||||
## Fetching and caching
|
||||
|
||||
`ExtensionGalleryService` uses `ExtensionGalleryHttpClient`, which wraps
|
||||
`HttpCachingClient` over a file-system cache. Both the feed JSON and any
|
||||
cacheable icon URLs are cached.
|
||||
|
||||
| Setting | Value | Defined in |
|
||||
| --- | --- | --- |
|
||||
| Cache root | `{AppCache}\GalleryCache\` | `ExtensionGalleryHttpClient.CacheDirectoryName` |
|
||||
| Feed TTL | 4 hours | `ExtensionGalleryHttpClient.DefaultTimeToLive` |
|
||||
| Icon TTL | 24 hours | `ExtensionGalleryService.IconCacheTtl` |
|
||||
| HTTP timeout | 15 s | `ExtensionGalleryHttpClient` |
|
||||
| `User-Agent` | `PowerToys-CmdPal/1.0` | `ExtensionGalleryHttpClient` |
|
||||
|
||||
`{AppCache}` resolves to `ApplicationData.Current.LocalCacheFolder` when
|
||||
CmdPal runs packaged, and to
|
||||
`%LOCALAPPDATA%\Microsoft\PowerToys\Microsoft.CmdPal\Cache\` when unpackaged
|
||||
(see `ApplicationInfoService.DetermineCacheDirectory`).
|
||||
|
||||
### Fetch flow
|
||||
|
||||
`GetExtensionsAsync` (normal load) and `RefreshAsync` (user-initiated
|
||||
refresh, `forceRefresh: true`) both go through `FetchWrappedFeedAsync`:
|
||||
|
||||
1. Resolve the feed URL (see above).
|
||||
2. If the URL is local, read it from disk. Otherwise, hand it to
|
||||
`HttpCachingClient.GetResourceAsync` which:
|
||||
- Serves a fresh cached copy if one exists and TTL has not elapsed.
|
||||
- Otherwise issues a conditional GET (ETag / `If-None-Match`). On `304
|
||||
Not Modified` it refreshes the cache metadata and returns the cached
|
||||
body.
|
||||
- On network failure it returns the last-known cached body with
|
||||
`UsedFallbackCache = true`, so the UI can show a "stale data" banner.
|
||||
3. Parse the JSON with the source-generated `GallerySerializationContext`
|
||||
(strongly-typed `GalleryRemoteIndex` — no reflection, AOT-friendly).
|
||||
4. Drop entries with missing `id`, normalize relative `iconUrl` and
|
||||
`screenshotUrls`, and resolve remote icon URIs through the same HTTP
|
||||
cache so the UI binds to local `file://` URIs.
|
||||
5. On a successful forced refresh, `PruneCachedResources` deletes cache
|
||||
entries that are no longer referenced by the current feed (old feed URL
|
||||
and icon URLs that dropped out of the feed).
|
||||
|
||||
### Fetch result flags
|
||||
|
||||
`GetExtensionsAsync` returns a `GalleryFetchResult` that the view model uses
|
||||
for UI hints:
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `FromCache` | The feed came from cache without hitting the network (TTL still valid). |
|
||||
| `UsedFallbackCache` | A network request was attempted and failed, and the cached copy was served as fallback. The UI shows a stale-data info bar. |
|
||||
| `RateLimited` | The origin returned `429 Too Many Requests` and no fallback was available. The UI shows a rate-limit error. |
|
||||
|
||||
## Authoring
|
||||
|
||||
- Entries for the production gallery are added to the feed repo
|
||||
`microsoft/CmdPal-Extensions`.
|
||||
- For editor validation of an entry, reference the schema published in the
|
||||
upstream repo via the entry's `$schema` field.
|
||||
- Keep `id` stable once an extension is published — users may have it
|
||||
installed and the gallery keys install status by id.
|
||||
- Prefer providing a `winget` source when the extension ships through App
|
||||
Installer; the gallery uses it both for status ("Installed" / "Update
|
||||
available") and for the in-app install button.
|
||||
- `detection.packageFamilyName` lets the gallery recognise an
|
||||
already-installed packaged extension before WinGet metadata resolves.
|
||||
|
||||
@@ -76,6 +76,13 @@ functionality.
|
||||
- [Status messages](#status-messages)
|
||||
- [Rendering of ICommandItems in Lists and Menus](#rendering-of-icommanditems-in-lists-and-menus)
|
||||
- [Addenda I: API additions (ICommandProvider2)](#addenda-i-api-additions-icommandprovider2)
|
||||
- [Addenda II: Commands with Parameters](#addenda-ii-commands-with-parameters)
|
||||
- [String parameters](#string-parameters)
|
||||
- [Command parameters - Invokable Commands](#command-parameters---invokable-commands)
|
||||
- [Command parameters - List Commands](#command-parameters---list-commands)
|
||||
- [Examples](#examples)
|
||||
- [Addenda III: Rich Search (DRAFT)](#addenda-iii-rich-search-draft)
|
||||
- [Nov 2025 status](#nov-2025-status)
|
||||
- [Addenda IV: Dock bands](#addenda-iv-dock-bands)
|
||||
- [Pinning nested commands to the dock (and top level)](#pinning-nested-commands-to-the-dock-and-top-level)
|
||||
- [Class diagram](#class-diagram)
|
||||
@@ -2048,6 +2055,183 @@ Fortunately, we can put all of that (`GetApiExtensionStubs`,
|
||||
developers won't have to do anything. The toolkit will just do the right thing
|
||||
for them.
|
||||
|
||||
## Addenda II: Commands with Parameters
|
||||
|
||||
Extensions will often want to provide commands that accept parameters from the
|
||||
user.
|
||||
|
||||
To support this, we're adding a new page type. The `IParametersPage` is a page
|
||||
that allows an extension to define a set of parameters that the user can fill.
|
||||
These parameters can be of different types, such as:
|
||||
* Labels: static text that provides context or instructions.
|
||||
* String parameters: text input fields where the user can type a string.
|
||||
* Command parameters: interactive fields that allow the user to select from a
|
||||
list of predefined commands, or just press a button to select an input.
|
||||
|
||||
Interleaving labels with parameters allows extensions to create rich, guided
|
||||
input forms for their commands. These are a more lightweight solution than the
|
||||
current adaptive card content.
|
||||
|
||||
```csharp
|
||||
[uuid("a2590cc9-510c-4af7-b562-a6b56fe37f55")]
|
||||
interface IParameterRun requires INotifyPropChanged
|
||||
{
|
||||
};
|
||||
|
||||
interface ILabelRun requires IParameterRun
|
||||
{
|
||||
String Text{ get; };
|
||||
};
|
||||
|
||||
interface IParameterValueRun requires IParameterRun
|
||||
{
|
||||
String PlaceholderText{ get; };
|
||||
Boolean NeedsValue{ get; }; // TODO! name is weird
|
||||
};
|
||||
|
||||
interface IStringParameterRun requires IParameterValueRun
|
||||
{
|
||||
String Text{ get; set; };
|
||||
|
||||
// TODO! do we need a way to validate string inputs?
|
||||
};
|
||||
|
||||
interface ICommandParameterRun requires IParameterValueRun
|
||||
{
|
||||
String DisplayText{ get; };
|
||||
ICommand GetSelectValueCommand(UInt64 hostHwnd);
|
||||
IIconInfo Icon{ get; }; // ? maybe
|
||||
|
||||
};
|
||||
|
||||
interface IParametersPage requires IPage
|
||||
{
|
||||
IParameterRun[] Parameters{ get; };
|
||||
IListItem Command{ get; };
|
||||
};
|
||||
```
|
||||
|
||||
When we open a `IParametersPage`, we will render the `Parameters` in the search
|
||||
box. We'll move focus to the first `IParameterRun` that is not a `ILabelRun`.
|
||||
What those interactions looks like depends on the type of `IParameterRun`.
|
||||
|
||||
There are three basic types of inputs: strings, invokable commands, and lists.
|
||||
Strings are a special case that doesn't require a command to set the value.
|
||||
Lists and invokable commands are picked based on the type of the
|
||||
`SelectValueCommand`. Each of these are detailed below.
|
||||
|
||||
When all the parameters have `NeedsValue` set to `false`, we will display a
|
||||
single item to the user - the `Command` item.
|
||||
|
||||
### String parameters
|
||||
|
||||
These are rendered as a text box within the search box. The user can type into
|
||||
it. Focus is moved to the next parameter when the user presses Enter or tab.
|
||||
|
||||
### Command parameters - Invokable Commands
|
||||
|
||||
These are used when the `SelectValueCommand` is an `IInvokableCommand`.
|
||||
|
||||
These are rendered as a button within the search box. The button text is
|
||||
`DisplayText` if it is set. If it is not, we will display the
|
||||
`PlaceholderText`. If the user clicks the button, we invoke the
|
||||
`SelectValueCommand` (and ignore the `CommandResult`).
|
||||
|
||||
This is good for file pickers, date pickers, color pickers, etc. Anything that
|
||||
requires a custom UI to pick a value.
|
||||
|
||||
When the extension has picked a value, it should set the `NeedsValue` to false.
|
||||
The extension can also set the `DisplayText` and `Icon` to reflect the chosen value.
|
||||
|
||||
When the user presses enter with the button focused, we will also invoke the
|
||||
`SelectValueCommand`.
|
||||
|
||||
When the user presses tab, we will move focus to the next parameter.
|
||||
|
||||
If the `NeedsValue` property is changed to `false` while it's focused, we will
|
||||
move focus to the next parameter.
|
||||
|
||||
### Command parameters - List Commands
|
||||
|
||||
These are used when the `SelectValueCommand` is an `IListPage` - both static and
|
||||
dynamic lists work similarly.
|
||||
|
||||
These are rendered as a text box within the search box. When the user focuses
|
||||
the text box, we will display the items from the `IListPage` in the body of
|
||||
CmdPal. The user can then type to filter the list. This filtering will work the
|
||||
same way as any other list page in CmdPal - CmdPal will filter static lists, or
|
||||
pass the query to a dynamic list.
|
||||
|
||||
The items in this list should all be `IListItem` objects with
|
||||
`IInvokableCommands`. Putting a `IPage` into one of these items will cause the
|
||||
user to navigate away from the parameters page, which would probably be
|
||||
unexpected.
|
||||
|
||||
When the user picks an item from the list, the extension should handle that
|
||||
command by bubbling an event up to the `CommandRun`, and setting the `Value`,
|
||||
`DisplayText`, and `Icon` properties, and setting `NeedsValue` to false.
|
||||
|
||||
When the user presses enter with the text box focused, we will invoke the
|
||||
command of the selected item in the list.
|
||||
|
||||
When the user presses tab, we will move focus to the next parameter.
|
||||
|
||||
If the `NeedsValue` property is changed to `false` while it's focused, we will
|
||||
move focus to the next parameter.
|
||||
|
||||
### Examples
|
||||
|
||||
Lets say you had a command like "Create a note \${title} in \${folder}".
|
||||
`title` is a string input, and `folder` is a static list of folders.
|
||||
|
||||
The extension author can then define a `IParametersPage` with four runs in it:
|
||||
* A `ILabelRun` for "Create a note"
|
||||
* A `IStringParameterRun` for the `title`
|
||||
* A `ILabelRun` for "in"
|
||||
* A `ICommandParameterRun` for the `folder`. The `Command` will be a
|
||||
`IListPage`, where the items are possible folders
|
||||
|
||||
In this example, the user can pick the "create note" command, then type the
|
||||
title, hit enter/tab, and then pick a folder from the list, then hit enter to
|
||||
run the command.
|
||||
|
||||
Samples for the parameters page are implemented over in
|
||||
[the sample extension](../../ext/SamplePagesExtension/Pages/ParameterSamples.cs)
|
||||
|
||||
|
||||
## Addenda III: Rich Search (DRAFT)
|
||||
|
||||
> [!NOTE]
|
||||
> _Mike_: Rich search and parameters were prototyped together, but ultimately we used two different solutions.
|
||||
>
|
||||
> Currently, we have a dummy implementation of draft C (ZWSP tokens), but without full API changes. Detailed [below](#nov-2025-status).
|
||||
|
||||
Extensions will often want to provide rich search experiences for their users.
|
||||
|
||||
This addenda is broken into multiple draft specs currently. These represent
|
||||
different approaches to the same goals.
|
||||
|
||||
* **A**: [Rich Search Box](./drafts/RichSearchBox-draft-A.md)
|
||||
* **B**: [Prefix Search](./drafts/PrefixSearch-draft-B.md)
|
||||
* **C**: [ZWSP tokens](./drafts/PlainRichSearch-draft-C.md)
|
||||
|
||||
### Nov 2025 status
|
||||
|
||||
As of Nov 2025, we're implementing a simple version of draft C in the host.
|
||||
|
||||
In this version, if the extension implements `IDynamicListPage`, and also
|
||||
implements `IExtendedAttributesProvider`, then they can set the `TokenSearch`
|
||||
property. This will enlighten CmdPal to treat ZWSP-separated tokens in the
|
||||
search text specially.
|
||||
|
||||
For an example, see
|
||||
[this sample implementation](../../ext/SamplePagesExtension/Pages/SampleSuggestionsPage.cs).
|
||||
|
||||
In my head, I am still leaning towards a more full-featured version of draft C,
|
||||
but with full CommandItem's in the `ISearchUpdateArgs` instead of just strings.
|
||||
We'd almost need a new page type to support that, where the extension can add
|
||||
`ICommandItem`s to the search box directly.
|
||||
|
||||
## Addenda IV: Dock bands
|
||||
|
||||
The "dock" is another way to surface commands to the user. This is a
|
||||
@@ -2158,7 +2342,6 @@ because that method is was designed for two main purposes:
|
||||
In neither of those scenarios was the full "display" of the item needed. In
|
||||
pinning scenarios, however, we need everything that the user would see in the UI
|
||||
for that item, which is all in the `ICommandItem`.
|
||||
|
||||
## Class diagram
|
||||
|
||||
This is a diagram attempting to show the relationships between the various types we've defined for the SDK. Some elements are omitted for clarity. (Notably, `IconData` and `IPropChanged`, which are used in many places.)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <ProjectTelemetry.h>
|
||||
#include <spdlog/sinks/base_sink.h>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string_view>
|
||||
|
||||
#include "../../src/common/logger/logger.h"
|
||||
@@ -1807,6 +1808,223 @@ void initSystemLogger()
|
||||
} });
|
||||
}
|
||||
|
||||
// Naming note: the *Hardlinks* names in this CA, the matching WiX CustomAction Ids
|
||||
// in Product.wxs, and the manifest filename "hardlinks.txt" are kept for continuity
|
||||
// with the original PR design. The implementation uses fs::copy_file -- not
|
||||
// CreateHardLinkW -- because hard-links share an inode (and DACL) between root and
|
||||
// WinUI3Apps, which lets MSIX sparse-package registration propagate a rich DACL onto
|
||||
// the root copy of files like hostfxr.dll and break LOW-IL prevhost.exe loads,
|
||||
// turning the Monaco preview pane blank. Copies create a fresh inode in WinUI3Apps so the root
|
||||
// copy keeps its simple DACL. See the in-body comment for the full RCA reference.
|
||||
UINT __stdcall CreateWinAppSDKHardlinksCA(MSIHANDLE hInstall)
|
||||
{
|
||||
HRESULT hr = S_OK;
|
||||
UINT er = ERROR_SUCCESS;
|
||||
std::wstring installationFolder;
|
||||
|
||||
hr = WcaInitialize(hInstall, "CreateWinAppSDKHardlinks");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
hr = getInstallFolder(hInstall, installationFolder);
|
||||
ExitOnFailure(hr, "Failed to get installFolder.");
|
||||
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path installDir(installationFolder);
|
||||
const fs::path winui3Dir = installDir / L"WinUI3Apps";
|
||||
const fs::path manifestPath = winui3Dir / L"hardlinks.txt";
|
||||
|
||||
if (!fs::exists(manifestPath))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: No hardlinks.txt manifest found, skipping.");
|
||||
goto LExit;
|
||||
}
|
||||
|
||||
std::ifstream manifestFile(manifestPath); // Read as bytes, then convert UTF-8 -> wide explicitly.
|
||||
std::string narrowLine;
|
||||
int created = 0;
|
||||
int failed = 0;
|
||||
|
||||
// INSTALLFOLDER from MSI typically arrives with a trailing backslash. lexically_normal
|
||||
// preserves that as an empty trailing path component, which would later make the
|
||||
// per-component std::mismatch containment check below reject every legitimate entry.
|
||||
// Strip any trailing separators before normalizing.
|
||||
auto stripTrailingSep = [](fs::path p) {
|
||||
auto s = p.native();
|
||||
while (s.size() > 1 && (s.back() == L'\\' || s.back() == L'/')) s.pop_back();
|
||||
return fs::path(s);
|
||||
};
|
||||
|
||||
// Normalize once so the per-line containment check below is cheap.
|
||||
const fs::path installDirNorm = stripTrailingSep(installDir).lexically_normal();
|
||||
const fs::path winui3DirNorm = stripTrailingSep(winui3Dir).lexically_normal();
|
||||
|
||||
while (std::getline(manifestFile, narrowLine))
|
||||
{
|
||||
if (narrowLine.empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Strip CR if the manifest uses CRLF line endings.
|
||||
if (narrowLine.back() == '\r')
|
||||
{
|
||||
narrowLine.pop_back();
|
||||
if (narrowLine.empty()) continue;
|
||||
}
|
||||
|
||||
// Manifest is written as UTF-8 (no BOM) -- convert to wide string explicitly
|
||||
// rather than relying on the locale-default codecvt of std::wifstream, which is
|
||||
// the ANSI code page on Windows and would silently mangle any non-ASCII path.
|
||||
const int wideLen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, narrowLine.c_str(), -1, nullptr, 0);
|
||||
if (wideLen <= 0)
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Skipping non-UTF-8 entry: %hs", narrowLine.c_str());
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
std::wstring fileName(static_cast<size_t>(wideLen) - 1, L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, narrowLine.c_str(), -1, fileName.data(), wideLen);
|
||||
|
||||
// Defense-in-depth: reject manifest entries that would escape the install root
|
||||
// via "..", absolute paths, or alternate stream syntax. lexically_normal collapses
|
||||
// any "." / ".." / repeated separators, then std::mismatch verifies the resolved
|
||||
// path is still rooted at installDir / winui3Dir respectively.
|
||||
const fs::path source = (installDir / fileName).lexically_normal();
|
||||
const fs::path target = (winui3Dir / fileName).lexically_normal();
|
||||
const auto sourceIn = std::mismatch(installDirNorm.begin(), installDirNorm.end(), source.begin(), source.end());
|
||||
const auto targetIn = std::mismatch(winui3DirNorm.begin(), winui3DirNorm.end(), target.begin(), target.end());
|
||||
if (sourceIn.first != installDirNorm.end() || targetIn.first != winui3DirNorm.end())
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Rejecting entry outside install root: %ls", fileName.c_str());
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fs::exists(source))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Source not found: %ls", source.c_str());
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove existing file if present (leftover from previous install)
|
||||
std::error_code ec;
|
||||
fs::remove(target, ec);
|
||||
|
||||
// Use a regular file copy (not a hard-link). Hard-links share an
|
||||
// NTFS inode -- and therefore one DACL -- between root and
|
||||
// WinUI3Apps, which lets MSIX sparse-package registration
|
||||
// propagate the WinUI3Apps parent's rich (Capability/Package SID)
|
||||
// DACL onto the root path. That trips a kernel "stricter access
|
||||
// evaluation" path that blocks LOW-IL prevhost.exe from loading
|
||||
// hostfxr.dll, so File Explorer Monaco preview goes blank on
|
||||
// Windows 11 23H2. Copying creates a fresh inode in WinUI3Apps,
|
||||
// so the root copy keeps its simple DACL while the WinUI3Apps
|
||||
// copy inherits the rich DACL from its parent (matches 0.99.1
|
||||
// behaviour). See Documents\PR-47233-Handoff.md for full RCA.
|
||||
fs::copy_file(source, target, fs::copy_options::overwrite_existing, ec);
|
||||
if (ec)
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Failed to copy: %ls (%hs)", fileName.c_str(), ec.message().c_str());
|
||||
failed++;
|
||||
}
|
||||
else
|
||||
{
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Copied %d files, %d failures", created, failed);
|
||||
|
||||
// Catastrophic-case escalation: if every copy failed, the WinUI3Apps tree is
|
||||
// unusable (Monaco preview / context-menu shells will break). Surface this rather
|
||||
// than reporting install success. Per-file failures remain tolerated.
|
||||
if (created == 0 && failed > 0)
|
||||
{
|
||||
hr = E_FAIL;
|
||||
ExitOnFailure(hr, "All WinAppSDK file copies failed; aborting install.");
|
||||
}
|
||||
}
|
||||
|
||||
LExit:
|
||||
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
|
||||
return WcaFinalize(er);
|
||||
}
|
||||
|
||||
UINT __stdcall DeleteWinAppSDKHardlinksCA(MSIHANDLE hInstall)
|
||||
{
|
||||
HRESULT hr = S_OK;
|
||||
UINT er = ERROR_SUCCESS;
|
||||
std::wstring installationFolder;
|
||||
|
||||
hr = WcaInitialize(hInstall, "DeleteWinAppSDKHardlinks");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
hr = getInstallFolder(hInstall, installationFolder);
|
||||
ExitOnFailure(hr, "Failed to get installFolder.");
|
||||
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path winui3Dir = fs::path(installationFolder) / L"WinUI3Apps";
|
||||
const fs::path manifestPath = winui3Dir / L"hardlinks.txt";
|
||||
|
||||
if (!fs::exists(manifestPath))
|
||||
{
|
||||
goto LExit;
|
||||
}
|
||||
|
||||
std::ifstream manifestFile(manifestPath); // Read as bytes; convert UTF-8 -> wide explicitly.
|
||||
std::string narrowLine;
|
||||
|
||||
// INSTALLFOLDER from MSI typically arrives with a trailing backslash; strip it before
|
||||
// normalizing so the per-line containment check doesn't false-reject every entry.
|
||||
auto stripTrailingSep = [](fs::path p) {
|
||||
auto s = p.native();
|
||||
while (s.size() > 1 && (s.back() == L'\\' || s.back() == L'/')) s.pop_back();
|
||||
return fs::path(s);
|
||||
};
|
||||
const fs::path winui3DirNorm = stripTrailingSep(winui3Dir).lexically_normal();
|
||||
|
||||
while (std::getline(manifestFile, narrowLine))
|
||||
{
|
||||
if (narrowLine.empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (narrowLine.back() == '\r')
|
||||
{
|
||||
narrowLine.pop_back();
|
||||
if (narrowLine.empty()) continue;
|
||||
}
|
||||
|
||||
const int wideLen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, narrowLine.c_str(), -1, nullptr, 0);
|
||||
if (wideLen <= 0)
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "DeleteWinAppSDKHardlinks: Skipping non-UTF-8 entry: %hs", narrowLine.c_str());
|
||||
continue;
|
||||
}
|
||||
std::wstring fileName(static_cast<size_t>(wideLen) - 1, L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, narrowLine.c_str(), -1, fileName.data(), wideLen);
|
||||
|
||||
// Defense-in-depth: reject entries whose resolved target escapes WinUI3Apps.
|
||||
const fs::path target = (winui3Dir / fileName).lexically_normal();
|
||||
const auto inWinui3 = std::mismatch(winui3DirNorm.begin(), winui3DirNorm.end(), target.begin(), target.end());
|
||||
if (inWinui3.first != winui3DirNorm.end())
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "DeleteWinAppSDKHardlinks: Rejecting entry outside WinUI3Apps: %ls", fileName.c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
fs::remove(target, ec);
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "DeleteWinAppSDKHardlinks: Cleaned up deduplicated copy files");
|
||||
}
|
||||
|
||||
LExit:
|
||||
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
|
||||
return WcaFinalize(er);
|
||||
}
|
||||
|
||||
// DllMain - Initialize and cleanup WiX custom action utils.
|
||||
extern "C" BOOL WINAPI DllMain(__in HINSTANCE hInst, __in ULONG ulReason, __in LPVOID)
|
||||
{
|
||||
|
||||
@@ -36,3 +36,5 @@ EXPORTS
|
||||
SetBundleInstallLocationCA
|
||||
InstallPackageIdentityMSIXCA
|
||||
UninstallPackageIdentityMSIXCA
|
||||
CreateWinAppSDKHardlinksCA
|
||||
DeleteWinAppSDKHardlinksCA
|
||||
|
||||
@@ -112,6 +112,8 @@
|
||||
<Custom Action="SetInstallCmdPalPackageParam" Before="InstallCmdPalPackage" />
|
||||
<Custom Action="SetUninstallCommandNotFoundParam" Before="UninstallCommandNotFound" />
|
||||
<Custom Action="SetUpgradeCommandNotFoundParam" Before="UpgradeCommandNotFound" />
|
||||
<Custom Action="SetCreateWinAppSDKHardlinksParam" Before="CreateWinAppSDKHardlinks" />
|
||||
<Custom Action="SetDeleteWinAppSDKHardlinksParam" Before="DeleteWinAppSDKHardlinks" />
|
||||
<Custom Action="SetApplyModulesRegistryChangeSetsParam" Before="ApplyModulesRegistryChangeSets" />
|
||||
<Custom Action="SetInstallPackageIdentityMSIXParam" Before="InstallPackageIdentityMSIX" />
|
||||
|
||||
@@ -124,6 +126,7 @@
|
||||
<Custom Action="SetBundleInstallLocationData" Before="SetBundleInstallLocation" Condition="NOT Installed OR WIX_UPGRADE_DETECTED" />
|
||||
<Custom Action="SetBundleInstallLocation" After="InstallFiles" Condition="NOT Installed OR WIX_UPGRADE_DETECTED" />
|
||||
<Custom Action="ApplyModulesRegistryChangeSets" After="InstallFiles" Condition="NOT Installed" />
|
||||
<Custom Action="CreateWinAppSDKHardlinks" After="InstallFiles" Condition="NOT Installed OR WIX_UPGRADE_DETECTED OR REINSTALL" />
|
||||
<Custom Action="InstallCmdPalPackage" After="InstallFiles" Condition="NOT Installed" />
|
||||
<Custom Action="InstallPackageIdentityMSIX" After="InstallFiles" Condition="NOT Installed AND WINDOWSBUILDNUMBER >= 22000" />
|
||||
<Custom Action="override Wix4CloseApplications_$(sys.BUILDARCHSHORT)" Before="RemoveFiles" />
|
||||
@@ -137,6 +140,7 @@
|
||||
<?endif?>
|
||||
<Custom Action="TelemetryLogInstallSuccess" After="InstallFinalize" Condition="NOT Installed" />
|
||||
<Custom Action="TelemetryLogUninstallSuccess" After="InstallFinalize" Condition="Installed and (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<Custom Action="DeleteWinAppSDKHardlinks" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" />
|
||||
<Custom Action="UnApplyModulesRegistryChangeSets" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" />
|
||||
<Custom Action="UnRegisterContextMenuPackages" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" />
|
||||
<Custom Action="CleanImageResizerRuntimeRegistry" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" />
|
||||
@@ -189,8 +193,10 @@
|
||||
<CustomAction Id="SetUpgradeCommandNotFoundParam" Property="UpgradeCommandNotFound" Value="[INSTALLFOLDER]" />
|
||||
|
||||
<CustomAction Id="SetCreateWinAppSDKHardlinksParam" Property="CreateWinAppSDKHardlinks" Value="[INSTALLFOLDER]" />
|
||||
<CustomAction Id="CreateWinAppSDKHardlinks" Return="check" Impersonate="yes" Execute="deferred" DllEntry="CreateWinAppSDKHardlinksCA" BinaryRef="PTCustomActions" />
|
||||
|
||||
<CustomAction Id="SetDeleteWinAppSDKHardlinksParam" Property="DeleteWinAppSDKHardlinks" Value="[INSTALLFOLDER]" />
|
||||
<CustomAction Id="DeleteWinAppSDKHardlinks" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="DeleteWinAppSDKHardlinksCA" BinaryRef="PTCustomActions" />
|
||||
|
||||
<CustomAction Id="SetCreatePTInteropHardlinksParam" Property="CreatePTInteropHardlinks" Value="[INSTALLFOLDER]" />
|
||||
|
||||
|
||||
@@ -7,11 +7,18 @@
|
||||
|
||||
<Fragment>
|
||||
<DirectoryRef Id="WinUI3AppsInstallFolder">
|
||||
<Component Id="WinUI3Apps_Hardlinks_Manifest" Guid="F7A2C3D1-8E4B-4F6A-9D2E-1B3C5A7F8E90" Bitness="always64">
|
||||
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
|
||||
<RegistryValue Type="string" Name="WinUI3Apps_Hardlinks_Manifest" Value="" KeyPath="yes" />
|
||||
</RegistryKey>
|
||||
<File Id="WinUI3Apps_hardlinks_txt" Source="$(var.BinDir)\WinUI3Apps\hardlinks.txt" />
|
||||
</Component>
|
||||
<!-- Generated by generateFileComponents.ps1 -->
|
||||
<!--WinUI3ApplicationsFiles_Component_Def-->
|
||||
</DirectoryRef>
|
||||
|
||||
<ComponentGroup Id="WinUI3ApplicationsComponentGroup">
|
||||
<ComponentRef Id="WinUI3Apps_Hardlinks_Manifest" />
|
||||
</ComponentGroup>
|
||||
|
||||
</Fragment>
|
||||
|
||||
@@ -30,6 +30,10 @@ Function Generate-FileList() {
|
||||
|
||||
$fileInclusionList = @("*.dll", "*.exe", "*.json", "*.msix", "*.png", "*.gif", "*.ico", "*.cur", "*.svg", "index.html", "reg.js", "gitignore.js", "srt.js", "monacoSpecialLanguages.js", "customTokenThemeRules.js", "*.pri")
|
||||
|
||||
# MFC DLLs leak into the output via WindowsAppSDKSelfContained but no PowerToys binary imports them.
|
||||
# Verified with dumpbin /dependents across all 2176 binaries — zero consumers.
|
||||
$fileExclusionList += @("mfc140.dll", "mfc140u.dll", "mfcm140.dll", "mfcm140u.dll")
|
||||
|
||||
$dllsToIgnore = @("System.CodeDom.dll", "WindowsBase.dll")
|
||||
|
||||
if ($fileDepsJson -eq [string]::Empty) {
|
||||
@@ -85,11 +89,16 @@ Function Generate-FileComponents() {
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'fileList',
|
||||
Justification = 'variable is used in another scope')]
|
||||
|
||||
$fileList = $matches[2] -split ';'
|
||||
$fileList = $matches[2] -split ';' | Where-Object { $_ -ne '' }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $fileList -or $fileList.Count -eq 0) {
|
||||
# No files to generate components for — leave placeholder intact
|
||||
return
|
||||
}
|
||||
|
||||
$componentId = "$($fileListName)_Component"
|
||||
|
||||
$componentDefs = "`r`n"
|
||||
@@ -154,6 +163,67 @@ Generate-FileComponents -fileListName "BaseApplicationsFiles" -wxsFilePath $PSSc
|
||||
|
||||
#WinUI3Applications
|
||||
Generate-FileList -fileDepsJson "" -fileListName WinUI3ApplicationsFiles -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps"
|
||||
|
||||
# Deduplicate: Remove files from WinUI3Apps that are identical to root (same name + same hash).
|
||||
# These will be re-created as plain file copies at install time by CreateWinAppSDKHardlinksCA.
|
||||
# (The CA's name is historical: it now uses fs::copy_file rather than CreateHardLinkW to avoid
|
||||
# DACL contamination across the shared inode -- see CustomAction.cpp for details.)
|
||||
$rootPath = "$PSScriptRoot..\..\..\$platform\Release"
|
||||
$winui3Path = "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps"
|
||||
$winui3WxsPath = "$PSScriptRoot\WinUI3Applications.wxs"
|
||||
$winui3Wxs = Get-Content $winui3WxsPath -Raw
|
||||
$manifestPath = Join-Path $winui3Path "hardlinks.txt"
|
||||
|
||||
if ($winui3Wxs -match "\<\?define WinUI3ApplicationsFiles=([^?]*)\?\>") {
|
||||
$winui3FileList = $matches[1] -split ';' | Where-Object { $_ -ne '' }
|
||||
$hardlinkFiles = @()
|
||||
|
||||
# Read the BaseApplications WXS file list so we only deduplicate files that the MSI
|
||||
# is actually deploying to the install root. If a file was stripped from BaseApplications
|
||||
# by an earlier step (e.g., the ImageResizer leaked-apphost workaround above), the
|
||||
# install-time CA's source would be missing and both copies would disappear.
|
||||
$baseAppsWxs = Get-Content $baseAppWxsPath -Raw
|
||||
$baseAppsFileList = @()
|
||||
if ($baseAppsWxs -match "\<\?define BaseApplicationsFiles=([^?]*)\?\>") {
|
||||
$baseAppsFileList = $matches[1] -split ';' | Where-Object { $_ -ne '' }
|
||||
}
|
||||
|
||||
foreach ($file in $winui3FileList) {
|
||||
# Skip files that were intentionally not deployed to root by the build
|
||||
if ($baseAppsFileList -notcontains $file) { continue }
|
||||
|
||||
$rootFile = Join-Path $rootPath $file
|
||||
$winui3File = Join-Path $winui3Path $file
|
||||
if ((Test-Path $rootFile) -and (Test-Path $winui3File)) {
|
||||
$rootHash = (Get-FileHash $rootFile -Algorithm SHA256).Hash
|
||||
$winui3Hash = (Get-FileHash $winui3File -Algorithm SHA256).Hash
|
||||
if ($rootHash -eq $winui3Hash) {
|
||||
$hardlinkFiles += $file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($hardlinkFiles.Count -gt 0) {
|
||||
# Remove deduplicated files from WinUI3Apps file list
|
||||
$remainingFiles = $winui3FileList | Where-Object { $_ -notin $hardlinkFiles }
|
||||
if ($remainingFiles.Count -eq 0) {
|
||||
# All files are duplicates — keep at least a dummy entry won't be emitted
|
||||
# Generate-FileComponents handles empty defines by producing no <File> entries
|
||||
$winui3Wxs = $winui3Wxs -replace "\<\?define WinUI3ApplicationsFiles=[^?]*\?\>", "<?define WinUI3ApplicationsFiles=?>"
|
||||
} else {
|
||||
$winui3Wxs = $winui3Wxs -replace "\<\?define WinUI3ApplicationsFiles=[^?]*\?\>", "<?define WinUI3ApplicationsFiles=$($remainingFiles -join ';')?>"
|
||||
}
|
||||
Set-Content -Path $winui3WxsPath -Value $winui3Wxs
|
||||
Write-Host "Deduplicated $($hardlinkFiles.Count) files from WinUI3Apps (will be copied at install time)"
|
||||
}
|
||||
|
||||
# Always write hardlinks.txt (may be empty — CA handles that gracefully)
|
||||
# Write as UTF-8 without BOM so the install-time CA can read it via std::ifstream
|
||||
# + MultiByteToWideChar(CP_UTF8) without dealing with PS-version-dependent default
|
||||
# encodings or a leading BOM.
|
||||
[System.IO.File]::WriteAllLines($manifestPath, [string[]]$hardlinkFiles, (New-Object System.Text.UTF8Encoding($false)))
|
||||
}
|
||||
|
||||
Generate-FileComponents -fileListName "WinUI3ApplicationsFiles" -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs
|
||||
|
||||
#AdvancedPaste
|
||||
|
||||
@@ -5,11 +5,17 @@
|
||||
#include <sstream>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <random>
|
||||
|
||||
namespace ExprtkCalculator::internal
|
||||
{
|
||||
static double factorial(const double n)
|
||||
{
|
||||
if (std::isnan(n) || std::isinf(n))
|
||||
{
|
||||
return std::numeric_limits<double>::quiet_NaN();
|
||||
}
|
||||
|
||||
// Only allow non-negative integers
|
||||
if (n < 0.0 || std::floor(n) != n)
|
||||
{
|
||||
@@ -20,13 +26,80 @@ namespace ExprtkCalculator::internal
|
||||
|
||||
static double sign(const double n)
|
||||
{
|
||||
// The sign of NaN is undefined.
|
||||
if (std::isnan(n))
|
||||
{
|
||||
return std::numeric_limits<double>::quiet_NaN();
|
||||
}
|
||||
|
||||
if (n > 0.0) return 1.0;
|
||||
if (n < 0.0) return -1.0;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// rand(): returns a uniformly distributed random double in [0, 1)
|
||||
struct rand_func : public exprtk::ifunction<double>
|
||||
{
|
||||
std::mt19937_64 rng;
|
||||
std::uniform_real_distribution<double> dist;
|
||||
|
||||
rand_func() :
|
||||
exprtk::ifunction<double>(0),
|
||||
rng(std::random_device{}()),
|
||||
dist(0.0, 1.0)
|
||||
{}
|
||||
|
||||
inline double operator()() override
|
||||
{
|
||||
return dist(rng);
|
||||
}
|
||||
};
|
||||
|
||||
// randi(n): returns a uniformly distributed random integer in [0, n-1]
|
||||
struct randi_func : public exprtk::ifunction<double>
|
||||
{
|
||||
std::mt19937_64 rng;
|
||||
|
||||
randi_func() :
|
||||
exprtk::ifunction<double>(1),
|
||||
rng(std::random_device{}())
|
||||
{}
|
||||
|
||||
inline double operator()(const double& n) override
|
||||
{
|
||||
if (std::isnan(n) || std::isinf(n))
|
||||
{
|
||||
return std::numeric_limits<double>::quiet_NaN();
|
||||
}
|
||||
|
||||
constexpr double maxLongLongAsDouble = static_cast<double>(std::numeric_limits<long long>::max());
|
||||
if (n < 1.0 || n >= maxLongLongAsDouble)
|
||||
{
|
||||
return std::numeric_limits<double>::quiet_NaN();
|
||||
}
|
||||
|
||||
if (std::floor(n) != n)
|
||||
{
|
||||
return std::numeric_limits<double>::quiet_NaN();
|
||||
}
|
||||
|
||||
std::uniform_int_distribution<long long> dist(0, static_cast<long long>(n) - 1);
|
||||
return static_cast<double>(dist(rng));
|
||||
}
|
||||
};
|
||||
|
||||
std::wstring ToWStringFullPrecision(double value)
|
||||
{
|
||||
if (std::isnan(value))
|
||||
{
|
||||
return L"NaN";
|
||||
}
|
||||
|
||||
if (std::isinf(value))
|
||||
{
|
||||
return value > 0 ? L"inf" : L"-inf";
|
||||
}
|
||||
|
||||
std::wostringstream oss;
|
||||
oss.imbue(std::locale::classic());
|
||||
oss << std::fixed << std::setprecision(15) << value;
|
||||
@@ -47,6 +120,13 @@ namespace ExprtkCalculator::internal
|
||||
symbol_table.add_function("factorial", factorial);
|
||||
symbol_table.add_function("sign", sign);
|
||||
|
||||
// thread_local ensures each thread has its own RNG instance (seeded once,
|
||||
// state preserved across calls) without requiring locks.
|
||||
static thread_local rand_func rand_fn;
|
||||
static thread_local randi_func randi_fn;
|
||||
symbol_table.add_function("rand", rand_fn);
|
||||
symbol_table.add_function("randi", randi_fn);
|
||||
|
||||
exprtk::expression<double> expression;
|
||||
expression.register_symbol_table(symbol_table);
|
||||
|
||||
@@ -65,7 +145,7 @@ namespace ExprtkCalculator::internal
|
||||
parser.settings().disable_all_inequality_ops(); // Disable inequality operators like <, >, <=, >=, !=, etc.
|
||||
|
||||
if (!parser.compile(expressionText, expression))
|
||||
return L"NaN";
|
||||
return L"ParseError";
|
||||
|
||||
return ToWStringFullPrecision(expression.value());
|
||||
}
|
||||
|
||||
@@ -275,6 +275,10 @@ namespace winrt::PowerToys::Interop::implementation
|
||||
{
|
||||
return CommonSharedConstants::HOTKEY_UPDATED_POWER_DISPLAY_EVENT;
|
||||
}
|
||||
hstring Constants::RescanPowerDisplayMonitorsEvent()
|
||||
{
|
||||
return CommonSharedConstants::RESCAN_POWER_DISPLAY_MONITORS_EVENT;
|
||||
}
|
||||
hstring Constants::PowerDisplayToggleMessage()
|
||||
{
|
||||
return CommonSharedConstants::POWER_DISPLAY_TOGGLE_MESSAGE;
|
||||
|
||||
@@ -72,6 +72,7 @@ namespace winrt::PowerToys::Interop::implementation
|
||||
static hstring SettingsUpdatedPowerDisplayEvent();
|
||||
static hstring PowerDisplaySendSettingsTelemetryEvent();
|
||||
static hstring HotkeyUpdatedPowerDisplayEvent();
|
||||
static hstring RescanPowerDisplayMonitorsEvent();
|
||||
static hstring PowerDisplayToggleMessage();
|
||||
static hstring PowerDisplayApplyProfileMessage();
|
||||
static hstring PowerDisplayTerminateAppMessage();
|
||||
|
||||
@@ -69,6 +69,7 @@ namespace PowerToys
|
||||
static String SettingsUpdatedPowerDisplayEvent();
|
||||
static String PowerDisplaySendSettingsTelemetryEvent();
|
||||
static String HotkeyUpdatedPowerDisplayEvent();
|
||||
static String RescanPowerDisplayMonitorsEvent();
|
||||
static String PowerDisplayToggleMessage();
|
||||
static String PowerDisplayApplyProfileMessage();
|
||||
static String PowerDisplayTerminateAppMessage();
|
||||
|
||||
@@ -165,6 +165,7 @@ namespace CommonSharedConstants
|
||||
const wchar_t SETTINGS_UPDATED_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-SettingsUpdatedEvent-2e4d6f8a-1c3b-5e7f-9a1d-4c6e8f0b2d3e";
|
||||
const wchar_t POWER_DISPLAY_SEND_SETTINGS_TELEMETRY_EVENT[] = L"Local\\PowerToysPowerDisplay-SettingsTelemetryEvent-8c4f2a1d-5e3b-7f9c-1a6d-3b8e5f2c9a7d";
|
||||
const wchar_t HOTKEY_UPDATED_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-HotkeyUpdatedEvent-9d5f3a2b-7e1c-4b8a-6f3d-2a9e5c7b1d4f";
|
||||
const wchar_t RESCAN_POWER_DISPLAY_MONITORS_EVENT[] = L"Local\\PowerToysPowerDisplay-RescanMonitorsEvent-7f3e8c5a-1d4b-4a9e-bc6f-5d8a2b9e3c4f";
|
||||
|
||||
// IPC Messages used in PowerDisplay (Named Pipe communication)
|
||||
const wchar_t POWER_DISPLAY_TOGGLE_MESSAGE[] = L"Toggle";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" />
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|x64">
|
||||
<Configuration>Debug</Configuration>
|
||||
@@ -193,5 +194,18 @@
|
||||
<ItemGroup>
|
||||
<Manifest Include="GrabAndMove.manifest" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="packages.config" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
<ImportGroup Label="ExtensionTargets">
|
||||
<Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" />
|
||||
</ImportGroup>
|
||||
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
|
||||
<PropertyGroup>
|
||||
<ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText>
|
||||
</PropertyGroup>
|
||||
<Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" />
|
||||
<Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" />
|
||||
</Target>
|
||||
</Project>
|
||||
4
src/modules/GrabAndMove/GrabAndMove/packages.config
Normal file
4
src/modules/GrabAndMove/GrabAndMove/packages.config
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" />
|
||||
</packages>
|
||||
@@ -21,7 +21,6 @@ using System.IO.Pipes;
|
||||
using System.Linq;
|
||||
using System.Security.Authentication.ExtendedProtection;
|
||||
using System.Security.Principal;
|
||||
using System.ServiceModel.Channels;
|
||||
using System.ServiceProcess;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
|
||||
<PackageReference Include="Microsoft.Windows.Compatibility" />
|
||||
<PackageReference Include="StreamJsonRpc" />
|
||||
<PackageReference Include="System.Data.SqlClient" /> <!-- It's a dependency of Microsoft.Windows.Compatibility. We're adding it here to force it to the version specified in Directory.Packages.props -->
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
|
||||
|
||||
@@ -214,7 +214,6 @@
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
|
||||
<PackageReference Include="Microsoft.Windows.Compatibility" />
|
||||
<PackageReference Include="StreamJsonRpc" />
|
||||
<PackageReference Include="System.Data.SqlClient" /> <!-- It's a dependency of Microsoft.Windows.Compatibility. We're adding it here to force it to the version specified in Directory.Packages.props -->
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
|
||||
<PackageReference Include="StreamJsonRpc" />
|
||||
<PackageReference Include="System.Data.SqlClient" /> <!-- It's a dependency of Microsoft.Windows.Compatibility. We're adding it here to force it to the version specified in Directory.Packages.props -->
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.UI.ViewModels.UnitTests\\Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.UITests\\Microsoft.CmdPal.UITests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CommandPalette.Extensions.Toolkit.UnitTests\\Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Actions\\Microsoft.CmdPal.Ext.Actions.csproj",
|
||||
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Apps\\Microsoft.CmdPal.Ext.Apps.csproj",
|
||||
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Bookmark\\Microsoft.CmdPal.Ext.Bookmarks.csproj",
|
||||
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Calc\\Microsoft.CmdPal.Ext.Calc.csproj",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
public sealed class GalleryAuthor
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
public sealed class GalleryDetection
|
||||
{
|
||||
public string? PackageFamilyName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
public sealed class GalleryExtensionEntry
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public string? ShortDescription { get; set; }
|
||||
|
||||
public GalleryAuthor Author { get; set; } = new();
|
||||
|
||||
public string? Homepage { get; set; }
|
||||
|
||||
public string? Readme { get; set; }
|
||||
|
||||
public string? IconUrl { get; set; }
|
||||
|
||||
public List<string> ScreenshotUrls { get; set; } = [];
|
||||
|
||||
public List<GalleryInstallSource> InstallSources { get; set; } = [];
|
||||
|
||||
public GalleryDetection? Detection { get; set; }
|
||||
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
public sealed class GalleryInstallSource
|
||||
{
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
public string? Id { get; set; }
|
||||
|
||||
public string? Uri { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the wrapped gallery index format where extension data is inline.
|
||||
/// </summary>
|
||||
public sealed class GalleryRemoteIndex
|
||||
{
|
||||
public List<GalleryExtensionEntry> Extensions { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
[JsonSerializable(typeof(GalleryExtensionEntry))]
|
||||
[JsonSerializable(typeof(GalleryRemoteIndex))]
|
||||
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
|
||||
public sealed partial class GallerySerializationContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.Services;
|
||||
using Microsoft.CmdPal.Common.Services.HttpCaching;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the HTTP client instance used by the extension gallery.
|
||||
/// </summary>
|
||||
public sealed partial class ExtensionGalleryHttpClient : IDisposable
|
||||
{
|
||||
internal const string CacheDirectoryName = "GalleryCache";
|
||||
private const int TimeoutSeconds = 15;
|
||||
private const string UserAgent = "PowerToys-CmdPal/1.0";
|
||||
private readonly HttpCachingClient _cache;
|
||||
|
||||
internal static readonly TimeSpan DefaultTimeToLive = TimeSpan.FromHours(4);
|
||||
|
||||
public ExtensionGalleryHttpClient(IApplicationInfoService applicationInfoService, ILogger<ExtensionGalleryHttpClient> logger)
|
||||
: this(applicationInfoService, innerHandler: null, logger)
|
||||
{
|
||||
}
|
||||
|
||||
internal ExtensionGalleryHttpClient(IApplicationInfoService applicationInfoService, HttpMessageHandler? innerHandler, ILogger<ExtensionGalleryHttpClient> logger)
|
||||
: this(
|
||||
Path.Combine(applicationInfoService.CacheDirectory, CacheDirectoryName),
|
||||
innerHandler,
|
||||
logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(applicationInfoService);
|
||||
}
|
||||
|
||||
internal ExtensionGalleryHttpClient(string cacheDirectory, HttpMessageHandler? innerHandler, ILogger<ExtensionGalleryHttpClient> logger)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_cache = new HttpCachingClient(
|
||||
cacheDirectory,
|
||||
DefaultTimeToLive,
|
||||
TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
UserAgent,
|
||||
innerHandler,
|
||||
logger);
|
||||
}
|
||||
|
||||
internal HttpCachingClient Cache => _cache;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MEL = Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
|
||||
|
||||
public sealed partial class ExtensionGalleryService : IExtensionGalleryService
|
||||
{
|
||||
private const string DefaultFeedUrl = "https://aka.ms/CmdPal-ExtensionsJson";
|
||||
private const string LocalFeedFileName = "extensions.json";
|
||||
private static readonly TimeSpan IconCacheTtl = TimeSpan.FromDays(1);
|
||||
private static readonly TimeSpan CacheTtl = ExtensionGalleryHttpClient.DefaultTimeToLive;
|
||||
private static readonly Action<MEL.ILogger, Exception?> LogGalleryFetchFailedMessage = LoggerMessage.Define(
|
||||
LogLevel.Error,
|
||||
new EventId(0, nameof(LogGalleryFetchFailed)),
|
||||
"Gallery fetch failed");
|
||||
|
||||
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToResolveExtensionGalleryIconMessage = LoggerMessage.Define<string>(
|
||||
LogLevel.Error,
|
||||
new EventId(1, nameof(LogFailedToResolveExtensionGalleryIcon)),
|
||||
"Failed to resolve extension gallery icon '{IconUri}'.");
|
||||
|
||||
private readonly ILogger<ExtensionGalleryService> _logger;
|
||||
private readonly GalleryFeedUrlProvider _galleryFeedUrlProvider;
|
||||
private readonly ExtensionGalleryHttpClient _galleryHttpClient;
|
||||
|
||||
private static readonly HashSet<string> SupportedFeedSchemes =
|
||||
[
|
||||
Uri.UriSchemeHttp,
|
||||
Uri.UriSchemeHttps,
|
||||
Uri.UriSchemeFile,
|
||||
];
|
||||
|
||||
public ExtensionGalleryService(
|
||||
ExtensionGalleryHttpClient galleryHttpClient,
|
||||
ILogger<ExtensionGalleryService> logger,
|
||||
GalleryFeedUrlProvider galleryFeedUrlProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(galleryHttpClient);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(galleryFeedUrlProvider);
|
||||
|
||||
_logger = logger;
|
||||
_galleryHttpClient = galleryHttpClient;
|
||||
_galleryFeedUrlProvider = galleryFeedUrlProvider;
|
||||
}
|
||||
|
||||
public bool IsCustomFeed => !string.IsNullOrWhiteSpace(_galleryFeedUrlProvider());
|
||||
|
||||
public string GetBaseUrl()
|
||||
{
|
||||
return GetFeedUrl();
|
||||
}
|
||||
|
||||
public string GetFeedUrl()
|
||||
{
|
||||
var configuredUrl = _galleryFeedUrlProvider();
|
||||
return string.IsNullOrWhiteSpace(configuredUrl) ? DefaultFeedUrl : configuredUrl.Trim();
|
||||
}
|
||||
|
||||
public Task<GalleryFetchResult> FetchExtensionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return FetchWrappedFeedAsync(forceRefresh: false, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<GalleryFetchResult> RefreshAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return FetchWrappedFeedAsync(forceRefresh: true, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<GalleryFetchResult> FetchWrappedFeedAsync(bool forceRefresh, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TryGetFeedUri(out var feedUri))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid gallery feed URL '{GetFeedUrl()}'.");
|
||||
}
|
||||
|
||||
var fetchResult = await FetchFeedDocumentAsync(feedUri, forceRefresh, cancellationToken);
|
||||
var extensions = TryParseWrappedGallery(fetchResult.Json);
|
||||
if (extensions is null || extensions.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("The extension gallery feed is empty or invalid.");
|
||||
}
|
||||
|
||||
TryGetBaseDirectoryUri(feedUri, out var baseDirectoryUri);
|
||||
NormalizeRemoteEntries(extensions, baseDirectoryUri);
|
||||
var cacheableIconUris = CollectCacheableIconUris(extensions);
|
||||
|
||||
if (forceRefresh && !fetchResult.UsedFallbackCache)
|
||||
{
|
||||
PruneCachedResources(feedUri, cacheableIconUris);
|
||||
}
|
||||
|
||||
await LocalizeIconUrisAsync(extensions, cancellationToken);
|
||||
|
||||
return new GalleryFetchResult
|
||||
{
|
||||
Extensions = extensions,
|
||||
FromCache = fetchResult.FromCache,
|
||||
UsedFallbackCache = fetchResult.UsedFallbackCache,
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException or OperationCanceledException or InvalidOperationException or UriFormatException)
|
||||
{
|
||||
LogGalleryFetchFailed(_logger, ex);
|
||||
var isRateLimited = ex is HttpRequestException { StatusCode: HttpStatusCode.TooManyRequests };
|
||||
return new GalleryFetchResult
|
||||
{
|
||||
IsRateLimited = isRateLimited,
|
||||
HasError = true,
|
||||
ErrorMessage = isRateLimited ? null : ex.Message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<FeedFetchResult> FetchFeedDocumentAsync(Uri feedUri, bool forceRefresh, CancellationToken cancellationToken)
|
||||
{
|
||||
if (feedUri.IsFile)
|
||||
{
|
||||
var localJson = await File.ReadAllTextAsync(feedUri.LocalPath, cancellationToken);
|
||||
return new FeedFetchResult(localJson, FromCache: false, UsedFallbackCache: false);
|
||||
}
|
||||
|
||||
if (!feedUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !feedUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported gallery URI scheme '{feedUri.Scheme}'.");
|
||||
}
|
||||
|
||||
var fetchResult = await _galleryHttpClient.Cache.GetResourceAsync(
|
||||
feedUri,
|
||||
fileNameHint: ResolveFeedFileName(feedUri),
|
||||
forceRefresh: forceRefresh,
|
||||
timeToLiveOverride: CacheTtl,
|
||||
cancellationToken: cancellationToken);
|
||||
var responseJson = await File.ReadAllTextAsync(fetchResult.Resource.ContentPath, cancellationToken);
|
||||
return new FeedFetchResult(responseJson, fetchResult.Resource.FromCache, fetchResult.UsedFallbackCache);
|
||||
}
|
||||
|
||||
private static List<GalleryExtensionEntry>? TryParseWrappedGallery(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var index = JsonSerializer.Deserialize(json, GallerySerializationContext.Default.GalleryRemoteIndex);
|
||||
return index?.Extensions;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeRemoteEntries(List<GalleryExtensionEntry> entries, Uri? baseDirectoryUri)
|
||||
{
|
||||
for (var i = entries.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var entry = entries[i];
|
||||
if (string.IsNullOrWhiteSpace(entry.Id))
|
||||
{
|
||||
entries.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.Id = entry.Id.Trim();
|
||||
NormalizeEntry(entry, baseDirectoryUri);
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeEntry(GalleryExtensionEntry entry, Uri? baseDirectoryUri)
|
||||
{
|
||||
entry.IconUrl = NormalizeOptionalUri(entry.IconUrl, baseDirectoryUri);
|
||||
entry.ScreenshotUrls = NormalizeOptionalUris(entry.ScreenshotUrls, baseDirectoryUri);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalUri(string? value, Uri? baseDirectoryUri)
|
||||
{
|
||||
var normalizedValue = ToNullIfWhiteSpace(value);
|
||||
if (normalizedValue is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(normalizedValue, UriKind.Absolute, out var absoluteUri))
|
||||
{
|
||||
return absoluteUri.AbsoluteUri;
|
||||
}
|
||||
|
||||
if (baseDirectoryUri is null || !Uri.TryCreate(baseDirectoryUri, normalizedValue, out var candidate))
|
||||
{
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
if (!candidate.AbsoluteUri.StartsWith(baseDirectoryUri.AbsoluteUri, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
return candidate.AbsoluteUri;
|
||||
}
|
||||
|
||||
private static List<string> NormalizeOptionalUris(List<string>? values, Uri? baseDirectoryUri)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<string> normalizedValues = [];
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
var normalizedValue = NormalizeOptionalUri(values[i], baseDirectoryUri);
|
||||
if (normalizedValue is not null)
|
||||
{
|
||||
normalizedValues.Add(normalizedValue);
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedValues;
|
||||
}
|
||||
|
||||
private static string? ToNullIfWhiteSpace(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private async Task LocalizeIconUrisAsync(IEnumerable<GalleryExtensionEntry> extensions, CancellationToken cancellationToken)
|
||||
{
|
||||
List<Task> localizationTasks = [];
|
||||
foreach (var extension in extensions)
|
||||
{
|
||||
localizationTasks.Add(LocalizeIconUriAsync(extension, cancellationToken));
|
||||
}
|
||||
|
||||
await Task.WhenAll(localizationTasks);
|
||||
}
|
||||
|
||||
private async Task LocalizeIconUriAsync(GalleryExtensionEntry extension, CancellationToken cancellationToken)
|
||||
{
|
||||
var iconUrl = ToNullIfWhiteSpace(extension.IconUrl);
|
||||
if (iconUrl is null || !Uri.TryCreate(iconUrl, UriKind.Absolute, out var iconUri))
|
||||
{
|
||||
extension.IconUrl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var localizedIconUri = await ResolveLocalizedIconUriAsync(iconUri, cancellationToken);
|
||||
extension.IconUrl = localizedIconUri?.AbsoluteUri;
|
||||
}
|
||||
|
||||
private async Task<Uri?> ResolveLocalizedIconUriAsync(Uri iconUri, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(iconUri);
|
||||
|
||||
if (iconUri.IsFile || iconUri.Scheme.Equals("ms-appx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return iconUri;
|
||||
}
|
||||
|
||||
if (!IsCacheableUri(iconUri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fetchResult = await _galleryHttpClient.Cache.GetResourceAsync(
|
||||
iconUri,
|
||||
fileNameHint: Path.GetFileName(Uri.UnescapeDataString(iconUri.AbsolutePath)),
|
||||
forceRefresh: false,
|
||||
timeToLiveOverride: IconCacheTtl,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return fetchResult.Resource.ContentUri;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidOperationException)
|
||||
{
|
||||
LogFailedToResolveExtensionGalleryIcon(_logger, iconUri.AbsoluteUri, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Uri> CollectCacheableIconUris(IEnumerable<GalleryExtensionEntry> extensions)
|
||||
{
|
||||
List<Uri> retainedResourceUris = [];
|
||||
foreach (var extension in extensions)
|
||||
{
|
||||
if (!Uri.TryCreate(extension.IconUrl, UriKind.Absolute, out var iconUri)
|
||||
|| !IsCacheableUri(iconUri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
retainedResourceUris.Add(iconUri);
|
||||
}
|
||||
|
||||
return retainedResourceUris;
|
||||
}
|
||||
|
||||
private void PruneCachedResources(Uri feedUri, IEnumerable<Uri> cacheableIconUris)
|
||||
{
|
||||
List<Uri> retainedResourceUris = [];
|
||||
if (IsCacheableUri(feedUri))
|
||||
{
|
||||
retainedResourceUris.Add(feedUri);
|
||||
}
|
||||
|
||||
foreach (var iconUri in cacheableIconUris)
|
||||
{
|
||||
retainedResourceUris.Add(iconUri);
|
||||
}
|
||||
|
||||
_galleryHttpClient.Cache.Prune(retainedResourceUris);
|
||||
}
|
||||
|
||||
private static bool IsCacheableUri(Uri resourceUri)
|
||||
{
|
||||
return resourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
|| resourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ResolveFeedFileName(Uri feedUri)
|
||||
{
|
||||
var fileNameHint = Path.GetFileName(Uri.UnescapeDataString(feedUri.AbsolutePath));
|
||||
return string.IsNullOrWhiteSpace(fileNameHint) ? LocalFeedFileName : fileNameHint;
|
||||
}
|
||||
|
||||
private bool TryGetFeedUri([NotNullWhen(true)] out Uri? feedUri)
|
||||
{
|
||||
feedUri = null;
|
||||
var feedUrl = GetFeedUrl();
|
||||
if (string.IsNullOrWhiteSpace(feedUrl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(feedUrl, UriKind.Absolute, out var candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SupportedFeedSchemes.Contains(candidate.Scheme))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.IsFile && Directory.Exists(candidate.LocalPath))
|
||||
{
|
||||
candidate = new Uri(Path.Combine(candidate.LocalPath, LocalFeedFileName));
|
||||
}
|
||||
|
||||
feedUri = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryGetBaseDirectoryUri(Uri feedUri, [NotNullWhen(true)] out Uri? baseDirectoryUri)
|
||||
{
|
||||
baseDirectoryUri = null;
|
||||
try
|
||||
{
|
||||
var candidate = new Uri(feedUri, ".");
|
||||
if (!SupportedFeedSchemes.Contains(candidate.Scheme))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
baseDirectoryUri = candidate;
|
||||
return true;
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void LogGalleryFetchFailed(MEL.ILogger logger, Exception exception)
|
||||
{
|
||||
LogGalleryFetchFailedMessage(logger, exception);
|
||||
}
|
||||
|
||||
private static void LogFailedToResolveExtensionGalleryIcon(MEL.ILogger logger, string iconUri, Exception exception)
|
||||
{
|
||||
LogFailedToResolveExtensionGalleryIconMessage(logger, iconUri, exception);
|
||||
}
|
||||
|
||||
private sealed record FeedFetchResult(string Json, bool FromCache, bool UsedFallbackCache);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
|
||||
|
||||
public delegate string? GalleryFeedUrlProvider();
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
|
||||
|
||||
public sealed record GalleryFetchResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the gallery entries returned by the fetch operation.
|
||||
/// </summary>
|
||||
public List<GalleryExtensionEntry> Extensions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the result was loaded from cache.
|
||||
/// </summary>
|
||||
public bool FromCache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the service had to fall back to cached data
|
||||
/// because a remote refresh could not be completed successfully.
|
||||
/// </summary>
|
||||
public bool UsedFallbackCache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the fetch failed because the gallery responded
|
||||
/// with HTTP 429 Too Many Requests and no cached fallback data was available.
|
||||
/// </summary>
|
||||
public bool IsRateLimited { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the fetch operation completed with an error.
|
||||
/// </summary>
|
||||
public bool HasError { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error message associated with the fetch operation, when available.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
|
||||
|
||||
public interface IExtensionGalleryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches the gallery feed.
|
||||
/// Falls back to cached data on failure.
|
||||
/// Returned entries are normalized for local display, including icon URIs.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token that cancels the fetch operation.</param>
|
||||
/// <returns>The fetched gallery data, optionally populated from cache.</returns>
|
||||
Task<GalleryFetchResult> FetchExtensionsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to fetch fresh data from the feed.
|
||||
/// Falls back to cached data if the refresh fails.
|
||||
/// Returned entries are normalized for local display, including icon URIs.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token that cancels the refresh operation.</param>
|
||||
/// <returns>The refreshed gallery data, optionally populated from cache.</returns>
|
||||
Task<GalleryFetchResult> RefreshAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured gallery feed URL.
|
||||
/// For compatibility this method keeps its historical name, but it returns the full feed endpoint.
|
||||
/// </summary>
|
||||
/// <returns>The configured gallery feed endpoint.</returns>
|
||||
string GetBaseUrl();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if a custom (non-default) feed URL is configured.
|
||||
/// </summary>
|
||||
bool IsCustomFeed { get; }
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Messages;
|
||||
namespace Microsoft.CmdPal.Common.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Message to request hiding the window.
|
||||
/// </summary>
|
||||
public partial record HideWindowMessage();
|
||||
public partial class GetHwndMessage
|
||||
{
|
||||
public nint Hwnd { get; set; } = 0;
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<CsWinRTAotOptimizerEnabled>true</CsWinRTAotOptimizerEnabled>
|
||||
<CsWinRTIncludes>Microsoft.Management.Deployment</CsWinRTIncludes>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -29,8 +30,18 @@
|
||||
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="NativeMethods.txt" />
|
||||
<AdditionalFiles Include="NativeMethods.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Microsoft.WindowsPackageManager.ComInterop">
|
||||
<NoWarn>NU1701</NoWarn>
|
||||
<GeneratePathProperty>true</GeneratePathProperty>
|
||||
<IncludeAssets>none</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Windows.CsWin32">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@@ -40,6 +51,11 @@
|
||||
<!-- This line forces the WebView2 version used by Windows App SDK to be the one we expect from Directory.Packages.props . -->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<CsWinRTInputs Include="$(PkgMicrosoft_WindowsPackageManager_ComInterop)\lib\uap10.0\Microsoft.Management.Deployment.winmd" />
|
||||
<Content Include="$(PkgMicrosoft_WindowsPackageManager_ComInterop)\lib\uap10.0\Microsoft.Management.Deployment.winmd" Link="Microsoft.Management.Deployment.winmd" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
|
||||
@@ -12,8 +12,9 @@ MonitorFromWindow
|
||||
|
||||
SHOW_WINDOW_CMD
|
||||
ShellExecuteEx
|
||||
SEE_MASK_INVOKEIDLIST
|
||||
SEE_MASK_INVOKEIDLIST
|
||||
|
||||
GetFileAttributes
|
||||
FILE_FLAGS_AND_ATTRIBUTES
|
||||
INVALID_FILE_ATTRIBUTES
|
||||
INVALID_FILE_ATTRIBUTES
|
||||
CoCreateInstance
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Security.Principal;
|
||||
using Microsoft.CmdPal.Common.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.ApplicationModel;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services;
|
||||
|
||||
@@ -16,6 +17,9 @@ namespace Microsoft.CmdPal.Common.Services;
|
||||
/// </summary>
|
||||
public sealed class ApplicationInfoService : IApplicationInfoService
|
||||
{
|
||||
private const string UnpackagedCacheDirectoryName = "Cache";
|
||||
|
||||
private readonly Lazy<string> _cacheDirectory;
|
||||
private readonly Lazy<string> _configDirectory = new(() => Utilities.BaseSettingsPath("Microsoft.CmdPal"));
|
||||
private readonly Lazy<bool> _isElevated;
|
||||
private readonly Lazy<string> _logDirectory;
|
||||
@@ -28,6 +32,7 @@ public sealed class ApplicationInfoService : IApplicationInfoService
|
||||
/// </summary>
|
||||
public ApplicationInfoService()
|
||||
{
|
||||
_cacheDirectory = new Lazy<string>(DetermineCacheDirectory);
|
||||
_packagingFlavor = new Lazy<AppPackagingFlavor>(DeterminePackagingFlavor);
|
||||
_isElevated = new Lazy<bool>(DetermineElevationStatus);
|
||||
_logDirectory = new Lazy<string>(() => _getLogDirectory?.Invoke() ?? "Not available");
|
||||
@@ -62,6 +67,8 @@ public sealed class ApplicationInfoService : IApplicationInfoService
|
||||
|
||||
public string ConfigDirectory => _configDirectory.Value;
|
||||
|
||||
public string CacheDirectory => _cacheDirectory.Value;
|
||||
|
||||
public bool IsElevated => _isElevated.Value;
|
||||
|
||||
public string GetApplicationInfoSummary()
|
||||
@@ -84,9 +91,33 @@ public sealed class ApplicationInfoService : IApplicationInfoService
|
||||
Paths:
|
||||
Log directory: {LogDirectory}
|
||||
Config directory: {ConfigDirectory}
|
||||
Cache directory: {CacheDirectory}
|
||||
""";
|
||||
}
|
||||
|
||||
private string DetermineCacheDirectory()
|
||||
{
|
||||
if (PackagingFlavor != AppPackagingFlavor.Packaged)
|
||||
{
|
||||
return Path.Combine(Utilities.BaseSettingsPath("Microsoft.CmdPal"), UnpackagedCacheDirectoryName);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cacheDirectory = ApplicationData.Current.LocalCacheFolder.Path;
|
||||
if (!string.IsNullOrWhiteSpace(cacheDirectory))
|
||||
{
|
||||
return cacheDirectory;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError("Failed to resolve packaged cache directory", ex);
|
||||
}
|
||||
|
||||
return Path.Combine(Utilities.BaseSettingsPath("Microsoft.CmdPal"), UnpackagedCacheDirectoryName);
|
||||
}
|
||||
|
||||
private static AppPackagingFlavor DeterminePackagingFlavor()
|
||||
{
|
||||
// Try to determine if running as packaged
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
|
||||
internal sealed class CachedHttpFetchResult(CachedHttpResource resource, bool usedFallbackCache)
|
||||
{
|
||||
public CachedHttpResource Resource { get; } = resource;
|
||||
|
||||
public bool UsedFallbackCache { get; } = usedFallbackCache;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
|
||||
internal sealed class CachedHttpResource(string contentPath, string? contentType, bool fromCache, bool wasRevalidated)
|
||||
{
|
||||
public string ContentPath { get; } = Path.GetFullPath(contentPath);
|
||||
|
||||
public Uri ContentUri => new(ContentPath);
|
||||
|
||||
public string? ContentType { get; } = contentType;
|
||||
|
||||
public bool FromCache { get; } = fromCache;
|
||||
|
||||
public bool WasRevalidated { get; } = wasRevalidated;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
|
||||
internal sealed class CachedHttpResourceEntry(
|
||||
Uri resourceUri,
|
||||
string entryDirectory,
|
||||
string metadataPath,
|
||||
string payloadPath,
|
||||
string payloadFileName,
|
||||
HttpResourceCacheMetadata? metadata)
|
||||
{
|
||||
public Uri ResourceUri { get; } = resourceUri;
|
||||
|
||||
public string EntryDirectory { get; } = Path.GetFullPath(entryDirectory);
|
||||
|
||||
public string MetadataPath { get; } = Path.GetFullPath(metadataPath);
|
||||
|
||||
public string PayloadPath { get; } = Path.GetFullPath(payloadPath);
|
||||
|
||||
public string PayloadFileName { get; } = payloadFileName;
|
||||
|
||||
public HttpResourceCacheMetadata? Metadata { get; } = metadata;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
|
||||
internal sealed class HttpResourceCacheMetadata
|
||||
{
|
||||
public string? ContentType { get; set; }
|
||||
|
||||
public string? ETag { get; set; }
|
||||
|
||||
public DateTimeOffset? ExpiresUtc { get; set; }
|
||||
|
||||
public string FileName { get; set; } = "payload.bin";
|
||||
|
||||
public DateTimeOffset? LastModifiedUtc { get; set; }
|
||||
|
||||
public DateTimeOffset LastValidatedUtc { get; set; }
|
||||
|
||||
public string SourceUri { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
|
||||
internal interface IHttpResourceCacheStore
|
||||
{
|
||||
CachedHttpResourceEntry GetEntry(Uri resourceUri, string? fileNameHint = null);
|
||||
|
||||
CachedHttpResource? TryGetFresh(CachedHttpResourceEntry entry, TimeSpan? timeToLiveOverride);
|
||||
|
||||
CachedHttpResource? TryGetCached(CachedHttpResourceEntry entry, bool fromCache, bool wasRevalidated);
|
||||
|
||||
CachedHttpResource? UpdateAfterNotModified(CachedHttpResourceEntry entry, HttpResponseMessage response);
|
||||
|
||||
Task<CachedHttpResource> SaveResponseAsync(CachedHttpResourceEntry entry, HttpResponseMessage response, CancellationToken cancellationToken);
|
||||
|
||||
void Prune(IEnumerable<Uri> retainedResourceUris);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MEL = Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching;
|
||||
|
||||
internal sealed class FileSystemHttpResourceCacheStore : IHttpResourceCacheStore
|
||||
{
|
||||
private const string MetadataFileName = "metadata.json";
|
||||
private const string DefaultPayloadFileName = "payload.bin";
|
||||
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToEnumerateHttpResourceCacheMessage = LoggerMessage.Define<string>(
|
||||
LogLevel.Error,
|
||||
new EventId(1, nameof(LogFailedToEnumerateHttpResourceCache)),
|
||||
"Failed to enumerate HTTP resource cache '{CacheDirectory}' for pruning.");
|
||||
|
||||
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToLoadCachedMetadataMessage = LoggerMessage.Define<string>(
|
||||
LogLevel.Error,
|
||||
new EventId(2, nameof(LogFailedToLoadCachedMetadata)),
|
||||
"Failed to load cached metadata from '{MetadataPath}'.");
|
||||
|
||||
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToSaveCachedMetadataMessage = LoggerMessage.Define<string>(
|
||||
LogLevel.Error,
|
||||
new EventId(3, nameof(LogFailedToSaveCachedMetadata)),
|
||||
"Failed to save cached metadata to '{MetadataPath}'.");
|
||||
|
||||
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToDeleteCachedHttpResourceDirectoryMessage = LoggerMessage.Define<string>(
|
||||
LogLevel.Error,
|
||||
new EventId(4, nameof(LogFailedToDeleteCachedHttpResourceDirectory)),
|
||||
"Failed to delete cached HTTP resource directory '{EntryDirectory}'.");
|
||||
|
||||
private readonly string _cacheDirectory;
|
||||
private readonly TimeSpan _defaultTimeToLive;
|
||||
private readonly MEL.ILogger _logger;
|
||||
|
||||
public FileSystemHttpResourceCacheStore(string cacheDirectory, TimeSpan defaultTimeToLive, MEL.ILogger logger)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_cacheDirectory = cacheDirectory;
|
||||
_defaultTimeToLive = defaultTimeToLive;
|
||||
_logger = logger;
|
||||
|
||||
Directory.CreateDirectory(_cacheDirectory);
|
||||
}
|
||||
|
||||
public CachedHttpResourceEntry GetEntry(Uri resourceUri, string? fileNameHint = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resourceUri);
|
||||
|
||||
var entryDirectory = GetEntryDirectory(resourceUri);
|
||||
Directory.CreateDirectory(entryDirectory);
|
||||
|
||||
var metadataPath = Path.Combine(entryDirectory, MetadataFileName);
|
||||
var metadata = TryLoadMetadata(metadataPath);
|
||||
var payloadFileName = ResolvePayloadFileName(resourceUri, fileNameHint, metadata);
|
||||
var payloadPath = Path.Combine(entryDirectory, payloadFileName);
|
||||
|
||||
return new CachedHttpResourceEntry(
|
||||
resourceUri,
|
||||
entryDirectory,
|
||||
metadataPath,
|
||||
payloadPath,
|
||||
payloadFileName,
|
||||
metadata);
|
||||
}
|
||||
|
||||
public CachedHttpResource? TryGetFresh(CachedHttpResourceEntry entry, TimeSpan? timeToLiveOverride)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
if (!File.Exists(entry.PayloadPath) || !IsFresh(entry.Metadata, timeToLiveOverride))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return CreateCachedResource(entry.PayloadPath, entry.Metadata, fromCache: true, wasRevalidated: false);
|
||||
}
|
||||
|
||||
public CachedHttpResource? TryGetCached(CachedHttpResourceEntry entry, bool fromCache, bool wasRevalidated)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
if (!File.Exists(entry.PayloadPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return CreateCachedResource(entry.PayloadPath, entry.Metadata, fromCache, wasRevalidated);
|
||||
}
|
||||
|
||||
public CachedHttpResource? UpdateAfterNotModified(CachedHttpResourceEntry entry, HttpResponseMessage response)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
|
||||
if (!File.Exists(entry.PayloadPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var refreshedMetadata = UpdateMetadata(entry.Metadata, entry.ResourceUri, response, entry.PayloadFileName, DateTimeOffset.UtcNow);
|
||||
TrySaveMetadata(entry.MetadataPath, refreshedMetadata);
|
||||
return CreateCachedResource(entry.PayloadPath, refreshedMetadata, fromCache: true, wasRevalidated: true);
|
||||
}
|
||||
|
||||
public async Task<CachedHttpResource> SaveResponseAsync(CachedHttpResourceEntry entry, HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
ArgumentNullException.ThrowIfNull(response.Content);
|
||||
|
||||
var tempPath = Path.Combine(entry.EntryDirectory, $"{entry.PayloadFileName}.tmp");
|
||||
try
|
||||
{
|
||||
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using (var destinationStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous))
|
||||
{
|
||||
await sourceStream.CopyToAsync(destinationStream, cancellationToken);
|
||||
}
|
||||
|
||||
File.Move(tempPath, entry.PayloadPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
var updatedMetadata = UpdateMetadata(entry.Metadata, entry.ResourceUri, response, entry.PayloadFileName, DateTimeOffset.UtcNow);
|
||||
TrySaveMetadata(entry.MetadataPath, updatedMetadata);
|
||||
return CreateCachedResource(entry.PayloadPath, updatedMetadata, fromCache: false, wasRevalidated: entry.Metadata is not null);
|
||||
}
|
||||
|
||||
public void Prune(IEnumerable<Uri> retainedResourceUris)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(retainedResourceUris);
|
||||
|
||||
HashSet<string> retainedEntryDirectories = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var retainedResourceUri in retainedResourceUris)
|
||||
{
|
||||
if (!IsSupportedHttpUri(retainedResourceUri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
retainedEntryDirectories.Add(Path.GetFullPath(GetEntryDirectory(retainedResourceUri)));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var entryDirectory in Directory.EnumerateDirectories(_cacheDirectory))
|
||||
{
|
||||
var fullEntryDirectory = Path.GetFullPath(entryDirectory);
|
||||
if (retainedEntryDirectories.Contains(fullEntryDirectory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TryDeleteEntryDirectory(fullEntryDirectory);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DirectoryNotFoundException)
|
||||
{
|
||||
LogFailedToEnumerateHttpResourceCache(_logger, _cacheDirectory, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsFresh(HttpResourceCacheMetadata? metadata, TimeSpan? timeToLiveOverride)
|
||||
{
|
||||
if (metadata is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (metadata.ExpiresUtc is { } expiresUtc)
|
||||
{
|
||||
return expiresUtc > now;
|
||||
}
|
||||
|
||||
var effectiveTimeToLive = timeToLiveOverride ?? _defaultTimeToLive;
|
||||
return metadata.LastValidatedUtc + effectiveTimeToLive > now;
|
||||
}
|
||||
|
||||
private static CachedHttpResource CreateCachedResource(
|
||||
string payloadPath,
|
||||
HttpResourceCacheMetadata? metadata,
|
||||
bool fromCache,
|
||||
bool wasRevalidated)
|
||||
{
|
||||
return new CachedHttpResource(
|
||||
payloadPath,
|
||||
metadata?.ContentType,
|
||||
fromCache,
|
||||
wasRevalidated);
|
||||
}
|
||||
|
||||
private static HttpResourceCacheMetadata UpdateMetadata(
|
||||
HttpResourceCacheMetadata? metadata,
|
||||
Uri resourceUri,
|
||||
HttpResponseMessage response,
|
||||
string payloadFileName,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
return new HttpResourceCacheMetadata
|
||||
{
|
||||
ContentType = response.Content?.Headers.ContentType?.MediaType ?? metadata?.ContentType,
|
||||
ETag = response.Headers.ETag?.ToString() ?? metadata?.ETag,
|
||||
ExpiresUtc = GetExpirationUtc(response, now),
|
||||
FileName = payloadFileName,
|
||||
LastModifiedUtc = response.Content?.Headers.LastModified ?? metadata?.LastModifiedUtc,
|
||||
LastValidatedUtc = now,
|
||||
SourceUri = resourceUri.AbsoluteUri,
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetExpirationUtc(HttpResponseMessage response, DateTimeOffset now)
|
||||
{
|
||||
if (response.Headers.CacheControl?.MaxAge is { } maxAge)
|
||||
{
|
||||
return now + maxAge;
|
||||
}
|
||||
|
||||
return response.Content?.Headers.Expires;
|
||||
}
|
||||
|
||||
private string GetEntryDirectory(Uri resourceUri)
|
||||
{
|
||||
var normalizedResourceName = BuildEntryName(resourceUri);
|
||||
if (normalizedResourceName.Length > 48)
|
||||
{
|
||||
normalizedResourceName = normalizedResourceName[..48];
|
||||
}
|
||||
|
||||
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(resourceUri.AbsoluteUri)));
|
||||
return Path.Combine(_cacheDirectory, $"{normalizedResourceName}_{hash}");
|
||||
}
|
||||
|
||||
private static string BuildEntryName(Uri resourceUri)
|
||||
{
|
||||
var host = SanitizeFileName(resourceUri.Host);
|
||||
var fileName = SanitizeFileName(Path.GetFileName(Uri.UnescapeDataString(resourceUri.AbsolutePath)));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
fileName = DefaultPayloadFileName;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return fileName;
|
||||
}
|
||||
|
||||
return $"{host}_{fileName}";
|
||||
}
|
||||
|
||||
private static string ResolvePayloadFileName(Uri resourceUri, string? fileNameHint, HttpResourceCacheMetadata? metadata)
|
||||
{
|
||||
var candidate = metadata?.FileName;
|
||||
if (!string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
candidate = fileNameHint;
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
candidate = Path.GetFileName(Uri.UnescapeDataString(resourceUri.AbsolutePath));
|
||||
}
|
||||
|
||||
candidate = SanitizeFileName(candidate);
|
||||
return string.IsNullOrWhiteSpace(candidate) ? DefaultPayloadFileName : candidate;
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
StringBuilder builder = new(value.Length);
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var current = value[i];
|
||||
builder.Append(Path.GetInvalidFileNameChars().Contains(current) ? '_' : current);
|
||||
}
|
||||
|
||||
return builder
|
||||
.ToString()
|
||||
.Trim()
|
||||
.Trim('.', ' ');
|
||||
}
|
||||
|
||||
private static bool IsSupportedHttpUri(Uri resourceUri)
|
||||
{
|
||||
return resourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
|| resourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private HttpResourceCacheMetadata? TryLoadMetadata(string metadataPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(metadataPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(metadataPath);
|
||||
return JsonSerializer.Deserialize(json, HttpResourceCacheJsonContext.Default.HttpResourceCacheMetadata) as HttpResourceCacheMetadata;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException)
|
||||
{
|
||||
LogFailedToLoadCachedMetadata(_logger, metadataPath, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void TrySaveMetadata(string metadataPath, HttpResourceCacheMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(metadata, HttpResourceCacheJsonContext.Default.HttpResourceCacheMetadata);
|
||||
File.WriteAllText(metadataPath, json);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
LogFailedToSaveCachedMetadata(_logger, metadataPath, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeleteEntryDirectory(string entryDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(entryDirectory, recursive: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DirectoryNotFoundException)
|
||||
{
|
||||
LogFailedToDeleteCachedHttpResourceDirectory(_logger, entryDirectory, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void LogFailedToEnumerateHttpResourceCache(MEL.ILogger logger, string cacheDirectory, Exception exception)
|
||||
{
|
||||
LogFailedToEnumerateHttpResourceCacheMessage(logger, cacheDirectory, exception);
|
||||
}
|
||||
|
||||
private static void LogFailedToLoadCachedMetadata(MEL.ILogger logger, string metadataPath, Exception exception)
|
||||
{
|
||||
LogFailedToLoadCachedMetadataMessage(logger, metadataPath, exception);
|
||||
}
|
||||
|
||||
private static void LogFailedToSaveCachedMetadata(MEL.ILogger logger, string metadataPath, Exception exception)
|
||||
{
|
||||
LogFailedToSaveCachedMetadataMessage(logger, metadataPath, exception);
|
||||
}
|
||||
|
||||
private static void LogFailedToDeleteCachedHttpResourceDirectory(MEL.ILogger logger, string entryDirectory, Exception exception)
|
||||
{
|
||||
LogFailedToDeleteCachedHttpResourceDirectoryMessage(logger, entryDirectory, exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
using MEL = Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching;
|
||||
|
||||
internal sealed partial class HttpCachingClient : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IHttpResourceCacheStore _cacheStore;
|
||||
private readonly HttpResourceCacheHandler _cacheHandler;
|
||||
|
||||
public HttpCachingClient(
|
||||
string cacheDirectory,
|
||||
TimeSpan defaultTimeToLive,
|
||||
TimeSpan timeout,
|
||||
string? userAgent,
|
||||
HttpMessageHandler? innerHandler,
|
||||
MEL.ILogger logger)
|
||||
: this(
|
||||
new FileSystemHttpResourceCacheStore(cacheDirectory, defaultTimeToLive, logger),
|
||||
timeout,
|
||||
userAgent,
|
||||
innerHandler,
|
||||
logger)
|
||||
{
|
||||
}
|
||||
|
||||
public HttpCachingClient(
|
||||
IHttpResourceCacheStore cacheStore,
|
||||
TimeSpan timeout,
|
||||
string? userAgent,
|
||||
HttpMessageHandler? innerHandler,
|
||||
MEL.ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cacheStore);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_cacheStore = cacheStore;
|
||||
_cacheHandler = new HttpResourceCacheHandler(cacheStore, innerHandler ?? new HttpClientHandler(), logger);
|
||||
_httpClient = new HttpClient(_cacheHandler) { Timeout = timeout };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CachedHttpFetchResult> GetResourceAsync(
|
||||
Uri resourceUri,
|
||||
string? fileNameHint = null,
|
||||
bool forceRefresh = false,
|
||||
TimeSpan? timeToLiveOverride = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resourceUri);
|
||||
|
||||
if (!IsSupportedHttpUri(resourceUri))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported HTTP resource URI scheme '{resourceUri.Scheme}'.");
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, resourceUri);
|
||||
HttpResourceCacheHandler.ConfigureRequest(
|
||||
request,
|
||||
fileNameHint: fileNameHint,
|
||||
forceRefresh: forceRefresh,
|
||||
timeToLiveOverride: timeToLiveOverride);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var cacheInfo = HttpResourceCacheHandler.GetResponseInfo(response);
|
||||
if (cacheInfo.Resource is null)
|
||||
{
|
||||
throw new InvalidOperationException($"The HTTP cache did not produce a cached resource for '{resourceUri}'.");
|
||||
}
|
||||
|
||||
return new CachedHttpFetchResult(cacheInfo.Resource, cacheInfo.UsedFallbackCache);
|
||||
}
|
||||
|
||||
public void Prune(IEnumerable<Uri> retainedResourceUris)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(retainedResourceUris);
|
||||
|
||||
List<Uri> retainedUris = [.. retainedResourceUris];
|
||||
_cacheHandler.AddInflightResourceUris(retainedUris);
|
||||
_cacheStore.Prune(retainedUris);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
|
||||
private static bool IsSupportedHttpUri(Uri resourceUri)
|
||||
{
|
||||
return resourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
|| resourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MEL = Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching;
|
||||
|
||||
internal sealed partial class HttpResourceCacheHandler : DelegatingHandler
|
||||
{
|
||||
private static readonly HttpRequestOptionsKey<HttpResourceCacheRequestOptions> RequestOptionsKey = new("CmdPal.HttpResourceCache.RequestOptions");
|
||||
private static readonly HttpRequestOptionsKey<CachedHttpResponseInfo> ResponseInfoKey = new("CmdPal.HttpResourceCache.ResponseInfo");
|
||||
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToCacheHttpResourceMessage = LoggerMessage.Define<string>(
|
||||
LogLevel.Error,
|
||||
new EventId(0, nameof(LogFailedToCacheHttpResource)),
|
||||
"Failed to cache HTTP resource '{ResourceUri}'.");
|
||||
|
||||
private readonly IHttpResourceCacheStore _cacheStore;
|
||||
private readonly MEL.ILogger _logger;
|
||||
private readonly Lock _lock = new();
|
||||
private readonly Dictionary<string, Task<CachedHttpResponseInfo?>> _inflightFetches = new(StringComparer.Ordinal);
|
||||
|
||||
public HttpResourceCacheHandler(IHttpResourceCacheStore cacheStore, HttpMessageHandler innerHandler, MEL.ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cacheStore);
|
||||
ArgumentNullException.ThrowIfNull(innerHandler);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_cacheStore = cacheStore;
|
||||
_logger = logger;
|
||||
InnerHandler = innerHandler;
|
||||
}
|
||||
|
||||
public static void ConfigureRequest(
|
||||
HttpRequestMessage request,
|
||||
string? fileNameHint = null,
|
||||
bool forceRefresh = false,
|
||||
TimeSpan? timeToLiveOverride = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
request.Options.Set(
|
||||
RequestOptionsKey,
|
||||
new HttpResourceCacheRequestOptions(fileNameHint, forceRefresh, timeToLiveOverride));
|
||||
}
|
||||
|
||||
public static CachedHttpResponseInfo GetResponseInfo(HttpResponseMessage response)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
|
||||
return response.RequestMessage?.Options.TryGetValue(ResponseInfoKey, out var responseInfo) == true
|
||||
? responseInfo
|
||||
: CachedHttpResponseInfo.None;
|
||||
}
|
||||
|
||||
public static bool TryGetResponseInfo(HttpResponseMessage response, [NotNullWhen(true)] out CachedHttpResponseInfo? responseInfo)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
if (response.RequestMessage?.Options.TryGetValue(ResponseInfoKey, out responseInfo) == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
responseInfo = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
internal void AddInflightResourceUris(ICollection<Uri> retainedResourceUris)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(retainedResourceUris);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var inflightKey in _inflightFetches.Keys)
|
||||
{
|
||||
if (!Uri.TryCreate(inflightKey, UriKind.Absolute, out var inflightUri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
retainedResourceUris.Add(inflightUri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!CanCache(request))
|
||||
{
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
var options = request.Options.TryGetValue(RequestOptionsKey, out var requestOptions)
|
||||
? requestOptions
|
||||
: HttpResourceCacheRequestOptions.Default;
|
||||
var fetchResult = await GetOrFetchAsync(request, options, cancellationToken);
|
||||
if (fetchResult?.Resource is null)
|
||||
{
|
||||
throw new HttpRequestException($"Could not reach HTTP resource '{request.RequestUri}'.");
|
||||
}
|
||||
|
||||
return CreateResponse(request, fetchResult);
|
||||
}
|
||||
|
||||
private Task<CachedHttpResponseInfo?> GetOrFetchAsync(
|
||||
HttpRequestMessage request,
|
||||
HttpResourceCacheRequestOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var inflightKey = request.RequestUri!.AbsoluteUri;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_inflightFetches.TryGetValue(inflightKey, out var existingTask))
|
||||
{
|
||||
return existingTask;
|
||||
}
|
||||
|
||||
var fetchTask = GetOrFetchCoreAsync(request, options, cancellationToken);
|
||||
_inflightFetches[inflightKey] = fetchTask;
|
||||
|
||||
_ = fetchTask.ContinueWith(
|
||||
_ =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_inflightFetches.Remove(inflightKey);
|
||||
}
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.None,
|
||||
TaskScheduler.Default);
|
||||
|
||||
return fetchTask;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CachedHttpResponseInfo?> GetOrFetchCoreAsync(
|
||||
HttpRequestMessage request,
|
||||
HttpResourceCacheRequestOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = _cacheStore.GetEntry(request.RequestUri!, options.FileNameHint);
|
||||
if (!options.ForceRefresh && _cacheStore.TryGetFresh(entry, options.TimeToLiveOverride) is { } freshResource)
|
||||
{
|
||||
return new CachedHttpResponseInfo(freshResource, usedFallbackCache: false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var networkRequest = CloneRequest(request, entry.Metadata);
|
||||
using var response = await base.SendAsync(networkRequest, cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotModified)
|
||||
{
|
||||
var revalidatedResource = _cacheStore.UpdateAfterNotModified(entry, response);
|
||||
return revalidatedResource is null
|
||||
? null
|
||||
: new CachedHttpResponseInfo(revalidatedResource, usedFallbackCache: false);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var cachedResource = await _cacheStore.SaveResponseAsync(entry, response, cancellationToken);
|
||||
return new CachedHttpResponseInfo(cachedResource, usedFallbackCache: false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
LogFailedToCacheHttpResource(_logger, request.RequestUri!.AbsoluteUri, ex);
|
||||
|
||||
var cachedResource = _cacheStore.TryGetCached(entry, fromCache: true, wasRevalidated: false);
|
||||
if (cachedResource is not null)
|
||||
{
|
||||
return new CachedHttpResponseInfo(cachedResource, usedFallbackCache: true);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
LogFailedToCacheHttpResource(_logger, request.RequestUri!.AbsoluteUri, ex);
|
||||
|
||||
var cachedResource = _cacheStore.TryGetCached(entry, fromCache: true, wasRevalidated: false);
|
||||
if (cachedResource is not null)
|
||||
{
|
||||
return new CachedHttpResponseInfo(cachedResource, usedFallbackCache: true);
|
||||
}
|
||||
|
||||
throw new HttpRequestException($"Could not reach HTTP resource '{request.RequestUri}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateResponse(HttpRequestMessage request, CachedHttpResponseInfo responseInfo)
|
||||
{
|
||||
var contentStream = new FileStream(
|
||||
responseInfo.Resource!.ContentPath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
81920,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StreamContent(contentStream),
|
||||
RequestMessage = request,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(responseInfo.Resource.ContentType))
|
||||
{
|
||||
response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(responseInfo.Resource.ContentType);
|
||||
}
|
||||
|
||||
request.Options.Set(ResponseInfoKey, responseInfo);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static HttpRequestMessage CloneRequest(HttpRequestMessage request, HttpResourceCacheMetadata? metadata)
|
||||
{
|
||||
var clone = new HttpRequestMessage(request.Method, request.RequestUri)
|
||||
{
|
||||
Version = request.Version,
|
||||
VersionPolicy = request.VersionPolicy,
|
||||
};
|
||||
|
||||
foreach (var header in request.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata?.ETag) && !clone.Headers.Contains("If-None-Match"))
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation("If-None-Match", metadata.ETag);
|
||||
}
|
||||
|
||||
if (metadata?.LastModifiedUtc is { } lastModifiedUtc && clone.Headers.IfModifiedSince is null)
|
||||
{
|
||||
clone.Headers.IfModifiedSince = lastModifiedUtc;
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static bool CanCache(HttpRequestMessage request)
|
||||
{
|
||||
return request.Method == HttpMethod.Get
|
||||
&& request.RequestUri is { } requestUri
|
||||
&& IsSupportedHttpUri(requestUri);
|
||||
}
|
||||
|
||||
private static bool IsSupportedHttpUri(Uri resourceUri)
|
||||
{
|
||||
return resourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
|| resourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void LogFailedToCacheHttpResource(MEL.ILogger logger, string resourceUri, Exception exception)
|
||||
{
|
||||
LogFailedToCacheHttpResourceMessage(logger, resourceUri, exception);
|
||||
}
|
||||
|
||||
private sealed record HttpResourceCacheRequestOptions(string? FileNameHint, bool ForceRefresh, TimeSpan? TimeToLiveOverride)
|
||||
{
|
||||
public static HttpResourceCacheRequestOptions Default { get; } = new(FileNameHint: null, ForceRefresh: false, TimeToLiveOverride: null);
|
||||
}
|
||||
|
||||
internal sealed class CachedHttpResponseInfo(CachedHttpResource? resource, bool usedFallbackCache)
|
||||
{
|
||||
public static CachedHttpResponseInfo None { get; } = new(resource: null, usedFallbackCache: false);
|
||||
|
||||
public CachedHttpResource? Resource { get; } = resource;
|
||||
|
||||
public bool UsedFallbackCache { get; } = usedFallbackCache;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching;
|
||||
|
||||
[JsonSerializable(typeof(HttpResourceCacheMetadata))]
|
||||
internal sealed partial class HttpResourceCacheJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -29,6 +29,12 @@ public interface IApplicationInfoService
|
||||
/// </summary>
|
||||
string ConfigDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory path where application cache files are stored.
|
||||
/// This location should be safe to recreate and should not be used for durable settings.
|
||||
/// </summary>
|
||||
string CacheDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the application is running with administrator privileges.
|
||||
/// </summary>
|
||||
|
||||
@@ -8,21 +8,61 @@ namespace Microsoft.CmdPal.Common.Services;
|
||||
|
||||
public interface IExtensionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the currently cached installed Command Palette extensions.
|
||||
/// </summary>
|
||||
/// <param name="includeDisabledExtensions">True to include disabled extensions in the result.</param>
|
||||
/// <returns>A sequence of installed Command Palette extensions from the current in-memory cache.</returns>
|
||||
Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsync(bool includeDisabledExtensions = false);
|
||||
|
||||
/// <summary>
|
||||
/// Forces a fresh scan of installed Command Palette extensions and updates the in-memory cache.
|
||||
/// </summary>
|
||||
/// <param name="includeDisabledExtensions">True to include disabled extensions in the result.</param>
|
||||
/// <returns>A sequence of installed Command Palette extensions after the cache has been rebuilt.</returns>
|
||||
Task<IEnumerable<IExtensionWrapper>> RefreshInstalledExtensionsAsync(bool includeDisabledExtensions = false);
|
||||
|
||||
// Task<IEnumerable<string>> GetInstalledHomeWidgetPackageFamilyNamesAsync(bool includeDisabledExtensions = false);
|
||||
/// <summary>
|
||||
/// Gets the installed Command Palette extensions for a specific provider type.
|
||||
/// </summary>
|
||||
/// <param name="providerType">The provider type to match.</param>
|
||||
/// <param name="includeDisabledExtensions">True to include disabled extensions in the result.</param>
|
||||
/// <returns>A sequence of installed Command Palette extensions for the requested provider type.</returns>
|
||||
Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsync(Microsoft.CommandPalette.Extensions.ProviderType providerType, bool includeDisabledExtensions = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached installed extension by its unique id.
|
||||
/// </summary>
|
||||
/// <param name="extensionUniqueId">The unique id of the extension to look up.</param>
|
||||
/// <returns>The cached extension if found; otherwise, null.</returns>
|
||||
IExtensionWrapper? GetInstalledExtension(string extensionUniqueId);
|
||||
|
||||
/// <summary>
|
||||
/// Signals running extensions to stop.
|
||||
/// </summary>
|
||||
Task SignalStopExtensionsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Raised when one or more extensions are added to the installed set.
|
||||
/// </summary>
|
||||
event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionAdded;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when one or more extensions are removed from the installed set.
|
||||
/// </summary>
|
||||
event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionRemoved;
|
||||
|
||||
/// <summary>
|
||||
/// Enables an installed extension by unique id.
|
||||
/// </summary>
|
||||
/// <param name="extensionUniqueId">The unique id of the extension to enable.</param>
|
||||
void EnableExtension(string extensionUniqueId);
|
||||
|
||||
/// <summary>
|
||||
/// Disables an installed extension by unique id.
|
||||
/// </summary>
|
||||
/// <param name="extensionUniqueId">The unique id of the extension to disable.</param>
|
||||
void DisableExtension(string extensionUniqueId);
|
||||
|
||||
///// <summary>
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using WindowsPackageManager.Interop;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.WinGet.WindowsPackageManager.Interop;
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Interop;
|
||||
|
||||
#nullable disable
|
||||
internal sealed class ClassModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the interface for the projected class type generated by CsWinRT
|
||||
/// </summary>
|
||||
public Type InterfaceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the projected class type generated by CsWinRT
|
||||
/// </summary>
|
||||
public Type ProjectedClassType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the interface IID for the projected class.
|
||||
/// </summary>
|
||||
public Guid InterfaceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Clsids for each context (e.g. OutOfProcProd, OutOfProcDev)
|
||||
/// </summary>
|
||||
@@ -43,5 +42,5 @@ internal sealed class ClassModel
|
||||
/// Get IID corresponding to the COM object
|
||||
/// </summary>
|
||||
/// <returns>IID.</returns>
|
||||
public Guid GetIid() => InterfaceType.GUID;
|
||||
public Guid GetIid() => InterfaceId;
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.CmdPal.Ext.WinGet.WindowsPackageManager.Interop;
|
||||
using Microsoft.Management.Deployment;
|
||||
|
||||
namespace WindowsPackageManager.Interop;
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Interop;
|
||||
|
||||
internal static class ClassesDefinition
|
||||
{
|
||||
@@ -16,7 +15,7 @@ internal static class ClassesDefinition
|
||||
[typeof(PackageManager)] = new()
|
||||
{
|
||||
ProjectedClassType = typeof(PackageManager),
|
||||
InterfaceType = typeof(IPackageManager),
|
||||
InterfaceId = new Guid("B375E3B9-F2E0-5C93-87A7-B67497F7E593"),
|
||||
Clsids = new Dictionary<ClsidContext, Guid>()
|
||||
{
|
||||
[ClsidContext.Prod] = new Guid("C53A4F16-787E-42A4-B304-29EFFB4BF597"),
|
||||
@@ -27,7 +26,7 @@ internal static class ClassesDefinition
|
||||
[typeof(FindPackagesOptions)] = new()
|
||||
{
|
||||
ProjectedClassType = typeof(FindPackagesOptions),
|
||||
InterfaceType = typeof(IFindPackagesOptions),
|
||||
InterfaceId = new Guid("A5270EDD-7DA7-57A3-BACE-F2593553561F"),
|
||||
Clsids = new Dictionary<ClsidContext, Guid>()
|
||||
{
|
||||
[ClsidContext.Prod] = new Guid("572DED96-9C60-4526-8F92-EE7D91D38C1A"),
|
||||
@@ -38,7 +37,7 @@ internal static class ClassesDefinition
|
||||
[typeof(CreateCompositePackageCatalogOptions)] = new()
|
||||
{
|
||||
ProjectedClassType = typeof(CreateCompositePackageCatalogOptions),
|
||||
InterfaceType = typeof(ICreateCompositePackageCatalogOptions),
|
||||
InterfaceId = new Guid("21ABAA76-089D-51C5-A745-C85EEFE70116"),
|
||||
Clsids = new Dictionary<ClsidContext, Guid>()
|
||||
{
|
||||
[ClsidContext.Prod] = new Guid("526534B8-7E46-47C8-8416-B1685C327D37"),
|
||||
@@ -49,7 +48,7 @@ internal static class ClassesDefinition
|
||||
[typeof(InstallOptions)] = new()
|
||||
{
|
||||
ProjectedClassType = typeof(InstallOptions),
|
||||
InterfaceType = typeof(IInstallOptions),
|
||||
InterfaceId = new Guid("6EE9DB69-AB48-5E72-A474-33A924CD23B3"),
|
||||
Clsids = new Dictionary<ClsidContext, Guid>()
|
||||
{
|
||||
[ClsidContext.Prod] = new Guid("1095F097-EB96-453B-B4E6-1613637F3B14"),
|
||||
@@ -60,7 +59,7 @@ internal static class ClassesDefinition
|
||||
[typeof(UninstallOptions)] = new()
|
||||
{
|
||||
ProjectedClassType = typeof(UninstallOptions),
|
||||
InterfaceType = typeof(IUninstallOptions),
|
||||
InterfaceId = new Guid("3EBC67F0-8339-594B-8A42-F90B69D02BBE"),
|
||||
Clsids = new Dictionary<ClsidContext, Guid>()
|
||||
{
|
||||
[ClsidContext.Prod] = new Guid("E1D9A11E-9F85-4D87-9C17-2B93143ADB8D"),
|
||||
@@ -71,7 +70,7 @@ internal static class ClassesDefinition
|
||||
[typeof(PackageMatchFilter)] = new()
|
||||
{
|
||||
ProjectedClassType = typeof(PackageMatchFilter),
|
||||
InterfaceType = typeof(IPackageMatchFilter),
|
||||
InterfaceId = new Guid("D981ECA3-4DE5-5AD7-967A-698C7D60FC3B"),
|
||||
Clsids = new Dictionary<ClsidContext, Guid>()
|
||||
{
|
||||
[ClsidContext.Prod] = new Guid("D02C9DAF-99DC-429C-B503-4E504E4AB000"),
|
||||
@@ -1,8 +1,8 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace WindowsPackageManager.Interop;
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Interop;
|
||||
|
||||
public enum ClsidContext
|
||||
{
|
||||
@@ -1,11 +1,11 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using Microsoft.Management.Deployment;
|
||||
|
||||
namespace WindowsPackageManager.Interop;
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Factory class for creating WinGet COM objects.
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
@@ -8,7 +8,7 @@ using Windows.Win32;
|
||||
using Windows.Win32.System.Com;
|
||||
using WinRT;
|
||||
|
||||
namespace WindowsPackageManager.Interop;
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Interop;
|
||||
|
||||
public class WindowsPackageManagerStandardFactory : WindowsPackageManagerFactory
|
||||
{
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetNamedLink(
|
||||
string Label,
|
||||
string Url);
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetPackageDetails(
|
||||
string? Name,
|
||||
string? Version,
|
||||
string? Summary,
|
||||
string? Description,
|
||||
string? Publisher,
|
||||
string? PublisherUrl,
|
||||
string? PublisherSupportUrl,
|
||||
string? Author,
|
||||
string? License,
|
||||
string? LicenseUrl,
|
||||
string? PackageUrl,
|
||||
string? ReleaseNotes,
|
||||
string? ReleaseNotesUrl,
|
||||
string? IconUrl,
|
||||
IReadOnlyList<WinGetNamedLink> DocumentationLinks,
|
||||
IReadOnlyList<string> Tags);
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetPackageInfo(
|
||||
WinGetPackageStatus Status,
|
||||
WinGetPackageDetails? Details);
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetPackageOperation(
|
||||
Guid OperationId,
|
||||
string PackageId,
|
||||
string PackageName,
|
||||
WinGetPackageOperationKind Kind,
|
||||
WinGetPackageOperationState State,
|
||||
bool CanCancel,
|
||||
bool IsIndeterminate,
|
||||
uint? ProgressPercent,
|
||||
ulong? BytesDownloaded,
|
||||
ulong? BytesRequired,
|
||||
string? ErrorMessage,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
DateTimeOffset? CompletedAt)
|
||||
{
|
||||
public bool IsCompleted =>
|
||||
State is WinGetPackageOperationState.Succeeded
|
||||
or WinGetPackageOperationState.Failed
|
||||
or WinGetPackageOperationState.Canceled;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public enum WinGetPackageOperationKind
|
||||
{
|
||||
Install = 0,
|
||||
Uninstall = 1,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetPackageOperationResult(
|
||||
bool Succeeded,
|
||||
bool IsUnavailable,
|
||||
string? ErrorMessage);
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public enum WinGetPackageOperationState
|
||||
{
|
||||
Queued = 0,
|
||||
Downloading = 1,
|
||||
Installing = 2,
|
||||
Uninstalling = 3,
|
||||
PostProcessing = 4,
|
||||
Succeeded = 5,
|
||||
Failed = 6,
|
||||
Canceled = 7,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetPackageStatus(
|
||||
bool IsInstalled,
|
||||
bool IsInstalledStateKnown,
|
||||
bool IsUpdateAvailable,
|
||||
bool IsUpdateStateKnown);
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1649:File name should match first type name", Justification = "Generic result type.")]
|
||||
public sealed record WinGetQueryResult<T>(
|
||||
T? Value,
|
||||
bool IsUnavailable,
|
||||
string? ErrorMessage)
|
||||
{
|
||||
public bool IsSuccess => string.IsNullOrWhiteSpace(ErrorMessage);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetServiceState(
|
||||
bool IsAvailable,
|
||||
string? Message);
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Services;
|
||||
|
||||
public interface IWinGetOperationTrackerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current and recently completed WinGet operations started by Command Palette.
|
||||
/// </summary>
|
||||
IReadOnlyList<WinGetPackageOperation> Operations { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a new tracked WinGet operation starts.
|
||||
/// </summary>
|
||||
event EventHandler<WinGetPackageOperationEventArgs>? OperationStarted;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a tracked WinGet operation reports new progress.
|
||||
/// </summary>
|
||||
event EventHandler<WinGetPackageOperationEventArgs>? OperationUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a tracked WinGet operation completes.
|
||||
/// </summary>
|
||||
event EventHandler<WinGetPackageOperationEventArgs>? OperationCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the newest tracked operation for a WinGet package id.
|
||||
/// </summary>
|
||||
/// <param name="packageId">The WinGet package id.</param>
|
||||
/// <returns>The newest tracked operation for the package, or null when none is tracked.</returns>
|
||||
WinGetPackageOperation? GetLatestOperation(string packageId);
|
||||
|
||||
/// <summary>
|
||||
/// Requests cancellation for a tracked WinGet operation when supported.
|
||||
/// </summary>
|
||||
/// <param name="operationId">The tracked operation id.</param>
|
||||
/// <returns>True when a cancellation request was issued; otherwise, false.</returns>
|
||||
bool TryCancelOperation(Guid operationId);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
using Microsoft.Management.Deployment;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Services;
|
||||
|
||||
public interface IWinGetPackageManagerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current WinGet availability for this machine.
|
||||
/// </summary>
|
||||
WinGetServiceState State { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Searches WinGet packages using the shared package manager infrastructure.
|
||||
/// </summary>
|
||||
/// <param name="query">The search text.</param>
|
||||
/// <param name="tag">An optional package tag filter.</param>
|
||||
/// <param name="includeStoreCatalog">True to include the Store catalog in the composite search.</param>
|
||||
/// <param name="resultLimit">The maximum number of results to return.</param>
|
||||
/// <param name="cancellationToken">A token that cancels the search.</param>
|
||||
/// <returns>A query result containing matching packages or availability information.</returns>
|
||||
Task<WinGetQueryResult<IReadOnlyList<CatalogPackage>>> SearchPackagesAsync(
|
||||
string query,
|
||||
string? tag = null,
|
||||
bool includeStoreCatalog = true,
|
||||
uint resultLimit = 25,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves packages by WinGet package id.
|
||||
/// </summary>
|
||||
/// <param name="packageIds">The package ids to resolve.</param>
|
||||
/// <param name="includeStoreCatalog">True to include the Store catalog in the lookup.</param>
|
||||
/// <param name="cancellationToken">A token that cancels the lookup.</param>
|
||||
/// <returns>A query result containing the resolved packages keyed by package id.</returns>
|
||||
Task<WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>> GetPackagesByIdAsync(
|
||||
IEnumerable<string> packageIds,
|
||||
bool includeStoreCatalog = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Installs or updates the provided package and refreshes package catalogs when possible.
|
||||
/// </summary>
|
||||
/// <param name="package">The package to install or update.</param>
|
||||
/// <param name="skipDependencies">True to skip dependent packages when supported.</param>
|
||||
/// <param name="progressHandler">An optional callback that receives install progress updates.</param>
|
||||
/// <param name="cancellationToken">A token that cancels the install or update.</param>
|
||||
/// <returns>The final result of the install or update operation.</returns>
|
||||
Task<WinGetPackageOperationResult> InstallPackageAsync(
|
||||
CatalogPackage package,
|
||||
bool skipDependencies = false,
|
||||
Action<InstallProgress>? progressHandler = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Uninstalls the provided package and refreshes package catalogs when possible.
|
||||
/// </summary>
|
||||
/// <param name="package">The package to uninstall.</param>
|
||||
/// <param name="progressHandler">An optional callback that receives uninstall progress updates.</param>
|
||||
/// <param name="cancellationToken">A token that cancels the uninstall.</param>
|
||||
/// <returns>The final result of the uninstall operation.</returns>
|
||||
Task<WinGetPackageOperationResult> UninstallPackageAsync(
|
||||
CatalogPackage package,
|
||||
Action<UninstallProgress>? progressHandler = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes WinGet package catalogs when supported and clears cached composite catalogs.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token that cancels the refresh.</param>
|
||||
/// <returns>True when catalog refresh was attempted successfully; otherwise, false.</returns>
|
||||
Task<bool> RefreshCatalogsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Services;
|
||||
|
||||
public interface IWinGetPackageStatusService
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to resolve WinGet package information for the provided package ids.
|
||||
/// Returns null when WinGet lookups are unavailable.
|
||||
/// </summary>
|
||||
/// <param name="packageIds">The WinGet package ids to resolve.</param>
|
||||
/// <param name="cancellationToken">A token that cancels the lookup.</param>
|
||||
/// <returns>A package-info map keyed by package id, or null when status lookups are unavailable.</returns>
|
||||
Task<IReadOnlyDictionary<string, WinGetPackageInfo>?> TryGetPackageInfosAsync(
|
||||
IEnumerable<string> packageIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to resolve WinGet install/update status for the provided package ids.
|
||||
/// Returns null when status detection is unavailable.
|
||||
/// </summary>
|
||||
/// <param name="packageIds">The WinGet package ids to inspect.</param>
|
||||
/// <param name="cancellationToken">A token that cancels the lookup.</param>
|
||||
/// <returns>A package-status map keyed by package id, or null when status detection is unavailable.</returns>
|
||||
Task<IReadOnlyDictionary<string, WinGetPackageStatus>?> TryGetPackageStatusesAsync(
|
||||
IEnumerable<string> packageIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Services;
|
||||
|
||||
public sealed class WinGetOperationTrackerService : IWinGetOperationTrackerService
|
||||
{
|
||||
private const int MaxTrackedOperations = 100;
|
||||
|
||||
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
private readonly Lock _operationsLock = new();
|
||||
private readonly List<WinGetPackageOperation> _operations = [];
|
||||
private readonly Dictionary<Guid, Action> _cancelCallbacks = [];
|
||||
|
||||
public event EventHandler<WinGetPackageOperationEventArgs>? OperationStarted;
|
||||
|
||||
public event EventHandler<WinGetPackageOperationEventArgs>? OperationUpdated;
|
||||
|
||||
public event EventHandler<WinGetPackageOperationEventArgs>? OperationCompleted;
|
||||
|
||||
public IReadOnlyList<WinGetPackageOperation> Operations
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_operationsLock)
|
||||
{
|
||||
return _operations.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public WinGetPackageOperation? GetLatestOperation(string packageId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packageId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (_operationsLock)
|
||||
{
|
||||
for (var i = 0; i < _operations.Count; i++)
|
||||
{
|
||||
if (OrdinalIgnoreCase.Equals(_operations[i].PackageId, packageId))
|
||||
{
|
||||
return _operations[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal WinGetPackageOperation StartOperation(string packageId, string packageName, WinGetPackageOperationKind kind)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var operation = new WinGetPackageOperation(
|
||||
OperationId: Guid.NewGuid(),
|
||||
PackageId: packageId,
|
||||
PackageName: packageName,
|
||||
Kind: kind,
|
||||
State: WinGetPackageOperationState.Queued,
|
||||
CanCancel: false,
|
||||
IsIndeterminate: true,
|
||||
ProgressPercent: null,
|
||||
BytesDownloaded: null,
|
||||
BytesRequired: null,
|
||||
ErrorMessage: null,
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
CompletedAt: null);
|
||||
|
||||
lock (_operationsLock)
|
||||
{
|
||||
_operations.Insert(0, operation);
|
||||
TrimCompletedOperationsNoLock();
|
||||
}
|
||||
|
||||
OperationStarted?.Invoke(this, new WinGetPackageOperationEventArgs(operation));
|
||||
return operation;
|
||||
}
|
||||
|
||||
public bool TryCancelOperation(Guid operationId)
|
||||
{
|
||||
Action? cancelCallback = null;
|
||||
WinGetPackageOperation? updated = null;
|
||||
|
||||
lock (_operationsLock)
|
||||
{
|
||||
var index = FindOperationIndexNoLock(operationId);
|
||||
if (index < 0 || _operations[index].IsCompleted || !_cancelCallbacks.Remove(operationId, out cancelCallback) || cancelCallback is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
updated = _operations[index] with
|
||||
{
|
||||
CanCancel = false,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
_operations[index] = updated;
|
||||
}
|
||||
|
||||
OperationUpdated?.Invoke(this, new WinGetPackageOperationEventArgs(updated));
|
||||
|
||||
try
|
||||
{
|
||||
cancelCallback();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogWarning($"Failed to cancel WinGet operation '{operationId}': {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal WinGetPackageOperation? RegisterCancellationHandler(Guid operationId, Action cancelCallback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cancelCallback);
|
||||
|
||||
WinGetPackageOperation? updated = null;
|
||||
|
||||
lock (_operationsLock)
|
||||
{
|
||||
var index = FindOperationIndexNoLock(operationId);
|
||||
if (index < 0 || _operations[index].IsCompleted)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_cancelCallbacks[operationId] = cancelCallback;
|
||||
updated = _operations[index] with
|
||||
{
|
||||
CanCancel = true,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
_operations[index] = updated;
|
||||
}
|
||||
|
||||
OperationUpdated?.Invoke(this, new WinGetPackageOperationEventArgs(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
internal WinGetPackageOperation? UpdateOperation(
|
||||
Guid operationId,
|
||||
WinGetPackageOperationState state,
|
||||
bool isIndeterminate,
|
||||
uint? progressPercent = null,
|
||||
ulong? bytesDownloaded = null,
|
||||
ulong? bytesRequired = null)
|
||||
{
|
||||
WinGetPackageOperation? updated = null;
|
||||
|
||||
lock (_operationsLock)
|
||||
{
|
||||
var index = FindOperationIndexNoLock(operationId);
|
||||
if (index < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
updated = _operations[index] with
|
||||
{
|
||||
State = state,
|
||||
CanCancel = _operations[index].CanCancel,
|
||||
IsIndeterminate = isIndeterminate,
|
||||
ProgressPercent = progressPercent,
|
||||
BytesDownloaded = bytesDownloaded,
|
||||
BytesRequired = bytesRequired,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
_operations[index] = updated;
|
||||
}
|
||||
|
||||
OperationUpdated?.Invoke(this, new WinGetPackageOperationEventArgs(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
internal WinGetPackageOperation? CompleteOperation(Guid operationId, WinGetPackageOperationState state, string? errorMessage = null)
|
||||
{
|
||||
WinGetPackageOperation? completed = null;
|
||||
|
||||
lock (_operationsLock)
|
||||
{
|
||||
var index = FindOperationIndexNoLock(operationId);
|
||||
if (index < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_cancelCallbacks.Remove(operationId);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
completed = _operations[index] with
|
||||
{
|
||||
State = state,
|
||||
CanCancel = false,
|
||||
IsIndeterminate = false,
|
||||
ProgressPercent = state == WinGetPackageOperationState.Succeeded ? 100u : _operations[index].ProgressPercent,
|
||||
ErrorMessage = errorMessage,
|
||||
UpdatedAt = now,
|
||||
CompletedAt = now,
|
||||
};
|
||||
|
||||
_operations[index] = completed;
|
||||
TrimCompletedOperationsNoLock();
|
||||
}
|
||||
|
||||
OperationCompleted?.Invoke(this, new WinGetPackageOperationEventArgs(completed));
|
||||
return completed;
|
||||
}
|
||||
|
||||
private int FindOperationIndexNoLock(Guid operationId)
|
||||
{
|
||||
for (var i = 0; i < _operations.Count; i++)
|
||||
{
|
||||
if (_operations[i].OperationId == operationId)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void TrimCompletedOperationsNoLock()
|
||||
{
|
||||
if (_operations.Count <= MaxTrackedOperations)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = _operations.Count - 1; i >= 0 && _operations.Count > MaxTrackedOperations; i--)
|
||||
{
|
||||
if (_operations[i].IsCompleted)
|
||||
{
|
||||
_operations.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.CmdPal.Common.WinGet;
|
||||
using Microsoft.CmdPal.Common.WinGet.Interop;
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
using Microsoft.Management.Deployment;
|
||||
using Windows.Foundation;
|
||||
using Windows.Foundation.Metadata;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Services;
|
||||
|
||||
public sealed class WinGetPackageManagerService : IWinGetPackageManagerService
|
||||
{
|
||||
private const string WinGetUnavailableMessage = "WinGet is unavailable. Install or repair App Installer to search and install packages.";
|
||||
private const string WinGetCatalogUnavailableMessage = "WinGet couldn't connect to its package catalogs. Check App Installer and your internet connection, then try again.";
|
||||
|
||||
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
private readonly Func<WindowsPackageManagerFactory?> _factoryProvider;
|
||||
private readonly WinGetOperationTrackerService _operationTracker;
|
||||
private readonly Lazy<InitializationState> _initialization;
|
||||
private readonly object _allCatalogTaskLock = new();
|
||||
private readonly object _wingetCatalogTaskLock = new();
|
||||
|
||||
private Task<WinGetQueryResult<PackageCatalog>>? _allCatalogTask;
|
||||
private Task<WinGetQueryResult<PackageCatalog>>? _wingetCatalogTask;
|
||||
|
||||
public WinGetPackageManagerService()
|
||||
: this(CreateFactory, new WinGetOperationTrackerService())
|
||||
{
|
||||
}
|
||||
|
||||
public WinGetPackageManagerService(WinGetOperationTrackerService operationTracker)
|
||||
: this(CreateFactory, operationTracker)
|
||||
{
|
||||
}
|
||||
|
||||
internal WinGetPackageManagerService(Func<WindowsPackageManagerFactory?>? factoryProvider, WinGetOperationTrackerService? operationTracker = null)
|
||||
{
|
||||
_factoryProvider = factoryProvider ?? CreateFactory;
|
||||
_operationTracker = operationTracker ?? new WinGetOperationTrackerService();
|
||||
_initialization = new Lazy<InitializationState>(Initialize, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
public WinGetServiceState State => _initialization.Value.State;
|
||||
|
||||
public async Task<WinGetQueryResult<IReadOnlyList<CatalogPackage>>> SearchPackagesAsync(
|
||||
string query,
|
||||
string? tag = null,
|
||||
bool includeStoreCatalog = true,
|
||||
uint resultLimit = 25,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
return new WinGetQueryResult<IReadOnlyList<CatalogPackage>>([], false, null);
|
||||
}
|
||||
|
||||
var catalogResult = await GetCompositeCatalogResultAsync(includeStoreCatalog, cancellationToken).ConfigureAwait(false);
|
||||
if (!catalogResult.IsSuccess || catalogResult.Value is null)
|
||||
{
|
||||
return new WinGetQueryResult<IReadOnlyList<CatalogPackage>>(null, catalogResult.IsUnavailable, catalogResult.ErrorMessage);
|
||||
}
|
||||
|
||||
var initialization = _initialization.Value;
|
||||
if (!initialization.State.IsAvailable || initialization.Factory is null)
|
||||
{
|
||||
return new WinGetQueryResult<IReadOnlyList<CatalogPackage>>(null, true, initialization.State.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var options = initialization.Factory.CreateFindPackagesOptions();
|
||||
options.ResultLimit = resultLimit;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
var selector = initialization.Factory.CreatePackageMatchFilter();
|
||||
selector.Field = PackageMatchField.CatalogDefault;
|
||||
selector.Value = query;
|
||||
selector.Option = PackageFieldMatchOption.ContainsCaseInsensitive;
|
||||
options.Selectors.Add(selector);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
var tagFilter = initialization.Factory.CreatePackageMatchFilter();
|
||||
tagFilter.Field = PackageMatchField.Tag;
|
||||
tagFilter.Value = tag;
|
||||
tagFilter.Option = PackageFieldMatchOption.ContainsCaseInsensitive;
|
||||
options.Filters.Add(tagFilter);
|
||||
}
|
||||
|
||||
var findResult = await Task.Run(() => catalogResult.Value.FindPackages(options), cancellationToken).ConfigureAwait(false);
|
||||
if (findResult.Status != FindPackagesResultStatus.Ok)
|
||||
{
|
||||
return new WinGetQueryResult<IReadOnlyList<CatalogPackage>>(
|
||||
null,
|
||||
false,
|
||||
$"WinGet search failed: {findResult.Status}");
|
||||
}
|
||||
|
||||
Dictionary<string, CatalogPackage> results = new(OrdinalIgnoreCase);
|
||||
for (var i = 0; i < findResult.Matches.Count; i++)
|
||||
{
|
||||
var package = findResult.Matches[i].CatalogPackage;
|
||||
results.TryAdd(CreatePackageKey(package), package);
|
||||
}
|
||||
|
||||
return new WinGetQueryResult<IReadOnlyList<CatalogPackage>>(
|
||||
new ReadOnlyCollection<CatalogPackage>(results.Values.ToList()),
|
||||
false,
|
||||
null);
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or InvalidOperationException or TaskCanceledException)
|
||||
{
|
||||
CoreLogger.LogWarning($"WinGet search failed: {ex.Message}");
|
||||
return new WinGetQueryResult<IReadOnlyList<CatalogPackage>>(null, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>> GetPackagesByIdAsync(
|
||||
IEnumerable<string> packageIds,
|
||||
bool includeStoreCatalog = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedIds = NormalizePackageIds(packageIds);
|
||||
if (normalizedIds.Count == 0)
|
||||
{
|
||||
return new WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>(
|
||||
new Dictionary<string, CatalogPackage>(OrdinalIgnoreCase),
|
||||
false,
|
||||
null);
|
||||
}
|
||||
|
||||
var catalogResult = await GetCompositeCatalogResultAsync(includeStoreCatalog, cancellationToken).ConfigureAwait(false);
|
||||
if (!catalogResult.IsSuccess || catalogResult.Value is null)
|
||||
{
|
||||
return new WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>(null, catalogResult.IsUnavailable, catalogResult.ErrorMessage);
|
||||
}
|
||||
|
||||
var initialization = _initialization.Value;
|
||||
if (!initialization.State.IsAvailable || initialization.Factory is null)
|
||||
{
|
||||
return new WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>(null, true, initialization.State.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var options = initialization.Factory.CreateFindPackagesOptions();
|
||||
options.ResultLimit = (uint)normalizedIds.Count;
|
||||
|
||||
for (var i = 0; i < normalizedIds.Count; i++)
|
||||
{
|
||||
var selector = initialization.Factory.CreatePackageMatchFilter();
|
||||
selector.Field = PackageMatchField.Id;
|
||||
selector.Option = PackageFieldMatchOption.EqualsCaseInsensitive;
|
||||
selector.Value = normalizedIds[i];
|
||||
options.Selectors.Add(selector);
|
||||
}
|
||||
|
||||
var findResult = await Task.Run(() => catalogResult.Value.FindPackages(options), cancellationToken).ConfigureAwait(false);
|
||||
if (findResult.Status != FindPackagesResultStatus.Ok)
|
||||
{
|
||||
return new WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>(
|
||||
null,
|
||||
false,
|
||||
$"WinGet package lookup failed: {findResult.Status}");
|
||||
}
|
||||
|
||||
Dictionary<string, CatalogPackage> results = new(OrdinalIgnoreCase);
|
||||
for (var i = 0; i < findResult.Matches.Count; i++)
|
||||
{
|
||||
var package = findResult.Matches[i].CatalogPackage;
|
||||
if (!results.ContainsKey(package.Id))
|
||||
{
|
||||
results[package.Id] = package;
|
||||
}
|
||||
}
|
||||
|
||||
return new WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>(results, false, null);
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or InvalidOperationException or TaskCanceledException)
|
||||
{
|
||||
CoreLogger.LogWarning($"WinGet package lookup failed: {ex.Message}");
|
||||
return new WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>(null, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WinGetPackageOperationResult> InstallPackageAsync(
|
||||
CatalogPackage package,
|
||||
bool skipDependencies = false,
|
||||
Action<InstallProgress>? progressHandler = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var trackedOperation = _operationTracker.StartOperation(
|
||||
package.Id,
|
||||
WinGetPackageMetadataHelper.GetPackageDisplayName(package),
|
||||
WinGetPackageOperationKind.Install);
|
||||
|
||||
var initialization = _initialization.Value;
|
||||
if (!initialization.State.IsAvailable || initialization.Factory is null || initialization.PackageManager is null)
|
||||
{
|
||||
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Failed, initialization.State.Message);
|
||||
return new WinGetPackageOperationResult(false, true, initialization.State.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var installOptions = initialization.Factory.CreateInstallOptions();
|
||||
installOptions.PackageInstallScope = PackageInstallScope.Any;
|
||||
installOptions.SkipDependencies = skipDependencies;
|
||||
|
||||
var operation = initialization.PackageManager.InstallPackageAsync(package, installOptions);
|
||||
_operationTracker.RegisterCancellationHandler(trackedOperation.OperationId, operation.Cancel);
|
||||
operation.Progress = new AsyncOperationProgressHandler<InstallResult, InstallProgress>((_, progress) =>
|
||||
{
|
||||
UpdateTrackedInstallOperation(trackedOperation.OperationId, progress);
|
||||
progressHandler?.Invoke(progress);
|
||||
});
|
||||
|
||||
await operation.AsTask().ConfigureAwait(false);
|
||||
await RefreshCatalogsAsync(cancellationToken).ConfigureAwait(false);
|
||||
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Succeeded);
|
||||
|
||||
return new WinGetPackageOperationResult(true, false, null);
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
CoreLogger.LogWarning($"WinGet install canceled for '{package.Id}': {ex.Message}");
|
||||
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Canceled, ex.Message);
|
||||
return new WinGetPackageOperationResult(false, false, ex.Message);
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or InvalidOperationException or TaskCanceledException)
|
||||
{
|
||||
CoreLogger.LogWarning($"WinGet install failed for '{package.Id}': {ex.Message}");
|
||||
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Failed, ex.Message);
|
||||
return new WinGetPackageOperationResult(false, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WinGetPackageOperationResult> UninstallPackageAsync(
|
||||
CatalogPackage package,
|
||||
Action<UninstallProgress>? progressHandler = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var trackedOperation = _operationTracker.StartOperation(
|
||||
package.Id,
|
||||
WinGetPackageMetadataHelper.GetPackageDisplayName(package),
|
||||
WinGetPackageOperationKind.Uninstall);
|
||||
|
||||
var initialization = _initialization.Value;
|
||||
if (!initialization.State.IsAvailable || initialization.Factory is null || initialization.PackageManager is null)
|
||||
{
|
||||
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Failed, initialization.State.Message);
|
||||
return new WinGetPackageOperationResult(false, true, initialization.State.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var uninstallOptions = initialization.Factory.CreateUninstallOptions();
|
||||
uninstallOptions.PackageUninstallScope = PackageUninstallScope.Any;
|
||||
|
||||
var operation = initialization.PackageManager.UninstallPackageAsync(package, uninstallOptions);
|
||||
_operationTracker.RegisterCancellationHandler(trackedOperation.OperationId, operation.Cancel);
|
||||
operation.Progress = new AsyncOperationProgressHandler<UninstallResult, UninstallProgress>((_, progress) =>
|
||||
{
|
||||
UpdateTrackedUninstallOperation(trackedOperation.OperationId, progress);
|
||||
progressHandler?.Invoke(progress);
|
||||
});
|
||||
|
||||
await operation.AsTask().ConfigureAwait(false);
|
||||
await RefreshCatalogsAsync(cancellationToken).ConfigureAwait(false);
|
||||
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Succeeded);
|
||||
|
||||
return new WinGetPackageOperationResult(true, false, null);
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
CoreLogger.LogWarning($"WinGet uninstall canceled for '{package.Id}': {ex.Message}");
|
||||
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Canceled, ex.Message);
|
||||
return new WinGetPackageOperationResult(false, false, ex.Message);
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or InvalidOperationException or TaskCanceledException)
|
||||
{
|
||||
CoreLogger.LogWarning($"WinGet uninstall failed for '{package.Id}': {ex.Message}");
|
||||
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Failed, ex.Message);
|
||||
return new WinGetPackageOperationResult(false, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> RefreshCatalogsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ClearCompositeCatalogCache();
|
||||
|
||||
var initialization = _initialization.Value;
|
||||
if (!initialization.State.IsAvailable || initialization.AvailableCatalogs.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ApiInformation.IsApiContractPresent("Microsoft.Management.Deployment.WindowsPackageManagerContract", 12))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < initialization.AvailableCatalogs.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await initialization.AvailableCatalogs[i].RefreshPackageCatalogAsync().AsTask().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or InvalidOperationException or TaskCanceledException)
|
||||
{
|
||||
CoreLogger.LogWarning($"WinGet catalog refresh failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static WindowsPackageManagerFactory? CreateFactory()
|
||||
{
|
||||
try
|
||||
{
|
||||
return new WindowsPackageManagerStandardFactory();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogWarning($"Failed to initialize WinGet API factory: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private InitializationState Initialize()
|
||||
{
|
||||
try
|
||||
{
|
||||
var factory = _factoryProvider();
|
||||
if (factory is null)
|
||||
{
|
||||
return InitializationState.Unavailable(WinGetUnavailableMessage);
|
||||
}
|
||||
|
||||
var packageManager = factory.CreatePackageManager();
|
||||
var wingetCatalog = packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.OpenWindowsCatalog);
|
||||
|
||||
List<PackageCatalogReference> availableCatalogs = [wingetCatalog];
|
||||
PackageCatalogReference? storeCatalog = null;
|
||||
|
||||
try
|
||||
{
|
||||
storeCatalog = packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.MicrosoftStore);
|
||||
availableCatalogs.Add(storeCatalog);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogWarning($"Failed to initialize Microsoft Store catalog: {ex.Message}");
|
||||
}
|
||||
|
||||
if (ApiInformation.IsApiContractPresent("Microsoft.Management.Deployment.WindowsPackageManagerContract", 8))
|
||||
{
|
||||
foreach (var catalogReference in availableCatalogs)
|
||||
{
|
||||
catalogReference.PackageCatalogBackgroundUpdateInterval = new(0);
|
||||
}
|
||||
}
|
||||
|
||||
return InitializationState.Available(
|
||||
factory,
|
||||
packageManager,
|
||||
wingetCatalog,
|
||||
storeCatalog,
|
||||
availableCatalogs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogWarning($"Failed to initialize WinGet package manager: {ex.Message}");
|
||||
return InitializationState.Unavailable(WinGetUnavailableMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<WinGetQueryResult<PackageCatalog>> GetCompositeCatalogResultAsync(bool includeStoreCatalog, CancellationToken cancellationToken)
|
||||
{
|
||||
Task<WinGetQueryResult<PackageCatalog>> task;
|
||||
if (includeStoreCatalog)
|
||||
{
|
||||
lock (_allCatalogTaskLock)
|
||||
{
|
||||
_allCatalogTask ??= CreateCompositeCatalogAsync(includeStoreCatalog, cancellationToken);
|
||||
task = _allCatalogTask;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (_wingetCatalogTaskLock)
|
||||
{
|
||||
_wingetCatalogTask ??= CreateCompositeCatalogAsync(includeStoreCatalog, cancellationToken);
|
||||
task = _wingetCatalogTask;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await task.ConfigureAwait(false);
|
||||
if (!result.IsSuccess || result.Value is null)
|
||||
{
|
||||
ClearCachedCompositeCatalogTask(includeStoreCatalog, task);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<WinGetQueryResult<PackageCatalog>> CreateCompositeCatalogAsync(bool includeStoreCatalog, CancellationToken cancellationToken)
|
||||
{
|
||||
var initialization = _initialization.Value;
|
||||
if (!initialization.State.IsAvailable || initialization.Factory is null || initialization.PackageManager is null || initialization.WingetCatalog is null)
|
||||
{
|
||||
return new WinGetQueryResult<PackageCatalog>(null, true, initialization.State.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var options = initialization.Factory.CreateCreateCompositePackageCatalogOptions();
|
||||
options.CompositeSearchBehavior = CompositeSearchBehavior.RemotePackagesFromAllCatalogs;
|
||||
options.Catalogs.Add(initialization.WingetCatalog);
|
||||
|
||||
if (includeStoreCatalog && initialization.StoreCatalog is not null)
|
||||
{
|
||||
options.Catalogs.Add(initialization.StoreCatalog);
|
||||
}
|
||||
|
||||
var compositeCatalogReference = initialization.PackageManager.CreateCompositePackageCatalog(options);
|
||||
var connectResult = await compositeCatalogReference.ConnectAsync().AsTask().ConfigureAwait(false);
|
||||
if (connectResult.Status != ConnectResultStatus.Ok || connectResult.PackageCatalog is null)
|
||||
{
|
||||
var message = connectResult.Status == ConnectResultStatus.CatalogError ?
|
||||
WinGetCatalogUnavailableMessage :
|
||||
$"WinGet catalog connection failed: {connectResult.Status}";
|
||||
CoreLogger.LogWarning(message);
|
||||
return new WinGetQueryResult<PackageCatalog>(null, false, message);
|
||||
}
|
||||
|
||||
return new WinGetQueryResult<PackageCatalog>(connectResult.PackageCatalog, false, null);
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or InvalidOperationException or TaskCanceledException)
|
||||
{
|
||||
CoreLogger.LogWarning($"Failed to create WinGet composite catalog: {ex.Message}");
|
||||
return new WinGetQueryResult<PackageCatalog>(null, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearCompositeCatalogCache()
|
||||
{
|
||||
lock (_allCatalogTaskLock)
|
||||
{
|
||||
_allCatalogTask = null;
|
||||
}
|
||||
|
||||
lock (_wingetCatalogTaskLock)
|
||||
{
|
||||
_wingetCatalogTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearCachedCompositeCatalogTask(bool includeStoreCatalog, Task<WinGetQueryResult<PackageCatalog>> task)
|
||||
{
|
||||
if (includeStoreCatalog)
|
||||
{
|
||||
lock (_allCatalogTaskLock)
|
||||
{
|
||||
if (ReferenceEquals(_allCatalogTask, task))
|
||||
{
|
||||
_allCatalogTask = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (_wingetCatalogTaskLock)
|
||||
{
|
||||
if (ReferenceEquals(_wingetCatalogTask, task))
|
||||
{
|
||||
_wingetCatalogTask = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> NormalizePackageIds(IEnumerable<string> packageIds)
|
||||
{
|
||||
List<string> normalized = [];
|
||||
HashSet<string> seen = new(OrdinalIgnoreCase);
|
||||
|
||||
foreach (var candidate in packageIds)
|
||||
{
|
||||
var trimmed = ToNullIfWhiteSpace(candidate);
|
||||
if (trimmed is null || !seen.Add(trimmed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(trimmed);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string CreatePackageKey(CatalogPackage package)
|
||||
{
|
||||
var catalogId =
|
||||
TryGetCatalogId(() => package.DefaultInstallVersion?.PackageCatalog?.Info?.Id)
|
||||
?? TryGetCatalogId(() => package.InstalledVersion?.PackageCatalog?.Info?.Id)
|
||||
?? string.Empty;
|
||||
|
||||
return string.Concat(package.Id, "\u001F", catalogId);
|
||||
}
|
||||
|
||||
private static string? TryGetCatalogId(Func<string?> getter)
|
||||
{
|
||||
try
|
||||
{
|
||||
return getter();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ToNullIfWhiteSpace(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private void UpdateTrackedInstallOperation(Guid operationId, InstallProgress progress)
|
||||
{
|
||||
switch (progress.State)
|
||||
{
|
||||
case PackageInstallProgressState.Queued:
|
||||
_operationTracker.UpdateOperation(operationId, WinGetPackageOperationState.Queued, isIndeterminate: true);
|
||||
break;
|
||||
case PackageInstallProgressState.Downloading:
|
||||
{
|
||||
var progressPercent = progress.BytesRequired > 0
|
||||
? (uint?)Math.Min(100, (progress.BytesDownloaded * 100UL) / progress.BytesRequired)
|
||||
: null;
|
||||
_operationTracker.UpdateOperation(
|
||||
operationId,
|
||||
WinGetPackageOperationState.Downloading,
|
||||
isIndeterminate: progress.BytesRequired == 0,
|
||||
progressPercent: progressPercent,
|
||||
bytesDownloaded: progress.BytesDownloaded,
|
||||
bytesRequired: progress.BytesRequired);
|
||||
break;
|
||||
}
|
||||
|
||||
case PackageInstallProgressState.Installing:
|
||||
_operationTracker.UpdateOperation(operationId, WinGetPackageOperationState.Installing, isIndeterminate: true);
|
||||
break;
|
||||
case PackageInstallProgressState.PostInstall:
|
||||
case PackageInstallProgressState.Finished:
|
||||
_operationTracker.UpdateOperation(operationId, WinGetPackageOperationState.PostProcessing, isIndeterminate: true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateTrackedUninstallOperation(Guid operationId, UninstallProgress progress)
|
||||
{
|
||||
switch (progress.State)
|
||||
{
|
||||
case PackageUninstallProgressState.Queued:
|
||||
_operationTracker.UpdateOperation(operationId, WinGetPackageOperationState.Queued, isIndeterminate: true);
|
||||
break;
|
||||
case PackageUninstallProgressState.Uninstalling:
|
||||
_operationTracker.UpdateOperation(operationId, WinGetPackageOperationState.Uninstalling, isIndeterminate: true);
|
||||
break;
|
||||
case PackageUninstallProgressState.PostUninstall:
|
||||
case PackageUninstallProgressState.Finished:
|
||||
_operationTracker.UpdateOperation(operationId, WinGetPackageOperationState.PostProcessing, isIndeterminate: true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record InitializationState(
|
||||
WinGetServiceState State,
|
||||
WindowsPackageManagerFactory? Factory,
|
||||
PackageManager? PackageManager,
|
||||
PackageCatalogReference? WingetCatalog,
|
||||
PackageCatalogReference? StoreCatalog,
|
||||
IReadOnlyList<PackageCatalogReference> AvailableCatalogs)
|
||||
{
|
||||
public static InitializationState Available(
|
||||
WindowsPackageManagerFactory factory,
|
||||
PackageManager packageManager,
|
||||
PackageCatalogReference wingetCatalog,
|
||||
PackageCatalogReference? storeCatalog,
|
||||
IReadOnlyList<PackageCatalogReference> availableCatalogs)
|
||||
{
|
||||
return new InitializationState(
|
||||
new WinGetServiceState(true, Message: null),
|
||||
factory,
|
||||
packageManager,
|
||||
wingetCatalog,
|
||||
storeCatalog,
|
||||
availableCatalogs);
|
||||
}
|
||||
|
||||
public static InitializationState Unavailable(string message)
|
||||
{
|
||||
return new InitializationState(
|
||||
new WinGetServiceState(false, message),
|
||||
Factory: null,
|
||||
PackageManager: null,
|
||||
WingetCatalog: null,
|
||||
StoreCatalog: null,
|
||||
AvailableCatalogs: []);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
using Microsoft.Management.Deployment;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Services;
|
||||
|
||||
internal static class WinGetPackageMetadataHelper
|
||||
{
|
||||
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public static async Task<WinGetPackageStatus> InspectPackageStatusAsync(CatalogPackage package)
|
||||
{
|
||||
try
|
||||
{
|
||||
await package.CheckInstalledStatusAsync();
|
||||
var isInstalled = package.InstalledVersion is not null;
|
||||
return new WinGetPackageStatus(
|
||||
IsInstalled: isInstalled,
|
||||
IsInstalledStateKnown: true,
|
||||
IsUpdateAvailable: isInstalled && package.IsUpdateAvailable,
|
||||
IsUpdateStateKnown: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogWarning($"Failed to inspect package status '{package.Id}': {ex.Message}");
|
||||
return new WinGetPackageStatus(
|
||||
IsInstalled: false,
|
||||
IsInstalledStateKnown: false,
|
||||
IsUpdateAvailable: false,
|
||||
IsUpdateStateKnown: false);
|
||||
}
|
||||
}
|
||||
|
||||
public static WinGetPackageDetails? TryBuildPackageDetails(CatalogPackage package)
|
||||
{
|
||||
try
|
||||
{
|
||||
var defaultVersion = TryGetRef(() => package.DefaultInstallVersion);
|
||||
var installedVersion = TryGetRef(() => package.InstalledVersion);
|
||||
var packageVersion = defaultVersion ?? installedVersion;
|
||||
|
||||
var packageName = ToNullIfWhiteSpace(TryGetString(() => package.Name));
|
||||
var version = packageVersion is not null ? ToNullIfWhiteSpace(TryGetString(() => packageVersion.Version)) : null;
|
||||
|
||||
if (packageVersion is null)
|
||||
{
|
||||
if (packageName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WinGetPackageDetails(
|
||||
Name: packageName,
|
||||
Version: version,
|
||||
Summary: null,
|
||||
Description: null,
|
||||
Publisher: null,
|
||||
PublisherUrl: null,
|
||||
PublisherSupportUrl: null,
|
||||
Author: null,
|
||||
License: null,
|
||||
LicenseUrl: null,
|
||||
PackageUrl: null,
|
||||
ReleaseNotes: null,
|
||||
ReleaseNotesUrl: null,
|
||||
IconUrl: null,
|
||||
DocumentationLinks: [],
|
||||
Tags: []);
|
||||
}
|
||||
|
||||
var metadata = TryGetRef(() => packageVersion.GetCatalogPackageMetadata());
|
||||
if (metadata is null)
|
||||
{
|
||||
if (packageName is null && version is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WinGetPackageDetails(
|
||||
Name: packageName,
|
||||
Version: version,
|
||||
Summary: null,
|
||||
Description: null,
|
||||
Publisher: null,
|
||||
PublisherUrl: null,
|
||||
PublisherSupportUrl: null,
|
||||
Author: null,
|
||||
License: null,
|
||||
LicenseUrl: null,
|
||||
PackageUrl: null,
|
||||
ReleaseNotes: null,
|
||||
ReleaseNotesUrl: null,
|
||||
IconUrl: null,
|
||||
DocumentationLinks: [],
|
||||
Tags: []);
|
||||
}
|
||||
|
||||
List<WinGetNamedLink> documentationLinks = [];
|
||||
var docs = TryGetRef(() => metadata.Documentations);
|
||||
if (docs is not null)
|
||||
{
|
||||
for (var i = 0; i < docs.Count; i++)
|
||||
{
|
||||
var doc = docs[i];
|
||||
var url = ToNullIfWhiteSpace(TryGetString(() => doc.DocumentUrl));
|
||||
if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var label = ToNullIfWhiteSpace(TryGetString(() => doc.DocumentLabel)) ?? url;
|
||||
documentationLinks.Add(new WinGetNamedLink(label, url));
|
||||
}
|
||||
}
|
||||
|
||||
List<string> tags = [];
|
||||
var metadataTags = TryGetRef(() => metadata.Tags);
|
||||
if (metadataTags is not null)
|
||||
{
|
||||
for (var i = 0; i < metadataTags.Count; i++)
|
||||
{
|
||||
var tag = ToNullIfWhiteSpace(metadataTags[i]);
|
||||
if (tag is null || ContainsIgnoreCase(tags, tag))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tags.Add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
var iconUrl = TryResolveIconUrl(metadata);
|
||||
var summary = ToNullIfWhiteSpace(TryGetString(() => metadata.ShortDescription));
|
||||
var description = ToNullIfWhiteSpace(TryGetString(() => metadata.Description));
|
||||
var releaseNotes = ToNullIfWhiteSpace(TryGetString(() => metadata.ReleaseNotes));
|
||||
if (releaseNotes is not null && releaseNotes.Length > 800)
|
||||
{
|
||||
releaseNotes = string.Concat(releaseNotes.AsSpan(0, 800), "...");
|
||||
}
|
||||
|
||||
var details = new WinGetPackageDetails(
|
||||
Name: ToNullIfWhiteSpace(TryGetString(() => metadata.PackageName)) ?? packageName,
|
||||
Version: version,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Publisher: ToNullIfWhiteSpace(TryGetString(() => metadata.Publisher)),
|
||||
PublisherUrl: ValidateAbsoluteUri(TryGetString(() => metadata.PublisherUrl)),
|
||||
PublisherSupportUrl: ValidateAbsoluteUri(TryGetString(() => metadata.PublisherSupportUrl)),
|
||||
Author: ToNullIfWhiteSpace(TryGetString(() => metadata.Author)),
|
||||
License: ToNullIfWhiteSpace(TryGetString(() => metadata.License)),
|
||||
LicenseUrl: ValidateAbsoluteUri(TryGetString(() => metadata.LicenseUrl)),
|
||||
PackageUrl: ValidateAbsoluteUri(TryGetString(() => metadata.PackageUrl)),
|
||||
ReleaseNotes: releaseNotes,
|
||||
ReleaseNotesUrl: ValidateAbsoluteUri(TryGetString(() => metadata.ReleaseNotesUrl)),
|
||||
IconUrl: iconUrl,
|
||||
DocumentationLinks: documentationLinks,
|
||||
Tags: tags);
|
||||
|
||||
return HasDetailsContent(details) ? details : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogWarning($"Failed to build package metadata: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetPackageDisplayName(CatalogPackage package)
|
||||
{
|
||||
var name = ToNullIfWhiteSpace(TryGetString(() => package.Name));
|
||||
return name ?? package.Id;
|
||||
}
|
||||
|
||||
private static bool HasDetailsContent(WinGetPackageDetails details)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(details.Name)
|
||||
|| !string.IsNullOrWhiteSpace(details.Version)
|
||||
|| !string.IsNullOrWhiteSpace(details.Summary)
|
||||
|| !string.IsNullOrWhiteSpace(details.Description)
|
||||
|| !string.IsNullOrWhiteSpace(details.Publisher)
|
||||
|| !string.IsNullOrWhiteSpace(details.PublisherUrl)
|
||||
|| !string.IsNullOrWhiteSpace(details.PublisherSupportUrl)
|
||||
|| !string.IsNullOrWhiteSpace(details.Author)
|
||||
|| !string.IsNullOrWhiteSpace(details.License)
|
||||
|| !string.IsNullOrWhiteSpace(details.LicenseUrl)
|
||||
|| !string.IsNullOrWhiteSpace(details.PackageUrl)
|
||||
|| !string.IsNullOrWhiteSpace(details.ReleaseNotes)
|
||||
|| !string.IsNullOrWhiteSpace(details.ReleaseNotesUrl)
|
||||
|| !string.IsNullOrWhiteSpace(details.IconUrl)
|
||||
|| details.DocumentationLinks.Count > 0
|
||||
|| details.Tags.Count > 0;
|
||||
}
|
||||
|
||||
private static string? TryResolveIconUrl(CatalogPackageMetadata metadata)
|
||||
{
|
||||
var icons = TryGetRef(() => metadata.Icons);
|
||||
if (icons is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var i = 0; i < icons.Count; i++)
|
||||
{
|
||||
var icon = icons[i];
|
||||
var url = ValidateAbsoluteUri(TryGetString(() => icon.Url));
|
||||
if (url is not null)
|
||||
{
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static T? TryGetRef<T>(Func<T> getter)
|
||||
where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
return getter();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetString(Func<string> getter)
|
||||
{
|
||||
try
|
||||
{
|
||||
return getter();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ValidateAbsoluteUri(string? value)
|
||||
{
|
||||
var normalized = ToNullIfWhiteSpace(value);
|
||||
if (normalized is null || !Uri.TryCreate(normalized, UriKind.Absolute, out _))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? ToNullIfWhiteSpace(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static bool ContainsIgnoreCase(IReadOnlyList<string> values, string candidate)
|
||||
{
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
if (OrdinalIgnoreCase.Equals(values[i], candidate))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Services;
|
||||
|
||||
public sealed class WinGetPackageOperationEventArgs(WinGetPackageOperation operation) : EventArgs
|
||||
{
|
||||
public WinGetPackageOperation Operation { get; } = operation;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
using Microsoft.Management.Deployment;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Services;
|
||||
|
||||
public sealed class WinGetPackageStatusService : IWinGetPackageStatusService
|
||||
{
|
||||
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
private readonly IWinGetPackageManagerService _packageManagerService;
|
||||
|
||||
public WinGetPackageStatusService(IWinGetPackageManagerService packageManagerService)
|
||||
{
|
||||
_packageManagerService = packageManagerService;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, WinGetPackageInfo>?> TryGetPackageInfosAsync(
|
||||
IEnumerable<string> packageIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedIds = NormalizePackageIds(packageIds);
|
||||
if (normalizedIds.Count == 0)
|
||||
{
|
||||
return new Dictionary<string, WinGetPackageInfo>(OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return await TryGetInfosViaWinGetApiAsync(normalizedIds, _packageManagerService, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, WinGetPackageStatus>?> TryGetPackageStatusesAsync(
|
||||
IEnumerable<string> packageIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var infos = await TryGetPackageInfosAsync(packageIds, cancellationToken);
|
||||
if (infos is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, WinGetPackageStatus> statuses = new(OrdinalIgnoreCase);
|
||||
foreach (var pair in infos)
|
||||
{
|
||||
statuses[pair.Key] = pair.Value.Status;
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyDictionary<string, WinGetPackageInfo>?> TryGetInfosViaWinGetApiAsync(
|
||||
IReadOnlyList<string> packageIds,
|
||||
IWinGetPackageManagerService packageManagerService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var packagesResult = await packageManagerService.GetPackagesByIdAsync(packageIds, includeStoreCatalog: false, cancellationToken);
|
||||
if (!packagesResult.IsSuccess || packagesResult.Value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Dictionary<string, WinGetPackageInfo> results = new(OrdinalIgnoreCase);
|
||||
for (var i = 0; i < packageIds.Count; i++)
|
||||
{
|
||||
var packageId = packageIds[i];
|
||||
var status = new WinGetPackageStatus(
|
||||
IsInstalled: false,
|
||||
IsInstalledStateKnown: true,
|
||||
IsUpdateAvailable: false,
|
||||
IsUpdateStateKnown: true);
|
||||
results[packageId] = new WinGetPackageInfo(status, Details: null);
|
||||
}
|
||||
|
||||
foreach (var package in packagesResult.Value.Values)
|
||||
{
|
||||
if (!results.ContainsKey(package.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
results[package.Id] = await InspectPackageAsync(package);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or COMException or TaskCanceledException)
|
||||
{
|
||||
CoreLogger.LogWarning($"WinGet API package info query failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<WinGetPackageInfo> InspectPackageAsync(CatalogPackage package)
|
||||
{
|
||||
var status = await WinGetPackageMetadataHelper.InspectPackageStatusAsync(package);
|
||||
var details = WinGetPackageMetadataHelper.TryBuildPackageDetails(package);
|
||||
return new WinGetPackageInfo(status, details);
|
||||
}
|
||||
|
||||
private static List<string> NormalizePackageIds(IEnumerable<string> packageIds)
|
||||
{
|
||||
List<string> normalized = [];
|
||||
HashSet<string> seen = new(OrdinalIgnoreCase);
|
||||
|
||||
foreach (var candidate in packageIds)
|
||||
{
|
||||
var trimmed = ToNullIfWhiteSpace(candidate);
|
||||
if (trimmed is null || !seen.Add(trimmed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(trimmed);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? ToNullIfWhiteSpace(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet;
|
||||
|
||||
public static class WinGetPackageTags
|
||||
{
|
||||
public const string CommandPaletteExtension = "windows-commandpalette-extension";
|
||||
}
|
||||
@@ -60,7 +60,19 @@ public abstract partial class AppExtensionHost : IExtensionHost
|
||||
return Task.CompletedTask.AsAsyncAction();
|
||||
}
|
||||
|
||||
CoreLogger.LogDebug(message.Message);
|
||||
switch (message.State)
|
||||
{
|
||||
case MessageState.Error:
|
||||
CoreLogger.LogError(message.Message);
|
||||
break;
|
||||
case MessageState.Warning:
|
||||
CoreLogger.LogWarning(message.Message);
|
||||
break;
|
||||
case MessageState.Info:
|
||||
default:
|
||||
CoreLogger.LogInfo(message.Message);
|
||||
break;
|
||||
}
|
||||
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ public class CommandPalettePageViewModelFactory
|
||||
MainListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext, _contextMenuFactory) { IsRootPage = !nested, IsMainPage = true },
|
||||
IListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext, _contextMenuFactory) { IsRootPage = !nested },
|
||||
IContentPage contentPage => new CommandPaletteContentPageViewModel(contentPage, _scheduler, host, providerContext),
|
||||
IParametersPage paramsPage => new ParametersPageViewModel(paramsPage, _scheduler, host, providerContext, _contextMenuFactory),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
SupportsPinning = true;
|
||||
|
||||
// Load pinned commands from saved settings
|
||||
pinnedCommands = LoadPinnedCommands(four, providerSettings);
|
||||
pinnedCommands = LoadPinnedCommands(four, settingsService.Settings);
|
||||
}
|
||||
|
||||
Id = model.Id;
|
||||
@@ -261,12 +261,6 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
}
|
||||
}
|
||||
|
||||
private record TopLevelObjects(
|
||||
ICommandItem[]? Commands,
|
||||
IFallbackCommandItem[]? Fallbacks,
|
||||
ICommandItem[]? PinnedCommands,
|
||||
ICommandItem[]? DockBands);
|
||||
|
||||
private void InitializeCommands(
|
||||
TopLevelObjects objects,
|
||||
IServiceProvider serviceProvider,
|
||||
@@ -295,7 +289,24 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
|
||||
if (objects.PinnedCommands is not null)
|
||||
{
|
||||
topLevelList.AddRange(objects.PinnedCommands.Select(c => make(c, TopLevelType.Normal)));
|
||||
foreach (var pinnedCommand in objects.PinnedCommands)
|
||||
{
|
||||
var pinnedItem = make(pinnedCommand, TopLevelType.Normal);
|
||||
var alreadyExists = false;
|
||||
foreach (var existingItem in topLevelList)
|
||||
{
|
||||
if (existingItem.Id == pinnedItem.Id)
|
||||
{
|
||||
alreadyExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!alreadyExists)
|
||||
{
|
||||
topLevelList.Add(pinnedItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TopLevelItems = topLevelList.ToArray();
|
||||
@@ -398,11 +409,11 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
return null;
|
||||
}
|
||||
|
||||
private ICommandItem[] LoadPinnedCommands(ICommandProvider4 model, ProviderSettings providerSettings)
|
||||
private ICommandItem[] LoadPinnedCommands(ICommandProvider4 model, SettingsModel settings)
|
||||
{
|
||||
var pinnedItems = new List<ICommandItem>();
|
||||
|
||||
foreach (var pinnedId in providerSettings.PinnedCommandIds)
|
||||
foreach (var pinnedId in settings.GetPinnedCommandIds(ProviderId))
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -441,71 +452,66 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
public void PinCommand(string commandId, IServiceProvider serviceProvider)
|
||||
{
|
||||
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
|
||||
var providerSettings = GetProviderSettings(settingsService.Settings);
|
||||
|
||||
if (!providerSettings.PinnedCommandIds.Contains(commandId))
|
||||
if (settingsService.Settings.IsCommandPinned(ProviderId, commandId))
|
||||
{
|
||||
settingsService.UpdateSettings(
|
||||
s =>
|
||||
{
|
||||
if (!s.ProviderSettings.TryGetValue(ProviderId, out var ps))
|
||||
{
|
||||
ps = new ProviderSettings();
|
||||
}
|
||||
|
||||
var providerSettings = ps.WithConnection(this);
|
||||
var newPinned = providerSettings.PinnedCommandIds.Add(commandId);
|
||||
var newPs = providerSettings with { PinnedCommandIds = newPinned };
|
||||
|
||||
return s with
|
||||
{
|
||||
ProviderSettings = s.ProviderSettings.SetItem(ProviderId, newPs),
|
||||
};
|
||||
},
|
||||
hotReload: false);
|
||||
|
||||
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public void UnpinCommand(string commandId, IServiceProvider serviceProvider)
|
||||
{
|
||||
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
|
||||
|
||||
settingsService.UpdateSettings(
|
||||
s =>
|
||||
{
|
||||
if (!s.ProviderSettings.TryGetValue(ProviderId, out var ps))
|
||||
{
|
||||
ps = new ProviderSettings();
|
||||
}
|
||||
|
||||
var providerSettings = ps.WithConnection(this);
|
||||
var newPinned = providerSettings.PinnedCommandIds.Remove(commandId);
|
||||
var newPs = providerSettings with { PinnedCommandIds = newPinned };
|
||||
|
||||
return s with
|
||||
{
|
||||
ProviderSettings = s.ProviderSettings.SetItem(ProviderId, newPs),
|
||||
};
|
||||
},
|
||||
s => s.TryPinCommand(ProviderId, commandId),
|
||||
hotReload: false);
|
||||
|
||||
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
}
|
||||
|
||||
public void PinDockBand(string commandId, IServiceProvider serviceProvider, Dock.DockPinSide side = Dock.DockPinSide.Start, bool? showTitles = null, bool? showSubtitles = null)
|
||||
public void UnpinCommand(string commandId, IServiceProvider serviceProvider)
|
||||
{
|
||||
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
|
||||
if (!settingsService.Settings.IsCommandPinned(ProviderId, commandId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
settingsService.UpdateSettings(
|
||||
s => s.TryUnpinCommand(ProviderId, commandId),
|
||||
hotReload: false);
|
||||
|
||||
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
}
|
||||
|
||||
public void PinDockBand(string commandId, IServiceProvider serviceProvider, Dock.DockPinSide side = Dock.DockPinSide.Start, bool? showTitles = null, bool? showSubtitles = null, string? monitorDeviceId = null)
|
||||
{
|
||||
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
|
||||
var settings = settingsService.Settings;
|
||||
var dockSettings = settings.DockSettings;
|
||||
|
||||
// Prevent duplicate pins — check all sections
|
||||
if (dockSettings.StartBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
|
||||
dockSettings.CenterBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
|
||||
dockSettings.EndBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId))
|
||||
// Prevent duplicate pins — check the target destination's bands.
|
||||
// When pinning to a specific monitor, check that monitor's resolved bands
|
||||
// (which include forked-from-global bands). Otherwise, check global bands.
|
||||
DockMonitorConfig? targetConfig = null;
|
||||
if (monitorDeviceId is not null)
|
||||
{
|
||||
foreach (var cfg in dockSettings.MonitorConfigs)
|
||||
{
|
||||
if (string.Equals(cfg.MonitorDeviceId, monitorDeviceId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
targetConfig = cfg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var resolvedStart = targetConfig?.ResolveStartBands(dockSettings.StartBands) ?? dockSettings.StartBands;
|
||||
var resolvedCenter = targetConfig?.ResolveCenterBands(dockSettings.CenterBands) ?? dockSettings.CenterBands;
|
||||
var resolvedEnd = targetConfig?.ResolveEndBands(dockSettings.EndBands) ?? dockSettings.EndBands;
|
||||
|
||||
var alreadyPinned = resolvedStart.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
|
||||
resolvedCenter.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
|
||||
resolvedEnd.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId);
|
||||
|
||||
if (alreadyPinned)
|
||||
{
|
||||
Logger.LogDebug($"Dock band '{commandId}' from provider '{this.ProviderId}' is already pinned; skipping.");
|
||||
return;
|
||||
@@ -519,6 +525,21 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
ShowSubtitles = showSubtitles,
|
||||
};
|
||||
|
||||
if (monitorDeviceId is not null)
|
||||
{
|
||||
PinDockBandToMonitor(settingsService, bandSettings, side, monitorDeviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
PinDockBandGlobal(settingsService, bandSettings, side);
|
||||
}
|
||||
|
||||
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
}
|
||||
|
||||
private static void PinDockBandGlobal(ISettingsService settingsService, DockBandSettings bandSettings, Dock.DockPinSide side)
|
||||
{
|
||||
settingsService.UpdateSettings(
|
||||
s =>
|
||||
{
|
||||
@@ -534,9 +555,59 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
};
|
||||
},
|
||||
hotReload: false);
|
||||
}
|
||||
|
||||
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
private static void PinDockBandToMonitor(ISettingsService settingsService, DockBandSettings bandSettings, Dock.DockPinSide side, string monitorDeviceId)
|
||||
{
|
||||
settingsService.UpdateSettings(
|
||||
s =>
|
||||
{
|
||||
var dockSettings = s.DockSettings;
|
||||
var configs = dockSettings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
|
||||
|
||||
// Find or create the monitor config
|
||||
DockMonitorConfig? target = null;
|
||||
var targetIndex = -1;
|
||||
for (var i = 0; i < configs.Count; i++)
|
||||
{
|
||||
if (string.Equals(configs[i].MonitorDeviceId, monitorDeviceId, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
target = configs[i];
|
||||
targetIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (target is null)
|
||||
{
|
||||
// Monitor not yet in config; create and fork from global
|
||||
target = new DockMonitorConfig { MonitorDeviceId = monitorDeviceId, Enabled = true };
|
||||
target = target.ForkFromGlobal(dockSettings);
|
||||
configs = configs.Add(target);
|
||||
targetIndex = configs.Count - 1;
|
||||
}
|
||||
else if (!target.IsCustomized)
|
||||
{
|
||||
// Fork from global on first per-monitor customization
|
||||
target = target.ForkFromGlobal(dockSettings);
|
||||
}
|
||||
|
||||
// Add band to the appropriate section
|
||||
target = side switch
|
||||
{
|
||||
Dock.DockPinSide.Center => target with { CenterBands = (target.CenterBands ?? System.Collections.Immutable.ImmutableList<DockBandSettings>.Empty).Add(bandSettings) },
|
||||
Dock.DockPinSide.End => target with { EndBands = (target.EndBands ?? System.Collections.Immutable.ImmutableList<DockBandSettings>.Empty).Add(bandSettings) },
|
||||
_ => target with { StartBands = (target.StartBands ?? System.Collections.Immutable.ImmutableList<DockBandSettings>.Empty).Add(bandSettings) },
|
||||
};
|
||||
|
||||
configs = configs.SetItem(targetIndex, target);
|
||||
|
||||
return s with
|
||||
{
|
||||
DockSettings = dockSettings with { MonitorConfigs = configs },
|
||||
};
|
||||
},
|
||||
hotReload: false);
|
||||
}
|
||||
|
||||
public void UnpinDockBand(string commandId, IServiceProvider serviceProvider)
|
||||
@@ -587,4 +658,10 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
this.DockBandItems = bands.ToArray();
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs());
|
||||
}
|
||||
|
||||
private record TopLevelObjects(
|
||||
ICommandItem[]? Commands,
|
||||
IFallbackCommandItem[]? Fallbacks,
|
||||
ICommandItem[]? PinnedCommands,
|
||||
ICommandItem[]? DockBands);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,10 @@ public partial class CommandViewModel : ExtensionObjectViewModel
|
||||
var iconInfo = model.Icon;
|
||||
Icon = new(iconInfo);
|
||||
Icon.InitializeProperties();
|
||||
break;
|
||||
case nameof(Properties):
|
||||
UpdatePropertiesFromExtension(model as IExtendedAttributesProvider);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
|
||||
public sealed partial class BuiltInsCommandProvider : CommandProvider
|
||||
{
|
||||
private readonly OpenSettingsCommand openSettings = new();
|
||||
private readonly OpenGallerySettingsCommand openGallerySettings = new();
|
||||
private readonly QuitCommand quitCommand = new();
|
||||
private readonly FallbackReloadItem _fallbackReloadItem = new();
|
||||
private readonly FallbackLogItem _fallbackLogItem = new();
|
||||
@@ -23,6 +24,7 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
|
||||
public override ICommandItem[] TopLevelCommands() =>
|
||||
[
|
||||
new CommandItem(openSettings) { },
|
||||
new CommandItem(openGallerySettings) { },
|
||||
new CommandItem(_newExtension) { Title = _newExtension.Title },
|
||||
];
|
||||
|
||||
|
||||
@@ -51,10 +51,15 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
|
||||
// Stable separator instances so that the VM cache and InPlaceUpdateList
|
||||
// recognise them across successive GetItems() calls
|
||||
private readonly Separator _pinnedSeparator = new(Resources.home_sections_pinned_title);
|
||||
private readonly Separator _resultsSeparator = new(Resources.results);
|
||||
private readonly Separator _fallbacksSeparator = new(Resources.fallbacks);
|
||||
private readonly Separator _commandsSeparator = new(Resources.home_sections_commands_title);
|
||||
|
||||
private TopLevelViewModel[]? _cachedPinnedViewModels;
|
||||
private TopLevelViewModel[]? _cachedRegularViewModels;
|
||||
private bool _defaultViewDirty = true;
|
||||
|
||||
private RoScored<IListItem>[]? _filteredItems;
|
||||
private RoScored<IListItem>[]? _filteredApps;
|
||||
|
||||
@@ -100,6 +105,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
|
||||
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
|
||||
_tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged;
|
||||
_tlcManager.PinnedCommands.CollectionChanged += PinnedCommands_CollectionChanged;
|
||||
|
||||
_refreshThrottledDebouncedAction = new ThrottledDebouncedAction(
|
||||
() =>
|
||||
@@ -166,8 +172,15 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
}
|
||||
|
||||
private void PinnedCommands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
_defaultViewDirty = true;
|
||||
RaiseItemsChanged();
|
||||
}
|
||||
|
||||
private void Commands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
_defaultViewDirty = true;
|
||||
_includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId);
|
||||
if (_includeApps != _filteredItemsIncludesApps)
|
||||
{
|
||||
@@ -238,65 +251,108 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
{
|
||||
lock (_tlcManager.TopLevelCommands)
|
||||
{
|
||||
// Either return the top-level commands (no search text), or the merged and
|
||||
// filtered results.
|
||||
if (string.IsNullOrWhiteSpace(SearchText))
|
||||
return string.IsNullOrWhiteSpace(SearchText) ? GetDefaultViewItems() : GetSearchViewItems();
|
||||
}
|
||||
}
|
||||
|
||||
private IListItem[] GetSearchViewItems()
|
||||
{
|
||||
var validScoredFallbacks = _scoredFallbackItems?
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
|
||||
.ToList();
|
||||
|
||||
var validFallbacks = _fallbackItems?
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
|
||||
.ToList();
|
||||
|
||||
return MainListPageResultFactory.Create(
|
||||
_filteredItems,
|
||||
validScoredFallbacks,
|
||||
_filteredApps,
|
||||
validFallbacks,
|
||||
_resultsSeparator,
|
||||
_fallbacksSeparator,
|
||||
AppResultLimit);
|
||||
}
|
||||
|
||||
private IListItem[] GetDefaultViewItems()
|
||||
{
|
||||
if (_defaultViewDirty)
|
||||
{
|
||||
RebuildDefaultViewCache();
|
||||
}
|
||||
|
||||
var pinned = _cachedPinnedViewModels!;
|
||||
var regular = _cachedRegularViewModels!;
|
||||
var pinnedCount = pinned.Length;
|
||||
var regularCount = regular.Length;
|
||||
|
||||
var sectionCount = (pinnedCount > 0 ? 1 : 0) + (regularCount > 0 ? 1 : 0);
|
||||
if (sectionCount == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new IListItem[pinnedCount + regularCount + sectionCount];
|
||||
var writeIndex = 0;
|
||||
if (pinnedCount > 0)
|
||||
{
|
||||
result[writeIndex++] = _pinnedSeparator;
|
||||
Array.Copy(pinned, 0, result, writeIndex, pinnedCount);
|
||||
writeIndex += pinnedCount;
|
||||
}
|
||||
|
||||
if (regularCount > 0)
|
||||
{
|
||||
result[writeIndex++] = _commandsSeparator;
|
||||
Array.Copy(regular, 0, result, writeIndex, regularCount);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void RebuildDefaultViewCache()
|
||||
{
|
||||
var allCommands = _tlcManager.TopLevelCommands;
|
||||
var pinnedSettings = _tlcManager.PinnedCommands;
|
||||
|
||||
// Resolve pinned VMs in settings order
|
||||
var pinned = new List<TopLevelViewModel>(pinnedSettings.Count);
|
||||
for (var i = 0; i < pinnedSettings.Count; i++)
|
||||
{
|
||||
var s = pinnedSettings[i];
|
||||
for (var j = 0; j < allCommands.Count; j++)
|
||||
{
|
||||
var allCommands = _tlcManager.TopLevelCommands;
|
||||
|
||||
// First pass: count eligible commands
|
||||
var eligibleCount = 0;
|
||||
for (var i = 0; i < allCommands.Count; i++)
|
||||
var cmd = allCommands[j];
|
||||
if (IsEligibleTopLevelCommand(cmd) &&
|
||||
cmd.CommandProviderId == s.ProviderId &&
|
||||
cmd.Id == s.CommandId)
|
||||
{
|
||||
var cmd = allCommands[i];
|
||||
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
|
||||
{
|
||||
eligibleCount++;
|
||||
}
|
||||
pinned.Add(cmd);
|
||||
break;
|
||||
}
|
||||
|
||||
if (eligibleCount == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// +1 for the separator
|
||||
var result = new IListItem[eligibleCount + 1];
|
||||
result[0] = _commandsSeparator;
|
||||
|
||||
// Second pass: populate
|
||||
var writeIndex = 1;
|
||||
for (var i = 0; i < allCommands.Count; i++)
|
||||
{
|
||||
var cmd = allCommands[i];
|
||||
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
|
||||
{
|
||||
result[writeIndex++] = cmd;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
else
|
||||
{
|
||||
var validScoredFallbacks = _scoredFallbackItems?
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
|
||||
.ToList();
|
||||
|
||||
var validFallbacks = _fallbackItems?
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
|
||||
.ToList();
|
||||
|
||||
return MainListPageResultFactory.Create(
|
||||
_filteredItems,
|
||||
validScoredFallbacks,
|
||||
_filteredApps,
|
||||
validFallbacks,
|
||||
_resultsSeparator,
|
||||
_fallbacksSeparator,
|
||||
AppResultLimit);
|
||||
}
|
||||
}
|
||||
|
||||
// Single pass for regular items
|
||||
var regular = new List<TopLevelViewModel>(allCommands.Count);
|
||||
for (var i = 0; i < allCommands.Count; i++)
|
||||
{
|
||||
var candidate = allCommands[i];
|
||||
if (IsEligibleTopLevelCommand(candidate) && !_tlcManager.IsPinned(candidate.CommandProviderId, candidate.Id))
|
||||
{
|
||||
regular.Add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
_cachedPinnedViewModels = [.. pinned];
|
||||
_cachedRegularViewModels = [.. regular];
|
||||
_defaultViewDirty = false;
|
||||
}
|
||||
|
||||
private static bool IsEligibleTopLevelCommand(TopLevelViewModel command)
|
||||
{
|
||||
return !command.IsFallback && !string.IsNullOrEmpty(command.Title);
|
||||
}
|
||||
|
||||
private void ClearResults()
|
||||
@@ -479,11 +535,9 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast<AppListItem>().ToList();
|
||||
|
||||
// We need to remove pinned apps from allNewApps so they don't show twice.
|
||||
// Pinned app command IDs are stored in ProviderSettings.PinnedCommandIds.
|
||||
_settingsService.Settings.ProviderSettings.TryGetValue(AllAppsCommandProvider.WellKnownId, out var providerSettings);
|
||||
var pinnedCommandIds = providerSettings?.PinnedCommandIds;
|
||||
var pinnedCommandIds = _settingsService.Settings.GetPinnedCommandIds(AllAppsCommandProvider.WellKnownId);
|
||||
|
||||
if (pinnedCommandIds is not null && pinnedCommandIds.Count > 0)
|
||||
if (pinnedCommandIds.Count > 0)
|
||||
{
|
||||
newApps = allNewApps.Where(li => li.Command != null && !pinnedCommandIds.Contains(li.Command.Id));
|
||||
}
|
||||
@@ -702,6 +756,8 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
|
||||
public void Receive(UpdateFallbackItemsMessage message)
|
||||
{
|
||||
_tlcManager.RebuildPinnedCache();
|
||||
_defaultViewDirty = true;
|
||||
RequestRefresh(fullRefresh: false);
|
||||
}
|
||||
|
||||
@@ -717,6 +773,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
|
||||
_tlcManager.PropertyChanged -= TlcManager_PropertyChanged;
|
||||
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;
|
||||
_tlcManager.PinnedCommands.CollectionChanged -= PinnedCommands_CollectionChanged;
|
||||
|
||||
if (_settingsService is not null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
|
||||
|
||||
public sealed partial class OpenGallerySettingsCommand : OpenSettingsCommand
|
||||
{
|
||||
public OpenGallerySettingsCommand()
|
||||
: base(
|
||||
settingsPageTag: "Gallery",
|
||||
name: Properties.Resources.builtin_open_gallery_name,
|
||||
glyph: "\uE719",
|
||||
id: "com.microsoft.cmdpal.opengallerysettings") /* #no-spell-check-line */
|
||||
{
|
||||
Icon = IconHelpers.FromRelativePath("Assets\\Extension.svg");
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,31 @@ namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
|
||||
public partial class OpenSettingsCommand : InvokableCommand
|
||||
{
|
||||
public OpenSettingsCommand()
|
||||
: this(
|
||||
settingsPageTag: string.Empty,
|
||||
name: Properties.Resources.builtin_open_settings_name,
|
||||
glyph: "\uE713",
|
||||
id: "com.microsoft.cmdpal.opensettings") /* #no-spell-check-line */
|
||||
{
|
||||
Name = Properties.Resources.builtin_open_settings_name;
|
||||
Icon = new IconInfo("\uE713");
|
||||
}
|
||||
|
||||
protected OpenSettingsCommand(
|
||||
string settingsPageTag,
|
||||
string name,
|
||||
string glyph,
|
||||
string id)
|
||||
{
|
||||
_settingsPageTag = settingsPageTag;
|
||||
Name = name;
|
||||
Icon = new IconInfo(glyph);
|
||||
Id = id;
|
||||
}
|
||||
|
||||
private readonly string _settingsPageTag;
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage(_settingsPageTag));
|
||||
return CommandResult.KeepOpen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,18 @@ public sealed partial class DockBandViewModel : ExtensionObjectViewModel
|
||||
/// </summary>
|
||||
internal void SaveShowLabels()
|
||||
{
|
||||
ReplaceBandInSettings(_bandSettings with { ShowTitles = _showTitles, ShowSubtitles = _showSubtitles });
|
||||
// Only write to settings if the label values actually changed from
|
||||
// the snapshot. When multiple non-customized monitors share global
|
||||
// bands, an unconditional save would overwrite changes made by
|
||||
// another monitor's ViewModel (last-save-wins clobber).
|
||||
var changed = _showTitlesSnapshot is null
|
||||
|| _showTitles != _showTitlesSnapshot
|
||||
|| _showSubtitles != _showSubtitlesSnapshot;
|
||||
if (changed)
|
||||
{
|
||||
ReplaceBandInSettings(_bandSettings with { ShowTitles = _showTitles, ShowSubtitles = _showSubtitles });
|
||||
}
|
||||
|
||||
_showTitlesSnapshot = null;
|
||||
_showSubtitlesSnapshot = null;
|
||||
}
|
||||
@@ -135,15 +146,52 @@ public sealed partial class DockBandViewModel : ExtensionObjectViewModel
|
||||
s =>
|
||||
{
|
||||
var dockSettings = s.DockSettings;
|
||||
return s with
|
||||
|
||||
// Update in global bands
|
||||
var updatedDock = dockSettings with
|
||||
{
|
||||
DockSettings = dockSettings with
|
||||
{
|
||||
StartBands = ReplaceBandInList(dockSettings.StartBands, commandId, newSettings),
|
||||
CenterBands = ReplaceBandInList(dockSettings.CenterBands, commandId, newSettings),
|
||||
EndBands = ReplaceBandInList(dockSettings.EndBands, commandId, newSettings),
|
||||
},
|
||||
StartBands = ReplaceBandInList(dockSettings.StartBands, commandId, newSettings),
|
||||
CenterBands = ReplaceBandInList(dockSettings.CenterBands, commandId, newSettings),
|
||||
EndBands = ReplaceBandInList(dockSettings.EndBands, commandId, newSettings),
|
||||
};
|
||||
|
||||
// Also update in per-monitor bands for customized monitors
|
||||
var configs = updatedDock.MonitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
|
||||
var configsChanged = false;
|
||||
for (var i = 0; i < configs.Count; i++)
|
||||
{
|
||||
var config = configs[i];
|
||||
if (!config.IsCustomized)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var start = config.StartBands ?? ImmutableList<DockBandSettings>.Empty;
|
||||
var center = config.CenterBands ?? ImmutableList<DockBandSettings>.Empty;
|
||||
var end = config.EndBands ?? ImmutableList<DockBandSettings>.Empty;
|
||||
|
||||
var newStart = ReplaceBandInList(start, commandId, newSettings);
|
||||
var newCenter = ReplaceBandInList(center, commandId, newSettings);
|
||||
var newEnd = ReplaceBandInList(end, commandId, newSettings);
|
||||
|
||||
if (newStart != start || newCenter != center || newEnd != end)
|
||||
{
|
||||
configs = configs.SetItem(i, config with
|
||||
{
|
||||
StartBands = newStart,
|
||||
CenterBands = newCenter,
|
||||
EndBands = newEnd,
|
||||
});
|
||||
configsChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (configsChanged)
|
||||
{
|
||||
updatedDock = updatedDock with { MonitorConfigs = configs };
|
||||
}
|
||||
|
||||
return s with { DockSettings = updatedDock };
|
||||
},
|
||||
false);
|
||||
_bandSettings = newSettings;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Models;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Settings;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel wrapping a <see cref="DockMonitorConfig"/> paired with its
|
||||
/// <see cref="MonitorInfo"/>. Exposes bindable properties for the monitor
|
||||
/// config UI and persists changes through <see cref="ISettingsService"/>.
|
||||
/// </summary>
|
||||
public partial class DockMonitorConfigViewModel : ObservableObject
|
||||
{
|
||||
private static readonly CompositeFormat ResolutionFormat = CompositeFormat.Parse("{0} \u00D7 {1}");
|
||||
|
||||
private readonly MonitorInfo _monitorInfo;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly string _monitorDeviceId;
|
||||
|
||||
public DockMonitorConfigViewModel(
|
||||
DockMonitorConfig config,
|
||||
MonitorInfo monitorInfo,
|
||||
ISettingsService settingsService)
|
||||
{
|
||||
_monitorInfo = monitorInfo;
|
||||
_settingsService = settingsService;
|
||||
_monitorDeviceId = config.MonitorDeviceId;
|
||||
}
|
||||
|
||||
/// <summary>Gets the human-readable display name from the monitor hardware.</summary>
|
||||
public string DisplayName => _monitorInfo.DisplayName;
|
||||
|
||||
/// <summary>Gets the stable device identifier for this monitor.</summary>
|
||||
public string DeviceId => _monitorInfo.DeviceId;
|
||||
|
||||
/// <summary>Gets a value indicating whether this is the primary monitor.</summary>
|
||||
public bool IsPrimary => _monitorInfo.IsPrimary;
|
||||
|
||||
/// <summary>Gets the monitor resolution formatted as "W × H".</summary>
|
||||
public string Resolution => string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
ResolutionFormat,
|
||||
_monitorInfo.Bounds.Width,
|
||||
_monitorInfo.Bounds.Height);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the dock is enabled on this monitor.
|
||||
/// </summary>
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => GetConfig()?.Enabled ?? true;
|
||||
set
|
||||
{
|
||||
UpdateConfig(c => c with { Enabled = value });
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the side-override index for ComboBox binding.
|
||||
/// 0 = "Use default" (inherit), 1 = Left, 2 = Top, 3 = Right, 4 = Bottom.
|
||||
/// </summary>
|
||||
public int SideOverrideIndex
|
||||
{
|
||||
get => GetConfig()?.Side switch
|
||||
{
|
||||
null => 0,
|
||||
DockSide.Left => 1,
|
||||
DockSide.Top => 2,
|
||||
DockSide.Right => 3,
|
||||
DockSide.Bottom => 4,
|
||||
_ => 0,
|
||||
};
|
||||
set
|
||||
{
|
||||
var newSide = value switch
|
||||
{
|
||||
1 => (DockSide?)DockSide.Left,
|
||||
2 => (DockSide?)DockSide.Top,
|
||||
3 => (DockSide?)DockSide.Right,
|
||||
4 => (DockSide?)DockSide.Bottom,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
UpdateConfig(c => c with { Side = newSide });
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasSideOverride));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets a value indicating whether this monitor has a per-monitor side override.</summary>
|
||||
public bool HasSideOverride => GetConfig()?.Side is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this monitor uses custom band pinning.
|
||||
/// When toggled ON, forks band lists from global settings.
|
||||
/// When toggled OFF, clears per-monitor bands.
|
||||
/// </summary>
|
||||
public bool IsCustomized
|
||||
{
|
||||
get => GetConfig()?.IsCustomized ?? false;
|
||||
set
|
||||
{
|
||||
_settingsService.UpdateSettings(s =>
|
||||
{
|
||||
var dockSettings = s.DockSettings;
|
||||
var configs = dockSettings.MonitorConfigs;
|
||||
var index = FindConfigIndex(configs);
|
||||
if (index < 0)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
|
||||
var config = configs[index];
|
||||
DockMonitorConfig updated;
|
||||
|
||||
if (value)
|
||||
{
|
||||
updated = config.ForkFromGlobal(dockSettings);
|
||||
}
|
||||
else
|
||||
{
|
||||
updated = config with
|
||||
{
|
||||
IsCustomized = false,
|
||||
StartBands = ImmutableList<DockBandSettings>.Empty,
|
||||
CenterBands = ImmutableList<DockBandSettings>.Empty,
|
||||
EndBands = ImmutableList<DockBandSettings>.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
return s with
|
||||
{
|
||||
DockSettings = dockSettings with { MonitorConfigs = configs.SetItem(index, updated) },
|
||||
};
|
||||
});
|
||||
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private DockMonitorConfig? GetConfig()
|
||||
{
|
||||
var configs = _settingsService.Settings.DockSettings.MonitorConfigs;
|
||||
for (var i = 0; i < configs.Count; i++)
|
||||
{
|
||||
if (string.Equals(configs[i].MonitorDeviceId, _monitorDeviceId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return configs[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void UpdateConfig(Func<DockMonitorConfig, DockMonitorConfig> transform)
|
||||
{
|
||||
_settingsService.UpdateSettings(s =>
|
||||
{
|
||||
var dockSettings = s.DockSettings;
|
||||
var configs = dockSettings.MonitorConfigs;
|
||||
var index = FindConfigIndex(configs);
|
||||
if (index < 0)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
|
||||
var updated = transform(configs[index]);
|
||||
return s with
|
||||
{
|
||||
DockSettings = dockSettings with { MonitorConfigs = configs.SetItem(index, updated) },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private int FindConfigIndex(ImmutableList<DockMonitorConfig> configs)
|
||||
{
|
||||
for (var i = 0; i < configs.Count; i++)
|
||||
{
|
||||
if (string.Equals(configs[i].MonitorDeviceId, _monitorDeviceId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,23 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
|
||||
|
||||
public sealed partial class DockViewModel
|
||||
public sealed partial class DockViewModel : IDisposable
|
||||
{
|
||||
private readonly TopLevelCommandManager _topLevelCommandManager;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly DockPageContext _pageContext; // only to be used for our own context menu - not for dock bands themselves
|
||||
private readonly IContextMenuFactory _contextMenuFactory;
|
||||
private readonly string? _monitorDeviceId;
|
||||
|
||||
private DockSettings _settings;
|
||||
private bool _isEditing;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the monitor device identifier this dock is associated with, or <c>null</c>
|
||||
/// for the default (single-monitor) dock.
|
||||
/// </summary>
|
||||
public string? MonitorDeviceId => _monitorDeviceId;
|
||||
|
||||
public TaskScheduler Scheduler { get; }
|
||||
|
||||
@@ -38,12 +46,14 @@ public sealed partial class DockViewModel
|
||||
TopLevelCommandManager tlcManager,
|
||||
IContextMenuFactory contextMenuFactory,
|
||||
TaskScheduler scheduler,
|
||||
ISettingsService settingsService)
|
||||
ISettingsService settingsService,
|
||||
string? monitorDeviceId = null)
|
||||
{
|
||||
_topLevelCommandManager = tlcManager;
|
||||
_contextMenuFactory = contextMenuFactory;
|
||||
_settingsService = settingsService;
|
||||
_settings = _settingsService.Settings.DockSettings;
|
||||
_monitorDeviceId = monitorDeviceId;
|
||||
Scheduler = scheduler;
|
||||
_pageContext = new(this);
|
||||
|
||||
@@ -72,17 +82,168 @@ public sealed partial class DockViewModel
|
||||
|
||||
public void UpdateSettings(DockSettings settings)
|
||||
{
|
||||
if (_isEditing)
|
||||
{
|
||||
Logger.LogDebug("DockViewModel.UpdateSettings skipped (edit in progress)");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogDebug($"DockViewModel.UpdateSettings");
|
||||
_settings = settings;
|
||||
SetupBands();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes bands from current settings. Call after the UI scheduler is ready
|
||||
/// (i.e., after the DockWindow is shown) to ensure proper dispatcher access.
|
||||
/// </summary>
|
||||
public void InitializeBands() => SetupBands();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active band lists for this dock instance. Returns per-monitor bands
|
||||
/// when the associated monitor is customized; otherwise falls back to global bands.
|
||||
/// </summary>
|
||||
private (ImmutableList<DockBandSettings> Start, ImmutableList<DockBandSettings> Center, ImmutableList<DockBandSettings> End) GetActiveBands()
|
||||
{
|
||||
if (_monitorDeviceId is not null)
|
||||
{
|
||||
var config = FindMonitorConfig(_settings, _monitorDeviceId);
|
||||
if (config is not null)
|
||||
{
|
||||
return (
|
||||
config.ResolveStartBands(_settings.StartBands),
|
||||
config.ResolveCenterBands(_settings.CenterBands),
|
||||
config.ResolveEndBands(_settings.EndBands));
|
||||
}
|
||||
}
|
||||
|
||||
return (_settings.StartBands, _settings.CenterBands, _settings.EndBands);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an updated <see cref="DockSettings"/> with the given bands placed in the
|
||||
/// correct location — per-monitor config when customized, or global otherwise.
|
||||
/// </summary>
|
||||
private DockSettings WithActiveBands(
|
||||
ImmutableList<DockBandSettings> start,
|
||||
ImmutableList<DockBandSettings> center,
|
||||
ImmutableList<DockBandSettings> end)
|
||||
{
|
||||
if (_monitorDeviceId is not null)
|
||||
{
|
||||
var config = FindMonitorConfig(_settings, _monitorDeviceId);
|
||||
if (config is not null && config.IsCustomized)
|
||||
{
|
||||
var updatedConfig = config with
|
||||
{
|
||||
StartBands = start,
|
||||
CenterBands = center,
|
||||
EndBands = end,
|
||||
};
|
||||
return _settings with
|
||||
{
|
||||
MonitorConfigs = ReplaceMonitorConfig(_settings.MonitorConfigs, updatedConfig),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return _settings with
|
||||
{
|
||||
StartBands = start,
|
||||
CenterBands = center,
|
||||
EndBands = end,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the monitor associated with this dock has its own independent band lists.
|
||||
/// If the monitor is not yet customized, forks bands from global settings.
|
||||
/// Returns <c>true</c> if the fork was performed, <c>false</c> if already customized or no monitor.
|
||||
/// </summary>
|
||||
public bool EnsureMonitorForked()
|
||||
{
|
||||
if (_monitorDeviceId is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var config = FindMonitorConfig(_settings, _monitorDeviceId);
|
||||
if (config is null || config.IsCustomized)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var forked = config.ForkFromGlobal(_settings);
|
||||
_settings = _settings with
|
||||
{
|
||||
MonitorConfigs = ReplaceMonitorConfig(_settings.MonitorConfigs, forked),
|
||||
};
|
||||
SaveSettings();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective dock side for this instance, considering per-monitor overrides.
|
||||
/// </summary>
|
||||
public DockSide GetEffectiveSide()
|
||||
{
|
||||
if (_monitorDeviceId is not null)
|
||||
{
|
||||
var config = FindMonitorConfig(_settings, _monitorDeviceId);
|
||||
if (config is not null)
|
||||
{
|
||||
return config.ResolveSide(_settings.Side);
|
||||
}
|
||||
}
|
||||
|
||||
return _settings.Side;
|
||||
}
|
||||
|
||||
private static DockMonitorConfig? FindMonitorConfig(DockSettings settings, string deviceId)
|
||||
{
|
||||
var configs = settings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
|
||||
foreach (var config in configs)
|
||||
{
|
||||
if (string.Equals(config.MonitorDeviceId, deviceId, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ImmutableList<DockMonitorConfig> ReplaceMonitorConfig(
|
||||
ImmutableList<DockMonitorConfig> configs,
|
||||
DockMonitorConfig updated)
|
||||
{
|
||||
for (var i = 0; i < configs.Count; i++)
|
||||
{
|
||||
if (string.Equals(configs[i].MonitorDeviceId, updated.MonitorDeviceId, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return configs.SetItem(i, updated);
|
||||
}
|
||||
}
|
||||
|
||||
return configs.Add(updated);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_topLevelCommandManager.DockBands.CollectionChanged -= DockBands_CollectionChanged;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupBands()
|
||||
{
|
||||
Logger.LogDebug($"Setting up dock bands");
|
||||
SetupBands(_settings.StartBands, StartItems);
|
||||
SetupBands(_settings.CenterBands, CenterItems);
|
||||
SetupBands(_settings.EndBands, EndItems);
|
||||
var (start, center, end) = GetActiveBands();
|
||||
SetupBands(start, StartItems);
|
||||
SetupBands(center, CenterItems);
|
||||
SetupBands(end, EndItems);
|
||||
}
|
||||
|
||||
private void SetupBands(
|
||||
@@ -207,42 +368,46 @@ public sealed partial class DockViewModel
|
||||
public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
|
||||
{
|
||||
var bandId = band.Id;
|
||||
var dockSettings = _settings;
|
||||
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
|
||||
|
||||
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
|
||||
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
|
||||
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
|
||||
var bandSettings = activeStart.FirstOrDefault(b => b.CommandId == bandId)
|
||||
?? activeCenter.FirstOrDefault(b => b.CommandId == bandId)
|
||||
?? activeEnd.FirstOrDefault(b => b.CommandId == bandId);
|
||||
|
||||
if (bandSettings == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from all settings lists
|
||||
var newDock = dockSettings with
|
||||
{
|
||||
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
|
||||
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
|
||||
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
|
||||
};
|
||||
// Remove from all active band lists
|
||||
var newStart = activeStart.RemoveAll(b => b.CommandId == bandId);
|
||||
var newCenter = activeCenter.RemoveAll(b => b.CommandId == bandId);
|
||||
var newEnd = activeEnd.RemoveAll(b => b.CommandId == bandId);
|
||||
|
||||
// Add to target settings list at the correct index
|
||||
// Add to target list at the correct index
|
||||
var targetList = targetSide switch
|
||||
{
|
||||
DockPinSide.Start => newDock.StartBands,
|
||||
DockPinSide.Center => newDock.CenterBands,
|
||||
DockPinSide.End => newDock.EndBands,
|
||||
_ => newDock.StartBands,
|
||||
DockPinSide.Start => newStart,
|
||||
DockPinSide.Center => newCenter,
|
||||
DockPinSide.End => newEnd,
|
||||
_ => newStart,
|
||||
};
|
||||
var insertIndex = Math.Min(targetIndex, targetList.Count);
|
||||
newDock = targetSide switch
|
||||
switch (targetSide)
|
||||
{
|
||||
DockPinSide.Start => newDock with { StartBands = targetList.Insert(insertIndex, bandSettings) },
|
||||
DockPinSide.Center => newDock with { CenterBands = targetList.Insert(insertIndex, bandSettings) },
|
||||
DockPinSide.End => newDock with { EndBands = targetList.Insert(insertIndex, bandSettings) },
|
||||
_ => newDock with { StartBands = targetList.Insert(insertIndex, bandSettings) },
|
||||
};
|
||||
_settings = newDock;
|
||||
case DockPinSide.Start:
|
||||
newStart = newStart.Insert(insertIndex, bandSettings);
|
||||
break;
|
||||
case DockPinSide.Center:
|
||||
newCenter = newCenter.Insert(insertIndex, bandSettings);
|
||||
break;
|
||||
case DockPinSide.End:
|
||||
default:
|
||||
newEnd = newEnd.Insert(insertIndex, bandSettings);
|
||||
break;
|
||||
}
|
||||
|
||||
_settings = WithActiveBands(newStart, newCenter, newEnd);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -252,11 +417,11 @@ public sealed partial class DockViewModel
|
||||
public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
|
||||
{
|
||||
var bandId = band.Id;
|
||||
var dockSettings = _settings;
|
||||
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
|
||||
|
||||
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
|
||||
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
|
||||
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
|
||||
var bandSettings = activeStart.FirstOrDefault(b => b.CommandId == bandId)
|
||||
?? activeCenter.FirstOrDefault(b => b.CommandId == bandId)
|
||||
?? activeEnd.FirstOrDefault(b => b.CommandId == bandId);
|
||||
|
||||
if (bandSettings == null)
|
||||
{
|
||||
@@ -265,12 +430,9 @@ public sealed partial class DockViewModel
|
||||
}
|
||||
|
||||
// Remove from all sides (settings)
|
||||
var newDock = dockSettings with
|
||||
{
|
||||
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
|
||||
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
|
||||
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
|
||||
};
|
||||
var newStart = activeStart.RemoveAll(b => b.CommandId == bandId);
|
||||
var newCenter = activeCenter.RemoveAll(b => b.CommandId == bandId);
|
||||
var newEnd = activeEnd.RemoveAll(b => b.CommandId == bandId);
|
||||
|
||||
// Remove from UI collections
|
||||
StartItems.Remove(band);
|
||||
@@ -282,8 +444,8 @@ public sealed partial class DockViewModel
|
||||
{
|
||||
case DockPinSide.Start:
|
||||
{
|
||||
var settingsIndex = Math.Min(targetIndex, newDock.StartBands.Count);
|
||||
newDock = newDock with { StartBands = newDock.StartBands.Insert(settingsIndex, bandSettings) };
|
||||
var settingsIndex = Math.Min(targetIndex, newStart.Count);
|
||||
newStart = newStart.Insert(settingsIndex, bandSettings);
|
||||
|
||||
var uiIndex = Math.Min(targetIndex, StartItems.Count);
|
||||
StartItems.Insert(uiIndex, band);
|
||||
@@ -292,8 +454,8 @@ public sealed partial class DockViewModel
|
||||
|
||||
case DockPinSide.Center:
|
||||
{
|
||||
var settingsIndex = Math.Min(targetIndex, newDock.CenterBands.Count);
|
||||
newDock = newDock with { CenterBands = newDock.CenterBands.Insert(settingsIndex, bandSettings) };
|
||||
var settingsIndex = Math.Min(targetIndex, newCenter.Count);
|
||||
newCenter = newCenter.Insert(settingsIndex, bandSettings);
|
||||
|
||||
var uiIndex = Math.Min(targetIndex, CenterItems.Count);
|
||||
CenterItems.Insert(uiIndex, band);
|
||||
@@ -302,8 +464,8 @@ public sealed partial class DockViewModel
|
||||
|
||||
case DockPinSide.End:
|
||||
{
|
||||
var settingsIndex = Math.Min(targetIndex, newDock.EndBands.Count);
|
||||
newDock = newDock with { EndBands = newDock.EndBands.Insert(settingsIndex, bandSettings) };
|
||||
var settingsIndex = Math.Min(targetIndex, newEnd.Count);
|
||||
newEnd = newEnd.Insert(settingsIndex, bandSettings);
|
||||
|
||||
var uiIndex = Math.Min(targetIndex, EndItems.Count);
|
||||
EndItems.Insert(uiIndex, band);
|
||||
@@ -311,7 +473,7 @@ public sealed partial class DockViewModel
|
||||
}
|
||||
}
|
||||
|
||||
_settings = newDock;
|
||||
_settings = WithActiveBands(newStart, newCenter, newEnd);
|
||||
|
||||
Logger.LogDebug($"Moved band {bandId} to {targetSide} at index {targetIndex} (not saved yet)");
|
||||
}
|
||||
@@ -331,29 +493,95 @@ public sealed partial class DockViewModel
|
||||
// Preserve any per-band label edits made while in edit mode. Those edits are
|
||||
// saved independently of reorder, so merge the latest band settings back into
|
||||
// the local reordered snapshot before we persist dock settings.
|
||||
var latestBandSettings = BuildBandSettingsLookup(_settingsService.Settings.DockSettings);
|
||||
_settings = _settings with
|
||||
{
|
||||
StartBands = MergeBandSettings(_settings.StartBands, latestBandSettings),
|
||||
CenterBands = MergeBandSettings(_settings.CenterBands, latestBandSettings),
|
||||
EndBands = MergeBandSettings(_settings.EndBands, latestBandSettings),
|
||||
};
|
||||
var (latestStart, latestCenter, latestEnd) = GetActiveBandsFromSettings(_settingsService.Settings.DockSettings);
|
||||
var latestBandSettings = BuildBandSettingsLookup(latestStart, latestCenter, latestEnd);
|
||||
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
|
||||
_settings = WithActiveBands(
|
||||
MergeBandSettings(activeStart, latestBandSettings),
|
||||
MergeBandSettings(activeCenter, latestBandSettings),
|
||||
MergeBandSettings(activeEnd, latestBandSettings));
|
||||
|
||||
_snapshotDockSettings = null;
|
||||
_snapshotBandViewModels = null;
|
||||
|
||||
// Save without hotReload to avoid triggering SettingsChanged → SetupBands,
|
||||
// which could race with stale DockBands_CollectionChanged work items and
|
||||
// re-add bands that were just unpinned.
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = _settings }, false);
|
||||
// Extract the final merged bands for this monitor
|
||||
var (myStart, myCenter, myEnd) = GetActiveBands();
|
||||
|
||||
// Save only this monitor's bands into the CURRENT persisted settings,
|
||||
// preserving other monitors' changes. Without this, each DockViewModel's
|
||||
// save would overwrite the entire DockSettings, causing the last save to
|
||||
// clobber changes from monitors that saved earlier.
|
||||
_settingsService.UpdateSettings(
|
||||
s =>
|
||||
{
|
||||
var currentDock = s.DockSettings;
|
||||
if (_monitorDeviceId is not null)
|
||||
{
|
||||
var config = FindMonitorConfig(currentDock, _monitorDeviceId);
|
||||
if (config is not null && config.IsCustomized)
|
||||
{
|
||||
var updatedConfig = config with
|
||||
{
|
||||
StartBands = myStart,
|
||||
CenterBands = myCenter,
|
||||
EndBands = myEnd,
|
||||
};
|
||||
var configs = currentDock.MonitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
|
||||
return s with
|
||||
{
|
||||
DockSettings = currentDock with
|
||||
{
|
||||
MonitorConfigs = ReplaceMonitorConfig(configs, updatedConfig),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return s with
|
||||
{
|
||||
DockSettings = currentDock with
|
||||
{
|
||||
StartBands = myStart,
|
||||
CenterBands = myCenter,
|
||||
EndBands = myEnd,
|
||||
},
|
||||
};
|
||||
},
|
||||
false);
|
||||
|
||||
// Refresh local settings from persisted state so all monitors' changes are visible
|
||||
_settings = _settingsService.Settings.DockSettings;
|
||||
_isEditing = false;
|
||||
Logger.LogDebug("Saved band order to settings");
|
||||
}
|
||||
|
||||
private static Dictionary<string, DockBandSettings> BuildBandSettingsLookup(DockSettings dockSettings)
|
||||
/// <summary>
|
||||
/// Gets active bands from a given DockSettings, considering this dock's monitor.
|
||||
/// </summary>
|
||||
private (ImmutableList<DockBandSettings> Start, ImmutableList<DockBandSettings> Center, ImmutableList<DockBandSettings> End) GetActiveBandsFromSettings(DockSettings dockSettings)
|
||||
{
|
||||
if (_monitorDeviceId is not null)
|
||||
{
|
||||
var config = FindMonitorConfig(dockSettings, _monitorDeviceId);
|
||||
if (config is not null)
|
||||
{
|
||||
return (
|
||||
config.ResolveStartBands(dockSettings.StartBands),
|
||||
config.ResolveCenterBands(dockSettings.CenterBands),
|
||||
config.ResolveEndBands(dockSettings.EndBands));
|
||||
}
|
||||
}
|
||||
|
||||
return (dockSettings.StartBands, dockSettings.CenterBands, dockSettings.EndBands);
|
||||
}
|
||||
|
||||
private static Dictionary<string, DockBandSettings> BuildBandSettingsLookup(
|
||||
ImmutableList<DockBandSettings> start,
|
||||
ImmutableList<DockBandSettings> center,
|
||||
ImmutableList<DockBandSettings> end)
|
||||
{
|
||||
var lookup = new Dictionary<string, DockBandSettings>(StringComparer.Ordinal);
|
||||
foreach (var band in dockSettings.StartBands.Concat(dockSettings.CenterBands).Concat(dockSettings.EndBands))
|
||||
foreach (var band in start.Concat(center).Concat(end))
|
||||
{
|
||||
lookup[band.CommandId] = band;
|
||||
}
|
||||
@@ -450,13 +678,13 @@ public sealed partial class DockViewModel
|
||||
return;
|
||||
}
|
||||
|
||||
var dockSettings = _settings;
|
||||
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
|
||||
|
||||
StartItems.Clear();
|
||||
CenterItems.Clear();
|
||||
EndItems.Clear();
|
||||
|
||||
foreach (var bandSettings in dockSettings.StartBands)
|
||||
foreach (var bandSettings in activeStart)
|
||||
{
|
||||
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
|
||||
{
|
||||
@@ -464,7 +692,7 @@ public sealed partial class DockViewModel
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var bandSettings in dockSettings.CenterBands)
|
||||
foreach (var bandSettings in activeCenter)
|
||||
{
|
||||
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
|
||||
{
|
||||
@@ -472,7 +700,7 @@ public sealed partial class DockViewModel
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var bandSettings in dockSettings.EndBands)
|
||||
foreach (var bandSettings in activeEnd)
|
||||
{
|
||||
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
|
||||
{
|
||||
@@ -483,7 +711,7 @@ public sealed partial class DockViewModel
|
||||
|
||||
private void RebuildUICollections()
|
||||
{
|
||||
var dockSettings = _settings;
|
||||
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
|
||||
|
||||
// Create a lookup of all current band ViewModels
|
||||
var allBands = StartItems.Concat(CenterItems).Concat(EndItems).ToDictionary(b => b.Id);
|
||||
@@ -492,7 +720,7 @@ public sealed partial class DockViewModel
|
||||
CenterItems.Clear();
|
||||
EndItems.Clear();
|
||||
|
||||
foreach (var bandSettings in dockSettings.StartBands)
|
||||
foreach (var bandSettings in activeStart)
|
||||
{
|
||||
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
|
||||
{
|
||||
@@ -500,7 +728,7 @@ public sealed partial class DockViewModel
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var bandSettings in dockSettings.CenterBands)
|
||||
foreach (var bandSettings in activeCenter)
|
||||
{
|
||||
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
|
||||
{
|
||||
@@ -508,7 +736,7 @@ public sealed partial class DockViewModel
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var bandSettings in dockSettings.EndBands)
|
||||
foreach (var bandSettings in activeEnd)
|
||||
{
|
||||
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
|
||||
{
|
||||
@@ -561,6 +789,7 @@ public sealed partial class DockViewModel
|
||||
// Create settings for the new band
|
||||
var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId };
|
||||
var dockSettings = _settings;
|
||||
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
|
||||
|
||||
// Create the band view model
|
||||
var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel);
|
||||
@@ -569,15 +798,15 @@ public sealed partial class DockViewModel
|
||||
switch (targetSide)
|
||||
{
|
||||
case DockPinSide.Start:
|
||||
_settings = dockSettings with { StartBands = dockSettings.StartBands.Add(bandSettings) };
|
||||
_settings = WithActiveBands(activeStart.Add(bandSettings), activeCenter, activeEnd);
|
||||
StartItems.Add(bandVm);
|
||||
break;
|
||||
case DockPinSide.Center:
|
||||
_settings = dockSettings with { CenterBands = dockSettings.CenterBands.Add(bandSettings) };
|
||||
_settings = WithActiveBands(activeStart, activeCenter.Add(bandSettings), activeEnd);
|
||||
CenterItems.Add(bandVm);
|
||||
break;
|
||||
case DockPinSide.End:
|
||||
_settings = dockSettings with { EndBands = dockSettings.EndBands.Add(bandSettings) };
|
||||
_settings = WithActiveBands(activeStart, activeCenter, activeEnd.Add(bandSettings));
|
||||
EndItems.Add(bandVm);
|
||||
break;
|
||||
}
|
||||
@@ -600,15 +829,13 @@ public sealed partial class DockViewModel
|
||||
public void UnpinBand(DockBandViewModel band)
|
||||
{
|
||||
var bandId = band.Id;
|
||||
var dockSettings = _settings;
|
||||
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
|
||||
|
||||
// Remove from settings
|
||||
_settings = dockSettings with
|
||||
{
|
||||
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
|
||||
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
|
||||
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
|
||||
};
|
||||
_settings = WithActiveBands(
|
||||
activeStart.RemoveAll(b => b.CommandId == bandId),
|
||||
activeCenter.RemoveAll(b => b.CommandId == bandId),
|
||||
activeEnd.RemoveAll(b => b.CommandId == bandId));
|
||||
|
||||
// Remove from UI collections
|
||||
StartItems.Remove(band);
|
||||
@@ -670,14 +897,16 @@ public sealed partial class DockViewModel
|
||||
private void EmitDockConfiguration()
|
||||
{
|
||||
var isDockEnabled = _settingsService.Settings.EnableDock;
|
||||
var dockSide = isDockEnabled ? _settings.Side.ToString().ToLowerInvariant() : "none";
|
||||
var dockSide = isDockEnabled ? GetEffectiveSide().ToString().ToLowerInvariant() : "none";
|
||||
|
||||
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
|
||||
|
||||
static string FormatBands(ImmutableList<DockBandSettings> bands) =>
|
||||
string.Join("\n", bands.Select(b => $"{b.ProviderId}/{b.CommandId}"));
|
||||
|
||||
var startBands = isDockEnabled ? FormatBands(_settings.StartBands) : string.Empty;
|
||||
var centerBands = isDockEnabled ? FormatBands(_settings.CenterBands) : string.Empty;
|
||||
var endBands = isDockEnabled ? FormatBands(_settings.EndBands) : string.Empty;
|
||||
var startBands = isDockEnabled ? FormatBands(activeStart) : string.Empty;
|
||||
var centerBands = isDockEnabled ? FormatBands(activeCenter) : string.Empty;
|
||||
var endBands = isDockEnabled ? FormatBands(activeEnd) : string.Empty;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new TelemetryDockConfigurationMessage(
|
||||
isDockEnabled, dockSide, startBands, centerBands, endBands));
|
||||
|
||||
@@ -0,0 +1,975 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
using Microsoft.CmdPal.Common.WinGet.Services;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Properties;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
|
||||
|
||||
public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
|
||||
{
|
||||
private static readonly Uri PlaceholderIconUri = new("ms-appx:///Assets/Icons/ExtensionIconPlaceholder.png");
|
||||
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
private static readonly IReadOnlyList<string> EmptyTags = [];
|
||||
private static readonly Action<ILogger, Exception?> LogWinGetInstallFailedMessage =
|
||||
LoggerMessage.Define(
|
||||
LogLevel.Error,
|
||||
new EventId(1, nameof(LogWinGetInstallFailed)),
|
||||
"WinGet install/update failed.");
|
||||
|
||||
private static readonly Action<ILogger, string, Exception?> LogIconLoadFailedMessage =
|
||||
LoggerMessage.Define<string>(
|
||||
LogLevel.Error,
|
||||
new EventId(2, nameof(LogIconLoadFailed)),
|
||||
"Failed to load icon from '{IconUri}'.");
|
||||
|
||||
private const string SourceTypeWinGet = "winget";
|
||||
private const string SourceTypeStore = "msstore";
|
||||
private const string SourceTypeUrl = "url";
|
||||
private const string SourceTypeGitHub = "github";
|
||||
private const string SourceTypeWebsite = "website";
|
||||
private const string SourceTypeUnknown = "unknown";
|
||||
|
||||
private readonly GalleryExtensionEntry _entry;
|
||||
private readonly ILogger<ExtensionGalleryItemViewModel> _logger;
|
||||
private readonly IWinGetPackageManagerService? _winGetPackageManagerService;
|
||||
private readonly IWinGetOperationTrackerService? _winGetOperationTrackerService;
|
||||
private readonly IWinGetPackageStatusService? _winGetPackageStatusService;
|
||||
private readonly IReadOnlyDictionary<string, GalleryInstallSource> _installSourcesByType;
|
||||
private readonly IReadOnlyDictionary<string, GallerySourceViewModel> _sourcesByKind;
|
||||
|
||||
// HTTP uris are set only for sources that can be opened in a browser
|
||||
private readonly Uri? _homepageHttpUri;
|
||||
private readonly Uri? _authorPageHttpUri;
|
||||
private readonly Uri? _installLinkHttpUri;
|
||||
|
||||
public ExtensionGalleryItemViewModel(
|
||||
GalleryExtensionEntry entry,
|
||||
ILogger<ExtensionGalleryItemViewModel> logger,
|
||||
IWinGetPackageManagerService? winGetPackageManagerService = null,
|
||||
IWinGetPackageStatusService? winGetPackageStatusService = null,
|
||||
IWinGetOperationTrackerService? winGetOperationTrackerService = null)
|
||||
{
|
||||
_entry = entry;
|
||||
_logger = logger;
|
||||
_winGetPackageManagerService = winGetPackageManagerService;
|
||||
_winGetPackageStatusService = winGetPackageStatusService;
|
||||
_winGetOperationTrackerService = winGetOperationTrackerService;
|
||||
_installSourcesByType = BuildInstallSourceLookup(entry.InstallSources);
|
||||
(Sources, _sourcesByKind) = BuildSourceInfos(_installSourcesByType, entry.Homepage);
|
||||
_homepageHttpUri = TryCreateWebUri(entry.Homepage);
|
||||
_authorPageHttpUri = TryCreateWebUri(entry.Author?.Url);
|
||||
_installLinkHttpUri = TryCreateWebUri(GetSource(SourceTypeGitHub)?.Uri ?? GetSource(SourceTypeWebsite)?.Uri);
|
||||
Screenshots = BuildScreenshots(entry.ScreenshotUrls);
|
||||
|
||||
var resolvedIconUri = ResolveIconUri();
|
||||
IconUri = resolvedIconUri ?? PlaceholderIconUri;
|
||||
}
|
||||
|
||||
public string Id => _entry.Id;
|
||||
|
||||
public string Title => _entry.Title;
|
||||
|
||||
public string DisplayTitle => !string.IsNullOrWhiteSpace(Title) ? Title : Id;
|
||||
|
||||
public string Description => _entry.Description;
|
||||
|
||||
public string DisplayDescription => !string.IsNullOrWhiteSpace(Description) ? Description : Resources.gallery_item_no_description;
|
||||
|
||||
public string? ShortDescription => _entry.ShortDescription;
|
||||
|
||||
public string DisplayShortDescription => !string.IsNullOrWhiteSpace(ShortDescription) ? ShortDescription : string.Empty;
|
||||
|
||||
public string AuthorName => _entry.Author?.Name ?? string.Empty;
|
||||
|
||||
public string DisplayAuthorName => !string.IsNullOrWhiteSpace(AuthorName) ? AuthorName : Resources.gallery_item_unknown_author;
|
||||
|
||||
public IReadOnlyList<string> Tags => _entry.Tags ?? EmptyTags;
|
||||
|
||||
public bool HasTags => Tags.Count > 0;
|
||||
|
||||
public string TagsText => BuildTagsText(Tags);
|
||||
|
||||
public string? AuthorUrl => _entry.Author?.Url;
|
||||
|
||||
public string? Homepage => _entry.Homepage;
|
||||
|
||||
public Uri IconUri { get; }
|
||||
|
||||
public ImageSource IconSource
|
||||
{
|
||||
get => field ??= CreateImageSource(IconUri);
|
||||
private set => SetProperty(ref field, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ExtensionGalleryScreenshotViewModel> Screenshots { get; }
|
||||
|
||||
public bool HasScreenshots => Screenshots.Count > 0;
|
||||
|
||||
public IReadOnlyList<GallerySourceViewModel> Sources { get; }
|
||||
|
||||
public bool HasWinGetSource => HasSource(SourceTypeWinGet);
|
||||
|
||||
public bool HasStoreSource => HasSource(SourceTypeStore);
|
||||
|
||||
public bool HasUrlSource => _installSourcesByType.ContainsKey(SourceTypeUrl) && InstallUrl is not null;
|
||||
|
||||
public bool HasHomepage => _homepageHttpUri is not null;
|
||||
|
||||
public bool HasAuthorUrl => _authorPageHttpUri is not null;
|
||||
|
||||
public bool HasGitHubSource => HasSource(SourceTypeGitHub);
|
||||
|
||||
public bool HasWebsiteSource => HasSource(SourceTypeWebsite);
|
||||
|
||||
public bool HasUnknownSource => HasSource(SourceTypeUnknown);
|
||||
|
||||
public bool HasAnySourceDetails => Sources.Count > 0;
|
||||
|
||||
public List<GallerySourceViewModel> SourcesWithDetails
|
||||
{
|
||||
get
|
||||
{
|
||||
List<GallerySourceViewModel> withDetails = [];
|
||||
for (var i = 0; i < Sources.Count; i++)
|
||||
{
|
||||
if (Sources[i].HasDetails)
|
||||
{
|
||||
withDetails.Add(Sources[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return withDetails;
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasSourceMetadataDetails => SourcesWithDetails.Count > 0;
|
||||
|
||||
public bool HasKnownSourceIndicator => Sources.Any(s => s.IsKnown);
|
||||
|
||||
public bool ShowUnknownSourceIndicator => HasUnknownSource || !HasKnownSourceIndicator;
|
||||
|
||||
public bool HasActionableSourceDetails => HasStoreSource || HasWinGetSource || HasHomepage || HasUrlSource;
|
||||
|
||||
public bool ShowNoSourceDetails => !HasActionableSourceDetails;
|
||||
|
||||
public string UnknownSourceTooltip => HasUnknownSource
|
||||
? Resources.gallery_item_unknown_source_unsupported_tooltip
|
||||
: Resources.gallery_item_unknown_source_unavailable_tooltip;
|
||||
|
||||
public string NoSourceMenuText => Resources.gallery_item_no_source_menu_text;
|
||||
|
||||
public string NoSourceDetailsText => Resources.gallery_item_no_source_details_text;
|
||||
|
||||
public string? WinGetId => GetSource(SourceTypeWinGet)?.Id;
|
||||
|
||||
public string? StoreId => GetSource(SourceTypeStore)?.Id;
|
||||
|
||||
public string? InstallUrl => _installLinkHttpUri?.AbsoluteUri;
|
||||
|
||||
public string WinGetInstallCommand => !string.IsNullOrWhiteSpace(WinGetId) ? $"winget install --id {WinGetId}" : string.Empty;
|
||||
|
||||
public bool CanCopyWinGetInstallCommand => !string.IsNullOrWhiteSpace(WinGetInstallCommand);
|
||||
|
||||
public string WinGetTooltip => !string.IsNullOrWhiteSpace(WinGetId)
|
||||
? FormatResource(Resources.gallery_item_winget_tooltip_with_id, WinGetId)
|
||||
: Resources.gallery_item_winget_tooltip;
|
||||
|
||||
public string StoreTooltip => !string.IsNullOrWhiteSpace(StoreId)
|
||||
? FormatResource(Resources.gallery_item_store_tooltip_with_id, StoreId)
|
||||
: Resources.gallery_item_store_tooltip;
|
||||
|
||||
public string GitHubTooltip => GetSource(SourceTypeGitHub)?.Uri ?? Resources.gallery_item_github_source;
|
||||
|
||||
public string WebsiteTooltip => GetSource(SourceTypeWebsite)?.Uri ?? _homepageHttpUri?.AbsoluteUri ?? Resources.gallery_item_website_source;
|
||||
|
||||
public string WinGetMenuText => !string.IsNullOrWhiteSpace(WinGetId)
|
||||
? FormatResource(Resources.gallery_item_winget_menu_text_with_id, WinGetId)
|
||||
: Resources.gallery_item_winget_menu_text;
|
||||
|
||||
public string StoreMenuText => !string.IsNullOrWhiteSpace(StoreId)
|
||||
? FormatResource(Resources.gallery_item_store_menu_text_with_id, StoreId)
|
||||
: Resources.gallery_item_store_menu_text;
|
||||
|
||||
public string GitHubMenuText => Resources.gallery_item_github_source;
|
||||
|
||||
public string WebsiteMenuText => Resources.gallery_item_website_source;
|
||||
|
||||
public string? PackageFamilyName => _entry.Detection?.PackageFamilyName;
|
||||
|
||||
public bool IsWinGetAvailable => _winGetPackageManagerService?.State.IsAvailable ?? false;
|
||||
|
||||
public string? WinGetUnavailableMessage => HasWinGetSource && !IsWinGetAvailable ? _winGetPackageManagerService?.State.Message : null;
|
||||
|
||||
public bool ShowWinGetUnavailableMessage => !string.IsNullOrWhiteSpace(WinGetUnavailableMessage);
|
||||
|
||||
public bool ShowInstallViaWinGetButton => HasWinGetSource && (!IsInstalled || IsUpdateAvailable);
|
||||
|
||||
public bool CanInstallViaWinGet => ShowInstallViaWinGetButton && IsWinGetAvailable && !IsWinGetActionInProgress;
|
||||
|
||||
public string InstallViaWinGetText => IsUpdateAvailable
|
||||
? Resources.gallery_item_update_action
|
||||
: Resources.gallery_item_install_action;
|
||||
|
||||
public bool ShowCancelWinGetActionButton => IsWinGetActionInProgress && CanCancelWinGetAction;
|
||||
|
||||
public bool ShowWinGetActionControls => ShowInstallViaWinGetButton || IsWinGetActionInProgress;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsInstalled { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsInstalledStateKnown { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsUpdateAvailable { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsUpdateStateKnown { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsWinGetActionInProgress { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool CanCancelWinGetAction { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string? WinGetActionMessage { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsWinGetActionIndeterminate { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial double WinGetActionProgressValue { get; set; }
|
||||
|
||||
public bool ShowInstalledBadge => IsInstalled && !IsUpdateAvailable;
|
||||
|
||||
public bool ShowInstallButton => !ShowInstalledBadge;
|
||||
|
||||
public bool ShowUpdateBadge => IsUpdateAvailable;
|
||||
|
||||
public bool ShowWinGetActionIndicator => IsWinGetActionInProgress;
|
||||
|
||||
public bool ShowWinGetActionStatus => IsWinGetActionInProgress && !string.IsNullOrWhiteSpace(WinGetActionMessage);
|
||||
|
||||
public string InstallStatusText =>
|
||||
IsUpdateAvailable
|
||||
? Resources.gallery_item_install_status_update_available
|
||||
: IsInstalled
|
||||
? Resources.gallery_item_install_status_installed
|
||||
: IsInstalledStateKnown
|
||||
? Resources.gallery_item_install_status_not_installed
|
||||
: Resources.gallery_item_install_status_unavailable;
|
||||
|
||||
public string WinGetStatusText =>
|
||||
!HasWinGetSource
|
||||
? string.Empty
|
||||
: IsUpdateAvailable
|
||||
? Resources.gallery_item_winget_status_update_available
|
||||
: IsInstalled
|
||||
? Resources.gallery_item_winget_status_installed
|
||||
: IsInstalledStateKnown
|
||||
? Resources.gallery_item_winget_status_not_installed
|
||||
: Resources.gallery_item_winget_status_unavailable;
|
||||
|
||||
public bool ShowWinGetStatusDetails => HasWinGetSource && !AreStatusTextsEquivalent(InstallStatusText, WinGetStatusText);
|
||||
|
||||
public bool HasWinGetActionMessage => !string.IsNullOrWhiteSpace(WinGetActionMessage);
|
||||
|
||||
public void ApplyWinGetPackageInfo(WinGetPackageInfo packageInfo)
|
||||
{
|
||||
IsInstalled = IsInstalled || packageInfo.Status.IsInstalled;
|
||||
IsInstalledStateKnown = IsInstalledStateKnown || packageInfo.Status.IsInstalledStateKnown;
|
||||
IsUpdateAvailable = packageInfo.Status.IsUpdateAvailable;
|
||||
IsUpdateStateKnown = packageInfo.Status.IsUpdateStateKnown;
|
||||
|
||||
if (packageInfo.Details is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplySourceDetails(SourceTypeWinGet, CreateSourceDetails(packageInfo.Details));
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasHomepage))]
|
||||
private void OpenHomepage()
|
||||
{
|
||||
if (_homepageHttpUri is not null)
|
||||
{
|
||||
ShellHelpers.OpenInShell(_homepageHttpUri.AbsoluteUri);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasAuthorUrl))]
|
||||
private void OpenAuthorPage()
|
||||
{
|
||||
if (_authorPageHttpUri is not null)
|
||||
{
|
||||
ShellHelpers.OpenInShell(_authorPageHttpUri.AbsoluteUri);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void InstallViaStore()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(StoreId))
|
||||
{
|
||||
ShellHelpers.OpenInShell($"ms-windows-store://pdp/?ProductId={StoreId}");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasUrlSource))]
|
||||
private void OpenInstallUrl()
|
||||
{
|
||||
if (_installLinkHttpUri is not null)
|
||||
{
|
||||
ShellHelpers.OpenInShell(_installLinkHttpUri.AbsoluteUri);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private static void OpenInstalledApps()
|
||||
{
|
||||
ShellHelpers.OpenInShell("ms-settings:appsfeatures");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CopyWinGetInstall()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(WinGetInstallCommand))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ClipboardHelper.SetText(WinGetInstallCommand);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanInstallViaWinGet))]
|
||||
private async Task InstallViaWinGetAsync()
|
||||
{
|
||||
if (_winGetPackageManagerService is null || string.IsNullOrWhiteSpace(WinGetId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsWinGetActionInProgress = true;
|
||||
IsWinGetActionIndeterminate = true;
|
||||
WinGetActionProgressValue = 0;
|
||||
WinGetActionMessage = IsUpdateAvailable
|
||||
? Resources.gallery_item_winget_action_updating
|
||||
: Resources.gallery_item_winget_action_installing;
|
||||
|
||||
try
|
||||
{
|
||||
var packagesResult = await _winGetPackageManagerService.GetPackagesByIdAsync([WinGetId], includeStoreCatalog: false);
|
||||
if (!packagesResult.IsSuccess)
|
||||
{
|
||||
WinGetActionMessage = packagesResult.ErrorMessage ?? Resources.gallery_item_winget_action_resolve_failed;
|
||||
return;
|
||||
}
|
||||
|
||||
if (packagesResult.Value is null || !packagesResult.Value.TryGetValue(WinGetId, out var package))
|
||||
{
|
||||
WinGetActionMessage = Resources.gallery_item_winget_action_package_not_found;
|
||||
return;
|
||||
}
|
||||
|
||||
var installResult = await _winGetPackageManagerService.InstallPackageAsync(package, skipDependencies: true);
|
||||
if (!installResult.Succeeded)
|
||||
{
|
||||
WinGetActionMessage = installResult.ErrorMessage ?? Resources.gallery_item_winget_action_install_failed;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWinGetInstallFailed(_logger, ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsWinGetActionInProgress = false;
|
||||
IsWinGetActionIndeterminate = false;
|
||||
WinGetActionProgressValue = 0;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanCancelWinGetAction))]
|
||||
private void CancelWinGetAction()
|
||||
{
|
||||
if (_winGetOperationTrackerService is null || string.IsNullOrWhiteSpace(WinGetId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var operation = _winGetOperationTrackerService.GetLatestOperation(WinGetId);
|
||||
if (operation is null || operation.IsCompleted || !operation.CanCancel)
|
||||
{
|
||||
CanCancelWinGetAction = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_winGetOperationTrackerService.TryCancelOperation(operation.OperationId))
|
||||
{
|
||||
CanCancelWinGetAction = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyTrackedOperation(WinGetPackageOperation operation)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(WinGetId) || !string.Equals(WinGetId, operation.PackageId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CanCancelWinGetAction = operation.CanCancel && !operation.IsCompleted;
|
||||
|
||||
var treatAsUpdate = IsInstalled || IsUpdateAvailable;
|
||||
switch (operation.State)
|
||||
{
|
||||
case WinGetPackageOperationState.Queued:
|
||||
IsWinGetActionInProgress = true;
|
||||
IsWinGetActionIndeterminate = true;
|
||||
WinGetActionProgressValue = 0;
|
||||
WinGetActionMessage = operation.Kind == WinGetPackageOperationKind.Uninstall
|
||||
? Resources.gallery_item_winget_action_queued_uninstall
|
||||
: treatAsUpdate
|
||||
? Resources.gallery_item_winget_action_queued_update
|
||||
: Resources.gallery_item_winget_action_queued_install;
|
||||
break;
|
||||
case WinGetPackageOperationState.Downloading:
|
||||
IsWinGetActionInProgress = true;
|
||||
IsWinGetActionIndeterminate = !operation.ProgressPercent.HasValue;
|
||||
WinGetActionProgressValue = operation.ProgressPercent ?? 0;
|
||||
WinGetActionMessage = operation.ProgressPercent is uint progressPercent
|
||||
? FormatResource(Resources.gallery_item_winget_action_downloading_with_progress, progressPercent)
|
||||
: Resources.gallery_item_winget_action_downloading;
|
||||
break;
|
||||
case WinGetPackageOperationState.Installing:
|
||||
IsWinGetActionInProgress = true;
|
||||
IsWinGetActionIndeterminate = true;
|
||||
WinGetActionProgressValue = 0;
|
||||
WinGetActionMessage = treatAsUpdate
|
||||
? Resources.gallery_item_winget_action_updating
|
||||
: Resources.gallery_item_winget_action_installing;
|
||||
break;
|
||||
case WinGetPackageOperationState.Uninstalling:
|
||||
IsWinGetActionInProgress = true;
|
||||
IsWinGetActionIndeterminate = true;
|
||||
WinGetActionProgressValue = 0;
|
||||
WinGetActionMessage = Resources.gallery_item_winget_action_uninstalling;
|
||||
break;
|
||||
case WinGetPackageOperationState.PostProcessing:
|
||||
IsWinGetActionInProgress = true;
|
||||
IsWinGetActionIndeterminate = true;
|
||||
WinGetActionProgressValue = 0;
|
||||
WinGetActionMessage = Resources.gallery_item_winget_action_finishing;
|
||||
break;
|
||||
case WinGetPackageOperationState.Succeeded:
|
||||
IsWinGetActionInProgress = false;
|
||||
IsWinGetActionIndeterminate = false;
|
||||
WinGetActionProgressValue = 100;
|
||||
WinGetActionMessage = operation.Kind == WinGetPackageOperationKind.Uninstall
|
||||
? Resources.gallery_item_winget_action_succeeded_uninstall
|
||||
: treatAsUpdate
|
||||
? Resources.gallery_item_winget_action_succeeded_update
|
||||
: Resources.gallery_item_winget_action_succeeded_install;
|
||||
ApplyOptimisticTrackedCompletion(operation.Kind);
|
||||
break;
|
||||
case WinGetPackageOperationState.Canceled:
|
||||
IsWinGetActionInProgress = false;
|
||||
IsWinGetActionIndeterminate = false;
|
||||
WinGetActionProgressValue = 0;
|
||||
WinGetActionMessage = Resources.gallery_item_winget_action_canceled;
|
||||
break;
|
||||
case WinGetPackageOperationState.Failed:
|
||||
IsWinGetActionInProgress = false;
|
||||
IsWinGetActionIndeterminate = false;
|
||||
WinGetActionProgressValue = 0;
|
||||
WinGetActionMessage = operation.ErrorMessage ?? Resources.gallery_item_winget_action_failed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Uri? ResolveIconUri()
|
||||
{
|
||||
var iconUrl = ToNullIfWhiteSpace(_entry.IconUrl);
|
||||
if (iconUrl is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(iconUrl, UriKind.Absolute, out var resolvedIconUri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return IsSupportedIconUri(resolvedIconUri) ? resolvedIconUri : null;
|
||||
}
|
||||
|
||||
private static bool IsSupportedIconUri(Uri uri)
|
||||
{
|
||||
return uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
|| uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
|
||||
|| uri.Scheme.Equals(Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase)
|
||||
|| uri.Scheme.Equals("ms-appx", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ExtensionGalleryScreenshotViewModel> BuildScreenshots(List<string>? screenshotUrls)
|
||||
{
|
||||
if (screenshotUrls is null || screenshotUrls.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<ExtensionGalleryScreenshotViewModel> screenshots = [];
|
||||
HashSet<string> seenUris = new(OrdinalIgnoreCase);
|
||||
for (var i = 0; i < screenshotUrls.Count; i++)
|
||||
{
|
||||
var screenshotUrl = ToNullIfWhiteSpace(screenshotUrls[i]);
|
||||
if (screenshotUrl is null
|
||||
|| !Uri.TryCreate(screenshotUrl, UriKind.Absolute, out var screenshotUri)
|
||||
|| !IsSupportedIconUri(screenshotUri)
|
||||
|| !seenUris.Add(screenshotUri.AbsoluteUri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
screenshots.Add(new ExtensionGalleryScreenshotViewModel(screenshotUri, screenshots.Count));
|
||||
}
|
||||
|
||||
return screenshots;
|
||||
}
|
||||
|
||||
private GallerySourceViewModel? GetSource(string sourceKind)
|
||||
{
|
||||
return _sourcesByKind.TryGetValue(sourceKind, out var source) ? source : null;
|
||||
}
|
||||
|
||||
private bool HasSource(string sourceKind)
|
||||
{
|
||||
return _sourcesByKind.ContainsKey(sourceKind);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, GalleryInstallSource> BuildInstallSourceLookup(List<GalleryInstallSource>? installSources)
|
||||
{
|
||||
Dictionary<string, GalleryInstallSource> lookup = new(OrdinalIgnoreCase);
|
||||
if (installSources is null)
|
||||
{
|
||||
return lookup;
|
||||
}
|
||||
|
||||
foreach (var installSource in installSources)
|
||||
{
|
||||
var normalizedType = NormalizeSourceType(installSource.Type);
|
||||
if (normalizedType is null || lookup.ContainsKey(normalizedType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
lookup[normalizedType] = installSource;
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<GallerySourceViewModel> SourceList, IReadOnlyDictionary<string, GallerySourceViewModel> SourceByKind) BuildSourceInfos(
|
||||
IReadOnlyDictionary<string, GalleryInstallSource> installSourcesByType,
|
||||
string? homepage)
|
||||
{
|
||||
Dictionary<string, GallerySourceViewModel> sourcesByKind = new(OrdinalIgnoreCase);
|
||||
|
||||
foreach (var installSource in installSourcesByType.Values)
|
||||
{
|
||||
var source = CreateSourceFromInstallSource(installSource);
|
||||
if (source is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
UpsertSource(sourcesByKind, source);
|
||||
}
|
||||
|
||||
if (TryCreateSourceFromUri(homepage, out var homepageSource))
|
||||
{
|
||||
UpsertSource(sourcesByKind, homepageSource);
|
||||
}
|
||||
|
||||
var orderedSources = sourcesByKind
|
||||
.Values
|
||||
.OrderBy(source => GetSortOrder(source.Kind))
|
||||
.ThenBy(source => source.DisplayName, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return (orderedSources, sourcesByKind);
|
||||
}
|
||||
|
||||
private static int GetSortOrder(string sourceKind)
|
||||
{
|
||||
return sourceKind.ToLowerInvariant() switch
|
||||
{
|
||||
SourceTypeStore => 0,
|
||||
SourceTypeWinGet => 1,
|
||||
SourceTypeGitHub => 2,
|
||||
SourceTypeWebsite => 3,
|
||||
SourceTypeUnknown => 99,
|
||||
_ => 98,
|
||||
};
|
||||
}
|
||||
|
||||
private static void UpsertSource(IDictionary<string, GallerySourceViewModel> sourcesByKind, GallerySourceViewModel source)
|
||||
{
|
||||
if (sourcesByKind.TryGetValue(source.Kind, out var existing))
|
||||
{
|
||||
sourcesByKind[source.Kind] = MergeSource(existing, source);
|
||||
return;
|
||||
}
|
||||
|
||||
sourcesByKind[source.Kind] = source;
|
||||
}
|
||||
|
||||
private static GallerySourceViewModel MergeSource(GallerySourceViewModel existing, GallerySourceViewModel incoming)
|
||||
{
|
||||
return new GallerySourceViewModel(
|
||||
existing.Kind,
|
||||
existing.DisplayName,
|
||||
!string.IsNullOrWhiteSpace(existing.Id) ? existing.Id : incoming.Id,
|
||||
!string.IsNullOrWhiteSpace(existing.Uri) ? existing.Uri : incoming.Uri,
|
||||
existing.IsKnown || incoming.IsKnown);
|
||||
}
|
||||
|
||||
private static GallerySourceViewModel CreateSourceViewModel(
|
||||
string kind,
|
||||
string displayName,
|
||||
string? id,
|
||||
string? uri,
|
||||
bool isKnown)
|
||||
{
|
||||
return new GallerySourceViewModel(
|
||||
kind,
|
||||
displayName,
|
||||
id,
|
||||
uri,
|
||||
isKnown);
|
||||
}
|
||||
|
||||
private static GallerySourceViewModel? CreateSourceFromInstallSource(GalleryInstallSource installSource)
|
||||
{
|
||||
var normalizedType = NormalizeSourceType(installSource.Type);
|
||||
if (normalizedType is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizedType switch
|
||||
{
|
||||
SourceTypeWinGet => CreateSourceViewModel(
|
||||
SourceTypeWinGet,
|
||||
Resources.gallery_item_source_name_winget,
|
||||
installSource.Id,
|
||||
uri: null,
|
||||
isKnown: true),
|
||||
SourceTypeStore => CreateSourceViewModel(
|
||||
SourceTypeStore,
|
||||
Resources.gallery_item_source_name_store,
|
||||
installSource.Id,
|
||||
uri: null,
|
||||
isKnown: true),
|
||||
SourceTypeUrl => CreateSourceFromUrl(installSource.Uri),
|
||||
_ => CreateSourceViewModel(
|
||||
SourceTypeUnknown,
|
||||
FormatResource(Resources.gallery_item_source_name_unknown, normalizedType),
|
||||
installSource.Id,
|
||||
installSource.Uri,
|
||||
isKnown: false),
|
||||
};
|
||||
}
|
||||
|
||||
private static GallerySourceViewModel? CreateSourceFromUrl(string? url)
|
||||
{
|
||||
var webUri = TryCreateWebUri(url);
|
||||
if (webUri is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (IsGitHubUri(webUri))
|
||||
{
|
||||
return CreateSourceViewModel(
|
||||
SourceTypeGitHub,
|
||||
Resources.gallery_item_source_name_github,
|
||||
id: null,
|
||||
uri: webUri.AbsoluteUri,
|
||||
isKnown: true);
|
||||
}
|
||||
|
||||
return CreateSourceViewModel(
|
||||
SourceTypeWebsite,
|
||||
Resources.gallery_item_source_name_website,
|
||||
id: null,
|
||||
uri: webUri.AbsoluteUri,
|
||||
isKnown: true);
|
||||
}
|
||||
|
||||
private static bool TryCreateSourceFromUri(string? uriValue, out GallerySourceViewModel source)
|
||||
{
|
||||
source = default!;
|
||||
if (CreateSourceFromUrl(uriValue) is not { } webSource)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
source = webSource;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? NormalizeSourceType(string? sourceType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return sourceType.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private void ApplySourceDetails(string sourceKind, IReadOnlyList<GallerySourceDetailItemViewModel> details)
|
||||
{
|
||||
if (!_sourcesByKind.TryGetValue(sourceKind, out var source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
source.SetDetails(details);
|
||||
OnPropertyChanged(nameof(SourcesWithDetails));
|
||||
OnPropertyChanged(nameof(HasSourceMetadataDetails));
|
||||
}
|
||||
|
||||
private static List<GallerySourceDetailItemViewModel> CreateSourceDetails(WinGetPackageDetails details)
|
||||
{
|
||||
List<GallerySourceDetailItemViewModel> rows = [];
|
||||
|
||||
AddDetail(rows, Resources.gallery_source_detail_summary_label, details.Summary, uri: null);
|
||||
AddDetail(rows, Resources.gallery_source_detail_description_label, details.Description, uri: null);
|
||||
AddDetail(rows, Resources.gallery_source_detail_version_label, details.Version, uri: null);
|
||||
AddDetail(rows, Resources.gallery_source_detail_package_label, details.Name, uri: null);
|
||||
AddDetail(rows, Resources.gallery_source_detail_publisher_label, details.Publisher, details.PublisherUrl);
|
||||
AddDetail(rows, Resources.gallery_source_detail_author_label, details.Author, uri: null);
|
||||
AddDetail(rows, Resources.gallery_source_detail_license_label, details.License, details.LicenseUrl);
|
||||
AddDetail(rows, Resources.gallery_source_detail_support_label, null, details.PublisherSupportUrl);
|
||||
AddDetail(rows, Resources.gallery_source_detail_package_page_label, null, details.PackageUrl);
|
||||
AddDetail(rows, Resources.gallery_source_detail_release_notes_label, details.ReleaseNotes, details.ReleaseNotesUrl);
|
||||
|
||||
for (var i = 0; i < details.DocumentationLinks.Count; i++)
|
||||
{
|
||||
var link = details.DocumentationLinks[i];
|
||||
AddDetail(rows, link.Label, null, link.Url);
|
||||
}
|
||||
|
||||
AddDetail(rows, Resources.gallery_source_detail_tags_label, BuildTagsText(details.Tags), uri: null);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static void AddDetail(ICollection<GallerySourceDetailItemViewModel> target, string label, string? value, string? uri)
|
||||
{
|
||||
var normalizedValue = ToNullIfWhiteSpace(value);
|
||||
var normalizedUri = TryCreateWebUri(uri);
|
||||
if (normalizedValue is null && normalizedUri is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
target.Add(new GallerySourceDetailItemViewModel(label, normalizedValue ?? normalizedUri!.AbsoluteUri, normalizedUri));
|
||||
}
|
||||
|
||||
private static Uri? TryCreateWebUri(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || !Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return IsWebUri(uri) ? uri : null;
|
||||
}
|
||||
|
||||
private static string? ToNullIfWhiteSpace(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string BuildTagsText(IReadOnlyList<string> tags)
|
||||
{
|
||||
if (tags.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
StringBuilder builder = new();
|
||||
for (var i = 0; i < tags.Count; i++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tags[i]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append(", ");
|
||||
}
|
||||
|
||||
builder.Append(tags[i]);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static bool IsGitHubUri(Uri uri)
|
||||
{
|
||||
return uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase)
|
||||
|| uri.Host.EndsWith(".github.com", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsWebUri(Uri uri)
|
||||
{
|
||||
return uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
|| uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool AreStatusTextsEquivalent(string first, string second)
|
||||
{
|
||||
return string.Equals(NormalizeStatusText(first), NormalizeStatusText(second), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string FormatResource(string format, params object?[] args)
|
||||
{
|
||||
return string.Format(System.Globalization.CultureInfo.CurrentCulture, format, args);
|
||||
}
|
||||
|
||||
private static string NormalizeStatusText(string value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().TrimEnd('.');
|
||||
}
|
||||
|
||||
public async Task RefreshWinGetPackageInfoAsync(WinGetPackageOperationKind completedOperationKind = WinGetPackageOperationKind.Install)
|
||||
{
|
||||
if (_winGetPackageStatusService is not null && !string.IsNullOrWhiteSpace(WinGetId))
|
||||
{
|
||||
var infos = await _winGetPackageStatusService.TryGetPackageInfosAsync([WinGetId]);
|
||||
if (infos is not null && infos.TryGetValue(WinGetId, out var packageInfo))
|
||||
{
|
||||
ApplyWinGetPackageInfo(packageInfo);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
IsInstalled = completedOperationKind != WinGetPackageOperationKind.Uninstall;
|
||||
IsInstalledStateKnown = true;
|
||||
IsUpdateAvailable = false;
|
||||
IsUpdateStateKnown = true;
|
||||
}
|
||||
|
||||
private void ApplyOptimisticTrackedCompletion(WinGetPackageOperationKind completedOperationKind)
|
||||
{
|
||||
IsInstalled = completedOperationKind != WinGetPackageOperationKind.Uninstall;
|
||||
IsInstalledStateKnown = true;
|
||||
IsUpdateAvailable = false;
|
||||
IsUpdateStateKnown = true;
|
||||
}
|
||||
|
||||
private ImageSource CreateImageSource(Uri iconUri)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new BitmapImage(iconUri);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogIconLoadFailed(_logger, iconUri.AbsoluteUri, ex);
|
||||
return new BitmapImage(PlaceholderIconUri);
|
||||
}
|
||||
}
|
||||
|
||||
private static void LogWinGetInstallFailed(ILogger logger, Exception exception)
|
||||
{
|
||||
LogWinGetInstallFailedMessage(logger, exception);
|
||||
}
|
||||
|
||||
private static void LogIconLoadFailed(ILogger logger, string iconUri, Exception exception)
|
||||
{
|
||||
LogIconLoadFailedMessage(logger, iconUri, exception);
|
||||
}
|
||||
|
||||
partial void OnIsInstalledChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowInstalledBadge));
|
||||
OnPropertyChanged(nameof(ShowInstallButton));
|
||||
OnPropertyChanged(nameof(InstallStatusText));
|
||||
OnPropertyChanged(nameof(WinGetStatusText));
|
||||
OnPropertyChanged(nameof(ShowWinGetStatusDetails));
|
||||
OnPropertyChanged(nameof(ShowInstallViaWinGetButton));
|
||||
OnPropertyChanged(nameof(CanInstallViaWinGet));
|
||||
OnPropertyChanged(nameof(InstallViaWinGetText));
|
||||
OnPropertyChanged(nameof(ShowWinGetActionControls));
|
||||
InstallViaWinGetCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnIsInstalledStateKnownChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(InstallStatusText));
|
||||
OnPropertyChanged(nameof(WinGetStatusText));
|
||||
OnPropertyChanged(nameof(ShowWinGetStatusDetails));
|
||||
}
|
||||
|
||||
partial void OnIsUpdateAvailableChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowInstalledBadge));
|
||||
OnPropertyChanged(nameof(ShowInstallButton));
|
||||
OnPropertyChanged(nameof(ShowUpdateBadge));
|
||||
OnPropertyChanged(nameof(InstallStatusText));
|
||||
OnPropertyChanged(nameof(WinGetStatusText));
|
||||
OnPropertyChanged(nameof(ShowWinGetStatusDetails));
|
||||
OnPropertyChanged(nameof(ShowInstallViaWinGetButton));
|
||||
OnPropertyChanged(nameof(CanInstallViaWinGet));
|
||||
OnPropertyChanged(nameof(InstallViaWinGetText));
|
||||
OnPropertyChanged(nameof(ShowWinGetActionControls));
|
||||
InstallViaWinGetCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnIsWinGetActionInProgressChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanInstallViaWinGet));
|
||||
OnPropertyChanged(nameof(ShowWinGetActionIndicator));
|
||||
OnPropertyChanged(nameof(ShowWinGetActionStatus));
|
||||
OnPropertyChanged(nameof(ShowCancelWinGetActionButton));
|
||||
OnPropertyChanged(nameof(ShowWinGetActionControls));
|
||||
InstallViaWinGetCommand.NotifyCanExecuteChanged();
|
||||
CancelWinGetActionCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnCanCancelWinGetActionChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowCancelWinGetActionButton));
|
||||
CancelWinGetActionCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnWinGetActionMessageChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasWinGetActionMessage));
|
||||
OnPropertyChanged(nameof(ShowWinGetActionStatus));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
using Microsoft.CmdPal.Common.WinGet.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
|
||||
|
||||
public sealed class ExtensionGalleryItemViewModelFactory
|
||||
{
|
||||
private readonly ILogger<ExtensionGalleryItemViewModel> _logger;
|
||||
private readonly IWinGetPackageManagerService? _winGetPackageManagerService;
|
||||
private readonly IWinGetOperationTrackerService? _winGetOperationTrackerService;
|
||||
private readonly IWinGetPackageStatusService? _winGetPackageStatusService;
|
||||
|
||||
public ExtensionGalleryItemViewModelFactory(
|
||||
ILogger<ExtensionGalleryItemViewModel> logger,
|
||||
IWinGetPackageManagerService? winGetPackageManagerService = null,
|
||||
IWinGetPackageStatusService? winGetPackageStatusService = null,
|
||||
IWinGetOperationTrackerService? winGetOperationTrackerService = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_winGetPackageManagerService = winGetPackageManagerService;
|
||||
_winGetPackageStatusService = winGetPackageStatusService;
|
||||
_winGetOperationTrackerService = winGetOperationTrackerService;
|
||||
}
|
||||
|
||||
public ExtensionGalleryItemViewModel Create(GalleryExtensionEntry entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
return new ExtensionGalleryItemViewModel(
|
||||
entry,
|
||||
_logger,
|
||||
_winGetPackageManagerService,
|
||||
_winGetPackageStatusService,
|
||||
_winGetOperationTrackerService);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using Resources = Microsoft.CmdPal.UI.ViewModels.Properties.Resources;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
|
||||
|
||||
public sealed class ExtensionGalleryScreenshotViewModel
|
||||
{
|
||||
private static readonly CompositeFormat DisplayNameFormat
|
||||
= CompositeFormat.Parse(Resources.gallery_screenshot_display_name!);
|
||||
|
||||
public ExtensionGalleryScreenshotViewModel(Uri uri, int index)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(uri);
|
||||
|
||||
Uri = uri;
|
||||
Index = index;
|
||||
DisplayName = string.Format(System.Globalization.CultureInfo.CurrentCulture, DisplayNameFormat, index + 1);
|
||||
}
|
||||
|
||||
public Uri Uri { get; }
|
||||
|
||||
public int Index { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public ImageSource ImageSource => field ??= CreateImageSource(Uri);
|
||||
|
||||
private static ImageSource CreateImageSource(Uri uri)
|
||||
{
|
||||
BitmapImage bitmap = new();
|
||||
bitmap.DecodePixelWidth = 720;
|
||||
bitmap.UriSource = uri;
|
||||
return bitmap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
|
||||
|
||||
public enum ExtensionGallerySortOption
|
||||
{
|
||||
Featured = 0,
|
||||
Name = 1,
|
||||
Author = 2,
|
||||
InstallationStatus = 3,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user