Compare commits

..

12 Commits

Author SHA1 Message Date
Michael Jolley
3924e04578 Fleshing out TS SDK 2026-06-17 13:58:31 -05:00
Michael Jolley
34bdb12cc5 Fleshing out TS SDK 2026-06-15 22:21:17 -05:00
Michael Jolley
734a50ee97 Initial pass at JS extensions. 2026-06-14 22:43:12 -05:00
Michael Jolley
c953f84fa3 Reverting changes to ShellViewModel 2026-06-12 22:08:16 -05:00
Michael Jolley
a8d699b145 More clean-up & testing 2026-06-12 22:06:09 -05:00
Michael Jolley
d2cee3a497 Removing unneeded class 2026-06-12 20:48:41 -05:00
Michael Jolley
757f60d719 Merge branch 'main' into michaeljolley/refactor-tlcm-extension-services 2026-06-12 18:23:22 -05:00
Michael Jolley
438507cdd4 Final cleanup 2026-06-12 18:21:31 -05:00
Michael Jolley
89cf4e3115 Continuing reorg clean-up 2026-06-12 17:51:13 -05:00
Michael Jolley
59bc58afe2 Address PR feedback: clean up types, graceful cancellation, extract class
- Use IExtensionWrapper directly (with existing using) in
  ExtensionGalleryViewModel instead of fully-qualified name
- Replace ct.ThrowIfCancellationRequested() with graceful early return
  in BuiltInExtensionService.LoadProvidersAsync
- Extract ExtensionStartResult into its own file
- Restore 'Go back to the main page, but keep it open' comment

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 22:47:05 -05:00
Michael Jolley
e22bbd05a5 Fix check-spelling: replace TLCM and startable in comments
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 21:45:50 -05:00
Michael Jolley
6b0779a7c5 Refactor TopLevelCommandManager to use IExtensionService implementations
Extract extension loading from TopLevelCommandManager into separate
IExtensionService implementations:

- BuiltInExtensionService: wraps in-proc ICommandProvider instances from DI
- WinRTExtensionService: manages out-of-process WinRT AppExtension providers

TLCM now receives IEnumerable<IExtensionService> and orchestrates them
uniformly. This reduces TLCM from ~900 to ~700 lines and separates
provider-specific concerns (timeout, retry, catalog events) from
command orchestration.

Additional fixes:
- Break circular DI dependency: BuiltInsCommandProvider no longer needs
  IRootPageService. Uses GoHomeDockCommand (returns CommandResult.GoHome())
  with onBeforeShowConfirmation callback to show palette at dock position.
- Restore two-phase loading: PreLoadAsync loads only built-ins (instant),
  PostLoadRootPageAsync loads WinRT extensions on background thread.
- Add try/finally around IsLoading to prevent stuck loading indicator.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 21:29:33 -05:00
198 changed files with 14047 additions and 2765 deletions

View File

@@ -309,11 +309,6 @@ pwa
AOT
Aot
ify
LAF
Laf
languagemodel
philm
phisilica
TFM
# YML

View File

@@ -112,6 +112,8 @@
^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$
@@ -135,8 +137,6 @@
^src/modules/previewpane/SvgPreviewHandler/SvgHTMLPreviewGenerator\.cs$
^src/modules/previewpane/UnitTests-MarkdownPreviewHandler/HelperFiles/MarkdownWithHTMLImageTag\.txt$
^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$
^src/modules/ZoomIt/ZoomIt/rnnoise/
^src/modules/ZoomIt/ZoomIt/selfie_segmentation\.onnx$
^src/modules/ZoomIt/ZoomIt/ZoomIt\.idc$
^src/Monaco/
^tools/project_template/ModuleTemplate/resource\.h$

View File

@@ -1602,6 +1602,7 @@ sancov
SAVEFAILED
schedtasks
SCID
SCL
Scode
SCREENFONTS
screenruler

View File

@@ -313,9 +313,6 @@ ms-windows-store://\S+
# ANSI color codes
(?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[\d+(?:;\d+)*m
# Phi Silica internal token/ID literals
<PhiSilicaLafToken\b[^>]*>[^<]+</PhiSilicaLafToken>
\bdjwsxzxb4ksa8\b
# Special licenses text from RNNoise (BSD-style disclaimer: ``AS IS'')
``AS IS''

3
.gitignore vendored
View File

@@ -378,6 +378,3 @@ installer/*/*.wxs.bk
vcpkg_installed/
deps/vcpkg/
# Superpowers-generated docs (specs, design, plans) — local-only, not committed
docs/superpowers/

View File

@@ -102,7 +102,7 @@ extends:
useManagedIdentity: $(SigningUseManagedIdentity)
clientId: $(SigningOriginalClientId)
# Have msbuild use the release nuget config profile
additionalBuildOptions: /p:RestoreConfigFile="$(Build.SourcesDirectory)\.pipelines\release-nuget.config" /p:EnableCmdPalAOT=true /p:PhiSilicaLafToken=$(PhiSilicaLafToken) /p:PhiSilicaLafAttestation="$(PhiSilicaLafAttestation)"
additionalBuildOptions: /p:RestoreConfigFile="$(Build.SourcesDirectory)\.pipelines\release-nuget.config" /p:EnableCmdPalAOT=true
beforeBuildSteps:
# Install the Terrapin retrieval tool, which replaces vcpkg's download handler
# to redirect it to a safe Microsoft-controlled location

View File

@@ -266,17 +266,6 @@ jobs:
VCWhereExtraVersionTarget: '-prerelease'
- ${{ if eq(parameters.official, true) }}:
# M.W.T.V Setup.ps1 sets the pipeline-level XES_APPXMANIFESTVERSION env var
# from the supplied -ProjectDirectory's custom.props. Whichever invocation
# runs last wins. cmdpal MUST run last because $(CmdPalVersion) (used by the
# VNext installer and CmdPal's AppxPackageTestDir) falls back to that env
# var; if AP wins, CmdPal's folder name and MSIX filename disagree on
# VersionMinor and the installer fails with WIX0103. Each project's own
# custom.props is still applied at MSBuild time, so AP versioning is
# unaffected by the order.
- template: .\steps-setup-versioning.yml
parameters:
directory: $(build.sourcesdirectory)\src\modules\AdvancedPaste
- template: .\steps-setup-versioning.yml
parameters:
directory: $(build.sourcesdirectory)\src\modules\cmdpal

View File

@@ -3,7 +3,6 @@
<RepoRoot>$(MSBuildThisFileDirectory)</RepoRoot>
</PropertyGroup>
<Import Project="$(RepoRoot)src\Version.props" />
<Import Project="$(RepoRoot)src\PhiSilicaLaf.props" />
<PropertyGroup>
<Copyright>Copyright (C) Microsoft Corporation. All rights reserved.</Copyright>
<AssemblyCopyright>Copyright (C) Microsoft Corporation. All rights reserved.</AssemblyCopyright>

View File

@@ -78,10 +78,10 @@
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1"/>
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6901" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
<PackageVersion Include="Microsoft.WindowsAppSDK.Foundation" Version="2.1.0" />
<PackageVersion Include="Microsoft.WindowsAppSDK.AI" Version="2.2.3" />
<PackageVersion Include="Microsoft.WindowsAppSDK.Runtime" Version="2.2.0" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.1" />
<PackageVersion Include="Microsoft.WindowsAppSDK.Foundation" Version="2.0.20" />
<PackageVersion Include="Microsoft.WindowsAppSDK.AI" Version="2.0.185" />
<PackageVersion Include="Microsoft.WindowsAppSDK.Runtime" Version="2.0.1" />
<PackageVersion Include="Microsoft.Xaml.Behaviors.WinUI.Managed" Version="2.0.9" />
<PackageVersion Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
<PackageVersion Include="ModernWpfUI" Version="0.9.4" />

View File

@@ -1,100 +0,0 @@
# Advanced Paste Phi Silica local testing
How to build, register, and test **Phi Silica** in **Advanced Paste (AP)** on a dev machine,
plus the few things that actually break it.
## How it fits together
AP ships as an **unpackaged, self-contained WinUI 3 exe** (`PowerToys.AdvancedPaste.exe`).
The Windows AI `LanguageModel` (Phi Silica) API is a **Limited Access Feature (LAF)**. For it
to work, all of these must line up:
1. **Package identity** — AP runs with identity granted by the sparse MSIX
`Microsoft.PowerToys.SparseApp`.
2. **Matching LAF creds** — the token/attestation baked into the exe match the registered
sparse package's publisher.
3. **AI metadata deployed** — the `Microsoft.Windows.AI*.winmd` files ship next to the exe;
the AI runtime resolves them **at runtime**.
4. **Model ready** — supported hardware and the on-device model downloaded
(`GetReadyState() == Ready`).
Two identities — the baked token must match the registered package's publisher:
| Build | Publisher Id | LAF creds |
|-------|--------------|-----------|
| **Dev** | `djwsxzxb4ksa8` | dev default in [`src/PhiSilicaLaf.props`](../../../src/PhiSilicaLaf.props) |
| **Prod** | `8wekyb3d8bbwe` | secret, injected only by `.pipelines/v2/release.yml` |
Non-secret pairing check: the exe's baked **Attestation** must equal the registered package's
**PublisherId**.
## Build + register (dev loop)
```powershell
$repo = "X:\GitHub\PowerToys"; $Plat = "ARM64"; $Cfg = "Debug" # or x64 / Release
# Build AP only (C#; reuses existing C++ outputs):
dotnet restore "$repo\src\modules\AdvancedPaste\AdvancedPaste\AdvancedPaste.csproj" /p:Platform=$Plat
& "$repo\tools\build\build.cmd" -Path "$repo\src\modules\AdvancedPaste\AdvancedPaste" `
-Platform $Plat -Configuration $Cfg /p:BuildProjectReferences=false
# Register the dev sparse package (creates + trusts a dev cert, grants identity):
pwsh -ExecutionPolicy Bypass -File "$repo\src\PackageIdentity\BuildSparsePackage.ps1" `
-Platform $Plat -Configuration $Cfg -DevRegister
# Expect: PublisherId djwsxzxb4ksa8, IsDevelopmentMode True
```
## Check the API
`PowerToys.AdvancedPaste.exe` is a **GUI-subsystem** app — run directly in a console it prints
nothing and returns no exit code. **Redirect** stdout/stderr and wait:
```powershell
$exe = "$repo\$Plat\$Cfg\WinUI3Apps\PowerToys.AdvancedPaste.exe"
$o = "$env:TEMP\ap.out"; $e = "$env:TEMP\ap.err"
$p = Start-Process $exe '--check-phi-silica' -Wait -PassThru -WindowStyle Hidden `
-RedirectStandardOutput $o -RedirectStandardError $e
"exit=$($p.ExitCode) stdout=$((Get-Content $o -Raw).Trim())"
Get-Content $e -Raw # stderr: [phi-silica] LAF unlock status: <…>; ReadyState: <…>
```
| `--check-phi-silica` | `--prepare-phi-silica` (downloads the model) |
|----------------------|----------------------------------------------|
| `0` Available · `1` NotReady · `2` NotSupported / unlock failed | `0` Ready · `1` Failed · `2` NotSupported |
`--check` only reads state; use `--prepare` to trigger the model download (`EnsureReadyAsync`).
On failure it prints the `HRESULT` to stderr.
Confirm the running AP has identity:
```powershell
$apPid = (Get-Process PowerToys.AdvancedPaste -EA SilentlyContinue | Select-Object -First 1).Id
if ($apPid) { & "$repo\src\PackageIdentity\Check-ProcessIdentity.ps1" -ProcessId $apPid }
# Expect a PFN ending in the publisher id that matches the baked attestation
```
## What actually breaks it
- **Missing `.winmd` (most important).** The Windows AI runtime resolves
`Microsoft.Windows.AI*.winmd` from the app folder at runtime. If they aren't deployed,
`GetReadyState()` returns `NotReady` and `EnsureReadyAsync()` fails with
`RO_E_METADATA_NAME_NOT_FOUND` (`0x8000000F`) — even though identity, token, and the AI DLLs
are all correct. The build emits these winmd into `WinUI3Apps\`; the **installer must harvest
them** (`*.winmd` is in the inclusion list of
[`generateAllFileComponents.ps1`](../../../installer/PowerToysSetupVNext/generateAllFileComponents.ps1)).
Classic symptom: "works from the build output but not from the installer" → check that the
installed `WinUI3Apps\` contains `Microsoft.Windows.AI*.winmd`.
- **Dev/prod mismatch.** A dev-cred exe running against a prod sparse package (or vice versa)
makes the LAF unlock silently return `Unavailable`. Keep the exe and the registered package
the same flavor, and verify with the attestation == publisherId check above.
- **Forgot to redirect.** `--check-phi-silica` in a console prints nothing — that's the
GUI-subsystem quirk, not a result.
## Cleanup
```powershell
pwsh -ExecutionPolicy Bypass -File "$repo\src\PackageIdentity\BuildSparsePackage.ps1" -Unregister
```
⚠️ This removes any `Microsoft.PowerToys.SparseApp` registration, **including a prod one** from
an installer — reinstall/repair PowerToys to restore it.

View File

@@ -33,81 +33,7 @@ See the `ExecutePasteFormatAsync(PasteFormat, PasteActionSource)` method in `Opt
## Debugging
Advanced Paste is an unpackaged, self-contained WinUI 3 app (`PowerToys.AdvancedPaste.exe`). To call Windows AI APIs (Phi Silica / `Microsoft.Windows.AI.Text.LanguageModel`) it acquires **package identity** at runtime via a shared sparse MSIX package (`Microsoft.PowerToys.SparseApp`).
### Running and attaching the debugger
1. Set the **Runner** project (`src/runner`) as the startup project in Visual Studio.
2. Launch the Runner (F5). This starts the PowerToys tray icon and loads all module interfaces.
3. Open Settings (right-click tray icon → Settings) and enable the **Advanced Paste** module if it isn't already. The module launches `PowerToys.AdvancedPaste.exe` in the background immediately.
4. In Visual Studio, go to **Debug → Attach to Process** (`Ctrl+Alt+P`) and attach to `PowerToys.AdvancedPaste.exe` (select **Managed (.NET Core)** debugger).
Alternatively, use the VS Code launch configuration **"Run AdvancedPaste"** from [.vscode/launch.json](/.vscode/launch.json) to launch the exe directly — but note that without the Runner, IPC and hotkeys won't work.
### Sparse package identity (local development)
#### Why is this needed?
- The `LanguageModel` API requires a Limited Access Feature (LAF) unlock, which only succeeds when the calling process has a matching package identity.
- Advanced Paste is an unpackaged, self-contained WinUI 3 app. The sparse package grants it identity without converting it to a full MSIX.
- The csproj uses `<ProjectPriFileName>PowerToys.AdvancedPaste.pri</ProjectPriFileName>` (matching the convention of other WinUI3 apps like ImageResizer). This requires WindowsAppSDK Foundation >= 2.0.22 ([PR #6376](https://github.com/microsoft/WindowsAppSDK/pull/6376)) which fixes MRT PRI lookup under sparse identity so `Application.LoadComponent` resolves custom-named PRI files instead of hard-coding `resources.pri`.
#### One-step dev setup
```powershell
pwsh src/PackageIdentity/BuildSparsePackage.ps1 -Platform ARM64 -Configuration Debug -DevRegister
```
`-DevRegister`:
1. Generates a dev certificate under `src/PackageIdentity/.user/` (first run only).
2. Auto-imports that certificate into `CurrentUser\TrustedPeople` and `CurrentUser\Root` so the OS grants sparse identity to AP (without trust, `GetPackageFamilyName` returns `APPMODEL_ERROR_NO_PACKAGE` and LAF unlock silently fails).
3. Removes any prior registration.
4. Rewrites the publisher in a temp copy of `AppxManifest.xml` to match the dev cert subject.
5. Registers via `Add-AppxPackage -Register … -ExternalLocation X:\…\<Platform>\<Config>\WinUI3Apps`.
After registration verify:
```powershell
$pkg = Get-AppxPackage -Name '*SparseApp*'
$pkg.PackageFamilyName # Microsoft.PowerToys.SparseApp_<PublisherId>
$pkg.PublisherId # djwsxzxb4ksa8
$pkg.IsDevelopmentMode # True
```
Confirm AP picks up sparse identity at runtime:
```powershell
& 'ARM64\Debug\WinUI3Apps\PowerToys.AdvancedPaste.exe' --check-phi-silica
# Exit 0 = Available, 1 = NotReady, 2 = NotSupported
```
Re-register after rebuilding AP, changing `src/PackageIdentity/AppxManifest.xml`, or switching platforms/configurations by re-running the same command. Unregister with `-Unregister`.
#### Troubleshooting
| Problem | Cause | Fix |
|---------|-------|-----|
| `GetPackageFamilyName` returns `APPMODEL_ERROR_NO_PACKAGE` (15700) at runtime; LAF unlock returns `Unavailable` | Dev certificate not trusted (or sparse package not registered) | Re-run `BuildSparsePackage.ps1 -DevRegister` — auto-imports the cert into `TrustedPeople` and `Root`. |
| `Microsoft.UI.Xaml.dll` crash with `0xC000027B` (class-not-registered) on AP or Settings startup | `<Application>` `Executable` path in `src/PackageIdentity/AppxManifest.xml` does not resolve under the registered `ExternalLocation` (`<Config>\WinUI3Apps\`) | Confirm every `Executable` is relative to `WinUI3Apps\` (per #47177) and the file exists under the build output. |
| AP launches but never shows a window when triggered via hotkey | Runner's pipe-server wait timed out before AP's cold-start finished bootstrapping WinAppSDK + DI host | Already mitigated by the 15 s pipe timeout in `AdvancedPasteProcessManager.cpp`; warm-start launches connect in well under 1 s. |
| `XamlParseException` / `ms-appx:///Microsoft.UI.Xaml/Themes/…` not found | WindowsAppSDK Foundation < 2.0.22; MRT can't resolve custom PRI name under sparse identity | Ensure `Microsoft.WindowsAppSDK.Foundation` >= 2.0.22 in `Directory.Packages.props`. |
### How Settings UI checks Phi Silica availability
Settings UI does not have sparse package identity. To check whether Phi Silica is available, it launches Advanced Paste as a short-lived subprocess:
```
PowerToys.AdvancedPaste.exe --check-phi-silica
```
`Program.Main` recognizes this flag, calls `PhiSilicaLafHelper.TryUnlock()` + `LanguageModel.GetReadyState()`, prints one of `Available` / `NotReady` / `NotSupported` to stdout, and exits with the matching code (0/1/2). Settings reads stdout with a 10 s wait. Because each call is a fresh process, transient `Unavailable` results are not cached across checks.
### See also
- [Phi Silica local testing & troubleshooting guide](advancedpaste-phisilica-local-testing.md) — layer-by-layer diagnostics for Phi Silica availability
- [`src/PackageIdentity/readme.md`](/src/PackageIdentity/readme.md) — full sparse package documentation
- [microsoft/microsoft-ui-xaml#10856](https://github.com/microsoft/microsoft-ui-xaml/issues/10856) — original WinUI sparse-identity PRI bug
- [microsoft/WindowsAppSDK#6376](https://github.com/microsoft/WindowsAppSDK/pull/6376) — MRT sparse PRI fix (Foundation >= 2.0.22)
TODO: Add debugging information
## Settings

View File

@@ -79,4 +79,4 @@ Below are community created plugins that target a website or software. They are
| [Linear](https://github.com/vednig/powertoys-linear) | [vednig](https://github.com/vednig) | Create Linear Issues directly from Powertoys Run |
| [PerplexitySearchShortcut](https://github.com/0x6f677548/PowerToys-Run-PerplexitySearchShortcut) | [0x6f677548](https://github.com/0x6f677548) | Search Perplexity |
| [SpeedTest](https://github.com/ruslanlap/PowerToysRun-SpeedTest) | [ruslanlap](https://github.com/ruslanlap) | One-command internet speed tests with real-time results, modern UI, and shareable links. |
| [DiskAnalyzer](https://github.com/valley-soft/powertoys-diskanalyzer) | [ValleySoft](https://github.com/valley-soft) | Scan folders, find the largest files, and view drive space usage. |
| [DiskAnalyzer](https://github.com/thetsaw/PowerToys.Plugin) | [thetsaw](https://github.com/thetsaw) | Scan folders, find the largest files, and view drive space usage with visual progress bars. |

View File

@@ -28,23 +28,12 @@ Function Generate-FileList() {
$fileExclusionList = @("*.pdb", "*.lastcodeanalysissucceeded", "createdump.exe", "powertoys.exe")
# *.winmd: WinRT metadata for the Windows App SDK AI APIs (Phi Silica, Imaging, etc.). The AI
# runtime resolves these from the app directory at runtime, so they must ship with the product.
# Without them GetReadyState() reports NotReady and EnsureReadyAsync() fails with
# RO_E_METADATA_NAME_NOT_FOUND (0x8000000F). The build already emits them into the app output
# (e.g. WinUI3Apps); they were previously dropped here because the harvest didn't include them.
$fileInclusionList = @("*.dll", "*.exe", "*.json", "*.msix", "*.png", "*.gif", "*.ico", "*.cur", "*.svg", "index.html", "reg.js", "gitignore.js", "srt.js", "monacoSpecialLanguages.js", "customTokenThemeRules.js", "*.pri", "*.yml", "*.winmd")
$fileInclusionList = @("*.dll", "*.exe", "*.json", "*.msix", "*.png", "*.gif", "*.ico", "*.cur", "*.svg", "index.html", "reg.js", "gitignore.js", "srt.js", "monacoSpecialLanguages.js", "customTokenThemeRules.js", "*.pri", "*.yml")
# 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")
# Microsoft.CommandPalette.Extensions.winmd already has a dedicated WiX component
# (Microsoft_CommandPalette_Extensions_winmd in BaseApplications.wxs, placed in WinUI3Apps for
# CmdPal's WinRT resolution). Exclude it from the generic *.winmd harvest so it isn't declared
# by two components (WIX ICE30 "installed by two different components" breaks ref-counting).
$fileExclusionList += @("Microsoft.CommandPalette.Extensions.winmd")
$dllsToIgnore = @("System.CodeDom.dll", "WindowsBase.dll")
if ($fileDepsJson -eq [string]::Empty) {

View File

@@ -58,16 +58,6 @@
AppListEntry="none">
</uap:VisualElements>
</Application>
<Application Id="PowerToys.AdvancedPasteUI" Executable="PowerToys.AdvancedPaste.exe" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="PowerToys.AdvancedPaste"
Description="PowerToys Advanced Paste"
BackgroundColor="transparent"
Square150x150Logo="Images\Square150x150Logo.png"
Square44x44Logo="Images\Square44x44Logo.png"
AppListEntry="none">
</uap:VisualElements>
</Application>
<Application Id="PowerToys.CmdPalExtension" Executable="Microsoft.CmdPal.Ext.PowerToys.exe" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="PowerToys.CommandPaletteExtension"

View File

@@ -13,9 +13,7 @@ Param(
[switch]$Clean,
[switch]$ForceCert,
[switch]$NoSign,
[switch]$CIBuild,
[switch]$DevRegister,
[switch]$Unregister
[switch]$CIBuild
)
# PowerToys sparse packaging helper.
@@ -24,19 +22,6 @@ Param(
$ErrorActionPreference = 'Stop'
# Handle -Unregister early exit
if ($Unregister) {
$existing = Get-AppxPackage -Name 'Microsoft.PowerToys.SparseApp' -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "Removing Microsoft.PowerToys.SparseApp..."
$existing | Remove-AppxPackage
Write-Host "Done."
} else {
Write-Host "Microsoft.PowerToys.SparseApp is not registered."
}
exit 0
}
$isCIBuild = $false
if ($CIBuild.IsPresent) {
$isCIBuild = $true
@@ -436,85 +421,3 @@ $winUI3AppsDir = Join-Path $outDir "WinUI3Apps"
Write-BuildLog "Register sparse package:" -Level Info
Write-BuildLog " Add-AppxPackage -Path `"$msixPath`" -ExternalLocation `"$winUI3AppsDir`"" -Level Warning
Write-BuildLog "(If already installed and you changed manifest only): Add-AppxPackage -Register `"$manifestPath`" -ExternalLocation `"$winUI3AppsDir`" -ForceApplicationShutdown" -Level Warning
# -DevRegister: automatically register the sparse package for local development
if ($DevRegister) {
Write-BuildLog "`nDev registration requested..." -Level Info
# Ensure the dev certificate is trusted (CurrentUser\TrustedPeople + CurrentUser\Root).
# Without trust, sparse identity lookup silently fails at runtime (GetPackageFamilyName
# returns APPMODEL_ERROR_NO_PACKAGE) so LAF unlocks like Phi Silica return Unavailable.
if (Test-Path $CertCerFile) {
try {
$devCert = (Get-PfxCertificate -FilePath $CertCerFile -ErrorAction Stop)
$devThumbprint = $devCert.Thumbprint
foreach ($store in @('TrustedPeople', 'Root')) {
$storePath = "Cert:\CurrentUser\$store"
$present = Get-ChildItem $storePath -ErrorAction SilentlyContinue |
Where-Object { $_.Thumbprint -eq $devThumbprint }
if (-not $present) {
Write-BuildLog "Importing dev certificate ($devThumbprint) into CurrentUser\$store" -Level Info
Import-Certificate -FilePath $CertCerFile -CertStoreLocation $storePath | Out-Null
}
}
} catch {
Write-BuildLog "Failed to import dev certificate into trust stores: $($_.Exception.Message)" -Level Warning
Write-BuildLog "Sparse identity may not be granted at runtime; LAF unlocks (e.g., Phi Silica) will fail." -Level Warning
}
} else {
Write-BuildLog "Certificate .cer file not found at $CertCerFile; skipping trust-store import." -Level Warning
}
# Remove existing registration
$existing = Get-AppxPackage -Name $script:Config.IdentityName -ErrorAction SilentlyContinue
if ($existing) {
Write-BuildLog "Removing existing registration..." -Level Info
$existing | Remove-AppxPackage
}
# Create a temp manifest with the dev publisher for -Register
$devRegDir = Join-Path ([System.IO.Path]::GetTempPath()) "PowerToysSparseDevReg"
if (Test-Path $devRegDir) { Remove-Item $devRegDir -Recurse -Force }
New-Item -ItemType Directory -Path $devRegDir -Force | Out-Null
try {
Copy-Item $manifestPath (Join-Path $devRegDir 'AppxManifest.xml')
$imagesDir = Join-Path $sparseDir 'Images'
if (Test-Path $imagesDir) {
Copy-Item $imagesDir (Join-Path $devRegDir 'Images') -Recurse
}
$devManifest = Join-Path $devRegDir 'AppxManifest.xml'
[xml]$devXml = Get-Content $devManifest -Raw
$devIdentity = $devXml.Package.Identity
if ($devIdentity.Publisher -ne $script:Config.CertSubject) {
Write-BuildLog "Rewriting publisher for dev registration" -Level Info
$devIdentity.SetAttribute('Publisher', $script:Config.CertSubject)
$devXml.Save($devManifest)
}
Write-BuildLog "Registering with ExternalLocation: $winUI3AppsDir" -Level Info
Add-AppxPackage -Register $devManifest -ExternalLocation $winUI3AppsDir
$pkg = Get-AppxPackage -Name $script:Config.IdentityName -ErrorAction SilentlyContinue
if ($pkg) {
Write-BuildLog "Dev registration successful:" -Level Success
Write-BuildLog " Publisher: $($pkg.Publisher)" -Level Info
Write-BuildLog " PublisherId: $($pkg.PublisherId)" -Level Info
Write-BuildLog " IsDevelopmentMode: $($pkg.IsDevelopmentMode)" -Level Info
Write-BuildLog " InstallLocation: $($pkg.InstallLocation)" -Level Info
if ($pkg.PublisherId -ne 'djwsxzxb4ksa8') {
Write-BuildLog "PublisherId mismatch! Expected 'djwsxzxb4ksa8', got '$($pkg.PublisherId)'. LAF unlock will fail." -Level Warning
}
} else {
Write-BuildLog "Dev registration failed — package not found." -Level Error
exit 1
}
} finally {
if (Test-Path $devRegDir) {
Remove-Item $devRegDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
}

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<!-- Phi Silica Limited Access Feature credentials.
Local dev defaults below; overridden by /p: in release pipelines. -->
<PhiSilicaLafToken Condition="'$(PhiSilicaLafToken)'==''">RmToMMYJHZkQSrKP5lWesA==</PhiSilicaLafToken>
<PhiSilicaLafAttestation Condition="'$(PhiSilicaLafAttestation)'==''">djwsxzxb4ksa8</PhiSilicaLafAttestation>
</PropertyGroup>
</Project>

View File

@@ -55,22 +55,6 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
public IReadOnlyList<PasteFormats> AdditionalActions => _additionalActions;
public string FixSpellingAndGrammarPrompt => string.Empty;
public string FixSpellingAndGrammarSystemPrompt => string.Empty;
public string FixSpellingAndGrammarProviderId => string.Empty;
public bool FixSpellingAndGrammarCoachingEnabled => false;
public bool FixSpellingAndGrammarCoachingShortcutSet => false;
public string FixSpellingAndGrammarCoachingPrompt => string.Empty;
public string FixSpellingAndGrammarCoachingSystemPrompt => string.Empty;
public string FixSpellingAndGrammarCoachingProviderId => string.Empty;
public PasteAIConfiguration PasteAIConfiguration => _configuration;
public event EventHandler Changed;

View File

@@ -30,16 +30,6 @@ public sealed class CustomActionKernelQueryCacheServiceTests
private static readonly CacheValue TestValue = new([new(PasteFormats.PlainText, [])]);
private static readonly CacheValue TestValue2 = new([new(PasteFormats.KernelQuery, new() { { "a", "b" }, { "c", "d" } })]);
private static string LocalizeResourceId(string resourceId) => resourceId switch
{
"PasteAsPlainText" => "Paste as plain text",
"PasteAsMarkdown" => MarkdownTestKey.Prompt,
"PasteAsJson" => JSONTestKey.Prompt,
"PasteAsTxtFile" => PasteAsTxtFileKey.Prompt,
"PasteAsPngFile" => PasteAsPngFileKey.Prompt,
_ => resourceId,
};
private CustomActionKernelQueryCacheService _cacheService;
private Mock<IUserSettings> _userSettings;
private MockFileSystem _fileSystem;
@@ -51,7 +41,7 @@ public sealed class CustomActionKernelQueryCacheServiceTests
UpdateUserActions([], []);
_fileSystem = new();
_cacheService = new(_userSettings.Object, _fileSystem, LocalizeResourceId);
_cacheService = new(_userSettings.Object, _fileSystem);
}
[TestMethod]
@@ -132,7 +122,7 @@ public sealed class CustomActionKernelQueryCacheServiceTests
await _cacheService.WriteAsync(JSONTestKey, TestValue);
await _cacheService.WriteAsync(MarkdownTestKey, TestValue2);
_cacheService = new(_userSettings.Object, _fileSystem, LocalizeResourceId); // recreate using same mock file-system to simulate app restart
_cacheService = new(_userSettings.Object, _fileSystem); // recreate using same mock file-system to simulate app restart
AssertAreEqual(TestValue, _cacheService.ReadOrNull(JSONTestKey));
AssertAreEqual(TestValue2, _cacheService.ReadOrNull(MarkdownTestKey));

View File

@@ -1,6 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\CmdPalVersion.props" />
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
@@ -9,7 +8,7 @@
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
<UseWinUI>true</UseWinUI>
<ApplicationIcon>Assets\AdvancedPaste\AdvancedPaste.ico</ApplicationIcon>
<ApplicationManifest>AdvancedPaste.dev.manifest</ApplicationManifest>
<ApplicationManifest>app.manifest</ApplicationManifest>
<GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
@@ -21,13 +20,9 @@
<RootNamespace>AdvancedPaste</RootNamespace>
<DisableWinExeOutputInference>true</DisableWinExeOutputInference>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri -->
<ProjectPriFileName>PowerToys.AdvancedPaste.pri</ProjectPriFileName>
<DefineConstants>DISABLE_XAML_GENERATED_MAIN,TRACE</DefineConstants>
<Version>$(AdvancedPasteVersion)</Version>
</PropertyGroup>
<PropertyGroup Condition="'$(CIBuild)'=='true'">
<ApplicationManifest>AdvancedPaste.prod.manifest</ApplicationManifest>
</PropertyGroup>
<!-- See https://learn.microsoft.com/windows/apps/develop/platform/csharp-winrt/net-projection-from-cppwinrt-component for more info -->
@@ -37,25 +32,6 @@
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
</PropertyGroup>
<!-- BODGY: XES Versioning and WinAppSDK get into a fight about the app manifest, which breaks WinAppSDK. -->
<Target Name="RearrangeXefVersioningAndWinAppSDKResourceGeneration" DependsOnTargets="GetNewAppManifestValues;CreateWinRTRegistration" BeforeTargets="XesWriteVersionInfoResourceFile">
<PropertyGroup>
<OriginalApplicationManifest>$(ApplicationManifest.Replace("$(MSBuildProjectDirectory)\",""))</OriginalApplicationManifest>
</PropertyGroup>
<Message Importance="High" Text="Updated final manifest path to $(OriginalApplicationManifest)" />
</Target>
<!-- Generate PhiSilicaLafCredentials from MSBuild properties (PhiSilicaLaf.props / CI overrides). -->
<Target Name="GeneratePhiSilicaLafCredentials" BeforeTargets="BeforeCompile;CoreCompile;XamlPreCompile">
<PropertyGroup>
<PhiSilicaLafCredentialsFile>$(IntermediateOutputPath)PhiSilicaLafCredentials.g.cs</PhiSilicaLafCredentialsFile>
</PropertyGroup>
<WriteLinesToFile File="$(PhiSilicaLafCredentialsFile)" Overwrite="true" Lines="// &lt;auto-generated/&gt;;namespace AdvancedPaste%3B;;internal static class PhiSilicaLafCredentials;{; internal const string Token = &quot;$(PhiSilicaLafToken)&quot;%3B; internal const string Attestation = &quot;$(PhiSilicaLafAttestation)&quot;%3B;}" />
<ItemGroup>
<Compile Include="$(PhiSilicaLafCredentialsFile)" Condition="!@(Compile->AnyHaveMetadataValue('Identity', '$(PhiSilicaLafCredentialsFile)'))" />
</ItemGroup>
</Target>
<ItemGroup>
<None Remove="AdvancedPasteXAML\Controls\ClipboardHistoryItemPreviewControl.xaml" />
<None Remove="AdvancedPasteXAML\Controls\PromptBox.xaml" />
@@ -90,9 +66,9 @@
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.SemanticKernel" />
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.WindowsAppSDK.AI" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
<PackageReference Include="System.ClientModel" />
<PackageReference Include="Microsoft.Windows.Compatibility" />
<PackageReference Include="Microsoft.Windows.CsWin32" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
<PackageReference Include="ReverseMarkdown" />
@@ -109,8 +85,7 @@
<!-- TODO: fix issues and reenable -->
<!-- These are caused by streamjsonrpc dependency on Microsoft.VisualStudio.Threading.Analyzers -->
<!-- We might want to add that to the project and fix the issues as well -->
<!-- CS8305: generated XamlTypeInfo references experimental ItemsView enums in WinAppSDK 2.2.x-experimental. -->
<NoWarn>VSTHRD002;VSTHRD110;VSTHRD100;VSTHRD200;VSTHRD101;CS8305</NoWarn>
<NoWarn>VSTHRD002;VSTHRD110;VSTHRD100;VSTHRD200;VSTHRD101</NoWarn>
</PropertyGroup>
<!--
@@ -122,10 +97,6 @@
<ProjectCapability Include="Msix" />
</ItemGroup>
<ItemGroup>
<SDKReference Include="Microsoft.VCLibs.Desktop, Version=14.0" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>AdvancedPaste.UnitTests</_Parameter1>
@@ -133,6 +104,8 @@
</ItemGroup>
<ItemGroup>
<!-- HACK: Common.UI is referenced, even if it is not used, to force dll versions to be the same as in other projects that use it. It's still unclear why this is the case, but this is need for flattening the install directory. -->
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\LanguageModelProvider\LanguageModelProvider.csproj" />
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
@@ -181,5 +154,4 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0"
xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="AdvancedPaste.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update
-->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<msix xmlns="urn:schemas-microsoft-com:msix.v1"
publisher="CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US"
packageName="Microsoft.PowerToys.SparseApp"
applicationId="PowerToys.AdvancedPasteUI" />
</assembly>

View File

@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0"
xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="AdvancedPaste.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update
-->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<msix xmlns="urn:schemas-microsoft-com:msix.v1"
publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US"
packageName="Microsoft.PowerToys.SparseApp"
applicationId="PowerToys.AdvancedPasteUI" />
</assembly>

View File

@@ -188,23 +188,14 @@ namespace AdvancedPaste
}
else
{
const string coachingSuffix = "-coaching";
var actionKey = messageParts[1];
bool forceCoaching = actionKey.EndsWith(coachingSuffix, StringComparison.OrdinalIgnoreCase);
if (forceCoaching)
{
actionKey = actionKey[..^coachingSuffix.Length];
}
if (!AdditionalActionIPCKeys.TryGetValue(actionKey, out PasteFormats pasteFormat))
if (!AdditionalActionIPCKeys.TryGetValue(messageParts[1], out PasteFormats pasteFormat))
{
Logger.LogWarning($"Unexpected additional action type {messageParts[1]}");
}
else
{
await ShowWindow();
await viewModel.ExecutePasteFormatAsync(pasteFormat, PasteActionSource.GlobalKeyboardShortcut, forceCoaching);
await viewModel.ExecutePasteFormatAsync(pasteFormat, PasteActionSource.GlobalKeyboardShortcut);
}
}
}

View File

@@ -382,7 +382,6 @@
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid
@@ -439,32 +438,13 @@
TextWrapping="Wrap" />
</ScrollViewer>
</Grid>
<StackPanel
Grid.Row="1"
Padding="12,8,12,8"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
Spacing="4"
Visibility="{x:Bind ViewModel.HasCoachingExplanation, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock
x:Uid="CoachingExplanationTitle"
FontWeight="SemiBold"
Style="{StaticResource CaptionTextBlockStyle}" />
<ScrollViewer MaxHeight="200">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
IsTextSelectionEnabled="True"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.CoachingExplanation, Mode=OneWay}"
TextWrapping="Wrap" />
</ScrollViewer>
</StackPanel>
<Rectangle
Grid.Row="2"
Grid.Row="1"
Height="1"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
<Grid
Grid.Row="3"
Grid.Row="2"
Margin="12"
ColumnSpacing="8">
<Grid.ColumnDefinitions>

View File

@@ -25,22 +25,6 @@ namespace AdvancedPaste.Settings
public IReadOnlyList<PasteFormats> AdditionalActions { get; }
public string FixSpellingAndGrammarPrompt { get; }
public string FixSpellingAndGrammarSystemPrompt { get; }
public string FixSpellingAndGrammarProviderId { get; }
public bool FixSpellingAndGrammarCoachingEnabled { get; }
public bool FixSpellingAndGrammarCoachingShortcutSet { get; }
public string FixSpellingAndGrammarCoachingPrompt { get; }
public string FixSpellingAndGrammarCoachingSystemPrompt { get; }
public string FixSpellingAndGrammarCoachingProviderId { get; }
public PasteAIConfiguration PasteAIConfiguration { get; }
public event EventHandler Changed;

View File

@@ -157,6 +157,8 @@ namespace AdvancedPaste.Helpers
{
public int X;
public int Y;
public static explicit operator System.Windows.Point(PointInter point) => new System.Windows.Point(point.X, point.Y);
}
[DllImport("user32.dll")]

View File

@@ -46,22 +46,6 @@ namespace AdvancedPaste.Settings
public IReadOnlyList<AdvancedPasteCustomAction> CustomActions => _customActions;
public string FixSpellingAndGrammarPrompt { get; private set; } = string.Empty;
public string FixSpellingAndGrammarSystemPrompt { get; private set; } = string.Empty;
public string FixSpellingAndGrammarProviderId { get; private set; } = string.Empty;
public bool FixSpellingAndGrammarCoachingEnabled { get; private set; }
public bool FixSpellingAndGrammarCoachingShortcutSet { get; private set; }
public string FixSpellingAndGrammarCoachingPrompt { get; private set; } = string.Empty;
public string FixSpellingAndGrammarCoachingSystemPrompt { get; private set; } = string.Empty;
public string FixSpellingAndGrammarCoachingProviderId { get; private set; } = string.Empty;
public PasteAIConfiguration PasteAIConfiguration { get; private set; }
public UserSettings(IFileSystem fileSystem)
@@ -129,21 +113,10 @@ namespace AdvancedPaste.Settings
EnableClipboardPreview = properties.EnableClipboardPreview;
PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration();
var fixSpellingAction = properties.AdditionalActions.FixSpellingAndGrammar;
FixSpellingAndGrammarPrompt = fixSpellingAction.Prompt ?? string.Empty;
FixSpellingAndGrammarSystemPrompt = fixSpellingAction.SystemPrompt ?? string.Empty;
FixSpellingAndGrammarProviderId = fixSpellingAction.ProviderId ?? string.Empty;
FixSpellingAndGrammarCoachingEnabled = fixSpellingAction.CoachingEnabled;
FixSpellingAndGrammarCoachingShortcutSet = fixSpellingAction.CoachingShortcut?.Code > 0;
FixSpellingAndGrammarCoachingPrompt = fixSpellingAction.CoachingPrompt ?? string.Empty;
FixSpellingAndGrammarCoachingSystemPrompt = fixSpellingAction.CoachingSystemPrompt ?? string.Empty;
FixSpellingAndGrammarCoachingProviderId = fixSpellingAction.CoachingProviderId ?? string.Empty;
var sourceAdditionalActions = properties.AdditionalActions;
(PasteFormats Format, IAdvancedPasteAction[] Actions)[] additionalActionFormats =
[
(PasteFormats.ImageToText, [sourceAdditionalActions.ImageToText]),
(PasteFormats.FixSpellingAndGrammar, [sourceAdditionalActions.FixSpellingAndGrammar]),
(PasteFormats.PasteAsTxtFile, [sourceAdditionalActions.PasteAsFile, sourceAdditionalActions.PasteAsFile.PasteAsTxtFile]),
(PasteFormats.PasteAsPngFile, [sourceAdditionalActions.PasteAsFile, sourceAdditionalActions.PasteAsFile.PasteAsPngFile]),
(PasteFormats.PasteAsHtmlFile, [sourceAdditionalActions.PasteAsFile, sourceAdditionalActions.PasteAsFile.PasteAsHtmlFile]),

View File

@@ -24,22 +24,20 @@ public sealed class PasteFormat
IsEnabled = SupportsClipboardFormats(clipboardFormats) && (isAIServiceEnabled || !Metadata.RequiresAIService);
}
public static PasteFormat CreateStandardFormat(PasteFormats format, ClipboardFormat clipboardFormats, bool isAIServiceEnabled, Func<string, string> resourceLoader, string providerId = null) =>
public static PasteFormat CreateStandardFormat(PasteFormats format, ClipboardFormat clipboardFormats, bool isAIServiceEnabled, Func<string, string> resourceLoader) =>
new(format, clipboardFormats, isAIServiceEnabled)
{
Name = MetadataDict[format].ResourceId == null ? string.Empty : resourceLoader(MetadataDict[format].ResourceId),
Prompt = string.Empty,
IsSavedQuery = false,
ProviderId = providerId ?? string.Empty,
};
public static PasteFormat CreateCustomAIFormat(PasteFormats format, string name, string prompt, bool isSavedQuery, ClipboardFormat clipboardFormats, bool isAIServiceEnabled, string providerId = null) =>
public static PasteFormat CreateCustomAIFormat(PasteFormats format, string name, string prompt, bool isSavedQuery, ClipboardFormat clipboardFormats, bool isAIServiceEnabled) =>
new(format, clipboardFormats, isAIServiceEnabled)
{
Name = name,
Prompt = prompt,
IsSavedQuery = isSavedQuery,
ProviderId = providerId ?? string.Empty,
};
public PasteFormatMetadataAttribute Metadata => MetadataDict[Format];
@@ -52,8 +50,6 @@ public sealed class PasteFormat
public string Prompt { get; private init; }
public string ProviderId { get; private init; } = string.Empty;
public bool IsSavedQuery { get; private init; }
public bool IsEnabled { get; private init; }

View File

@@ -38,17 +38,6 @@ public enum PasteFormats
KernelFunctionDescription = "Takes clipboard text and formats it as JSON text.")]
Json,
[PasteFormatMetadata(
IsCoreAction = false,
ResourceId = "FixSpellingAndGrammar",
IconGlyph = "\uE8E2",
RequiresAIService = true,
CanPreview = true,
SupportedClipboardFormats = ClipboardFormat.Text,
IPCKey = AdvancedPasteAdditionalActions.PropertyNames.FixSpellingAndGrammar,
KernelFunctionDescription = "Fixes all spelling and grammar errors in the clipboard text and returns the corrected version.")]
FixSpellingAndGrammar,
[PasteFormatMetadata(
IsCoreAction = false,
ResourceId = "ImageToText",

View File

@@ -1,67 +0,0 @@
// 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.Diagnostics;
using Windows.ApplicationModel;
namespace AdvancedPaste;
internal static class PhiSilicaLafHelper
{
private const string FeatureId = "com.microsoft.windows.ai.languagemodel";
private static readonly object _lock = new();
private static bool _unlocked;
/// <summary>
/// Gets the status of the most recent <see cref="TryUnlock"/> attempt
/// (e.g. Available, AvailableWithoutToken, Unavailable, or "Exception: ...").
/// Exposed so callers can surface the real LAF result for diagnostics; the
/// generic "Access is denied" from downstream model calls does not reveal it.
/// </summary>
public static string LastUnlockStatus { get; private set; } = "NotAttempted";
public static bool TryUnlock()
{
// Only cache a successful unlock. Negative results (Unavailable, Unknown, exceptions)
// are often transient — e.g., AI feature stack not yet initialized after sign-in or
// sparse identity not fully applied to a freshly-started process — and retrying on
// the next call lets AP recover without restart.
if (_unlocked)
{
return true;
}
lock (_lock)
{
if (_unlocked)
{
return true;
}
try
{
var access = LimitedAccessFeatures.TryUnlockFeature(
FeatureId,
PhiSilicaLafCredentials.Token,
PhiSilicaLafCredentials.Attestation + " has registered their use of com.microsoft.windows.ai.languagemodel with Microsoft and agrees to the terms of use.");
_unlocked = access.Status == LimitedAccessFeatureStatus.Available
|| access.Status == LimitedAccessFeatureStatus.AvailableWithoutToken;
LastUnlockStatus = access.Status.ToString();
Debug.WriteLine($"Phi Silica LAF unlock status: {access.Status}");
}
catch (Exception ex)
{
LastUnlockStatus = "Exception: " + ex.Message;
Debug.WriteLine($"Phi Silica LAF unlock failed: {ex.Message}");
_unlocked = false;
}
return _unlocked;
}
}
}

View File

@@ -3,7 +3,6 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Linq;
using System.Threading;
using ManagedCommon;
@@ -15,26 +14,16 @@ namespace AdvancedPaste
public static class Program
{
[STAThread]
public static int Main(string[] args)
public static void Main(string[] args)
{
Logger.InitializeLogger("\\AdvancedPaste\\Logs");
WinRT.ComWrappersSupport.InitializeComWrappers();
if (args.Contains("--check-phi-silica", StringComparer.OrdinalIgnoreCase))
{
return CheckPhiSilicaAvailability();
}
if (args.Contains("--prepare-phi-silica", StringComparer.OrdinalIgnoreCase))
{
return PreparePhiSilica();
}
if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredAdvancedPasteEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
{
Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.");
return 1;
return;
}
var instanceKey = AppInstance.FindOrRegisterForKey("PowerToys_AdvancedPaste_Instance");
@@ -52,100 +41,6 @@ namespace AdvancedPaste
{
Logger.LogWarning("Another instance of AdvancedPasteUI is running. Exiting.");
}
return 0;
}
/// <summary>
/// Checks Phi Silica availability without starting the WinUI app.
/// Used by Settings UI to probe API status via subprocess.
/// Exit codes: 0 = available, 1 = not ready (model needs download), 2 = not supported or error.
/// </summary>
private static int CheckPhiSilicaAvailability()
{
try
{
PhiSilicaLafHelper.TryUnlock();
var readyState = Microsoft.Windows.AI.Text.LanguageModel.GetReadyState();
Console.Error.WriteLine($"[phi-silica] LAF unlock status: {PhiSilicaLafHelper.LastUnlockStatus}; ReadyState: {readyState}");
switch (readyState)
{
case Microsoft.Windows.AI.AIFeatureReadyState.Ready:
Console.Out.WriteLine("Available");
return 0;
case Microsoft.Windows.AI.AIFeatureReadyState.NotReady:
Console.Out.WriteLine("NotReady");
return 1;
default:
// NotSupportedOnCurrentSystem, DisabledByUser, CapabilityMissing,
// NotCompatibleWithSystemHardware, OSUpdateNeeded, or any future state:
// the model isn't usable and "Download model" (EnsureReadyAsync) won't fix it.
// CapabilityMissing in particular means the systemAIModels capability isn't
// authorized for the app, so EnsureReadyAsync throws E_ACCESSDENIED (0x80070005).
Console.Out.WriteLine("NotSupported");
return 2;
}
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
Console.Out.WriteLine("NotSupported");
return 2;
}
}
/// <summary>
/// Triggers Phi Silica model preparation (download) without starting the WinUI app.
/// Moves the model from NotReady to Ready by calling EnsureReadyAsync.
/// Exit codes: 0 = ready, 1 = preparation failed, 2 = not supported or error.
/// </summary>
private static int PreparePhiSilica()
{
try
{
PhiSilicaLafHelper.TryUnlock();
var readyState = Microsoft.Windows.AI.Text.LanguageModel.GetReadyState();
Console.Error.WriteLine($"[phi-silica] LAF unlock status: {PhiSilicaLafHelper.LastUnlockStatus}; ReadyState: {readyState}");
if (readyState is Microsoft.Windows.AI.AIFeatureReadyState.NotSupportedOnCurrentSystem
or Microsoft.Windows.AI.AIFeatureReadyState.DisabledByUser)
{
Console.Out.WriteLine("NotSupported");
return 2;
}
if (readyState == Microsoft.Windows.AI.AIFeatureReadyState.Ready)
{
Console.Out.WriteLine("Ready");
return 0;
}
// Run on a thread-pool (MTA) thread: the WinRT async operation does not
// marshal correctly when blocked on from the [STAThread] entry point.
var result = System.Threading.Tasks.Task.Run(
() => Microsoft.Windows.AI.Text.LanguageModel.EnsureReadyAsync().AsTask()).GetAwaiter().GetResult();
if (result.Status != Microsoft.Windows.AI.AIFeatureReadyResultState.Success)
{
int hresult = result.ExtendedError?.HResult ?? 0;
Console.Error.WriteLine($"[phi-silica] EnsureReadyAsync Status: {result.Status}; HRESULT: 0x{hresult:X8}; Message: {result.ExtendedError?.Message}");
Console.Error.WriteLine(result.ExtendedError?.Message ?? result.Status.ToString());
Console.Out.WriteLine("Failed");
return 1;
}
Console.Out.WriteLine("Ready");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
Console.Out.WriteLine("NotSupported");
return 2;
}
}
}
}

View File

@@ -33,21 +33,14 @@ public sealed class CustomActionKernelQueryCacheService : IKernelQueryCacheServi
private readonly IUserSettings _userSettings;
private readonly IFileSystem _fileSystem;
private readonly SettingsUtils _settingsUtil;
private readonly Func<string, string> _getLocalizedString;
private static string Version => Assembly.GetExecutingAssembly()?.GetName()?.Version?.ToString() ?? string.Empty;
public CustomActionKernelQueryCacheService(IUserSettings userSettings, IFileSystem fileSystem)
: this(userSettings, fileSystem, ResourceLoaderInstance.ResourceLoader.GetString)
{
}
internal CustomActionKernelQueryCacheService(IUserSettings userSettings, IFileSystem fileSystem, Func<string, string> getLocalizedString)
{
_userSettings = userSettings;
_fileSystem = fileSystem;
_settingsUtil = new SettingsUtils(fileSystem);
_getLocalizedString = getLocalizedString;
_userSettings.Changed += OnUserSettingsChanged;
@@ -119,7 +112,7 @@ public sealed class CustomActionKernelQueryCacheService : IKernelQueryCacheServi
let metadata = pair.Value
where !string.IsNullOrEmpty(metadata.ResourceId)
where metadata.IsCoreAction || _userSettings.AdditionalActions.Contains(format)
select _getLocalizedString(metadata.ResourceId);
select ResourceLoaderInstance.ResourceLoader.GetString(metadata.ResourceId);
var customActionPrompts = from customAction in _userSettings.CustomActions
select customAction.Prompt;

View File

@@ -40,15 +40,10 @@ namespace AdvancedPaste.Services.CustomActions
this.userSettings = userSettings;
}
public async Task<CustomActionTransformResult> TransformAsync(string prompt, string inputText, byte[] imageBytes, CancellationToken cancellationToken, IProgress<double> progress, string systemPromptOverride = null, string providerIdOverride = null)
public async Task<CustomActionTransformResult> TransformAsync(string prompt, string inputText, byte[] imageBytes, CancellationToken cancellationToken, IProgress<double> progress)
{
var pasteConfig = userSettings?.PasteAIConfiguration;
var providerConfig = BuildProviderConfig(pasteConfig, providerIdOverride);
if (systemPromptOverride != null)
{
providerConfig.SystemPrompt = systemPromptOverride;
}
var providerConfig = BuildProviderConfig(pasteConfig);
return await TransformAsync(prompt, inputText, imageBytes, providerConfig, cancellationToken, progress);
}
@@ -153,26 +148,13 @@ namespace AdvancedPaste.Services.CustomActions
return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType;
}
private PasteAIConfig BuildProviderConfig(PasteAIConfiguration config, string providerIdOverride = null)
private PasteAIConfig BuildProviderConfig(PasteAIConfiguration config)
{
config ??= new PasteAIConfiguration();
PasteAIProviderDefinition provider;
if (!string.IsNullOrWhiteSpace(providerIdOverride))
{
provider = config.Providers?.FirstOrDefault(p => string.Equals(p.Id, providerIdOverride, StringComparison.OrdinalIgnoreCase))
?? config.ActiveProvider
?? config.Providers?.FirstOrDefault()
?? new PasteAIProviderDefinition();
}
else
{
provider = config.ActiveProvider ?? config.Providers?.FirstOrDefault() ?? new PasteAIProviderDefinition();
}
var provider = config.ActiveProvider ?? config.Providers?.FirstOrDefault() ?? new PasteAIProviderDefinition();
var serviceType = NormalizeServiceType(provider.ServiceTypeKind);
var systemPrompt = string.IsNullOrWhiteSpace(provider.SystemPrompt) ? DefaultSystemPrompt : provider.SystemPrompt;
var apiKey = AcquireApiKey(serviceType, provider.Id);
var apiKey = AcquireApiKey(serviceType);
var modelName = provider.ModelName;
var providerConfig = new PasteAIConfig
@@ -191,14 +173,15 @@ namespace AdvancedPaste.Services.CustomActions
return providerConfig;
}
private string AcquireApiKey(AIServiceType serviceType, string providerId)
private string AcquireApiKey(AIServiceType serviceType)
{
if (!RequiresApiKey(serviceType))
{
return string.Empty;
}
return credentialsProvider.GetKey(serviceType, providerId ?? string.Empty);
credentialsProvider.Refresh();
return credentialsProvider.GetKey() ?? string.Empty;
}
private static bool RequiresApiKey(AIServiceType serviceType)
@@ -207,8 +190,6 @@ namespace AdvancedPaste.Services.CustomActions
{
AIServiceType.Onnx => false,
AIServiceType.Ollama => false,
AIServiceType.FoundryLocal => false,
AIServiceType.PhiSilica => false,
_ => true,
};
}

View File

@@ -12,6 +12,6 @@ namespace AdvancedPaste.Services.CustomActions
{
public interface ICustomActionTransformService
{
Task<CustomActionTransformResult> TransformAsync(string prompt, string inputText, byte[] imageBytes, CancellationToken cancellationToken, IProgress<double> progress, string systemPromptOverride = null, string providerIdOverride = null);
Task<CustomActionTransformResult> TransformAsync(string prompt, string inputText, byte[] imageBytes, CancellationToken cancellationToken, IProgress<double> progress);
}
}

View File

@@ -15,7 +15,6 @@ namespace AdvancedPaste.Services.CustomActions
SemanticKernelPasteProvider.Registration,
LocalModelPasteProvider.Registration,
FoundryLocalPasteProvider.Registration,
PhiSilicaPasteProvider.Registration,
};
private static readonly IReadOnlyDictionary<AIServiceType, Func<PasteAIConfig, IPasteAIProvider>> ProviderFactories = CreateProviderFactories();

View File

@@ -1,208 +0,0 @@
// 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 System.Threading;
using System.Threading.Tasks;
using AdvancedPaste.Models;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.Windows.AI;
using Microsoft.Windows.AI.ContentSafety;
using Microsoft.Windows.AI.Text;
using PhiSilicaLanguageModel = Microsoft.Windows.AI.Text.LanguageModel;
namespace AdvancedPaste.Services.CustomActions;
public sealed class PhiSilicaPasteProvider : IPasteAIProvider
{
private static readonly IReadOnlyCollection<AIServiceType> SupportedTypes = new[]
{
AIServiceType.PhiSilica,
};
public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new PhiSilicaPasteProvider(config));
private static readonly SemaphoreSlim _initLock = new(1, 1);
private static PhiSilicaLanguageModel _cachedModel;
private readonly PasteAIConfig _config;
public PhiSilicaPasteProvider(PasteAIConfig config)
{
ArgumentNullException.ThrowIfNull(config);
_config = config;
}
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
PhiSilicaLafHelper.TryUnlock();
var readyState = PhiSilicaLanguageModel.GetReadyState();
return Task.FromResult(readyState is not (AIFeatureReadyState.NotSupportedOnCurrentSystem or AIFeatureReadyState.DisabledByUser));
}
catch (Exception)
{
return Task.FromResult(false);
}
}
public async Task<string> ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress<double> progress)
{
ArgumentNullException.ThrowIfNull(request);
try
{
var systemPrompt = request.SystemPrompt;
if (string.IsNullOrWhiteSpace(systemPrompt))
{
throw new PasteActionException(
"System prompt is required for Phi Silica",
new ArgumentException("System prompt must be provided", nameof(request)));
}
var prompt = request.Prompt;
var inputText = request.InputText;
if (string.IsNullOrWhiteSpace(prompt) || string.IsNullOrWhiteSpace(inputText))
{
throw new PasteActionException(
"Prompt and input text are required",
new ArgumentException("Prompt and input text must be provided", nameof(request)));
}
cancellationToken.ThrowIfCancellationRequested();
var languageModel = await GetOrCreateModelAsync(cancellationToken).ConfigureAwait(false);
progress?.Report(0.1);
var contentFilterOptions = new ContentFilterOptions();
var context = languageModel.CreateContext(systemPrompt, contentFilterOptions);
var userPrompt = $"""
User instructions:
{prompt}
Text:
{inputText}
Output:
""";
if ((ulong)userPrompt.Length > languageModel.GetUsablePromptLength(context, userPrompt))
{
throw new PasteActionException(
"Prompt is too large for the Phi Silica model context",
new InvalidOperationException("Prompt exceeds usable prompt length"),
aiServiceMessage: "The input text is too large for on-device processing. Try with shorter text.");
}
var options = new LanguageModelOptions
{
ContentFilterOptions = contentFilterOptions,
};
var result = await languageModel.GenerateResponseAsync(context, userPrompt, options).AsTask(cancellationToken).ConfigureAwait(false);
progress?.Report(0.8);
if (result.Status != LanguageModelResponseStatus.Complete)
{
var statusMessage = result.Status switch
{
LanguageModelResponseStatus.BlockedByPolicy => "Response was blocked by policy.",
LanguageModelResponseStatus.PromptBlockedByContentModeration => "Prompt was blocked by content moderation.",
LanguageModelResponseStatus.ResponseBlockedByContentModeration => "Response was blocked by content moderation.",
LanguageModelResponseStatus.PromptLargerThanContext => "Prompt is too large for the model context.",
_ => $"Unexpected status: {result.Status}",
};
throw new PasteActionException(
$"Phi Silica returned status: {result.Status}",
new InvalidOperationException($"LanguageModel response status: {result.Status}"),
aiServiceMessage: statusMessage);
}
var responseText = result.Text ?? string.Empty;
request.Usage = AIServiceUsage.None;
progress?.Report(1.0);
return responseText;
}
catch (OperationCanceledException)
{
throw;
}
catch (PasteActionException)
{
throw;
}
catch (Exception ex)
{
throw new PasteActionException(
"Failed to generate response using Phi Silica",
ex,
aiServiceMessage: $"Error details: {ex.Message}");
}
}
private static async Task<PhiSilicaLanguageModel> GetOrCreateModelAsync(CancellationToken cancellationToken)
{
if (_cachedModel is not null)
{
return _cachedModel;
}
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_cachedModel is not null)
{
return _cachedModel;
}
PhiSilicaLafHelper.TryUnlock();
var readyState = PhiSilicaLanguageModel.GetReadyState();
if (readyState is AIFeatureReadyState.NotSupportedOnCurrentSystem or AIFeatureReadyState.DisabledByUser)
{
throw new PasteActionException(
"Phi Silica is not supported on this device. A Copilot+ PC is required.",
new InvalidOperationException("Phi Silica requires a Copilot+ PC with an NPU."),
aiServiceMessage: "Phi Silica requires a Copilot+ PC with an NPU. For on-device AI on any Windows PC, consider using Foundry Local.");
}
if (readyState is AIFeatureReadyState.NotReady)
{
var ensureResult = await PhiSilicaLanguageModel.EnsureReadyAsync().AsTask(cancellationToken).ConfigureAwait(false);
if (ensureResult.Status != AIFeatureReadyResultState.Success)
{
throw new PasteActionException(
"Failed to prepare Phi Silica model",
ensureResult.ExtendedError,
aiServiceMessage: $"Model preparation failed (status: {ensureResult.Status})");
}
}
if (PhiSilicaLanguageModel.GetReadyState() is not AIFeatureReadyState.Ready)
{
throw new PasteActionException(
"Phi Silica model is not ready",
new InvalidOperationException("Phi Silica model is not in Ready state after preparation."),
aiServiceMessage: "Phi Silica model is not available. Please ensure the model is downloaded and ready.");
}
_cachedModel = await PhiSilicaLanguageModel.CreateAsync().AsTask(cancellationToken).ConfigureAwait(false);
return _cachedModel;
}
finally
{
_initLock.Release();
}
}
}

View File

@@ -175,7 +175,6 @@ namespace AdvancedPaste.Services.CustomActions
AIServiceType.OpenAI or AIServiceType.AzureOpenAI => new OpenAIPromptExecutionSettings
{
FunctionChoiceBehavior = null,
ReasoningEffort = "minimal",
},
_ => new PromptExecutionSettings(),
};

View File

@@ -55,13 +55,6 @@ public sealed class EnhancedVaultCredentialsProvider : IAICredentialsProvider
return !string.IsNullOrEmpty(GetKey());
}
public string GetKey(AIServiceType serviceType, string providerId)
{
var normalizedType = NormalizeServiceType(serviceType);
var entry = BuildCredentialEntry(normalizedType, providerId ?? string.Empty);
return LoadKey(entry);
}
public bool Refresh()
{
using (_syncRoot.EnterScope())
@@ -128,7 +121,6 @@ public sealed class EnhancedVaultCredentialsProvider : IAICredentialsProvider
try
{
var credential = new PasswordVault().Retrieve(entry.Value.Resource, entry.Value.Username);
credential?.RetrievePassword();
return credential?.Password ?? string.Empty;
}
catch (Exception)
@@ -168,7 +160,6 @@ public sealed class EnhancedVaultCredentialsProvider : IAICredentialsProvider
case AIServiceType.ML:
case AIServiceType.Onnx:
case AIServiceType.Ollama:
case AIServiceType.PhiSilica:
return null;
default:
return null;

View File

@@ -2,8 +2,6 @@
// 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.PowerToys.Settings.UI.Library;
namespace AdvancedPaste.Services;
/// <summary>
@@ -23,14 +21,6 @@ public interface IAICredentialsProvider
/// <returns>Credential string or <see cref="string.Empty"/> when missing.</returns>
string GetKey();
/// <summary>
/// Retrieves the credential for a specific AI provider.
/// </summary>
/// <param name="serviceType">The AI service type.</param>
/// <param name="providerId">The provider identifier.</param>
/// <returns>Credential string or <see cref="string.Empty"/> when missing.</returns>
string GetKey(AIServiceType serviceType, string providerId);
/// <summary>
/// Refreshes the cached credential for the active AI provider.
/// </summary>

View File

@@ -12,5 +12,5 @@ namespace AdvancedPaste.Services;
public interface IKernelService
{
Task<DataPackage> TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress<double> progress, string providerIdOverride = null);
Task<DataPackage> TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress<double> progress);
}

View File

@@ -46,7 +46,7 @@ public abstract class KernelServiceBase(
protected abstract IKernelRuntimeConfiguration GetRuntimeConfiguration();
public async Task<DataPackage> TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress<double> progress, string providerIdOverride = null)
public async Task<DataPackage> TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress<double> progress)
{
Logger.LogTrace();

View File

@@ -9,18 +9,15 @@ using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services.CustomActions;
using AdvancedPaste.Settings;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Telemetry;
using Windows.ApplicationModel.DataTransfer;
namespace AdvancedPaste.Services;
public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService, IUserSettings userSettings) : IPasteFormatExecutor
public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor
{
private readonly IKernelService _kernelService = kernelService;
private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService;
private readonly IUserSettings _userSettings = userSettings;
public async Task<DataPackage> ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress<double> progress)
{
@@ -39,9 +36,8 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
return await Task.Run(async () =>
pasteFormat.Format switch
{
PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery, cancellationToken, progress, pasteFormat.ProviderId),
PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformAsync(pasteFormat.Prompt, await clipboardData.GetTextOrHtmlTextAsync(), await clipboardData.GetImageAsPngBytesAsync(), cancellationToken, progress, providerIdOverride: pasteFormat.ProviderId))?.Content ?? string.Empty),
PasteFormats.FixSpellingAndGrammar => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformAsync(GetFixSpellingPrompt(), await clipboardData.GetTextOrHtmlTextAsync(), null, cancellationToken, progress, GetFixSpellingSystemPrompt(), pasteFormat.ProviderId))?.Content ?? string.Empty),
PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery, cancellationToken, progress),
PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformAsync(pasteFormat.Prompt, await clipboardData.GetTextOrHtmlTextAsync(), await clipboardData.GetImageAsPngBytesAsync(), cancellationToken, progress))?.Content ?? string.Empty),
_ => await TransformHelpers.TransformAsync(format, clipboardData, cancellationToken, progress),
});
}
@@ -66,16 +62,4 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
throw new ArgumentOutOfRangeException(nameof(format));
}
}
private string GetFixSpellingPrompt()
{
var customPrompt = _userSettings.FixSpellingAndGrammarPrompt;
return string.IsNullOrWhiteSpace(customPrompt) ? AdvancedPasteDefaultPrompts.FixSpellingAndGrammar : customPrompt;
}
private string GetFixSpellingSystemPrompt()
{
var customSystemPrompt = _userSettings.FixSpellingAndGrammarSystemPrompt;
return string.IsNullOrWhiteSpace(customSystemPrompt) ? AdvancedPasteDefaultPrompts.FixSpellingAndGrammarSystem : customSystemPrompt;
}
}

View File

@@ -232,12 +232,6 @@
<data name="PasteAsPlainText" xml:space="preserve">
<value>Paste as plain text</value>
</data>
<data name="FixSpellingAndGrammar" xml:space="preserve">
<value>Fix spelling and grammar</value>
</data>
<data name="CoachingExplanationTitle.Text" xml:space="preserve">
<value>What was changed and why</value>
</data>
<data name="ImageToText" xml:space="preserve">
<value>Image to text</value>
</data>

View File

@@ -16,8 +16,8 @@ using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services;
using AdvancedPaste.Services.CustomActions;
using AdvancedPaste.Settings;
using Common.UI;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ManagedCommon;
@@ -41,7 +41,6 @@ namespace AdvancedPaste.ViewModels
private readonly IUserSettings _userSettings;
private readonly IPasteFormatExecutor _pasteFormatExecutor;
private readonly IAICredentialsProvider _credentialsProvider;
private readonly ICustomActionTransformService _customActionTransformService;
private CancellationTokenSource _pasteActionCancellationTokenSource;
@@ -259,12 +258,11 @@ namespace AdvancedPaste.ViewModels
public event EventHandler PreviewRequested;
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor, ICustomActionTransformService customActionTransformService)
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor)
{
_credentialsProvider = credentialsProvider;
_userSettings = userSettings;
_pasteFormatExecutor = pasteFormatExecutor;
_customActionTransformService = customActionTransformService;
GeneratedResponses = [];
GeneratedResponses.CollectionChanged += (s, e) =>
@@ -343,21 +341,11 @@ namespace AdvancedPaste.ViewModels
});
}
private PasteFormat CreateStandardPasteFormat(PasteFormats format)
{
var providerId = GetProviderIdForFormat(format);
return PasteFormat.CreateStandardFormat(format, AvailableClipboardFormats, IsCustomAIServiceEnabled, ResourceLoaderInstance.ResourceLoader.GetString, providerId);
}
private PasteFormat CreateStandardPasteFormat(PasteFormats format) =>
PasteFormat.CreateStandardFormat(format, AvailableClipboardFormats, IsCustomAIServiceEnabled, ResourceLoaderInstance.ResourceLoader.GetString);
private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery, string providerId = null) =>
PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled, providerId);
private string GetProviderIdForFormat(PasteFormats format) =>
format switch
{
PasteFormats.FixSpellingAndGrammar => _userSettings.FixSpellingAndGrammarProviderId,
_ => string.Empty,
};
private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery) =>
PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled);
private void UpdateAIProviderActiveFlags()
{
@@ -430,7 +418,7 @@ namespace AdvancedPaste.ViewModels
UpdateFormats(
CustomActionPasteFormats,
IsCustomAIServiceEnabled ? _userSettings.CustomActions.Select(customAction => CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true, customAction.ProviderId)) : []);
IsCustomAIServiceEnabled ? _userSettings.CustomActions.Select(customAction => CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true)) : []);
}
public void Dispose()
@@ -551,7 +539,6 @@ namespace AdvancedPaste.ViewModels
{
PasteActionError = PasteActionError.None;
Query = string.Empty;
CoachingExplanation = null;
await ReadClipboardAsync();
@@ -629,12 +616,6 @@ namespace AdvancedPaste.ViewModels
[ObservableProperty]
private string _customFormatResult;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasCoachingExplanation))]
private string _coachingExplanation;
public bool HasCoachingExplanation => !string.IsNullOrEmpty(CoachingExplanation);
[RelayCommand]
public async Task PasteCustomAsync()
{
@@ -680,37 +661,17 @@ namespace AdvancedPaste.ViewModels
[RelayCommand]
public void OpenSettings()
{
try
{
var exePath = System.IO.Path.Combine(
ManagedCommon.PowerToysPathResolver.GetPowerToysInstallPath(),
"PowerToys.exe");
if (exePath != null && System.IO.File.Exists(exePath))
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = exePath,
Arguments = "--open-settings=AdvancedPaste",
UseShellExecute = false,
});
}
}
catch (Exception ex)
{
Logger.LogError("Failed to open settings", ex);
}
SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.AdvancedPaste);
GetMainWindow()?.Close();
}
internal async Task ExecutePasteFormatAsync(PasteFormats format, PasteActionSource source, bool forceCoaching = false)
internal async Task ExecutePasteFormatAsync(PasteFormats format, PasteActionSource source)
{
await ReadClipboardAsync();
await ExecutePasteFormatAsync(CreateStandardPasteFormat(format), source, forceCoaching);
await ExecutePasteFormatAsync(CreateStandardPasteFormat(format), source);
}
internal async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, bool forceCoaching = false)
internal async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source)
{
if (IsBusy)
{
@@ -743,30 +704,12 @@ namespace AdvancedPaste.ViewModels
await delayTask;
var outputText = await dataPackage.GetView().GetTextOrEmptyAsync();
bool isCoachingAction = pasteFormat.Format == PasteFormats.FixSpellingAndGrammar &&
(forceCoaching || (_userSettings.FixSpellingAndGrammarCoachingEnabled && !_userSettings.FixSpellingAndGrammarCoachingShortcutSet));
bool shouldPreview = pasteFormat.Metadata.CanPreview && _userSettings.ShowCustomPreview && !string.IsNullOrEmpty(outputText) && source != PasteActionSource.GlobalKeyboardShortcut;
// Coaching mode forces preview even for global keyboard shortcuts
if (isCoachingAction && !string.IsNullOrEmpty(outputText))
{
shouldPreview = true;
}
if (shouldPreview)
{
GeneratedResponses.Add(outputText);
CurrentResponseIndex = GeneratedResponses.Count - 1;
if (isCoachingAction)
{
await GenerateCoachingExplanationAsync(outputText);
}
else
{
CoachingExplanation = null;
}
PreviewRequested?.Invoke(this, EventArgs.Empty);
}
else
@@ -787,65 +730,6 @@ namespace AdvancedPaste.ViewModels
Logger.LogDebug($"Finished executing {pasteFormat.Format} from source {source}; timeTakenMs={elapsedWatch.ElapsedMilliseconds}");
}
private async Task GenerateCoachingExplanationAsync(string correctedText)
{
try
{
var originalText = ClipboardData != null ? await ClipboardData.GetTextOrEmptyAsync() : string.Empty;
if (string.IsNullOrEmpty(originalText))
{
CoachingExplanation = null;
return;
}
static string NormalizeForComparison(string s) =>
s.Replace('\u2018', '\'') // left single quote
.Replace('\u2019', '\'') // right single quote / apostrophe
.Replace('\u201C', '"') // left double quote
.Replace('\u201D', '"') // right double quote
.Replace('\u2013', '-') // en dash
.Replace('\u2014', '-'); // em dash
if (string.Equals(NormalizeForComparison(originalText), NormalizeForComparison(correctedText), StringComparison.Ordinal))
{
CoachingExplanation = null;
return;
}
var coachingInstruction = string.IsNullOrWhiteSpace(_userSettings.FixSpellingAndGrammarCoachingPrompt)
? AdvancedPasteDefaultPrompts.FixSpellingAndGrammarCoaching
: _userSettings.FixSpellingAndGrammarCoachingPrompt;
var coachingInputText = $"Original:\n\"{originalText}\"\n\nCorrected:\n\"{correctedText}\"";
var coachingSystemPrompt = string.IsNullOrWhiteSpace(_userSettings.FixSpellingAndGrammarCoachingSystemPrompt)
? AdvancedPasteDefaultPrompts.FixSpellingAndGrammarCoachingSystem
: _userSettings.FixSpellingAndGrammarCoachingSystemPrompt;
var coachingProviderId = _userSettings.FixSpellingAndGrammarCoachingProviderId;
if (string.IsNullOrWhiteSpace(coachingProviderId))
{
coachingProviderId = _userSettings.FixSpellingAndGrammarProviderId;
}
var result = await _customActionTransformService.TransformAsync(
coachingInstruction,
coachingInputText,
null,
_pasteActionCancellationTokenSource?.Token ?? CancellationToken.None,
null,
coachingSystemPrompt,
string.IsNullOrWhiteSpace(coachingProviderId) ? null : coachingProviderId);
CoachingExplanation = result?.Content;
}
catch (Exception ex)
{
Logger.LogError("Error generating coaching explanation", ex);
CoachingExplanation = null;
}
}
internal async Task ExecutePasteFormatAsync(VirtualKey key)
{
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats)

View File

@@ -1,6 +1,40 @@
#include <windows.h>
#include "resource.h"
#include "../../../../common/version/version.h"
#define APSTUDIO_READONLY_SYMBOLS
#include "winres.h"
#undef APSTUDIO_READONLY_SYMBOLS
1 VERSIONINFO
FILEVERSION FILE_VERSION
PRODUCTVERSION PRODUCT_VERSION
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
#ifdef _DEBUG
FILEFLAGS VS_FF_DEBUG
#else
FILEFLAGS 0x0L
#endif
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_DLL
FILESUBTYPE VFT2_UNKNOWN
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0" // US English (0x0409), Unicode (0x04B0) charset
BEGIN
VALUE "CompanyName", COMPANY_NAME
VALUE "FileDescription", FILE_DESCRIPTION
VALUE "FileVersion", FILE_VERSION_STRING
VALUE "InternalName", INTERNAL_NAME
VALUE "LegalCopyright", COPYRIGHT_NOTE
VALUE "OriginalFilename", ORIGINAL_FILENAME
VALUE "ProductName", PRODUCT_NAME
VALUE "ProductVersion", PRODUCT_VERSION_STRING
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200 // US English (0x0409), Unicode (1200) charset
END
END

View File

@@ -15,6 +15,7 @@
</PropertyGroup>
<PropertyGroup Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">

View File

@@ -100,30 +100,25 @@ HRESULT AdvancedPasteProcessManager::start_process(const std::wstring& pipe_name
{
const unsigned long powertoys_pid = GetCurrentProcessId();
const auto launch_direct_exe = [&]() -> HRESULT {
// Fallback: launch exe directly (dev builds without GenerateAppxPackageOnBuild)
const auto executable_args = std::format(L"{} {}", std::to_wstring(powertoys_pid), pipe_name);
const auto executable_args = std::format(L"{} {}", std::to_wstring(powertoys_pid), pipe_name);
SHELLEXECUTEINFOW sei{ sizeof(sei) };
sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI };
sei.lpFile = L"WinUI3Apps\\PowerToys.AdvancedPaste.exe";
sei.nShow = SW_SHOWNORMAL;
sei.lpParameters = executable_args.data();
if (ShellExecuteExW(&sei))
{
Logger::trace("Successfully started Advanced Paste process (direct)");
terminate_process();
m_hProcess = sei.hProcess;
return S_OK;
}
else
{
Logger::error(L"Advanced Paste process failed to start. {}", get_last_error_or_default(GetLastError()));
return E_FAIL;
}
};
return launch_direct_exe();
SHELLEXECUTEINFOW sei{ sizeof(sei) };
sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI };
sei.lpFile = L"WinUI3Apps\\PowerToys.AdvancedPaste.exe";
sei.nShow = SW_SHOWNORMAL;
sei.lpParameters = executable_args.data();
if (ShellExecuteExW(&sei))
{
Logger::trace("Successfully started Advanced Paste process");
terminate_process();
m_hProcess = sei.hProcess;
return S_OK;
}
else
{
Logger::error(L"Advanced Paste process failed to start. {}", get_last_error_or_default(GetLastError()));
return E_FAIL;
}
}
HRESULT AdvancedPasteProcessManager::start_named_pipe_server(const std::wstring& pipe_name)
@@ -180,9 +175,8 @@ HRESULT AdvancedPasteProcessManager::start_named_pipe_server(const std::wstring&
}
}
// Wait for client. AdvancedPaste under sparse identity can take >5s on cold start to
// bootstrap WinAppSDK + DI host before connecting back to this pipe.
const constexpr DWORD client_timeout_millis = 15000;
// Wait for client.
const constexpr DWORD client_timeout_millis = 5000;
switch (WaitForSingleObject(overlapped.hEvent, client_timeout_millis))
{
case WAIT_OBJECT_0:

View File

@@ -66,8 +66,6 @@ namespace
const wchar_t JSON_KEY_PROVIDERS[] = L"providers";
const wchar_t JSON_KEY_SERVICE_TYPE[] = L"service-type";
const wchar_t JSON_KEY_ENABLE_ADVANCED_AI[] = L"enable-advanced-ai";
const wchar_t JSON_KEY_COACHING_SHORTCUT[] = L"coaching-shortcut";
const wchar_t JSON_KEY_COACHING_ENABLED[] = L"coaching-enabled";
const wchar_t JSON_KEY_VALUE[] = L"value";
}
@@ -257,21 +255,6 @@ private:
};
m_additional_actions.push_back(additionalAction);
// Register coaching shortcut as a separate hotkey with a "-coaching" suffix ID
if (action.HasKey(JSON_KEY_COACHING_SHORTCUT) && action.GetNamedBoolean(JSON_KEY_COACHING_ENABLED, false))
{
auto coachingHotkey = parse_single_hotkey(action.GetNamedObject(JSON_KEY_COACHING_SHORTCUT), actionIsShown);
if (coachingHotkey.key != 0)
{
const AdditionalAction coachingAction
{
std::wstring(actionName.c_str()) + L"-coaching",
coachingHotkey
};
m_additional_actions.push_back(coachingAction);
}
}
}
else
{
@@ -424,7 +407,6 @@ private:
// Define the expected order to ensure consistent hotkey ID assignment
const std::vector<winrt::hstring> expectedOrder = {
L"image-to-text",
L"fix-spelling-and-grammar",
L"paste-as-file",
L"transcode"
};
@@ -1000,12 +982,6 @@ public:
m_triggerEventWaiter.start(CommonSharedConstants::ADVANCED_PASTE_SHOW_UI_EVENT, [this](DWORD) {
// Same logic as hotkeyId == 1 (m_advanced_paste_ui_hotkey)
Logger::trace(L"AdvancedPaste ShowUI event triggered");
if (m_auto_copy_selection_custom_action)
{
send_copy_selection(); // best-effort; ignore failure
}
m_process_manager.start();
m_process_manager.bring_to_front();
m_process_manager.send_message(CommonSharedConstants::ADVANCED_PASTE_SHOW_UI_MESSAGE);
@@ -1056,11 +1032,13 @@ public:
}
}
// Try to capture selected text for all hotkey actions when the setting is enabled.
// If nothing is selected (clipboard unchanged), fall through to use existing clipboard content.
if (m_auto_copy_selection_custom_action)
if (is_custom_action_hotkey && m_auto_copy_selection_custom_action)
{
send_copy_selection(); // best-effort; ignore failure
if (!send_copy_selection())
{
Logger::warn(L"Auto-copy: failed to copy selection for custom action index {} — aborting action", custom_action_index);
return false;
}
}
m_process_manager.start();

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<!-- This file is read by XES, which we use in our Release builds. -->
<PropertyGroup Label="Version">
<XesUseOneStoreVersioning>true</XesUseOneStoreVersioning>
<XesBaseYearForStoreVersion>2025</XesBaseYearForStoreVersion>
<VersionMajor>0</VersionMajor>
<VersionMinor>9</VersionMinor>
<VersionInfoProductName>PowerToys Advanced Paste</VersionInfoProductName>
</PropertyGroup>
</Project>

View File

@@ -1149,10 +1149,13 @@ VideoRecordingSession::VideoRecordingSession(
// Store frame interval for timeout-based frame production when webcam is active.
m_frameIntervalTicks = ( frameRate > 0 ) ? ( 10'000'000LL / frameRate ) : 333'333LL;
// NOTE: Audio encoding profile (m_encodingProfile.Audio) is set in
// StartAsync() after the audio graph is fully initialized, not here.
// Calling GetEncodingProperties() before InitializeAsync completes
// would crash because m_audioOutputNode is still null.
if (m_audioGenerator)
{
// Always set up audio profile for loopback capture (stereo AAC)
auto audioProps = m_audioGenerator->GetEncodingProperties();
m_encodingProfile.Audio(winrt::AudioEncodingProperties::CreateAac(
audioProps.SampleRate(), audioProps.ChannelCount(), 192000));
}
// Describe our input: uncompressed BGRA8 buffers
auto properties = winrt::VideoEncodingProperties::CreateUncompressed(
@@ -1230,16 +1233,7 @@ winrt::IAsyncAction VideoRecordingSession::StartAsync()
co_await m_audioGenerator->InitializeAsync();
}
RecDiag( L"StartAsync: audio initialized\n" );
// Set up the audio encoding profile now that the audio graph is
// fully initialized. GetEncodingProperties() requires
// m_audioOutputNode to be valid, which is only guaranteed after
// InitializeAsync completes.
auto audioProps = m_audioGenerator->GetEncodingProperties();
m_encodingProfile.Audio(winrt::AudioEncodingProperties::CreateAac(
audioProps.SampleRate(), audioProps.ChannelCount(), 192000));
m_streamSource = winrt::MediaStreamSource(m_videoDescriptor, winrt::AudioStreamDescriptor(audioProps));
m_streamSource = winrt::MediaStreamSource(m_videoDescriptor, winrt::AudioStreamDescriptor(m_audioGenerator->GetEncodingProperties()));
}
else {

View File

@@ -10,7 +10,7 @@
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.1" />
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="System.Text.Json" Version="9.0.8" />

View File

@@ -1,75 +0,0 @@
// 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 Windows.Foundation;
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>
///// Gets a boolean indicating whether the extension was disabled due to the corresponding Windows optional feature
///// being absent from the machine or in an unknown state.
///// </summary>
///// <param name="extension">The out of proc extension object</param>
///// <returns>True only if the extension was disabled. False otherwise.</returns>
// public Task<bool> DisableExtensionIfWindowsFeatureNotAvailable(IExtensionWrapper extension);
}

View File

@@ -125,6 +125,49 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
isValid = true;
}
/// <summary>
/// Creates a wrapper for a JavaScript extension where the <see cref="ICommandProvider"/>
/// is obtained directly (not through <see cref="IExtensionWrapper.GetExtensionObject"/>).
/// </summary>
/// <param name="extension">The JS extension wrapper managing the node process.</param>
/// <param name="provider">The command provider proxy obtained via JSON-RPC.</param>
/// <param name="mainThread">The UI thread scheduler.</param>
public CommandProviderWrapper(IExtensionWrapper extension, ICommandProvider provider, TaskScheduler mainThread)
{
_taskScheduler = mainThread;
TopLevelPageContext = new(this, _taskScheduler);
Extension = extension;
ExtensionHost = new CommandPaletteHost(extension);
_commandProvider = new(provider);
try
{
var model = _commandProvider.Unsafe!;
model.InitializeWithHost(ExtensionHost);
model.ItemsChanged += CommandProvider_ItemsChanged;
isValid = true;
Id = provider.Id;
DisplayName = provider.DisplayName;
Icon = new(provider.Icon);
Icon.InitializeProperties();
Settings = new(provider.Settings, this, _taskScheduler);
Logger.LogDebug($"Initialized JS extension command provider {Extension.PackageFamilyName}:{Extension.ExtensionUniqueId}");
}
catch (Exception e)
{
Logger.LogError("Failed to initialize CommandProvider for JS extension.");
Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
Logger.LogError(e.ToString());
}
isValid = true;
}
private ProviderSettings GetProviderSettings(SettingsModel settings)
{
if (!settings.ProviderSettings.TryGetValue(ProviderId, out var ps))

View File

@@ -2,6 +2,7 @@
// 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.UI.ViewModels.Commands;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -18,8 +19,7 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
private readonly FallbackReloadItem _fallbackReloadItem = new();
private readonly FallbackLogItem _fallbackLogItem = new();
private readonly NewExtensionPage _newExtension = new();
private readonly IRootPageService _rootPageService;
private readonly GoHomeDockCommand _goHomeDockCommand = new();
public override ICommandItem[] TopLevelCommands() =>
[
@@ -41,22 +41,16 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
_fallbackLogItem,
];
public BuiltInsCommandProvider(IRootPageService rootPageService)
public BuiltInsCommandProvider()
{
Id = "com.microsoft.cmdpal.builtin.core";
DisplayName = Properties.Resources.builtin_display_name;
Icon = IconHelpers.FromRelativePath("Assets\\Square44x44Logo.altform-unplated_targetsize-256.png");
_rootPageService = rootPageService;
}
public override ICommandItem[]? GetDockBands()
{
var rootPage = _rootPageService.GetRootPage();
List<ICommandItem> bandItems = new();
bandItems.Add(new WrappedDockItem(rootPage, Properties.Resources.builtin_command_palette_title));
return bandItems.ToArray();
return [new WrappedDockItem(_goHomeDockCommand, Properties.Resources.builtin_command_palette_title)];
}
public override void InitializeWithHost(IExtensionHost host) => BuiltinsExtensionHost.Instance.Initialize(host);

View File

@@ -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.
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Commands;
/// <summary>
/// A lightweight command used as a dock band item that navigates the user
/// back to the Command Palette home page when invoked.
/// </summary>
internal sealed partial class GoHomeDockCommand : InvokableCommand
{
public GoHomeDockCommand()
{
Name = Properties.Resources.builtin_command_palette_title;
Icon = IconHelpers.FromRelativePath("Assets\\Square44x44Logo.altform-unplated_targetsize-256.png");
}
public override ICommandResult Invoke() => CommandResult.GoHome();
}

View File

@@ -36,6 +36,7 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
private const string SourceTypeWinGet = "winget";
private const string SourceTypeStore = "msstore";
private const string SourceTypeNpm = "npm";
private const string SourceTypeUrl = "url";
private const string SourceTypeGitHub = "github";
private const string SourceTypeWebsite = "website";
@@ -129,6 +130,10 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
public bool HasStoreSource => HasSource(SourceTypeStore);
public bool HasNpmSource => HasSource(SourceTypeNpm);
public string? NpmPackageId => GetSource(SourceTypeNpm)?.Id;
public bool HasUrlSource => _installSourcesByType.ContainsKey(SourceTypeUrl) && InstallUrl is not null;
public bool HasHomepage => _homepageHttpUri is not null;
@@ -166,7 +171,7 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
public bool ShowUnknownSourceIndicator => HasUnknownSource || !HasKnownSourceIndicator;
public bool HasActionableSourceDetails => HasStoreSource || HasWinGetSource || HasHomepage || HasUrlSource;
public bool HasActionableSourceDetails => HasStoreSource || HasWinGetSource || HasNpmSource || HasHomepage || HasUrlSource;
public bool ShowNoSourceDetails => !HasActionableSourceDetails;

View File

@@ -13,6 +13,7 @@ using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Common.WinGet.Models;
using Microsoft.CmdPal.Common.WinGet.Services;
using Microsoft.CmdPal.UI.ViewModels.Properties;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.Logging;
@@ -50,7 +51,7 @@ public sealed partial class ExtensionGalleryViewModel : ObservableObject, IDispo
"Failed to check WinGet package status");
private readonly IExtensionGalleryService _galleryService;
private readonly IExtensionService _extensionService;
private readonly IEnumerable<IExtensionService> _extensionServices;
private readonly ILogger<ExtensionGalleryViewModel> _logger;
private readonly ExtensionGalleryItemViewModelFactory _galleryExtensionViewModelFactory;
private readonly IWinGetPackageManagerService? _winGetPackageManagerService;
@@ -162,7 +163,7 @@ public sealed partial class ExtensionGalleryViewModel : ObservableObject, IDispo
public ExtensionGalleryViewModel(
IExtensionGalleryService galleryService,
IExtensionService extensionService,
IEnumerable<IExtensionService> extensionServices,
ILogger<ExtensionGalleryViewModel> logger,
ExtensionGalleryItemViewModelFactory galleryExtensionViewModelFactory,
IWinGetPackageManagerService? winGetPackageManagerService = null,
@@ -171,7 +172,7 @@ public sealed partial class ExtensionGalleryViewModel : ObservableObject, IDispo
TaskScheduler? uiScheduler = null)
{
_galleryService = galleryService;
_extensionService = extensionService;
_extensionServices = extensionServices;
_logger = logger;
_galleryExtensionViewModelFactory = galleryExtensionViewModelFactory;
_winGetPackageManagerService = winGetPackageManagerService;
@@ -347,17 +348,23 @@ public sealed partial class ExtensionGalleryViewModel : ObservableObject, IDispo
List<ExtensionGalleryItemViewModel> snapshot;
try
{
var installedExtensions = refreshInstalledExtensions
? await RunInBackgroundAsync(
() => _extensionService.RefreshInstalledExtensionsAsync(includeDisabledExtensions: true),
cancellationToken)
: await RunInBackgroundAsync(
() => _extensionService.GetInstalledExtensionsAsync(includeDisabledExtensions: true),
cancellationToken);
var allInstalledExtensions = new List<IExtensionWrapper>();
foreach (var service in _extensionServices)
{
var extensions = refreshInstalledExtensions
? await RunInBackgroundAsync(
() => service.RefreshInstalledExtensionsAsync(includeDisabledExtensions: true),
cancellationToken)
: await RunInBackgroundAsync(
() => service.GetInstalledExtensionsAsync(includeDisabledExtensions: true),
cancellationToken);
allInstalledExtensions.AddRange(extensions);
}
cancellationToken.ThrowIfCancellationRequested();
var installedPfns = new HashSet<string>(
installedExtensions
allInstalledExtensions
.Select(e => e.PackageFamilyName)
.Where(pfn => !string.IsNullOrEmpty(pfn)),
StringComparer.OrdinalIgnoreCase);

View File

@@ -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.UI.ViewModels.Messages;
public record ProviderEnabledStateChangedMessage(string ProviderId, bool IsEnabled);

View File

@@ -0,0 +1,558 @@
// 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.
#pragma warning disable SA1402 // File may only contain a single type - closely related adapters grouped together
#pragma warning disable SA1649 // File name should match first type name
#pragma warning disable SA1300 // Element should begin with upper-case letter (private event backing fields)
#pragma warning disable SA1516 // Elements should be separated by blank line
using System.Runtime.InteropServices.WindowsRuntime;
using System.Text.Json;
using System.Text.Json.Nodes;
using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels.Services.JsonRpc;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
using Windows.Storage.Streams;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// <summary>
/// Adapts JSON command data to ICommand and IInvokableCommand interfaces.
/// </summary>
internal sealed class JSCommandAdapter : ICommand, IInvokableCommand
{
private readonly JsonElement _data;
private readonly JsonRpcConnection _connection;
private readonly Lock _eventLock = new();
private event TypedEventHandler<object, IPropChangedEventArgs>? _propChanged;
public JSCommandAdapter(JsonElement data, JsonRpcConnection connection)
{
_data = data;
_connection = connection;
}
public string Name => GetStringProperty("displayName") ?? GetStringProperty("name") ?? string.Empty;
public string Id => GetStringProperty("id") ?? string.Empty;
public IIconInfo Icon => GetIconInfo();
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged
{
add
{
lock (_eventLock)
{
_propChanged += value;
}
}
remove
{
lock (_eventLock)
{
_propChanged -= value;
}
}
}
public void RaisePropChanged(string propertyName)
{
lock (_eventLock)
{
_propChanged?.Invoke(this, new PropChangedEventArgs(propertyName));
}
}
public ICommandResult Invoke(object sender)
{
try
{
var response = _connection.SendRequestAsync(
"command/invoke",
new JsonObject { ["commandId"] = Id },
CancellationToken.None).GetAwaiter().GetResult();
if (response.Error != null)
{
Logger.LogError($"Command invoke error: {response.Error.Message}");
return CommandResult.KeepOpen();
}
return ParseCommandResult(response.Result);
}
catch (Exception ex)
{
Logger.LogError($"Failed to invoke command {Id}: {ex.Message}");
return CommandResult.KeepOpen();
}
}
private string? GetStringProperty(string name)
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
{
return prop.GetString();
}
return null;
}
private IIconInfo GetIconInfo()
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("icon", out var iconProp))
{
return JSIconInfoAdapter.FromJson(iconProp);
}
return new IconInfo(string.Empty);
}
private ICommandResult ParseCommandResult(JsonElement? result)
{
return JSCommandResultAdapter.ParseCommandResult(result, _connection);
}
}
/// <summary>
/// Adapts JSON command item data to ICommandItem interface.
/// </summary>
internal sealed class JSCommandItemAdapter : ICommandItem
{
private readonly JsonElement _data;
private readonly JsonRpcConnection _connection;
private readonly Lock _eventLock = new();
private ICommand? _command;
private event TypedEventHandler<object, IPropChangedEventArgs>? _propChanged;
public JSCommandItemAdapter(JsonElement data, JsonRpcConnection connection)
{
_data = data;
_connection = connection;
}
public ICommand Command
{
get
{
if (_command == null)
{
var commandData = _data;
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("command", out var commandElement) &&
commandElement.ValueKind == JsonValueKind.Object)
{
commandData = commandElement;
}
_command = JSCommandFactory.CreateCommandFromJson(commandData, _connection);
}
return _command;
}
}
public IContextItem[] MoreCommands => [];
public IIconInfo Icon => GetIconInfo();
public string Title => GetStringProperty("displayName") ?? GetStringProperty("title") ?? string.Empty;
public string Subtitle => GetStringProperty("description") ?? GetStringProperty("subtitle") ?? string.Empty;
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged
{
add
{
lock (_eventLock)
{
_propChanged += value;
}
}
remove
{
lock (_eventLock)
{
_propChanged -= value;
}
}
}
public void RaisePropChanged(string propertyName)
{
lock (_eventLock)
{
_propChanged?.Invoke(this, new PropChangedEventArgs(propertyName));
}
}
private string? GetStringProperty(string name)
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
{
return prop.GetString();
}
return null;
}
private IIconInfo GetIconInfo()
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("icon", out var iconProp))
{
return JSIconInfoAdapter.FromJson(iconProp);
}
return new IconInfo(string.Empty);
}
}
/// <summary>
/// Utility for creating the appropriate ICommand implementation from JSON data,
/// inspecting the _type discriminator to return page proxies for pages.
/// </summary>
internal static class JSCommandFactory
{
internal static ICommand CreateCommandFromJson(JsonElement data, JsonRpcConnection connection)
{
if (data.ValueKind == JsonValueKind.Object &&
data.TryGetProperty("_type", out var typeProp) && typeProp.ValueKind == JsonValueKind.String)
{
var commandId = string.Empty;
if (data.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String)
{
commandId = idProp.GetString() ?? string.Empty;
}
var pageType = typeProp.GetString();
if (pageType == "dynamicListPage")
{
return new JSDynamicListPageProxy(commandId, connection, data);
}
if (pageType == "listPage")
{
return new JSListPageProxy(commandId, connection, data);
}
if (pageType == "contentPage")
{
return new JSContentPageProxy(commandId, data, connection);
}
}
return new JSCommandAdapter(data, connection);
}
}
/// <summary>
/// Adapts JSON fallback command item data to IFallbackCommandItem interface.
/// </summary>
internal sealed class JSFallbackCommandItemAdapter : IFallbackCommandItem, IFallbackCommandItem2
{
private readonly JsonElement _data;
private readonly JsonRpcConnection _connection;
private readonly Lock _eventLock = new();
private ICommand? _command;
private JSFallbackHandler? _fallbackHandler;
private string? _displayTitleOverride;
private event TypedEventHandler<object, IPropChangedEventArgs>? _propChanged;
public JSFallbackCommandItemAdapter(JsonElement data, JsonRpcConnection connection)
{
_data = data;
_connection = connection;
}
public ICommand Command
{
get
{
if (_command == null)
{
var commandData = _data;
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("command", out var commandElement) &&
commandElement.ValueKind == JsonValueKind.Object)
{
commandData = commandElement;
}
_command = JSCommandFactory.CreateCommandFromJson(commandData, _connection);
}
return _command;
}
}
public IContextItem[] MoreCommands => [];
public IIconInfo Icon => GetIconInfo();
public string Title => GetStringProperty("displayName") ?? GetStringProperty("title") ?? string.Empty;
public string Subtitle => GetStringProperty("description") ?? GetStringProperty("subtitle") ?? string.Empty;
public string DisplayTitle => _displayTitleOverride ?? GetStringProperty("displayTitle") ?? Title;
public string Id => GetStringProperty("id") ?? string.Empty;
public void UpdateDisplayTitle(string newTitle)
{
_displayTitleOverride = newTitle;
}
public IFallbackHandler FallbackHandler
{
get
{
_fallbackHandler ??= new JSFallbackHandler(_connection, Id);
return _fallbackHandler;
}
}
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged
{
add
{
lock (_eventLock)
{
_propChanged += value;
}
}
remove
{
lock (_eventLock)
{
_propChanged -= value;
}
}
}
public void RaisePropChanged(string propertyName)
{
lock (_eventLock)
{
_propChanged?.Invoke(this, new PropChangedEventArgs(propertyName));
}
}
private string? GetStringProperty(string name)
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
{
return prop.GetString();
}
return null;
}
private IIconInfo GetIconInfo()
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("icon", out var iconProp))
{
return JSIconInfoAdapter.FromJson(iconProp);
}
return new IconInfo(string.Empty);
}
private sealed class JSFallbackHandler : IFallbackHandler
{
private readonly JsonRpcConnection _connection;
private readonly string _commandId;
public JSFallbackHandler(JsonRpcConnection connection, string commandId)
{
_connection = connection;
_commandId = commandId;
}
public void UpdateQuery(string query)
{
try
{
_connection.SendNotificationAsync(
"fallback/updateQuery",
new JsonObject { ["commandId"] = _commandId, ["query"] = query },
CancellationToken.None).Wait(TimeSpan.FromSeconds(2));
}
catch (Exception ex)
{
Logger.LogWarning($"Failed to send fallback query update: {ex.Message}");
}
}
}
}
/// <summary>
/// Adapts JSON icon data to IIconInfo and IIconData interfaces.
/// </summary>
internal sealed class JSIconInfoAdapter : IIconInfo
{
private readonly JSIconDataAdapter? _light;
private readonly JSIconDataAdapter? _dark;
private JSIconInfoAdapter(JSIconDataAdapter? light, JSIconDataAdapter? dark)
{
_light = light;
_dark = dark;
}
public IIconData Light => _light ?? new JSIconDataAdapter(string.Empty, null);
public IIconData Dark => _dark ?? new JSIconDataAdapter(string.Empty, null);
public static JSIconInfoAdapter FromJson(JsonElement iconJson)
{
if (iconJson.ValueKind != JsonValueKind.Object)
{
return new JSIconInfoAdapter(null, null);
}
#pragma warning disable CA1507 // JSON property names, not C# members
JSIconDataAdapter? light = null;
JSIconDataAdapter? dark = null;
if (iconJson.TryGetProperty("Light", out var lightProp) || iconJson.TryGetProperty("light", out lightProp))
{
light = ParseIconData(lightProp);
}
if (iconJson.TryGetProperty("Dark", out var darkProp) || iconJson.TryGetProperty("dark", out darkProp))
{
dark = ParseIconData(darkProp);
}
// If only "icon"/"Icon" property exists, use it for both light and dark
if (light == null && dark == null)
{
if (iconJson.TryGetProperty("Icon", out var iconProp) || iconJson.TryGetProperty("icon", out iconProp))
{
var iconData = ParseIconData(iconProp);
light = iconData;
dark = iconData;
}
}
#pragma warning restore CA1507
return new JSIconInfoAdapter(light, dark);
}
private static JSIconDataAdapter? ParseIconData(JsonElement iconDataJson)
{
#pragma warning disable CA1507 // JSON property names, not C# members
string? iconPath = null;
string? base64Data = null;
if (iconDataJson.ValueKind == JsonValueKind.String)
{
iconPath = iconDataJson.GetString();
}
else if (iconDataJson.ValueKind == JsonValueKind.Object)
{
if ((iconDataJson.TryGetProperty("Icon", out var iconProp) || iconDataJson.TryGetProperty("icon", out iconProp)) &&
iconProp.ValueKind == JsonValueKind.String)
{
iconPath = iconProp.GetString();
}
if ((iconDataJson.TryGetProperty("Data", out var dataProp) || iconDataJson.TryGetProperty("data", out dataProp)) &&
dataProp.ValueKind == JsonValueKind.String)
{
base64Data = dataProp.GetString();
}
}
#pragma warning restore CA1507
if (!string.IsNullOrEmpty(iconPath) || !string.IsNullOrEmpty(base64Data))
{
return new JSIconDataAdapter(iconPath ?? string.Empty, base64Data);
}
return null;
}
internal sealed class JSIconDataAdapter : IIconData
{
private readonly string _icon;
private readonly string? _base64Data;
public JSIconDataAdapter(string icon, string? base64Data)
{
_icon = icon;
_base64Data = base64Data;
}
public string Icon => _icon;
public IRandomAccessStreamReference? Data
{
get
{
if (string.IsNullOrEmpty(_base64Data))
{
return null;
}
try
{
byte[] bytes;
if (_base64Data.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
bytes = DecodeDataUri(_base64Data);
}
else
{
bytes = Convert.FromBase64String(_base64Data);
}
var stream = new InMemoryRandomAccessStream();
stream.WriteAsync(bytes.AsBuffer()).GetResults();
stream.Seek(0);
return RandomAccessStreamReference.CreateFromStream(stream);
}
catch (Exception ex)
{
Logger.LogWarning($"Failed to decode icon data: {ex.Message}");
return null;
}
}
}
private static byte[] DecodeDataUri(string dataUri)
{
// data:[<mediatype>][;base64],<data>
var commaIndex = dataUri.IndexOf(',', StringComparison.Ordinal);
if (commaIndex < 0)
{
throw new FormatException("Invalid data URI: no comma separator");
}
var header = dataUri[..commaIndex];
var data = dataUri[(commaIndex + 1)..];
if (header.Contains(";base64", StringComparison.OrdinalIgnoreCase))
{
return Convert.FromBase64String(data);
}
// URL-encoded data (e.g., SVG)
var decoded = Uri.UnescapeDataString(data);
return System.Text.Encoding.UTF8.GetBytes(decoded);
}
}
}

View File

@@ -0,0 +1,588 @@
// 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.
#pragma warning disable SA1300 // Element should begin with upper-case letter (private event backing fields)
#pragma warning disable CS4014 // Because this call is not awaited
using System.Text.Json;
using System.Text.Json.Nodes;
using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels.Services.JsonRpc;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// <summary>
/// Proxy that implements ICommandProvider by forwarding calls to a Node.js extension via JSON-RPC.
/// </summary>
public sealed class JSCommandProviderProxy : ICommandProvider, IDisposable
{
private readonly JsonRpcConnection _rpcConnection;
private readonly JSExtensionManifest _manifest;
private readonly IconInfo _defaultIcon;
private readonly Lock _eventLock = new();
private readonly Dictionary<(string Message, int State), StatusMessage> _shownStatusMessages = new();
private readonly Dictionary<string, JSFallbackCommandItemAdapter> _fallbackAdapters = new();
private IExtensionHost? _host;
private bool _isDisposed;
private ICommandSettings? _settingsCache;
private bool _settingsQueried;
private event TypedEventHandler<object, IItemsChangedEventArgs>? _itemsChanged;
public JSCommandProviderProxy(JsonRpcConnection rpcConnection, JSExtensionManifest manifest)
{
_rpcConnection = rpcConnection ?? throw new ArgumentNullException(nameof(rpcConnection));
_manifest = manifest ?? throw new ArgumentNullException(nameof(manifest));
_defaultIcon = new IconInfo(string.Empty);
RegisterNotificationHandlers();
}
public string Id => _manifest.Name ?? "unknown";
public string DisplayName => _manifest.DisplayName ?? _manifest.Name ?? "Unknown";
public IIconInfo Icon => _defaultIcon;
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged
{
add
{
lock (_eventLock)
{
_itemsChanged += value;
}
}
remove
{
lock (_eventLock)
{
_itemsChanged -= value;
}
}
}
public ICommandItem[] TopLevelCommands()
{
try
{
var response = _rpcConnection.SendRequestAsync(
"provider/getTopLevelCommands",
null,
CancellationToken.None).GetAwaiter().GetResult();
if (response.Error != null)
{
Logger.LogError($"TopLevelCommands error: {response.Error.Message}");
return [];
}
return ParseCommandItems(response.Result);
}
catch (Exception ex)
{
Logger.LogError($"Failed to get top-level commands: {ex.Message}");
return [];
}
}
public IFallbackCommandItem[]? FallbackCommands()
{
try
{
var response = _rpcConnection.SendRequestAsync(
"provider/getFallbackCommands",
null,
CancellationToken.None).GetAwaiter().GetResult();
if (response.Error != null)
{
Logger.LogWarning($"FallbackCommands error: {response.Error.Message}");
return null;
}
return ParseFallbackCommandItems(response.Result);
}
catch (Exception ex)
{
Logger.LogWarning($"Failed to get fallback commands: {ex.Message}");
return null;
}
}
public ICommand? GetCommand(string id)
{
try
{
var response = _rpcConnection.SendRequestAsync(
"provider/getCommand",
new JsonObject { ["commandId"] = id },
CancellationToken.None).GetAwaiter().GetResult();
if (response.Error != null)
{
Logger.LogWarning($"GetCommand error for {id}: {response.Error.Message}");
return null;
}
if (!response.Result.HasValue || response.Result.Value.ValueKind == JsonValueKind.Null)
{
return null;
}
var data = response.Result.Value;
// Check for page type discriminator to return the appropriate proxy
if (data.TryGetProperty("_type", out var typeProp) && typeProp.ValueKind == JsonValueKind.String)
{
var pageType = typeProp.GetString();
if (pageType == "dynamicListPage")
{
return new JSDynamicListPageProxy(id, _rpcConnection);
}
if (pageType == "listPage")
{
return new JSListPageProxy(id, _rpcConnection);
}
if (pageType == "contentPage")
{
return new JSContentPageProxy(id, _rpcConnection);
}
}
return new JSCommandAdapter(data, _rpcConnection);
}
catch (Exception ex)
{
Logger.LogWarning($"Failed to get command {id}: {ex.Message}");
return null;
}
}
public ICommandSettings? Settings
{
get
{
if (_settingsQueried)
{
return _settingsCache;
}
_settingsQueried = true;
try
{
var response = _rpcConnection.SendRequestAsync(
"provider/getSettings",
null,
CancellationToken.None).GetAwaiter().GetResult();
if (response.Error != null)
{
Logger.LogDebug($"Settings not available for {DisplayName}: {response.Error.Message}");
return null;
}
if (!response.Result.HasValue || response.Result.Value.ValueKind == JsonValueKind.Null)
{
return null;
}
var data = response.Result.Value;
var pageId = string.Empty;
if (data.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String)
{
pageId = idProp.GetString() ?? string.Empty;
}
if (!string.IsNullOrEmpty(pageId))
{
_settingsCache = new JSCommandSettingsProxy(pageId, _rpcConnection);
}
}
catch (Exception ex)
{
Logger.LogDebug($"Failed to get settings for {DisplayName}: {ex.Message}");
}
return _settingsCache;
}
}
public bool Frozen => true;
public void InitializeWithHost(IExtensionHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
Logger.LogDebug($"JSCommandProviderProxy initialized with host for {DisplayName}");
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
lock (_eventLock)
{
_itemsChanged = null;
}
_host = null;
}
private void RegisterNotificationHandlers()
{
_rpcConnection.RegisterNotificationHandler("provider/itemsChanged", HandleItemsChangedNotification);
_rpcConnection.RegisterNotificationHandler("command/propChanged", HandleCommandPropChangedNotification);
_rpcConnection.RegisterNotificationHandler("page/propChanged", HandlePagePropChangedNotification);
_rpcConnection.RegisterNotificationHandler("content/propChanged", HandleContentPropChangedNotification);
_rpcConnection.RegisterNotificationHandler("provider/propChanged", HandleProviderPropChangedNotification);
_rpcConnection.RegisterNotificationHandler("host/logMessage", HandleLogMessageNotification);
_rpcConnection.RegisterNotificationHandler("host/showStatus", HandleShowStatusNotification);
_rpcConnection.RegisterNotificationHandler("host/hideStatus", HandleHideStatusNotification);
_rpcConnection.RegisterNotificationHandler("host/copyText", HandleCopyTextNotification);
}
private void HandleItemsChangedNotification(JsonElement paramsElement)
{
try
{
var totalItems = -1;
if (paramsElement.TryGetProperty("totalItems", out var totalItemsProp))
{
totalItems = totalItemsProp.GetInt32();
}
lock (_eventLock)
{
_itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems));
}
}
catch (Exception ex)
{
Logger.LogWarning($"Error handling itemsChanged notification: {ex.Message}");
}
}
private void HandleCommandPropChangedNotification(JsonElement paramsElement)
{
try
{
var commandId = string.Empty;
if (paramsElement.TryGetProperty("commandId", out var commandIdProp))
{
commandId = commandIdProp.GetString() ?? string.Empty;
}
// Update fallback adapter properties if applicable
if (!string.IsNullOrEmpty(commandId) && _fallbackAdapters.TryGetValue(commandId, out var fallbackAdapter))
{
if (paramsElement.TryGetProperty("properties", out var propsProp) && propsProp.ValueKind == JsonValueKind.Object)
{
if (propsProp.TryGetProperty("displayTitle", out var displayTitleProp) && displayTitleProp.ValueKind == JsonValueKind.String)
{
fallbackAdapter.UpdateDisplayTitle(displayTitleProp.GetString() ?? string.Empty);
}
}
fallbackAdapter.RaisePropChanged("DisplayTitle");
}
Logger.LogDebug($"[{DisplayName}] command/propChanged: commandId={commandId}");
}
catch (Exception ex)
{
Logger.LogWarning($"Error handling command/propChanged notification: {ex.Message}");
}
}
private void HandlePagePropChangedNotification(JsonElement paramsElement)
{
try
{
var pageId = string.Empty;
if (paramsElement.TryGetProperty("pageId", out var pageIdProp))
{
pageId = pageIdProp.GetString() ?? string.Empty;
}
var propertyName = string.Empty;
if (paramsElement.TryGetProperty("propertyName", out var propertyNameProp))
{
propertyName = propertyNameProp.GetString() ?? string.Empty;
}
Logger.LogDebug($"[{DisplayName}] page/propChanged: pageId={pageId}, property={propertyName}");
}
catch (Exception ex)
{
Logger.LogWarning($"Error handling page/propChanged notification: {ex.Message}");
}
}
private void HandleContentPropChangedNotification(JsonElement paramsElement)
{
try
{
var contentId = string.Empty;
if (paramsElement.TryGetProperty("contentId", out var contentIdProp))
{
contentId = contentIdProp.GetString() ?? string.Empty;
}
var propertyName = string.Empty;
if (paramsElement.TryGetProperty("propertyName", out var propertyNameProp))
{
propertyName = propertyNameProp.GetString() ?? string.Empty;
}
Logger.LogDebug($"[{DisplayName}] content/propChanged: contentId={contentId}, property={propertyName}");
}
catch (Exception ex)
{
Logger.LogWarning($"Error handling content/propChanged notification: {ex.Message}");
}
}
private void HandleProviderPropChangedNotification(JsonElement paramsElement)
{
try
{
var propertyName = string.Empty;
if (paramsElement.TryGetProperty("propertyName", out var propertyNameProp))
{
propertyName = propertyNameProp.GetString() ?? string.Empty;
}
Logger.LogDebug($"[{DisplayName}] provider/propChanged: property={propertyName}");
}
catch (Exception ex)
{
Logger.LogWarning($"Error handling provider/propChanged notification: {ex.Message}");
}
}
private void HandleLogMessageNotification(JsonElement paramsElement)
{
try
{
if (!paramsElement.TryGetProperty("message", out var messageProp))
{
return;
}
var message = messageProp.GetString() ?? string.Empty;
var state = 2; // Default to Info
if (paramsElement.TryGetProperty("state", out var stateProp))
{
state = stateProp.GetInt32();
}
// Map MessageState enum: 0=Trace, 1=Debug, 2=Info, 3=Warning, 4=Error
switch (state)
{
case 0:
case 1:
Logger.LogDebug($"[{DisplayName}] {message}");
break;
case 2:
Logger.LogInfo($"[{DisplayName}] {message}");
break;
case 3:
Logger.LogWarning($"[{DisplayName}] {message}");
break;
case 4:
Logger.LogError($"[{DisplayName}] {message}");
break;
default:
Logger.LogInfo($"[{DisplayName}] {message}");
break;
}
// Forward to host if available
if (_host != null)
{
var logMessage = new LogMessage { Message = message, State = (MessageState)state };
_host.LogMessage(logMessage);
}
}
catch (Exception ex)
{
Logger.LogWarning($"Error handling logMessage notification: {ex.Message}");
}
}
private void HandleShowStatusNotification(JsonElement paramsElement)
{
try
{
if (_host == null)
{
return;
}
if (!paramsElement.TryGetProperty("message", out var messageProp))
{
return;
}
var message = string.Empty;
var state = 2; // Default to Info
if (messageProp.ValueKind == JsonValueKind.Object)
{
if (messageProp.TryGetProperty("Message", out var msgText))
{
message = msgText.GetString() ?? string.Empty;
}
if (messageProp.TryGetProperty("State", out var msgState))
{
state = msgState.GetInt32();
}
}
else if (messageProp.ValueKind == JsonValueKind.String)
{
message = messageProp.GetString() ?? string.Empty;
}
var statusMessage = new StatusMessage
{
Message = message,
State = (MessageState)state,
};
_shownStatusMessages[(message, state)] = statusMessage;
var context = StatusContext.Extension;
if (paramsElement.TryGetProperty("context", out var contextProp))
{
if (contextProp.ValueKind == JsonValueKind.Number)
{
context = (StatusContext)contextProp.GetInt32();
}
else if (contextProp.ValueKind == JsonValueKind.String)
{
var contextStr = contextProp.GetString();
if (contextStr == "page")
{
context = StatusContext.Page;
}
}
}
_host.ShowStatus(statusMessage, context);
}
catch (Exception ex)
{
Logger.LogWarning($"Error handling showStatus notification: {ex.Message}");
}
}
private void HandleHideStatusNotification(JsonElement paramsElement)
{
try
{
if (_host == null)
{
return;
}
var message = string.Empty;
var state = 2; // Default to Info
if (paramsElement.TryGetProperty("message", out var messageProp) &&
messageProp.ValueKind == JsonValueKind.Object)
{
if (messageProp.TryGetProperty("Message", out var msgText))
{
message = msgText.GetString() ?? string.Empty;
}
if (messageProp.TryGetProperty("State", out var msgState))
{
state = msgState.GetInt32();
}
}
var key = (message, state);
if (_shownStatusMessages.TryGetValue(key, out var originalMessage))
{
_shownStatusMessages.Remove(key);
_host.HideStatus(originalMessage);
}
}
catch (Exception ex)
{
Logger.LogWarning($"Error handling hideStatus notification: {ex.Message}");
}
}
private void HandleCopyTextNotification(JsonElement paramsElement)
{
try
{
if (paramsElement.TryGetProperty("text", out var textProp) && textProp.ValueKind == JsonValueKind.String)
{
var text = textProp.GetString() ?? string.Empty;
ClipboardHelper.SetText(text);
}
}
catch (Exception ex)
{
Logger.LogWarning($"Error handling copyText notification: {ex.Message}");
}
}
private ICommandItem[] ParseCommandItems(JsonElement? result)
{
if (!result.HasValue || result.Value.ValueKind != JsonValueKind.Array)
{
return [];
}
var items = new List<ICommandItem>();
foreach (var element in result.Value.EnumerateArray())
{
items.Add(new JSCommandItemAdapter(element, _rpcConnection));
}
return items.ToArray();
}
private IFallbackCommandItem[]? ParseFallbackCommandItems(JsonElement? result)
{
if (!result.HasValue || result.Value.ValueKind != JsonValueKind.Array)
{
return null;
}
var items = new List<IFallbackCommandItem>();
foreach (var element in result.Value.EnumerateArray())
{
var adapter = new JSFallbackCommandItemAdapter(element, _rpcConnection);
items.Add(adapter);
var id = adapter.Id;
if (!string.IsNullOrEmpty(id))
{
_fallbackAdapters[id] = adapter;
}
}
return items.ToArray();
}
}

View File

@@ -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.
using Microsoft.CmdPal.UI.ViewModels.Services.JsonRpc;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// <summary>
/// Proxy that implements ICommandSettings for JavaScript extensions by wrapping
/// a JSContentPageProxy for the settings page. The settings page is fetched via
/// the <c>provider/getSettings</c> JSON-RPC method and then relies on existing
/// <c>contentPage/getContent</c> and <c>form/submit</c> handlers for interaction.
/// </summary>
internal sealed class JSCommandSettingsProxy : ICommandSettings
{
private readonly JSContentPageProxy _settingsPage;
public JSCommandSettingsProxy(string settingsPageId, JsonRpcConnection connection)
{
_settingsPage = new JSContentPageProxy(settingsPageId, connection);
}
public IContentPage SettingsPage => _settingsPage;
}

View File

@@ -0,0 +1,793 @@
// 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.
#pragma warning disable SA1402 // File may only contain a single type - closely related content adapters grouped together
#pragma warning disable SA1649 // File name should match first type name
using System.Text.Json;
using System.Text.Json.Nodes;
using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels.Services.JsonRpc;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// <summary>
/// Static helper for parsing IContent arrays and enhanced ICommandResult
/// including GoToPage, ShowToast, and Confirm args.
/// </summary>
internal static class JSCommandResultAdapter
{
internal static ICommandResult ParseCommandResult(JsonElement? result, JsonRpcConnection? connection = null)
{
if (!result.HasValue || result.Value.ValueKind == JsonValueKind.Null)
{
return CommandResult.Dismiss();
}
var kindValue = 0;
if (result.Value.TryGetProperty("Kind", out var kindProp) ||
result.Value.TryGetProperty("kind", out kindProp))
{
kindValue = kindProp.GetInt32();
}
var kind = (CommandResultKind)kindValue;
var hasArgs = result.Value.TryGetProperty("Args", out var argsProp) ||
result.Value.TryGetProperty("args", out argsProp);
if (hasArgs && argsProp.ValueKind == JsonValueKind.Object)
{
if (kind == CommandResultKind.GoToPage)
{
return ParseGoToPage(argsProp, connection);
}
if (kind == CommandResultKind.ShowToast)
{
return ParseShowToast(argsProp);
}
if (kind == CommandResultKind.Confirm)
{
return ParseConfirm(argsProp, connection);
}
}
if (kind == CommandResultKind.GoHome)
{
return CommandResult.GoHome();
}
if (kind == CommandResultKind.GoBack)
{
return CommandResult.GoBack();
}
if (kind == CommandResultKind.Hide)
{
return CommandResult.Hide();
}
if (kind == CommandResultKind.KeepOpen)
{
return CommandResult.KeepOpen();
}
return CommandResult.Dismiss();
}
internal static IContent[] ParseContentArray(JsonElement? result, string pageId, JsonRpcConnection connection)
{
if (!result.HasValue || result.Value.ValueKind != JsonValueKind.Array)
{
return [];
}
var count = result.Value.GetArrayLength();
var items = new IContent[count];
var i = 0;
foreach (var item in result.Value.EnumerateArray())
{
items[i++] = ParseContentItem(item, pageId, connection);
}
return items;
}
internal static IContent ParseContentItem(JsonElement element, string pageId, JsonRpcConnection connection)
{
var type = string.Empty;
if (element.TryGetProperty("type", out var typeProp) && typeProp.ValueKind == JsonValueKind.String)
{
type = typeProp.GetString() ?? string.Empty;
}
if (type == "form")
{
return new JSFormContentProxy(pageId, element, connection);
}
if (type == "tree")
{
return new JSTreeContentAdapter(element, pageId, connection);
}
if (type == "plainText")
{
return new JSPlainTextContentAdapter(element);
}
if (type == "image")
{
return new JSImageContentAdapter(element);
}
return new JSMarkdownContentAdapter(element);
}
internal static OptionalColor ParseOptionalColor(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
{
return default;
}
if (!element.TryGetProperty("hasValue", out var hasValueProp) || hasValueProp.ValueKind != JsonValueKind.True)
{
return default;
}
if (!element.TryGetProperty("color", out var colorProp) || colorProp.ValueKind != JsonValueKind.String)
{
return default;
}
var hex = colorProp.GetString();
if (string.IsNullOrEmpty(hex) || hex[0] != '#')
{
return default;
}
try
{
if (hex.Length == 7)
{
var r = Convert.ToByte(hex.Substring(1, 2), 16);
var g = Convert.ToByte(hex.Substring(3, 2), 16);
var b = Convert.ToByte(hex.Substring(5, 2), 16);
return ColorHelpers.FromRgb(r, g, b);
}
if (hex.Length == 9)
{
var a = Convert.ToByte(hex.Substring(1, 2), 16);
var r = Convert.ToByte(hex.Substring(3, 2), 16);
var g = Convert.ToByte(hex.Substring(5, 2), 16);
var b = Convert.ToByte(hex.Substring(7, 2), 16);
return ColorHelpers.FromArgb(a, r, g, b);
}
}
catch
{
// Invalid hex format; fall through to default
}
return default;
}
private static ICommandResult ParseGoToPage(JsonElement args, JsonRpcConnection? connection)
{
var pageId = string.Empty;
if ((args.TryGetProperty("PageId", out var pageIdProp) || args.TryGetProperty("pageId", out pageIdProp)) &&
pageIdProp.ValueKind == JsonValueKind.String)
{
pageId = pageIdProp.GetString() ?? string.Empty;
}
var navMode = NavigationMode.Push;
if (args.TryGetProperty("Mode", out var navModeProp) || args.TryGetProperty("mode", out navModeProp) || args.TryGetProperty("navigationMode", out navModeProp))
{
if (navModeProp.ValueKind == JsonValueKind.Number)
{
navMode = (NavigationMode)navModeProp.GetInt32();
}
else if (navModeProp.ValueKind == JsonValueKind.String)
{
var modeStr = navModeProp.GetString()?.ToLowerInvariant();
navMode = modeStr switch
{
"push" => NavigationMode.Push,
"goback" or "goBack" => NavigationMode.GoBack,
"gohome" or "goHome" => NavigationMode.GoHome,
_ => NavigationMode.Push,
};
}
}
// Resolve the page via JSONRPC if we have a connection and a pageId
IPage? resolvedPage = null;
if (connection != null && !string.IsNullOrEmpty(pageId))
{
try
{
var response = connection.SendRequestAsync(
"provider/getCommand",
new JsonObject { ["commandId"] = pageId },
CancellationToken.None).GetAwaiter().GetResult();
if (response.Error == null && response.Result.HasValue)
{
var command = JSCommandFactory.CreateCommandFromJson(response.Result.Value, connection);
if (command is IPage page)
{
resolvedPage = page;
}
}
}
catch (Exception ex)
{
Logger.LogError($"Failed to resolve page '{pageId}': {ex.Message}");
}
}
return new JSGoToPageCommandResult
{
Kind = CommandResultKind.GoToPage,
Args = new GoToPageArgs { PageId = pageId, NavigationMode = navMode },
Page = resolvedPage,
};
}
private static ICommandResult ParseShowToast(JsonElement args)
{
string? message = null;
if ((args.TryGetProperty("Message", out var messageProp) || args.TryGetProperty("message", out messageProp)) &&
messageProp.ValueKind == JsonValueKind.String)
{
message = messageProp.GetString();
}
return CommandResult.ShowToast(new ToastArgs { Message = message });
}
private static ICommandResult ParseConfirm(JsonElement args, JsonRpcConnection? connection)
{
string? title = null;
string? description = null;
ICommand? primaryCommand = null;
var isCritical = false;
if ((args.TryGetProperty("Title", out var titleProp) || args.TryGetProperty("title", out titleProp)) &&
titleProp.ValueKind == JsonValueKind.String)
{
title = titleProp.GetString();
}
if ((args.TryGetProperty("Description", out var descProp) || args.TryGetProperty("description", out descProp)) &&
descProp.ValueKind == JsonValueKind.String)
{
description = descProp.GetString();
}
if (args.TryGetProperty("IsPrimaryCommandCritical", out var criticalProp) ||
args.TryGetProperty("isPrimaryCommandCritical", out criticalProp))
{
isCritical = criticalProp.ValueKind == JsonValueKind.True;
}
if (connection != null &&
(args.TryGetProperty("PrimaryCommand", out var cmdProp) || args.TryGetProperty("primaryCommand", out cmdProp)) &&
cmdProp.ValueKind == JsonValueKind.Object)
{
primaryCommand = new JSCommandAdapter(cmdProp, connection);
}
return CommandResult.Confirm(new ConfirmationArgs
{
Title = title,
Description = description,
PrimaryCommand = primaryCommand,
IsPrimaryCommandCritical = isCritical,
});
}
}
/// <summary>
/// Custom ICommandResult for GoToPage that carries a pre-resolved IPage reference.
/// This allows UnsafeHandleCommandResult to navigate directly to the page
/// without needing access to the JsonRpcConnection.
/// </summary>
internal sealed class JSGoToPageCommandResult : ICommandResult
{
public CommandResultKind Kind { get; init; }
public ICommandResultArgs? Args { get; init; }
public IPage? Page { get; init; }
}
/// <summary>
/// Adapts JSON content page data to IContentPage interface.
/// </summary>
internal sealed class JSContentPageProxy : IContentPage
{
private readonly string _pageId;
private readonly JsonElement _data;
private readonly JsonRpcConnection _connection;
public JSContentPageProxy(string pageId, JsonElement data, JsonRpcConnection connection)
{
_pageId = pageId;
_data = data;
_connection = connection;
}
public JSContentPageProxy(string pageId, JsonRpcConnection connection)
{
_pageId = pageId;
_data = default;
_connection = connection;
}
public string Name => GetStringProperty("name") ?? string.Empty;
public string Id => _pageId;
public IIconInfo Icon => GetIconInfo();
public string Title => GetStringProperty("title") ?? string.Empty;
public bool IsLoading
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("isLoading", out var prop))
{
return prop.ValueKind == JsonValueKind.True;
}
return false;
}
}
public OptionalColor AccentColor
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("accentColor", out var prop))
{
return JSCommandResultAdapter.ParseOptionalColor(prop);
}
return default;
}
}
public IDetails Details
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("details", out var detailsProp) && detailsProp.ValueKind == JsonValueKind.Object)
{
return new JSDetailsAdapter(detailsProp);
}
return null!;
}
}
public IContextItem[] Commands
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("commands", out var commandsProp) && commandsProp.ValueKind == JsonValueKind.Array)
{
var count = commandsProp.GetArrayLength();
var items = new IContextItem[count];
var i = 0;
foreach (var item in commandsProp.EnumerateArray())
{
items[i++] = new JSContextItemAdapter(item);
}
return items;
}
return [];
}
}
public IContent[] GetContent()
{
try
{
var response = _connection.SendRequestAsync(
"contentPage/getContent",
new JsonObject { ["pageId"] = _pageId },
CancellationToken.None).GetAwaiter().GetResult();
if (response.Error != null)
{
Logger.LogWarning($"Content page getContent error: {response.Error.Message}");
return [];
}
var resultElement = response.Result;
if (resultElement.HasValue &&
resultElement.Value.ValueKind == JsonValueKind.Object &&
resultElement.Value.TryGetProperty("content", out var contentArray))
{
resultElement = contentArray;
}
return JSCommandResultAdapter.ParseContentArray(resultElement, _pageId, _connection);
}
catch (Exception ex)
{
Logger.LogWarning($"Failed to get content for page {_pageId}: {ex.Message}");
return [];
}
}
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged
{
add { }
remove { }
}
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged
{
add { }
remove { }
}
private string? GetStringProperty(string name)
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
{
return prop.GetString();
}
return null;
}
private IIconInfo GetIconInfo()
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("icon", out var iconProp))
{
return JSIconInfoAdapter.FromJson(iconProp);
}
return new IconInfo(string.Empty);
}
}
/// <summary>
/// Adapts JSON markdown content data to IMarkdownContent interface.
/// </summary>
internal sealed class JSMarkdownContentAdapter : IMarkdownContent
{
private readonly JsonElement _data;
public JSMarkdownContentAdapter(JsonElement data)
{
_data = data;
}
public string Body
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("body", out var prop) && prop.ValueKind == JsonValueKind.String)
{
return prop.GetString() ?? string.Empty;
}
return string.Empty;
}
}
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged
{
add { }
remove { }
}
}
/// <summary>
/// Adapts JSON form content data to IFormContent interface.
/// </summary>
internal sealed class JSFormContentProxy : IFormContent
{
private readonly string _pageId;
private readonly JsonElement _data;
private readonly JsonRpcConnection _connection;
public JSFormContentProxy(string pageId, JsonElement data, JsonRpcConnection connection)
{
_pageId = pageId;
_data = data;
_connection = connection;
}
public string TemplateJson
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("templateJson", out var prop) && prop.ValueKind == JsonValueKind.String)
{
return prop.GetString() ?? string.Empty;
}
return string.Empty;
}
}
public string DataJson
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("dataJson", out var prop) && prop.ValueKind == JsonValueKind.String)
{
return prop.GetString() ?? string.Empty;
}
return string.Empty;
}
}
public string StateJson
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("stateJson", out var prop) && prop.ValueKind == JsonValueKind.String)
{
return prop.GetString() ?? string.Empty;
}
return string.Empty;
}
}
public ICommandResult SubmitForm(string inputs, string data)
{
try
{
var response = _connection.SendRequestAsync(
"form/submit",
new JsonObject { ["pageId"] = _pageId, ["inputs"] = inputs, ["data"] = data },
CancellationToken.None).GetAwaiter().GetResult();
if (response.Error != null)
{
Logger.LogWarning($"Form submit error: {response.Error.Message}");
return CommandResult.KeepOpen();
}
return JSCommandResultAdapter.ParseCommandResult(response.Result, _connection);
}
catch (Exception ex)
{
Logger.LogWarning($"Failed to submit form for page {_pageId}: {ex.Message}");
return CommandResult.KeepOpen();
}
}
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged
{
add { }
remove { }
}
}
/// <summary>
/// Adapts JSON tree content data to ITreeContent interface.
/// </summary>
internal sealed class JSTreeContentAdapter : ITreeContent
{
private readonly JsonElement _data;
private readonly string _pageId;
private readonly JsonRpcConnection _connection;
public JSTreeContentAdapter(JsonElement data, string pageId, JsonRpcConnection connection)
{
_data = data;
_pageId = pageId;
_connection = connection;
}
public IContent RootContent
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("rootContent", out var rootProp) && rootProp.ValueKind == JsonValueKind.Object)
{
return JSCommandResultAdapter.ParseContentItem(rootProp, _pageId, _connection);
}
return new JSMarkdownContentAdapter(default);
}
}
public IContent[] GetChildren()
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("children", out var childrenProp) && childrenProp.ValueKind == JsonValueKind.Array)
{
var count = childrenProp.GetArrayLength();
var items = new IContent[count];
var i = 0;
foreach (var child in childrenProp.EnumerateArray())
{
items[i++] = JSCommandResultAdapter.ParseContentItem(child, _pageId, _connection);
}
return items;
}
return [];
}
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged
{
add { }
remove { }
}
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged
{
add { }
remove { }
}
}
/// <summary>
/// Adapts JSON context item data to IContextItem marker interface.
/// </summary>
internal sealed class JSContextItemAdapter : IContextItem
{
private readonly JsonElement _data;
public JSContextItemAdapter(JsonElement data)
{
_data = data;
}
}
/// <summary>
/// Adapts JSON plain text content data to IPlainTextContent interface.
/// </summary>
internal sealed class JSPlainTextContentAdapter : IPlainTextContent
{
private readonly JsonElement _data;
public JSPlainTextContentAdapter(JsonElement data)
{
_data = data;
}
public string Text
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("text", out var prop) && prop.ValueKind == JsonValueKind.String)
{
return prop.GetString() ?? string.Empty;
}
return string.Empty;
}
}
public FontFamily FontFamily
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("fontFamily", out var prop) && prop.ValueKind == JsonValueKind.String)
{
var value = prop.GetString();
return value == "monospace" ? FontFamily.Monospace : FontFamily.UserInterface;
}
return FontFamily.UserInterface;
}
}
public bool WrapWords
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("wrapWords", out var prop))
{
return prop.ValueKind == JsonValueKind.True;
}
return true;
}
}
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged
{
add { }
remove { }
}
}
/// <summary>
/// Adapts JSON image content data to IImageContent interface.
/// </summary>
internal sealed class JSImageContentAdapter : IImageContent
{
private readonly JsonElement _data;
public JSImageContentAdapter(JsonElement data)
{
_data = data;
}
public IIconInfo Image
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("image", out var imageProp))
{
return JSIconInfoAdapter.FromJson(imageProp);
}
return new IconInfo(string.Empty);
}
}
public int MaxWidth
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("maxWidth", out var prop) && prop.ValueKind == JsonValueKind.Number)
{
return prop.GetInt32();
}
return -1; // Unlimited
}
}
public int MaxHeight
{
get
{
if (_data.ValueKind == JsonValueKind.Object &&
_data.TryGetProperty("maxHeight", out var prop) && prop.ValueKind == JsonValueKind.Number)
{
return prop.GetInt32();
}
return -1; // Unlimited
}
}
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged
{
add { }
remove { }
}
}

View File

@@ -0,0 +1,223 @@
// 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.
#pragma warning disable SA1402 // File may only contain a single type - manifest and related types grouped together
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// <summary>
/// Represents the resolved extension manifest, built from a package.json file
/// that contains a "cmdpal" section (similar to VS Code's contributes field).
/// </summary>
public sealed record JSExtensionManifest
{
/// <summary>
/// Gets the internal identifier for the extension (from package.json "name", required).
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Gets the display name shown to users (from cmdpal.displayName).
/// </summary>
public string? DisplayName { get; init; }
/// <summary>
/// Gets the version string (from package.json "version").
/// </summary>
public string? Version { get; init; }
/// <summary>
/// Gets the description of the extension (from package.json "description").
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Gets the icon glyph or relative path (from cmdpal.icon).
/// </summary>
public string? Icon { get; init; }
/// <summary>
/// Gets the entry point script path (cmdpal.main overrides package.json "main", required).
/// </summary>
public string? Main { get; init; }
/// <summary>
/// Gets the publisher or author name (from cmdpal.publisher).
/// </summary>
public string? Publisher { get; init; }
/// <summary>
/// Gets a value indicating whether debug mode is enabled for this extension.
/// When true, the Node.js process is started with --inspect to allow debugger attachment.
/// </summary>
public bool Debug { get; init; }
/// <summary>
/// Gets the port number for the Node.js inspector when debug mode is enabled.
/// If not specified, a default port starting at 9229 is assigned automatically.
/// </summary>
public int? DebugPort { get; init; }
/// <summary>
/// Gets the engine requirements (from package.json "engines").
/// </summary>
public JSExtensionEngines? Engines { get; init; }
/// <summary>
/// Gets the capabilities exposed by the extension (from cmdpal.capabilities).
/// </summary>
public string[]? Capabilities { get; init; }
/// <summary>
/// Validates that required fields are present.
/// </summary>
/// <returns>True if the manifest is valid; otherwise, false.</returns>
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(Name) && !string.IsNullOrWhiteSpace(Main);
}
/// <summary>
/// Loads an extension manifest from a package.json file that contains a "cmdpal" section.
/// </summary>
/// <param name="packageJsonPath">The full path to the package.json file.</param>
/// <returns>The resolved manifest if successful and valid; otherwise, null.</returns>
public static async Task<JSExtensionManifest?> LoadFromFileAsync(string packageJsonPath)
{
if (string.IsNullOrWhiteSpace(packageJsonPath))
{
return null;
}
if (!File.Exists(packageJsonPath))
{
return null;
}
try
{
using var stream = File.OpenRead(packageJsonPath);
var packageJson = await JsonSerializer.DeserializeAsync(
stream,
JSExtensionManifestJsonContext.Default.JSPackageJson);
if (packageJson?.CmdPal is null)
{
return null;
}
var cmdpal = packageJson.CmdPal;
// cmdpal.main overrides top-level main
var entryPoint = !string.IsNullOrWhiteSpace(cmdpal.Main)
? cmdpal.Main
: packageJson.Main;
var manifest = new JSExtensionManifest
{
Name = packageJson.Name,
DisplayName = cmdpal.DisplayName,
Version = packageJson.Version,
Description = packageJson.Description,
Icon = cmdpal.Icon,
Main = entryPoint,
Publisher = cmdpal.Publisher,
Debug = cmdpal.Debug,
DebugPort = cmdpal.DebugPort,
Engines = packageJson.Engines,
Capabilities = cmdpal.Capabilities,
};
if (!manifest.IsValid())
{
return null;
}
return manifest;
}
catch
{
return null;
}
}
}
/// <summary>
/// Represents the top-level package.json structure for extension discovery.
/// </summary>
public sealed record JSPackageJson
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("main")]
public string? Main { get; init; }
[JsonPropertyName("engines")]
public JSExtensionEngines? Engines { get; init; }
[JsonPropertyName("cmdpal")]
public JSCmdPalSection? CmdPal { get; init; }
}
/// <summary>
/// Represents the "cmdpal" section within package.json containing CmdPal-specific metadata.
/// </summary>
public sealed record JSCmdPalSection
{
[JsonPropertyName("displayName")]
public string? DisplayName { get; init; }
[JsonPropertyName("icon")]
public string? Icon { get; init; }
[JsonPropertyName("main")]
public string? Main { get; init; }
[JsonPropertyName("publisher")]
public string? Publisher { get; init; }
[JsonPropertyName("debug")]
public bool Debug { get; init; }
[JsonPropertyName("debugPort")]
public int? DebugPort { get; init; }
[JsonPropertyName("capabilities")]
public string[]? Capabilities { get; init; }
}
/// <summary>
/// Represents the engine requirements for a JavaScript/TypeScript extension.
/// </summary>
public sealed record JSExtensionEngines
{
/// <summary>
/// Gets the Node.js version requirement (e.g., ">=18").
/// </summary>
[JsonPropertyName("node")]
public string? Node { get; init; }
}
/// <summary>
/// JSON serialization context for extension manifest types.
/// </summary>
[JsonSerializable(typeof(JSPackageJson))]
[JsonSerializable(typeof(JSCmdPalSection))]
[JsonSerializable(typeof(JSExtensionManifest))]
[JsonSerializable(typeof(JSExtensionEngines))]
[JsonSerializable(typeof(string[]))]
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)]
internal sealed partial class JSExtensionManifestJsonContext : JsonSerializerContext
{
}

View File

@@ -0,0 +1,559 @@
// 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;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Services.JsonRpc;
using Microsoft.CommandPalette.Extensions;
using Windows.ApplicationModel;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// <summary>
/// Adapter that manages a single Node.js extension process and presents it as an IExtensionWrapper to the CmdPal host.
/// </summary>
public sealed class JSExtensionWrapper : IExtensionWrapper, IDisposable
{
private readonly JSExtensionManifest _manifest;
private readonly string _manifestDirectory;
private readonly Lock _lock = new();
private readonly List<ProviderType> _providerTypes = [];
private static int _nextDebugPort = 9229;
private Process? _nodeProcess;
private JsonRpcConnection? _rpcConnection;
private JSCommandProviderProxy? _commandProviderProxy;
private bool _isDisposed;
private int _consecutiveCrashCount;
/// <summary>
/// Gets the number of times this extension has been restarted (due to crashes or hot-reload).
/// </summary>
public int RestartCount { get; private set; }
/// <summary>
/// Gets a value indicating whether the extension is considered healthy.
/// An extension becomes unhealthy after exceeding 3 consecutive crashes.
/// </summary>
public bool IsHealthy { get; private set; } = true;
/// <summary>
/// Initializes a new instance of the <see cref="JSExtensionWrapper"/> class.
/// </summary>
/// <param name="manifest">The parsed extension manifest.</param>
/// <param name="manifestDirectory">The directory containing the manifest file.</param>
public JSExtensionWrapper(JSExtensionManifest manifest, string manifestDirectory)
{
_manifest = manifest ?? throw new ArgumentNullException(nameof(manifest));
_manifestDirectory = manifestDirectory ?? throw new ArgumentNullException(nameof(manifestDirectory));
if (!_manifest.IsValid())
{
throw new ArgumentException("Invalid manifest", nameof(manifest));
}
// Map manifest capabilities to provider types
var caps = _manifest.Capabilities;
if (caps != null)
{
foreach (var cap in caps)
{
if (string.Equals(cap, "commands", StringComparison.OrdinalIgnoreCase))
{
AddProviderType(ProviderType.Commands);
}
}
}
else
{
// Default: assume commands capability if not specified
AddProviderType(ProviderType.Commands);
}
}
public string PackageDisplayName => _manifest.DisplayName ?? _manifest.Name ?? "Unknown";
public string ExtensionDisplayName => _manifest.DisplayName ?? _manifest.Name ?? "Unknown";
public string PackageFullName => $"js!{_manifest.Name}";
public string PackageFamilyName => $"js!{_manifest.Name}";
public string Publisher => _manifest.Publisher ?? "Unknown";
public string ExtensionClassId
{
get
{
// Generate a deterministic "GUID-like" identifier from the manifest name
if (string.IsNullOrWhiteSpace(_manifest.Name))
{
return "unknown";
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(_manifest.Name));
var hashString = Convert.ToHexString(hash);
return $"js-{hashString[..32]}";
}
}
public DateTimeOffset InstalledDate
{
get
{
try
{
var manifestPath = Path.Combine(_manifestDirectory, "package.json");
if (File.Exists(manifestPath))
{
return File.GetCreationTimeUtc(manifestPath);
}
}
catch
{
// Fallback if file operations fail
}
return DateTimeOffset.UtcNow;
}
}
public PackageVersion Version
{
get
{
if (string.IsNullOrWhiteSpace(_manifest.Version))
{
return new PackageVersion { Major = 1, Minor = 0, Build = 0, Revision = 0 };
}
var parts = _manifest.Version.Split('.');
return new PackageVersion
{
Major = parts.Length > 0 && ushort.TryParse(parts[0], out var major) ? major : (ushort)1,
Minor = parts.Length > 1 && ushort.TryParse(parts[1], out var minor) ? minor : (ushort)0,
Build = parts.Length > 2 && ushort.TryParse(parts[2], out var build) ? build : (ushort)0,
Revision = 0,
};
}
}
public string ExtensionUniqueId => $"js!{_manifest.Name}";
public bool IsRunning()
{
lock (_lock)
{
if (_nodeProcess == null || _rpcConnection == null)
{
return false;
}
try
{
return !_nodeProcess.HasExited;
}
catch
{
return false;
}
}
}
public async Task StartExtensionAsync()
{
ObjectDisposedException.ThrowIf(_isDisposed, this);
lock (_lock)
{
if (_nodeProcess != null && _rpcConnection != null)
{
try
{
if (!_nodeProcess.HasExited)
{
return; // Already running
}
}
catch
{
// Process handle invalid — fall through to restart
}
}
}
Logger.LogDebug($"Starting JS extension {_manifest.DisplayName ?? _manifest.Name}");
try
{
var entryPoint = Path.Combine(_manifestDirectory, _manifest.Main ?? string.Empty);
// Retry up to 5 times with 1s backoff — the entry point file may
// still be in flight when the directory watcher fires.
for (var attempt = 0; attempt < 5; attempt++)
{
if (File.Exists(entryPoint))
{
break;
}
if (attempt == 4)
{
Logger.LogError($"Entry point not found after retries: {entryPoint}");
return;
}
await Task.Delay(1000).ConfigureAwait(false);
}
var psi = new ProcessStartInfo
{
FileName = "node",
Arguments = BuildNodeArguments(entryPoint),
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
WorkingDirectory = _manifestDirectory,
};
var nodeProcess = Process.Start(psi);
if (nodeProcess == null)
{
Logger.LogError($"Failed to start Node.js process for {_manifest.Name}");
return;
}
// Drain stderr asynchronously to prevent the process from blocking
// if the stderr pipe buffer fills up
nodeProcess.ErrorDataReceived += (_, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
Logger.LogDebug($"[{_manifest.Name} stderr] {e.Data}");
}
};
nodeProcess.BeginErrorReadLine();
var loggerAdapter = new LoggerAdapter();
var rpcConnection = new JsonRpcConnection(nodeProcess, loggerAdapter);
rpcConnection.OnError += ex => Logger.LogError($"JSON-RPC error in {_manifest.Name}: {ex.Message}");
rpcConnection.OnDisconnected += HandleDisconnection;
// Store the process and connection BEFORE awaiting initialize so
// IsRunning() reflects the actual process state immediately.
lock (_lock)
{
_nodeProcess = nodeProcess;
_rpcConnection = rpcConnection;
}
nodeProcess.Exited += (_, _) => HandleDisconnection();
nodeProcess.EnableRaisingEvents = true;
rpcConnection.StartListening();
// Send initialize request to the extension
var initResponse = await rpcConnection.SendRequestAsync(
"initialize",
new JsonObject { ["extensionId"] = _manifest.Name },
CancellationToken.None).ConfigureAwait(false);
if (initResponse.Error != null)
{
Logger.LogError($"Initialization failed for {_manifest.Name}: {initResponse.Error.Message}");
SignalDispose();
return;
}
// Extension started and initialized successfully — reset crash tracking
ResetCrashCount();
Logger.LogInfo($"Successfully started JS extension {_manifest.DisplayName ?? _manifest.Name}");
}
catch (Exception ex)
{
Logger.LogError($"Failed to start JS extension {_manifest.Name}: {ex.Message}");
SignalDispose();
}
}
public void SignalDispose()
{
lock (_lock)
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
try
{
if (_rpcConnection != null && IsRunning())
{
// Send dispose notification (fire-and-forget)
_rpcConnection.SendNotificationAsync("dispose", null, CancellationToken.None).Wait(TimeSpan.FromSeconds(2));
}
}
catch (Exception ex)
{
Logger.LogWarning($"Error sending dispose notification to {_manifest.Name}: {ex.Message}");
}
try
{
_rpcConnection?.Dispose();
}
catch
{
// Best effort
}
try
{
if (_nodeProcess != null && !_nodeProcess.HasExited)
{
_nodeProcess.Kill(entireProcessTree: true);
_nodeProcess.WaitForExit(2000);
}
_nodeProcess?.Dispose();
}
catch (Exception ex)
{
Logger.LogWarning($"Error terminating Node.js process for {_manifest.Name}: {ex.Message}");
}
_nodeProcess = null;
_rpcConnection = null;
_commandProviderProxy = null;
}
}
public void Dispose()
{
SignalDispose();
}
public IExtension? GetExtensionObject()
{
// JS extensions don't have COM objects - the wrapper itself is the bridge
return null;
}
public void AddProviderType(ProviderType providerType)
{
lock (_lock)
{
if (!_providerTypes.Contains(providerType))
{
_providerTypes.Add(providerType);
}
}
}
public bool HasProviderType(ProviderType providerType)
{
lock (_lock)
{
return _providerTypes.Contains(providerType);
}
}
public async Task<T?> GetProviderAsync<T>()
where T : class
{
if (typeof(T) != typeof(ICommandProvider))
{
return null;
}
await StartExtensionAsync().ConfigureAwait(false);
lock (_lock)
{
if (!IsRunning() || _rpcConnection == null)
{
return null;
}
if (_commandProviderProxy == null)
{
_commandProviderProxy = new JSCommandProviderProxy(_rpcConnection, _manifest);
}
return _commandProviderProxy as T;
}
}
public async Task<IEnumerable<T>> GetListOfProvidersAsync<T>()
where T : class
{
var provider = await GetProviderAsync<T>().ConfigureAwait(false);
if (provider != null)
{
return new[] { provider };
}
return [];
}
private void HandleDisconnection()
{
lock (_lock)
{
if (!_isDisposed)
{
_consecutiveCrashCount++;
Logger.LogWarning($"Node.js process for {_manifest.Name} disconnected unexpectedly (crash #{_consecutiveCrashCount})");
if (_consecutiveCrashCount > 3)
{
IsHealthy = false;
Logger.LogError($"JS extension {_manifest.Name} disabled after {_consecutiveCrashCount} consecutive crashes");
}
_nodeProcess = null;
_rpcConnection = null;
_commandProviderProxy = null;
}
}
}
/// <summary>
/// Resets the consecutive crash counter. Call after a successful operation to indicate the extension is stable.
/// </summary>
internal void ResetCrashCount()
{
lock (_lock)
{
_consecutiveCrashCount = 0;
}
}
/// <summary>
/// Stops the running Node.js process and starts a fresh one.
/// Used by hot-reload to restart the extension after source file changes.
/// </summary>
internal async Task RestartAsync()
{
lock (_lock)
{
if (_isDisposed || !IsHealthy)
{
return;
}
}
Logger.LogInfo($"Restarting JS extension {_manifest.DisplayName ?? _manifest.Name}");
// Gracefully shut down the current process
lock (_lock)
{
try
{
if (_rpcConnection != null && IsRunning())
{
_rpcConnection.SendNotificationAsync("dispose", null, CancellationToken.None).Wait(TimeSpan.FromSeconds(2));
}
}
catch (Exception ex)
{
Logger.LogWarning($"Error sending dispose during restart of {_manifest.Name}: {ex.Message}");
}
try
{
_rpcConnection?.Dispose();
}
catch
{
// Best effort
}
try
{
if (_nodeProcess != null && !_nodeProcess.HasExited)
{
_nodeProcess.Kill(entireProcessTree: true);
_nodeProcess.WaitForExit(2000);
}
_nodeProcess?.Dispose();
}
catch (Exception ex)
{
Logger.LogWarning($"Error terminating Node.js process during restart of {_manifest.Name}: {ex.Message}");
}
_nodeProcess = null;
_rpcConnection = null;
_commandProviderProxy = null;
_isDisposed = false;
}
RestartCount++;
await StartExtensionAsync().ConfigureAwait(false);
}
/// <summary>
/// Gets the manifest directory path for this extension.
/// </summary>
internal string ManifestDirectory => _manifestDirectory;
private string BuildNodeArguments(string entryPoint)
{
if (_manifest.Debug)
{
var port = _manifest.DebugPort ?? Interlocked.Increment(ref _nextDebugPort);
var debugUrl = $"chrome-devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:{port}";
Logger.LogInfo($"Debug mode enabled for {_manifest.Name} on port {port}. Attach debugger at: {debugUrl}");
return $"--inspect={port} \"{entryPoint}\"";
}
return $"\"{entryPoint}\"";
}
private sealed class LoggerAdapter : Microsoft.Extensions.Logging.ILogger
{
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(
Microsoft.Extensions.Logging.LogLevel logLevel,
Microsoft.Extensions.Logging.EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
var message = formatter(state, exception);
switch (logLevel)
{
case Microsoft.Extensions.Logging.LogLevel.Error:
case Microsoft.Extensions.Logging.LogLevel.Critical:
Logger.LogError(message);
break;
case Microsoft.Extensions.Logging.LogLevel.Warning:
Logger.LogWarning(message);
break;
case Microsoft.Extensions.Logging.LogLevel.Information:
Logger.LogInfo(message);
break;
default:
Logger.LogDebug(message);
break;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -120,7 +120,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
ProviderSettings = s.ProviderSettings.SetItem(_provider.ProviderId, newSettings),
});
_providerSettings = newSettings;
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
WeakReferenceMessenger.Default.Send<ProviderEnabledStateChangedMessage>(new(_provider.ProviderId, value));
OnPropertyChanged(nameof(IsEnabled));
OnPropertyChanged(nameof(ExtensionSubtext));
OnPropertyChanged(nameof(Icon));

View File

@@ -0,0 +1,80 @@
// 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.CommandPalette.Extensions;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// Extension service that manages in-process built-in command providers
/// registered in the DI container as <see cref="ICommandProvider"/>.
/// </summary>
public sealed class BuiltInExtensionService : IExtensionService
{
private readonly IEnumerable<ICommandProvider> _commandProviders;
private readonly TaskScheduler _taskScheduler;
private readonly List<IExtensionWrapper> _wrappers = [];
#pragma warning disable CS0067 // Events are required by the interface but not raised by this implementation
public event TypedEventHandler<IExtensionService, IEnumerable<CommandProviderWrapper>>? OnProviderAdded;
public event TypedEventHandler<IExtensionService, IEnumerable<CommandProviderWrapper>>? OnProviderRemoved;
#pragma warning restore CS0067
public BuiltInExtensionService(IEnumerable<ICommandProvider> commandProviders, TaskScheduler taskScheduler)
{
_commandProviders = commandProviders;
_taskScheduler = taskScheduler;
}
public Task<IEnumerable<CommandProviderWrapper>> LoadProvidersAsync(CancellationToken ct)
{
if (ct.IsCancellationRequested)
{
return Task.FromResult<IEnumerable<CommandProviderWrapper>>([]);
}
var wrappers = new List<CommandProviderWrapper>();
foreach (var provider in _commandProviders)
{
wrappers.Add(new CommandProviderWrapper(provider, _taskScheduler));
}
return Task.FromResult<IEnumerable<CommandProviderWrapper>>(wrappers);
}
public Task SignalStopAsync()
{
// Built-in providers are in-proc and don't need explicit stop signaling.
return Task.CompletedTask;
}
public Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsync(bool includeDisabledExtensions = false)
{
return Task.FromResult<IEnumerable<IExtensionWrapper>>(_wrappers);
}
public Task<IEnumerable<IExtensionWrapper>> RefreshInstalledExtensionsAsync(bool includeDisabledExtensions = false)
{
// Built-in set is fixed at startup; refresh is a no-op.
return GetInstalledExtensionsAsync(includeDisabledExtensions);
}
public IExtensionWrapper? GetInstalledExtension(string extensionUniqueId)
{
return _wrappers.FirstOrDefault(w => w.ExtensionUniqueId == extensionUniqueId);
}
public void EnableExtension(string extensionUniqueId)
{
// Nothing to do here. We're built-in extensions.
}
public void DisableExtension(string extensionUniqueId)
{
// Nothing to do here. We're built-in extensions.
}
}

View File

@@ -0,0 +1,48 @@
// 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;
using Microsoft.CmdPal.Common.Services;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// Represents the outcome of attempting to start a single WinRT extension.
/// </summary>
internal sealed class ExtensionStartResult
{
public IExtensionWrapper Extension { get; }
public CommandProviderWrapper? Wrapper { get; private init; }
public Task? PendingStartTask { get; private init; }
public Stopwatch? Stopwatch { get; private init; }
[System.Diagnostics.CodeAnalysis.MemberNotNullWhen(true, nameof(Wrapper))]
public bool IsStarted => Wrapper is not null;
[System.Diagnostics.CodeAnalysis.MemberNotNullWhen(true, nameof(PendingStartTask), nameof(Stopwatch))]
public bool IsTimedOut => PendingStartTask is not null;
private ExtensionStartResult(IExtensionWrapper extension)
{
Extension = extension;
}
public static ExtensionStartResult Started(IExtensionWrapper extension, CommandProviderWrapper wrapper)
{
return new ExtensionStartResult(extension) { Wrapper = wrapper };
}
public static ExtensionStartResult TimedOut(IExtensionWrapper extension, Task pendingStartTask, Stopwatch sw)
{
return new ExtensionStartResult(extension) { PendingStartTask = pendingStartTask, Stopwatch = sw };
}
public static ExtensionStartResult Failed(IExtensionWrapper extension)
{
return new ExtensionStartResult(extension);
}
}

View File

@@ -0,0 +1,67 @@
// 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 Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
public interface IExtensionService
{
/// <summary>
/// Loads command providers managed by this service. Returns providers that
/// are immediately ready. Slow or late providers arrive via <see cref="OnProviderAdded"/>.
/// </summary>
/// <param name="ct">Cancellation token owned by the caller to cancel in-flight loading.</param>
/// <returns>Command provider wrappers that are started and ready for command loading.</returns>
Task<IEnumerable<CommandProviderWrapper>> LoadProvidersAsync(CancellationToken ct);
/// <summary>
/// Signals running providers managed by this service to stop/dispose.
/// </summary>
Task SignalStopAsync();
/// <summary>
/// Gets the currently cached installed extensions managed by this service.
/// </summary>
/// <param name="includeDisabledExtensions">True to include disabled extensions in the result.</param>
/// <returns>A sequence of installed extensions from the current in-memory cache.</returns>
Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsync(bool includeDisabledExtensions = false);
/// <summary>
/// Forces a fresh scan of installed 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 extensions after the cache has been rebuilt.</returns>
Task<IEnumerable<IExtensionWrapper>> RefreshInstalledExtensionsAsync(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>
/// 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>
/// Raised when one or more providers become available (late start, new package install, etc.).
/// </summary>
event TypedEventHandler<IExtensionService, IEnumerable<CommandProviderWrapper>>? OnProviderAdded;
/// <summary>
/// Raised when one or more providers are removed (package uninstall, etc.).
/// </summary>
event TypedEventHandler<IExtensionService, IEnumerable<CommandProviderWrapper>>? OnProviderRemoved;
}

View File

@@ -0,0 +1,488 @@
// 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;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// Extension service that manages JavaScript/TypeScript extensions running as individual Node.js processes.
/// Each extension gets its own process communicating over JSON-RPC 2.0 via stdio with LSP-style framing.
/// Supports hot-reload (via FileSystemWatcher), crash recovery, and debug attachment.
/// </summary>
public sealed class JavaScriptExtensionService : IExtensionService, IDisposable
{
private static readonly string ExtensionsPath = GetDefaultExtensionsPath();
private readonly TaskScheduler _taskScheduler;
private readonly Lock _extensionsLock = new();
private readonly List<JSExtensionWrapper> _extensions = [];
private readonly List<CommandProviderWrapper> _providerWrappers = [];
private readonly HashSet<string> _disabledExtensions = [];
private readonly Lock _sourceWatcherLock = new();
private readonly Dictionary<string, FileSystemWatcher> _sourceFileWatchers = [];
private readonly Dictionary<string, Timer> _debounceTimers = [];
private FileSystemWatcher? _directoryWatcher;
private bool _disposed;
#pragma warning disable CS0067 // Events are required by the interface but not raised by this implementation yet
public event TypedEventHandler<IExtensionService, IEnumerable<CommandProviderWrapper>>? OnProviderAdded;
public event TypedEventHandler<IExtensionService, IEnumerable<CommandProviderWrapper>>? OnProviderRemoved;
#pragma warning restore CS0067
public JavaScriptExtensionService(TaskScheduler taskScheduler)
{
_taskScheduler = taskScheduler;
}
public async Task<IEnumerable<CommandProviderWrapper>> LoadProvidersAsync(CancellationToken ct)
{
if (ct.IsCancellationRequested)
{
return [];
}
var sw = Stopwatch.StartNew();
if (!Directory.Exists(ExtensionsPath))
{
try
{
Directory.CreateDirectory(ExtensionsPath);
Logger.LogDebug($"Created JS extensions directory: {ExtensionsPath}");
}
catch (Exception ex)
{
Logger.LogError($"Failed to create JS extensions directory {ExtensionsPath}: {ex.Message}");
return [];
}
}
var wrappers = await DiscoverAndLoadExtensionsAsync(ExtensionsPath, ct).ConfigureAwait(false);
StartDirectoryWatcher();
sw.Stop();
Logger.LogInfo($"JavaScriptExtensionService: Loaded {wrappers.Count} extension(s) in {sw.ElapsedMilliseconds} ms");
return wrappers;
}
public Task SignalStopAsync()
{
StopDirectoryWatcher();
StopAllSourceFileWatchers();
lock (_extensionsLock)
{
foreach (var ext in _extensions)
{
try
{
ext.SignalDispose();
}
catch (Exception ex)
{
Logger.LogError($"Failed to stop JS extension {ext.ExtensionDisplayName}: {ex.Message}");
}
}
_extensions.Clear();
_providerWrappers.Clear();
}
return Task.CompletedTask;
}
public Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsync(bool includeDisabledExtensions = false)
{
lock (_extensionsLock)
{
if (includeDisabledExtensions)
{
return Task.FromResult<IEnumerable<IExtensionWrapper>>(_extensions.ToList());
}
return Task.FromResult<IEnumerable<IExtensionWrapper>>(
_extensions.Where(e => !_disabledExtensions.Contains(e.ExtensionUniqueId)).ToList());
}
}
public async Task<IEnumerable<IExtensionWrapper>> RefreshInstalledExtensionsAsync(bool includeDisabledExtensions = false)
{
// Re-scan the directory for any new extensions
if (Directory.Exists(ExtensionsPath))
{
var subdirs = Directory.GetDirectories(ExtensionsPath);
foreach (var subdir in subdirs)
{
var manifestPath = Path.Combine(subdir, "package.json");
if (!File.Exists(manifestPath))
{
continue;
}
// Skip if already loaded
var dirName = Path.GetFileName(subdir);
bool alreadyLoaded;
lock (_extensionsLock)
{
alreadyLoaded = _extensions.Any(e => e.ManifestDirectory == subdir);
}
if (!alreadyLoaded)
{
await LoadExtensionFromDirectoryAsync(subdir, CancellationToken.None).ConfigureAwait(false);
}
}
}
return await GetInstalledExtensionsAsync(includeDisabledExtensions).ConfigureAwait(false);
}
public IExtensionWrapper? GetInstalledExtension(string extensionUniqueId)
{
lock (_extensionsLock)
{
return _extensions.FirstOrDefault(e => e.ExtensionUniqueId == extensionUniqueId);
}
}
public void EnableExtension(string extensionUniqueId)
{
_disabledExtensions.Remove(extensionUniqueId);
}
public void DisableExtension(string extensionUniqueId)
{
_disabledExtensions.Add(extensionUniqueId);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
StopDirectoryWatcher();
StopAllSourceFileWatchers();
lock (_extensionsLock)
{
foreach (var ext in _extensions)
{
ext.Dispose();
}
_extensions.Clear();
_providerWrappers.Clear();
}
}
private async Task<List<CommandProviderWrapper>> DiscoverAndLoadExtensionsAsync(string extensionsPath, CancellationToken ct)
{
var wrappers = new List<CommandProviderWrapper>();
if (!Directory.Exists(extensionsPath))
{
return wrappers;
}
var subdirectories = Directory.GetDirectories(extensionsPath);
foreach (var subdir in subdirectories)
{
if (ct.IsCancellationRequested)
{
break;
}
var wrapper = await LoadExtensionFromDirectoryAsync(subdir, ct).ConfigureAwait(false);
if (wrapper != null)
{
wrappers.Add(wrapper);
}
}
return wrappers;
}
private async Task<CommandProviderWrapper?> LoadExtensionFromDirectoryAsync(string extensionDirectory, CancellationToken ct)
{
var manifestPath = Path.Combine(extensionDirectory, "package.json");
if (!File.Exists(manifestPath))
{
return null;
}
try
{
var manifest = await JSExtensionManifest.LoadFromFileAsync(manifestPath).ConfigureAwait(false);
if (manifest == null)
{
Logger.LogWarning($"Invalid manifest at {manifestPath}");
return null;
}
var extensionWrapper = new JSExtensionWrapper(manifest, extensionDirectory);
lock (_extensionsLock)
{
_extensions.Add(extensionWrapper);
}
await extensionWrapper.StartExtensionAsync().ConfigureAwait(false);
if (!extensionWrapper.IsRunning())
{
Logger.LogError($"Failed to start JS extension {manifest.DisplayName ?? manifest.Name}");
return null;
}
var provider = await extensionWrapper.GetProviderAsync<ICommandProvider>().ConfigureAwait(false);
if (provider == null)
{
Logger.LogWarning($"JS extension {manifest.DisplayName ?? manifest.Name} does not provide ICommandProvider");
return null;
}
var wrapper = new CommandProviderWrapper(extensionWrapper, provider, _taskScheduler);
lock (_extensionsLock)
{
_providerWrappers.Add(wrapper);
}
Logger.LogInfo($"Loaded JS extension: {manifest.DisplayName ?? manifest.Name}");
// Start source file watcher for hot-reload in dev mode
StartSourceFileWatcher(extensionWrapper, extensionDirectory);
return wrapper;
}
catch (Exception ex)
{
Logger.LogError($"Failed to load JS extension from {extensionDirectory}: {ex.Message}");
return null;
}
}
private void StartDirectoryWatcher()
{
if (!Directory.Exists(ExtensionsPath))
{
return;
}
try
{
_directoryWatcher = new FileSystemWatcher(ExtensionsPath)
{
NotifyFilter = NotifyFilters.DirectoryName,
EnableRaisingEvents = true,
};
_directoryWatcher.Created += OnExtensionDirectoryCreated;
_directoryWatcher.Deleted += OnExtensionDirectoryDeleted;
Logger.LogDebug($"Started directory watcher for {ExtensionsPath}");
}
catch (Exception ex)
{
Logger.LogError($"Failed to start directory watcher for {ExtensionsPath}: {ex.Message}");
}
}
private void StopDirectoryWatcher()
{
if (_directoryWatcher != null)
{
_directoryWatcher.Created -= OnExtensionDirectoryCreated;
_directoryWatcher.Deleted -= OnExtensionDirectoryDeleted;
_directoryWatcher.Dispose();
_directoryWatcher = null;
}
}
private void OnExtensionDirectoryCreated(object sender, FileSystemEventArgs e)
{
_ = Task.Run(async () =>
{
// Small delay to let files finish copying
await Task.Delay(500).ConfigureAwait(false);
var wrapper = await LoadExtensionFromDirectoryAsync(e.FullPath, CancellationToken.None).ConfigureAwait(false);
if (wrapper != null)
{
OnProviderAdded?.Invoke(this, [wrapper]);
}
});
}
private void OnExtensionDirectoryDeleted(object sender, FileSystemEventArgs e)
{
_ = Task.Run(async () =>
{
await RemoveExtensionByDirectoryAsync(e.FullPath).ConfigureAwait(false);
});
}
private Task RemoveExtensionByDirectoryAsync(string directoryPath)
{
JSExtensionWrapper? extensionToRemove = null;
CommandProviderWrapper? wrapperToRemove = null;
lock (_extensionsLock)
{
extensionToRemove = _extensions.FirstOrDefault(e => e.ManifestDirectory == directoryPath);
if (extensionToRemove != null)
{
_extensions.Remove(extensionToRemove);
wrapperToRemove = _providerWrappers.FirstOrDefault(w => w.Extension == extensionToRemove);
if (wrapperToRemove != null)
{
_providerWrappers.Remove(wrapperToRemove);
}
}
}
if (extensionToRemove != null)
{
StopSourceFileWatcher(directoryPath);
extensionToRemove.SignalDispose();
if (wrapperToRemove != null)
{
OnProviderRemoved?.Invoke(this, [wrapperToRemove]);
}
}
return Task.CompletedTask;
}
private void StartSourceFileWatcher(JSExtensionWrapper extensionWrapper, string extensionDirectory)
{
try
{
var watcher = new FileSystemWatcher(extensionDirectory, "*.js")
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName,
IncludeSubdirectories = true,
EnableRaisingEvents = true,
};
watcher.Changed += (sender, e) => OnSourceFileChanged(extensionWrapper, extensionDirectory, e);
watcher.Created += (sender, e) => OnSourceFileChanged(extensionWrapper, extensionDirectory, e);
lock (_sourceWatcherLock)
{
_sourceFileWatchers[extensionDirectory] = watcher;
}
Logger.LogDebug($"Started source file watcher for {extensionWrapper.ExtensionDisplayName} at {extensionDirectory}");
}
catch (Exception ex)
{
Logger.LogError($"Failed to start source file watcher for {extensionWrapper.ExtensionDisplayName}: {ex.Message}");
}
}
private void StopSourceFileWatcher(string extensionDirectory)
{
lock (_sourceWatcherLock)
{
if (_sourceFileWatchers.TryGetValue(extensionDirectory, out var watcher))
{
watcher.EnableRaisingEvents = false;
watcher.Dispose();
_sourceFileWatchers.Remove(extensionDirectory);
}
if (_debounceTimers.TryGetValue(extensionDirectory, out var timer))
{
timer.Dispose();
_debounceTimers.Remove(extensionDirectory);
}
}
}
private void StopAllSourceFileWatchers()
{
lock (_sourceWatcherLock)
{
foreach (var kvp in _sourceFileWatchers)
{
kvp.Value.EnableRaisingEvents = false;
kvp.Value.Dispose();
}
_sourceFileWatchers.Clear();
foreach (var kvp in _debounceTimers)
{
kvp.Value.Dispose();
}
_debounceTimers.Clear();
}
}
private void OnSourceFileChanged(JSExtensionWrapper extensionWrapper, string extensionDirectory, FileSystemEventArgs e)
{
// Skip node_modules changes
if (e.FullPath.Contains("node_modules", StringComparison.OrdinalIgnoreCase))
{
return;
}
lock (_sourceWatcherLock)
{
if (_debounceTimers.TryGetValue(extensionDirectory, out var existingTimer))
{
existingTimer.Dispose();
}
_debounceTimers[extensionDirectory] = new Timer(
_ => _ = Task.Run(() => RestartExtensionAsync(extensionWrapper)),
null,
500,
Timeout.Infinite);
}
}
private async Task RestartExtensionAsync(JSExtensionWrapper extensionWrapper)
{
if (!extensionWrapper.IsHealthy)
{
Logger.LogWarning($"Skipping hot-reload for unhealthy extension {extensionWrapper.ExtensionDisplayName}");
return;
}
Logger.LogInfo($"Hot-reload: restarting {extensionWrapper.ExtensionDisplayName}");
await extensionWrapper.RestartAsync().ConfigureAwait(false);
if (!extensionWrapper.IsRunning())
{
Logger.LogError($"Hot-reload failed: {extensionWrapper.ExtensionDisplayName} did not restart");
return;
}
Logger.LogInfo($"Hot-reload completed for {extensionWrapper.ExtensionDisplayName}");
}
private static string GetDefaultExtensionsPath()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(localAppData, "Microsoft", "PowerToys", "CommandPalette", "JSExtensions");
}
}

View File

@@ -0,0 +1,366 @@
// 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.
#pragma warning disable CA1848 // Use LoggerMessage delegates - this is a low-level protocol class using ILogger directly
#pragma warning disable CA1873 // Logging argument evaluation
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Microsoft.CmdPal.UI.ViewModels.Services.JsonRpc;
/// <summary>
/// Manages JSON-RPC 2.0 communication with a Node.js child process over stdin/stdout using LSP-style length-prefixed framing.
/// </summary>
public sealed class JsonRpcConnection : IDisposable
{
private readonly Process _process;
private readonly ILogger _logger;
private readonly ConcurrentDictionary<int, TaskCompletionSource<JsonRpcResponse>> _pendingRequests = new();
private readonly ConcurrentDictionary<string, Action<JsonElement>> _notificationHandlers = new();
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly Lock _idLock = new();
private readonly CancellationTokenSource _disposalCts = new();
private readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(10);
private int _nextRequestId = 1;
private Task? _readLoopTask;
private bool _disposed;
/// <summary>
/// Raised when an error occurs during reading or processing messages.
/// </summary>
public event Action<Exception>? OnError;
/// <summary>
/// Raised when the connection is disconnected (process exit or stream close).
/// </summary>
public event Action? OnDisconnected;
/// <summary>
/// Initializes a new instance of the <see cref="JsonRpcConnection"/> class.
/// </summary>
/// <param name="process">The Node.js child process to communicate with.</param>
/// <param name="logger">Logger for diagnostics.</param>
public JsonRpcConnection(Process process, ILogger logger)
{
_process = process ?? throw new ArgumentNullException(nameof(process));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Starts listening for messages from the process stdout on a background thread.
/// </summary>
public void StartListening()
{
if (_readLoopTask != null)
{
throw new InvalidOperationException("Already listening");
}
_readLoopTask = Task.Run(ReadLoopAsync, _disposalCts.Token);
}
/// <summary>
/// Sends a JSON-RPC request and waits for the response.
/// </summary>
/// <param name="method">The method name to invoke.</param>
/// <param name="params">Optional parameters for the method.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The JSON-RPC response.</returns>
public async Task<JsonRpcResponse> SendRequestAsync(string method, JsonNode? @params, CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
int id;
lock (_idLock)
{
id = _nextRequestId++;
}
var request = new JsonRpcRequest
{
Id = id,
Method = method,
Params = @params,
};
var tcs = new TaskCompletionSource<JsonRpcResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingRequests[id] = tcs;
try
{
await SendMessageAsync(request, ct).ConfigureAwait(false);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposalCts.Token);
timeoutCts.CancelAfter(_defaultTimeout);
using (timeoutCts.Token.Register(() => tcs.TrySetCanceled()))
{
return await tcs.Task.ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
_pendingRequests.TryRemove(id, out _);
throw;
}
}
/// <summary>
/// Sends a JSON-RPC notification (no response expected).
/// </summary>
/// <param name="method">The method name.</param>
/// <param name="params">Optional parameters.</param>
/// <param name="ct">Cancellation token.</param>
public async Task SendNotificationAsync(string method, JsonNode? @params, CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var notification = new JsonRpcNotification
{
Method = method,
Params = @params,
};
await SendMessageAsync(notification, ct).ConfigureAwait(false);
}
/// <summary>
/// Registers a handler for incoming notifications of a specific method.
/// </summary>
/// <param name="method">The notification method name.</param>
/// <param name="handler">The handler to invoke when the notification is received.</param>
public void RegisterNotificationHandler(string method, Action<JsonElement> handler)
{
_notificationHandlers[method] = handler;
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_disposalCts.Cancel();
try
{
_readLoopTask?.Wait(TimeSpan.FromSeconds(2));
}
catch
{
// Best effort
}
foreach (var kvp in _pendingRequests)
{
kvp.Value.TrySetCanceled();
}
_pendingRequests.Clear();
_writeLock.Dispose();
_disposalCts.Dispose();
}
private async Task SendMessageAsync(object message, CancellationToken ct)
{
var json = message switch
{
JsonRpcRequest req => JsonSerializer.Serialize(req, JsonRpcSerializerContext.Default.JsonRpcRequest),
JsonRpcNotification notif => JsonSerializer.Serialize(notif, JsonRpcSerializerContext.Default.JsonRpcNotification),
_ => throw new ArgumentException("Invalid message type", nameof(message)),
};
var contentBytes = Encoding.UTF8.GetBytes(json);
var header = $"Content-Length: {contentBytes.Length}\r\n\r\n";
var headerBytes = Encoding.ASCII.GetBytes(header);
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
var stdin = _process.StandardInput.BaseStream;
await stdin.WriteAsync(headerBytes, ct).ConfigureAwait(false);
await stdin.WriteAsync(contentBytes, ct).ConfigureAwait(false);
await stdin.FlushAsync(ct).ConfigureAwait(false);
_logger.LogDebug("Sent {MessageType}: {Json}", message.GetType().Name, json);
}
finally
{
_writeLock.Release();
}
}
private async Task ReadLoopAsync()
{
try
{
var stdout = _process.StandardOutput.BaseStream;
var headerBuffer = new byte[1024];
var contentBuffer = new byte[65536];
while (!_disposalCts.Token.IsCancellationRequested && !_process.HasExited)
{
var contentLength = await ReadContentLengthAsync(stdout, headerBuffer, _disposalCts.Token).ConfigureAwait(false);
if (contentLength <= 0)
{
_logger.LogWarning("Read loop: contentLength={ContentLength}, exiting loop", contentLength);
break;
}
// Ensure buffer is large enough
if (contentLength > contentBuffer.Length)
{
contentBuffer = new byte[contentLength];
}
// Read exactly contentLength bytes
var totalRead = 0;
while (totalRead < contentLength)
{
var read = await stdout.ReadAsync(contentBuffer.AsMemory(totalRead, contentLength - totalRead), _disposalCts.Token).ConfigureAwait(false);
if (read == 0)
{
_logger.LogWarning("Stream closed before reading full message");
return;
}
totalRead += read;
}
var json = Encoding.UTF8.GetString(contentBuffer, 0, contentLength);
_logger.LogDebug("Received message: {Json}", json);
ProcessMessage(json);
}
}
catch (OperationCanceledException)
{
// Expected during disposal
_logger.LogInformation("Read loop exiting due to cancellation");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in read loop");
OnError?.Invoke(ex);
}
finally
{
_logger.LogInformation("Read loop ended");
OnDisconnected?.Invoke();
}
}
private async Task<int> ReadContentLengthAsync(Stream stream, byte[] buffer, CancellationToken ct)
{
var position = 0;
var headerComplete = false;
while (!headerComplete && position < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(position, 1), ct).ConfigureAwait(false);
if (read == 0)
{
return -1; // Stream closed
}
position++;
// Check for \r\n\r\n (end of headers)
if (position >= 4 &&
buffer[position - 4] == '\r' &&
buffer[position - 3] == '\n' &&
buffer[position - 2] == '\r' &&
buffer[position - 1] == '\n')
{
headerComplete = true;
}
}
if (!headerComplete)
{
_logger.LogError("Header too large or malformed");
return -1;
}
var headerText = Encoding.ASCII.GetString(buffer, 0, position);
var lines = headerText.Split(["\r\n"], StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
{
var valueStr = line.Substring(15).Trim();
if (int.TryParse(valueStr, out var length))
{
return length;
}
}
}
_logger.LogError("Content-Length header not found");
return -1;
}
private void ProcessMessage(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.Number)
{
// This is a response
var id = idProp.GetInt32();
var response = JsonSerializer.Deserialize(json, JsonRpcSerializerContext.Default.JsonRpcResponse);
if (response != null && _pendingRequests.TryRemove(id, out var tcs))
{
tcs.SetResult(response);
}
else
{
_logger.LogWarning("Received response for unknown request ID: {Id}", id);
}
}
else if (root.TryGetProperty("method", out var methodProp))
{
// This is a notification
var method = methodProp.GetString() ?? string.Empty;
if (_notificationHandlers.TryGetValue(method, out var handler))
{
var paramsElement = root.TryGetProperty("params", out var p) ? p : default;
handler(paramsElement);
}
else
{
_logger.LogDebug("No handler registered for notification: {Method}", method);
}
}
else
{
_logger.LogWarning("Received message with neither id nor method");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing message: {Json}", json);
OnError?.Invoke(ex);
}
}
}

View File

@@ -0,0 +1,102 @@
// 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.
#pragma warning disable SA1402 // File may only contain a single type - closely related message types grouped together
#pragma warning disable SA1649 // File name should match first type name
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels.Services.JsonRpc;
/// <summary>
/// Represents a JSON-RPC 2.0 request message.
/// </summary>
public sealed class JsonRpcRequest
{
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; set; } = "2.0";
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("method")]
public string Method { get; set; } = string.Empty;
[JsonPropertyName("params")]
public JsonNode? Params { get; set; }
}
/// <summary>
/// Represents a JSON-RPC 2.0 response message.
/// </summary>
public sealed class JsonRpcResponse
{
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; set; } = "2.0";
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("result")]
public JsonElement? Result { get; set; }
[JsonPropertyName("error")]
public JsonRpcError? Error { get; set; }
}
/// <summary>
/// Represents a JSON-RPC 2.0 notification message (no id, no response expected).
/// </summary>
public sealed class JsonRpcNotification
{
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; set; } = "2.0";
[JsonPropertyName("method")]
public string Method { get; set; } = string.Empty;
[JsonPropertyName("params")]
public JsonNode? Params { get; set; }
}
/// <summary>
/// Represents a JSON-RPC 2.0 error object.
/// </summary>
public sealed class JsonRpcError
{
[JsonPropertyName("code")]
public int Code { get; set; }
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
[JsonPropertyName("data")]
public object? Data { get; set; }
// Standard JSON-RPC 2.0 error codes
public const int ParseError = -32700;
public const int InvalidRequest = -32600;
public const int MethodNotFound = -32601;
public const int InvalidParams = -32602;
public const int InternalError = -32603;
}
/// <summary>
/// JSON source generation context for AOT-safe serialization.
/// </summary>
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(JsonRpcRequest))]
[JsonSerializable(typeof(JsonRpcResponse))]
[JsonSerializable(typeof(JsonRpcNotification))]
[JsonSerializable(typeof(JsonRpcError))]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(JsonObject))]
internal partial class JsonRpcSerializerContext : JsonSerializerContext
{
}

View File

@@ -2,28 +2,34 @@
// 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.Immutable;
using System.Diagnostics;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Windows.ApplicationModel;
using Windows.ApplicationModel.AppExtensions;
using Windows.Foundation;
using Windows.Foundation.Collections;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
public partial class ExtensionService : IExtensionService, IDisposable
/// <summary>
/// Extension service that manages out-of-process WinRT AppExtension-based command providers.
/// Handles package catalog monitoring, extension startup with timeouts, and background retries.
/// </summary>
public partial class WinRTExtensionService : IExtensionService, IDisposable
{
public event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionAdded;
public event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionRemoved;
private static readonly TimeSpan ExtensionStartTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan BackgroundStartTimeout = TimeSpan.FromSeconds(60);
private static readonly PackageCatalog _catalog = PackageCatalog.OpenForCurrentUser();
private static readonly Lock _lock = new();
private readonly SemaphoreSlim _getInstalledExtensionsLock = new(1, 1);
private readonly SemaphoreSlim _getInstalledWidgetsLock = new(1, 1);
private readonly TaskScheduler _taskScheduler;
private readonly ICommandProviderCache _commandProviderCache;
// private readonly ILocalSettingsService _localSettingsService;
private bool _disposedValue;
private const string CreateInstanceProperty = "CreateInstance";
@@ -32,17 +38,119 @@ public partial class ExtensionService : IExtensionService, IDisposable
private static readonly List<IExtensionWrapper> _installedExtensions = [];
private static readonly List<IExtensionWrapper> _enabledExtensions = [];
public ExtensionService()
public event TypedEventHandler<IExtensionService, IEnumerable<CommandProviderWrapper>>? OnProviderAdded;
public event TypedEventHandler<IExtensionService, IEnumerable<CommandProviderWrapper>>? OnProviderRemoved;
public WinRTExtensionService(TaskScheduler taskScheduler, ICommandProviderCache commandProviderCache)
{
_taskScheduler = taskScheduler;
_commandProviderCache = commandProviderCache;
_catalog.PackageInstalling += Catalog_PackageInstalling;
_catalog.PackageUninstalling += Catalog_PackageUninstalling;
_catalog.PackageUpdating += Catalog_PackageUpdating;
}
//// These two were an investigation into getting updates when a package
//// gets redeployed from VS. Neither get raised (nor do the above)
//// _catalog.PackageStatusChanged += Catalog_PackageStatusChanged;
//// _catalog.PackageStaging += Catalog_PackageStaging;
// _localSettingsService = settingsService;
public async Task<IEnumerable<CommandProviderWrapper>> LoadProvidersAsync(CancellationToken ct)
{
var extensions = (await GetInstalledExtensionsAsync().ConfigureAwait(false)).ToImmutableList();
var timer = Stopwatch.StartNew();
// Start all extensions in parallel
var startResults = await Task.WhenAll(extensions.Select(ext => TryStartExtensionAsync(ext, ct))).ConfigureAwait(false);
var startedWrappers = new List<CommandProviderWrapper>();
foreach (var r in startResults)
{
if (r.IsStarted)
{
startedWrappers.Add(r.Wrapper);
}
else if (r.IsTimedOut)
{
_ = StartExtensionWhenReadyAsync(r.Extension, r.PendingStartTask, r.Stopwatch, ct);
}
}
timer.Stop();
Logger.LogInfo($"WinRTExtensionService: Started {startedWrappers.Count} extension(s) in {timer.ElapsedMilliseconds} ms");
return startedWrappers;
}
public async Task SignalStopAsync()
{
var installedExtensions = await GetInstalledExtensionsAsync().ConfigureAwait(false);
foreach (var installedExtension in installedExtensions)
{
Logger.LogDebug($"Signaling dispose to {installedExtension.ExtensionUniqueId}");
try
{
if (installedExtension.IsRunning())
{
installedExtension.SignalDispose();
}
}
catch (Exception ex)
{
Logger.LogError($"Failed to send dispose signal to extension {installedExtension.ExtensionUniqueId}", ex);
}
}
}
private async Task<ExtensionStartResult> TryStartExtensionAsync(IExtensionWrapper extension, CancellationToken ct)
{
Logger.LogDebug($"Starting {extension.PackageFullName}");
var sw = Stopwatch.StartNew();
var startTask = extension.StartExtensionAsync();
try
{
await startTask.WaitAsync(ExtensionStartTimeout, ct).ConfigureAwait(false);
Logger.LogInfo($"Started extension {extension.PackageFullName} in {sw.ElapsedMilliseconds} ms");
return ExtensionStartResult.Started(extension, new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache));
}
catch (TimeoutException)
{
Logger.LogWarning($"Starting extension {extension.PackageFullName} timed out after {sw.ElapsedMilliseconds} ms, continuing in background");
return ExtensionStartResult.TimedOut(extension, startTask, sw);
}
catch (OperationCanceledException)
{
Logger.LogDebug($"Starting extension {extension.PackageFullName} was cancelled after {sw.ElapsedMilliseconds} ms");
return ExtensionStartResult.Failed(extension);
}
catch (Exception ex)
{
Logger.LogError($"Failed to start extension {extension.PackageFullName} after {sw.ElapsedMilliseconds} ms: {ex}");
return ExtensionStartResult.Failed(extension);
}
}
private async Task StartExtensionWhenReadyAsync(
IExtensionWrapper extension,
Task startTask,
Stopwatch sw,
CancellationToken ct)
{
try
{
await startTask.WaitAsync(BackgroundStartTimeout, ct).ConfigureAwait(false);
var wrapper = new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache);
Logger.LogInfo($"Late-started extension {extension.PackageFullName} in {sw.ElapsedMilliseconds} ms");
OnProviderAdded?.Invoke(this, [wrapper]);
}
catch (OperationCanceledException)
{
// Reload happened -- discard stale results
}
catch (Exception ex)
{
Logger.LogError($"Background start of extension {extension.PackageFullName} failed after {sw.ElapsedMilliseconds} ms: {ex}");
}
}
private void Catalog_PackageInstalling(PackageCatalog sender, PackageInstallingEventArgs args)
@@ -73,10 +181,7 @@ public partial class ExtensionService : IExtensionService, IDisposable
{
lock (_lock)
{
// Get any extension providers that we previously had from this app
UninstallPackageUnderLock(args.TargetPackage);
// then add the new ones.
InstallPackageUnderLock(args.TargetPackage);
}
}
@@ -103,7 +208,25 @@ public partial class ExtensionService : IExtensionService, IDisposable
UpdateExtensionsListsFromWrappers(wrappers);
OnExtensionAdded?.Invoke(this, wrappers);
// Start extensions and notify via OnProviderAdded
var startedProviders = new List<CommandProviderWrapper>();
foreach (var wrapper in wrappers)
{
try
{
await wrapper.StartExtensionAsync().ConfigureAwait(false);
startedProviders.Add(new CommandProviderWrapper(wrapper, _taskScheduler, _commandProviderCache));
}
catch (Exception ex)
{
Logger.LogError($"Failed to start newly installed extension {wrapper.ExtensionUniqueId}: {ex}");
}
}
if (startedProviders.Count > 0)
{
OnProviderAdded?.Invoke(this, startedProviders);
}
}
finally
{
@@ -121,7 +244,6 @@ public partial class ExtensionService : IExtensionService, IDisposable
if (extension.PackageFullName == package.Id.FullName)
{
CommandPaletteHost.Instance.DebugLog($"Uninstalled extension app {extension.PackageDisplayName}");
removedExtensions.Add(extension);
}
}
@@ -134,7 +256,24 @@ public partial class ExtensionService : IExtensionService, IDisposable
_installedExtensions.RemoveAll(i => removedExtensions.Contains(i));
_enabledExtensions.RemoveAll(i => removedExtensions.Contains(i));
OnExtensionRemoved?.Invoke(this, removedExtensions);
// Build placeholder wrappers for removal notification.
// The TopLevelCommandManager matches by Extension reference, so we need to pass
// something that carries the IExtensionWrapper identity.
var removedProviders = new List<CommandProviderWrapper>();
foreach (var ext in removedExtensions)
{
try
{
removedProviders.Add(new CommandProviderWrapper(ext, _taskScheduler, _commandProviderCache));
}
catch
{
// Extension may not be in a runnable state if it was uninstalled;
// we still need to signal removal
}
}
OnProviderRemoved?.Invoke(this, removedProviders);
}
finally
{
@@ -143,52 +282,6 @@ public partial class ExtensionService : IExtensionService, IDisposable
});
}
private static async Task<IsExtensionResult> IsValidCmdPalExtension(Package package)
{
var extensions = await AppExtensionCatalog.Open("com.microsoft.commandpalette").FindAllAsync();
foreach (var extension in extensions)
{
if (package.Id?.FullName == extension.Package?.Id?.FullName)
{
var (cmdPalProvider, classId) = await GetCmdPalExtensionPropertiesAsync(extension);
return new(cmdPalProvider is not null && classId.Count != 0, extension);
}
}
return new(false, null);
}
private static async Task<(IPropertySet? CmdPalProvider, List<string> ClassIds)> GetCmdPalExtensionPropertiesAsync(AppExtension extension)
{
var classIds = new List<string>();
var properties = await extension.GetExtensionPropertiesAsync();
if (properties is null)
{
return (null, classIds);
}
var cmdPalProvider = GetSubPropertySet(properties, "CmdPalProvider");
if (cmdPalProvider is null)
{
return (null, classIds);
}
var activation = GetSubPropertySet(cmdPalProvider, "Activation");
if (activation is null)
{
return (cmdPalProvider, classIds);
}
// Handle case where extension creates multiple instances.
classIds.AddRange(GetCreateInstanceList(activation));
return (cmdPalProvider, classIds);
}
private static async Task<IEnumerable<AppExtension>> GetInstalledAppExtensionsAsync() => await AppExtensionCatalog.Open("com.microsoft.commandpalette").FindAllAsync();
public async Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsync(bool includeDisabledExtensions = false)
{
await _getInstalledExtensionsLock.WaitAsync();
@@ -215,25 +308,22 @@ public partial class ExtensionService : IExtensionService, IDisposable
}
}
private static void UpdateExtensionsListsFromWrappers(List<ExtensionWrapper> wrappers)
public IExtensionWrapper? GetInstalledExtension(string extensionUniqueId)
{
foreach (var extensionWrapper in wrappers)
{
// var localSettingsService = Application.Current.GetService<ILocalSettingsService>();
var extensionUniqueId = extensionWrapper.ExtensionUniqueId;
var isExtensionDisabled = false; // await localSettingsService.ReadSettingAsync<bool>(extensionUniqueId + "-ExtensionDisabled");
var extension = _installedExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal));
return extension.FirstOrDefault();
}
_installedExtensions.Add(extensionWrapper);
if (!isExtensionDisabled)
{
_enabledExtensions.Add(extensionWrapper);
}
public void EnableExtension(string extensionUniqueId)
{
var extension = _installedExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal));
_enabledExtensions.Add(extension.First());
}
// TelemetryFactory.Get<ITelemetry>().Log(
// "Extension_ReportInstalled",
// LogLevel.Critical,
// new ReportInstalledExtensionEvent(extensionUniqueId, isEnabled: !isExtensionDisabled));
}
public void DisableExtension(string extensionUniqueId)
{
var extension = _enabledExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal));
_enabledExtensions.Remove(extension.First());
}
private static async Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsyncUnderLock(bool includeDisabledExtensions, bool refresh)
@@ -295,6 +385,8 @@ public partial class ExtensionService : IExtensionService, IDisposable
}
}
private static async Task<IEnumerable<AppExtension>> GetInstalledAppExtensionsAsync() => await AppExtensionCatalog.Open("com.microsoft.commandpalette").FindAllAsync();
private static async Task<List<ExtensionWrapper>> CreateWrappersForExtension(AppExtension extension)
{
var (cmdPalProvider, classIds) = await GetCmdPalExtensionPropertiesAsync(extension);
@@ -337,7 +429,6 @@ public partial class ExtensionService : IExtensionService, IDisposable
}
else
{
// log warning that extension declared unsupported extension interface
CommandPaletteHost.Instance.DebugLog($"Extension {extension.DisplayName} declared an unsupported interface: {supportedInterface.Key}");
}
}
@@ -346,77 +437,68 @@ public partial class ExtensionService : IExtensionService, IDisposable
return extensionWrapper;
}
public IExtensionWrapper? GetInstalledExtension(string extensionUniqueId)
private static void UpdateExtensionsListsFromWrappers(List<ExtensionWrapper> wrappers)
{
var extension = _installedExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal));
return extension.FirstOrDefault();
}
public async Task SignalStopExtensionsAsync()
{
var installedExtensions = await GetInstalledExtensionsAsync();
foreach (var installedExtension in installedExtensions)
foreach (var extensionWrapper in wrappers)
{
Logger.LogDebug($"Signaling dispose to {installedExtension.ExtensionUniqueId}");
try
var extensionUniqueId = extensionWrapper.ExtensionUniqueId;
var isExtensionDisabled = false;
_installedExtensions.Add(extensionWrapper);
if (!isExtensionDisabled)
{
if (installedExtension.IsRunning())
{
installedExtension.SignalDispose();
}
}
catch (Exception ex)
{
Logger.LogError($"Failed to send dispose signal to extension {installedExtension.ExtensionUniqueId}", ex);
_enabledExtensions.Add(extensionWrapper);
}
}
}
public async Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsync(ProviderType providerType, bool includeDisabledExtensions = false)
private static async Task<IsExtensionResult> IsValidCmdPalExtension(Package package)
{
var installedExtensions = await GetInstalledExtensionsAsync(includeDisabledExtensions);
List<IExtensionWrapper> filteredExtensions = [];
foreach (var installedExtension in installedExtensions)
var extensions = await AppExtensionCatalog.Open("com.microsoft.commandpalette").FindAllAsync();
foreach (var extension in extensions)
{
if (installedExtension.HasProviderType(providerType))
if (package.Id?.FullName == extension.Package?.Id?.FullName)
{
filteredExtensions.Add(installedExtension);
var (cmdPalProvider, classId) = await GetCmdPalExtensionPropertiesAsync(extension);
return new(cmdPalProvider is not null && classId.Count != 0, extension);
}
}
return filteredExtensions;
return new(false, null);
}
public void Dispose()
private static async Task<(IPropertySet? CmdPalProvider, List<string> ClassIds)> GetCmdPalExtensionPropertiesAsync(AppExtension extension)
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
var classIds = new List<string>();
var properties = await extension.GetExtensionPropertiesAsync();
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
if (properties is null)
{
if (disposing)
{
_getInstalledExtensionsLock.Dispose();
_getInstalledWidgetsLock.Dispose();
}
_disposedValue = true;
return (null, classIds);
}
var cmdPalProvider = GetSubPropertySet(properties, "CmdPalProvider");
if (cmdPalProvider is null)
{
return (null, classIds);
}
var activation = GetSubPropertySet(cmdPalProvider, "Activation");
if (activation is null)
{
return (cmdPalProvider, classIds);
}
classIds.AddRange(GetCreateInstanceList(activation));
return (cmdPalProvider, classIds);
}
private static IPropertySet? GetSubPropertySet(IPropertySet propSet, string name) => propSet.TryGetValue(name, out var value) ? value as IPropertySet : null;
private static object[]? GetSubPropertySetArray(IPropertySet propSet, string name) => propSet.TryGetValue(name, out var value) ? value as object[] : null;
/// <summary>
/// There are cases where the extension creates multiple COM instances.
/// </summary>
/// <param name="activationPropSet">Activation property set object</param>
/// <returns>List of ClassId strings associated with the activation property</returns>
private static List<string> GetCreateInstanceList(IPropertySet activationPropSet)
{
var propSetList = new List<string>();
@@ -424,8 +506,6 @@ public partial class ExtensionService : IExtensionService, IDisposable
if (singlePropertySet is not null)
{
var classId = GetProperty(singlePropertySet, ClassIdProperty);
// If the instance has a classId as a single string, then it's only supporting a single instance.
if (classId is not null)
{
propSetList.Add(classId);
@@ -457,35 +537,24 @@ public partial class ExtensionService : IExtensionService, IDisposable
private static string? GetProperty(IPropertySet propSet, string name) => propSet[name] as string;
public void EnableExtension(string extensionUniqueId)
public void Dispose()
{
var extension = _installedExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal));
_enabledExtensions.Add(extension.First());
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
public void DisableExtension(string extensionUniqueId)
private void Dispose(bool disposing)
{
var extension = _enabledExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal));
_enabledExtensions.Remove(extension.First());
}
if (!_disposedValue)
{
if (disposing)
{
_getInstalledExtensionsLock.Dispose();
}
/*
///// <inheritdoc cref="IExtensionService.DisableExtensionIfWindowsFeatureNotAvailable(IExtensionWrapper)"/>
//public async Task<bool> DisableExtensionIfWindowsFeatureNotAvailable(IExtensionWrapper extension)
//{
// // Only attempt to disable feature if its available.
// if (IsWindowsOptionalFeatureAvailableForExtension(extension.ExtensionClassId))
// {
// return false;
// }
// _log.Warning($"Disabling extension: '{extension.ExtensionDisplayName}' because its feature is absent or unknown");
// // Remove extension from list of enabled extensions to prevent Dev Home from re-querying for this extension
// // for the rest of its process lifetime.
// DisableExtension(extension.ExtensionUniqueId);
// // Update the local settings so the next time the user launches Dev Home the extension will be disabled.
// await _localSettingsService.SaveSettingAsync(extension.ExtensionUniqueId + "-ExtensionDisabled", true);
// return true;
//} */
_disposedValue = true;
}
}
}
internal record struct IsExtensionResult(bool IsExtension, AppExtension? Extension)

View File

@@ -489,6 +489,34 @@ public partial class ShellViewModel : ObservableObject,
UnsafeHandleCommandResult(a.Result, onBeforeShowConfirmation);
}
break;
}
case CommandResultKind.GoToPage:
{
if (result is JSGoToPageCommandResult jsGoToPage && jsGoToPage.Page != null)
{
// Handle NavigationMode before navigating to the target page
if (jsGoToPage.Args is IGoToPageArgs goToPageArgs)
{
switch (goToPageArgs.NavigationMode)
{
case NavigationMode.GoBack:
GoBack();
break;
case NavigationMode.GoHome:
GoHome(withAnimation: false, focusSearch: false);
break;
case NavigationMode.Push:
default:
break;
}
}
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(
new(new ExtensionObject<ICommand>(jsGoToPage.Page)));
}
break;
}
}

View File

@@ -2,7 +2,6 @@
// 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.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
@@ -11,7 +10,6 @@ using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CommandPalette.Extensions;
@@ -22,22 +20,20 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class TopLevelCommandManager : ObservableObject,
IRecipient<ReloadCommandsMessage>,
IRecipient<ProviderEnabledStateChangedMessage>,
IRecipient<PinCommandItemMessage>,
IRecipient<UnpinCommandItemMessage>,
IRecipient<PinToDockMessage>,
IDisposable
{
private static readonly TimeSpan ExtensionStartTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan CommandLoadTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan BackgroundStartTimeout = TimeSpan.FromSeconds(60);
private static readonly TimeSpan BackgroundCommandLoadTimeout = TimeSpan.FromSeconds(60);
private readonly IServiceProvider _serviceProvider;
private readonly ICommandProviderCache _commandProviderCache;
private readonly IEnumerable<IExtensionService> _extensionServices;
private readonly TaskScheduler _taskScheduler;
private readonly List<CommandProviderWrapper> _builtInCommands = [];
private readonly List<CommandProviderWrapper> _extensionCommandProviders = [];
private readonly List<CommandProviderWrapper> _commandProviders = [];
private readonly Lock _commandProvidersLock = new();
// watch out: if you add code that locks CommandProviders, be sure to always
@@ -50,18 +46,25 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
private HashSet<(string ProviderId, string CommandId)> _pinnedCommandSet = [];
public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache)
public TopLevelCommandManager(IServiceProvider serviceProvider, IEnumerable<IExtensionService> extensionServices)
{
_serviceProvider = serviceProvider;
_commandProviderCache = commandProviderCache;
_extensionServices = extensionServices;
_currentExtensionLoadCancellationToken = _extensionLoadCts.Token;
_taskScheduler = _serviceProvider.GetService<TaskScheduler>()!;
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
WeakReferenceMessenger.Default.Register<ProviderEnabledStateChangedMessage>(this);
WeakReferenceMessenger.Default.Register<PinCommandItemMessage>(this);
WeakReferenceMessenger.Default.Register<UnpinCommandItemMessage>(this);
WeakReferenceMessenger.Default.Register<PinToDockMessage>(this);
_reloadCommandsGate = new(ReloadAllCommandsAsyncCore);
RebuildPinnedCache();
foreach (var service in _extensionServices)
{
service.OnProviderAdded += ExtensionService_OnProviderAdded;
service.OnProviderRemoved += ExtensionService_OnProviderRemoved;
}
}
public ObservableCollection<PinnedCommandSettings> PinnedCommands { get; } = [];
@@ -84,7 +87,7 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
{
lock (_commandProvidersLock)
{
return _builtInCommands.Concat(_extensionCommandProviders).ToList();
return _commandProviders.ToList();
}
}
}
@@ -101,58 +104,6 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
ListHelpers.InPlaceUpdateList(PinnedCommands, settings.PinnedCommands);
}
public async Task<bool> LoadBuiltinsAsync()
{
var s = new Stopwatch();
s.Start();
lock (_commandProvidersLock)
{
_builtInCommands.Clear();
}
// Load built-In commands first. These are all in-proc, and
// owned by our ServiceProvider.
var builtInCommands = _serviceProvider.GetServices<ICommandProvider>();
foreach (var provider in builtInCommands)
{
CommandProviderWrapper wrapper = new(provider, _taskScheduler);
lock (_commandProvidersLock)
{
_builtInCommands.Add(wrapper);
}
var objects = await LoadTopLevelCommandsFromProvider(wrapper);
lock (TopLevelCommands)
{
if (objects.Commands is IEnumerable<TopLevelViewModel> commands)
{
foreach (var c in commands)
{
TopLevelCommands.Add(c);
}
}
}
lock (_dockBandsLock)
{
if (objects.DockBands is IEnumerable<TopLevelViewModel> bands)
{
foreach (var c in bands)
{
DockBands.Add(c);
}
}
}
}
s.Stop();
Logger.LogDebug($"Loading built-ins took {s.ElapsedMilliseconds}ms");
return true;
}
// May be called from a background thread
private async Task<TopLevelObjectSets> LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider)
{
@@ -288,125 +239,197 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
{
// gate ensures that the reload is serialized and if multiple calls
// request a reload, only the first and the last one will be executed.
// this should be superseded with a cancellable version.
await _reloadCommandsGate.ExecuteAsync(CancellationToken.None);
}
/// <summary>
/// Loads only built-in (in-process) command providers. This is fast and
/// suitable for the initial pre-load phase so the UI appears immediately.
/// </summary>
public async Task LoadBuiltInProvidersAsync()
{
var ct = _currentExtensionLoadCancellationToken;
foreach (var service in _extensionServices.OfType<BuiltInExtensionService>())
{
var wrappers = await service.LoadProvidersAsync(ct).ConfigureAwait(false);
await RegisterAndLoadCommandsAsync(wrappers, ct).ConfigureAwait(false);
}
}
/// <summary>
/// Loads external (out-of-process WinRT) command providers. Call this after
/// the root page is displayed so the UI is not blocked by extension startup.
/// Commands appear progressively via <see cref="IExtensionService.OnProviderAdded"/>.
/// </summary>
[RelayCommand]
public async Task LoadExternalProvidersAsync()
{
IsLoading = true;
try
{
var ct = _currentExtensionLoadCancellationToken;
foreach (var service in _extensionServices.Where(s => s is not BuiltInExtensionService))
{
var wrappers = await service.LoadProvidersAsync(ct).ConfigureAwait(false);
await RegisterAndLoadCommandsAsync(wrappers, ct).ConfigureAwait(false);
}
}
finally
{
IsLoading = false;
}
}
private async Task ReloadAllCommandsAsyncCore(CancellationToken cancellationToken)
{
IsLoading = true;
// Invalidate any background continuations from the previous load cycle
await _extensionLoadCts.CancelAsync().ConfigureAwait(false);
_extensionLoadCts.Dispose();
_extensionLoadCts = new();
_currentExtensionLoadCancellationToken = _extensionLoadCts.Token;
var extensionService = _serviceProvider.GetService<IExtensionService>()!;
await extensionService.SignalStopExtensionsAsync().ConfigureAwait(false);
lock (TopLevelCommands)
try
{
TopLevelCommands.Clear();
}
// Invalidate any background continuations from the previous load cycle
await _extensionLoadCts.CancelAsync().ConfigureAwait(false);
_extensionLoadCts.Dispose();
_extensionLoadCts = new();
_currentExtensionLoadCancellationToken = _extensionLoadCts.Token;
lock (_dockBandsLock)
{
DockBands.Clear();
}
await LoadBuiltinsAsync().ConfigureAwait(false);
_ = Task.Run(LoadExtensionsAsync, cancellationToken);
}
// Load commands from our extensions. Called on a background thread.
// Currently, this
// * queries the package catalog,
// * starts all the extensions,
// * then fetches the top-level commands from them.
// TODO In the future, we'll probably abstract some of this away, to have
// separate extension tracking vs stub loading.
[RelayCommand]
public async Task<bool> LoadExtensionsAsync()
{
var extensionService = _serviceProvider.GetService<IExtensionService>()!;
extensionService.OnExtensionAdded -= ExtensionService_OnExtensionAdded;
extensionService.OnExtensionRemoved -= ExtensionService_OnExtensionRemoved;
var ct = _currentExtensionLoadCancellationToken;
var extensions = (await extensionService.GetInstalledExtensionsAsync().ConfigureAwait(false)).ToImmutableList();
lock (_commandProvidersLock)
{
_extensionCommandProviders.Clear();
}
await StartExtensionsAndGetCommands(extensions, ct).ConfigureAwait(false);
extensionService.OnExtensionAdded += ExtensionService_OnExtensionAdded;
extensionService.OnExtensionRemoved += ExtensionService_OnExtensionRemoved;
IsLoading = false;
// Send on the current thread; receivers should marshal to UI if needed
WeakReferenceMessenger.Default.Send<ReloadFinishedMessage>();
return true;
}
private void ExtensionService_OnExtensionAdded(IExtensionService sender, IEnumerable<IExtensionWrapper> extensions)
{
var ct = _currentExtensionLoadCancellationToken;
// When we get an extension install event, hop off to a BG thread
_ = Task.Run(
async () =>
// Signal all services to stop their running providers
foreach (var service in _extensionServices)
{
// for each newly installed extension, start it and get commands
// from it. One single package might have more than one
// IExtensionWrapper in it.
await StartExtensionsAndGetCommands(extensions, ct).ConfigureAwait(false);
},
ct);
}
private async Task StartExtensionsAndGetCommands(IEnumerable<IExtensionWrapper> extensions, CancellationToken ct)
{
var timer = Stopwatch.StartNew();
// Start all extensions in parallel
var startResults = await Task.WhenAll(extensions.Select(TryStartExtensionAsync)).ConfigureAwait(false);
var startedWrappers = new List<CommandProviderWrapper>();
foreach (var r in startResults)
{
if (r.IsStarted)
{
startedWrappers.Add(r.Wrapper);
await service.SignalStopAsync().ConfigureAwait(false);
}
else if (r.IsTimedOut)
lock (TopLevelCommands)
{
_ = StartExtensionWhenReadyAsync(r.Extension, r.PendingStartTask, r.Stopwatch, ct);
TopLevelCommands.Clear();
}
lock (_dockBandsLock)
{
DockBands.Clear();
}
lock (_commandProvidersLock)
{
_commandProviders.Clear();
}
var ct = _currentExtensionLoadCancellationToken;
// Load providers from each service sequentially (order matters: built-ins first)
foreach (var service in _extensionServices)
{
var wrappers = await service.LoadProvidersAsync(ct).ConfigureAwait(false);
await RegisterAndLoadCommandsAsync(wrappers, ct).ConfigureAwait(false);
}
}
// Register started extensions and load their commands
var loadSummary = await RegisterAndLoadCommandsAsync(startedWrappers, ct).ConfigureAwait(false);
timer.Stop();
Logger.LogInfo($"Loaded {loadSummary.CommandCount} command(s) and {loadSummary.DockBandCount} band(s) from {startedWrappers.Count} extension(s) in {timer.ElapsedMilliseconds} ms");
finally
{
IsLoading = false;
WeakReferenceMessenger.Default.Send<ReloadFinishedMessage>();
}
}
private async Task<RegisterAndLoadSummary> RegisterAndLoadCommandsAsync(ICollection<CommandProviderWrapper> wrappers, CancellationToken ct)
private async Task UpdateProviderEnabledStateAsyncCore(string providerId, bool isEnabled)
{
IsLoading = true;
try
{
// If disabled, we'll remove that providers commands from top level commands, dock bands, and pinned commands.
if (!isEnabled)
{
lock (TopLevelCommands)
{
var commandsToRemove = TopLevelCommands.Where(c => c.CommandProviderId == providerId).ToList();
foreach (var command in commandsToRemove)
{
TopLevelCommands.Remove(command);
}
}
lock (_dockBandsLock)
{
var dockBandsToRemove = DockBands.Where(b => b.CommandProviderId == providerId).ToList();
foreach (var band in dockBandsToRemove)
{
DockBands.Remove(band);
}
}
lock (PinnedCommands)
{
var pinnedToRemove = PinnedCommands.Where(p => p.ProviderId == providerId).ToList();
foreach (var command in pinnedToRemove)
{
PinnedCommands.Remove(command);
}
}
}
else
{
CommandProviderWrapper? provider;
lock (_commandProvidersLock)
{
provider = _commandProviders.FirstOrDefault(p => p.ProviderId == providerId);
}
if (provider != null)
{
await provider.LoadTopLevelCommands(_serviceProvider);
lock (TopLevelCommands)
{
foreach (var command in provider.TopLevelItems)
{
if (!TopLevelCommands.Any(a => a.Id == command.Id))
{
TopLevelCommands.Add(command);
}
}
foreach (var item in provider.FallbackItems)
{
if (!TopLevelCommands.Any(a => a.Id == item.Id) && item.IsEnabled)
{
TopLevelCommands.Add(item);
}
}
}
lock (_dockBandsLock)
{
foreach (var band in provider.DockBandItems)
{
if (!DockBands.Any(a => a.Id == band.Id))
{
DockBands.Add(band);
}
}
}
}
else
{
Logger.LogWarning($"Could not find provider with id '{providerId}' to update enabled state.");
return;
}
}
}
finally
{
IsLoading = false;
}
}
private async Task<RegisterAndLoadSummary> RegisterAndLoadCommandsAsync(IEnumerable<CommandProviderWrapper> wrappers, CancellationToken ct)
{
var wrapperList = wrappers.ToList();
lock (_commandProvidersLock)
{
_extensionCommandProviders.AddRange(wrappers);
_commandProviders.AddRange(wrapperList);
}
// Load the commands from the providers in parallel
var loadResults = await Task.WhenAll(wrappers.Select(w => TryLoadCommandsAsync(w, ct))).ConfigureAwait(false);
var loadResults = await Task.WhenAll(wrapperList.Select(w => TryLoadCommandsAsync(w, ct))).ConfigureAwait(false);
var totalCommands = 0;
var totalDockBands = 0;
@@ -463,7 +486,6 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
// Fire background continuations for timed-out loads outside the lock
foreach (var r in timedOut)
{
// It's weird to repeat the condition here, but it allows the compiler to track nullability of other properties
if (r.IsTimedOut)
{
_ = AppendCommandsWhenReadyAsync(r.Wrapper, r.PendingLoadTask, r.Stopwatch, ct);
@@ -473,60 +495,6 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
return new RegisterAndLoadSummary(totalCommands, totalDockBands);
}
private async Task<ExtensionStartResult> TryStartExtensionAsync(IExtensionWrapper extension)
{
Logger.LogDebug($"Starting {extension.PackageFullName}");
var sw = Stopwatch.StartNew();
var ct = _currentExtensionLoadCancellationToken;
var startTask = extension.StartExtensionAsync();
try
{
await startTask.WaitAsync(ExtensionStartTimeout, ct).ConfigureAwait(false);
Logger.LogInfo($"Started extension {extension.PackageFullName} in {sw.ElapsedMilliseconds} ms");
return ExtensionStartResult.Started(extension, new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache));
}
catch (TimeoutException)
{
Logger.LogWarning($"Starting extension {extension.PackageFullName} timed out after {sw.ElapsedMilliseconds} ms, continuing in background");
return ExtensionStartResult.TimedOut(extension, startTask, sw);
}
catch (OperationCanceledException)
{
Logger.LogDebug($"Starting extension {extension.PackageFullName} was cancelled after {sw.ElapsedMilliseconds} ms");
return ExtensionStartResult.Failed(extension);
}
catch (Exception ex)
{
Logger.LogError($"Failed to start extension {extension.PackageFullName} after {sw.ElapsedMilliseconds} ms: {ex}");
return ExtensionStartResult.Failed(extension);
}
}
private async Task StartExtensionWhenReadyAsync(
IExtensionWrapper extension,
Task startTask,
Stopwatch sw,
CancellationToken ct)
{
try
{
await startTask.WaitAsync(BackgroundStartTimeout, ct).ConfigureAwait(false);
var wrapper = new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache);
Logger.LogInfo($"Late-started extension {extension.PackageFullName} in {sw.ElapsedMilliseconds} ms, loading commands and bands");
await RegisterAndLoadCommandsAsync([wrapper], ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Reload happened -- discard stale results
}
catch (Exception ex)
{
Logger.LogError($"Background start/load of extension {extension.PackageFullName} failed after {sw.ElapsedMilliseconds} ms: {ex}");
}
}
private async Task<CommandLoadResult> TryLoadCommandsAsync(CommandProviderWrapper wrapper, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
@@ -536,22 +504,22 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
var result = await loadTask.WaitAsync(CommandLoadTimeout, ct).ConfigureAwait(false);
var commandCount = result.Commands?.Count ?? 0;
var dockBandCount = result.DockBands?.Count ?? 0;
Logger.LogInfo($"Loaded {commandCount} command(s) and {dockBandCount} band(s) from {wrapper.ExtensionHost?.Extension?.PackageFullName} in {sw.ElapsedMilliseconds} ms");
Logger.LogInfo($"Loaded {commandCount} command(s) and {dockBandCount} band(s) from {wrapper.ExtensionHost?.Extension?.PackageFullName ?? wrapper.DisplayName} in {sw.ElapsedMilliseconds} ms");
return CommandLoadResult.Loaded(wrapper, result);
}
catch (TimeoutException)
{
Logger.LogWarning($"Loading commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName} timed out after {sw.ElapsedMilliseconds} ms, continuing in background");
Logger.LogWarning($"Loading commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName ?? wrapper.DisplayName} timed out after {sw.ElapsedMilliseconds} ms, continuing in background");
return CommandLoadResult.TimedOut(wrapper, loadTask, sw);
}
catch (OperationCanceledException)
{
Logger.LogDebug($"Loading commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName} was cancelled after {sw.ElapsedMilliseconds} ms");
Logger.LogDebug($"Loading commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName ?? wrapper.DisplayName} was cancelled after {sw.ElapsedMilliseconds} ms");
return CommandLoadResult.Failed(wrapper);
}
catch (Exception ex)
{
Logger.LogError($"Failed to load commands and bands for extension {wrapper.ExtensionHost?.Extension?.PackageFullName} after {sw.ElapsedMilliseconds} ms: {ex}");
Logger.LogError($"Failed to load commands and bands for {wrapper.ExtensionHost?.Extension?.PackageFullName ?? wrapper.DisplayName} after {sw.ElapsedMilliseconds} ms: {ex}");
return CommandLoadResult.Failed(wrapper);
}
}
@@ -590,7 +558,7 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
}
}
Logger.LogInfo($"Late-loaded {commands?.Count ?? 0} command(s) and {dockBands?.Count ?? 0} band(s) from {wrapper.ExtensionHost?.Extension?.PackageFullName} in {sw.ElapsedMilliseconds} ms");
Logger.LogInfo($"Late-loaded {commands?.Count ?? 0} command(s) and {dockBands?.Count ?? 0} band(s) from {wrapper.ExtensionHost?.Extension?.PackageFullName ?? wrapper.DisplayName} in {sw.ElapsedMilliseconds} ms");
}
catch (OperationCanceledException)
{
@@ -598,52 +566,63 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
}
catch (Exception ex)
{
Logger.LogError($"Background loading of commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName} failed after {sw.ElapsedMilliseconds} ms: {ex}");
Logger.LogError($"Background loading of commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName ?? wrapper.DisplayName} failed after {sw.ElapsedMilliseconds} ms: {ex}");
}
}
private void ExtensionService_OnExtensionRemoved(IExtensionService sender, IEnumerable<IExtensionWrapper> extensions)
private void ExtensionService_OnProviderAdded(IExtensionService sender, IEnumerable<CommandProviderWrapper> wrappers)
{
// When we get an extension uninstall event, hop off to a BG thread
var ct = _currentExtensionLoadCancellationToken;
_ = Task.Run(
async () =>
{
// Then find all the top-level commands that belonged to that extension
await RegisterAndLoadCommandsAsync(wrappers, ct).ConfigureAwait(false);
},
ct);
}
private void ExtensionService_OnProviderRemoved(IExtensionService sender, IEnumerable<CommandProviderWrapper> removedWrappers)
{
// When we get a provider removal event, hop off to a BG thread
_ = Task.Run(
async () =>
{
var removedProviderIds = new HashSet<string>(removedWrappers.Select(w => w.ProviderId));
List<TopLevelViewModel> commandsToRemove = [];
List<TopLevelViewModel> bandsToRemove = [];
foreach (var extension in extensions)
{
lock (TopLevelCommands)
{
foreach (var command in TopLevelCommands)
{
var host = command.ExtensionHost;
if (host?.Extension == extension)
{
commandsToRemove.Add(command);
}
}
}
lock (_dockBandsLock)
lock (TopLevelCommands)
{
foreach (var command in TopLevelCommands)
{
foreach (var band in DockBands)
if (removedProviderIds.Contains(command.CommandProviderId))
{
var host = band.ExtensionHost;
if (host?.Extension == extension)
{
bandsToRemove.Add(band);
}
commandsToRemove.Add(command);
}
}
}
// Then back on the UI thread (remember, TopLevelCommands is
// Observable, so you can't touch it on the BG thread)...
lock (_dockBandsLock)
{
foreach (var band in DockBands)
{
if (removedProviderIds.Contains(band.CommandProviderId))
{
bandsToRemove.Add(band);
}
}
}
lock (_commandProvidersLock)
{
_commandProviders.RemoveAll(w => removedProviderIds.Contains(w.ProviderId));
}
await Task.Factory.StartNew(
() =>
{
// ... remove all the deleted commands.
lock (TopLevelCommands)
{
if (commandsToRemove.Count != 0)
@@ -715,6 +694,9 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
public void Receive(ReloadCommandsMessage message) =>
_ = ReloadAllCommandsAsync();
public void Receive(ProviderEnabledStateChangedMessage message) =>
_ = UpdateProviderEnabledStateAsyncCore(message.ProviderId, message.IsEnabled);
public void Receive(PinCommandItemMessage message)
{
var wrapper = LookupProvider(message.ProviderId);
@@ -752,8 +734,7 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
{
lock (_commandProvidersLock)
{
return _builtInCommands.FirstOrDefault(w => w.ProviderId == providerId)
?? _extensionCommandProviders.FirstOrDefault(w => w.ProviderId == providerId);
return _commandProviders.FirstOrDefault(w => w.ProviderId == providerId);
}
}
@@ -761,8 +742,7 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
{
lock (_commandProvidersLock)
{
return _builtInCommands.Any(wrapper => wrapper.Id == id && wrapper.IsActive)
|| _extensionCommandProviders.Any(wrapper => wrapper.Id == id && wrapper.IsActive);
return _commandProviders.Any(wrapper => wrapper.Id == id && wrapper.IsActive);
}
}
@@ -815,49 +795,18 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
public void Dispose()
{
foreach (var service in _extensionServices)
{
service.OnProviderAdded -= ExtensionService_OnProviderAdded;
service.OnProviderRemoved -= ExtensionService_OnProviderRemoved;
}
_extensionLoadCts.Cancel();
_extensionLoadCts.Dispose();
_reloadCommandsGate.Dispose();
GC.SuppressFinalize(this);
}
private sealed class ExtensionStartResult
{
public IExtensionWrapper Extension { get; }
public CommandProviderWrapper? Wrapper { get; private init; }
public Task? PendingStartTask { get; private init; }
public Stopwatch? Stopwatch { get; private init; }
[MemberNotNullWhen(true, nameof(Wrapper))]
public bool IsStarted => Wrapper is not null;
[MemberNotNullWhen(true, nameof(PendingStartTask), nameof(Stopwatch))]
public bool IsTimedOut => PendingStartTask is not null;
private ExtensionStartResult(IExtensionWrapper extension)
{
Extension = extension;
}
public static ExtensionStartResult Started(IExtensionWrapper extension, CommandProviderWrapper wrapper)
{
return new ExtensionStartResult(extension) { Wrapper = wrapper };
}
public static ExtensionStartResult TimedOut(IExtensionWrapper extension, Task pendingStartTask, Stopwatch sw)
{
return new ExtensionStartResult(extension) { PendingStartTask = pendingStartTask, Stopwatch = sw };
}
public static ExtensionStartResult Failed(IExtensionWrapper extension)
{
return new ExtensionStartResult(extension);
}
}
private sealed class CommandLoadResult
{
public TopLevelObjectSets? TopLevelObjectSets { get; private init; }

View File

@@ -264,7 +264,11 @@ public partial class App : Application, IDisposable
// Core services
services.AddSingleton(appInfoService);
services.AddSingleton<IExtensionService, ExtensionService>();
// Load IExtensionServices here
services.AddSingleton<IExtensionService, BuiltInExtensionService>();
services.AddSingleton<IExtensionService, WinRTExtensionService>();
services.AddSingleton<IExtensionService, JavaScriptExtensionService>();
services.AddSingleton<IRunHistoryService, RunHistoryService>();
services.AddSingleton<IRootPageService, PowerToysRootPageService>();

View File

@@ -922,8 +922,11 @@ public sealed partial class MainWindow : WindowEx,
}
}
var extensionService = serviceProvider.GetService<IExtensionService>()!;
extensionService.SignalStopExtensionsAsync();
var extensionServices = serviceProvider.GetServices<IExtensionService>();
foreach (var extensionService in extensionServices)
{
extensionService.SignalStopAsync();
}
App.Current.Services.GetService<TrayIconService>()!.Destroy();

View File

@@ -110,7 +110,6 @@
Visibility="{x:Bind IsText, Mode=OneWay}" />
<HyperlinkButton
Padding="0"
Command="{x:Bind NavigateCommand, Mode=OneWay}"
NavigateUri="{x:Bind Link, Mode=OneWay}"
UseSystemFocusVisuals="{StaticResource UseSystemFocusVisuals}"
Visibility="{x:Bind IsLink, Mode=OneWay}">

View File

@@ -36,7 +36,7 @@ internal sealed class PowerToysRootPageService : IRootPageService
public async Task PreLoadAsync()
{
await _tlcManager.LoadBuiltinsAsync();
await _tlcManager.LoadBuiltInProvidersAsync();
}
public Microsoft.CommandPalette.Extensions.IPage GetRootPage()
@@ -46,11 +46,11 @@ internal sealed class PowerToysRootPageService : IRootPageService
public async Task PostLoadRootPageAsync()
{
// After loading built-ins, and starting navigation, kick off a thread to load extensions.
_tlcManager.LoadExtensionsCommand.Execute(null);
// After loading built-ins, and starting navigation, kick off a thread to load external extensions.
_tlcManager.LoadExternalProvidersCommand.Execute(null);
await _tlcManager.LoadExtensionsCommand.ExecutionTask!;
if (_tlcManager.LoadExtensionsCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
await _tlcManager.LoadExternalProvidersCommand.ExecutionTask!;
if (_tlcManager.LoadExternalProvidersCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
{
// TODO: Handle failure case
}

View File

@@ -0,0 +1,67 @@
using System;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
// Mimic the JsonRpcResponse
public sealed class TestJsonRpcResponse
{
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; set; } = "2.0";
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("result")]
public JsonElement? Result { get; set; }
[JsonPropertyName("error")]
public object? Error { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(TestJsonRpcResponse))]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(JsonObject))]
internal partial class TestContext : JsonSerializerContext { }
var json = """{"jsonrpc":"2.0","id":3,"result":{"items":[{"title":"Say Hello","displayName":"Say Hello","subtitle":"Displays a toast message","icon":null,"section":"Commands","tags":[{"text":"Action"}],"command":{"id":"say-hello","name":"Say Hello","displayName":"Say Hello"}},{"title":"View Readme","displayName":"View Readme","subtitle":"Shows a markdown content page","icon":null,"section":"Pages","tags":[{"text":"Page"}],"command":{"id":"open-markdown","name":"View Readme","displayName":"View Readme"}},{"title":"Static Item","displayName":"Static Item","subtitle":"This item does not have an action","icon":null,"section":"Other","command":{"id":"item-3","name":"Static Item","displayName":"Static Item"}}]}}""";
Console.WriteLine($"Input JSON length: {json.Length}");
var response = JsonSerializer.Deserialize(json, TestContext.Default.TestJsonRpcResponse);
Console.WriteLine($"Response is null: {response == null}");
Console.WriteLine($"Response.Id: {response?.Id}");
Console.WriteLine($"Response.Result.HasValue: {response?.Result.HasValue}");
Console.WriteLine($"Response.Result.Value.ValueKind: {response?.Result?.ValueKind}");
if (response?.Result?.ValueKind == JsonValueKind.Object)
{
var result = response.Result.Value;
if (result.TryGetProperty("items", out var itemsProp))
{
Console.WriteLine($"items.ValueKind: {itemsProp.ValueKind}");
if (itemsProp.ValueKind == JsonValueKind.Array)
{
Console.WriteLine($"items count: {itemsProp.GetArrayLength()}");
foreach (var item in itemsProp.EnumerateArray())
{
if (item.TryGetProperty("title", out var t))
Console.WriteLine($" item title: {t.GetString()}");
}
}
}
else
{
Console.WriteLine("ERROR: 'items' property not found!");
Console.WriteLine($"Available properties:");
foreach (var prop in result.EnumerateObject())
{
Console.WriteLine($" {prop.Name}: {prop.Value.ValueKind}");
}
}
}
else
{
Console.WriteLine($"ERROR: Result is not an object! ValueKind = {response?.Result?.ValueKind}");
}

View File

@@ -13,6 +13,7 @@ using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Common.WinGet.Models;
using Microsoft.CmdPal.Common.WinGet.Services;
using Microsoft.CmdPal.UI.ViewModels.Gallery;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
@@ -61,7 +62,7 @@ public class ExtensionGalleryViewModelTests
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
new[] { extensionService.Object },
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
@@ -118,7 +119,7 @@ public class ExtensionGalleryViewModelTests
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
new[] { extensionService.Object },
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory(winGetService.Object, winGetStatusService.Object),
winGetService.Object,
@@ -202,7 +203,7 @@ public class ExtensionGalleryViewModelTests
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
new[] { extensionService.Object },
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory(winGetService.Object, winGetStatusService.Object),
winGetService.Object,
@@ -250,7 +251,7 @@ public class ExtensionGalleryViewModelTests
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
new[] { extensionService.Object },
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
@@ -292,7 +293,7 @@ public class ExtensionGalleryViewModelTests
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
new[] { extensionService.Object },
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
@@ -324,7 +325,7 @@ public class ExtensionGalleryViewModelTests
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
new[] { extensionService.Object },
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
@@ -359,7 +360,7 @@ public class ExtensionGalleryViewModelTests
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
new[] { extensionService.Object },
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
@@ -389,7 +390,7 @@ public class ExtensionGalleryViewModelTests
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
new[] { extensionService.Object },
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
@@ -421,7 +422,7 @@ public class ExtensionGalleryViewModelTests
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
new[] { extensionService.Object },
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
@@ -485,7 +486,7 @@ public class ExtensionGalleryViewModelTests
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
new[] { extensionService.Object },
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory(winGetPackageStatusService: winGetStatusService.Object),
winGetPackageManagerService: null,

View File

@@ -0,0 +1,424 @@
# Building Command Palette Extensions with JavaScript/TypeScript
This guide explains how to create, develop, and publish JavaScript/TypeScript extensions for Command Palette using the JSONRPC extension system.
## Prerequisites
- Node.js 22+ (for development; end-users get it automatically)
- npm (comes with Node.js)
- TypeScript 5.8+ (recommended)
## Quick Start
### 1. Create a new extension project
```bash
mkdir my-cmdpal-extension
cd my-cmdpal-extension
npm init -y
npm install @microsoft/cmdpal-sdk
npm install -D typescript @types/node
```
### 2. Configure TypeScript
Create `tsconfig.json`:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"declaration": true
},
"include": ["src/**/*.ts"]
}
```
### 3. Configure package.json
Add the `cmdpal` field to your `package.json`:
```json
{
"name": "@yourname/my-extension",
"version": "1.0.0",
"main": "dist/index.js",
"cmdpal": {
"main": "dist/index.js",
"displayName": "My Extension",
"minVersion": "0.100.0"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
}
}
```
The `cmdpal.main` field specifies the entry point for your extension. If omitted, the standard `main` field is used as a fallback.
### 4. Write your extension
Create `src/index.ts`:
```typescript
import {
CommandProviderBase,
ListPageBase,
ListItemBase,
InvokableCommandBase,
ExtensionHost,
type ActivationContext,
type ICommandItem,
type IListItem,
type CommandResult,
} from '@microsoft/cmdpal-sdk';
// A command that does something when invoked
class GreetCommand extends InvokableCommandBase {
id = 'greet';
name = 'Greet';
invoke(): CommandResult {
ExtensionHost.log('User invoked greet!');
return { kind: 'showToast', args: { message: 'Hello from my extension! 👋' } };
}
}
// A list page showing items
class MyListPage extends ListPageBase {
id = 'my-list';
name = 'My Items';
title = 'My Extension';
placeholderText = 'Search items...';
getItems(): IListItem[] {
return [
new ListItemBase({
command: new GreetCommand(),
title: 'Say Hello',
subtitle: 'Shows a greeting toast',
tags: [{ text: 'Action' }],
}),
];
}
}
// The main provider — this is what CmdPal loads
class MyProvider extends CommandProviderBase {
id = 'my-extension';
displayName = 'My Extension';
private page = new MyListPage();
topLevelCommands(): ICommandItem[] {
return [{
command: this.page,
title: this.page.title,
subtitle: 'My awesome extension',
}];
}
getCommand(id: string) {
if (id === this.page.id) return this.page as any;
if (id === 'greet') return new GreetCommand();
return null;
}
}
// Entry point — CmdPal calls this to activate your extension
export function activate(context: ActivationContext) {
ExtensionHost.log(`Extension activated in ${context.extensionDirectory}`);
return new MyProvider();
}
```
### 5. Build and test
```bash
npm run build
```
## Extension Architecture
### How it works
```
┌─────────────────────┐ JSONRPC/stdio ┌──────────────────┐
│ Command Palette │ ◄───────────────────────► │ Node.js Host │
│ (C# / WinUI) │ │ │
│ │ │ ┌────────────┐ │
│ JSONRPCExtension- │ provider/getCommands ──► │ │ Extension1 │ │
│ Service │ ◄── { items: [...] } │ └────────────┘ │
│ │ │ ┌────────────┐ │
│ │ command/invoke ────────► │ │ Extension2 │ │
│ │ ◄── { kind: "toast" } │ └────────────┘ │
└─────────────────────┘ └──────────────────┘
```
1. CmdPal spawns a single Node.js host process at startup
2. The host loads all installed JS extensions
3. CmdPal communicates with extensions via JSONRPC over stdio
4. Your extension responds to requests and can send notifications back
### Entry Point
Your extension must export an `activate` function:
```typescript
export function activate(context: ActivationContext): CommandProvider {
return new MyProvider();
}
```
The `context` provides:
- `extensionId` — Your extension's unique identifier
- `extensionDirectory` — Absolute path to your extension's install directory
### CommandProvider
The `CommandProvider` is the root of your extension. It must implement:
| Method | Description |
|--------|-------------|
| `topLevelCommands()` | Returns items shown in the main palette |
| `fallbackCommands()` | (Optional) Returns fallback search handlers |
| `getCommand(id)` | (Optional) Returns a command/page by ID |
| `initializeWithHost(host)` | (Optional) Called with the extension host API |
| `dispose()` | (Optional) Cleanup when extension is unloaded |
## Page Types
### ListPage
Displays a searchable list of items:
```typescript
class MyList extends ListPageBase {
id = 'my-list';
name = 'My List';
title = 'Items';
placeholderText = 'Search...';
showDetails = true;
getItems(): IListItem[] {
return [/* items */];
}
}
```
### DynamicListPage
A list where your extension handles filtering:
```typescript
class SearchPage extends DynamicListPageBase {
id = 'search';
name = 'Search';
title = 'Search';
private query = '';
setSearchText(text: string) {
this.query = text;
// Items will be re-fetched automatically
}
async getItems() {
return await myApi.search(this.query);
}
}
```
### ContentPage
Displays rich content (markdown, forms, images):
```typescript
class AboutPage extends ContentPageBase {
id = 'about';
name = 'About';
title = 'About My Extension';
getContent(): Content[] {
return [
{ type: 'markdown', body: '# Hello\n\nThis is **markdown** content.' },
{ type: 'image', image: { light: { icon: 'https://...' } }, maxWidth: 300 },
];
}
}
```
## Commands
### InvokableCommand
A command that performs an action:
```typescript
class CopyCommand extends InvokableCommandBase {
id = 'copy';
name = 'Copy to Clipboard';
async invoke(): Promise<CommandResult> {
// Do your action here
return { kind: 'showToast', args: { message: 'Copied!' } };
}
}
```
### CommandResult
After invoking, return a result to control navigation:
| Kind | Description |
|------|-------------|
| `dismiss` | Close the palette |
| `goHome` | Return to the main page, keep palette open |
| `goBack` | Go back one page |
| `hide` | Hide palette but keep current page |
| `keepOpen` | Do nothing (stay on current page) |
| `goToPage` | Navigate to another page (`args: { pageId, navigationMode }`) |
| `showToast` | Show a toast message (`args: { message }`) |
| `confirm` | Show a confirmation dialog |
## Logging & Status
Use `ExtensionHost` to communicate with CmdPal:
```typescript
import { ExtensionHost } from '@microsoft/cmdpal-sdk';
// Logging (visible in CmdPal logs)
ExtensionHost.log('Something happened');
ExtensionHost.log('Something failed', 'error');
// Status bar messages
ExtensionHost.showStatus('Loading data...', 'info', { isIndeterminate: true });
ExtensionHost.hideStatus('loading-id');
```
## Publishing to the Gallery
### 1. Publish to npm
```bash
npm publish --access public
```
### 2. Submit to the gallery
Create a PR to [microsoft/CmdPal-Extensions](https://github.com/microsoft/CmdPal-Extensions) adding your extension to the gallery manifest:
```json
{
"id": "yourname.my-extension",
"title": "My Extension",
"shortDescription": "A brief description",
"description": "Full description of what your extension does...",
"author": { "name": "Your Name", "url": "https://github.com/yourname" },
"installSources": [
{ "type": "npm", "id": "@yourname/my-extension" }
],
"iconUrl": "https://raw.githubusercontent.com/.../icon.png",
"tags": ["productivity"],
"categories": ["utilities-and-tools"],
"addedAt": "2026-01-01"
}
```
### Install source types
| Type | Description | Required fields |
|------|-------------|-----------------|
| `npm` | npm package | `id` (package name) |
| `winget` | WinGet package | `id` (winget ID) |
| `msstore` | Microsoft Store | `id` (store product ID) |
## Extension Settings
Extensions can provide a settings page by implementing `settings` on the provider:
```typescript
class SettingsPage extends ContentPageBase {
id = 'settings';
name = 'Settings';
title = 'My Extension Settings';
getContent(): Content[] {
return [{
type: 'form',
templateJson: JSON.stringify({
type: 'AdaptiveCard',
body: [
{ type: 'Input.Text', id: 'apiKey', label: 'API Key' }
],
actions: [{ type: 'Action.Submit', title: 'Save' }]
}),
dataJson: JSON.stringify({ apiKey: '' }),
submitForm(inputs: string, data: string) {
// Save settings
return { kind: 'showToast', args: { message: 'Settings saved!' } };
}
}];
}
}
class MyProvider extends CommandProviderBase {
// ...
settings = { settingsPage: new SettingsPage() };
}
```
## Notifications (Push Updates)
Extensions can notify CmdPal when data changes, triggering a re-fetch:
```typescript
// In DynamicListPageBase subclasses:
this.notifyItemsChanged(); // triggers page/getItems re-call
// Or use the connection directly (advanced):
// The SDK wires this up through the JSONRPC bridge
```
## File Structure
A typical extension project:
```
my-extension/
├── package.json # npm config + cmdpal field
├── tsconfig.json # TypeScript config
├── src/
│ └── index.ts # Extension entry point
├── dist/ # Compiled output (git-ignored)
│ └── index.js
└── README.md
```
## Troubleshooting
### Extension not loading?
1. Check that `cmdpal.main` (or `main`) in package.json points to a valid JS file
2. Verify your `activate()` function returns a valid `CommandProvider`
3. Check CmdPal logs for error messages from your extension
### Commands not appearing?
1. Ensure `topLevelCommands()` returns at least one item
2. Verify the extension is enabled in CmdPal settings
3. Check that the `getCommand(id)` method returns pages/commands referenced by your items
### Logs
Extension logs are written to:
`%LOCALAPPDATA%\Microsoft\PowerToys\CmdPal\logs\`
## API Reference
See the [TypeScript SDK README](../ts-sdk/README.md) for the complete API surface.
See the [JSONRPC Protocol Specification](../docs/jsonrpc-protocol.md) for the wire protocol details.

View File

@@ -0,0 +1,652 @@
# Command Palette JSONRPC Extension Protocol
This document defines the JSONRPC 2.0 protocol used between the Command Palette host (C#) and the Node.js extension host process.
## Transport
- **Transport:** stdio (stdin/stdout)
- **Framing:** Header-delimited (Content-Length), matching the LSP/DAP convention
- **Direction:** Bidirectional — both host and extensions can send requests and notifications
## Lifecycle
1. CmdPal spawns the Node host process
2. Host sends `initialize` request with configuration
3. Node host responds with capabilities
4. Host sends `extensions/load` with list of extensions to activate
5. Node host loads each extension, responds with success/failure per extension
6. Normal operation: host sends requests, extensions respond; extensions send notifications
## Conventions
- All method names use `/` as namespace separator
- Request IDs are integers, auto-incremented by the sender
- Extension-specific requests include an `extensionId` field to route to the correct extension
- All property values use camelCase JSON naming
---
## Host → Node Requests
### `initialize`
Sent once after process spawn to configure the Node host.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"hostVersion": "0.100.0",
"extensionsDirectory": "C:\\Users\\...\\extensions",
"logsDirectory": "C:\\Users\\...\\logs"
}
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "1.0.0",
"hostReady": true
}
}
```
### `extensions/load`
Instructs the Node host to load a set of extensions.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "extensions/load",
"params": {
"extensions": [
{
"id": "cooldev.weather",
"packageName": "@cooldev/weather-extension",
"entryPoint": "dist/index.js",
"directory": "C:\\Users\\...\\extensions\\@cooldev-weather-extension"
}
]
}
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"loaded": [
{ "id": "cooldev.weather", "success": true }
],
"failed": []
}
}
```
### `extensions/unload`
Instructs the Node host to unload a specific extension.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 3,
"method": "extensions/unload",
"params": {
"extensionId": "cooldev.weather"
}
}
```
### `provider/getTopLevelCommands`
Maps to `ICommandProvider.TopLevelCommands()`.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 4,
"method": "provider/getTopLevelCommands",
"params": {
"extensionId": "cooldev.weather"
}
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"items": [
{
"command": {
"id": "weather-now",
"name": "Current Weather",
"icon": { "light": { "icon": "\uE9CA" }, "dark": { "icon": "\uE9CA" } }
},
"title": "Current Weather",
"subtitle": "Get current weather for your location",
"icon": { "light": { "icon": "\uE9CA" }, "dark": { "icon": "\uE9CA" } }
}
]
}
}
```
### `provider/getFallbackCommands`
Maps to `ICommandProvider.FallbackCommands()`.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 5,
"method": "provider/getFallbackCommands",
"params": {
"extensionId": "cooldev.weather"
}
}
```
**Response:** Same shape as `provider/getTopLevelCommands` but items include `fallbackHandler` field.
### `provider/getSettings`
Maps to `ICommandProvider.Settings`.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 6,
"method": "provider/getSettings",
"params": {
"extensionId": "cooldev.weather"
}
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": 6,
"result": {
"settingsPage": {
"id": "weather-settings",
"name": "Weather Settings",
"title": "Weather Settings",
"content": [...]
}
}
}
```
### `command/invoke`
Maps to `IInvokableCommand.Invoke()`.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 7,
"method": "command/invoke",
"params": {
"extensionId": "cooldev.weather",
"commandId": "weather-now"
}
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"kind": "goToPage",
"args": {
"pageId": "weather-detail",
"navigationMode": "push"
}
}
}
```
### `page/getItems`
Maps to `IListPage.GetItems()`.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 8,
"method": "page/getItems",
"params": {
"extensionId": "cooldev.weather",
"pageId": "weather-list"
}
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"items": [
{
"command": { "id": "city-nyc", "name": "New York" },
"title": "New York",
"subtitle": "72°F, Sunny",
"tags": [{ "text": "Favorite", "icon": null }],
"section": "Favorites"
}
]
}
}
```
### `page/setSearchText`
Maps to `IDynamicListPage.SearchText { set }`.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 9,
"method": "page/setSearchText",
"params": {
"extensionId": "cooldev.weather",
"pageId": "weather-list",
"searchText": "new york"
}
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": 9,
"result": { "accepted": true }
}
```
### `page/loadMore`
Maps to `IListPage.LoadMore()`.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 10,
"method": "page/loadMore",
"params": {
"extensionId": "cooldev.weather",
"pageId": "weather-list"
}
}
```
### `page/getContent`
Maps to `IContentPage.GetContent()`.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 11,
"method": "page/getContent",
"params": {
"extensionId": "cooldev.weather",
"pageId": "weather-detail"
}
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": 11,
"result": {
"content": [
{ "type": "markdown", "body": "## Current Weather\n\n72°F and sunny" },
{ "type": "image", "image": { "light": { "icon": "https://..." } }, "maxWidth": 200, "maxHeight": 200 }
]
}
}
```
### `page/getProperties`
Gets page-level properties (title, isLoading, placeholderText, showDetails, filters, gridProperties, etc.).
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 12,
"method": "page/getProperties",
"params": {
"extensionId": "cooldev.weather",
"pageId": "weather-list"
}
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": 12,
"result": {
"title": "Weather",
"isLoading": false,
"placeholderText": "Search cities...",
"showDetails": true,
"hasMoreItems": false,
"filters": {
"currentFilterId": "all",
"filters": [
{ "id": "all", "name": "All" },
{ "id": "favorites", "name": "Favorites" }
]
}
}
}
```
### `form/submit`
Maps to `IFormContent.SubmitForm()`.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 13,
"method": "form/submit",
"params": {
"extensionId": "cooldev.weather",
"pageId": "settings-form",
"inputs": "{ \"apiKey\": \"abc123\" }",
"data": "{ \"location\": \"NYC\" }"
}
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": 13,
"result": {
"kind": "dismiss"
}
}
```
### `fallback/updateQuery`
Maps to `IFallbackHandler.UpdateQuery()`.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 14,
"method": "fallback/updateQuery",
"params": {
"extensionId": "cooldev.weather",
"commandId": "weather-fallback",
"query": "weather in paris"
}
}
```
### `provider/getCommand`
Maps to `ICommandProvider.GetCommand(id)`.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 15,
"method": "provider/getCommand",
"params": {
"extensionId": "cooldev.weather",
"commandId": "weather-now"
}
}
```
---
## Node → Host Notifications
These are fire-and-forget messages from extensions to the host.
### `notify/itemsChanged`
Extension signals that its list items have changed. Host should re-fetch.
```json
{
"jsonrpc": "2.0",
"method": "notify/itemsChanged",
"params": {
"extensionId": "cooldev.weather",
"pageId": "weather-list",
"totalItems": 15
}
}
```
### `notify/propChanged`
Extension signals that a property has changed on a command/page.
```json
{
"jsonrpc": "2.0",
"method": "notify/propChanged",
"params": {
"extensionId": "cooldev.weather",
"objectId": "weather-now",
"propertyName": "title"
}
}
```
### `notify/commandsChanged`
Extension signals that its top-level commands have changed. Host should re-fetch.
```json
{
"jsonrpc": "2.0",
"method": "notify/commandsChanged",
"params": {
"extensionId": "cooldev.weather"
}
}
```
### `host/log`
Extension sends a log message.
```json
{
"jsonrpc": "2.0",
"method": "host/log",
"params": {
"extensionId": "cooldev.weather",
"state": "info",
"message": "Fetched weather data for 5 cities"
}
}
```
`state` is one of: `"info"`, `"success"`, `"warning"`, `"error"`.
### `host/showStatus`
Extension requests a status message be shown.
```json
{
"jsonrpc": "2.0",
"method": "host/showStatus",
"params": {
"extensionId": "cooldev.weather",
"context": "page",
"message": "Loading weather data...",
"state": "info",
"progress": { "isIndeterminate": true }
}
}
```
### `host/hideStatus`
Extension requests a status message be hidden.
```json
{
"jsonrpc": "2.0",
"method": "host/hideStatus",
"params": {
"extensionId": "cooldev.weather",
"messageId": "loading-weather"
}
}
```
---
## Data Types
### IconInfo
```json
{
"light": { "icon": "<glyph or URL>", "data": null },
"dark": { "icon": "<glyph or URL>", "data": null }
}
```
### CommandResultKind (enum string)
`"dismiss"` | `"goHome"` | `"goBack"` | `"hide"` | `"keepOpen"` | `"goToPage"` | `"showToast"` | `"confirm"`
### NavigationMode (enum string)
`"push"` | `"goBack"` | `"goHome"`
### MessageState (enum string)
`"info"` | `"success"` | `"warning"` | `"error"`
### StatusContext (enum string)
`"page"` | `"extension"`
### ContentType (discriminator for content array items)
`"markdown"` | `"form"` | `"tree"` | `"plainText"` | `"image"`
### Tag
```json
{
"icon": <IconInfo | null>,
"text": "string",
"foreground": { "hasValue": true, "color": { "r": 255, "g": 0, "b": 0, "a": 255 } },
"background": null,
"toolTip": "string"
}
```
### Details
```json
{
"heroImage": <IconInfo | null>,
"title": "string",
"body": "string",
"metadata": [
{ "key": "Author", "data": { "type": "tags", "tags": [...] } },
{ "key": "Link", "data": { "type": "link", "link": "https://...", "text": "Homepage" } }
]
}
```
### Filter
```json
{
"id": "string",
"name": "string",
"icon": <IconInfo | null>
}
```
### GridProperties
```json
{
"type": "small" | "medium" | "gallery",
"showTitle": true,
"showSubtitle": true
}
```
---
## Error Handling
JSONRPC errors use standard error codes:
- `-32700`: Parse error
- `-32600`: Invalid request
- `-32601`: Method not found
- `-32602`: Invalid params
- `-32603`: Internal error
Custom error codes (extension-specific):
- `-32000`: Extension not found
- `-32001`: Extension not loaded
- `-32002`: Command not found
- `-32003`: Page not found
- `-32004`: Extension threw an exception (details in `data` field)
```json
{
"jsonrpc": "2.0",
"id": 7,
"error": {
"code": -32004,
"message": "Extension threw an exception",
"data": {
"extensionId": "cooldev.weather",
"stack": "Error: API key invalid\n at ..."
}
}
}
```

View File

@@ -0,0 +1,2 @@
import { type ActivationContext, type ICommandProvider } from '@microsoft/cmdpal-sdk';
export declare function activate(context: ActivationContext): ICommandProvider;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
{
"name": "@cmdpal/sample-js-extension",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@cmdpal/sample-js-extension",
"version": "0.1.0",
"dependencies": {
"@microsoft/cmdpal-sdk": "file:../../ts-sdk"
},
"devDependencies": {
"typescript": "^5.8.0"
}
},
"../../ts-sdk": {
"name": "@microsoft/cmdpal-sdk",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "^9.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.8.0"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@microsoft/cmdpal-sdk": {
"resolved": "../../ts-sdk",
"link": true
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

@@ -0,0 +1,26 @@
{
"name": "@microsoft/cmdpal-sample-extension",
"version": "0.1.0",
"description": "Sample JavaScript extension for Command Palette demonstrating the JSONRPC SDK",
"main": "dist/index.js",
"cmdpal": {
"displayName": "Sample JS Extension",
"icon": "\uE943",
"publisher": "Microsoft",
"capabilities": ["commands"],
"debug": false
},
"engines": {
"node": ">=22"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"dependencies": {
"@microsoft/cmdpal-sdk": "file:../../ts-sdk"
},
"devDependencies": {
"typescript": "^5.8.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,68 @@
# setup-dev-extensions.ps1
# Sets up the JavaScript extension development environment for local testing.
# Run from: src/modules/cmdpal/
#
# What it does:
# 1. Builds the TypeScript SDK (ts-sdk)
# 2. Builds the sample JS extension
# 3. Installs the sample extension into the JSExtensions directory
param(
[string]$Configuration = "Debug",
[string]$Platform = "ARM64"
)
$ErrorActionPreference = "Stop"
$scriptDir = $PSScriptRoot
# Paths
$sdkDir = Join-Path $scriptDir "ts-sdk"
$sampleExtDir = Join-Path $scriptDir "ext\SampleJSExtension"
$extensionsDir = Join-Path $env:LOCALAPPDATA "Microsoft\PowerToys\CommandPalette\JSExtensions"
$sampleInstallDir = Join-Path $extensionsDir "sample-js-extension"
Write-Host "=== Step 1: Building TypeScript SDK ===" -ForegroundColor Cyan
Push-Location $sdkDir
npm install --quiet 2>&1 | Out-Null
npm run build
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "SDK build failed" }
Pop-Location
Write-Host " SDK built successfully." -ForegroundColor Green
Write-Host ""
Write-Host "=== Step 2: Building Sample Extension ===" -ForegroundColor Cyan
Push-Location $sampleExtDir
npm install --quiet 2>&1 | Out-Null
npm run build
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "Sample extension build failed" }
Pop-Location
Write-Host " Sample extension built successfully." -ForegroundColor Green
Write-Host ""
Write-Host "=== Step 3: Installing Sample Extension ===" -ForegroundColor Cyan
# Create the extensions directory structure
New-Item -ItemType Directory -Path $sampleInstallDir -Force | Out-Null
# Copy the built sample extension (dist/, node_modules/, package.json)
Copy-Item -Path "$sampleExtDir\package.json" -Destination $sampleInstallDir -Force
if (Test-Path "$sampleExtDir\dist") {
Copy-Item -Path "$sampleExtDir\dist" -Destination $sampleInstallDir -Recurse -Force
}
if (Test-Path "$sampleExtDir\node_modules") {
Copy-Item -Path "$sampleExtDir\node_modules" -Destination $sampleInstallDir -Recurse -Force
}
Write-Host " Installed to: $sampleInstallDir" -ForegroundColor Green
Write-Host ""
Write-Host "=== Done! ===" -ForegroundColor Green
Write-Host ""
Write-Host "Extension installed at: $sampleInstallDir"
Write-Host "The JavaScriptExtensionService will discover it on next CmdPal launch."
Write-Host ""
Write-Host "Requirements:"
Write-Host " - Node.js v22+ must be on your PATH"
Write-Host " - Each JS extension runs as its own 'node dist/index.js' process"
Write-Host ""
Write-Host "Hot-reload: Edit .js files in the extension directory and CmdPal will"
Write-Host "automatically restart the extension process (500ms debounce)."

View File

@@ -0,0 +1,115 @@
# @microsoft/cmdpal-sdk
TypeScript SDK for building Command Palette extensions.
## Quick Start
```bash
npm install @microsoft/cmdpal-sdk
```
```typescript
import {
CommandProviderBase,
ListPageBase,
ListItemBase,
type ActivationContext,
} from '@microsoft/cmdpal-sdk';
class MyListPage extends ListPageBase {
id = 'my-list';
name = 'My List';
title = 'My Extension';
placeholderText = 'Search...';
getItems() {
return [
new ListItemBase({
command: { id: 'item-1', name: 'Hello World' },
title: 'Hello World',
subtitle: 'My first extension item',
}),
];
}
}
class MyProvider extends CommandProviderBase {
id = 'my-extension';
displayName = 'My Extension';
private page = new MyListPage();
topLevelCommands() {
return [{ command: this.page, title: this.page.title }];
}
getCommand(id: string) {
if (id === this.page.id) return this.page;
return null;
}
}
export function activate(context: ActivationContext) {
return new MyProvider();
}
```
## Package.json Configuration
Add a `cmdpal` field to your `package.json`:
```json
{
"name": "@yourorg/my-extension",
"version": "1.0.0",
"cmdpal": {
"main": "dist/index.js",
"displayName": "My Extension",
"minVersion": "0.100.0"
},
"main": "dist/index.js"
}
```
## API Overview
### Base Classes
| Class | Description |
|-------|-------------|
| `CommandProviderBase` | Main extension entry point. Provides commands to the palette. |
| `ListPageBase` | A page displaying a searchable list of items. |
| `DynamicListPageBase` | A list page where the extension handles search filtering. |
| `ContentPageBase` | A page displaying rich content (markdown, forms, images). |
| `InvokableCommandBase` | A command that performs an action when invoked. |
| `ListItemBase` | A single item in a list page. |
| `CommandItemBase` | A command item displayed in menus. |
### Runtime
| Export | Description |
|--------|-------------|
| `ExtensionHost` | Static class for logging and status messages. |
| `activate()` | Helper for extension activation. |
### Types
The SDK exports all interfaces matching the Command Palette extension contract:
`ICommand`, `IListPage`, `IDynamicListPage`, `IContentPage`, `ICommandProvider`, etc.
## Extension Lifecycle
1. CmdPal discovers your package in the extensions directory
2. The Node host calls your `activate()` export
3. Your provider's `topLevelCommands()` is called to populate the palette
4. When a user interacts with your commands, corresponding methods are called via JSONRPC
## Logging
```typescript
import { ExtensionHost } from '@microsoft/cmdpal-sdk';
ExtensionHost.log('Something happened');
ExtensionHost.log('Something went wrong', 'error');
ExtensionHost.showStatus('Loading...', 'info', { isIndeterminate: true });
```

View File

@@ -0,0 +1,19 @@
import type { ICommandItem, ICommand, ContextItem, IconInfo } from '../types';
/**
* Base class for command items displayed in lists and menus.
*/
export declare class CommandItemBase implements ICommandItem {
command: ICommand;
title: string;
subtitle?: string;
icon?: IconInfo | null;
moreCommands?: ContextItem[];
constructor(options: {
command: ICommand;
title: string;
subtitle?: string;
icon?: IconInfo | null;
moreCommands?: ContextItem[];
});
}
//# sourceMappingURL=CommandItemBase.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"CommandItemBase.d.ts","sourceRoot":"","sources":["../../src/base/CommandItemBase.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAE9E;;GAEG;AACH,qBAAa,eAAgB,YAAW,YAAY;IAClD,OAAO,EAAE,QAAQ,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAC;IACvB,YAAY,CAAC,EAAE,WAAW,EAAE,CAAC;gBAEjB,OAAO,EAAE;QACnB,OAAO,EAAE,QAAQ,CAAC;QAClB,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,IAAI,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAC;QACvB,YAAY,CAAC,EAAE,WAAW,EAAE,CAAC;KAC9B;CAOF"}

View File

@@ -0,0 +1,25 @@
"use strict";
// 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.
Object.defineProperty(exports, "__esModule", { value: true });
exports.CommandItemBase = void 0;
/**
* Base class for command items displayed in lists and menus.
*/
class CommandItemBase {
command;
title;
subtitle;
icon;
moreCommands;
constructor(options) {
this.command = options.command;
this.title = options.title;
this.subtitle = options.subtitle;
this.icon = options.icon ?? null;
this.moreCommands = options.moreCommands;
}
}
exports.CommandItemBase = CommandItemBase;
//# sourceMappingURL=CommandItemBase.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"CommandItemBase.js","sourceRoot":"","sources":["../../src/base/CommandItemBase.ts"],"names":[],"mappings":";AAAA,sCAAsC;AACtC,6EAA6E;AAC7E,iEAAiE;;;AAIjE;;GAEG;AACH,MAAa,eAAe;IAC1B,OAAO,CAAW;IAClB,KAAK,CAAS;IACd,QAAQ,CAAU;IAClB,IAAI,CAAmB;IACvB,YAAY,CAAiB;IAE7B,YAAY,OAMX;QACC,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;QAC/B,IAAI,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC;QAC3B,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;QACjC,IAAI,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC;QACjC,IAAI,CAAC,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;IAC3C,CAAC;CACF;AApBD,0CAoBC"}

View File

@@ -0,0 +1,33 @@
import type { ICommandProvider, ICommandItem, IFallbackCommandItem, ICommand, ICommandSettings, IconInfo, IExtensionHost } from '../types';
/**
* Base class for Command Palette extension providers.
* Extend this class to create an extension that provides commands to the palette.
*
* @example
* ```typescript
* import { CommandProviderBase, ListPageBase } from '@microsoft/cmdpal-sdk';
*
* class MyProvider extends CommandProviderBase {
* id = 'my-extension';
* displayName = 'My Extension';
*
* topLevelCommands() {
* return [{ command: new MyPage(), title: 'My Command', icon: null }];
* }
* }
* ```
*/
export declare abstract class CommandProviderBase implements ICommandProvider {
abstract id: string;
abstract displayName: string;
icon?: IconInfo | null;
frozen?: boolean;
settings?: ICommandSettings | null;
protected host?: IExtensionHost;
abstract topLevelCommands(): Promise<ICommandItem[]> | ICommandItem[];
fallbackCommands?(): Promise<IFallbackCommandItem[]> | IFallbackCommandItem[];
getCommand?(id: string): Promise<ICommand | null> | ICommand | null;
initializeWithHost(host: IExtensionHost): void;
dispose(): void;
}
//# sourceMappingURL=CommandProviderBase.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"CommandProviderBase.d.ts","sourceRoot":"","sources":["../../src/base/CommandProviderBase.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,gBAAgB,EAChB,YAAY,EACZ,oBAAoB,EACpB,QAAQ,EACR,gBAAgB,EAChB,QAAQ,EACR,cAAc,EACf,MAAM,UAAU,CAAC;AAElB;;;;;;;;;;;;;;;;;GAiBG;AACH,8BAAsB,mBAAoB,YAAW,gBAAgB;IACnE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAE7B,IAAI,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAQ;IAC9B,MAAM,CAAC,EAAE,OAAO,CAAS;IACzB,QAAQ,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAQ;IAE1C,SAAS,CAAC,IAAI,CAAC,EAAE,cAAc,CAAC;IAEhC,QAAQ,CAAC,gBAAgB,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC,GAAG,YAAY,EAAE;IAErE,gBAAgB,CAAC,IAAI,OAAO,CAAC,oBAAoB,EAAE,CAAC,GAAG,oBAAoB,EAAE;IAI7E,UAAU,CAAC,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,GAAG,QAAQ,GAAG,IAAI;IAInE,kBAAkB,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI;IAI9C,OAAO,IAAI,IAAI;CAGhB"}

View File

@@ -0,0 +1,44 @@
"use strict";
// 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.
Object.defineProperty(exports, "__esModule", { value: true });
exports.CommandProviderBase = void 0;
/**
* Base class for Command Palette extension providers.
* Extend this class to create an extension that provides commands to the palette.
*
* @example
* ```typescript
* import { CommandProviderBase, ListPageBase } from '@microsoft/cmdpal-sdk';
*
* class MyProvider extends CommandProviderBase {
* id = 'my-extension';
* displayName = 'My Extension';
*
* topLevelCommands() {
* return [{ command: new MyPage(), title: 'My Command', icon: null }];
* }
* }
* ```
*/
class CommandProviderBase {
icon = null;
frozen = false;
settings = null;
host;
fallbackCommands() {
return [];
}
getCommand(id) {
return null;
}
initializeWithHost(host) {
this.host = host;
}
dispose() {
// Override to clean up resources
}
}
exports.CommandProviderBase = CommandProviderBase;
//# sourceMappingURL=CommandProviderBase.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"CommandProviderBase.js","sourceRoot":"","sources":["../../src/base/CommandProviderBase.ts"],"names":[],"mappings":";AAAA,sCAAsC;AACtC,6EAA6E;AAC7E,iEAAiE;;;AAYjE;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAsB,mBAAmB;IAIvC,IAAI,GAAqB,IAAI,CAAC;IAC9B,MAAM,GAAa,KAAK,CAAC;IACzB,QAAQ,GAA6B,IAAI,CAAC;IAEhC,IAAI,CAAkB;IAIhC,gBAAgB;QACd,OAAO,EAAE,CAAC;IACZ,CAAC;IAED,UAAU,CAAE,EAAU;QACpB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,kBAAkB,CAAC,IAAoB;QACrC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;IAED,OAAO;QACL,iCAAiC;IACnC,CAAC;CACF;AA3BD,kDA2BC"}

View File

@@ -0,0 +1,33 @@
import type { IContentPage, Content, Details, ContextItem, IconInfo, OptionalColor } from '../types';
/**
* Base class for content pages that display rich content (markdown, forms, images, etc.).
*
* @example
* ```typescript
* import { ContentPageBase } from '@microsoft/cmdpal-sdk';
*
* class ReadmePage extends ContentPageBase {
* id = 'readme';
* name = 'README';
* title = 'About This Extension';
*
* getContent() {
* return [
* { type: 'markdown', body: '# Hello World\n\nThis is my extension.' }
* ];
* }
* }
* ```
*/
export declare abstract class ContentPageBase implements IContentPage {
abstract id: string;
abstract name: string;
abstract title: string;
icon?: IconInfo | null;
isLoading?: boolean;
accentColor?: OptionalColor | null;
details?: Details | null;
commands?: ContextItem[];
abstract getContent(): Promise<Content[]> | Content[];
}
//# sourceMappingURL=ContentPageBase.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"ContentPageBase.d.ts","sourceRoot":"","sources":["../../src/base/ContentPageBase.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,YAAY,EACZ,OAAO,EACP,OAAO,EACP,WAAW,EACX,QAAQ,EACR,aAAa,EACd,MAAM,UAAU,CAAC;AAElB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,8BAAsB,eAAgB,YAAW,YAAY;IAC3D,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAEvB,IAAI,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAQ;IAC9B,SAAS,CAAC,EAAE,OAAO,CAAS;IAC5B,WAAW,CAAC,EAAE,aAAa,GAAG,IAAI,CAAQ;IAC1C,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAQ;IAChC,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAM;IAE9B,QAAQ,CAAC,UAAU,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC,GAAG,OAAO,EAAE;CACtD"}

Some files were not shown because too many files have changed in this diff Show More