mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-06 10:30:29 +02:00
Compare commits
12 Commits
gleb/advan
...
dev/mjolle
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3924e04578 | ||
|
|
34bdb12cc5 | ||
|
|
734a50ee97 | ||
|
|
c953f84fa3 | ||
|
|
a8d699b145 | ||
|
|
d2cee3a497 | ||
|
|
757f60d719 | ||
|
|
438507cdd4 | ||
|
|
89cf4e3115 | ||
|
|
59bc58afe2 | ||
|
|
e22bbd05a5 | ||
|
|
6b0779a7c5 |
5
.github/actions/spell-check/allow/code.txt
vendored
5
.github/actions/spell-check/allow/code.txt
vendored
@@ -309,11 +309,6 @@ pwa
|
||||
AOT
|
||||
Aot
|
||||
ify
|
||||
LAF
|
||||
Laf
|
||||
languagemodel
|
||||
philm
|
||||
phisilica
|
||||
TFM
|
||||
|
||||
# YML
|
||||
|
||||
4
.github/actions/spell-check/excludes.txt
vendored
4
.github/actions/spell-check/excludes.txt
vendored
@@ -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$
|
||||
|
||||
1
.github/actions/spell-check/expect.txt
vendored
1
.github/actions/spell-check/expect.txt
vendored
@@ -1602,6 +1602,7 @@ sancov
|
||||
SAVEFAILED
|
||||
schedtasks
|
||||
SCID
|
||||
SCL
|
||||
Scode
|
||||
SCREENFONTS
|
||||
screenruler
|
||||
|
||||
3
.github/actions/spell-check/patterns.txt
vendored
3
.github/actions/spell-check/patterns.txt
vendored
@@ -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
3
.gitignore
vendored
@@ -378,6 +378,3 @@ installer/*/*.wxs.bk
|
||||
vcpkg_installed/
|
||||
|
||||
deps/vcpkg/
|
||||
|
||||
# Superpowers-generated docs (specs, design, plans) — local-only, not committed
|
||||
docs/superpowers/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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="// <auto-generated/>;namespace AdvancedPaste%3B;;internal static class PhiSilicaLafCredentials;{; internal const string Token = "$(PhiSilicaLafToken)"%3B; internal const string Attestation = "$(PhiSilicaLafAttestation)"%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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,6 @@ namespace AdvancedPaste.Services.CustomActions
|
||||
AIServiceType.OpenAI or AIServiceType.AzureOpenAI => new OpenAIPromptExecutionSettings
|
||||
{
|
||||
FunctionChoiceBehavior = null,
|
||||
ReasoningEffort = "minimal",
|
||||
},
|
||||
_ => new PromptExecutionSettings(),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<ImportGroup Label="ExtensionSettings">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
67
src/modules/cmdpal/TestDeserialize.cs
Normal file
67
src/modules/cmdpal/TestDeserialize.cs
Normal 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}");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
424
src/modules/cmdpal/doc/js-extensions/extension-authoring-js.md
Normal file
424
src/modules/cmdpal/doc/js-extensions/extension-authoring-js.md
Normal 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.
|
||||
652
src/modules/cmdpal/doc/js-extensions/jsonrpc-protocol.md
Normal file
652
src/modules/cmdpal/doc/js-extensions/jsonrpc-protocol.md
Normal 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 ..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
2
src/modules/cmdpal/ext/SampleJSExtension/dist/index.d.ts
vendored
Normal file
2
src/modules/cmdpal/ext/SampleJSExtension/dist/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import { type ActivationContext, type ICommandProvider } from '@microsoft/cmdpal-sdk';
|
||||
export declare function activate(context: ActivationContext): ICommandProvider;
|
||||
1031
src/modules/cmdpal/ext/SampleJSExtension/dist/index.js
vendored
Normal file
1031
src/modules/cmdpal/ext/SampleJSExtension/dist/index.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
51
src/modules/cmdpal/ext/SampleJSExtension/package-lock.json
generated
Normal file
51
src/modules/cmdpal/ext/SampleJSExtension/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/modules/cmdpal/ext/SampleJSExtension/package.json
Normal file
26
src/modules/cmdpal/ext/SampleJSExtension/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1282
src/modules/cmdpal/ext/SampleJSExtension/src/index.ts
Normal file
1282
src/modules/cmdpal/ext/SampleJSExtension/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
16
src/modules/cmdpal/ext/SampleJSExtension/tsconfig.json
Normal file
16
src/modules/cmdpal/ext/SampleJSExtension/tsconfig.json
Normal 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"]
|
||||
}
|
||||
68
src/modules/cmdpal/setup-dev-extensions.ps1
Normal file
68
src/modules/cmdpal/setup-dev-extensions.ps1
Normal 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)."
|
||||
115
src/modules/cmdpal/ts-sdk/README.md
Normal file
115
src/modules/cmdpal/ts-sdk/README.md
Normal 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 });
|
||||
```
|
||||
19
src/modules/cmdpal/ts-sdk/dist/base/CommandItemBase.d.ts
vendored
Normal file
19
src/modules/cmdpal/ts-sdk/dist/base/CommandItemBase.d.ts
vendored
Normal 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
|
||||
1
src/modules/cmdpal/ts-sdk/dist/base/CommandItemBase.d.ts.map
vendored
Normal file
1
src/modules/cmdpal/ts-sdk/dist/base/CommandItemBase.d.ts.map
vendored
Normal 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"}
|
||||
25
src/modules/cmdpal/ts-sdk/dist/base/CommandItemBase.js
vendored
Normal file
25
src/modules/cmdpal/ts-sdk/dist/base/CommandItemBase.js
vendored
Normal 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
|
||||
1
src/modules/cmdpal/ts-sdk/dist/base/CommandItemBase.js.map
vendored
Normal file
1
src/modules/cmdpal/ts-sdk/dist/base/CommandItemBase.js.map
vendored
Normal 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"}
|
||||
33
src/modules/cmdpal/ts-sdk/dist/base/CommandProviderBase.d.ts
vendored
Normal file
33
src/modules/cmdpal/ts-sdk/dist/base/CommandProviderBase.d.ts
vendored
Normal 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
|
||||
1
src/modules/cmdpal/ts-sdk/dist/base/CommandProviderBase.d.ts.map
vendored
Normal file
1
src/modules/cmdpal/ts-sdk/dist/base/CommandProviderBase.d.ts.map
vendored
Normal 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"}
|
||||
44
src/modules/cmdpal/ts-sdk/dist/base/CommandProviderBase.js
vendored
Normal file
44
src/modules/cmdpal/ts-sdk/dist/base/CommandProviderBase.js
vendored
Normal 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
|
||||
1
src/modules/cmdpal/ts-sdk/dist/base/CommandProviderBase.js.map
vendored
Normal file
1
src/modules/cmdpal/ts-sdk/dist/base/CommandProviderBase.js.map
vendored
Normal 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"}
|
||||
33
src/modules/cmdpal/ts-sdk/dist/base/ContentPageBase.d.ts
vendored
Normal file
33
src/modules/cmdpal/ts-sdk/dist/base/ContentPageBase.d.ts
vendored
Normal 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
|
||||
1
src/modules/cmdpal/ts-sdk/dist/base/ContentPageBase.d.ts.map
vendored
Normal file
1
src/modules/cmdpal/ts-sdk/dist/base/ContentPageBase.d.ts.map
vendored
Normal 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
Reference in New Issue
Block a user